diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index ba471de07..21edeef8b 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -459,6 +459,37 @@ const x = 1; expect(shortstat).toEqual({ additions: 1, deletions: 0 }); }); + it("includes untracked file lines in shortstat additions", async () => { + setupRemoteTrackingMain(repoDir, tempDir); + execSync("git checkout -b feature", { cwd: repoDir }); + commitFile(repoDir, "committed.txt", "one\n", "add committed"); + writeFileSync(join(repoDir, "untracked.txt"), "line1\nline2\nline3\n"); + + const shortstat = await getCheckoutShortstat(repoDir); + + expect(shortstat).toEqual({ additions: 4, deletions: 0 }); + }); + + it("reports untracked-only additions when no tracked changes exist", async () => { + setupRemoteTrackingMain(repoDir, tempDir); + writeFileSync(join(repoDir, "newfile.txt"), "a\nb\n"); + + const shortstat = await getCheckoutShortstat(repoDir); + + expect(shortstat).toEqual({ additions: 2, deletions: 0 }); + }); + + it("counts empty untracked files as 0 additions", async () => { + setupRemoteTrackingMain(repoDir, tempDir); + execSync("git checkout -b feature", { cwd: repoDir }); + commitFile(repoDir, "committed.txt", "one\n", "add committed"); + writeFileSync(join(repoDir, "empty.txt"), ""); + + const shortstat = await getCheckoutShortstat(repoDir); + + expect(shortstat).toEqual({ additions: 1, deletions: 0 }); + }); + it("uses the merge-base for shortstat when a feature branch diverged from its tracked remote", async () => { setupRemoteTrackingMain(repoDir, tempDir); execSync("git checkout -b feature", { cwd: repoDir }); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 226934906..30bd4e358 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1504,6 +1504,41 @@ function parseCheckoutShortstat(text: string): CheckoutShortstat | null { return { additions, deletions }; } +const UNTRACKED_SHORTSTAT_MAX_FILES = 500; + +async function countUntrackedAdditions(cwd: string): Promise { + try { + const { stdout } = await runGitCommand(["ls-files", "--others", "--exclude-standard"], { + cwd, + envOverlay: READ_ONLY_GIT_ENV, + }); + const files = stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + + let additions = 0; + for (const file of files.slice(0, UNTRACKED_SHORTSTAT_MAX_FILES)) { + const absolutePath = resolve(cwd, file); + try { + const metadata = await statFile(absolutePath); + if (metadata.size > PER_FILE_DIFF_MAX_BYTES) continue; + if (await isLikelyBinaryFile(absolutePath)) continue; + const content = await readFile(absolutePath, "utf-8"); + if (content.length === 0) continue; + const normalized = content.replace(/\r\n/g, "\n"); + const lineCount = normalized.split("\n").length; + additions += normalized.endsWith("\n") ? lineCount - 1 : lineCount; + } catch { + // Skip unreadable files. + } + } + return additions; + } catch { + return 0; + } +} + async function getCheckoutShortstatUncached( cwd: string, context?: CheckoutContext, @@ -1545,11 +1580,23 @@ async function getCheckoutShortstatUncached( return null; } - const { stdout } = await runGitCommand(["diff", "--shortstat", mergeBase], { - cwd, - envOverlay: READ_ONLY_GIT_ENV, - }); - return parseCheckoutShortstat(stdout); + const [{ stdout }, untrackedAdditions] = await Promise.all([ + runGitCommand(["diff", "--shortstat", mergeBase], { + cwd, + envOverlay: READ_ONLY_GIT_ENV, + }), + countUntrackedAdditions(cwd), + ]); + + const tracked = parseCheckoutShortstat(stdout); + + if (tracked) { + return { additions: tracked.additions + untrackedAdditions, deletions: tracked.deletions }; + } + if (untrackedAdditions > 0) { + return { additions: untrackedAdditions, deletions: 0 }; + } + return null; } catch { return null; }