diff --git a/.changeset/config.json b/.changeset/config.json index 8775c1d..28c3697 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -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": [] } diff --git a/.gitignore b/.gitignore index 4b8b27f..5f1a10e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,10 @@ coverage/ # IDE .vscode/ .claude/ + +# Tooling .entire/ +.local/ + +# AI agent instructions +AGENTS.md diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 1cb29cc..55ef3f7 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -1,4 +1,5 @@ { "$schema": "./node_modules/oxfmt/configuration_schema.json", - "useTabs": true + "useTabs": true, + "ignorePatterns": ["CHANGELOG.md"] } diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..49b5943 --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index deebe21..67f7ffb 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/package.json b/package.json index 6400297..7ee4213 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/skills/diffx/SKILL.md b/skills/diffx/SKILL.md index 28c92b6..810b881 100644 --- a/skills/diffx/SKILL.md +++ b/skills/diffx/SKILL.md @@ -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 diff --git a/src/cli/pager.ts b/src/cli/pager.ts index 6f4b5ba..85148be 100644 --- a/src/cli/pager.ts +++ b/src/cli/pager.ts @@ -151,7 +151,9 @@ async function runPager(command: string, output: string): Promise { 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}`)); diff --git a/src/errors/__snapshots__/error-handler.test.ts.snap b/src/errors/__snapshots__/error-handler.test.ts.snap index 5a3fde9..4686bac 100644 --- a/src/errors/__snapshots__/error-handler.test.ts.snap +++ b/src/errors/__snapshots__/error-handler.test.ts.snap @@ -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", -} -`; diff --git a/src/parsers/range-parser.test.ts b/src/parsers/range-parser.test.ts index 2f038c1..5ff777d 100644 --- a/src/parsers/range-parser.test.ts +++ b/src/parsers/range-parser.test.ts @@ -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"); + }); }); }); @@ -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", () => { @@ -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"); + }); }); }); diff --git a/src/parsers/range-parser.ts b/src/parsers/range-parser.ts index d65d5f9..d8254b4 100644 --- a/src/parsers/range-parser.ts +++ b/src/parsers/range-parser.ts @@ -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 { @@ -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) { @@ -30,6 +22,7 @@ export function parseRangeInput(input: string): RefRange { right: "", leftPr: prRange.left, rightPr: prRange.right, + rangeSyntax: undefined, }; } @@ -41,6 +34,7 @@ export function parseRangeInput(input: string): RefRange { right: gitUrlRange.rightRef, leftGitUrl: gitUrlRange.leftUrl, rightGitUrl: gitUrlRange.rightUrl, + rangeSyntax: gitUrlRange.rangeSyntax, }; } @@ -55,6 +49,7 @@ export function parseRangeInput(input: string): RefRange { rightRef: githubCompare.rightRef, rightOwner: githubCompare.rightOwner, rightRepo: githubCompare.rightRepo, + rangeSyntax: undefined, }; } @@ -68,6 +63,7 @@ export function parseRangeInput(input: string): RefRange { prNumber: githubPrChanges.prNumber, leftCommitSha: githubPrChanges.leftCommitSha, rightCommitSha: githubPrChanges.rightCommitSha, + rangeSyntax: undefined, }; } @@ -75,10 +71,11 @@ export function parseRangeInput(input: string): RefRange { if (githubPr) { return { type: "github-url", - left: "", // Will be resolved later + left: "", right: "", ownerRepo: `${githubPr.owner}/${githubPr.repo}`, prNumber: githubPr.prNumber, + rangeSyntax: undefined, }; } @@ -90,6 +87,7 @@ export function parseRangeInput(input: string): RefRange { right: "", ownerRepo: `${githubCommit.owner}/${githubCommit.repo}`, commitSha: githubCommit.commitSha, + rangeSyntax: undefined, }; } @@ -102,6 +100,7 @@ export function parseRangeInput(input: string): RefRange { right: githubRefRange.right, leftGitUrl: gitUrl, rightGitUrl: gitUrl, + rangeSyntax: githubRefRange.rangeSyntax, }; } @@ -114,6 +113,7 @@ export function parseRangeInput(input: string): RefRange { right: gitlabRefRange.right, leftGitUrl: gitUrl, rightGitUrl: gitUrl, + rangeSyntax: gitlabRefRange.rangeSyntax, }; } @@ -125,6 +125,7 @@ export function parseRangeInput(input: string): RefRange { right: "", ownerRepo: `${prRef.owner}/${prRef.repo}`, prNumber: prRef.prNumber, + rangeSyntax: undefined, }; } @@ -136,6 +137,7 @@ export function parseRangeInput(input: string): RefRange { right: "", ownerRepo: `${mrRef.owner}/${mrRef.repo}`, prNumber: mrRef.mrNumber, + rangeSyntax: undefined, }; } @@ -146,6 +148,7 @@ export function parseRangeInput(input: string): RefRange { left: remoteRange.left, right: remoteRange.right, ownerRepo: remoteRange.ownerRepo, + rangeSyntax: remoteRange.rangeSyntax, }; } @@ -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, ); } diff --git a/src/parsers/range/git-url-parser.ts b/src/parsers/range/git-url-parser.ts index 6544a80..f5b7b38 100644 --- a/src/parsers/range/git-url-parser.ts +++ b/src/parsers/range/git-url-parser.ts @@ -3,13 +3,17 @@ export function parseGitUrlRange(input: string): { leftRef: string; rightUrl: string; rightRef: string; + rangeSyntax: "two-dot" | "three-dot"; } | null { const isGitUrl = (s: string) => s.includes("://") || (s.includes("@") && s.includes(":")); - const doubleDotIndex = input.indexOf(".."); - if (doubleDotIndex !== -1) { - const leftPart = input.slice(0, doubleDotIndex); - const rightPart = input.slice(doubleDotIndex + 2); + const separatorMatch = input.match(/\.\.\.|\.\./); + if (separatorMatch && separatorMatch.index !== undefined) { + const sepIdx = separatorMatch.index; + const sepLen = separatorMatch[0].length; + const rangeSyntax = sepLen === 3 ? ("three-dot" as const) : ("two-dot" as const); + const leftPart = input.slice(0, sepIdx); + const rightPart = input.slice(sepIdx + sepLen); const lastAtLeft = leftPart.lastIndexOf("@"); const lastAtRight = rightPart.lastIndexOf("@"); @@ -21,23 +25,41 @@ export function parseGitUrlRange(input: string): { const rightRef = rightPart.slice(lastAtRight + 1); if (isGitUrl(leftUrl) && isGitUrl(rightUrl)) { - return { leftUrl, leftRef, rightUrl, rightRef }; + return { leftUrl, leftRef, rightUrl, rightRef, rangeSyntax }; } } } - const atBeforeDoubleDot = input.lastIndexOf("@", input.indexOf("..")); - if (atBeforeDoubleDot !== -1 && input.includes("..")) { - const url = input.slice(0, atBeforeDoubleDot); - const refPart = input.slice(atBeforeDoubleDot + 1); - const parts = refPart.split(".."); - if (parts.length === 2 && isGitUrl(url)) { - return { - leftUrl: url, - leftRef: parts[0], - rightUrl: url, - rightRef: parts[1], - }; + if (separatorMatch && separatorMatch.index !== undefined) { + const sepIdx = separatorMatch.index; + const atBeforeSep = input.lastIndexOf("@", sepIdx); + if (atBeforeSep !== -1) { + const url = input.slice(0, atBeforeSep); + const refPart = input.slice(atBeforeSep + 1); + const rangeSyntax = + separatorMatch[0].length === 3 ? ("three-dot" as const) : ("two-dot" as const); + + if (rangeSyntax === "three-dot") { + const dotIdx = refPart.indexOf("..."); + if (dotIdx !== -1) { + const leftRef = refPart.slice(0, dotIdx); + const rightRef = refPart.slice(dotIdx + 3); + if (leftRef && rightRef && isGitUrl(url)) { + return { leftUrl: url, leftRef, rightUrl: url, rightRef, rangeSyntax }; + } + } + } else { + const parts = refPart.split(".."); + if (parts.length === 2 && parts[0] && parts[1] && isGitUrl(url)) { + return { + leftUrl: url, + leftRef: parts[0], + rightUrl: url, + rightRef: parts[1], + rangeSyntax, + }; + } + } } } diff --git a/src/parsers/range/github-parser.ts b/src/parsers/range/github-parser.ts index b95a9fb..119b199 100644 --- a/src/parsers/range/github-parser.ts +++ b/src/parsers/range/github-parser.ts @@ -97,15 +97,17 @@ export function parsePRRef(input: string): GitHubPRUrl | null { export function parseGithubRefRange( input: string, -): { ownerRepo: string; left: string; right: string } | null { - const match = /^github:([^/]+)\/([^@]+)@(.+)\.\.(.+)$/i; +): { ownerRepo: string; left: string; right: string; rangeSyntax: "two-dot" | "three-dot" } | null { + const match = /^github:([^/]+)\/([^@]+)@(.+)(\.\.\.?)(.+)$/i; const result = input.match(match); if (!result) return null; const owner = result[1]; const repo = result[2]; const left = result[3].trim(); - const right = result[4].trim(); + const separator = result[4]; + const right = result[5].trim(); + const rangeSyntax = separator.length === 3 ? ("three-dot" as const) : ("two-dot" as const); if (!owner || !repo || !left || !right) { return null; @@ -115,15 +117,21 @@ export function parseGithubRefRange( ownerRepo: `${owner}/${repo}`, left, right, + rangeSyntax, }; } export function parsePRRange(input: string): { left: GitHubPRUrl; right: GitHubPRUrl } | null { if (!input.includes("..")) return null; - const parts = input.split(".."); - if (parts.length !== 2) return null; - const left = parseGitHubPRUrl(parts[0].trim()) ?? parsePRRef(parts[0].trim()); - const right = parseGitHubPRUrl(parts[1].trim()) ?? parsePRRef(parts[1].trim()); + const separatorMatch = input.match(/\.\.\.|\.\./); + if (!separatorMatch || separatorMatch.index === undefined) return null; + + const leftStr = input.slice(0, separatorMatch.index).trim(); + const rightStr = input.slice(separatorMatch.index + separatorMatch[0].length).trim(); + if (!leftStr || !rightStr) return null; + + const left = parseGitHubPRUrl(leftStr) ?? parsePRRef(leftStr); + const right = parseGitHubPRUrl(rightStr) ?? parsePRRef(rightStr); if (!left || !right) return null; return { left, right }; } diff --git a/src/parsers/range/gitlab-parser.ts b/src/parsers/range/gitlab-parser.ts index fba6b20..c5aadc8 100644 --- a/src/parsers/range/gitlab-parser.ts +++ b/src/parsers/range/gitlab-parser.ts @@ -14,15 +14,17 @@ export function parseMRRef(input: string): GitLabMRUrl | null { export function parseGitlabRefRange( input: string, -): { ownerRepo: string; left: string; right: string } | null { - const match = /^gitlab:([^/]+)\/([^@]+)@(.+)\.\.(.+)$/i; +): { ownerRepo: string; left: string; right: string; rangeSyntax: "two-dot" | "three-dot" } | null { + const match = /^gitlab:([^/]+)\/([^@]+)@(.+)(\.\.\.?)(.+)$/i; const result = input.match(match); if (!result) return null; const owner = result[1]; const repo = result[2]; const left = result[3].trim(); - const right = result[4].trim(); + const separator = result[4]; + const right = result[5].trim(); + const rangeSyntax = separator.length === 3 ? ("three-dot" as const) : ("two-dot" as const); if (!owner || !repo || !left || !right) { return null; @@ -32,5 +34,6 @@ export function parseGitlabRefRange( ownerRepo: `${owner}/${repo}`, left, right, + rangeSyntax, }; } diff --git a/src/parsers/range/ref-range-parser.ts b/src/parsers/range/ref-range-parser.ts index f204f69..556bf14 100644 --- a/src/parsers/range/ref-range-parser.ts +++ b/src/parsers/range/ref-range-parser.ts @@ -2,14 +2,19 @@ export function parseRemoteRefRange(input: string): { left: string; right: string; ownerRepo: string; + rangeSyntax: "two-dot" | "three-dot"; } | null { - const separatorIndex = input.indexOf(".."); - if (separatorIndex === -1) { + const separatorMatch = input.match(/\.\.\.|\.\./); + if (!separatorMatch || separatorMatch.index === undefined) { return null; } + const separatorIndex = separatorMatch.index; + const rangeSyntax = + separatorMatch[0].length === 3 ? ("three-dot" as const) : ("two-dot" as const); + const leftPart = input.slice(0, separatorIndex).trim(); - const rightPart = input.slice(separatorIndex + 2).trim(); + const rightPart = input.slice(separatorIndex + separatorMatch[0].length).trim(); if (!leftPart || !rightPart) { return null; @@ -46,6 +51,7 @@ export function parseRemoteRefRange(input: string): { left: `${left.owner}/${left.repo}@${left.ref}`, right: `${rightFull.owner}/${rightFull.repo}@${rightFull.ref}`, ownerRepo: `${left.owner}/${left.repo}`, + rangeSyntax, }; } @@ -53,16 +59,27 @@ export function parseRemoteRefRange(input: string): { left: `${left.owner}/${left.repo}@${left.ref}`, right: `${left.owner}/${left.repo}@${rightPart}`, ownerRepo: `${left.owner}/${left.repo}`, + rangeSyntax, }; } -export function parseLocalRefRange(input: string): { left: string; right: string } | null { - const parts = input.split(".."); - if (parts.length !== 2 || !parts[0] || !parts[1]) { +export function parseLocalRefRange( + input: string, +): { left: string; right: string; rangeSyntax: "two-dot" | "three-dot" } | null { + const separatorMatch = input.match(/\.\.(?:\.?)/); + if (!separatorMatch || separatorMatch.index === undefined) { return null; } - return { - left: parts[0].trim(), - right: parts[1].trim(), - }; + + const separatorIndex = separatorMatch.index; + const rangeSyntax = + separatorMatch[0].length === 3 ? ("three-dot" as const) : ("two-dot" as const); + const left = input.slice(0, separatorIndex).trim(); + const right = input.slice(separatorIndex + separatorMatch[0].length).trim(); + + if (!left || !right) { + return null; + } + + return { left, right, rangeSyntax }; } diff --git a/src/resolvers/git-url-resolver.test.ts b/src/resolvers/git-url-resolver.test.ts index 6f0faf1..df476bb 100644 --- a/src/resolvers/git-url-resolver.test.ts +++ b/src/resolvers/git-url-resolver.test.ts @@ -41,6 +41,7 @@ describe("resolveGitUrlRefs", () => { right: "feature", leftGitUrl: "git@github.com:owner/repo.git", rightGitUrl: "git@github.com:owner/repo.git", + rangeSyntax: undefined, }; const result = await resolveGitUrlRefs(range); @@ -69,6 +70,7 @@ describe("resolveGitUrlRefs", () => { right: "v2.0", leftGitUrl: "https://github.com/owner/repo.git", rightGitUrl: "https://github.com/owner/repo.git", + rangeSyntax: undefined, }; const result = await resolveGitUrlRefs(range); @@ -93,6 +95,7 @@ describe("resolveGitUrlRefs", () => { right: "feature", leftGitUrl: "git@github.com:owner/repo.git", rightGitUrl: "git@gitlab.com:owner/repo.git", + rangeSyntax: undefined, }; const result = await resolveGitUrlRefs(range); @@ -125,6 +128,7 @@ describe("resolveGitUrlRefs", () => { right: "develop", leftGitUrl: "https://github.com/owner/repo.git", rightGitUrl: "git@github.com:owner/repo.git", + rangeSyntax: undefined, }; const _result = await resolveGitUrlRefs(range); @@ -143,6 +147,7 @@ describe("resolveGitUrlRefs", () => { right: "feature", leftGitUrl: "git@github.com:owner/repo.git", rightGitUrl: "git@github.com:owner/repo.git", + rangeSyntax: undefined, }; const result = await resolveGitUrlRefs(range); @@ -163,6 +168,7 @@ describe("resolveGitUrlRefs", () => { right: "feature", leftGitUrl: "git@github.com:owner/repo.git", rightGitUrl: "git@gitlab.com:owner/repo.git", + rangeSyntax: undefined, }; const result = await resolveGitUrlRefs(range); @@ -181,6 +187,7 @@ describe("resolveGitUrlRefs", () => { type: "local-range", left: "main", right: "feature", + rangeSyntax: undefined, }; await expect(resolveGitUrlRefs(range)).rejects.toThrow(DiffxError); @@ -194,6 +201,7 @@ describe("resolveGitUrlRefs", () => { type: "git-url-range", left: "main", right: "feature", + rangeSyntax: undefined, }; await expect(resolveGitUrlRefs(range)).rejects.toThrow(DiffxError); @@ -208,6 +216,7 @@ describe("resolveGitUrlRefs", () => { right: "feature", leftGitUrl: "git@github.com:owner/nonexistent.git", rightGitUrl: "git@github.com:owner/nonexistent.git", + rangeSyntax: undefined, }; try { @@ -230,6 +239,7 @@ describe("resolveGitUrlRefs", () => { right: "feature", leftGitUrl: "git@github.com:owner/repo.git", rightGitUrl: "git@github.com:owner/repo.git", + rangeSyntax: undefined, }; await expect(resolveGitUrlRefs(range)).rejects.toThrow(); @@ -242,6 +252,7 @@ describe("resolveGitUrlRefs", () => { type: "local-range", left: "main", right: "feature", + rangeSyntax: undefined, }; try { @@ -261,6 +272,7 @@ describe("resolveGitUrlRefs", () => { right: "feature", leftGitUrl: "git@github.com:owner/repo.git", rightGitUrl: "git@github.com:owner/repo.git", + rangeSyntax: undefined, }; try { diff --git a/src/resolvers/git-url-resolver.ts b/src/resolvers/git-url-resolver.ts index 46abacf..f0fa501 100644 --- a/src/resolvers/git-url-resolver.ts +++ b/src/resolvers/git-url-resolver.ts @@ -44,7 +44,17 @@ export async function resolveGitUrlRefs(range: RefRange): Promise<{ await gitClient.fetchFromUrl(rightUrl, [`${rightRef}:${rightDestRef}`], 1); } - // Return as temp refs + if (range.rangeSyntax === "three-dot") { + const mergeBase = (await gitClient.mergeBase(leftDestRef, rightDestRef)).trim(); + return { + left: mergeBase, + right: rightDestRef, + cleanup: async () => { + await gitClient.deleteRefs([leftDestRef, rightDestRef]); + }, + }; + } + return { left: leftDestRef, right: rightDestRef, diff --git a/src/resolvers/gitlab-mr-resolver.test.ts b/src/resolvers/gitlab-mr-resolver.test.ts index 40a1416..2816bff 100644 --- a/src/resolvers/gitlab-mr-resolver.test.ts +++ b/src/resolvers/gitlab-mr-resolver.test.ts @@ -26,6 +26,7 @@ describe("GitLab MR resolver", () => { it("should resolve GitLab MR refs", async () => { const range = { type: "gitlab-mr-ref" as const, + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", @@ -55,6 +56,7 @@ describe("GitLab MR resolver", () => { it("should cleanup refs on cleanup call", async () => { const range = { type: "gitlab-mr-ref" as const, + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", @@ -79,6 +81,7 @@ describe("GitLab MR resolver", () => { it("should throw error for invalid owner/repo", async () => { const range = { type: "gitlab-mr-ref" as const, + rangeSyntax: undefined, left: "", right: "", ownerRepo: "invalid", @@ -91,6 +94,7 @@ describe("GitLab MR resolver", () => { it("should throw error for missing MR number", async () => { const range = { type: "gitlab-mr-ref" as const, + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", @@ -103,6 +107,7 @@ describe("GitLab MR resolver", () => { it("should throw error when fetch fails", async () => { const range = { type: "gitlab-mr-ref" as const, + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", diff --git a/src/resolvers/local-ref-resolver.test.ts b/src/resolvers/local-ref-resolver.test.ts index f72a72d..e0ba6bd 100644 --- a/src/resolvers/local-ref-resolver.test.ts +++ b/src/resolvers/local-ref-resolver.test.ts @@ -13,6 +13,7 @@ import { DiffxError, ExitCode } from "../types"; vi.mock("../git/git-client", () => ({ gitClient: { refExistsAny: vi.fn(), + mergeBase: vi.fn(), }, })); @@ -22,6 +23,7 @@ vi.mock("../git/utils", () => ({ describe("resolveLocalRefs", () => { const mockRefExistsAny = mockedFn(gitClient.refExistsAny); + const mockMergeBase = mockedFn(gitClient.mergeBase); beforeEach(() => { vi.clearAllMocks(); @@ -37,6 +39,7 @@ describe("resolveLocalRefs", () => { type: "local-range", left: "main", right: "feature", + rangeSyntax: undefined, }; const result = await resolveLocalRefs(range); @@ -56,6 +59,7 @@ describe("resolveLocalRefs", () => { type: "local-range", left: "refs/heads/main", right: "refs/tags/v1.0", + rangeSyntax: undefined, }; const result = await resolveLocalRefs(range); @@ -73,6 +77,7 @@ describe("resolveLocalRefs", () => { type: "local-range", left: "abc123", right: "def456", + rangeSyntax: undefined, }; const result = await resolveLocalRefs(range); @@ -82,6 +87,45 @@ describe("resolveLocalRefs", () => { right: "def456", }); }); + + it("should compute merge-base for triple-dot range", async () => { + mockRefExistsAny.mockResolvedValue(true); + mockMergeBase.mockResolvedValue("mergebase456\n"); + + const range: RefRange = { + type: "local-range", + left: "main", + right: "feature", + rangeSyntax: "three-dot", + }; + + const result = await resolveLocalRefs(range); + + expect(mockMergeBase).toHaveBeenCalledWith("main", "feature"); + expect(result).toEqual({ + left: "mergebase456", + right: "feature", + }); + }); + + it("should not compute merge-base for double-dot range", async () => { + mockRefExistsAny.mockResolvedValue(true); + + const range: RefRange = { + type: "local-range", + left: "main", + right: "feature", + rangeSyntax: "two-dot", + }; + + const result = await resolveLocalRefs(range); + + expect(mockMergeBase).not.toHaveBeenCalled(); + expect(result).toEqual({ + left: "main", + right: "feature", + }); + }); }); describe("error cases", () => { @@ -91,6 +135,7 @@ describe("resolveLocalRefs", () => { left: "main", right: "feature", ownerRepo: "owner/repo", + rangeSyntax: undefined, }; await expect(resolveLocalRefs(range)).rejects.toThrow(DiffxError); @@ -106,6 +151,7 @@ describe("resolveLocalRefs", () => { type: "local-range", left: "nonexistent", right: "feature", + rangeSyntax: undefined, }; await expect(resolveLocalRefs(range)).rejects.toThrow(DiffxError); @@ -121,6 +167,7 @@ describe("resolveLocalRefs", () => { type: "local-range", left: "main", right: "nonexistent", + rangeSyntax: undefined, }; await expect(resolveLocalRefs(range)).rejects.toThrow(DiffxError); @@ -136,6 +183,7 @@ describe("resolveLocalRefs", () => { type: "local-range", left: "nonexistent1", right: "nonexistent2", + rangeSyntax: undefined, }; await expect(resolveLocalRefs(range)).rejects.toThrow(DiffxError); @@ -152,6 +200,7 @@ describe("resolveLocalRefs", () => { left: "main", right: "feature", ownerRepo: "owner/repo", + rangeSyntax: undefined, }; try { @@ -170,6 +219,7 @@ describe("resolveLocalRefs", () => { type: "local-range", left: "missing", right: "feature", + rangeSyntax: undefined, }; try { diff --git a/src/resolvers/local-ref-resolver.ts b/src/resolvers/local-ref-resolver.ts index 9d1cee9..33a498f 100644 --- a/src/resolvers/local-ref-resolver.ts +++ b/src/resolvers/local-ref-resolver.ts @@ -22,7 +22,6 @@ export async function resolveLocalRefs(range: RefRange): Promise<{ const left = normalizeRef(range.left); const right = normalizeRef(range.right); - // Validate that both refs exist (branches, tags, commits, and rev expressions) const leftExists = await gitClient.refExistsAny(left); const rightExists = await gitClient.refExistsAny(right); @@ -33,5 +32,10 @@ export async function resolveLocalRefs(range: RefRange): Promise<{ throw new DiffxError(`Right ref does not exist: ${range.right}`, ExitCode.INVALID_INPUT); } + if (range.rangeSyntax === "three-dot") { + const mergeBase = (await gitClient.mergeBase(left, right)).trim(); + return { left: mergeBase, right }; + } + return { left, right }; } diff --git a/src/resolvers/pr-url-resolver.test.ts b/src/resolvers/pr-url-resolver.test.ts index 6dfb482..946395f 100644 --- a/src/resolvers/pr-url-resolver.test.ts +++ b/src/resolvers/pr-url-resolver.test.ts @@ -46,6 +46,7 @@ describe("resolvePRRefs", () => { const range: RefRange = { type: "pr-ref", + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", @@ -75,6 +76,7 @@ describe("resolvePRRefs", () => { const range: RefRange = { type: "pr-ref", + rangeSyntax: undefined, left: "", right: "", ownerRepo: "octocat/Hello-World", @@ -100,6 +102,7 @@ describe("resolvePRRefs", () => { const range: RefRange = { type: "pr-ref", + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", @@ -120,6 +123,7 @@ describe("resolvePRRefs", () => { it("should throw when ownerRepo is missing", async () => { const range: RefRange = { type: "pr-ref", + rangeSyntax: undefined, left: "", right: "", prNumber: 123, @@ -132,6 +136,7 @@ describe("resolvePRRefs", () => { it("should throw when prNumber is missing", async () => { const range: RefRange = { type: "pr-ref", + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", @@ -145,6 +150,7 @@ describe("resolvePRRefs", () => { const range: RefRange = { type: "pr-ref", + rangeSyntax: undefined, left: "", right: "", ownerRepo: "invalid", @@ -160,6 +166,7 @@ describe("resolvePRRefs", () => { const range: RefRange = { type: "pr-ref", + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", @@ -190,6 +197,7 @@ describe("resolvePRRangeRefs", () => { const range: RefRange = { type: "pr-range", + rangeSyntax: undefined, left: "", right: "", leftPr: { owner: "owner", repo: "repo", prNumber: 123 }, @@ -213,6 +221,7 @@ describe("resolvePRRangeRefs", () => { const range: RefRange = { type: "pr-range", + rangeSyntax: undefined, left: "", right: "", leftPr: { owner: "owner", repo: "repo", prNumber: 1 }, @@ -231,6 +240,7 @@ describe("resolvePRRangeRefs", () => { it("should throw when leftPr is missing", async () => { const range: RefRange = { type: "pr-range", + rangeSyntax: undefined, left: "", right: "", rightPr: { owner: "owner", repo: "repo", prNumber: 123 }, @@ -242,6 +252,7 @@ describe("resolvePRRangeRefs", () => { it("should throw when rightPr is missing", async () => { const range: RefRange = { type: "pr-range", + rangeSyntax: undefined, left: "", right: "", leftPr: { owner: "owner", repo: "repo", prNumber: 123 }, @@ -255,6 +266,7 @@ describe("resolvePRRangeRefs", () => { const range: RefRange = { type: "pr-range", + rangeSyntax: undefined, left: "", right: "", leftPr: { owner: "owner", repo: "repo", prNumber: 1 }, @@ -284,6 +296,7 @@ describe("resolveGitHubCommitRefs", () => { const range: RefRange = { type: "github-commit-url", + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", @@ -310,6 +323,7 @@ describe("resolveGitHubCommitRefs", () => { const range: RefRange = { type: "github-commit-url", + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", @@ -326,6 +340,7 @@ describe("resolveGitHubCommitRefs", () => { it("should throw when ownerRepo is missing", async () => { const range: RefRange = { type: "github-commit-url", + rangeSyntax: undefined, left: "", right: "", commitSha: "abc123", @@ -337,6 +352,7 @@ describe("resolveGitHubCommitRefs", () => { it("should throw when commitSha is missing", async () => { const range: RefRange = { type: "github-commit-url", + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", @@ -360,6 +376,7 @@ describe("resolveGitHubPRChangesRefs", () => { const range: RefRange = { type: "github-pr-changes-url", + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", @@ -391,6 +408,7 @@ describe("resolveGitHubPRChangesRefs", () => { const range: RefRange = { type: "github-pr-changes-url", + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", @@ -413,6 +431,7 @@ describe("resolveGitHubPRChangesRefs", () => { it("should throw when required fields are missing", async () => { const range: RefRange = { type: "github-pr-changes-url", + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", @@ -441,6 +460,7 @@ describe("resolveGitHubCompareRefs", () => { const range: RefRange = { type: "github-compare-url", + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", @@ -464,6 +484,7 @@ describe("resolveGitHubCompareRefs", () => { const range: RefRange = { type: "github-compare-url", + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", @@ -484,6 +505,7 @@ describe("resolveGitHubCompareRefs", () => { const range: RefRange = { type: "github-compare-url", + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", @@ -511,6 +533,7 @@ describe("resolveGitHubCompareRefs", () => { const range: RefRange = { type: "github-compare-url", + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", @@ -539,6 +562,7 @@ describe("resolveGitHubCompareRefs", () => { const range: RefRange = { type: "github-compare-url", + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", @@ -566,6 +590,7 @@ describe("resolveGitHubCompareRefs", () => { const range: RefRange = { type: "github-compare-url", + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", @@ -589,6 +614,7 @@ describe("resolveGitHubCompareRefs", () => { const range: RefRange = { type: "github-compare-url", + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", diff --git a/src/resolvers/ref-resolver.test.ts b/src/resolvers/ref-resolver.test.ts index 85793a9..9cbcfdb 100644 --- a/src/resolvers/ref-resolver.test.ts +++ b/src/resolvers/ref-resolver.test.ts @@ -105,6 +105,7 @@ describe("resolveRefs", () => { it("should route local-range to resolveLocalRefs", async () => { const range: RefRange = { type: "local-range", + rangeSyntax: undefined, left: "main", right: "feature", }; @@ -118,6 +119,7 @@ describe("resolveRefs", () => { it("should route remote-range to resolveRemoteRefs", async () => { const range: RefRange = { type: "remote-range", + rangeSyntax: undefined, left: "owner/repo@main", right: "owner/repo@feature", ownerRepo: "owner/repo", @@ -132,6 +134,7 @@ describe("resolveRefs", () => { it("should route pr-ref to resolvePRRefs", async () => { const range: RefRange = { type: "pr-ref", + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", @@ -147,6 +150,7 @@ describe("resolveRefs", () => { it("should route github-url to resolvePRRefs", async () => { const range: RefRange = { type: "github-url", + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", @@ -161,6 +165,7 @@ describe("resolveRefs", () => { it("should route pr-range to resolvePRRangeRefs", async () => { const range: RefRange = { type: "pr-range", + rangeSyntax: undefined, left: "", right: "", leftPr: { owner: "owner", repo: "repo", prNumber: 123 }, @@ -176,6 +181,7 @@ describe("resolveRefs", () => { it("should route git-url-range to resolveGitUrlRefs", async () => { const range: RefRange = { type: "git-url-range", + rangeSyntax: undefined, left: "main", right: "feature", leftGitUrl: "git@github.com:owner/repo.git", @@ -191,6 +197,7 @@ describe("resolveRefs", () => { it("should route github-commit-url to resolveGitHubCommitRefs", async () => { const range: RefRange = { type: "github-commit-url", + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", @@ -206,6 +213,7 @@ describe("resolveRefs", () => { it("should route github-pr-changes-url to resolveGitHubPRChangesRefs", async () => { const range: RefRange = { type: "github-pr-changes-url", + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", @@ -223,6 +231,7 @@ describe("resolveRefs", () => { it("should route github-compare-url to resolveGitHubCompareRefs", async () => { const range: RefRange = { type: "github-compare-url", + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", @@ -239,6 +248,7 @@ describe("resolveRefs", () => { it("should route gitlab-mr-ref to resolveGitLabMRRefs", async () => { const range: RefRange = { type: "gitlab-mr-ref", + rangeSyntax: undefined, left: "", right: "", ownerRepo: "owner/repo", @@ -270,6 +280,7 @@ describe("resolveRefs", () => { for (const type of refTypes) { const range: RefRange = { type, + rangeSyntax: undefined, left: "main", right: "feature", }; @@ -287,6 +298,7 @@ describe("resolveRefs", () => { const range: RefRange = { type: "local-range", + rangeSyntax: undefined, left: "main", right: "feature", }; @@ -299,6 +311,7 @@ describe("resolveRefs", () => { const range: RefRange = { type: "remote-range", + rangeSyntax: undefined, left: "owner/repo@main", right: "owner/repo@feature", ownerRepo: "owner/repo", @@ -319,6 +332,7 @@ describe("resolveRefs", () => { const range: RefRange = { type: "remote-range", + rangeSyntax: undefined, left: "owner/repo@main", right: "owner/repo@feature", ownerRepo: "owner/repo", @@ -337,6 +351,7 @@ describe("resolveRefs", () => { it("should not return cleanup for local refs", async () => { const range: RefRange = { type: "local-range", + rangeSyntax: undefined, left: "main", right: "feature", }; diff --git a/src/resolvers/remote-ref-resolver.test.ts b/src/resolvers/remote-ref-resolver.test.ts index 06c76e0..086ecda 100644 --- a/src/resolvers/remote-ref-resolver.test.ts +++ b/src/resolvers/remote-ref-resolver.test.ts @@ -41,6 +41,7 @@ describe("resolveRemoteRefs", () => { left: "owner/repo@main", right: "owner/repo@feature", ownerRepo: "owner/repo", + rangeSyntax: undefined, }; const result = await resolveRemoteRefs(range); @@ -66,6 +67,7 @@ describe("resolveRemoteRefs", () => { left: "owner/repo@v1.0.0", right: "owner/repo@v2.0.0", ownerRepo: "owner/repo", + rangeSyntax: undefined, }; const result = await resolveRemoteRefs(range); @@ -88,6 +90,7 @@ describe("resolveRemoteRefs", () => { left: "owner/repo@release/v1", right: "owner/repo@release/v2", ownerRepo: "owner/repo", + rangeSyntax: undefined, }; const result = await resolveRemoteRefs(range); @@ -112,6 +115,7 @@ describe("resolveRemoteRefs", () => { left: "owner/repo@main", right: "owner/repo@feature", ownerRepo: "owner/repo", + rangeSyntax: undefined, }; const result = await resolveRemoteRefs(range); @@ -131,6 +135,7 @@ describe("resolveRemoteRefs", () => { type: "local-range", left: "main", right: "feature", + rangeSyntax: undefined, }; await expect(resolveRemoteRefs(range)).rejects.toThrow(DiffxError); @@ -144,6 +149,7 @@ describe("resolveRemoteRefs", () => { type: "remote-range", left: "owner/repo@main", right: "owner/repo@feature", + rangeSyntax: undefined, }; await expect(resolveRemoteRefs(range)).rejects.toThrow(DiffxError); @@ -155,6 +161,7 @@ describe("resolveRemoteRefs", () => { left: "/repo@main", right: "/repo@feature", ownerRepo: "/repo", + rangeSyntax: undefined, }; await expect(resolveRemoteRefs(range)).rejects.toThrow(DiffxError); @@ -167,6 +174,7 @@ describe("resolveRemoteRefs", () => { left: "owner/@main", right: "owner/@feature", ownerRepo: "owner/", + rangeSyntax: undefined, }; await expect(resolveRemoteRefs(range)).rejects.toThrow(DiffxError); @@ -181,6 +189,7 @@ describe("resolveRemoteRefs", () => { left: "owner/repo/main", // missing @ right: "owner/repo@feature", ownerRepo: "owner/repo", + rangeSyntax: undefined, }; await expect(resolveRemoteRefs(range)).rejects.toThrow(DiffxError); @@ -195,6 +204,7 @@ describe("resolveRemoteRefs", () => { left: "main@feature", // wrong format right: "owner/repo@feature", ownerRepo: "owner/repo", + rangeSyntax: undefined, }; await expect(resolveRemoteRefs(range)).rejects.toThrow(DiffxError); @@ -209,6 +219,7 @@ describe("resolveRemoteRefs", () => { left: "owner/repo@main", right: "owner/repo@feature", ownerRepo: "owner/repo", + rangeSyntax: undefined, }; try { @@ -229,6 +240,7 @@ describe("resolveRemoteRefs", () => { left: "owner/repo@main", right: "owner/repo@feature", ownerRepo: "owner/repo", + rangeSyntax: undefined, }; try { @@ -246,6 +258,7 @@ describe("resolveRemoteRefs", () => { type: "local-range", left: "main", right: "feature", + rangeSyntax: undefined, }; try { @@ -262,6 +275,7 @@ describe("resolveRemoteRefs", () => { left: "owner/repo@main", right: "owner/repo@feature", ownerRepo: "invalid", + rangeSyntax: undefined, }; try { @@ -280,6 +294,7 @@ describe("resolveRemoteRefs", () => { left: "owner/repo@main", right: "owner/repo@feature", ownerRepo: "owner/repo", + rangeSyntax: undefined, }; try { diff --git a/src/resolvers/remote-ref-resolver.ts b/src/resolvers/remote-ref-resolver.ts index ec01da2..1c675be 100644 --- a/src/resolvers/remote-ref-resolver.ts +++ b/src/resolvers/remote-ref-resolver.ts @@ -42,14 +42,23 @@ export async function resolveRemoteRefs(range: RefRange): Promise<{ const rightDestRef = `${tempPrefix}/right`; try { - // Fetch the refs (shallow fetch) without creating a remote await gitClient.fetchFromUrl( remoteUrl, [`${leftRemoteRef}:${leftDestRef}`, `${rightRemoteRef}:${rightDestRef}`], 1, ); - // Return as temp refs + if (range.rangeSyntax === "three-dot") { + const mergeBase = (await gitClient.mergeBase(leftDestRef, rightDestRef)).trim(); + return { + left: mergeBase, + right: rightDestRef, + cleanup: async () => { + await gitClient.deleteRefs([leftDestRef, rightDestRef]); + }, + }; + } + return { left: leftDestRef, right: rightDestRef, diff --git a/src/types.ts b/src/types.ts index e8671f1..fd5d729 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,11 +29,15 @@ export type RefType = | "github-compare-url" | "gitlab-mr-ref"; +export type RangeSyntax = "two-dot" | "three-dot"; + /** Parsed reference range */ export interface RefRange { type: RefType; left: string; right: string; + /** The range separator syntax used in the input */ + rangeSyntax: RangeSyntax | undefined; /** Owner/repo for remote refs (e.g., "octocat/Hello-World") */ ownerRepo?: string; /** PR/MR number for PR/MR refs */