Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions docs/patching.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
61 changes: 61 additions & 0 deletions src/change-audit.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
25 changes: 12 additions & 13 deletions src/change-audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
const paths = await sourceChangedPaths(root, stateDir);
Expand All @@ -13,7 +13,7 @@ export async function hasSourceDirtyWorktree(root: string, stateDir: string): Pr
export async function sourceChangedSnapshots(
root: string,
stateDir: string,
): Promise<Map<string, string> | null> {
): Promise<Map<string, string>> {
const paths =
(await sourceChangedPaths(root, stateDir)) ?? (await sourceSnapshotPaths(root, stateDir));
const snapshots = new Map<string, string>();
Expand All @@ -33,18 +33,17 @@ export function changedPathsBetweenSnapshots(
}

async function sourceChangedPaths(root: string, stateDir: string): Promise<Set<string> | null> {
const result = await runCommand("git status --porcelain=v1 -z -uall", root, undefined, {
trimOutput: false,
});
if (result.exitCode !== 0) {
return null;
let paths: Set<string>;
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<string> {
Expand Down
6 changes: 2 additions & 4 deletions src/fix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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 = {
Expand Down