From 40af3ae1d09b946b2edc5fc9369fe629cf9ef06b Mon Sep 17 00:00:00 2001 From: tdwd <111124579+tdwd@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:22:00 +0200 Subject: [PATCH] Carry a project's agent instructions into each worktree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `git worktree add` checks out tracked files only, so a gitignored CLAUDE.md stays behind — as this repository's own does. An isolated session started with no instructions at all and behaved differently from one run in the project directory, for a reason nothing on screen explained. linkProjectFiles symlinks CLAUDE.md, AGENTS.md and .claude into each new worktree. A link and not a copy, so the project keeps one copy of each and an edit made in any session is the edit every sibling reads. A copy would be one more thing to drift, which is the problem this is fixing. **Only a name the project ignores is linked.** The target is an absolute path on this machine, so a link git can see is a machine path one `git add -A` away from a public commit, and the thing running in that worktree is an agent. A tracked CLAUDE.md needs nothing: git carries it. Being ignored is also what keeps the worktree removable, and that was measured rather than assumed. `git worktree remove` refuses a tree holding an untracked symlink (exit 128) and accepts one holding an ignored symlink (exit 0). newSession unwinds the worktree when linking fails, so a link git could see would strand the very tree the unwind is for. TestIgnoredLinksDoNotBlockRemoval keeps that measured. What this does not do is merge the agent's own memory. Claude Code partitions transcripts and memory by absolute directory — ui.claudeSlug already mirrors the rule — so each worktree is a separate project to it. Giving sessions a shared directory is giving up the isolation that is the point of one. internal/coord is what crosses sessions. Each test was checked by stubbing linkProjectFiles to return nil. The four that assert linking fail; the two that assert nothing was linked pass, as they should. --- README.md | 10 ++ docs/architecture.md | 36 ++++- internal/gitx/gitx.go | 11 ++ internal/gitx/gitx_test.go | 28 ++++ internal/ui/projectfiles.go | 74 ++++++++++ internal/ui/projectfiles_test.go | 239 +++++++++++++++++++++++++++++++ internal/ui/sessions.go | 18 +++ 7 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 internal/ui/projectfiles.go create mode 100644 internal/ui/projectfiles_test.go diff --git a/README.md b/README.md index 334d963..d0144a3 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,16 @@ A repository with no commits yet cannot have a worktree — there is no commit t check out. Deck says so and leaves the form open with your title intact, so switching to **Project directory** is one `tab` and one `→` away. +**Your agent instructions follow the session.** `git worktree add` checks out +tracked files only, so a gitignored `CLAUDE.md` stays behind and an isolated +session would start with none of them. Deck links `CLAUDE.md`, `AGENTS.md` and +`.claude` into each new worktree — a symlink, so the project keeps one copy and +an edit made in any session is the edit every sibling reads. + +Only names your repository **ignores** are linked. A file git can see would be +a link to an absolute path on your machine, one `git add -A` away from a commit. +A tracked `CLAUDE.md` needs nothing: it travels on its own. + The project field steps with `←`/`→` and opens the full list on `↵`, so it stays usable whether you have three projects or ninety. diff --git a/docs/architecture.md b/docs/architecture.md index ad8212d..c90ca06 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,7 +56,7 @@ see its siblings. ``` main.go flags, state load, cwd registration, teardown after Run internal/naming scheming-hawk-jhgk names, branch names, slugs -internal/gitx repo root, branch, worktree add and remove +internal/gitx repo root, branch, ignore rules, worktree add and remove internal/termquery answers terminal queries for harnesses with no terminal internal/store projects + sessions, atomic JSON persistence: store.go the document, load and save @@ -92,6 +92,7 @@ internal/ui the Bubble Tea program, split by job: actions.go forms; sessions.go and projects.go do the work closing.go ending a session: close keeps the worktree, delete removes it and the branch + projectfiles.go what a worktree does not get from git runner.go agent lifecycle; agentargs.go builds its argv dashboard.go + projectlist.go / projectdetail.go / help.go session.go + chrome.go / sidebar.go / status.go / pane.go @@ -688,6 +689,39 @@ switching to the project directory is one `tab` away. Check `gitx.HasCommits` before anything else that assumes a resolvable HEAD — git's own "fatal: invalid reference: HEAD" is accurate and useless. +### A worktree does not get what git does not track (`ui/projectfiles.go`) + +`git worktree add` checks out tracked files only. A project's agent +instructions are commonly gitignored — this repository's own `CLAUDE.md` is — +so an isolated session started with none of them and behaved differently from +one run in the project directory, for a reason nothing on screen explained. + +`linkProjectFiles` symlinks `CLAUDE.md`, `AGENTS.md` and `.claude` into each new +worktree. A link and not a copy, so the project keeps one copy of each: an edit +made in any session is the edit every sibling reads, and a copy would be one +more thing to drift. The target is absolute, because a worktree lives under the +state directory and arbitrarily far from the project. + +**Only a name the project ignores is linked**, and this is the part to keep. The +target is an absolute path on this machine, so a link git can see is a machine +path one `git add -A` away from a public commit — and the thing running in that +worktree is an agent. Being ignored also keeps the tree removable: git refuses +to remove a worktree holding untracked files, and an ignored symlink does not +count. `newSession` unwinds the worktree when linking fails, and that unwind +only works because of it. `TestIgnoredLinksDoNotBlockRemoval` keeps the +measurement honest. + +Top-level names only. A `.claude` that is partly tracked arrives as a real +directory holding the tracked half, and the ignored files inside it still do not +travel. + +This does not merge the agent's own memory. Claude Code partitions transcripts +and memory by absolute directory (`ui.claudeSlug` mirrors the rule), so each +worktree is a separate project to it. Giving sessions a shared directory is +giving up the isolation that is the point of one, so the split stands. +`internal/coord` is what crosses sessions: notes, messages and claims are keyed +by session rather than by path. + ### Ending a session is two outcomes (`ui/closing.go`) `x` opens a modal rather than acting. **Close** forgets the record and leaves diff --git a/internal/gitx/gitx.go b/internal/gitx/gitx.go index f274916..3433acb 100644 --- a/internal/gitx/gitx.go +++ b/internal/gitx/gitx.go @@ -122,3 +122,14 @@ func HoldsRepos(dir string) bool { func HeadCommit(dir string) (string, error) { return run(dir, "rev-parse", "HEAD") } + +// Ignores reports whether repo's ignore rules cover name. +// +// check-ignore exits 1 for a path that is not ignored and 128 for a real +// failure, and both answer false here. The distinction does not matter to the +// one caller: it carries files into a worktree, and a repository it cannot ask +// is one it should carry nothing into. +func Ignores(repo, name string) bool { + _, err := run(repo, "check-ignore", "-q", name) + return err == nil +} diff --git a/internal/gitx/gitx_test.go b/internal/gitx/gitx_test.go index c60ad3e..f171902 100644 --- a/internal/gitx/gitx_test.go +++ b/internal/gitx/gitx_test.go @@ -189,3 +189,31 @@ func TestHoldsReposIgnoresLinksToFiles(t *testing.T) { t.Error("links to a file and to nothing were counted as repositories") } } + +// TestIgnores covers the question Deck asks before carrying a file into a +// worktree. The three answers are distinct and only two of them are "yes, it is +// safe to link": an ignored path, and nothing else. +func TestIgnores(t *testing.T) { + repo := testRepo(t) + if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("CLAUDE.md\n"), 0o644); err != nil { + t.Fatal(err) + } + for _, name := range []string{"CLAUDE.md", "NOTES.md"} { + if err := os.WriteFile(filepath.Join(repo, name), []byte("x\n"), 0o644); err != nil { + t.Fatal(err) + } + } + + if !Ignores(repo, "CLAUDE.md") { + t.Error("an ignored file was not reported as ignored") + } + if Ignores(repo, "NOTES.md") { + t.Error("an untracked file that no rule covers was reported as ignored") + } + // A directory that is not a repository cannot answer, and false is the + // safe answer: the caller carries nothing into a worktree it cannot ask + // about. + if Ignores(t.TempDir(), "CLAUDE.md") { + t.Error("a non-repository answered yes") + } +} diff --git a/internal/ui/projectfiles.go b/internal/ui/projectfiles.go new file mode 100644 index 0000000..2d5d967 --- /dev/null +++ b/internal/ui/projectfiles.go @@ -0,0 +1,74 @@ +package ui + +// The project files a worktree does not get from git, and how a session gets +// them anyway. + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/tripledownab/deck/internal/gitx" +) + +// agentInstructions are the project files a hosted agent reads to learn how to +// work in this repository. +// +// CLAUDE.md and .claude cover claude and cathode, AGENTS.md covers codex — +// the agents ui.agentChoices offers. Covering a new one is adding a name here. +var agentInstructions = []string{"CLAUDE.md", "AGENTS.md", ".claude"} + +// linkProjectFiles links the project's agent instructions into a new worktree. +// +// `git worktree add` checks out tracked files only, so anything gitignored +// stays behind — and a project's instructions are commonly gitignored, as this +// repository's own CLAUDE.md is. Without this an isolated session starts with +// no instructions at all and behaves differently from one run in the project +// directory, for a reason nothing on screen explains. +// +// A symlink rather than a copy, so the project keeps one copy of each file. An +// edit made in any session is the edit every sibling reads, which is the whole +// point: a copy would be one more thing to drift. The target is absolute +// because a worktree lives under the state directory, arbitrarily far from the +// project. +// +// A name already present in the worktree is left alone. git put it there, so it +// is tracked and travels on its own. +// +// Only a name the project's ignore rules cover is linked, and an untracked one +// they do not cover is left behind on purpose. The link's target is an absolute +// path on this machine, so a link git can see is a machine path one `git add +// -A` away from a public commit. Being ignored is also what keeps the worktree +// removable: git refuses to remove a tree holding untracked files, and an +// ignored symlink does not count — measured, because the rollback in newSession +// depends on it. +// +// Top-level names only. A .claude that is partly tracked arrives as a real +// directory holding the tracked half, and the ignored files inside it still do +// not travel. Linking into an existing directory is a different job and waits +// for someone who needs it. +func linkProjectFiles(project, worktree string) error { + var errs []error + for _, name := range agentInstructions { + src := filepath.Join(project, name) + // Stat, not Lstat: a dangling link in the project is not something to + // copy the dangle of into every session. + if _, err := os.Stat(src); err != nil { + continue + } + dst := filepath.Join(worktree, name) + // Lstat, so an existing link counts as present rather than being + // followed to whatever it points at. + if _, err := os.Lstat(dst); err == nil { + continue + } + if !gitx.Ignores(project, name) { + continue + } + if err := os.Symlink(src, dst); err != nil { + errs = append(errs, fmt.Errorf("link %s into the worktree: %w", name, err)) + } + } + return errors.Join(errs...) +} diff --git a/internal/ui/projectfiles_test.go b/internal/ui/projectfiles_test.go new file mode 100644 index 0000000..9f02462 --- /dev/null +++ b/internal/ui/projectfiles_test.go @@ -0,0 +1,239 @@ +package ui + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/tripledownab/deck/internal/gittest" + "github.com/tripledownab/deck/internal/gitx" +) + +// projectAndWorktree builds a repository that ignores the instruction files, +// and a real worktree of it. That is the arrangement the feature is for, and +// only a real worktree shows what git does and does not carry across. +func projectAndWorktree(t *testing.T) (project, worktree string) { + t.Helper() + project, _ = gittest.RepoWith(t, "a.txt", "one\n") + ignore := strings.Join(agentInstructions, "\n") + "\n" + if err := os.WriteFile(filepath.Join(project, ".gitignore"), []byte(ignore), 0o644); err != nil { + t.Fatal(err) + } + gittest.Run(t, project, "add", ".gitignore") + gittest.Run(t, project, "commit", "-q", "-m", "ignore instructions") + + worktree = filepath.Join(t.TempDir(), "wt") + if err := gitx.AddWorktree(project, worktree, "session/test"); err != nil { + t.Fatalf("AddWorktree: %v", err) + } + return project, worktree +} + +// TestLinkProjectFilesCarriesAnIgnoredFile is the case the feature exists for, +// and it is this repository's own: CLAUDE.md is gitignored here, so a worktree +// gets none of the instructions the project directory has. +// +// The fixture commits the .gitignore and leaves CLAUDE.md untracked, because +// that is what makes git leave it behind. Writing the file without ignoring it +// would prove nothing. +func TestLinkProjectFilesCarriesAnIgnoredFile(t *testing.T) { + project, worktree := projectAndWorktree(t) + if err := os.WriteFile(filepath.Join(project, "CLAUDE.md"), []byte("build with make\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(worktree, "CLAUDE.md")); err == nil { + t.Fatal("git carried the ignored file, so this test proves nothing") + } + + if err := linkProjectFiles(project, worktree); err != nil { + t.Fatalf("linkProjectFiles: %v", err) + } + + body, err := os.ReadFile(filepath.Join(worktree, "CLAUDE.md")) + if err != nil { + t.Fatalf("the session cannot read the instructions: %v", err) + } + if string(body) != "build with make\n" { + t.Errorf("read %q through the link, want the project's own text", body) + } +} + +// TestLinkProjectFilesSharesOneCopy is why it is a link and not a copy. An edit +// made in one session has to be the edit every sibling reads, or the project +// has as many sets of instructions as it has sessions. +func TestLinkProjectFilesSharesOneCopy(t *testing.T) { + project, worktree := projectAndWorktree(t) + if err := os.WriteFile(filepath.Join(project, "CLAUDE.md"), []byte("first\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := linkProjectFiles(project, worktree); err != nil { + t.Fatalf("linkProjectFiles: %v", err) + } + + // The session edits through the link. + if err := os.WriteFile(filepath.Join(worktree, "CLAUDE.md"), []byte("second\n"), 0o644); err != nil { + t.Fatal(err) + } + + body, err := os.ReadFile(filepath.Join(project, "CLAUDE.md")) + if err != nil { + t.Fatal(err) + } + if string(body) != "second\n" { + t.Errorf("the project still reads %q, so the session edited a copy", body) + } +} + +// TestLinkProjectFilesLeavesTrackedFilesAlone covers a project whose +// instructions are committed. git already put them in the worktree, and +// replacing a checked-out file with a link would take it out of the tree the +// session is supposed to be working in. +func TestLinkProjectFilesLeavesTrackedFilesAlone(t *testing.T) { + project, _ := projectAndWorktree(t) + if err := os.WriteFile(filepath.Join(project, "CLAUDE.md"), []byte("tracked\n"), 0o644); err != nil { + t.Fatal(err) + } + // -f because the fixture ignores the name. A project that commits its + // instructions is the arrangement under test, and the ignore rule is what + // every other test here needs. + gittest.Run(t, project, "add", "-f", "CLAUDE.md") + gittest.Run(t, project, "commit", "-q", "-m", "track instructions") + + worktree := filepath.Join(t.TempDir(), "wt2") + if err := gitx.AddWorktree(project, worktree, "session/second"); err != nil { + t.Fatal(err) + } + + if err := linkProjectFiles(project, worktree); err != nil { + t.Fatalf("linkProjectFiles: %v", err) + } + + info, err := os.Lstat(filepath.Join(worktree, "CLAUDE.md")) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSymlink != 0 { + t.Error("a tracked file was replaced with a link to the project's copy") + } +} + +// TestLinkProjectFilesCarriesADirectory covers .claude, which holds settings, +// commands and skills rather than one file. Linking the directory is what makes +// a later addition inside it reach every session without Deck running again. +func TestLinkProjectFilesCarriesADirectory(t *testing.T) { + project, worktree := projectAndWorktree(t) + if err := os.MkdirAll(filepath.Join(project, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + if err := linkProjectFiles(project, worktree); err != nil { + t.Fatalf("linkProjectFiles: %v", err) + } + + // Added after the link, so this asserts the link and not a snapshot of it. + if err := os.WriteFile(filepath.Join(project, ".claude", "settings.json"), []byte("{}\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(worktree, ".claude", "settings.json")); err != nil { + t.Errorf("a file added to the project after linking is not visible: %v", err) + } +} + +// TestLinkProjectFilesOnAProjectWithNone is the common case and must be quiet. +// A repository with no instructions is not a failure, and reporting one would +// put an error in front of every session on such a project. +func TestLinkProjectFilesOnAProjectWithNone(t *testing.T) { + project, worktree := projectAndWorktree(t) + + if err := linkProjectFiles(project, worktree); err != nil { + t.Fatalf("a project with no instructions reported: %v", err) + } + + entries, err := os.ReadDir(worktree) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if e.Type()&os.ModeSymlink != 0 { + t.Errorf("linked %q, which the project does not have", e.Name()) + } + } +} + +// TestLinkProjectFilesReportsAFailure keeps the rule that failures are reported +// rather than absorbed. A session that started without its instructions would +// behave differently from one in the project directory, and nothing on screen +// would explain why. +func TestLinkProjectFilesReportsAFailure(t *testing.T) { + project, worktree := projectAndWorktree(t) + if err := os.WriteFile(filepath.Join(project, "CLAUDE.md"), []byte("x\n"), 0o644); err != nil { + t.Fatal(err) + } + // A worktree nothing can be written into. Refusing the link is the only + // outcome available, so what is under test is whether it is reported. + if err := os.Chmod(worktree, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(worktree, 0o755) }) + + if err := linkProjectFiles(project, worktree); err == nil { + t.Fatal("a link that could not be made reported nothing") + } +} + +// TestLinkProjectFilesLeavesAnUnignoredFileBehind is the rule that keeps a +// machine path out of a commit. +// +// The link's target is absolute. A link git can see is one `git add -A` away +// from publishing the layout of this machine, and an agent running in the +// worktree is exactly the thing that runs `git add -A`. Being ignored is also +// what keeps the tree removable, which the rollback in newSession depends on. +func TestLinkProjectFilesLeavesAnUnignoredFileBehind(t *testing.T) { + project, worktree := projectAndWorktree(t) + // Narrow the project's ignore rules so AGENTS.md is no longer covered. + // check-ignore reads the working tree, so this needs no commit — and the + // two files then differ only in whether git can see them. + if err := os.WriteFile(filepath.Join(project, ".gitignore"), []byte("CLAUDE.md\n.claude\n"), 0o644); err != nil { + t.Fatal(err) + } + for _, name := range []string{"CLAUDE.md", "AGENTS.md"} { + if err := os.WriteFile(filepath.Join(project, name), []byte("inst\n"), 0o644); err != nil { + t.Fatal(err) + } + } + + if err := linkProjectFiles(project, worktree); err != nil { + t.Fatalf("linkProjectFiles: %v", err) + } + + if _, err := os.Lstat(filepath.Join(worktree, "AGENTS.md")); err == nil { + t.Error("linked a file git does not ignore, so a machine path is now committable") + } + // The ignored one still travels, so what refused above was the ignore rule + // and not linking that had stopped working. + if _, err := os.Lstat(filepath.Join(worktree, "CLAUDE.md")); err != nil { + t.Errorf("the ignored file did not travel: %v", err) + } +} + +// TestIgnoredLinksDoNotBlockRemoval is the measurement the rollback in +// newSession rests on. git refuses to remove a worktree holding untracked +// files, and a linked instruction file would be one of those if it were not +// ignored — so a half-finished link would strand the tree it was trying to +// unwind. +func TestIgnoredLinksDoNotBlockRemoval(t *testing.T) { + project, worktree := projectAndWorktree(t) + if err := os.WriteFile(filepath.Join(project, "CLAUDE.md"), []byte("inst\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := linkProjectFiles(project, worktree); err != nil { + t.Fatalf("linkProjectFiles: %v", err) + } + if _, err := os.Lstat(filepath.Join(worktree, "CLAUDE.md")); err != nil { + t.Fatalf("nothing was linked, so this proves nothing: %v", err) + } + + if err := gitx.RemoveWorktree(project, worktree); err != nil { + t.Fatalf("a linked instruction file blocked the rollback: %v", err) + } +} diff --git a/internal/ui/sessions.go b/internal/ui/sessions.go index bbf8df0..1c9a9f1 100644 --- a/internal/ui/sessions.go +++ b/internal/ui/sessions.go @@ -4,6 +4,7 @@ package ui // the dashboard cursor is on. Ending one is closing.go. import ( + "errors" "fmt" "path/filepath" @@ -52,6 +53,23 @@ func (m Model) newSession(projectID, title string, isolated bool, agent string) m.formProblem(err) return m, nil } + // Reported, not absorbed, and the worktree comes back out with it. An + // agent missing its instructions behaves differently from one in the + // project directory and nothing on screen would say why, and a tree + // left on disk that no session names is an orphan the user cannot see, + // retry or delete through Deck. + // + // The removal works because linkProjectFiles links only ignored names. + // git refuses to remove a tree holding untracked files, so a link it + // could see would strand the very tree this is unwinding — measured, + // and TestIgnoredLinksDoNotBlockRemoval keeps it measured. + if err := linkProjectFiles(p.Path, dir); err != nil { + if rmErr := gitx.RemoveWorktree(p.Path, dir); rmErr != nil { + err = errors.Join(err, rmErr) + } + m.formProblem(err) + return m, nil + } } sess := m.state.AddSession(store.Session{