diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 941d92cdbff..aab69de6a12 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -997,6 +997,52 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.equal(status.aheadOfDefaultCount, 1); }), ); + + it.effect("reports combined staged and unstaged edits to the same file", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "feature.ts", "// line one\n"); + yield* git(cwd, ["add", "feature.ts"]); + yield* git(cwd, ["commit", "-m", "add feature"]); + yield* writeTextFile(cwd, "feature.ts", "// line one\n// line two\n"); + yield* git(cwd, ["add", "feature.ts"]); + yield* writeTextFile(cwd, "feature.ts", "// line one\n// line two\n// line three\n"); + + const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); + + assert.equal(status.isRepo, true); + assert.equal(status.hasWorkingTreeChanges, true); + const file = status.workingTree.files.find((f) => f.path === "feature.ts"); + assert.ok(file); + // HEAD has 1 line. Staged has 2 lines (+1). Unstaged has 3 lines (+2 from HEAD). + // Combined net from HEAD: +2 insertions. + assert.equal(file.insertions, 2); + assert.equal(file.deletions, 0); + }), + ); + + it.effect("reports staged file counts on unborn HEAD without failing", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(cwd, "initial.ts", "// first file\n"); + yield* git(cwd, ["add", "initial.ts"]); + + const status = yield* driver.statusDetails(cwd); + + assert.equal(status.isRepo, true); + assert.equal(status.workingTree.files.length, 1); + const file = status.workingTree.files[0]; + if (file) { + assert.equal(file.path, "initial.ts"); + assert.equal(file.insertions, 1); + } + }), + ); }); describe("refName operations", () => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index f739c98da29..73d9b6b0147 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -415,6 +415,12 @@ function isMissingGitCwdError(error: GitCommandError): boolean { function isNonRepositoryGitStderr(stderr: string): boolean { return stderr.toLowerCase().includes("not a git repository"); } +function isUnbornHeadStderr(stderr: string): boolean { + return ( + stderr.toLowerCase().includes("unknown revision") && + stderr.toLowerCase().includes("path not in the working tree") + ); +} interface Trace2Monitor { readonly env: NodeJS.ProcessEnv; @@ -1511,27 +1517,73 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); } - const [unstagedNumstatStdout, stagedNumstatStdout, defaultRefResult, hasPrimaryRemote] = - yield* Effect.all( - [ - runGitStdout("GitVcsDriver.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), - runGitStdout("GitVcsDriver.statusDetails.stagedNumstat", cwd, [ - "diff", - "--cached", - "--numstat", - ]), - executeGit( - "GitVcsDriver.statusDetails.defaultRef", - cwd, - ["symbolic-ref", "refs/remotes/origin/HEAD"], - { - allowNonZeroExit: true, - }, - ), - originRemoteExists(cwd).pipe(Effect.orElseSucceed(() => false)), - ], - { concurrency: "unbounded" }, - ); + const [numstatStdout, defaultRefResult, hasPrimaryRemote] = yield* Effect.all( + [ + executeGitWithStableDiagnostics( + "GitVcsDriver.statusDetails.numstat", + cwd, + ["diff", "HEAD", "--numstat"], + { allowNonZeroExit: true }, + ).pipe( + Effect.flatMap((result) => { + if (result.exitCode === 0) return Effect.succeed(result.stdout); + if (isUnbornHeadStderr(result.stderr)) { + return Effect.map( + Effect.all([ + runGitStdout("GitVcsDriver.statusDetails.numstat.unborn", cwd, [ + "diff", + "--numstat", + ]), + runGitStdout("GitVcsDriver.statusDetails.numstat.unborn.staged", cwd, [ + "diff", + "--cached", + "--numstat", + ]), + ]), + ([unstagedStdout, stagedStdout]) => { + const staged = parseNumstatEntries(stagedStdout); + const unstaged = parseNumstatEntries(unstagedStdout); + const map = new Map(); + for (const entry of [...staged, ...unstaged]) { + const existing = map.get(entry.path) ?? { + insertions: 0, + deletions: 0, + }; + existing.insertions += entry.insertions; + existing.deletions += entry.deletions; + map.set(entry.path, existing); + } + return Array.from(map.entries()) + .map(([p, s]) => `${s.insertions}\t${s.deletions}\t${p}`) + .join("\n"); + }, + ); + } + return Effect.fail( + new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.statusDetails.numstat", + cwd, + args: ["diff", "HEAD", "--numstat"], + }), + detail: "git diff HEAD --numstat failed.", + exitCode: result.exitCode, + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ); + }), + ), + executeGit( + "GitVcsDriver.statusDetails.defaultRef", + cwd, + ["symbolic-ref", "refs/remotes/origin/HEAD"], + { allowNonZeroExit: true }, + ), + originRemoteExists(cwd).pipe(Effect.orElseSucceed(() => false)), + ], + { concurrency: "unbounded" }, + ); const statusStdout = statusResult.stdout; const defaultBranch = defaultRefResult.exitCode === 0 @@ -1592,14 +1644,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : yield* computeAheadCountAgainstBase(cwd, refName).pipe(Effect.orElseSucceed(() => 0)); } - const stagedEntries = parseNumstatEntries(stagedNumstatStdout); - const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); + const numstatEntries = parseNumstatEntries(numstatStdout); const fileStatMap = new Map(); - for (const entry of [...stagedEntries, ...unstagedEntries]) { - const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 }; - existing.insertions += entry.insertions; - existing.deletions += entry.deletions; - fileStatMap.set(entry.path, existing); + for (const entry of numstatEntries) { + fileStatMap.set(entry.path, { insertions: entry.insertions, deletions: entry.deletions }); } let insertions = 0;