diff --git a/src/agent/utils.ts b/src/agent/utils.ts index 016a6ac1..ee52d2c8 100644 --- a/src/agent/utils.ts +++ b/src/agent/utils.ts @@ -521,8 +521,16 @@ function formatGitSection(command: string, output: string): string { ); } +/** + * Matches the two-character status field `git status --short` puts in front of + * each path. The field is only one character wide on the first line of a + * trimmed run, because `runGit` strips the leading space of an unstaged-only + * status such as " M openwiki/.last-update.json". + */ +const GIT_STATUS_LINE_PATTERN = /^[ !?ACDMRTU]{1,2} (.+)$/u; + function isUpdateMetadataStatusLine(line: string): boolean { - const statusPath = line.length > 3 ? line.slice(3).trim() : line.trim(); + const statusPath = (GIT_STATUS_LINE_PATTERN.exec(line)?.[1] ?? line).trim(); const normalizedPath = statusPath.replace(/\\/gu, "/"); return ( diff --git a/test/update-noop.test.ts b/test/update-noop.test.ts index 632cd4d0..ca556854 100644 --- a/test/update-noop.test.ts +++ b/test/update-noop.test.ts @@ -62,6 +62,21 @@ describe("getUpdateNoopStatus", () => { expect(status.shouldSkip).toBe(true); }); + test("detects a no-op when only the committed run metadata is dirty", async () => { + // A committed wiki leaves openwiki/.last-update.json tracked, so the next + // run sees it as an unstaged modification: " M openwiki/.last-update.json". + const repo = await createRepoWithOpenWiki(); + await writeLastUpdate(repo, "0".repeat(40)); + await git(repo, ["add", "openwiki/.last-update.json"]); + await git(repo, ["commit", "-m", "record update"]); + const head = await git(repo, ["rev-parse", "HEAD"]); + await writeLastUpdate(repo, head); + + const status = await getUpdateNoopStatus(repo); + + expect(status.shouldSkip).toBe(true); + }); + test("does not skip update when the worktree has uncommitted changes", async () => { const repo = await createRepoWithOpenWiki(); const head = await git(repo, ["rev-parse", "HEAD"]);