diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d732f6..db27b6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/patching.md b/docs/patching.md index 706c1cf..ee2e85a 100644 --- a/docs/patching.md +++ b/docs/patching.md @@ -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` diff --git a/src/change-audit.test.ts b/src/change-audit.test.ts index b9f7a0a..fa0308d 100644 --- a/src/change-audit.test.ts +++ b/src/change-audit.test.ts @@ -1,3 +1,4 @@ +import { symlink, unlink } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { @@ -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"]); + }, + ); +}); diff --git a/src/change-audit.ts b/src/change-audit.ts index c36ce1d..7a3aff2 100644 --- a/src/change-audit.ts +++ b/src/change-audit.ts @@ -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); } } @@ -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; }