diff --git a/src/agent/utils.ts b/src/agent/utils.ts index 0432482d..d367ef96 100644 --- a/src/agent/utils.ts +++ b/src/agent/utils.ts @@ -114,6 +114,13 @@ export async function getUpdateNoopStatus( } if (head !== lastUpdate.gitHead) { + if (!(await commitExists(cwd, lastUpdate.gitHead))) { + return { + shouldSkip: false, + reason: `recorded git head ${lastUpdate.gitHead} not found (history rewritten or shallow clone)`, + }; + } + const committedPaths = await getChangedPathsSinceLastUpdate( cwd, lastUpdate.gitHead, @@ -346,7 +353,12 @@ async function createGitSummary( sections.push(formatGitSection("git status --short", status)); sections.push(formatGitSection("git rev-parse HEAD", head ?? "(unknown)")); - if (command === "update" && lastUpdate?.gitHead) { + const hasUsableGitHead = + command === "update" && + lastUpdate?.gitHead !== undefined && + (await commitExists(cwd, lastUpdate.gitHead)); + + if (hasUsableGitHead && lastUpdate?.gitHead) { const logSinceLastHead = await runGit(cwd, [ "log", `${lastUpdate.gitHead}..HEAD`, @@ -361,6 +373,12 @@ async function createGitSummary( ), ); } else if (command === "update" && lastUpdate?.updatedAt) { + if (lastUpdate.gitHead) { + sections.push( + `Note: recorded git head ${lastUpdate.gitHead} is no longer present (history was rewritten or commit was amended/rebased away). Falling back to time-based change evidence.`, + ); + } + const logSinceLastUpdate = await runGit(cwd, [ "log", "--since", @@ -407,6 +425,23 @@ async function getGitHead(cwd: string): Promise { return head.length > 0 ? head : undefined; } +/** + * Returns true when the given commit SHA exists in the local object database. + * Uses `git cat-file -e ^{commit}` which exits 0 only when the object is + * present and is a commit (or resolves to one). A missing or rewritten commit + * (amend/rebase + gc, or a narrow shallow clone) exits non-zero. + */ +async function commitExists(cwd: string, sha: string): Promise { + try { + await execFileAsync("git", ["--no-pager", "cat-file", "-e", `${sha}^{commit}`], { + cwd, + }); + return true; + } catch { + return false; + } +} + /** * Runs git commands without failing the whole run for normal git command errors. */ diff --git a/test/update-noop.test.ts b/test/update-noop.test.ts index e530b1d2..16308d8c 100644 --- a/test/update-noop.test.ts +++ b/test/update-noop.test.ts @@ -5,6 +5,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { describe, expect, test } from "vitest"; import { + createRunContext, getUpdateNoopStatus, shouldCheckUpdateNoop, } from "../src/agent/utils.ts"; @@ -16,6 +17,14 @@ async function git(cwd: string, args: string[]): Promise { return stdout.trim(); } +/** Removes dangling objects by expiring the reflog and running gc --prune=now. */ +async function gcPrune(repo: string): Promise { + await execFileAsync("git", ["reflog", "expire", "--expire=now", "--all"], { + cwd: repo, + }); + await execFileAsync("git", ["gc", "--prune=now", "-q"], { cwd: repo }); +} + async function createRepoWithOpenWiki(): Promise { const repo = await mkdtemp(path.join(tmpdir(), "openwiki-noop-")); await git(repo, ["init"]); @@ -33,11 +42,15 @@ async function createRepoWithOpenWiki(): Promise { return repo; } -async function writeLastUpdate(repo: string, gitHead: string): Promise { +async function writeLastUpdate( + repo: string, + gitHead: string, + updatedAt?: string, +): Promise { await writeFile( path.join(repo, "openwiki", ".last-update.json"), `${JSON.stringify({ - updatedAt: new Date().toISOString(), + updatedAt: updatedAt ?? new Date().toISOString(), command: "update", gitHead, model: "test-model", @@ -119,3 +132,138 @@ describe("shouldCheckUpdateNoop", () => { expect(shouldCheckUpdateNoop({ userMessage: " " })).toBe(true); }); }); + +describe("getUpdateNoopStatus — missing base commit (rewrite/amend scenario)", () => { + test("does not skip update when the recorded gitHead no longer exists", async () => { + const repo = await createRepoWithOpenWiki(); + const absentSha = "0000000000000000000000000000000000000000"; + await writeLastUpdate(repo, absentSha); + + const status = await getUpdateNoopStatus(repo); + + expect(status.shouldSkip).toBe(false); + expect(status.reason).toMatch(/not found/); + expect(status.reason).toContain(absentSha); + }); + + test("does not skip update when the recorded gitHead was amended and gc'd away", async () => { + const repo = await createRepoWithOpenWiki(); + const originalHead = await git(repo, ["rev-parse", "HEAD"]); + + // Amend the commit so the original SHA becomes dangling. + await writeFile( + path.join(repo, "README.md"), + "# Test Repo\nAmended\n", + "utf8", + ); + await git(repo, ["add", "README.md"]); + await git(repo, ["commit", "--amend", "--no-edit"]); + + // Remove the dangling object so commitExists definitely returns false. + await gcPrune(repo); + + await writeLastUpdate(repo, originalHead); + + const status = await getUpdateNoopStatus(repo); + + expect(status.shouldSkip).toBe(false); + expect(status.reason).toMatch(/not found/); + expect(status.reason).toContain(originalHead); + }); + + test("does not skip when missing gitHead in metadata", async () => { + const repo = await createRepoWithOpenWiki(); + // Write metadata with gitHead omitted (e.g., older format or local-wiki metadata). + await writeFile( + path.join(repo, "openwiki", ".last-update.json"), + `${JSON.stringify({ + updatedAt: new Date().toISOString(), + command: "update", + model: "test-model", + })}\n`, + "utf8", + ); + + const status = await getUpdateNoopStatus(repo); + + expect(status.shouldSkip).toBe(false); + expect(status.reason).toContain("missing previous update git head"); + }); +}); + +describe("createRunContext git summary — present base commit (happy path)", () => { + test("uses precise gitHead..HEAD range when recorded commit exists", async () => { + const repo = await createRepoWithOpenWiki(); + const baseHead = await git(repo, ["rev-parse", "HEAD"]); + await writeLastUpdate(repo, baseHead); + + // Add a source commit after the recorded head. + await writeFile( + path.join(repo, "README.md"), + "# Test Repo\nChanged\n", + "utf8", + ); + await git(repo, ["add", "README.md"]); + await git(repo, ["commit", "-m", "change readme"]); + + const context = await createRunContext("update", repo, "repository"); + + // Should use the exact gitHead..HEAD diff, not the time-based fallback. + expect(context.gitSummary).toContain( + `git log ${baseHead}..HEAD --name-status --oneline`, + ); + // The "no longer present" note must NOT appear. + expect(context.gitSummary).not.toMatch(/no longer present/i); + }); +}); + +describe("createRunContext git summary — missing base commit (rewrite/amend scenario)", () => { + test("does not include a fatal git error in gitSummary when recorded gitHead is absent", async () => { + const repo = await createRepoWithOpenWiki(); + const absentSha = "0000000000000000000000000000000000000000"; + await writeLastUpdate(repo, absentSha); + + const context = await createRunContext("update", repo, "repository"); + + expect(context.gitSummary).not.toMatch(/^fatal:/m); + expect(context.gitSummary).not.toContain("Invalid revision range"); + }); + + test("includes a note and time-based fallback evidence when recorded gitHead is absent", async () => { + const repo = await createRepoWithOpenWiki(); + const absentSha = "0000000000000000000000000000000000000000"; + await writeLastUpdate(repo, absentSha); + + const context = await createRunContext("update", repo, "repository"); + + expect(context.gitSummary).toContain(absentSha); + expect(context.gitSummary).toMatch(/no longer present/i); + // Falls back to git log --since which should appear in the evidence. + expect(context.gitSummary).toContain("git log --since"); + }); + + test("uses time-based fallback after a gc'd amend, no fatal error", async () => { + const repo = await createRepoWithOpenWiki(); + const originalHead = await git(repo, ["rev-parse", "HEAD"]); + + // Amend and gc so the original commit is truly gone. + await writeFile( + path.join(repo, "README.md"), + "# Test Repo\nAmended\n", + "utf8", + ); + await git(repo, ["add", "README.md"]); + await git(repo, ["commit", "--amend", "--no-edit"]); + await gcPrune(repo); + + await writeLastUpdate(repo, originalHead); + + const context = await createRunContext("update", repo, "repository"); + + expect(context.gitSummary).not.toMatch(/^fatal:/m); + expect(context.gitSummary).not.toContain("Invalid revision range"); + expect(context.gitSummary).toMatch(/no longer present/i); + expect(context.gitSummary).toContain(originalHead); + expect(context.gitSummary).toContain("git log --since"); + }); +});