Skip to content

Refuse a symlinked board so each directory keeps its own - #28

Merged
than merged 2 commits into
mainfrom
refuse-symlinked-boards
Aug 31, 2026
Merged

than merged 2 commits into
mainfrom
refuse-symlinked-boards

Conversation

@than

@than than commented Aug 31, 2026

Copy link
Copy Markdown
Owner

What

sidecar init now refuses a board reached through a symlink — the board file itself, or the .sidecar/ home it sits in — and writes nothing. The CLAUDE.md note carries the matching rule, so an agent doesn't create the link in the first place.

A link points two checkouts at one file. Every session writing there lands in a single queue, and the board turns into noise.

How

  • symlinkedBoardPath uses Lstat, never Stat. A dangling link reports NotExist, so without it init falls through to scaffold and writes the board at the link's target — in another directory.
  • Three call sites: the path init was given, a legacy root SIDECAR.md before migration renames it into .sidecar/ (a symlink moves as a symlink), and the viewer's own create prompt.
  • Only the board and its .sidecar/ home are checked. A project directory behind a symlink is ordinary and still inits normally.
  • Reading is unchanged: the viewer and its watcher still follow a symlinked board that already exists, so an existing setup keeps working.

Tests

Seven new tests in init_test.go: file symlink, .sidecar/ directory symlink, dangling link, the legacy-migration hole, the viewer create path, the note carrying the rule, and a guard that a real board under a symlinked parent still inits. Full suite green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MZ6tbMtg6729spTZSRqbjw

`sidecar init` writes nothing when the board file — or the `.sidecar/`
home it sits in — is a symlink, and prints what to do instead. A link
points two checkouts at one file, so every session's work piles into a
single queue.

The check uses Lstat, never Stat: a dangling link reports NotExist, and
init would otherwise scaffold straight through it into the link's target.
It runs on the path init was given, on a legacy root SIDECAR.md before
migration renames it into `.sidecar/`, and on the viewer's own create
prompt. Only the board and its `.sidecar/` home are checked, so a project
directory behind a symlink still inits normally.

The CLAUDE.md note carries the matching rule, so an agent doesn't create
the link init refuses. Reading an existing symlinked board is unchanged —
the viewer and its watcher still follow the link.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MZ6tbMtg6729spTZSRqbjw
@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Reviewed against main. Build, go vet, and go test ./... are all green. Nothing here touches the watcher, reload/scroll-clamp, or render width, so the hard requirements are untouched.

The core is right. Lstat-never-Stat is the correct call, and for exactly the reason the comment gives: the dangling link is the dangerous case, because Stat reports NotExist and scaffold then writes the board into another directory. The three call sites cover the paths that actually write, and TestRunInitAllowsBoardUnderSymlinkedParent draws the boundary in the right place — only what sidecar creates is checked, not an ordinary symlinked project directory.

Three things worth a look.

offerCreate refuses before the TTY guard

init.go:673 puts refuseSymlinkedBoard above if !stdinIsTerminal() { return }. On a non-interactive launch, offerCreate was going to return silently and write nothing — so the refusal now prints three lines of stderr about a write that was never going to happen, ending in "nothing written." TestOfferCreateRefusesDanglingBoardSymlink runs under non-TTY stdin, so the test is asserting precisely the case that shouldn't speak.

Interactively it isn't much better: the message lands in the primary buffer, then runViewer immediately enters tea.WithAltScreen(), so the user sees it only after quitting — possibly hours later in a split pane.

Moving the call below the stdinIsTerminal check fixes both: it then fires only where a scaffold was actually imminent, and right before a prompt the user is already looking at.

A live symlinked board blocks the upgrade path, not just the write

refuseSymlinkedBoard(abs, ...) is the third statement in runInitBoard, ahead of the os.Stat(abs) == nil "already exists — leaving it untouched" branch. When the link resolves, init was never going to touch the board; the only writes on that path are CLAUDE.md, .claude/settings.json, and .git/info/exclude. So someone who linked a board on purpose (shared worktrees, dotfiles) can no longer re-run sidecar init to pick up a newer note or hook — exit 1, nothing changed, no --force.

The README says "an existing setup keeps working," but that is now true of the viewer only. If refusing outright is the intent, the message should say what it also skips. Otherwise, scoping the refusal to the cases that actually scaffold — a dangling link, or a symlinked .sidecar/ with no board inside — would let a live link fall through to the note/hook update, which is the whole point of re-running init.

A symlinked .sidecar/ still shares previous.md

Same failure the PR is guarding against, one file over. snapshotPath (diffcmd.go:110) keys per board path for custom boards, but the default board always gets a fixed .sidecar/previous.md. Two checkouts behind a linked .sidecar/ clobber each other's baseline, so the per-turn reconcile hook's sidecar diff reports another session's changes as this one's. A file-level board symlink is fine here — each checkout keeps its own real .sidecar/. init now refuses to create the directory-link case, so this only affects setups that predate the PR; a line in the README caveat is probably enough, no code needed.

Nit

claudeNote says "Never symlink %[1]s or its directory." With a custom path (sidecar init notes.md), rel is notes.md and "its directory" is the project root — which symlinkedBoardPath deliberately doesn't check, since it only looks at a parent named .sidecar. The rule reads broader than the code enforces.

