From 63aa737ef7d7cadcb32cf2514fb378d0f179adc2 Mon Sep 17 00:00:00 2001 From: Alexander Johansson Date: Fri, 26 Jun 2026 09:58:20 +0200 Subject: [PATCH 1/6] Add `codiff base` to review against a branch's PR/MR base. Adds a convenience command that diffs the current branch against the branch its open pull/merge request targets, which is handy for stacked changes where the base is not `main`. It resolves the base through `gh` (GitHub) or `glab` (GitLab), chosen from the repository's remote, refreshes that branch, and reuses the existing branch review path. Falls back to the remote's default branch when there is no request or the CLI is unavailable. Based on the `cdf` shell helper from https://x.com/alexdotjs/status/2070398507513495610. Co-authored-by: Cursor --- README.md | 12 +++ bin/arguments.js | 22 ++++ bin/codiff-app | 66 ++++++++++++ bin/codiff.js | 14 ++- core/__tests__/codiff-cli.test.ts | 173 ++++++++++++++++++++++++++++++ electron/review-source.cjs | 131 +++++++++++++++++++++- 6 files changed, 415 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8bc2d20..5908cb4 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,18 @@ Review the current branch against a target branch: codiff main ``` +Review the current branch against the branch its pull request or merge request +targets, which is handy for stacked changes where the base is not `main`: + +```bash +codiff base +``` + +Codiff asks GitHub through `gh` or GitLab through `glab` (chosen from the +repository's remote) for the current branch's PR/MR base branch, refreshes that +branch from the remote, and diffs against it. When there is no open request, or +the relevant CLI is unavailable, it falls back to the remote's default branch. + Review a GitHub pull request or GitLab merge request using the current repository remote: ```bash diff --git a/bin/arguments.js b/bin/arguments.js index 475b738..9866fcf 100644 --- a/bin/arguments.js +++ b/bin/arguments.js @@ -91,6 +91,10 @@ export const usageExamples = [ { command: 'codiff', description: 'Review staged and unstaged changes.' }, { command: 'codiff /path/to/repo', description: 'Review changes in a specific repository.' }, { command: 'codiff main', description: 'Review the current branch against main.' }, + { + command: 'codiff base', + description: "Review against the current branch's PR/MR base branch.", + }, { command: 'codiff a1b2c3d', description: 'Review a specific commit.' }, { command: "codiff '#75'", description: 'Review pull request #75.' }, { command: 'codiff pr 75', description: 'Review pull request #75 (alternate syntax).' }, @@ -216,6 +220,8 @@ const getReviewProviderMarker = (arg) => ? 'gitlab' : null; +const isBaseReviewMarker = (arg) => /^base$/i.test(arg); + const isPullRequestUrlArgument = (arg) => parseReviewUrl(arg) != null; export const resolvePullRequestUrl = (repositoryPath, number, provider) => @@ -250,6 +256,7 @@ export const parseArguments = (args) => { let pullRequestProvider = null; let pullRequestUrl = null; let requestedPath = null; + let reviewBase = false; let sourceCandidate = null; let rangeCandidate = null; const walkthroughContextPath = @@ -268,6 +275,20 @@ export const parseArguments = (args) => { continue; } + if ( + !reviewBase && + !pullRequestUrl && + pullRequestNumber == null && + !commitRef && + !branchRef && + !sourceCandidate && + !rangeCandidate && + isBaseReviewMarker(arg) + ) { + reviewBase = true; + continue; + } + if (!pullRequestUrl && pullRequestNumber == null) { const number = parsePullRequestNumberArgument(arg); if (number != null) { @@ -338,6 +359,7 @@ export const parseArguments = (args) => { pullRequestNumber, ...(pullRequestProvider ? { pullRequestProvider } : {}), pullRequestUrl, + ...(reviewBase ? { reviewBase: true } : {}), requestedPath: resolve(requestedPath ?? process.cwd()), ...(values.share === true ? { share: true } : {}), version: values.version === true, diff --git a/bin/codiff-app b/bin/codiff-app index 1dc0b50..98b7c84 100755 --- a/bin/codiff-app +++ b/bin/codiff-app @@ -51,6 +51,7 @@ show_help() { printf ' %-32s%s%s%s\n' "codiff" "$gray" "Review staged and unstaged changes." "$reset" printf ' %-32s%s%s%s\n' "codiff /path/to/repo" "$gray" "Review changes in a specific repository." "$reset" printf ' %-32s%s%s%s\n' "codiff main" "$gray" "Review the current branch against main." "$reset" + printf ' %-32s%s%s%s\n' "codiff base" "$gray" "Review against the current branch's PR/MR base branch." "$reset" printf ' %-32s%s%s%s\n' "codiff a1b2c3d" "$gray" "Review a specific commit." "$reset" printf ' %-32s%s%s%s\n' "codiff '#75'" "$gray" "Review pull request #75." "$reset" printf ' %-32s%s%s%s\n' "codiff pr 75" "$gray" "Review pull request #75 (alternate syntax)." "$reset" @@ -90,6 +91,7 @@ branch_ref="" commit_ref="" pull_request_source="" pull_request_provider="" +review_base=0 source_ref="" walkthrough_context_path="" walkthrough_file_path="" @@ -148,6 +150,59 @@ is_git_commit_ref() { git -C "$1" rev-parse --verify "$2^{commit}" >/dev/null 2>&1 } +# Prefer origin, otherwise the first configured remote, falling back to origin. +resolve_remote_name() { + if git -C "$1" remote get-url origin >/dev/null 2>&1; then + printf 'origin' + return 0 + fi + first_remote="$(git -C "$1" remote 2>/dev/null | head -n 1)" + printf '%s' "${first_remote:-origin}" +} + +# Best-effort default branch for a remote: HEAD symbolic ref, then main/master. +remote_default_branch() { + head_ref="$(git -C "$1" symbolic-ref --quiet --short "refs/remotes/$2/HEAD" 2>/dev/null || true)" + case "$head_ref" in + "$2"/*) + printf '%s' "${head_ref#"$2"/}" + return 0 + ;; + esac + for candidate in main master; do + if git -C "$1" show-ref --verify "refs/remotes/$2/$candidate" >/dev/null 2>&1; then + printf '%s' "$candidate" + return 0 + fi + done + printf 'main' +} + +# Resolve the current branch's PR/MR base branch (GitHub via gh, GitLab via +# glab, chosen from the primary remote), refresh it, and echo "/". +resolve_base_branch_ref() { + base_repo="$1" + base_remote="$(resolve_remote_name "$base_repo")" + base_remote_url="$(git -C "$base_repo" remote get-url "$base_remote" 2>/dev/null || true)" + base_branch="" + case "$base_remote_url" in + *github.com*) + base_branch="$( (cd "$base_repo" && gh pr view --json baseRefName --jq .baseRefName) 2>/dev/null || true)" + ;; + ?*) + base_branch="$( (cd "$base_repo" && glab mr view --output json --jq .target_branch) 2>/dev/null || true)" + ;; + *) + base_branch="$( (cd "$base_repo" && gh pr view --json baseRefName --jq .baseRefName) 2>/dev/null || true)" + ;; + esac + if [ -z "$base_branch" ]; then + base_branch="$(remote_default_branch "$base_repo" "$base_remote")" + fi + git -C "$base_repo" fetch "$base_remote" "$base_branch" --quiet >/dev/null 2>&1 || true + printf '%s/%s' "$base_remote" "$base_branch" +} + for arg in "$@"; do if [ "$expect_codex_session" -eq 1 ]; then codex_session_id="$arg" @@ -334,6 +389,8 @@ for arg in "$@"; do pull_request_source="$arg" elif [ -z "$pull_request_source" ] && printf '%s' "$arg" | grep -Eq '^https?://[^/]+/(.+/)?[^/]+/(-/merge_requests|pull)/[0-9]+/?$'; then pull_request_source="$arg" + elif [ -z "$pull_request_source" ] && [ -z "$commit_ref" ] && [ -z "$branch_ref" ] && [ -z "$source_ref" ] && [ "$review_base" -eq 0 ] && printf '%s' "$arg" | grep -Eiq '^base$'; then + review_base=1 elif [ -z "$commit_ref" ] && [ -z "$branch_ref" ] && [ -z "$source_ref" ] && ! is_explicit_path_arg "$arg"; then source_ref="$arg" elif [ -z "$repository_path" ]; then @@ -366,6 +423,15 @@ if [ -n "$source_ref" ] && [ -z "$commit_ref" ] && [ -z "$branch_ref" ]; then fi fi +if [ "$review_base" -eq 1 ] && [ -z "$branch_ref" ] && [ -z "$commit_ref" ]; then + base_repository_context="${repository_path:-$PWD}" + case "$base_repository_context" in + /*) ;; + *) base_repository_context="$PWD/$base_repository_context" ;; + esac + branch_ref="$(resolve_base_branch_ref "$base_repository_context")" +fi + repository_path="${repository_path:-$PWD}" case "$repository_path" in diff --git a/bin/codiff.js b/bin/codiff.js index 570356f..2d5027b 100755 --- a/bin/codiff.js +++ b/bin/codiff.js @@ -19,6 +19,7 @@ const { shareWalkthroughFile, } = require('../electron/headless-walkthrough-share.cjs'); const { sharePlanFile } = require('../electron/headless-plan-share.cjs'); +const { refreshBaseBranchRef, resolveBaseBranchRef } = require('../electron/review-source.cjs'); // The renderer is the built dist/ by default. When Codiff's own Vite dev server // is running, use it instead so source edits hot-reload without a rebuild. The @@ -119,7 +120,6 @@ const run = async () => { const { agentBackend, - branchRef, claudeSessionId, codexSessionId, commitRef, @@ -130,12 +130,22 @@ const run = async () => { pullRequestProvider, range, requestedPath, + reviewBase, share, walkthrough, walkthroughContextPath, walkthroughFilePath, } = parsedArguments; - let { pullRequestUrl } = parsedArguments; + let { branchRef, pullRequestUrl } = parsedArguments; + + // `codiff base` reviews the current branch against the branch its PR/MR + // targets. Resolve that base into a concrete remote branch ref and refresh + // it so the diff is current, then reuse the regular branch review path. + if (reviewBase && !branchRef && !commitRef && !range) { + const { base, ref, remote } = resolveBaseBranchRef(requestedPath); + branchRef = ref; + refreshBaseBranchRef(requestedPath, remote, base); + } if (planFilePath && (!existsSync(planFilePath) || !/\.md$/i.test(planFilePath))) { process.stderr.write(`codiff: plan file not found or not Markdown: ${planFilePath}\n`); diff --git a/core/__tests__/codiff-cli.test.ts b/core/__tests__/codiff-cli.test.ts index d16c692..10ddd09 100644 --- a/core/__tests__/codiff-cli.test.ts +++ b/core/__tests__/codiff-cli.test.ts @@ -10,6 +10,7 @@ import { truncate, writeFile, } from 'node:fs/promises'; +import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { promisify } from 'node:util'; @@ -20,6 +21,23 @@ import { createFakeCommandLogger, createFakeOpenLogger } from './helpers/cli.ts' const execFileAsync = promisify(execFile); +const require = createRequire(import.meta.url); +const { resolveBaseBranchRef } = require('../../electron/review-source.cjs') as { + resolveBaseBranchRef: (repositoryPath: string) => { + base: string; + provider: string; + ref: string; + remote: string; + }; +}; + +const writeFakeExecutable = async (directory: string, name: string, body: string) => { + const path = join(directory, name); + await writeFile(path, body); + await chmod(path, 0o755); + return path; +}; + const git = async (repo: string, args: ReadonlyArray) => { await execFileAsync( 'git', @@ -329,6 +347,121 @@ test('parseArguments treats GitLab MR marker values as review sources', () => { }); }); +test('parseArguments treats the base keyword as a PR/MR base review', () => { + expect(parseArguments(['base'])).toEqual({ + commitRef: null, + help: false, + pullRequestNumber: null, + pullRequestUrl: null, + requestedPath: resolve(process.cwd()), + reviewBase: true, + version: false, + walkthrough: false, + }); + + expect(parseArguments(['BASE'])).toMatchObject({ reviewBase: true }); +}); + +test('parseArguments still lets --branch base review a literal base branch', async () => { + await withCwd(refRepositoryPath, () => { + expect(parseArguments(['--branch', 'base'])).toMatchObject({ + branchRef: 'base', + commitRef: null, + }); + expect(parseArguments(['--branch', 'base'])).not.toHaveProperty('reviewBase'); + }); +}); + +test('resolveBaseBranchRef reads the GitHub PR base branch through gh', async () => { + const directory = await realpath(await mkdtemp(join(tmpdir(), 'codiff-base-github-'))); + const repositoryPath = join(directory, 'repo'); + const fakeBin = join(directory, 'bin'); + const previousPath = process.env.PATH; + + try { + await mkdir(repositoryPath); + await mkdir(fakeBin); + await git(repositoryPath, ['init']); + await git(repositoryPath, ['remote', 'add', 'origin', 'git@github.com:owner/repo.git']); + await writeFakeExecutable(fakeBin, 'gh', '#!/bin/sh\nprintf "feature-base\\n"\n'); + process.env.PATH = `${fakeBin}:${previousPath ?? ''}`; + + expect(resolveBaseBranchRef(repositoryPath)).toEqual({ + base: 'feature-base', + provider: 'github', + ref: 'origin/feature-base', + remote: 'origin', + }); + } finally { + process.env.PATH = previousPath; + await rm(directory, { force: true, recursive: true }); + } +}); + +test('resolveBaseBranchRef reads the GitLab MR target branch through glab', async () => { + const directory = await realpath(await mkdtemp(join(tmpdir(), 'codiff-base-gitlab-'))); + const repositoryPath = join(directory, 'repo'); + const fakeBin = join(directory, 'bin'); + const previousPath = process.env.PATH; + + try { + await mkdir(repositoryPath); + await mkdir(fakeBin); + await git(repositoryPath, ['init']); + await git(repositoryPath, [ + 'remote', + 'add', + 'origin', + 'git@gitlab.example.com:group/project.git', + ]); + await writeFakeExecutable(fakeBin, 'glab', '#!/bin/sh\nprintf "release\\n"\n'); + process.env.PATH = `${fakeBin}:${previousPath ?? ''}`; + + expect(resolveBaseBranchRef(repositoryPath)).toEqual({ + base: 'release', + provider: 'gitlab', + ref: 'origin/release', + remote: 'origin', + }); + } finally { + process.env.PATH = previousPath; + await rm(directory, { force: true, recursive: true }); + } +}); + +test('resolveBaseBranchRef falls back to the remote default branch without a request', async () => { + const directory = await realpath(await mkdtemp(join(tmpdir(), 'codiff-base-fallback-'))); + const repositoryPath = join(directory, 'repo'); + const fakeBin = join(directory, 'bin'); + const previousPath = process.env.PATH; + + try { + await mkdir(repositoryPath); + await mkdir(fakeBin); + await git(repositoryPath, ['init']); + await git(repositoryPath, ['config', 'user.email', 'codiff@example.com']); + await git(repositoryPath, ['config', 'user.name', 'Codiff Test']); + await git(repositoryPath, ['remote', 'add', 'origin', 'git@github.com:owner/repo.git']); + await git(repositoryPath, ['commit', '--allow-empty', '-m', 'init']); + const { stdout } = await execFileAsync('git', ['-C', repositoryPath, 'rev-parse', 'HEAD'], { + encoding: 'utf8', + }); + await git(repositoryPath, ['update-ref', 'refs/remotes/origin/main', stdout.trim()]); + await writeFakeExecutable(fakeBin, 'gh', '#!/bin/sh\nexit 1\n'); + process.env.PATH = `${fakeBin}:${previousPath ?? ''}`; + + expect(resolveBaseBranchRef(repositoryPath)).toEqual({ + base: 'main', + provider: 'github', + ref: 'origin/main', + remote: 'origin', + }); + } finally { + process.env.PATH = previousPath; + await rm(directory, { force: true, recursive: true }); + } +}); + test('parseArguments accepts nested GitLab merge request URLs', () => { expect( parseArguments(['https://gitlab.example.com/group/subgroup/project/-/merge_requests/23']), @@ -418,6 +551,46 @@ test('packaged terminal helper forwards GitLab MR markers to Electron', async () } }); +test('packaged terminal helper resolves the PR base branch for `base`', async () => { + const logger = await createFakeOpenLogger(); + const repositoryPath = join(logger.directory, 'repo'); + + try { + await mkdir(repositoryPath); + const realRepositoryPath = await realpath(repositoryPath); + await git(realRepositoryPath, ['init']); + // A local path that still contains "github.com" selects the GitHub provider + // while keeping the background fetch offline and instant during the test. + await git(realRepositoryPath, [ + 'remote', + 'add', + 'origin', + join(logger.directory, 'github.com', 'remote.git'), + ]); + await writeFakeExecutable( + join(logger.directory, 'bin'), + 'gh', + '#!/bin/sh\nprintf "feature-base\\n"\n', + ); + + await execFileAsync(resolve('bin/codiff-app'), ['base'], { + cwd: realRepositoryPath, + env: logger.env, + }); + + expect(await logger.readArgs()).toEqual([ + '-n', + resolve('bin/../../../..'), + '--args', + '--branch', + 'origin/feature-base', + realRepositoryPath, + ]); + } finally { + await logger.cleanup(); + } +}); + test('packaged terminal helper forwards HEAD^1 to Electron as a commit', async () => { const logger = await createFakeOpenLogger(); diff --git a/electron/review-source.cjs b/electron/review-source.cjs index f74902d..bccd561 100644 --- a/electron/review-source.cjs +++ b/electron/review-source.cjs @@ -1,6 +1,6 @@ // @ts-check -const { execFileSync } = require('node:child_process'); +const { execFileSync, spawn } = require('node:child_process'); /** @typedef {'github' | 'gitlab'} ReviewProvider */ @@ -104,6 +104,133 @@ const remotePriority = (remote) => ? 2 : 3; +/** + * Run a command and return its trimmed stdout, or an empty string when it is + * not installed, errors, or exceeds the timeout. Never throws so base-branch + * resolution can always fall back gracefully. + * @param {string} command + * @param {ReadonlyArray} args + * @param {string} cwd + */ +const tryCommandOutput = (command, args, cwd) => { + try { + return execFileSync(command, args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 15_000, + }).trim(); + } catch { + return ''; + } +}; + +/** + * Best-effort default branch for a remote: the remote's `HEAD` symbolic ref, + * then `main`/`master` if either is tracked, then `main`. + * @param {string} repositoryPath + * @param {string} remoteName + */ +const resolveDefaultRemoteBranch = (repositoryPath, remoteName) => { + const head = tryCommandOutput( + 'git', + ['symbolic-ref', '--quiet', '--short', `refs/remotes/${remoteName}/HEAD`], + repositoryPath, + ); + const prefix = `${remoteName}/`; + if (head.startsWith(prefix)) { + return head.slice(prefix.length); + } + + for (const candidate of ['main', 'master']) { + if ( + tryCommandOutput( + 'git', + ['show-ref', '--verify', `refs/remotes/${remoteName}/${candidate}`], + repositoryPath, + ) + ) { + return candidate; + } + } + + return 'main'; +}; + +/** + * Resolve the branch the current branch's open pull/merge request targets so a + * review can diff against the real base of a stack instead of always against + * `main`. Asks GitHub through `gh` or GitLab through `glab` (chosen from the + * repository's primary review remote) and falls back to the remote's default + * branch when there is no request or the CLI is unavailable. + * @param {string} repositoryPath + * @returns {{ base: string; provider: ReviewProvider; ref: string; remote: string }} + */ +const resolveBaseBranchRef = (repositoryPath) => { + let remotes = []; + try { + remotes = readReviewRemotes(repositoryPath); + } catch { + remotes = []; + } + + const remote = remotes.sort((left, right) => remotePriority(left) - remotePriority(right))[0]; + const remoteName = remote?.name ?? 'origin'; + const provider = remote?.provider ?? /** @type {ReviewProvider} */ ('github'); + + const base = + (provider === 'gitlab' + ? tryCommandOutput( + 'glab', + ['mr', 'view', '--output', 'json', '--jq', '.target_branch'], + repositoryPath, + ) + : tryCommandOutput( + 'gh', + ['pr', 'view', '--json', 'baseRefName', '--jq', '.baseRefName'], + repositoryPath, + )) || resolveDefaultRemoteBranch(repositoryPath, remoteName); + + return { base, provider, ref: `${remoteName}/${base}`, remote: remoteName }; +}; + +/** + * Refresh a single base branch from its remote. Blocks only when the ref is + * not present locally yet so the diff has something to compare against; + * otherwise it refreshes in the background and returns immediately, mirroring + * the parallel fetch in the `cdf` shell helper this command is based on. + * @param {string} repositoryPath + * @param {string} remoteName + * @param {string} base + */ +const refreshBaseBranchRef = (repositoryPath, remoteName, base) => { + const hasRef = Boolean( + tryCommandOutput( + 'git', + ['rev-parse', '--verify', '--quiet', `refs/remotes/${remoteName}/${base}^{commit}`], + repositoryPath, + ), + ); + const fetchArgs = ['-C', repositoryPath, 'fetch', remoteName, base, '--quiet']; + + if (hasRef) { + try { + const child = spawn('git', fetchArgs, { detached: true, stdio: 'ignore' }); + child.on('error', () => {}); + child.unref(); + } catch { + // A failed background refresh still leaves the existing ref to diff against. + } + return; + } + + try { + execFileSync('git', fetchArgs, { stdio: 'ignore', timeout: 30_000 }); + } catch { + // Offline or unknown branch: fall through and let the diff report what it can. + } +}; + /** * @param {string} repositoryPath * @param {number} number @@ -137,5 +264,7 @@ module.exports = { parseRemoteUrl, parseReviewUrl, readReviewRemotes, + refreshBaseBranchRef, + resolveBaseBranchRef, resolveReviewUrl, }; From 7911dd3228c244c6ba09d7a2c67244626df74973 Mon Sep 17 00:00:00 2001 From: Alexander Johansson Date: Fri, 26 Jun 2026 10:03:46 +0200 Subject: [PATCH 2/6] Simplify `codiff base` implementation. Delegate packaged helper handling to the Node CLI and remove duplicated shell-side PR/MR base resolution, keeping the command behavior easier to follow. Co-authored-by: Cursor --- bin/arguments.js | 8 +-- bin/codiff-app | 95 +++++----------------------- bin/codiff.js | 16 +++-- core/__tests__/codiff-cli.test.ts | 41 +++--------- electron/review-source.cjs | 100 +++++------------------------- 5 files changed, 52 insertions(+), 208 deletions(-) diff --git a/bin/arguments.js b/bin/arguments.js index 9866fcf..beb5ca9 100644 --- a/bin/arguments.js +++ b/bin/arguments.js @@ -275,16 +275,14 @@ export const parseArguments = (args) => { continue; } - if ( - !reviewBase && + const canReadBaseMarker = !pullRequestUrl && pullRequestNumber == null && !commitRef && !branchRef && !sourceCandidate && - !rangeCandidate && - isBaseReviewMarker(arg) - ) { + !rangeCandidate; + if (canReadBaseMarker && isBaseReviewMarker(arg)) { reviewBase = true; continue; } diff --git a/bin/codiff-app b/bin/codiff-app index 98b7c84..1394d31 100755 --- a/bin/codiff-app +++ b/bin/codiff-app @@ -64,21 +64,25 @@ show_help() { printf ' %-32s%s%s%s\n' "codiff --share HEAD" "$gray" "Share a walkthrough for a commit." "$reset" } -for arg in "$@"; do - if [ "$arg" = "--share" ]; then - runtime="${CODIFF_NODE_COMMAND:-}" - if [ -z "$runtime" ]; then - electron_exec="$(ls "$app_path/Contents/MacOS/" 2>/dev/null | head -n 1)" - if [ -n "$electron_exec" ]; then - runtime="$app_path/Contents/MacOS/$electron_exec" - fi - fi - if [ -z "$runtime" ] || [ ! -x "$runtime" ]; then - printf '%s\n' "codiff: could not find the bundled runtime for --share." >&2 - exit 1 +run_with_bundled_node() { + runtime="${CODIFF_NODE_COMMAND:-}" + if [ -z "$runtime" ]; then + electron_exec="$(ls "$app_path/Contents/MacOS/" 2>/dev/null | head -n 1)" + if [ -n "$electron_exec" ]; then + runtime="$app_path/Contents/MacOS/$electron_exec" fi - ELECTRON_RUN_AS_NODE=1 exec "$runtime" "$script_dir/codiff.js" "$@" fi + if [ -z "$runtime" ] || [ ! -x "$runtime" ]; then + printf '%s\n' "codiff: could not find the bundled runtime." >&2 + exit 1 + fi + ELECTRON_RUN_AS_NODE=1 exec "$runtime" "$script_dir/codiff.js" "$@" +} + +for arg in "$@"; do + case "$arg" in + --share|[Bb][Aa][Ss][Ee]) run_with_bundled_node "$@" ;; + esac done repository_path="" @@ -91,7 +95,6 @@ branch_ref="" commit_ref="" pull_request_source="" pull_request_provider="" -review_base=0 source_ref="" walkthrough_context_path="" walkthrough_file_path="" @@ -150,59 +153,6 @@ is_git_commit_ref() { git -C "$1" rev-parse --verify "$2^{commit}" >/dev/null 2>&1 } -# Prefer origin, otherwise the first configured remote, falling back to origin. -resolve_remote_name() { - if git -C "$1" remote get-url origin >/dev/null 2>&1; then - printf 'origin' - return 0 - fi - first_remote="$(git -C "$1" remote 2>/dev/null | head -n 1)" - printf '%s' "${first_remote:-origin}" -} - -# Best-effort default branch for a remote: HEAD symbolic ref, then main/master. -remote_default_branch() { - head_ref="$(git -C "$1" symbolic-ref --quiet --short "refs/remotes/$2/HEAD" 2>/dev/null || true)" - case "$head_ref" in - "$2"/*) - printf '%s' "${head_ref#"$2"/}" - return 0 - ;; - esac - for candidate in main master; do - if git -C "$1" show-ref --verify "refs/remotes/$2/$candidate" >/dev/null 2>&1; then - printf '%s' "$candidate" - return 0 - fi - done - printf 'main' -} - -# Resolve the current branch's PR/MR base branch (GitHub via gh, GitLab via -# glab, chosen from the primary remote), refresh it, and echo "/". -resolve_base_branch_ref() { - base_repo="$1" - base_remote="$(resolve_remote_name "$base_repo")" - base_remote_url="$(git -C "$base_repo" remote get-url "$base_remote" 2>/dev/null || true)" - base_branch="" - case "$base_remote_url" in - *github.com*) - base_branch="$( (cd "$base_repo" && gh pr view --json baseRefName --jq .baseRefName) 2>/dev/null || true)" - ;; - ?*) - base_branch="$( (cd "$base_repo" && glab mr view --output json --jq .target_branch) 2>/dev/null || true)" - ;; - *) - base_branch="$( (cd "$base_repo" && gh pr view --json baseRefName --jq .baseRefName) 2>/dev/null || true)" - ;; - esac - if [ -z "$base_branch" ]; then - base_branch="$(remote_default_branch "$base_repo" "$base_remote")" - fi - git -C "$base_repo" fetch "$base_remote" "$base_branch" --quiet >/dev/null 2>&1 || true - printf '%s/%s' "$base_remote" "$base_branch" -} - for arg in "$@"; do if [ "$expect_codex_session" -eq 1 ]; then codex_session_id="$arg" @@ -389,8 +339,6 @@ for arg in "$@"; do pull_request_source="$arg" elif [ -z "$pull_request_source" ] && printf '%s' "$arg" | grep -Eq '^https?://[^/]+/(.+/)?[^/]+/(-/merge_requests|pull)/[0-9]+/?$'; then pull_request_source="$arg" - elif [ -z "$pull_request_source" ] && [ -z "$commit_ref" ] && [ -z "$branch_ref" ] && [ -z "$source_ref" ] && [ "$review_base" -eq 0 ] && printf '%s' "$arg" | grep -Eiq '^base$'; then - review_base=1 elif [ -z "$commit_ref" ] && [ -z "$branch_ref" ] && [ -z "$source_ref" ] && ! is_explicit_path_arg "$arg"; then source_ref="$arg" elif [ -z "$repository_path" ]; then @@ -423,15 +371,6 @@ if [ -n "$source_ref" ] && [ -z "$commit_ref" ] && [ -z "$branch_ref" ]; then fi fi -if [ "$review_base" -eq 1 ] && [ -z "$branch_ref" ] && [ -z "$commit_ref" ]; then - base_repository_context="${repository_path:-$PWD}" - case "$base_repository_context" in - /*) ;; - *) base_repository_context="$PWD/$base_repository_context" ;; - esac - branch_ref="$(resolve_base_branch_ref "$base_repository_context")" -fi - repository_path="${repository_path:-$PWD}" case "$repository_path" in diff --git a/bin/codiff.js b/bin/codiff.js index 2d5027b..9a14846 100755 --- a/bin/codiff.js +++ b/bin/codiff.js @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { spawn } from 'node:child_process'; +import { execFileSync, spawn } from 'node:child_process'; import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import http from 'node:http'; import https from 'node:https'; @@ -19,7 +19,7 @@ const { shareWalkthroughFile, } = require('../electron/headless-walkthrough-share.cjs'); const { sharePlanFile } = require('../electron/headless-plan-share.cjs'); -const { refreshBaseBranchRef, resolveBaseBranchRef } = require('../electron/review-source.cjs'); +const { resolveBaseBranchRef } = require('../electron/review-source.cjs'); // The renderer is the built dist/ by default. When Codiff's own Vite dev server // is running, use it instead so source edits hot-reload without a rebuild. The @@ -138,13 +138,17 @@ const run = async () => { } = parsedArguments; let { branchRef, pullRequestUrl } = parsedArguments; - // `codiff base` reviews the current branch against the branch its PR/MR - // targets. Resolve that base into a concrete remote branch ref and refresh - // it so the diff is current, then reuse the regular branch review path. if (reviewBase && !branchRef && !commitRef && !range) { const { base, ref, remote } = resolveBaseBranchRef(requestedPath); branchRef = ref; - refreshBaseBranchRef(requestedPath, remote, base); + try { + execFileSync('git', ['-C', requestedPath, 'fetch', remote, base, '--quiet'], { + stdio: 'ignore', + timeout: 30_000, + }); + } catch { + // Keep going with the local remote ref when offline. + } } if (planFilePath && (!existsSync(planFilePath) || !/\.md$/i.test(planFilePath))) { diff --git a/core/__tests__/codiff-cli.test.ts b/core/__tests__/codiff-cli.test.ts index 10ddd09..8012b33 100644 --- a/core/__tests__/codiff-cli.test.ts +++ b/core/__tests__/codiff-cli.test.ts @@ -25,7 +25,6 @@ const require = createRequire(import.meta.url); const { resolveBaseBranchRef } = require('../../electron/review-source.cjs') as { resolveBaseBranchRef: (repositoryPath: string) => { base: string; - provider: string; ref: string; remote: string; }; @@ -388,7 +387,6 @@ test('resolveBaseBranchRef reads the GitHub PR base branch through gh', async () expect(resolveBaseBranchRef(repositoryPath)).toEqual({ base: 'feature-base', - provider: 'github', ref: 'origin/feature-base', remote: 'origin', }); @@ -419,7 +417,6 @@ test('resolveBaseBranchRef reads the GitLab MR target branch through glab', asyn expect(resolveBaseBranchRef(repositoryPath)).toEqual({ base: 'release', - provider: 'gitlab', ref: 'origin/release', remote: 'origin', }); @@ -452,7 +449,6 @@ test('resolveBaseBranchRef falls back to the remote default branch without a req expect(resolveBaseBranchRef(repositoryPath)).toEqual({ base: 'main', - provider: 'github', ref: 'origin/main', remote: 'origin', }); @@ -551,41 +547,18 @@ test('packaged terminal helper forwards GitLab MR markers to Electron', async () } }); -test('packaged terminal helper resolves the PR base branch for `base`', async () => { - const logger = await createFakeOpenLogger(); - const repositoryPath = join(logger.directory, 'repo'); +test('packaged terminal helper runs `base` through the bundled Node entry point', async () => { + const logger = await createFakeCommandLogger('codiff-packaged-base-', 'runtime'); try { - await mkdir(repositoryPath); - const realRepositoryPath = await realpath(repositoryPath); - await git(realRepositoryPath, ['init']); - // A local path that still contains "github.com" selects the GitHub provider - // while keeping the background fetch offline and instant during the test. - await git(realRepositoryPath, [ - 'remote', - 'add', - 'origin', - join(logger.directory, 'github.com', 'remote.git'), - ]); - await writeFakeExecutable( - join(logger.directory, 'bin'), - 'gh', - '#!/bin/sh\nprintf "feature-base\\n"\n', - ); - await execFileAsync(resolve('bin/codiff-app'), ['base'], { - cwd: realRepositoryPath, - env: logger.env, + env: { + ...logger.env, + CODIFF_NODE_COMMAND: logger.commandPath, + }, }); - expect(await logger.readArgs()).toEqual([ - '-n', - resolve('bin/../../../..'), - '--args', - '--branch', - 'origin/feature-base', - realRepositoryPath, - ]); + expect(await logger.readArgs()).toEqual([resolve('bin/codiff.js'), 'base']); } finally { await logger.cleanup(); } diff --git a/electron/review-source.cjs b/electron/review-source.cjs index bccd561..5771da4 100644 --- a/electron/review-source.cjs +++ b/electron/review-source.cjs @@ -1,6 +1,6 @@ // @ts-check -const { execFileSync, spawn } = require('node:child_process'); +const { execFileSync } = require('node:child_process'); /** @typedef {'github' | 'gitlab'} ReviewProvider */ @@ -104,14 +104,6 @@ const remotePriority = (remote) => ? 2 : 3; -/** - * Run a command and return its trimmed stdout, or an empty string when it is - * not installed, errors, or exceeds the timeout. Never throws so base-branch - * resolution can always fall back gracefully. - * @param {string} command - * @param {ReadonlyArray} args - * @param {string} cwd - */ const tryCommandOutput = (command, args, cwd) => { try { return execFileSync(command, args, { @@ -125,12 +117,13 @@ const tryCommandOutput = (command, args, cwd) => { } }; -/** - * Best-effort default branch for a remote: the remote's `HEAD` symbolic ref, - * then `main`/`master` if either is tracked, then `main`. - * @param {string} repositoryPath - * @param {string} remoteName - */ +/** @param {string} repositoryPath */ +const getPrimaryReviewRemote = (repositoryPath) => + readReviewRemotes(repositoryPath).sort( + (left, right) => remotePriority(left) - remotePriority(right), + )[0]; + +/** @param {string} repositoryPath @param {string} remoteName */ const resolveDefaultRemoteBranch = (repositoryPath, remoteName) => { const head = tryCommandOutput( 'git', @@ -142,44 +135,18 @@ const resolveDefaultRemoteBranch = (repositoryPath, remoteName) => { return head.slice(prefix.length); } - for (const candidate of ['main', 'master']) { - if ( - tryCommandOutput( - 'git', - ['show-ref', '--verify', `refs/remotes/${remoteName}/${candidate}`], - repositoryPath, - ) - ) { - return candidate; - } - } - return 'main'; }; /** - * Resolve the branch the current branch's open pull/merge request targets so a - * review can diff against the real base of a stack instead of always against - * `main`. Asks GitHub through `gh` or GitLab through `glab` (chosen from the - * repository's primary review remote) and falls back to the remote's default - * branch when there is no request or the CLI is unavailable. * @param {string} repositoryPath - * @returns {{ base: string; provider: ReviewProvider; ref: string; remote: string }} + * @returns {{ base: string; ref: string; remote: string }} */ const resolveBaseBranchRef = (repositoryPath) => { - let remotes = []; - try { - remotes = readReviewRemotes(repositoryPath); - } catch { - remotes = []; - } - - const remote = remotes.sort((left, right) => remotePriority(left) - remotePriority(right))[0]; - const remoteName = remote?.name ?? 'origin'; - const provider = remote?.provider ?? /** @type {ReviewProvider} */ ('github'); - + const remote = getPrimaryReviewRemote(repositoryPath); + const remoteName = remote.name; const base = - (provider === 'gitlab' + remote.provider === 'gitlab' ? tryCommandOutput( 'glab', ['mr', 'view', '--output', 'json', '--jq', '.target_branch'], @@ -189,46 +156,10 @@ const resolveBaseBranchRef = (repositoryPath) => { 'gh', ['pr', 'view', '--json', 'baseRefName', '--jq', '.baseRefName'], repositoryPath, - )) || resolveDefaultRemoteBranch(repositoryPath, remoteName); - - return { base, provider, ref: `${remoteName}/${base}`, remote: remoteName }; -}; - -/** - * Refresh a single base branch from its remote. Blocks only when the ref is - * not present locally yet so the diff has something to compare against; - * otherwise it refreshes in the background and returns immediately, mirroring - * the parallel fetch in the `cdf` shell helper this command is based on. - * @param {string} repositoryPath - * @param {string} remoteName - * @param {string} base - */ -const refreshBaseBranchRef = (repositoryPath, remoteName, base) => { - const hasRef = Boolean( - tryCommandOutput( - 'git', - ['rev-parse', '--verify', '--quiet', `refs/remotes/${remoteName}/${base}^{commit}`], - repositoryPath, - ), - ); - const fetchArgs = ['-C', repositoryPath, 'fetch', remoteName, base, '--quiet']; - - if (hasRef) { - try { - const child = spawn('git', fetchArgs, { detached: true, stdio: 'ignore' }); - child.on('error', () => {}); - child.unref(); - } catch { - // A failed background refresh still leaves the existing ref to diff against. - } - return; - } + ); - try { - execFileSync('git', fetchArgs, { stdio: 'ignore', timeout: 30_000 }); - } catch { - // Offline or unknown branch: fall through and let the diff report what it can. - } + const branch = base || resolveDefaultRemoteBranch(repositoryPath, remoteName); + return { base: branch, ref: `${remoteName}/${branch}`, remote: remoteName }; }; /** @@ -264,7 +195,6 @@ module.exports = { parseRemoteUrl, parseReviewUrl, readReviewRemotes, - refreshBaseBranchRef, resolveBaseBranchRef, resolveReviewUrl, }; From 9d2ec850e2a3631ce315deb0f99ea471149bb513 Mon Sep 17 00:00:00 2001 From: Alexander Johansson Date: Fri, 26 Jun 2026 10:08:07 +0200 Subject: [PATCH 3/6] Clarify `base` argument parsing. Handle the `base` convenience keyword during source resolution instead of using a large guard in the positional scanner. Co-authored-by: Cursor --- bin/arguments.js | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/bin/arguments.js b/bin/arguments.js index beb5ca9..d6c9d00 100644 --- a/bin/arguments.js +++ b/bin/arguments.js @@ -275,18 +275,6 @@ export const parseArguments = (args) => { continue; } - const canReadBaseMarker = - !pullRequestUrl && - pullRequestNumber == null && - !commitRef && - !branchRef && - !sourceCandidate && - !rangeCandidate; - if (canReadBaseMarker && isBaseReviewMarker(arg)) { - reviewBase = true; - continue; - } - if (!pullRequestUrl && pullRequestNumber == null) { const number = parsePullRequestNumberArgument(arg); if (number != null) { @@ -333,13 +321,17 @@ export const parseArguments = (args) => { : null; } if (!range && !commitRef && !branchRef && sourceCandidate) { - const source = resolveSourceCandidate(repositoryPath, sourceCandidate); - if (source?.branchRef) { - branchRef = source.branchRef; - } else if (source?.commitRef) { - commitRef = source.commitRef; - } else if (requestedPath == null) { - requestedPath = sourceCandidate; + if (isBaseReviewMarker(sourceCandidate)) { + reviewBase = true; + } else { + const source = resolveSourceCandidate(repositoryPath, sourceCandidate); + if (source?.branchRef) { + branchRef = source.branchRef; + } else if (source?.commitRef) { + commitRef = source.commitRef; + } else if (requestedPath == null) { + requestedPath = sourceCandidate; + } } } From b9b9d2b08fc77ed1d5302b0478b49deb0f4605ba Mon Sep 17 00:00:00 2001 From: Alexander Johansson Date: Fri, 26 Jun 2026 10:08:48 +0200 Subject: [PATCH 4/6] Match CLI usage example formatting. Keep the `codiff base` example on one line like the surrounding usage examples. Co-authored-by: Cursor --- bin/arguments.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bin/arguments.js b/bin/arguments.js index d6c9d00..2edd405 100644 --- a/bin/arguments.js +++ b/bin/arguments.js @@ -91,10 +91,7 @@ export const usageExamples = [ { command: 'codiff', description: 'Review staged and unstaged changes.' }, { command: 'codiff /path/to/repo', description: 'Review changes in a specific repository.' }, { command: 'codiff main', description: 'Review the current branch against main.' }, - { - command: 'codiff base', - description: "Review against the current branch's PR/MR base branch.", - }, + { command: 'codiff base', description: "Review against the current branch's PR/MR base branch." }, { command: 'codiff a1b2c3d', description: 'Review a specific commit.' }, { command: "codiff '#75'", description: 'Review pull request #75.' }, { command: 'codiff pr 75', description: 'Review pull request #75 (alternate syntax).' }, From 28e59241a3a06af277a1386ef6e5675c04d81e06 Mon Sep 17 00:00:00 2001 From: Alexander Johansson Date: Fri, 26 Jun 2026 10:09:57 +0200 Subject: [PATCH 5/6] Inline packaged base delegation. Avoid adding a helper for bundled Node delegation; keep the packaged helper close to the existing --share handling. Co-authored-by: Cursor --- bin/codiff-app | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/bin/codiff-app b/bin/codiff-app index 1394d31..d0c2a0c 100755 --- a/bin/codiff-app +++ b/bin/codiff-app @@ -64,24 +64,22 @@ show_help() { printf ' %-32s%s%s%s\n' "codiff --share HEAD" "$gray" "Share a walkthrough for a commit." "$reset" } -run_with_bundled_node() { - runtime="${CODIFF_NODE_COMMAND:-}" - if [ -z "$runtime" ]; then - electron_exec="$(ls "$app_path/Contents/MacOS/" 2>/dev/null | head -n 1)" - if [ -n "$electron_exec" ]; then - runtime="$app_path/Contents/MacOS/$electron_exec" - fi - fi - if [ -z "$runtime" ] || [ ! -x "$runtime" ]; then - printf '%s\n' "codiff: could not find the bundled runtime." >&2 - exit 1 - fi - ELECTRON_RUN_AS_NODE=1 exec "$runtime" "$script_dir/codiff.js" "$@" -} - for arg in "$@"; do case "$arg" in - --share|[Bb][Aa][Ss][Ee]) run_with_bundled_node "$@" ;; + --share|[Bb][Aa][Ss][Ee]) + runtime="${CODIFF_NODE_COMMAND:-}" + if [ -z "$runtime" ]; then + electron_exec="$(ls "$app_path/Contents/MacOS/" 2>/dev/null | head -n 1)" + if [ -n "$electron_exec" ]; then + runtime="$app_path/Contents/MacOS/$electron_exec" + fi + fi + if [ -z "$runtime" ] || [ ! -x "$runtime" ]; then + printf '%s\n' "codiff: could not find the bundled runtime." >&2 + exit 1 + fi + ELECTRON_RUN_AS_NODE=1 exec "$runtime" "$script_dir/codiff.js" "$@" + ;; esac done From 129e71d534a31758ca48027863bba8784e7237f3 Mon Sep 17 00:00:00 2001 From: Alexander Johansson Date: Fri, 26 Jun 2026 10:12:52 +0200 Subject: [PATCH 6/6] Use explicit disposal in new CLI tests. Convert the added base-command tests to explicit resource management so temporary directories, PATH overrides, and fake command loggers clean up without large try/finally blocks. Co-authored-by: Cursor --- core/__tests__/codiff-cli.test.ts | 187 ++++++++++++++++-------------- 1 file changed, 97 insertions(+), 90 deletions(-) diff --git a/core/__tests__/codiff-cli.test.ts b/core/__tests__/codiff-cli.test.ts index 8012b33..90668ab 100644 --- a/core/__tests__/codiff-cli.test.ts +++ b/core/__tests__/codiff-cli.test.ts @@ -37,6 +37,32 @@ const writeFakeExecutable = async (directory: string, name: string, body: string return path; }; +const createTemporaryDirectory = async (prefix: string) => { + const directory = await realpath(await mkdtemp(join(tmpdir(), prefix))); + return { + directory, + [Symbol.asyncDispose]: () => rm(directory, { force: true, recursive: true }), + }; +}; + +const overridePath = (value: string) => { + const previousPath = process.env.PATH; + process.env.PATH = value; + return { + [Symbol.dispose]: () => { + process.env.PATH = previousPath; + }, + }; +}; + +const createDisposableFakeCommandLogger = async ( + prefix: string, + commandName: string, +): Promise> & AsyncDisposable> => { + const logger = await createFakeCommandLogger(prefix, commandName); + return { ...logger, [Symbol.asyncDispose]: logger.cleanup }; +}; + const git = async (repo: string, args: ReadonlyArray) => { await execFileAsync( 'git', @@ -372,90 +398,75 @@ test('parseArguments still lets --branch base review a literal base branch', asy }); test('resolveBaseBranchRef reads the GitHub PR base branch through gh', async () => { - const directory = await realpath(await mkdtemp(join(tmpdir(), 'codiff-base-github-'))); - const repositoryPath = join(directory, 'repo'); - const fakeBin = join(directory, 'bin'); - const previousPath = process.env.PATH; - - try { - await mkdir(repositoryPath); - await mkdir(fakeBin); - await git(repositoryPath, ['init']); - await git(repositoryPath, ['remote', 'add', 'origin', 'git@github.com:owner/repo.git']); - await writeFakeExecutable(fakeBin, 'gh', '#!/bin/sh\nprintf "feature-base\\n"\n'); - process.env.PATH = `${fakeBin}:${previousPath ?? ''}`; - - expect(resolveBaseBranchRef(repositoryPath)).toEqual({ - base: 'feature-base', - ref: 'origin/feature-base', - remote: 'origin', - }); - } finally { - process.env.PATH = previousPath; - await rm(directory, { force: true, recursive: true }); - } + await using temporaryDirectory = await createTemporaryDirectory('codiff-base-github-'); + const repositoryPath = join(temporaryDirectory.directory, 'repo'); + const fakeBin = join(temporaryDirectory.directory, 'bin'); + + await mkdir(repositoryPath); + await mkdir(fakeBin); + await git(repositoryPath, ['init']); + await git(repositoryPath, ['remote', 'add', 'origin', 'git@github.com:owner/repo.git']); + await writeFakeExecutable(fakeBin, 'gh', '#!/bin/sh\nprintf "feature-base\\n"\n'); + using pathOverride = overridePath(`${fakeBin}:${process.env.PATH ?? ''}`); + void pathOverride; + + expect(resolveBaseBranchRef(repositoryPath)).toEqual({ + base: 'feature-base', + ref: 'origin/feature-base', + remote: 'origin', + }); }); test('resolveBaseBranchRef reads the GitLab MR target branch through glab', async () => { - const directory = await realpath(await mkdtemp(join(tmpdir(), 'codiff-base-gitlab-'))); - const repositoryPath = join(directory, 'repo'); - const fakeBin = join(directory, 'bin'); - const previousPath = process.env.PATH; - - try { - await mkdir(repositoryPath); - await mkdir(fakeBin); - await git(repositoryPath, ['init']); - await git(repositoryPath, [ - 'remote', - 'add', - 'origin', - 'git@gitlab.example.com:group/project.git', - ]); - await writeFakeExecutable(fakeBin, 'glab', '#!/bin/sh\nprintf "release\\n"\n'); - process.env.PATH = `${fakeBin}:${previousPath ?? ''}`; - - expect(resolveBaseBranchRef(repositoryPath)).toEqual({ - base: 'release', - ref: 'origin/release', - remote: 'origin', - }); - } finally { - process.env.PATH = previousPath; - await rm(directory, { force: true, recursive: true }); - } + await using temporaryDirectory = await createTemporaryDirectory('codiff-base-gitlab-'); + const repositoryPath = join(temporaryDirectory.directory, 'repo'); + const fakeBin = join(temporaryDirectory.directory, 'bin'); + + await mkdir(repositoryPath); + await mkdir(fakeBin); + await git(repositoryPath, ['init']); + await git(repositoryPath, [ + 'remote', + 'add', + 'origin', + 'git@gitlab.example.com:group/project.git', + ]); + await writeFakeExecutable(fakeBin, 'glab', '#!/bin/sh\nprintf "release\\n"\n'); + using pathOverride = overridePath(`${fakeBin}:${process.env.PATH ?? ''}`); + void pathOverride; + + expect(resolveBaseBranchRef(repositoryPath)).toEqual({ + base: 'release', + ref: 'origin/release', + remote: 'origin', + }); }); test('resolveBaseBranchRef falls back to the remote default branch without a request', async () => { - const directory = await realpath(await mkdtemp(join(tmpdir(), 'codiff-base-fallback-'))); - const repositoryPath = join(directory, 'repo'); - const fakeBin = join(directory, 'bin'); - const previousPath = process.env.PATH; - - try { - await mkdir(repositoryPath); - await mkdir(fakeBin); - await git(repositoryPath, ['init']); - await git(repositoryPath, ['config', 'user.email', 'codiff@example.com']); - await git(repositoryPath, ['config', 'user.name', 'Codiff Test']); - await git(repositoryPath, ['remote', 'add', 'origin', 'git@github.com:owner/repo.git']); - await git(repositoryPath, ['commit', '--allow-empty', '-m', 'init']); - const { stdout } = await execFileAsync('git', ['-C', repositoryPath, 'rev-parse', 'HEAD'], { - encoding: 'utf8', - }); - await git(repositoryPath, ['update-ref', 'refs/remotes/origin/main', stdout.trim()]); - await writeFakeExecutable(fakeBin, 'gh', '#!/bin/sh\nexit 1\n'); - process.env.PATH = `${fakeBin}:${previousPath ?? ''}`; - - expect(resolveBaseBranchRef(repositoryPath)).toEqual({ - base: 'main', - ref: 'origin/main', - remote: 'origin', - }); - } finally { - process.env.PATH = previousPath; - await rm(directory, { force: true, recursive: true }); - } + await using temporaryDirectory = await createTemporaryDirectory('codiff-base-fallback-'); + const repositoryPath = join(temporaryDirectory.directory, 'repo'); + const fakeBin = join(temporaryDirectory.directory, 'bin'); + + await mkdir(repositoryPath); + await mkdir(fakeBin); + await git(repositoryPath, ['init']); + await git(repositoryPath, ['config', 'user.email', 'codiff@example.com']); + await git(repositoryPath, ['config', 'user.name', 'Codiff Test']); + await git(repositoryPath, ['remote', 'add', 'origin', 'git@github.com:owner/repo.git']); + await git(repositoryPath, ['commit', '--allow-empty', '-m', 'init']); + const { stdout } = await execFileAsync('git', ['-C', repositoryPath, 'rev-parse', 'HEAD'], { + encoding: 'utf8', + }); + await git(repositoryPath, ['update-ref', 'refs/remotes/origin/main', stdout.trim()]); + await writeFakeExecutable(fakeBin, 'gh', '#!/bin/sh\nexit 1\n'); + using pathOverride = overridePath(`${fakeBin}:${process.env.PATH ?? ''}`); + void pathOverride; + + expect(resolveBaseBranchRef(repositoryPath)).toEqual({ + base: 'main', + ref: 'origin/main', + remote: 'origin', + }); }); test('parseArguments accepts nested GitLab merge request URLs', () => { @@ -548,20 +559,16 @@ test('packaged terminal helper forwards GitLab MR markers to Electron', async () }); test('packaged terminal helper runs `base` through the bundled Node entry point', async () => { - const logger = await createFakeCommandLogger('codiff-packaged-base-', 'runtime'); + await using logger = await createDisposableFakeCommandLogger('codiff-packaged-base-', 'runtime'); - try { - await execFileAsync(resolve('bin/codiff-app'), ['base'], { - env: { - ...logger.env, - CODIFF_NODE_COMMAND: logger.commandPath, - }, - }); + await execFileAsync(resolve('bin/codiff-app'), ['base'], { + env: { + ...logger.env, + CODIFF_NODE_COMMAND: logger.commandPath, + }, + }); - expect(await logger.readArgs()).toEqual([resolve('bin/codiff.js'), 'base']); - } finally { - await logger.cleanup(); - } + expect(await logger.readArgs()).toEqual([resolve('bin/codiff.js'), 'base']); }); test('packaged terminal helper forwards HEAD^1 to Electron as a commit', async () => {