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
12 changes: 12 additions & 0 deletions .fork/customizations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,24 @@
Fork Workflow section pointing to .fork/AGENTS.md, which defines the
branch model (work from custom, never touch main) and the placement
ladder for fork changes.
CLAUDE.md is a Tier-4 inline edit the fork owns outright, and it
deliberately diverges from the blob upstream ships. Upstream's committed
symlink target carries a trailing newline, so it names a file that cannot
exist and CLAUDE.md resolves to nothing — upstream fixed that in 5e13f5357
and reintroduced it in 6891c77d3, so it recurs. The fork's blob is the
9-byte target with no newline. Resolve any sync conflict on CLAUDE.md by
keeping the fork's side; taking upstream's restores the bug and silently
strips the rules from every Claude agent. It is the one Tier-4 edit that
cannot carry the usual begin/end fence comments: a symlink's blob is its
target verbatim, so a comment marker inside it would break the very link
the marker exists to describe. The watch entry is the whole fence.
tier: 4
files:
- .fork/AGENTS.md
shadows: []
watch:
- AGENTS.md
- CLAUDE.md
verify:
- apps/web/src/__fork_guards__/forkWorkflowDocs.test.ts

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
51 changes: 47 additions & 4 deletions apps/web/src/__fork_guards__/forkWorkflowDocs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@
* AGENTS.md is upstream-owned, so an upstream rewrite could silently drop the
* fenced Fork Workflow section in a "clean" sync — and with it, every future
* agent's knowledge of the branch rules. Fail loudly instead.
*
* CLAUDE.md is watched for the same reason and owned by the fork: its blob
* deliberately differs from upstream's, which carries a trailing newline that
* breaks the link. Never resolve a sync conflict on it by taking upstream.
*/

import * as NodeChildProcess from "node:child_process";
import * as NodeFS from "node:fs";
import * as NodePath from "node:path";
import * as NodeURL from "node:url";
Expand All @@ -26,9 +31,47 @@ describe("fork guard: fork-workflow-docs", () => {
});

it("keeps CLAUDE.md aliased to AGENTS.md so Claude agents get the same rules", () => {
const link = NodeFS.lstatSync(NodePath.join(repoRoot, "CLAUDE.md"));
expect(link.isSymbolicLink()).toBe(true);
// The committed symlink blob carries a trailing newline in its target.
expect(NodeFS.readlinkSync(NodePath.join(repoRoot, "CLAUDE.md")).trim()).toBe("AGENTS.md");
// Assert the committed object, not the working tree. What an agent gets
// is whatever the clone materializes from this blob, and a checkout with
// core.symlinks=false writes a regular file instead — so a working-tree
// check reds against a perfectly correct commit. The blob is also where
// the bug lives: no trim, because a symlink's target is its blob
// verbatim, and a trailing newline aims it at a filename that cannot
// exist. That is exactly the state the old `.trim()` assertion
// normalized away, holding its value while upstream fixed the newline in
// 5e13f5357 and reintroduced it in 6891c77d3.
const lsTree = NodeChildProcess.execSync("git ls-tree HEAD -- CLAUDE.md", {
cwd: repoRoot,
encoding: "utf8",
});
expect(lsTree.split(/\s+/u)[0]).toBe("120000");
const target = NodeChildProcess.execSync("git cat-file -p HEAD:CLAUDE.md", {
cwd: repoRoot,
encoding: "utf8",
});
expect(target).toBe("AGENTS.md");
});

it("resolves CLAUDE.md to the rules rather than merely pointing at them", () => {
// The promise in the manifest is that an agent opening CLAUDE.md learns
// the fork rules. That is a statement about what reading it produces, not
// about how the link is spelled, so read through it. This covers what the
// committed-blob check above cannot: a conflicted or dirty working tree,
// where HEAD still holds the fork's good blob.
const claudePath = NodePath.join(repoRoot, "CLAUDE.md");
if (!NodeFS.lstatSync(claudePath).isSymbolicLink()) {
// core.symlinks=false checkout — git wrote the target as a regular
// file. Read-through is not a property this checkout can have, and the
// commit is still correct, so assert the fallback shape rather than a
// false red.
expect(NodeFS.readFileSync(claudePath, "utf8")).toBe("AGENTS.md");
return;
}
// Precondition, so a broken link fails as an assertion rather than an
// ENOENT thrown out of readFileSync.
expect(NodeFS.existsSync(claudePath)).toBe(true);
const claude = NodeFS.readFileSync(claudePath, "utf8");
const agents = NodeFS.readFileSync(NodePath.join(repoRoot, "AGENTS.md"), "utf8");
expect(claude).toBe(agents);
});
});
Loading