From 2aa98850e403cf39d1d3abb53d6f226f0abd8f1b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 12 Sep 2026 16:24:09 -0700 Subject: [PATCH] fix: scope patch audits to the selected project root --- CHANGELOG.md | 2 ++ docs/patching.md | 4 +++ src/change-audit.test.ts | 61 ++++++++++++++++++++++++++++++++++++++++ src/change-audit.ts | 25 ++++++++-------- src/fix.ts | 6 ++-- 5 files changed, 81 insertions(+), 17 deletions(-) create mode 100644 src/change-audit.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b17af09..7e3cfd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.8.1 - Unreleased +- Fixed nested-project repairs to ignore their own state and sibling changes, fingerprint project-relative source paths, and record both sides of renames. + - Updated Zod and development tooling, aligned Node typings with the Node 22 floor, and added Node 22/24 runtime CI with pinned GitHub Actions. - Updated workflow and architecture docs to match current providers, explicit PR creation, validation order, and stale-lock recovery. diff --git a/docs/patching.md b/docs/patching.md index 446f8c4..bf5c849 100644 --- a/docs/patching.md +++ b/docs/patching.md @@ -23,6 +23,10 @@ Current behavior: - records command results - links the patch attempt to the finding +When `--root` selects a subdirectory of a Git repository, dirty checks and patch +file records are scoped to that project. Its state directory and sibling-project +changes are excluded; renames record both the old and new project-relative paths. + Status updates: - validation success marks the finding `uncertain` diff --git a/src/change-audit.test.ts b/src/change-audit.test.ts new file mode 100644 index 0000000..b9f7a0a --- /dev/null +++ b/src/change-audit.test.ts @@ -0,0 +1,61 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + changedPathsBetweenSnapshots, + hasSourceDirtyWorktree, + sourceChangedSnapshots, +} from "./change-audit.js"; +import { runCommandArgs } from "./exec.js"; +import { fixtureRoot, writeFixture } from "./test-helpers.js"; + +async function nestedProject() { + const root = await fixtureRoot("clawpatch-nested-audit-"); + await writeFixture(root, "app/src/index.ts", "export const value = 1;\n"); + await writeFixture(root, "sibling.txt", "original\n"); + for (const args of [ + ["init", "-q"], + ["add", "."], + [ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "-qm", + "fixture", + ], + ]) { + const result = await runCommandArgs("git", args, root); + expect(result.exitCode, result.stderr).toBe(0); + } + return { root, project: join(root, "app"), state: join(root, "app/.clawpatch") }; +} + +describe("project-scoped change audit", () => { + it("ignores nested project state and changes outside the selected project", async () => { + const { root, project, state } = await nestedProject(); + await writeFixture(root, "app/.clawpatch/run.json", "{}\n"); + await writeFixture(root, "sibling.txt", "unrelated change\n"); + expect(await hasSourceDirtyWorktree(project, state)).toBe(false); + expect(await sourceChangedSnapshots(project, state)).toEqual(new Map()); + }); + + it("fingerprints nested source contents relative to the selected project", async () => { + const { root, project, state } = await nestedProject(); + await writeFixture(root, "app/src/index.ts", "export const value = 2;\n"); + const before = await sourceChangedSnapshots(project, state); + await writeFixture(root, "app/src/index.ts", "export const value = 3;\n"); + const after = await sourceChangedSnapshots(project, state); + expect(await hasSourceDirtyWorktree(project, state)).toBe(true); + expect(changedPathsBetweenSnapshots(before, after)).toEqual(["src/index.ts"]); + }); + + it("records both sides of a staged rename using project-relative paths", async () => { + const { project, state } = await nestedProject(); + const before = await sourceChangedSnapshots(project, state); + const moved = await runCommandArgs("git", ["mv", "src/index.ts", "src/renamed.ts"], project); + expect(moved.exitCode, moved.stderr).toBe(0); + const after = await sourceChangedSnapshots(project, state); + expect(changedPathsBetweenSnapshots(before, after)).toEqual(["src/index.ts", "src/renamed.ts"]); + }); +}); diff --git a/src/change-audit.ts b/src/change-audit.ts index b33785c..c36ce1d 100644 --- a/src/change-audit.ts +++ b/src/change-audit.ts @@ -2,8 +2,8 @@ import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; import { lstat, readdir, readlink } from "node:fs/promises"; import { relative, resolve } from "node:path"; -import { runCommand } from "./exec.js"; -import { parseGitStatus } from "./git-status.js"; +import { ClawpatchError } from "./errors.js"; +import { dirtyFiles } from "./git.js"; export async function hasSourceDirtyWorktree(root: string, stateDir: string): Promise { const paths = await sourceChangedPaths(root, stateDir); @@ -13,7 +13,7 @@ export async function hasSourceDirtyWorktree(root: string, stateDir: string): Pr export async function sourceChangedSnapshots( root: string, stateDir: string, -): Promise | null> { +): Promise> { const paths = (await sourceChangedPaths(root, stateDir)) ?? (await sourceSnapshotPaths(root, stateDir)); const snapshots = new Map(); @@ -33,18 +33,17 @@ export function changedPathsBetweenSnapshots( } async function sourceChangedPaths(root: string, stateDir: string): Promise | null> { - const result = await runCommand("git status --porcelain=v1 -z -uall", root, undefined, { - trimOutput: false, - }); - if (result.exitCode !== 0) { - return null; + let paths: Set; + try { + paths = await dirtyFiles(root); + } catch (error) { + if (error instanceof ClawpatchError && error.code === "git-failure") { + return null; + } + throw error; } const relativeStateDir = normalizePath(relative(root, stateDir)); - return new Set( - parseGitStatus(result.stdout) - .map((change) => change.primaryPath) - .filter((path) => path.length > 0 && !isStatePath(path, relativeStateDir)), - ); + return new Set([...paths].filter((path) => !isStatePath(path, relativeStateDir))); } async function pathFingerprint(root: string, path: string): Promise { diff --git a/src/fix.ts b/src/fix.ts index 32bee0b..316b501 100644 --- a/src/fix.ts +++ b/src/fix.ts @@ -80,8 +80,7 @@ export async function fixCommand( } await writePatchAttempt(loaded.paths, initialPatch); const startedAt = nowIso(); - const beforeChanged = - (await sourceChangedSnapshots(loaded.root, loaded.paths.stateDir)) ?? new Map(); + const beforeChanged = await sourceChangedSnapshots(loaded.root, loaded.paths.stateDir); let plan: FixPlanOutput; try { plan = await provider.fix(loaded.root, prompt, providerOptions(config)); @@ -120,8 +119,7 @@ export async function fixCommand( }), ); } - const afterChanged = - (await sourceChangedSnapshots(loaded.root, loaded.paths.stateDir)) ?? new Map(); + const afterChanged = await sourceChangedSnapshots(loaded.root, loaded.paths.stateDir); const filesChanged = changedPathsBetweenSnapshots(beforeChanged, afterChanged); const failed = commandsRun.some((result) => result.exitCode !== 0); const patch: PatchAttempt = {