fix: include untracked files in checkout shortstat (#608) (#762)

Count lines in untracked files when computing branch diffstat.
Adds file size limit, empty file handling, and CRLF normalization.
This commit is contained in:
Somasundaram Ayyappan
2026-05-05 21:10:54 +08:00
committed by GitHub
parent 425aa20747
commit 9f01b4b7a2
2 changed files with 83 additions and 5 deletions
@@ -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 });
+52 -5
View File
@@ -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<number> {
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;
}