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{