Skip to content

Delete a session, and the worktree it leaves - #12

Merged
tdwd merged 7 commits into
mainfrom
session-delete-worktree
Sep 18, 2026
Merged

tdwd merged 7 commits into
mainfrom
session-delete-worktree

Conversation

@tdwd

@tdwd tdwd commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

x closed a session outright. It now opens a modal with both outcomes: Close keeps the worktree, Delete removes it and the branch.

Close is the row the cursor starts on, because it is the reversible one. The Delete row carries what the session changed — "3 files changed, 41 insertions(+)", or "no files changed" — measured by gitx.Diff against BaseRef. A confirmation that states a fact can be answered. One that states a warning can only be believed or dismissed.

Nothing is forced. git worktree remove refuses a tree holding modified or untracked files, and git branch -d refuses unmerged commits. Both refusals are the answer. The worktree is the gate and the branch is best effort: a clean tree whose branch holds unmerged commits removes fine, branch -d then refuses, and the notice names the branch that was kept.

A session that ran in the project directory skips the modal and closes as before. Its Dir is the project's own checkout, so deleteSession guards that itself rather than trusting its caller.

Commits

Each builds and passes on its own.

  1. Move worktree creation into its own file — pure move, no body changes.
  2. Correct three claims that measurement disproves — a doc comment crediting git with a refusal Deck performs, a stderr test that passed with stderr dropped, and an if whose body was a bare return as the last statement of a test.
  3. Add worktree and branch removal to gitx — every doc claim measured against a real repository.
  4. Rename connectFrom to pickerSubject — rename only.
  5. Extract stopRunner from stopCurrent — the delete path would have been the third copy.
  6. Ask what to do with the worktree when a session ends — the modal.
  7. Document ending a session, and close backlog entry 14.

Worth a reviewer's eye

forget is not atomic. RemoveSession runs before Save, so a failed save leaves the session gone from memory and still in state.json, where it returns at the next launch. Putting it back is not possible from there — RemoveSession also drops the links the session held. The doc comment and the architecture doc both say so. Making the pair atomic belongs in store, and is not in this PR.

Three things git does that were measured rather than assumed, and that shaped the design:

  • git worktree remove refuses a tree with untracked files, not only modified ones. An agent that wrote a file and never added it is enough to stop a delete.
  • git branch -d refuses a branch a worktree still has checked out, which fixes the order: worktree first, branch second.
  • A directory the user already deleted by hand is not an error. git drops the registration and reports success, so a delete finishes instead of stranding the session.

The delete tests drive real git rather than a stub. A fake that refuses when told to would prove only that it was told.

AddWorktree and ErrNoCommits leave gitx.go for worktree.go, and their
tests follow into worktree_test.go. No line of the function body changes.

Size did not force this: gitx.go was 155 lines, under the limit. The
category did. What stays in gitx.go is running git at all and the
questions asked of a repository before Deck commits to anything — where
its root is, what it has checked out, whether it has a commit yet.
Making a worktree is a different job, and RemoveWorktree needs somewhere
to land that is not the file holding the rev-parse wrappers.

ErrNoCommits travels because AddWorktree is its only producer.
ErrNotARepo stays, because RepoRoot returns it too.
Each described behaviour the code or git does not have, and each was
found by running something rather than by reading it again.

AddWorktree's doc said git refuses to reuse a populated directory.
Running `git worktree add -b feat <existing-empty-dir> HEAD` exits 0, so
git accepts an empty one. The os.Stat check in AddWorktree is Deck's own
rule and it is the stricter one: it refuses any existing path.

TestRunErrorCarriesGitStderr asserted the error mentions the branch name.
run formats its message as "git <args>: <stderr>", so the args already
carry the branch. Replacing the stderr read with err.Error() left the
test passing on "git worktree add -b main ...: exit status 255". It now
asserts the message is not the bare exit status, which is the assertion
that fails when the passthrough is removed.

TestHoldsRepos ended with an if whose body was a bare return, as the last
statement of the function. Both branches ended the test, so the one-level
property it named was asserted by nothing. HoldsRepos does stop at one
level. The test now builds a repository two levels down and checks that
its grandparent is refused.
RemoveWorktree runs `git worktree remove` and DeleteBranch runs `git
branch -d`. Neither forces, which is the whole design: git's own refusals
are the answer rather than an obstacle to work around.

