Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -55,18 +55,53 @@ beforeEach(() => {

describe("getChangedFiles", () => {
it("returns changed file list from git diff", () => {
mockedExecFileSync.mockReturnValue("src/index.ts\nsrc/utils.ts\n");
mockedExecFileSync.mockReturnValue("src/index.ts\0src/utils.ts\0");

const result = getChangedFiles("/project", "abc123");

expect(result).toEqual(["src/index.ts", "src/utils.ts"]);
expect(mockedExecFileSync).toHaveBeenCalledWith(
"git",
["diff", "abc123..HEAD", "--name-only"],
["diff", "abc123..HEAD", "--name-only", "-z"],
{ cwd: "/project", encoding: "utf-8" },
);
});

it("detects non-ASCII and quoted/spaced paths via NUL-terminated output", () => {
// git with -z emits unquoted, NUL-terminated paths (no C-quoting).
mockedExecFileSync.mockReturnValue("uni-café.txt\0with space.txt\0");

const result = getChangedFiles("/project", "abc123");

expect(result).toEqual(["uni-café.txt", "with space.txt"]);
// The -z flag must be passed so git does not C-quote non-ASCII paths.
expect(mockedExecFileSync).toHaveBeenCalledWith(
"git",
["diff", "abc123..HEAD", "--name-only", "-z"],
{ cwd: "/project", encoding: "utf-8" },
);
});

it("tolerates paths containing literal newlines (the -z motivation)", () => {
// A path with an embedded newline is exactly what split("\n") would have
// corrupted; -z + split("\0") must keep it intact as a single token.
mockedExecFileSync.mockReturnValue("weird\nname.txt\0other.txt\0");

const result = getChangedFiles("/project", "abc123");

expect(result).toEqual(["weird\nname.txt", "other.txt"]);
});

it("preserves leading and trailing whitespace in path tokens", () => {
// git -z emits raw path bytes; tokens must not be trimmed, otherwise
// legitimate filenames with surrounding spaces/tabs are corrupted.
mockedExecFileSync.mockReturnValue(" leading.txt\0trailing.txt \0\ttabbed.txt\0");

const result = getChangedFiles("/project", "abc123");

expect(result).toEqual([" leading.txt", "trailing.txt ", "\ttabbed.txt"]);
});

it("returns empty array when no changes", () => {
mockedExecFileSync.mockReturnValue("");

Expand All @@ -88,7 +123,7 @@ describe("getChangedFiles", () => {

describe("isStale", () => {
it("returns stale when files have changed", () => {
mockedExecFileSync.mockReturnValue("src/index.ts\n");
mockedExecFileSync.mockReturnValue("src/index.ts\0");

const result = isStale("/project", "abc123");

Expand Down
20 changes: 15 additions & 5 deletions understand-anything-plugin/packages/core/src/staleness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,24 @@ export function getChangedFiles(
lastCommitHash: string,
): string[] {
try {
const output = execFileSync('git', ['diff', `${lastCommitHash}..HEAD`, '--name-only'], {
// -z makes git emit NUL-terminated, unquoted paths. Without it git
// C-quotes any path containing non-ASCII bytes (e.g. `uni-café.txt`
// becomes `"uni-caf\303\251.txt"`), which never matches the stored
// filePath and silently skips incremental updates for that file.
//
// This parser assumes --name-only, where each NUL-terminated token is a
// single path. Do NOT switch to --name-status or -M/-C without rewriting
// this: under -z those modes emit multi-token entries (e.g. a rename is
// `R100\0old\0new\0`), and naive splitting would treat the status prefix
// and the old path as bogus changed files.
const output = execFileSync('git', ['diff', `${lastCommitHash}..HEAD`, '--name-only', '-z'], {
cwd: projectDir,
encoding: "utf-8",
});
return output
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
// Split on NUL only. git -z preserves raw path bytes (including any
// leading/trailing whitespace), so we must NOT trim tokens. The final
// NUL produces an empty trailing token, dropped by the length filter.
return output.split("\0").filter((line) => line.length > 0);
} catch {
return [];
}
Expand Down
Loading