Error handling and scope look good otherwise — refuseSymlinkedBoard degrades sensibly when Readlink or Getwd fails, and the Lstat gate on the legacy branch correctly runs before os.Rename moves a symlink as a symlink.

…skips

Move the symlink check in offerCreate below the terminal guard. A piped
launch never prompts and never scaffolds, so the refusal had nothing to
announce there — and its three lines landed in the primary buffer just
before the viewer's alt screen hid them. Its test now asserts the silence
when stdin isn't a terminal, and the refusal when it is.

Say what else init leaves alone. A live symlinked board also skips the
CLAUDE.md note and the reconcile hook, so re-running init upgrades
nothing until the link is gone; the message and the README now state
that instead of implying the board is the only thing untouched. The
README also names the shared previous.md a linked .sidecar/ leaves
behind, which is what makes `sidecar diff` report another session's
changes as this one's.

Name .sidecar/ in the CLAUDE.md rule. "Its directory" read as the
project root for a custom board path — a directory the check never
looks at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MZ6tbMtg6729spTZSRqbjw
@than

than commented Aug 31, 2026

Copy link
Copy Markdown
Owner Author

Three of the four are in 66e4bd9; the second one I'm keeping as-is, with the message and README fixed instead.

offerCreate before the TTY guard — moved below it. One correction to the premise: stdinIsTerminal tests ModeCharDevice, and /dev/null is a character device, so under go test the guard was reached and the test asserted a real refusal. It would have gone the other way under a pipe. The test now asserts silence when stdinIsTerminal() is false and the refusal when it's true, so it holds either way.

A live symlinked board blocks the upgrade path — intended, and staying. The point of the refusal is that the link itself is the problem; letting a re-run quietly upgrade the note and hook around it leaves the shared queue in place, which is what this PR exists to stop. What was wrong was the wording. The message now ends with "Remove the symlink, then run sidecar init again — the CLAUDE.md note and the reconcile hook stay as they are until you do," and the README says init writes nothing at all rather than implying only the board is skipped.

Shared previous.md — confirmed at diffcmd.go:110: the default board keys to a fixed .sidecar/previous.md, so a linked .sidecar/ clobbers the baseline while a file-level board link doesn't. It's in the README caveat as the concrete reason sidecar diff reports another session's changes as this one's. No code, as suggested.

Nit — the rule now names .sidecar/ instead of "its directory," so it no longer implies the project root is checked.

@than
than merged commit e72da7f into main Aug 31, 2026
1 check passed
@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Solid, focused PR. The core reasoning is right: Lstat not Stat is the correct call, and the dangling link genuinely is the dangerous case — scaffold would have MkdirAll'd and written into another directory. The watcher, renderer, and scroll/reload paths are untouched, so none of the hard requirements are at risk. go vet and the full suite are green here.

Three things, none blocking.

a. A dangling legacy SIDECAR.md link now blocks init, and never could have hurt

The new pre-check refuses on any symlink at root/SIDECAR.md, but both things that consume that path already gate on os.Stat:

  • migrateLegacyBoard returns early when os.Stat(legacy) fails, so a dangling link is never renamed.
  • The --keep-board branch retargets only when os.Stat(legacy) succeeds.

So a leftover broken SIDECAR.md link — target deleted long ago — is inert to both, yet sidecar init now exits 1 and writes nothing, describing a shared queue that does not exist. The only remedy is deleting a link that points nowhere. That is the opposite of the board-file case, where the dangling link is exactly what has to be caught.

To make the pre-check match what the code below it can actually do, gate the legacy call on os.Stat succeeding (a live link is the sharing hazard) while keeping Lstat for the board itself. A live legacy link still refuses, which is the case that matters — migration would move the link itself, and --keep-board would wire the note and hook to a shared board.

b. The offerCreate refusal is printed straight into the alt screen

main calls offerCreate(abs) and then runViewer, which starts with tea.WithAltScreen(). The four-line refusal goes to stderr and is covered before anyone reads it; the user gets a bare waiting screen with no explanation and finds the message only after quitting. runInitBoard's own needsAttention machinery exists precisely to avoid this, and the comment there says so.

The wait is also not obviously going to end: watchFile watches the board's parent directory, and the link target lives in a different one, so creating that target fires no event.

Cheapest fix is to let the caller decide — have offerCreate return a bool and have main exit non-zero instead of entering the alt screen, or move the refusal somewhere its output survives.

c. Minor

  • In the isDefaultTarget branch, filepath.Join(root, sidecarDirName, "sidecar.md") is exactly abs (root is filepath.Dir(filepath.Dir(abs))). os.Lstat(abs) says the same thing without the reader having to re-derive it.
  • !strings.HasPrefix(rel, "..") in refuseSymlinkedBoard also rejects a legitimate relative name like ..hidden.md, falling back to the absolute path. Cosmetic only — rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) is the precise form.

Tests

Good coverage, and TestRunInitAllowsBoardUnderSymlinkedParent is the right guard to have written — it pins down that a project directory behind a link stays ordinary. One caveat: TestOfferCreateRefusesDanglingBoardSymlink only asserts the refusal when stdinIsTerminal() happens to be true, which depends on how the test binary's stdin is wired. Calling refuseSymlinkedBoard (or symlinkedBoardPath) directly in a second test would make that coverage unconditional.

@than
than deleted the refuse-symlinked-boards branch August 31, 2026 17:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant