Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions .changeset/config.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json",
"changelog": ["@changesets/changelog-github", { "repo": "jaydenfyi/diffx" }],
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
"$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json",
"changelog": ["@changesets/changelog-github", { "repo": "jaydenfyi/diffx" }],
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,10 @@ coverage/
# IDE
.vscode/
.claude/

# Tooling
.entire/
.local/

# AI agent instructions
AGENTS.md
3 changes: 2 additions & 1 deletion .oxfmtrc.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"useTabs": true
"useTabs": true,
"ignorePatterns": ["CHANGELOG.md"]
}
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# @jaydenfyi/diffx

## 0.0.2

### Patch Changes

- 1d37359: Add three-dot (A...B) range syntax support with merge-base semantics
- Parser detects separator and sets rangeSyntax ("two-dot" | "three-dot")
- Resolvers compute merge-base when rangeSyntax is "three-dot"
- Supported across local, remote, git-url, github, and gitlab ranges
21 changes: 14 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,13 @@ diffx --name-status

## `diffx` vs `git diff`

| Capability | `diffx` | `git diff` |
| ---------- | ------- | ---------- |
| Full working tree snapshot (tracked + untracked) | ✅ | ❌ |
| Direct GitHub PR and GitLab MR diffing | ✅ | ❌ |
| Cross-remote and fork comparisons | ✅ | ❌ |
| Include/exclude glob filtering | ✅ | ❌ |
| `git diff` compatibility | ✅ | ✅ |
| Capability | `diffx` | `git diff` |
| ------------------------------------------------ | ------- | ---------- |
| Full working tree snapshot (tracked + untracked) | ✅ | ❌ |
| Direct GitHub PR and GitLab MR diffing | ✅ | ❌ |
| Cross-remote and fork comparisons | ✅ | ❌ |
| Include/exclude glob filtering | ✅ | ❌ |
| `git diff` compatibility | ✅ | ✅ |

## Command

Expand Down Expand Up @@ -124,6 +124,13 @@ diffx gitlab:owner/repo@main..feature
diffx gitlab:owner/repo!123
```

### Two-dot vs three-dot

All range formats support both `..` and `...` separators:

- `A..B` compares the two tips directly (same as `git diff A B`)
- `A...B` compares from the merge-base of A and B to B (same as `git diff A...B`)

## Output Modes

`diffx` defaults to `diff` mode.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@jaydenfyi/diffx",
"version": "0.0.1",
"version": "0.0.2",
"description": "A CLI tool for generating filtered Git diffs/patches with GitHub PR support",
"keywords": [
"cli",
Expand Down
4 changes: 4 additions & 0 deletions skills/diffx/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,26 @@ Use this skill to translate a diffing request into the right `diffx` command.
## Decision Flow

1. Classify the target.

- Current repo changes only -> load `references/worktree.md`.
- Two refs/branches/tags/SHAs -> load `references/local-and-remote-ranges.md`.
- GitHub PR/commit/compare URL or `github:owner/repo#123` -> load `references/github-and-gitlab.md`.
- GitLab MR (`gitlab:owner/repo!123`) -> load `references/github-and-gitlab.md`.

2. Choose output shape.

- Full code review output -> `diff` (default).
- Apply-ready output -> `--mode patch`.
- Summary output -> `--stat`, `--numstat`, `--shortstat`, `--name-only`, `--name-status`, or `--summary`.
- Custom table output -> `--overview` only.

3. Apply optional narrowing.

- File globs -> load `references/filters-and-pass-through.md`.
- Native git diff flags/pathspec -> load `references/filters-and-pass-through.md`.

4. Handle failures/conflicts.

- Unexpected empty output, invalid input, or flag conflicts -> load `references/troubleshooting.md`.

## Defaults
Expand Down
4 changes: 3 additions & 1 deletion src/cli/pager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,9 @@ async function runPager(command: string, output: string): Promise<void> {
env: { ...process.env, ...env },
});

