diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b57b2000..6177e70bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Python classes used as values — `return SomeSerializer` from a factory method, `handler = SomeClass` aliases, registry dicts and lists, and classes passed as arguments — now produce reference edges in the graph. Previously these idioms were invisible, so on Django and Django REST Framework projects, asking for a serializer's callers or the impact of editing it missed the views that actually use it. Re-index after upgrading to pick up the new edges. (#1478) - When a file changed on disk after its last index sync, `codegraph_node` and `codegraph_explore` could return a different symbol's code under the requested name — current file bytes cut at outdated line positions — while presenting it as verbatim, trustworthy source. This hit hardest on projects queried through `projectPath` (for example, sub-projects of a monorepo), which have no live file watcher to flag pending edits. Both tools now verify each file against the index before showing sliced code: an out-of-date file is either shown whole with its full current source, or its code is withheld with a clear "changed on disk" notice — never served as a wrong slice. A fresh re-index restores normal output automatically. Thanks @inth3shadows for the thorough report and verification passes. (#1474) - The blast-radius section of `codegraph_explore` flagged "no covering tests found" whenever no test called a symbol directly — falsely branding helpers that tests exercise through their callers as untested (about 40% of flagged symbols in a measured sample). The check now follows caller chains up to 3 hops and reports indirect coverage as "tested via callers"; when nothing is found it states exactly what was checked instead of an unconditional warning. Thanks @inth3shadows for measuring the false-positive rate. (#1475) +- `codegraph install` no longer replaces symlinked config files with regular files — writes now follow the symlink and update its real target. Setups that share one `AGENTS.md` across agents via symlinks (for example `~/.claude/CLAUDE.md` and `~/.codex/AGENTS.md` both pointing at one file), and dotfiles-managed configs, keep receiving edits; installing several agents wired to the same shared file writes its guidance block exactly once. If a previous install already turned your symlink into a regular file, restore the link once and future runs will preserve it. Thanks @0x1306a94 for first diagnosing this and proposing a fix in #433. ## [1.5.0] - 2026-07-21 diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index e6a363e96..a804ed02e 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -22,6 +22,13 @@ import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targ import { uninstallTargets, refreshTargets } from '../src/installer'; import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml'; import { cleanupLegacyHooks, writePromptHookEntry, removePromptHookEntry } from '../src/installer/targets/claude'; +import { + atomicWriteFileSync, + writeJsonFile, + upsertInstructionsEntry, + removeMarkedSection, +} from '../src/installer/targets/shared'; +import { CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END } from '../src/installer/instructions-template'; function mkTmpDir(label: string): string { return fs.mkdtempSync(path.join(os.tmpdir(), `cg-targets-${label}-`)); @@ -1897,3 +1904,150 @@ describe('Installer targets — opencode XDG config path (#535)', () => { expect(opencode.detect('global').alreadyConfigured).toBe(false); }); }); + +// --------------------------------------------------------------------------- +// symlink preservation (shared write helpers) +// +// `atomicWriteFileSync` lands a tmp file on `filePath` via `renameSync` — +// but rename replaces the destination *link itself*, not what it points to. +// Left unhandled, installing into a dotfiles-managed symlink (e.g. +// `~/.claude/CLAUDE.md` -> `~/dotfiles/claude.md`) would silently detach the +// link and leave a plain file behind, so future dotfiles edits stop +// reaching the file the agent actually reads. These tests pin the fix: +// every write must follow the link to its real target, the same way a +// plain `writeFileSync` would. +// +// POSIX-only: symlink creation needs elevated privileges on Windows (see +// the repo's Windows-gated-tests convention). +// --------------------------------------------------------------------------- +describe('symlink preservation (shared write helpers)', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkTmpDir('symlink'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it.runIf(process.platform !== 'win32')('atomicWriteFileSync writes through a symlink, preserving the link', () => { + const realDir = path.join(tmpDir, 'real'); + const linkDir = path.join(tmpDir, 'link'); + fs.mkdirSync(realDir, { recursive: true }); + fs.mkdirSync(linkDir, { recursive: true }); + const realPath = path.join(realDir, 'config.md'); + const linkPath = path.join(linkDir, 'config.md'); + fs.writeFileSync(realPath, 'old'); + fs.symlinkSync(realPath, linkPath); + + atomicWriteFileSync(linkPath, 'new'); + + expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true); + expect(fs.readFileSync(realPath, 'utf-8')).toBe('new'); + // No leftover tmp files in either directory. + expect(fs.readdirSync(linkDir).some((f) => f.includes('.tmp.'))).toBe(false); + expect(fs.readdirSync(realDir).some((f) => f.includes('.tmp.'))).toBe(false); + }); + + it.runIf(process.platform !== 'win32')('atomicWriteFileSync follows a symlink chain to the real target', () => { + const realPath = path.join(tmpDir, 'real.md'); + const bPath = path.join(tmpDir, 'b'); + const aPath = path.join(tmpDir, 'a'); + fs.writeFileSync(realPath, 'old'); + fs.symlinkSync(realPath, bPath); + fs.symlinkSync(bPath, aPath); + + atomicWriteFileSync(aPath, 'chained'); + + expect(fs.lstatSync(aPath).isSymbolicLink()).toBe(true); + expect(fs.lstatSync(bPath).isSymbolicLink()).toBe(true); + expect(fs.readFileSync(realPath, 'utf-8')).toBe('chained'); + }); + + it.runIf(process.platform !== 'win32')('atomicWriteFileSync creates the target of a dangling symlink', () => { + const realDir = path.join(tmpDir, 'real'); + const realPath = path.join(realDir, 'notyet.md'); + const linkPath = path.join(tmpDir, 'link.md'); + // realDir doesn't exist yet — the symlink target is unreachable. + fs.symlinkSync(realPath, linkPath); + expect(fs.existsSync(realDir)).toBe(false); + + atomicWriteFileSync(linkPath, 'created through dangling link'); + + expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true); + expect(fs.readFileSync(realPath, 'utf-8')).toBe('created through dangling link'); + }); + + it.runIf(process.platform !== 'win32')('writeJsonFile writes through a symlink, preserving the link', () => { + const realPath = path.join(tmpDir, 'real.json'); + const linkPath = path.join(tmpDir, 'link.json'); + fs.writeFileSync(realPath, '{}\n'); + fs.symlinkSync(realPath, linkPath); + + writeJsonFile(linkPath, { foo: 'bar' }); + + expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true); + expect(JSON.parse(fs.readFileSync(realPath, 'utf-8'))).toEqual({ foo: 'bar' }); + }); + + it.runIf(process.platform !== 'win32')( + 'reproduces the reported case: a dotfiles-managed CLAUDE.md symlink keeps user content across install/uninstall', + () => { + const dotfilesDir = path.join(tmpDir, 'dotfiles'); + fs.mkdirSync(dotfilesDir, { recursive: true }); + const realPath = path.join(dotfilesDir, 'claude.md'); + const linkPath = path.join(tmpDir, 'CLAUDE.md'); + const userContent = '# My CLAUDE.md\n\nSome personal notes I keep in dotfiles.'; + fs.writeFileSync(realPath, userContent + '\n'); + fs.symlinkSync(realPath, linkPath); + + const first = upsertInstructionsEntry(linkPath); + expect(first.action).toBe('updated'); + expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true); + const afterFirst = fs.readFileSync(realPath, 'utf-8'); + expect(afterFirst).toContain(CODEGRAPH_SECTION_START); + expect(afterFirst).toContain(userContent); + + const second = upsertInstructionsEntry(linkPath); + expect(second.action).toBe('unchanged'); + expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true); + + const removeResult = removeMarkedSection(linkPath, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END); + expect(removeResult).toBe('removed'); + expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true); + const afterRemove = fs.readFileSync(realPath, 'utf-8'); + expect(afterRemove).not.toContain(CODEGRAPH_SECTION_START); + expect(afterRemove).toContain(userContent); + }, + ); + + it.runIf(process.platform !== 'win32')( + 'two agents symlinked to one shared AGENTS.md get exactly one block: the second upsert is unchanged', + () => { + // Multi-select install: several targets' instructions files are + // symlinks to one shared AGENTS.md. Now that writes resolve to the + // shared target, the marker-based upsert must dedupe across + // targets — same guarantee gemini.ts documents for Gemini + + // Antigravity sharing GEMINI.md, extended through symlinks. + const sharedPath = path.join(tmpDir, 'AGENTS.md'); + const userContent = '# Shared agent instructions'; + fs.writeFileSync(sharedPath, userContent + '\n'); + const claudeLink = path.join(tmpDir, 'CLAUDE.md'); + const codexLink = path.join(tmpDir, 'codex-AGENTS.md'); + fs.symlinkSync(sharedPath, claudeLink); + fs.symlinkSync(sharedPath, codexLink); + + const first = upsertInstructionsEntry(claudeLink); + expect(first.action).toBe('updated'); + const second = upsertInstructionsEntry(codexLink); + expect(second.action).toBe('unchanged'); + + const content = fs.readFileSync(sharedPath, 'utf-8'); + expect(content.split(CODEGRAPH_SECTION_START).length - 1).toBe(1); + expect(content).toContain(userContent); + expect(fs.lstatSync(claudeLink).isSymbolicLink()).toBe(true); + expect(fs.lstatSync(codexLink).isSymbolicLink()).toBe(true); + }, + ); +}); diff --git a/src/installer/targets/shared.ts b/src/installer/targets/shared.ts index 364f40427..af1cc2ac4 100644 --- a/src/installer/targets/shared.ts +++ b/src/installer/targets/shared.ts @@ -72,13 +72,46 @@ export function readJsonFile(filePath: string): Record { } } +/** + * Follow a symlink chain to the path a write should land on. + * + * `renameSync` replaces the destination *link itself* rather than its + * target, so an atomic write aimed at a symlinked config (e.g. a + * dotfiles-managed CLAUDE.md) would silently swap the link for a + * regular file and detach it from the user's dotfiles. Resolving + * first gives the temp-file-plus-rename the same follow-the-link + * semantics a plain `writeFileSync` has. + * + * `fs.realpathSync` alone can't do this: it throws on dangling links, + * and writing through a dangling link (creating its target) must keep + * working. Hence the manual walk. The 32-hop cap mirrors typical + * kernel ELOOP limits; on a loop we just write to the last path seen. + */ +function resolveWriteTarget(filePath: string): string { + let target = filePath; + for (let i = 0; i < 32; i++) { + let st: fs.Stats; + try { + st = fs.lstatSync(target); + } catch { + return target; // end of chain — target doesn't exist yet + } + if (!st.isSymbolicLink()) return target; + target = path.resolve(path.dirname(target), fs.readlinkSync(target)); + } + return target; +} + /** * Write a file atomically: write to `.tmp.`, then rename. * * Prevents corruption if the process crashes mid-write. The temp - * file is cleaned up on rename failure. + * file is cleaned up on rename failure. Follows symlinks: the write + * lands on the link's target, like plain `writeFileSync`, instead of + * replacing the link itself. */ export function atomicWriteFileSync(filePath: string, content: string): void { + filePath = resolveWriteTarget(filePath); const dir = path.dirname(filePath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true });