Every claim in the doc comments was measured against a real repository
rather than read off the manual.

  - A tree holding modified or untracked files is refused. Untracked is
    the case that catches people: an agent that wrote a file and never
    added it leaves the tree dirty, with nothing in `git diff` to show
    for it.
  - `git branch -d` refuses a branch a worktree still has checked out,
    which is why the order is worktree first and branch second. The
    reverse fails on every session rather than on the ones worth
    refusing.
  - `git branch -d` measures merged-ness against the branch's upstream,
    or against HEAD when there is none. `worktree add -b` off a local
    HEAD sets none, so for a session branch it is HEAD.
  - A directory the user already deleted is not an error. git drops the
    registration and reports success, which is what lets a delete finish
    instead of stranding the session behind a tree that is already gone.

Each test was checked by stubbing both functions to return nil. All five
fail, so none of them is green on the fixture alone.

Both functions are exported with no production caller. The consumer is
the x modal in the next commit on this branch. If that does not land,
this commit comes out with it.
The field holds the session the open picker is about, captured because a
modal outlives the keystroke that opened it. That reason is not specific
to connecting, and the next commit opens a second picker that needs the
same capture for the same reason.

Rename only. No behaviour changes.
Stopping an agent and releasing what it held in the coordinator always
happen together, and the pair was written out twice. The session-delete
path would have made it three.

A claim held by a process that has exited is worse than no claim, which
is why the two belong in one call rather than side by side at every call
site.

No behaviour change. stopCurrent still reports "session is not running"
before stopping anything, and still tells the coordinator only when there
was a runner to stop.
x closed the session outright, so the key that keeps a worktree and the
key that would remove one were the same key with no step in between. It
now opens a modal holding both outcomes.

Close is the row the cursor starts on. It is the reversible one, and a
modal that opens on the destructive row turns a confirmation into a trap.

The Delete row carries what the session changed — "3 files changed, 41
insertions(+)", or "no files changed" — rather than a warning about what
is about to go. A confirmation that states a fact can be answered. One
that states a warning can only be believed or dismissed. A measurement
that fails says so in place of the figure, because a row reading "no
files changed" because git errored would state a fact it does not have.

Nothing is forced. git refuses a worktree holding modified or untracked
files and the session then stays, so the user can open it again, commit,
and delete it after. The worktree is the gate and the branch is best
effort: a clean tree whose branch holds unmerged commits removes fine,
`branch -d` then refuses, and the notice names the branch that was kept.

A session that ran in the project directory skips the modal and closes as
before. It has no worktree and no branch of its own, so Delete would have
nothing to remove. deleteSession guards that itself rather than trusting
its caller, because such a session's Dir is the project's own checkout.

The delete tests drive real git rather than a stub. The refusals are the
whole feature, and a fake that refuses when told to would prove only that
it was told.
Adds "Ending a session is two outcomes" to the architecture doc: the two
rows, why Close is the default, and the three rules the code has to keep —
nothing is forced, the worktree is the gate and the branch is best effort,
and a session in the project directory has nothing to delete.

It also says plainly that forget is not atomic. RemoveSession runs before
Save, so a failed save leaves the session gone from memory and still in
state.json. Making the pair atomic belongs in store, and saying so is
better than a doc that implies the guarantee.

Four stale claims the change exposed, each checked against the code:

  - The dead-code paragraph named RemoveWorktree and DeleteBranch as
    helpers gitx does not carry, and prescribed adding one back with the
    feature that needs it. That is exactly what happened, so the
    paragraph now records it. IsDirty, AheadBehind and HeadSubject are
    still absent.
  - The file map named picker_open.go, which was renamed to pickerctl.go.
  - "One list widget, two jobs" counted two kinds. There are four.
  - README's key table said x closes a session. It asks now.
@tdwd
tdwd merged commit 6c0efec into main Sep 18, 2026
3 checks passed
@tdwd
tdwd deleted the session-delete-worktree branch September 18, 2026 12:36
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