child.on("error", (error) => rejectOnce(error instanceof Error ? error : new Error(String(error))));
child.on("error", (error) =>
rejectOnce(error instanceof Error ? error : new Error(String(error))),
);
child.on("exit", (code) => {
if (code && code !== 0) {
rejectOnce(new Error(`Pager exited with code ${code}`));
Expand Down
8 changes: 0 additions & 8 deletions src/errors/__snapshots__/error-handler.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,3 @@ exports[`createNoFilesMatchedError > maintains stable error message for user exp
"message": "No files matched the specified filters",
}
`;

exports[`createNoFilesMatchedError maintains stable error message for user expectations 1`] = `
{
"exitCode": 1,
"exitCodeValue": 1,
"message": "No files matched the specified filters",
}
`;
39 changes: 39 additions & 0 deletions src/parsers/range-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,15 @@ describe("parseGitUrlRange", () => {
expect(result.right).toBe("owner/repo@release/2.0");
expect(result.ownerRepo).toBe("owner/repo");
});

it("should parse triple-dot remote range", () => {
const result = parseRangeInput("owner/repo@v1.0...v2.0");
expect(result.type).toBe("remote-range");
expect(result.left).toBe("owner/repo@v1.0");
expect(result.right).toBe("owner/repo@v2.0");
expect(result.ownerRepo).toBe("owner/repo");
expect(result.rangeSyntax).toBe("three-dot");
});
});
});

Expand All @@ -199,6 +208,7 @@ describe("local ref ranges", () => {
expect(result.type).toBe("local-range");
expect(result.left).toBe("main");
expect(result.right).toBe("feature");
expect(result.rangeSyntax).toBe("two-dot");
});

it("should parse refs with slashes", () => {
Expand Down Expand Up @@ -228,6 +238,35 @@ describe("local ref ranges", () => {
expect(result.left).toBe("refs/heads/main");
expect(result.right).toBe("refs/tags/v1.0");
});

it("should parse triple-dot range (main...HEAD)", () => {
const result = parseRangeInput("main...HEAD");
expect(result.type).toBe("local-range");
expect(result.left).toBe("main");
expect(result.right).toBe("HEAD");
expect(result.rangeSyntax).toBe("three-dot");
});

it("should parse triple-dot range with feature branches", () => {
const result = parseRangeInput("main...feature/auth");
expect(result.type).toBe("local-range");
expect(result.left).toBe("main");
expect(result.right).toBe("feature/auth");
});

it("should parse triple-dot range with tags", () => {
const result = parseRangeInput("v1.0...v2.0");
expect(result.type).toBe("local-range");
expect(result.left).toBe("v1.0");
expect(result.right).toBe("v2.0");
});

it("should parse triple-dot range with SHAs", () => {
const result = parseRangeInput("abc123...def456");
expect(result.type).toBe("local-range");
expect(result.left).toBe("abc123");
expect(result.right).toBe("def456");
});
});
});

Expand Down
24 changes: 14 additions & 10 deletions src/parsers/range-parser.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
/**
* Range input parser for diffx
* Parses various input formats into a RefRange
*/

import type { RefRange } from "../types";
import { DiffxError, ExitCode } from "../types";
import {
Expand All @@ -18,9 +13,6 @@ import { parseGitUrlRange } from "./range/git-url-parser";
import { parseGitlabRefRange, parseMRRef } from "./range/gitlab-parser";
import { parseLocalRefRange, parseRemoteRefRange } from "./range/ref-range-parser";

/**
* Parse a range input string into a RefRange
*/
export function parseRangeInput(input: string): RefRange {
const prRange = parsePRRange(input);
if (prRange) {
Expand All @@ -30,6 +22,7 @@ export function parseRangeInput(input: string): RefRange {
right: "",
leftPr: prRange.left,
rightPr: prRange.right,
rangeSyntax: undefined,
};
}

Expand All @@ -41,6 +34,7 @@ export function parseRangeInput(input: string): RefRange {
right: gitUrlRange.rightRef,
leftGitUrl: gitUrlRange.leftUrl,
rightGitUrl: gitUrlRange.rightUrl,
rangeSyntax: gitUrlRange.rangeSyntax,
};
}

Expand All @@ -55,6 +49,7 @@ export function parseRangeInput(input: string): RefRange {
rightRef: githubCompare.rightRef,
rightOwner: githubCompare.rightOwner,
rightRepo: githubCompare.rightRepo,
rangeSyntax: undefined,
};
}

Expand All @@ -68,17 +63,19 @@ export function parseRangeInput(input: string): RefRange {
prNumber: githubPrChanges.prNumber,
leftCommitSha: githubPrChanges.leftCommitSha,
rightCommitSha: githubPrChanges.rightCommitSha,
rangeSyntax: undefined,
};
}

const githubPr = parseGitHubPRUrl(input);
if (githubPr) {
return {
type: "github-url",
left: "", // Will be resolved later
left: "",
right: "",
ownerRepo: `${githubPr.owner}/${githubPr.repo}`,
prNumber: githubPr.prNumber,
rangeSyntax: undefined,
};
}

