From 00870740efd8f8f9d1adbc3f8cdab14f2f8d1578 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 21 Apr 2026 15:00:08 +0700 Subject: [PATCH] Fix committed diff excluding dirty worktree --- .../server/src/utils/checkout-git.test.ts | 65 +++++++++++++++ packages/server/src/utils/checkout-git.ts | 80 ++++++++++++------- 2 files changed, 116 insertions(+), 29 deletions(-) diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index f151dadff..05a6002aa 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -615,6 +615,41 @@ const x = 1; expect(diff.diff).toContain("feature.txt"); }); + it("does not include dirty working tree changes in base mode", async () => { + writeFileSync(join(repoDir, "file.txt"), "dirty\n"); + writeFileSync(join(repoDir, "untracked.txt"), "untracked\n"); + + const diff = await getCheckoutDiff(repoDir, { + mode: "base", + baseRef: "main", + includeStructured: true, + }); + + expect(diff.diff).toBe(""); + expect(diff.structured).toEqual([]); + }); + + it("shows committed branch changes without dirty working tree changes in base mode", async () => { + execSync("git checkout -b feature", { cwd: repoDir }); + writeFileSync(join(repoDir, "feature.txt"), "feature\n"); + execSync("git add feature.txt", { cwd: repoDir }); + execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { cwd: repoDir }); + + writeFileSync(join(repoDir, "file.txt"), "dirty\n"); + writeFileSync(join(repoDir, "untracked.txt"), "untracked\n"); + + const diff = await getCheckoutDiff(repoDir, { + mode: "base", + baseRef: "main", + includeStructured: true, + }); + + expect(diff.diff).toContain("feature.txt"); + expect(diff.diff).not.toContain("file.txt"); + expect(diff.diff).not.toContain("untracked.txt"); + expect(diff.structured?.map((file) => file.path)).toEqual(["feature.txt"]); + }); + it("warms shortstat cache in the background without blocking listing callers", async () => { expect(getCachedCheckoutShortstat(repoDir)).toBeUndefined(); @@ -1524,6 +1559,36 @@ const x = 1; expect(baseDiff.diff).not.toContain("file.txt"); }); + it("excludes dirty working tree changes from Paseo worktree base diffs", async () => { + const worktree = await createLegacyWorktreeForTest({ + branchName: "feature", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "dirty-feature", + paseoHome, + }); + + writeFileSync(join(worktree.worktreePath, "feature.txt"), "feature\n"); + execSync("git add feature.txt", { cwd: worktree.worktreePath }); + execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { + cwd: worktree.worktreePath, + }); + + writeFileSync(join(worktree.worktreePath, "file.txt"), "dirty\n"); + writeFileSync(join(worktree.worktreePath, "untracked.txt"), "untracked\n"); + + const baseDiff = await getCheckoutDiff( + worktree.worktreePath, + { mode: "base", includeStructured: true }, + { paseoHome }, + ); + + expect(baseDiff.diff).toContain("feature.txt"); + expect(baseDiff.diff).not.toContain("file.txt"); + expect(baseDiff.diff).not.toContain("untracked.txt"); + expect(baseDiff.structured?.map((file) => file.path)).toEqual(["feature.txt"]); + }); + it("resolves the repository default branch from origin HEAD", async () => { execSync("git checkout -b develop", { cwd: repoDir }); execSync("git checkout main", { cwd: repoDir }); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index d719b5bd9..65109f918 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -103,6 +103,16 @@ type CheckoutFileChange = { isUntracked?: boolean; }; +type CheckoutDiffRefs = { + baseRef: string; + targetRef?: string; + includeUntracked: boolean; +}; + +function getCheckoutDiffRefArgs(refs: CheckoutDiffRefs): string[] { + return [refs.baseRef, ...(refs.targetRef ? [refs.targetRef] : [])]; +} + function normalizeBranchSuggestionName(raw: string): string | null { const trimmed = raw.trim(); if (!trimmed) { @@ -358,7 +368,7 @@ export async function checkoutResolvedBranch( async function listCheckoutFileChanges( cwd: string, - ref: string, + refs: CheckoutDiffRefs, ignoreWhitespace = false, ): Promise { const changes: CheckoutFileChange[] = []; @@ -366,7 +376,7 @@ async function listCheckoutFileChanges( const { stdout: nameStatusOut } = await runGitCommand( buildGitDiffArgs({ ignoreWhitespace, - extra: ["--name-status", ref], + extra: ["--name-status", ...getCheckoutDiffRefArgs(refs)], }), { cwd, env: READ_ONLY_GIT_ENV }, ); @@ -405,24 +415,26 @@ async function listCheckoutFileChanges( }); } - const { stdout: untrackedOut } = await runGitCommand( - ["ls-files", "--others", "--exclude-standard"], - { - cwd, - env: READ_ONLY_GIT_ENV, - }, - ); - for (const file of untrackedOut - .split("\n") - .map((l) => l.trim()) - .filter(Boolean)) { - changes.push({ - path: file, - status: "U", - isNew: true, - isDeleted: false, - isUntracked: true, - }); + if (refs.includeUntracked) { + const { stdout: untrackedOut } = await runGitCommand( + ["ls-files", "--others", "--exclude-standard"], + { + cwd, + env: READ_ONLY_GIT_ENV, + }, + ); + for (const file of untrackedOut + .split("\n") + .map((l) => l.trim()) + .filter(Boolean)) { + changes.push({ + path: file, + status: "U", + isNew: true, + isDeleted: false, + isUntracked: true, + }); + } } // Deduplicate by path (prefer tracked status over untracked marker if both appear). @@ -495,13 +507,13 @@ const TRACKED_MAX_CHANGED_LINES = 40_000; async function getTrackedNumstatByPath( cwd: string, - ref: string, + refs: CheckoutDiffRefs, ignoreWhitespace = false, ): Promise> { const result = await runGitCommand( buildGitDiffArgs({ ignoreWhitespace, - extra: ["--numstat", ref], + extra: ["--numstat", ...getCheckoutDiffRefArgs(refs)], }), { cwd, @@ -1507,10 +1519,10 @@ export async function getCheckoutDiff( ): Promise { await requireGitRepo(cwd); - let refForDiff: string; + let refsForDiff: CheckoutDiffRefs; if (compare.mode === "uncommitted") { - refForDiff = "HEAD"; + refsForDiff = { baseRef: "HEAD", includeUntracked: true }; } else { const configured = await getConfiguredBaseRefForCwd(cwd, context); const baseRef = configured.baseRef ?? compare.baseRef ?? (await resolveBaseRef(cwd)); @@ -1522,11 +1534,15 @@ export async function getCheckoutDiff( } const bestBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef); - refForDiff = (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef; + refsForDiff = { + baseRef: (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef, + targetRef: "HEAD", + includeUntracked: false, + }; } const ignoreWhitespace = compare.ignoreWhitespace === true; - const changes = await listCheckoutFileChanges(cwd, refForDiff, ignoreWhitespace); + const changes = await listCheckoutFileChanges(cwd, refsForDiff, ignoreWhitespace); changes.sort((a, b) => { if (a.path === b.path) return 0; return a.path < b.path ? -1 : 1; @@ -1557,7 +1573,7 @@ export async function getCheckoutDiff( const trackedNumstatByPath = trackedChanges.length > 0 - ? await getTrackedNumstatByPath(cwd, refForDiff, ignoreWhitespace) + ? await getTrackedNumstatByPath(cwd, refsForDiff, ignoreWhitespace) : new Map(); const trackedDiffPaths: string[] = []; const trackedPlaceholderByPath = new Map< @@ -1584,7 +1600,7 @@ export async function getCheckoutDiff( const trackedDiffResult = await runGitCommand( buildGitDiffArgs({ ignoreWhitespace, - extra: [refForDiff, "--", ...trackedDiffPaths], + extra: [...getCheckoutDiffRefArgs(refsForDiff), "--", ...trackedDiffPaths], }), { cwd, @@ -1621,7 +1637,13 @@ export async function getCheckoutDiff( return null; } const refPath = change.oldPath ?? change.path; - return readGitFileContentAtRef(cwd, refForDiff, refPath); + return readGitFileContentAtRef(cwd, refsForDiff.baseRef, refPath); + }, + getNewFileContent: async (file) => { + if (!refsForDiff.targetRef) { + return null; + } + return readGitFileContentAtRef(cwd, refsForDiff.targetRef, file.path); }, }) : [];