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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 0.8.2 - Unreleased

- Fixed non-Git patch audits to retain symlink edits and literal backslashes in Unix filenames without reading linked targets.
- Fixed diff-scoped review, CI, and revalidation to include both paths of committed renames, preventing features mapped to the old path from being silently skipped.

## 0.8.1 - 2026-09-13
Expand Down
4 changes: 4 additions & 0 deletions docs/patching.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ 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.

For non-Git roots explicitly enabled with `--skip-git-repo-check`, patch audits
record changes to regular files and symlinks without following linked targets.
Unix filenames retain literal backslashes in the recorded paths.

Status updates:

- validation success marks the finding `uncertain`
Expand Down
44 changes: 44 additions & 0 deletions src/change-audit.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { symlink, unlink } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
Expand Down Expand Up @@ -59,3 +60,46 @@ describe("project-scoped change audit", () => {
expect(changedPathsBetweenSnapshots(before, after)).toEqual(["src/index.ts", "src/renamed.ts"]);
});
});

describe("non-Git change audit", () => {
it.skipIf(process.platform === "win32")(
"preserves literal backslashes in source paths",
async () => {
const root = await fixtureRoot("clawpatch-audit-path-");
const state = join(root, ".clawpatch");
await writeFixture(root, "literal\\name.txt", "before\n");
const before = await sourceChangedSnapshots(root, state);
await writeFixture(root, "literal\\name.txt", "after\n");
const after = await sourceChangedSnapshots(root, state);
expect(changedPathsBetweenSnapshots(before, after)).toEqual(["literal\\name.txt"]);
},
);

it.skipIf(process.platform === "win32")(
"records symlink additions, retargeting, and deletions without following targets",
async () => {
const root = await fixtureRoot("clawpatch-audit-links-");
const external = await fixtureRoot("clawpatch-audit-external-");
const state = join(root, ".clawpatch");
await writeFixture(external, "outside.txt", "before\n");
const empty = await sourceChangedSnapshots(root, state);
const link = join(root, "link");
await symlink(external, link);
const added = await sourceChangedSnapshots(root, state);
expect(changedPathsBetweenSnapshots(empty, added)).toEqual(["link"]);
expect([...added.keys()]).toEqual(["link"]);
await writeFixture(external, "outside.txt", "after\n");
expect(
changedPathsBetweenSnapshots(added, await sourceChangedSnapshots(root, state)),
).toEqual([]);
await unlink(link);
await symlink("missing-target", link);
const retargeted = await sourceChangedSnapshots(root, state);
expect(changedPathsBetweenSnapshots(added, retargeted)).toEqual(["link"]);
await unlink(link);
expect(
changedPathsBetweenSnapshots(retargeted, await sourceChangedSnapshots(root, state)),
).toEqual(["link"]);
},
);
});
6 changes: 3 additions & 3 deletions src/change-audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,12 @@ async function collectSnapshotPaths(
continue;
}
const info = await lstat(full).catch(() => null);
if (info === null || info.isSymbolicLink()) {
if (info === null) {
continue;
}
if (info.isDirectory()) {
await collectSnapshotPaths(root, full, relativeStateDir, paths);
} else if (info.isFile()) {
} else if (info.isFile() || info.isSymbolicLink()) {
paths.add(path);
}
}
Expand All @@ -120,5 +120,5 @@ function shouldSkipSnapshotPath(path: string, relativeStateDir: string): boolean
}

function normalizePath(path: string): string {
return path.replace(/\\/gu, "/").replace(/\/$/u, "");
return process.platform === "win32" ? path.replace(/\\/gu, "/") : path;
}
Loading