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
13 changes: 11 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ library plus CoreFoundation/Security.
Run from the repo root:

```bash
zig build test --summary all # unit tests (~3s, 289 at last count)
zig build test --summary all # unit tests (~3s, 292 at last count)
zig build # debug binary → zig-out/bin/lcc
zig build -Doptimize=ReleaseFast # what PATH should be serving
zig build run -- list # run without installing
Expand Down Expand Up @@ -110,7 +110,16 @@ Do not "simplify" `build.zig`'s separate `test_mod`: reusing the executable's mo
- **Interactive commands need a tty.** `src/prompt.zig` puts the terminal in raw mode and
returns `Error.NotATerminal` otherwise, so any path that reaches a picker fails when run
from a tool call. Exercise the non-interactive paths instead: `lcc start PE-N --json`,
`lcc issue show PE-N --json`, `lcc list --local`, `--yes` on the destructive ones.
`lcc issue show PE-N --json`, `lcc list --local`, `--yes` on the destructive ones. A
picker's *rows* are still reachable: they are built by pure functions over a row struct
(`cellsFor` / `rowLine` / `headerLine` in `src/commands/remove.zig`), which is where a
layout is tested and where a throwaway `std.debug.print` can show you a frame.
- **Every line a picker prints has to be paid for in `pageSize`.** `prompt.checkbox` and
`prompt.search` erase their frame by walking the cursor up `screen.lines`, so a frame taller
than the terminal scrolls and every later redraw eats a line of scrollback instead. The
`reserved` argument to `pageSize` is what keeps the two in step — `checkbox` passes 5 when
it has a column header and 4 when it does not. Add a line to a frame without raising it and
the list looks fine until the terminal is short.
- **Keychain and code signing are coupled.** The Linear token's ACL is keyed on the binary's
code signature, so `build.zig` signs the installed binary to keep one "Always Allow"
valid across rebuilds. Removing or bypassing that (`-Dsign=none`) brings back a login
Expand Down
37 changes: 30 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -512,9 +512,31 @@ Uses the same worktree picker, then launches Xcode instead of Claude. It looks f

## Branch cleanup

`lcc remove` opens one checkbox list of every non-main worktree. Select any number with Space and press Enter; lcc inspects the whole selection, shows one combined confirmation, then removes it in one run. Nothing starts checked, so pressing Enter on an untouched list is a no-op. `-y` skips the combined confirmation but still lets you choose the worktrees. A failure or an unsaved Xcode document keeps that worktree without stopping the rest of the selection.
`lcc remove` opens one checkbox list of every non-main worktree. Select any number with Space and press Enter; lcc shows one combined confirmation, then removes the lot in one run. Nothing starts checked, so pressing Enter on an untouched list is a no-op. `-y` skips the combined confirmation but still lets you choose the worktrees. A failure or an unsaved Xcode document keeps that worktree without stopping the rest of the selection.

It deletes each selected branch along with its worktree — but only when the commits survive somewhere else. Three things count as safe:
Every worktree is inspected *before* the list is drawn, not after you have picked, because which ones you want gone is the question the list is there to answer:

```
$ lcc remove
? Select worktrees to remove (space toggles, enter confirms):
BRANCH MERGE STATUS AGE FREES SPENT PATH
❯ ◯ feature/pe-101-shipped merged #412 clean 3d 1.1 GB 53M ~/…/worktrees/pe-101-shipped lcc
◯ feature/pe-256-app-hangs 3 unmerged 2 dirty 2h 839 MB 18M ~/…/worktrees/pe-256-app-hangs lcc — open in Xcode
◯ 9f2c11ab (detached) — clean — — — ~/…/spike prunable
0/3 selected · space toggles · enter confirms · esc cancel
```

- **MERGE** — the verdict below: `merged`, `merged #412`, `remote gone`, or the number of commits that only exist here. `--keep-branch` leaves the branch alone, so the column is `—`.
- **STATUS** — uncommitted changes in the worktree, in `lcc list`'s words: `clean`, `2 dirty`, or `missing` when the directory is no longer there to ask.
- **AGE** — how long ago the branch was last committed to.
- **FREES** — disk the removal hands back: Xcode build data, plus the session transcripts when `--sessions` says they go too.
- **SPENT** — what Claude Code has spent in that worktree, same number `lcc stats` reports.

