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
37 changes: 36 additions & 1 deletion src/agent/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`,
Expand All @@ -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",
Expand Down Expand Up @@ -407,6 +425,23 @@ async function getGitHead(cwd: string): Promise<string | undefined> {
return head.length > 0 ? head : undefined;
}

/**
* Returns true when the given commit SHA exists in the local object database.
* Uses `git cat-file -e <sha>^{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<boolean> {
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.
*/
Expand Down
152 changes: 150 additions & 2 deletions test/update-noop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -16,6 +17,14 @@ async function git(cwd: string, args: string[]): Promise<string> {
return stdout.trim();
}

/** Removes dangling objects by expiring the reflog and running gc --prune=now. */
async function gcPrune(repo: string): Promise<void> {
await execFileAsync("git", ["reflog", "expire", "--expire=now", "--all"], {
cwd: repo,
});
await execFileAsync("git", ["gc", "--prune=now", "-q"], { cwd: repo });
}

async function createRepoWithOpenWiki(): Promise<string> {
const repo = await mkdtemp(path.join(tmpdir(), "openwiki-noop-"));
await git(repo, ["init"]);
Expand All @@ -33,11 +42,15 @@ async function createRepoWithOpenWiki(): Promise<string> {
return repo;
}

async function writeLastUpdate(repo: string, gitHead: string): Promise<void> {
async function writeLastUpdate(
repo: string,
gitHead: string,
updatedAt?: string,
): Promise<void> {
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",
Expand Down Expand Up @@ -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");
});
});