Skip to content
Merged
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
36 changes: 35 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions internal/gitx/gitx.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
28 changes: 28 additions & 0 deletions internal/gitx/gitx_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
74 changes: 74 additions & 0 deletions internal/ui/projectfiles.go
Original file line number Diff line number Diff line change
@@ -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...)
}
Loading
Loading