Fix committed diff excluding dirty worktree

This commit is contained in:
Mohamed Boudra
2026-04-21 15:00:08 +07:00
parent ebf8a21c08
commit 00870740ef
2 changed files with 116 additions and 29 deletions

View File

@@ -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 });

View File

@@ -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<CheckoutFileChange[]> {
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<Map<string, FileStat>> {
const result = await runGitCommand(
buildGitDiffArgs({
ignoreWhitespace,
extra: ["--numstat", ref],
extra: ["--numstat", ...getCheckoutDiffRefArgs(refs)],
}),
{
cwd,
@@ -1507,10 +1519,10 @@ export async function getCheckoutDiff(
): Promise<CheckoutDiffResult> {
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<string, FileStat>();
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);
},
})
: [];