The path is followed by the same flags `lcc list` prints (`locked`, `prunable`, `lcc`) and by `— open in Xcode` / `— unsaved in Xcode`. That last one is the reason the inspection happens up front: a worktree with unsaved editor work is held back after you tick it, and it is better to know before.

The confirmation that follows spells the same facts out per worktree, one line each, and adds `changes 2 uncommitted (lost if the removal is forced)` for anything the `STATUS` column called dirty — git refuses to remove such a worktree, and the answer to that refusal is a prompt offering `--force`.

`lcc remove` deletes each selected branch along with its worktree — but only when the commits survive somewhere else. Three things count as safe:

- The branch is an ancestor of the default branch (an ordinary merge).
- The branch was pushed and its upstream is now `[gone]` — what a squash-merged PR looks like locally, where ancestry can never prove the commits survived.
Expand All @@ -533,15 +555,16 @@ The delete itself goes through `git branch -d` for a plain merge, so git gets to
```
$ lcc remove --merged
? Select what to remove (space toggles, enter confirms):
❯ ◉ feature/pe-101-shipped merged 2.4 GB 53M ~/…/.lcc/worktrees/pe-101-shipped
◉ feature/pe-102-squashed merged #412 1.1 GB 18M ~/…/.lcc/worktrees/pe-102-squashed
◉ feature/pe-103-squashed remote gone — — branch only — no worktree left
BRANCH MERGE STATUS AGE FREES SPENT PATH
❯ ◉ feature/pe-101-shipped merged clean 3d 2.4 GB 53M ~/…/worktrees/pe-101-shipped lcc
◉ feature/pe-102-squashed merged #412 clean 5d 1.1 GB 18M ~/…/worktrees/pe-102-squashed lcc
◉ feature/pe-103-squashed remote gone — 2w — — branch only — no worktree left
3/3 selected · space toggles · enter confirms · esc cancel
```

The reason column says which of the three vouched for the row, so a branch deleted on GitHub's word names the pull request that gave it.
The columns are the ones above. `MERGE` says which of the three vouched for the row, so a branch deleted on GitHub's word names the pull request that gave it, and a branch that outlived its worktree has nothing to report under `STATUS`.

A branch checked out anywhere — including the main worktree — is never offered, and a worktree with uncommitted changes is reported and skipped rather than forced. A merged pull request whose branch has since been reopened is not a merge: an open PR shadows the old one, and the branch is left alone. A row Xcode still has open is marked `— open in Xcode`, and one with unsaved work in it `— unsaved in Xcode`; ticking that one holds it back rather than removing it. `-y` takes everything without asking; `--force`, `--keep-branch`, `--keep-derived-data` and `--keep-xcode` mean the same as they do for a single removal.
A branch checked out anywhere — including the main worktree — is never offered, and a worktree with uncommitted changes is reported and skipped rather than forced. A merged pull request whose branch has since been reopened is not a merge: an open PR shadows the old one, and the branch is left alone. Ticking a row with unsaved Xcode work holds it back rather than removing it. `-y` takes everything without asking, and skips the list along with it; `--force`, `--keep-branch`, `--keep-derived-data` and `--keep-xcode` mean the same as they do for a single removal.

## Xcode build data

Expand Down
1 change: 1 addition & 0 deletions src/commands/clean.zig
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ fn select(app: app_mod.App, candidates: []const Candidate) ![]const Candidate {
app.gpa,
app.io,
"Select what to delete (space toggles, enter confirms):",
"",
items,
true,
) orelse std.process.exit(app_mod.cancelled_exit_code);
Expand Down
Loading
Loading