Expand All @@ -90,6 +87,7 @@ export function parseRangeInput(input: string): RefRange {
right: "",
ownerRepo: `${githubCommit.owner}/${githubCommit.repo}`,
commitSha: githubCommit.commitSha,
rangeSyntax: undefined,
};
}

Expand All @@ -102,6 +100,7 @@ export function parseRangeInput(input: string): RefRange {
right: githubRefRange.right,
leftGitUrl: gitUrl,
rightGitUrl: gitUrl,
rangeSyntax: githubRefRange.rangeSyntax,
};
}

Expand All @@ -114,6 +113,7 @@ export function parseRangeInput(input: string): RefRange {
right: gitlabRefRange.right,
leftGitUrl: gitUrl,
rightGitUrl: gitUrl,
rangeSyntax: gitlabRefRange.rangeSyntax,
};
}

Expand All @@ -125,6 +125,7 @@ export function parseRangeInput(input: string): RefRange {
right: "",
ownerRepo: `${prRef.owner}/${prRef.repo}`,
prNumber: prRef.prNumber,
rangeSyntax: undefined,
};
}

Expand All @@ -136,6 +137,7 @@ export function parseRangeInput(input: string): RefRange {
right: "",
ownerRepo: `${mrRef.owner}/${mrRef.repo}`,
prNumber: mrRef.mrNumber,
rangeSyntax: undefined,
};
}

Expand All @@ -146,6 +148,7 @@ export function parseRangeInput(input: string): RefRange {
left: remoteRange.left,
right: remoteRange.right,
ownerRepo: remoteRange.ownerRepo,
rangeSyntax: remoteRange.rangeSyntax,
};
}

Expand All @@ -155,11 +158,12 @@ export function parseRangeInput(input: string): RefRange {
type: "local-range",
left: localRange.left,
right: localRange.right,
rangeSyntax: localRange.rangeSyntax,
};
}

throw new DiffxError(
`Invalid range or URL: ${input}\n\nSupported formats:\n - Local refs: main..feature, abc123..def456\n - Remote refs: owner/repo@main..owner/repo@feature\n - Git URL: git@github.com:owner/repo.git@main..feature\n - Git URL (HTTPS): https://github.com/owner/repo.git@main..feature\n - GitHub refs: github:owner/repo@main..feature\n - GitHub PR ref: github:owner/repo#123\n - GitHub PR range: github:owner/repo#123..github:owner/repo#456\n - GitHub PR URL: https://github.com/owner/repo/pull/123\n - PR URL range: https://github.com/owner/repo/pull/123..https://github.com/owner/repo/pull/456\n - GitHub commit URL: https://github.com/owner/repo/commit/abc123\n - GitHub PR changes URL: https://github.com/owner/repo/pull/123/changes/abc123..def456\n - GitHub compare URL: https://github.com/owner/repo/compare/main...feature\n - Cross-fork compare: https://github.com/owner/repo/compare/main...other:repo:feature\n - GitLab refs: gitlab:owner/repo@main..feature\n - GitLab MR ref: gitlab:owner/repo!123`,
`Invalid range or URL: ${input}\n\nSupported formats:\n - Local refs: main..feature, main...feature, abc123..def456\n - Remote refs: owner/repo@main..owner/repo@feature\n - Git URL: git@github.com:owner/repo.git@main..feature\n - Git URL (HTTPS): https://github.com/owner/repo.git@main..feature\n - GitHub refs: github:owner/repo@main..feature\n - GitHub PR ref: github:owner/repo#123\n - GitHub PR range: github:owner/repo#123..github:owner/repo#456\n - GitHub PR URL: https://github.com/owner/repo/pull/123\n - PR URL range: https://github.com/owner/repo/pull/123..https://github.com/owner/repo/pull/456\n - GitHub commit URL: https://github.com/owner/repo/commit/abc123\n - GitHub PR changes URL: https://github.com/owner/repo/pull/123/changes/abc123..def456\n - GitHub compare URL: https://github.com/owner/repo/compare/main...feature\n - Cross-fork compare: https://github.com/owner/repo/compare/main...other:repo:feature\n - GitLab refs: gitlab:owner/repo@main..feature\n - GitLab MR ref: gitlab:owner/repo!123`,
ExitCode.INVALID_INPUT,
);
}
Loading
Loading