Skip to content
Open
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 @@ -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

Expand Down
154 changes: 154 additions & 0 deletions __tests__/installer-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}-`));
Expand Down Expand Up @@ -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);
},
);
});
35 changes: 34 additions & 1 deletion src/installer/targets/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,46 @@ export function readJsonFile(filePath: string): Record<string, any> {
}
}

/**
* 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 `<path>.tmp.<pid>`, 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 });
Expand Down