From 0222d96e240cf3e79c393681e901bb65c7339ab9 Mon Sep 17 00:00:00 2001 From: Pfriedrix Date: Fri, 7 Aug 2026 22:50:38 +0300 Subject: [PATCH] feat(remove): say what each worktree is before it is ticked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker offered a branch name and a path, and nothing else. Whether the work had landed, whether anything was uncommitted, how much the removal hands back — the three facts that decide it — were printed in the confirmation, which is after the choice. Picking meant guessing and then reading back what you had guessed at. The list is a table now: the merge verdict, the worktree's dirty count, the age of the last commit, the disk it frees, what Claude Code spent in it, then the path with its flags and whatever Xcode is holding. The words are lcc list's, because a status that reads one way there and another way here is two things to learn instead of one. It costs a slower first paint. The verdict per branch, a git status per worktree, one GitHub call for the branches nothing local vouches for, one du and one transcript scan now cover the whole repo rather than the selection; the verdicts and the status calls share an Io.Group so the wait is roughly the slowest of them rather than their sum, and --local still asks nothing over the network. Measuring only the ticked rows stays cheaper and puts the numbers back where they cannot be used. --merged already had most of the columns, so both modes build the same row and go through the same picker rather than two that drift apart. The column header is new to prompt.checkbox and has to be reserved for in pageSize: the frame is erased by walking the cursor up screen.lines, and a line the page size does not know about scrolls the terminal instead. --- CLAUDE.md | 13 +- README.md | 37 +- src/commands/clean.zig | 1 + src/commands/remove.zig | 887 ++++++++++++++++++++++++---------------- src/prompt.zig | 14 +- 5 files changed, 580 insertions(+), 372 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 10e2bcc..73db156 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 diff --git a/README.md b/README.md index 0120906..16409b7 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/src/commands/clean.zig b/src/commands/clean.zig index 3cad72a..a2470e1 100644 --- a/src/commands/clean.zig +++ b/src/commands/clean.zig @@ -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); diff --git a/src/commands/remove.zig b/src/commands/remove.zig index 3ccab33..396b3b6 100644 --- a/src/commands/remove.zig +++ b/src/commands/remove.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const Io = std.Io; const app_mod = @import("../app.zig"); const cp = @import("../claude_projects.zig"); const dd = @import("../derived_data.zig"); @@ -38,10 +39,24 @@ const Attached = struct { } }; -const Removal = struct { - choice: app_mod.Choice, - attached: Attached = .{}, +const Row = struct { + choice: ?app_mod.Choice = null, + branch: ?[]const u8 = null, disposition: ?git.BranchDisposition = null, + attached: Attached = .{}, + dirty: ?u32 = null, + committed_at: i64 = 0, + + fn entry(self: Row) ?git.WorktreeEntry { + const choice = self.choice orelse return null; + return choice.entry; + } + + fn label(self: Row, gpa: std.mem.Allocator) ![]const u8 { + if (self.branch) |branch| return branch; + const found = self.entry() orelse return "—"; + return std.fmt.allocPrint(gpa, "{s} (detached)", .{app_mod.shortHead(found.head)}); + } }; pub fn run(app: app_mod.App, opts: Opts) !void { @@ -56,16 +71,34 @@ pub fn run(app: app_mod.App, opts: Opts) !void { return; } - const selected = try selectWorktrees(app, choices); + const rows = try app.gpa.alloc(Row, choices.len); + for (choices, 0..) |choice, i| { + rows[i] = .{ .choice = choice, .branch = choice.entry.branch }; + } + + if (!opts.keep_branch) { + try judge(app, repo, rows); + if (!opts.local) try consultGitHub(app, repo, rows); + } + try inspect(app, repo, rows); + + const dd_root = try dd.root(app.gpa, app.io, app.environ); + const cp_root = try cp.root(app.gpa, app.environ); + try attach(app, rows, opts, dd_root, cp_root); + + const selected = try select( + app, + rows, + opts, + "Select worktrees to remove (space toggles, enter confirms):", + false, + ); if (selected.len == 0) { app.ui.hint("Nothing selected.", .{}); return; } - const dd_root = try dd.root(app.gpa, app.io, app.environ); - const cp_root = try cp.root(app.gpa, app.environ); - const removals = try prepareRemovals(app, repo, selected, opts, dd_root, cp_root); - const actionable = try withoutUnsavedWork(app, removals, opts); + const actionable = try withoutUnsavedWork(app, selected, opts); if (actionable.len == 0) return; if (!opts.yes) { @@ -81,163 +114,245 @@ pub fn run(app: app_mod.App, opts: Opts) !void { try removeSelected(app, repo, actionable, opts, dd_root, cp_root); } -fn selectWorktrees(app: app_mod.App, choices: []const app_mod.Choice) ![]const app_mod.Choice { - const items = try app.gpa.alloc(prompt.Item, choices.len); - for (choices, 0..) |choice, i| { - items[i] = .{ .label = try app_mod.worktreeLabel(app.gpa, choice) }; - } +fn branchStatusTask(repo: git.Repo, out: *[]const git.BranchStatus) void { + out.* = repo.branchStatuses() catch &.{}; +} - app.ui.flush(); - const chosen = try prompt.checkbox( - app.gpa, - app.io, - "Select worktrees to remove (space toggles, enter confirms):", - items, - false, - ) orelse std.process.exit(app_mod.cancelled_exit_code); - return choicesAt(app.gpa, choices, chosen); +fn dirtyTask(repo: git.Repo, worktree_path: []const u8, out: *?u32) void { + out.* = repo.dirtyCount(worktree_path); } -fn choicesAt( - gpa: std.mem.Allocator, - choices: []const app_mod.Choice, - indices: []const usize, -) ![]const app_mod.Choice { - const subset = try gpa.alloc(app_mod.Choice, indices.len); - for (indices, 0..) |index, i| subset[i] = choices[index]; - return subset; +fn dispositionTask(repo: git.Repo, branch: []const u8, out: *?git.BranchDisposition) void { + out.* = repo.branchDisposition(branch) catch null; } -fn prepareRemovals( - app: app_mod.App, - repo: git.Repo, - selected: []const app_mod.Choice, - opts: Opts, - dd_root: []const u8, - cp_root: []const u8, -) ![]Removal { - const removals = try app.gpa.alloc(Removal, selected.len); - for (selected, 0..) |choice, i| { - removals[i] = .{ - .choice = choice, - .disposition = if (choice.entry.branch != null and !opts.keep_branch) - try repo.branchDisposition(choice.entry.branch.?) - else - null, - }; +fn judge(app: app_mod.App, repo: git.Repo, rows: []Row) !void { + var group: Io.Group = .init; + for (rows) |*row| { + const branch = row.branch orelse continue; + group.async(app.io, dispositionTask, .{ repo, branch, &row.disposition }); } + try group.await(app.io); +} - if (!opts.local and !opts.keep_branch) { - var asked: std.ArrayList([]const u8) = .empty; - for (removals) |removal| { - const disposition = removal.disposition orelse continue; - if (disposition.reason == .unmerged) try asked.append(app.gpa, disposition.branch); - } - if (asked.items.len > 0) { - if (pullRequests(app, repo, asked.items)) |prs| { - for (removals) |*removal| { - const disposition = removal.disposition orelse continue; - const number = github.mergedFor(prs, disposition.branch) orelse continue; - removal.disposition = disposition.withMergedPr(number); - } - } +fn inspect(app: app_mod.App, repo: git.Repo, rows: []Row) !void { + var statuses: []const git.BranchStatus = &.{}; + { + var group: Io.Group = .init; + group.async(app.io, branchStatusTask, .{ repo, &statuses }); + for (rows) |*row| { + const found = row.entry() orelse continue; + group.async(app.io, dirtyTask, .{ repo, found.path, &row.dirty }); } + try group.await(app.io); } - const dd_all: []dd.Entry = if (opts.keep_derived_data) - &.{} - else - try dd.list(app.gpa, app.io, dd_root); - const cp_all = try cp.list(app.gpa, app.io, cp_root); - const held: xcode.Open = if (opts.keep_xcode) .{} else try xcode.openDocuments(app.gpa, app.io); - if (held.unanswered) { - app.ui.warn("Could not ask Xcode what it has open — it may be holding some of these.", .{}); - app.ui.hint(" {s}", .{automation_hint}); + for (rows) |*row| { + const branch = row.branch orelse continue; + for (statuses) |status| { + if (!std.mem.eql(u8, status.branch, branch)) continue; + row.committed_at = status.committed_at; + break; + } } +} - const derived = try app.gpa.alloc([]dd.Entry, removals.len); - const sessions = try app.gpa.alloc([]cp.Entry, removals.len); - const in_xcode = try app.gpa.alloc(xcode.Open, removals.len); - var paths: std.ArrayList([]const u8) = .empty; +const headers = .{ + .branch = "BRANCH", + .merge = "MERGE", + .status = "STATUS", + .age = "AGE", + .frees = "FREES", + .spent = "SPENT", + .where = "PATH", +}; - for (removals, 0..) |removal, i| { - const path = removal.choice.entry.path; - derived[i] = try dd.forWorktree(app.gpa, app.io, dd_all, path); - sessions[i] = try cp.forWorktree(app.gpa, app.io, cp_all, path); - in_xcode[i] = try held.inside(app.gpa, app.io, path); - for (derived[i]) |entry| try paths.append(app.gpa, entry.path); - for (sessions[i]) |entry| try paths.append(app.gpa, entry.path); - } +const Cells = struct { + branch: []const u8, + merge: []const u8, + status: []const u8, + age: []const u8, + frees: []const u8, + spent: []const u8, + where: []const u8, + note: []const u8, +}; - if (paths.items.len > 0) { - app.ui.step("Measuring build data and sessions ({d})…", .{paths.items.len}); - app.ui.flush(); +const Widths = struct { + branch: usize = headers.branch.len, + merge: usize = headers.merge.len, + status: usize = headers.status.len, + age: usize = headers.age.len, + frees: usize = headers.frees.len, + spent: usize = headers.spent.len, +}; + +fn measure(cells: []const Cells) Widths { + var w: Widths = .{}; + for (cells) |cell| { + w.branch = @max(w.branch, ui.displayWidth(cell.branch)); + w.merge = @max(w.merge, ui.displayWidth(cell.merge)); + w.status = @max(w.status, ui.displayWidth(cell.status)); + w.age = @max(w.age, ui.displayWidth(cell.age)); + w.frees = @max(w.frees, ui.displayWidth(cell.frees)); + w.spent = @max(w.spent, ui.displayWidth(cell.spent)); } - const sizes = try disk.usage(app.gpa, app.io, paths.items); + return w; +} - var scanner: usage.Scanner = .init(app.gpa, app.io, .open(app.gpa, app.io, app.environ)); - defer scanner.deinit(); +fn headerLine(gpa: std.mem.Allocator, w: Widths) ![]const u8 { + return std.fmt.allocPrint(gpa, "{f} {f} {f} {f} {f} {f} {s}", .{ + ui.pad(headers.branch, w.branch), + ui.pad(headers.merge, w.merge), + ui.pad(headers.status, w.status), + ui.pad(headers.age, w.age), + ui.pad(headers.frees, w.frees), + ui.pad(headers.spent, w.spent), + headers.where, + }); +} - var at: usize = 0; - for (removals, 0..) |*removal, i| { - const dd_sized = try app.gpa.alloc(dd.Sized, derived[i].len); - for (derived[i], 0..) |entry, j| { - dd_sized[j] = .{ .entry = entry, .size = sizes[at] }; - at += 1; - } +fn rowLine(gpa: std.mem.Allocator, cell: Cells, w: Widths) ![]const u8 { + return std.fmt.allocPrint(gpa, "{f} {f} {f} {f} {f} {f} {s}{s}", .{ + ui.pad(cell.branch, w.branch), + ui.pad(cell.merge, w.merge), + ui.pad(cell.status, w.status), + ui.pad(cell.age, w.age), + ui.pad(cell.frees, w.frees), + ui.pad(cell.spent, w.spent), + cell.where, + cell.note, + }); +} - const cp_sized = try app.gpa.alloc(cp.Sized, sessions[i].len); - for (sessions[i], 0..) |entry, j| { - cp_sized[j] = .{ .entry = entry, .size = sizes[at] }; - at += 1; - } - removal.attached = .{ - .derived = dd_sized, - .sessions = cp_sized, - .spent = try scanner.worktree(cp_all, removal.choice.entry.path), - .xcode = in_xcode[i], - }; - } - return removals; +fn cellsFor(app: app_mod.App, row: Row, opts: Opts, now: i64) !Cells { + return .{ + .branch = try row.label(app.gpa), + .merge = try mergeCell(app.gpa, row.disposition), + .status = try statusCell(app.gpa, row), + .age = try ageCell(app.gpa, row.committed_at, now), + .frees = try sizeCell(app.gpa, row.attached.reclaimable(opts.sessions)), + .spent = try spentCell(app.gpa, row.attached.spent), + .where = if (row.entry()) |found| + disk.abbreviate(app.gpa, app.environ, found.path) + else + "branch only — no worktree left", + .note = try noteCell(app.gpa, row), + }; +} + +fn mergeCell(gpa: std.mem.Allocator, disposition: ?git.BranchDisposition) ![]const u8 { + const d = disposition orelse return "—"; + return switch (d.reason) { + .merged => "merged", + .merged_pr => try std.fmt.allocPrint(gpa, "merged #{d}", .{d.pr}), + .upstream_gone => "remote gone", + .default_branch => "default branch", + .unmerged => if (d.unmerged == 0) + "unmerged" + else + try std.fmt.allocPrint(gpa, "{d} unmerged", .{d.unmerged}), + }; +} + +fn statusCell(gpa: std.mem.Allocator, row: Row) ![]const u8 { + if (row.entry() == null) return "—"; + const count = row.dirty orelse return "missing"; + if (count == 0) return "clean"; + return std.fmt.allocPrint(gpa, "{d} dirty", .{count}); +} + +fn ageCell(gpa: std.mem.Allocator, committed_at: i64, now: i64) ![]const u8 { + if (committed_at <= 0) return "—"; + return std.fmt.allocPrint(gpa, "{f}", .{ui.age(now - committed_at)}); } -fn confirmRemovalsMessage(app: app_mod.App, removals: []const Removal, opts: Opts) ![]u8 { - if (removals.len == 1) { - const removal = removals[0]; - return confirmMessage(app, removal.choice, removal.attached, removal.disposition, opts); +fn sizeCell(gpa: std.mem.Allocator, size: u64) ![]const u8 { + if (size == 0) return "—"; + return std.fmt.allocPrint(gpa, "{f}", .{ui.bytes(size)}); +} + +fn spentCell(gpa: std.mem.Allocator, spent: usage.Totals) ![]const u8 { + if (spent.empty()) return "—"; + return std.fmt.allocPrint(gpa, "{f}", .{ui.count(spent.counts.contextTokens())}); +} + +fn noteCell(gpa: std.mem.Allocator, row: Row) ![]const u8 { + var out: std.ArrayList(u8) = .empty; + if (row.choice) |choice| { + if (choice.entry.locked) try out.appendSlice(gpa, " locked"); + if (choice.entry.prunable) try out.appendSlice(gpa, " prunable"); + if (choice.managed) try out.appendSlice(gpa, " lcc"); + } + if (row.attached.xcode.unsaved.len > 0) { + try out.appendSlice(gpa, " — unsaved in Xcode"); + } else if (row.attached.xcode.workspaces.len > 0) { + try out.appendSlice(gpa, " — open in Xcode"); } + return out.toOwnedSlice(gpa); +} + +fn select( + app: app_mod.App, + rows: []const Row, + opts: Opts, + message: []const u8, + checked_default: bool, +) ![]const Row { + const now = app_mod.nowSeconds(app.io); + const cells = try app.gpa.alloc(Cells, rows.len); + for (rows, 0..) |row, i| cells[i] = try cellsFor(app, row, opts, now); + const w = measure(cells); + + const items = try app.gpa.alloc(prompt.Item, rows.len); + for (cells, 0..) |cell, i| items[i] = .{ .label = try rowLine(app.gpa, cell, w) }; + + app.ui.flush(); + const chosen = try prompt.checkbox( + app.gpa, + app.io, + message, + try headerLine(app.gpa, w), + items, + checked_default, + ) orelse std.process.exit(app_mod.cancelled_exit_code); + return rowsAt(app.gpa, rows, chosen); +} + +fn rowsAt(gpa: std.mem.Allocator, rows: []const Row, indices: []const usize) ![]const Row { + const subset = try gpa.alloc(Row, indices.len); + for (indices, 0..) |index, i| subset[i] = rows[index]; + return subset; +} + +fn confirmRemovalsMessage(app: app_mod.App, rows: []const Row, opts: Opts) ![]u8 { + if (rows.len == 1) return confirmMessage(app, rows[0], opts); var out: std.ArrayList(u8) = .empty; - try out.appendSlice(app.gpa, try std.fmt.allocPrint(app.gpa, "Remove {d} worktrees?\n", .{removals.len})); - for (removals) |removal| { - const label = removal.choice.entry.branch orelse app_mod.shortHead(removal.choice.entry.head); - try out.appendSlice(app.gpa, try std.fmt.allocPrint(app.gpa, "\n {s}\n", .{label})); - try appendConfirmationDetails( - app, - &out, - removal.choice, - removal.attached, - removal.disposition, - opts, - ); + try out.appendSlice(app.gpa, try std.fmt.allocPrint(app.gpa, "Remove {d} worktrees?\n", .{rows.len})); + for (rows) |row| { + try out.appendSlice(app.gpa, try std.fmt.allocPrint(app.gpa, "\n {s}\n", .{ + try row.label(app.gpa), + })); + try appendConfirmationDetails(app, &out, row, opts); } return out.toOwnedSlice(app.gpa); } -fn withoutUnsavedWork(app: app_mod.App, removals: []const Removal, opts: Opts) ![]const Removal { - if (opts.force) return removals; +fn withoutUnsavedWork(app: app_mod.App, rows: []const Row, opts: Opts) ![]const Row { + if (opts.force) return rows; - var actionable: std.ArrayList(Removal) = .empty; - for (removals) |removal| { - if (removal.attached.xcode.unsaved.len == 0) { - try actionable.append(app.gpa, removal); + var actionable: std.ArrayList(Row) = .empty; + for (rows) |row| { + if (row.attached.xcode.unsaved.len == 0) { + try actionable.append(app.gpa, row); continue; } - const entry = removal.choice.entry; - const label = entry.branch orelse app_mod.shortHead(entry.head); - app.ui.warn("Kept {f} — Xcode has unsaved changes in it", .{ui.cyan(label)}); - for (removal.attached.xcode.unsaved) |doc| app.ui.hint(" {s}", .{doc.path}); + app.ui.warn("Kept {f} — Xcode has unsaved changes in it", .{ + ui.cyan(try row.label(app.gpa)), + }); + for (row.attached.xcode.unsaved) |doc| app.ui.hint(" {s}", .{doc.path}); app.ui.hint(" Save them there, or rerun with: lcc remove --force", .{}); } return actionable.toOwnedSlice(app.gpa); @@ -246,7 +361,7 @@ fn withoutUnsavedWork(app: app_mod.App, removals: []const Removal, opts: Opts) ! fn removeSelected( app: app_mod.App, repo: git.Repo, - removals: []const Removal, + rows: []const Row, opts: Opts, dd_root: []const u8, cp_root: []const u8, @@ -255,18 +370,18 @@ fn removeSelected( var kept_sessions: usize = 0; var reclaimed: u64 = 0; - for (removals) |removal| { - const entry = removal.choice.entry; - const label = entry.branch orelse app_mod.shortHead(entry.head); + for (rows) |row| { + const entry = row.entry() orelse continue; + const label = try row.label(app.gpa); - if (removal.attached.xcode.unsaved.len > 0 and !opts.force) { + if (row.attached.xcode.unsaved.len > 0 and !opts.force) { app.ui.warn("Kept {f} — Xcode has unsaved changes in it", .{ui.cyan(label)}); - for (removal.attached.xcode.unsaved) |doc| app.ui.hint(" {s}", .{doc.path}); + for (row.attached.xcode.unsaved) |doc| app.ui.hint(" {s}", .{doc.path}); app.ui.hint(" Save them there, or rerun with: lcc remove --force", .{}); continue; } - const closed = closeXcode(app, removal.attached.xcode); + const closed = closeXcode(app, row.attached.xcode); const gone = remove: { repo.removeWorktree(entry.path, opts.force) catch { if (opts.force) { @@ -301,14 +416,14 @@ fn removeSelected( removed += 1; app.ui.success("Removed worktree {f}", .{ui.cyan(label)}); - reclaimed += try purgeDerived(app, removal.attached.derived, dd_root); + reclaimed += try purgeDerived(app, row.attached.derived, dd_root); if (opts.sessions) { - reclaimed += try purgeSessions(app, removal.attached.sessions, cp_root); + reclaimed += try purgeSessions(app, row.attached.sessions, cp_root); } else { - kept_sessions += removal.attached.sessions.len; + kept_sessions += row.attached.sessions.len; } - try disposeBranch(app, repo, entry.branch, removal.disposition); + try disposeBranch(app, repo, entry.branch, row.disposition); } if (kept_sessions > 0) { @@ -321,8 +436,8 @@ fn removeSelected( ui.bold(try std.fmt.allocPrint(app.gpa, "{f}", .{ui.bytes(reclaimed)})), }); } - if (removals.len > 1) { - app.ui.success("Removed {d} of {d} selected worktrees.", .{ removed, removals.len }); + if (rows.len > 1) { + app.ui.success("Removed {d} of {d} selected worktrees.", .{ removed, rows.len }); } } @@ -379,48 +494,51 @@ fn pullRequests( return list; } -fn confirmMessage( - app: app_mod.App, - picked: app_mod.Choice, - attached: Attached, - disposition: ?git.BranchDisposition, - opts: Opts, -) ![]u8 { - const label = picked.entry.branch orelse app_mod.shortHead(picked.entry.head); - +fn confirmMessage(app: app_mod.App, row: Row, opts: Opts) ![]u8 { var out: std.ArrayList(u8) = .empty; const w = app.gpa; - try out.appendSlice(w, try std.fmt.allocPrint(w, "Remove worktree {s}?\n", .{label})); - try appendConfirmationDetails(app, &out, picked, attached, disposition, opts); + try out.appendSlice(w, try std.fmt.allocPrint(w, "Remove worktree {s}?\n", .{ + try row.label(w), + })); + try appendConfirmationDetails(app, &out, row, opts); return out.toOwnedSlice(w); } fn appendConfirmationDetails( app: app_mod.App, out: *std.ArrayList(u8), - picked: app_mod.Choice, - attached: Attached, - disposition: ?git.BranchDisposition, + row: Row, opts: Opts, ) !void { const w = app.gpa; - try out.appendSlice(w, try std.fmt.allocPrint(w, " worktree {s}\n", .{picked.entry.path})); - for (attached.xcode.workspaces) |doc| { + if (row.entry()) |entry| { + try out.appendSlice(w, try std.fmt.allocPrint(w, " worktree {s}\n", .{entry.path})); + } + if (row.dirty) |count| { + if (count > 0) { + try out.appendSlice(w, try std.fmt.allocPrint( + w, + " changes {d} uncommitted (lost if the removal is forced)\n", + .{count}, + )); + } + } + for (row.attached.xcode.workspaces) |doc| { try out.appendSlice(w, try std.fmt.allocPrint(w, " xcode {s} (open — will be closed)\n", .{ doc.name(), })); } - for (attached.xcode.unsaved) |doc| { + for (row.attached.xcode.unsaved) |doc| { try out.appendSlice(w, try std.fmt.allocPrint(w, " unsaved {s} (in Xcode — the changes go too)\n", .{ doc.name(), })); } - for (attached.derived) |item| { + for (row.attached.derived) |item| { try out.appendSlice(w, try std.fmt.allocPrint(w, " build data {s} ({f})\n", .{ item.entry.name, ui.bytes(item.size), })); } - for (attached.sessions) |item| { + for (row.attached.sessions) |item| { try out.appendSlice(w, try std.fmt.allocPrint(w, " sessions {s} ({d} session{s}, {f}){s}\n", .{ item.entry.name, item.entry.sessions, @@ -429,12 +547,12 @@ fn appendConfirmationDetails( if (opts.sessions) "" else " — kept, use --sessions", })); } - if (!attached.spent.empty()) { + if (!row.attached.spent.empty()) { try out.appendSlice(w, try std.fmt.allocPrint(w, " spent {f}\n", .{ - usage.brief(attached.spent, app_mod.nowSeconds(app.io)), + usage.brief(row.attached.spent, app_mod.nowSeconds(app.io)), })); } - if (disposition) |d| { + if (row.disposition) |d| { try out.appendSlice(w, try std.fmt.allocPrint(w, " branch {s} {s}\n", .{ d.branch, try describeDisposition(w, d), })); @@ -520,22 +638,6 @@ fn purgeSessions(app: app_mod.App, sized: []const cp.Sized, root: []const u8) !u return reclaimed; } -const Row = struct { - worktree: ?git.WorktreeEntry, - branch: []const u8, - disposition: git.BranchDisposition, - attached: Attached = .{}, - - fn reason(self: Row, gpa: std.mem.Allocator) []const u8 { - return switch (self.disposition.reason) { - .merged => "merged", - .merged_pr => std.fmt.allocPrint(gpa, "merged #{d}", .{self.disposition.pr}) catch "merged", - .upstream_gone => "remote gone", - else => "safe", - }; - } -}; - fn runMerged(app: app_mod.App, repo: git.Repo, opts: Opts) !void { if (!opts.local) refresh(app, repo); @@ -549,31 +651,30 @@ fn runMerged(app: app_mod.App, repo: git.Repo, opts: Opts) !void { if (entry.branch) |branch| try checked_out.put(app.gpa, branch, {}); } + const prefix = try app_mod.managedPrefix(app, repo); + var candidates: std.ArrayList(Row) = .empty; for (worktrees) |entry| { if (entry.is_main) continue; const branch = entry.branch orelse continue; try candidates.append(app.gpa, .{ - .worktree = entry, + .choice = .{ .entry = entry, .managed = app_mod.isManaged(prefix, entry.path) }, .branch = branch, - .disposition = try repo.branchDisposition(branch), }); } for (try repo.branchStatuses()) |status| { if (checked_out.contains(status.branch)) continue; - try candidates.append(app.gpa, .{ - .worktree = null, - .branch = status.branch, - .disposition = try repo.branchDisposition(status.branch), - }); + try candidates.append(app.gpa, .{ .branch = status.branch }); } + try judge(app, repo, candidates.items); if (!opts.local) try consultGitHub(app, repo, candidates.items); var rows: std.ArrayList(Row) = .empty; for (candidates.items) |row| { - if (row.disposition.safe) try rows.append(app.gpa, row); + const d = row.disposition orelse continue; + if (d.safe) try rows.append(app.gpa, row); } if (rows.items.len == 0) { @@ -581,11 +682,19 @@ fn runMerged(app: app_mod.App, repo: git.Repo, opts: Opts) !void { return; } + try inspect(app, repo, rows.items); + const dd_root = try dd.root(app.gpa, app.io, app.environ); const cp_root = try cp.root(app.gpa, app.environ); try attach(app, rows.items, opts, dd_root, cp_root); - const picked: []const Row = if (opts.yes) rows.items else try selectRows(app, rows.items, opts); + const picked: []const Row = if (opts.yes) rows.items else try select( + app, + rows.items, + opts, + "Select what to remove (space toggles, enter confirms):", + true, + ); if (picked.len == 0) { app.ui.hint("Nothing selected.", .{}); return; @@ -596,22 +705,23 @@ fn runMerged(app: app_mod.App, repo: git.Repo, opts: Opts) !void { var branches_gone: usize = 0; for (picked) |row| { - if (row.worktree) |entry| { + const branch = row.branch orelse continue; + if (row.entry()) |entry| { if (row.attached.xcode.unsaved.len > 0 and !opts.force) { - app.ui.warn("Kept {f} — Xcode has unsaved changes in it", .{ui.cyan(row.branch)}); + app.ui.warn("Kept {f} — Xcode has unsaved changes in it", .{ui.cyan(branch)}); app.ui.hint(" Save them there, or rerun with: lcc remove --merged --force", .{}); continue; } const closed = closeXcode(app, row.attached.xcode); repo.removeWorktree(entry.path, opts.force) catch { - app.ui.warn("Kept {f} — {s}", .{ ui.cyan(row.branch), git.last_error }); + app.ui.warn("Kept {f} — {s}", .{ ui.cyan(branch), git.last_error }); app.ui.hint(" Retry with: lcc remove --merged --force", .{}); noteReopen(app, closed); continue; }; worktrees_gone += 1; - app.ui.success("Removed worktree {f}", .{ui.cyan(row.branch)}); + app.ui.success("Removed worktree {f}", .{ui.cyan(branch)}); reclaimed += try purgeDerived(app, row.attached.derived, dd_root); if (opts.sessions) { @@ -620,13 +730,14 @@ fn runMerged(app: app_mod.App, repo: git.Repo, opts: Opts) !void { } if (opts.keep_branch) continue; - const forced = repo.deleteVerified(row.disposition) catch { - app.ui.warn("Could not delete branch {s} — {s}", .{ row.branch, git.last_error }); - app.ui.hint(" Delete manually with: git branch -D {s}", .{row.branch}); + const disposition = row.disposition orelse continue; + const forced = repo.deleteVerified(disposition) catch { + app.ui.warn("Could not delete branch {s} — {s}", .{ branch, git.last_error }); + app.ui.hint(" Delete manually with: git branch -D {s}", .{branch}); continue; }; branches_gone += 1; - app.ui.success("Deleted branch {f}", .{ui.cyan(row.branch)}); + app.ui.success("Deleted branch {f}", .{ui.cyan(branch)}); if (forced) app.ui.hint(" git's own check could not see the merge — used -D.", .{}); } @@ -646,17 +757,19 @@ fn runMerged(app: app_mod.App, repo: git.Repo, opts: Opts) !void { fn consultGitHub(app: app_mod.App, repo: git.Repo, rows: []Row) !void { var asked: std.ArrayList([]const u8) = .empty; for (rows) |row| { - if (row.disposition.reason != .unmerged) continue; + const d = row.disposition orelse continue; + if (d.reason != .unmerged) continue; for (asked.items) |seen| { - if (std.mem.eql(u8, seen, row.branch)) break; - } else try asked.append(app.gpa, row.branch); + if (std.mem.eql(u8, seen, d.branch)) break; + } else try asked.append(app.gpa, d.branch); } if (asked.items.len == 0) return; const prs = pullRequests(app, repo, asked.items) orelse return; for (rows) |*row| { - const number = github.mergedFor(prs, row.branch) orelse continue; - row.disposition = row.disposition.withMergedPr(number); + const d = row.disposition orelse continue; + const number = github.mergedFor(prs, d.branch) orelse continue; + row.disposition = d.withMergedPr(number); } } @@ -685,7 +798,7 @@ fn attach( const in_xcode = try app.gpa.alloc(xcode.Open, rows.len); for (rows, 0..) |row, i| { - const entry = row.worktree orelse { + const entry = row.entry() orelse { derived[i] = &.{}; sessions[i] = &.{}; in_xcode[i] = .{}; @@ -722,134 +835,55 @@ fn attach( row.attached = .{ .derived = dd_sized, .sessions = cp_sized, - .spent = if (row.worktree) |entry| try scanner.worktree(cp_all, entry.path) else .{}, + .spent = if (row.entry()) |entry| try scanner.worktree(cp_all, entry.path) else .{}, .xcode = in_xcode[i], }; } } -fn rowLabel( - gpa: std.mem.Allocator, - environ: *const std.process.Environ.Map, - row: Row, - branch_width: usize, - reason_width: usize, - opts: Opts, -) ![]const u8 { - const size = row.attached.reclaimable(opts.sessions); - const reclaim = if (size == 0) - try gpa.dupe(u8, "—") - else - try std.fmt.allocPrint(gpa, "{f}", .{ui.bytes(size)}); - - const spent = if (row.attached.spent.empty()) - try gpa.dupe(u8, "—") - else - try std.fmt.allocPrint(gpa, "{f}", .{ - ui.count(row.attached.spent.counts.contextTokens()), - }); - - const where = if (row.worktree) |entry| - disk.abbreviate(gpa, environ, entry.path) - else - "branch only — no worktree left"; - - const note = if (row.attached.xcode.unsaved.len > 0) - " — unsaved in Xcode" - else if (row.attached.xcode.workspaces.len > 0) - " — open in Xcode" - else - ""; - - return std.fmt.allocPrint(gpa, "{f} {f} {f} {f} {s}{s}", .{ - ui.pad(row.branch, branch_width), - ui.pad(row.reason(gpa), reason_width), - ui.pad(reclaim, 8), - ui.pad(spent, 7), - where, - note, - }); -} - -fn selectRows(app: app_mod.App, rows: []const Row, opts: Opts) ![]const Row { - var branch_width: usize = 0; - var reason_width: usize = 0; - for (rows) |row| { - branch_width = @max(branch_width, ui.displayWidth(row.branch)); - reason_width = @max(reason_width, ui.displayWidth(row.reason(app.gpa))); - } - - const items = try app.gpa.alloc(prompt.Item, rows.len); - for (rows, 0..) |row, i| { - items[i] = .{ - .label = try rowLabel(app.gpa, app.environ, row, branch_width, reason_width, opts), - }; - } - - app.ui.flush(); - const chosen = try prompt.checkbox( - app.gpa, - app.io, - "Select what to remove (space toggles, enter confirms):", - items, - true, - ) orelse std.process.exit(app_mod.cancelled_exit_code); - - const subset = try app.gpa.alloc(Row, chosen.len); - for (chosen, 0..) |index, i| subset[i] = rows[index]; - return subset; -} - fn plural(n: anytype) []const u8 { return if (n == 1) "" else "s"; } -test "worktree multi-selection preserves zero one and many choices" { - var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); - defer arena_state.deinit(); - const arena = arena_state.allocator(); - - const choices = [_]app_mod.Choice{ - .{ .entry = .{ - .path = "/tmp/one", - .branch = "feature/one", - .head = "11111111", - .locked = false, - .prunable = false, - .is_main = false, - }, .managed = true }, - .{ .entry = .{ - .path = "/tmp/two", - .branch = "feature/two", - .head = "22222222", +fn testRow(path: []const u8, branch: ?[]const u8, head: []const u8) Row { + return .{ + .choice = .{ .entry = .{ + .path = path, + .branch = branch, + .head = head, .locked = false, .prunable = false, .is_main = false, }, .managed = true }, - .{ .entry = .{ - .path = "/tmp/detached", - .branch = null, - .head = "33333333", - .locked = false, - .prunable = false, - .is_main = false, - }, .managed = false }, + .branch = branch, + }; +} + +test "multi-selection preserves zero one and many picks" { + var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const rows = [_]Row{ + testRow("/tmp/one", "feature/one", "11111111"), + testRow("/tmp/two", "feature/two", "22222222"), + testRow("/tmp/detached", null, "33333333"), }; - const none = try choicesAt(arena, &choices, &.{}); + const none = try rowsAt(arena, &rows, &.{}); try std.testing.expectEqual(@as(usize, 0), none.len); - const one = try choicesAt(arena, &choices, &.{1}); - try std.testing.expectEqualStrings("feature/two", one[0].entry.branch.?); + const one = try rowsAt(arena, &rows, &.{1}); + try std.testing.expectEqualStrings("feature/two", one[0].branch.?); - const many = try choicesAt(arena, &choices, &.{ 0, 2 }); + const many = try rowsAt(arena, &rows, &.{ 0, 2 }); try std.testing.expectEqual(@as(usize, 2), many.len); - try std.testing.expectEqualStrings("/tmp/one", many[0].entry.path); - try std.testing.expectEqualStrings("/tmp/detached", many[1].entry.path); - try std.testing.expect(many[1].entry.branch == null); + try std.testing.expectEqualStrings("/tmp/one", many[0].entry().?.path); + try std.testing.expectEqualStrings("/tmp/detached", many[1].entry().?.path); + try std.testing.expect(many[1].branch == null); } -test "a bulk row shows what it frees and what it spent" { +test "a row says whether its work landed before it is ticked, not after" { const gpa = std.testing.allocator; var arena_state: std.heap.ArenaAllocator = .init(gpa); @@ -859,49 +893,146 @@ test "a bulk row shows what it frees and what it spent" { var environ: std.process.Environ.Map = .init(arena); try environ.put("HOME", "/Users/me"); - const worktree: git.WorktreeEntry = .{ - .path = "/Users/me/Projects/App/.lcc/worktrees/pe-101", - .branch = "feature/pe-101-shipped", - .head = "abc", - .locked = false, - .prunable = false, - .is_main = false, + const app: app_mod.App = .{ + .gpa = arena, + .io = undefined, + .environ = &environ, + .ui = undefined, + }; + + const now: i64 = 1_800_000_000; + var row = testRow( + "/Users/me/Projects/App/.lcc/worktrees/pe-256", + "feature/pe-256-app-hangs", + "abc", + ); + row.disposition = .{ + .branch = "feature/pe-256-app-hangs", + .safe = false, + .reason = .unmerged, + .unmerged = 3, + }; + row.dirty = 2; + row.committed_at = now - 7200; + + const unmerged = try cellsFor(app, row, .{}, now); + try std.testing.expectEqualStrings("3 unmerged", unmerged.merge); + try std.testing.expectEqualStrings("2 dirty", unmerged.status); + try std.testing.expectEqualStrings("2h", unmerged.age); + + var landed = row; + landed.disposition = row.disposition.?.withMergedPr(412); + landed.dirty = 0; + const merged = try cellsFor(app, landed, .{}, now); + try std.testing.expectEqualStrings("merged #412", merged.merge); + try std.testing.expectEqualStrings("clean", merged.status); + + var untracked = row; + untracked.disposition = null; + untracked.dirty = null; + untracked.committed_at = 0; + const unknown = try cellsFor(app, untracked, .{}, now); + try std.testing.expectEqualStrings("—", unknown.merge); + try std.testing.expectEqualStrings("missing", unknown.status); + try std.testing.expectEqualStrings("—", unknown.age); +} + +test "a row shows what it frees and what it spent" { + const gpa = std.testing.allocator; + + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var environ: std.process.Environ.Map = .init(arena); + try environ.put("HOME", "/Users/me"); + + const app: app_mod.App = .{ + .gpa = arena, + .io = undefined, + .environ = &environ, + .ui = undefined, }; - const row: Row = .{ - .worktree = worktree, + + const now: i64 = 1_800_000_000; + var row = testRow( + "/Users/me/Projects/App/.lcc/worktrees/pe-101", + "feature/pe-101-shipped", + "abc", + ); + row.disposition = .{ .branch = "feature/pe-101-shipped", - .disposition = .{ .branch = "feature/pe-101-shipped", .safe = true, .reason = .merged, .unmerged = 0 }, - .attached = .{ - .derived = &.{}, - .sessions = &.{}, - .spent = .{ - .counts = .{ .messages = 190, .cache_read = 52_600_000 }, - .sessions = 1, - }, + .safe = true, + .reason = .merged, + .unmerged = 0, + }; + row.dirty = 0; + + var derived = [_]dd.Sized{.{ + .entry = .{ + .name = "App-abc", + .path = "/dd/App-abc", + .workspace_path = "/Users/me/x", + }, + .size = 1_200_000_000, + }}; + var sessions = [_]cp.Sized{.{ + .entry = .{ + .name = "-Users-me-x", + .path = "/cp/-Users-me-x", + .cwd = "/Users/me/x", + .sessions = 3, + }, + .size = 40_000_000, + }}; + row.attached = .{ + .derived = &derived, + .sessions = &sessions, + .spent = .{ + .counts = .{ .messages = 190, .cache_read = 52_600_000 }, + .sessions = 1, }, }; - const label = try rowLabel(arena, &environ, row, 22, 11, .{}); - try std.testing.expect(std.mem.indexOf(u8, label, "53M") != null); - try std.testing.expect(std.mem.indexOf(u8, label, "—") != null); - try std.testing.expect(std.mem.indexOf(u8, label, "~/Projects/App/.lcc/worktrees/pe-101") != null); + const kept = try cellsFor(app, row, .{}, now); + try std.testing.expectEqualStrings("1.1 GB", kept.frees); + try std.testing.expectEqualStrings("53M", kept.spent); + try std.testing.expectEqualStrings("~/Projects/App/.lcc/worktrees/pe-101", kept.where); + try std.testing.expectEqualStrings(" lcc", kept.note); + + const with_sessions = try cellsFor(app, row, .{ .sessions = true }, now); + try std.testing.expectEqualStrings("1.2 GB", with_sessions.frees); + + var orphan: Row = .{ .branch = "feature/pe-103-squashed" }; + orphan.disposition = .{ + .branch = "feature/pe-103-squashed", + .safe = true, + .reason = .upstream_gone, + .unmerged = 1, + }; + const gone = try cellsFor(app, orphan, .{}, now); + try std.testing.expectEqualStrings("remote gone", gone.merge); + try std.testing.expectEqualStrings("—", gone.status); + try std.testing.expectEqualStrings("—", gone.frees); + try std.testing.expectEqualStrings("branch only — no worktree left", gone.where); +} - var orphan = row; - orphan.worktree = null; - orphan.attached = .{}; - const orphan_label = try rowLabel(arena, &environ, orphan, 22, 11, .{}); - try std.testing.expect(std.mem.indexOf(u8, orphan_label, "53M") == null); - try std.testing.expect(std.mem.indexOf(u8, orphan_label, "branch only") != null); +test "an open Xcode window is named on the row that would close it" { + const gpa = std.testing.allocator; - var by_pr = row; - by_pr.disposition = (git.BranchDisposition{ - .branch = "feature/pe-101-shipped", - .safe = false, - .reason = .unmerged, - .unmerged = 3, - }).withMergedPr(412); - const pr_label = try rowLabel(arena, &environ, by_pr, 22, 11, .{}); - try std.testing.expect(std.mem.indexOf(u8, pr_label, "merged #412") != null); + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var environ: std.process.Environ.Map = .init(arena); + try environ.put("HOME", "/Users/me"); + + const app: app_mod.App = .{ + .gpa = arena, + .io = undefined, + .environ = &environ, + .ui = undefined, + }; const doc: xcode.Document = .{ .app = "/Applications/Xcode.app", @@ -909,13 +1040,51 @@ test "a bulk row shows what it frees and what it spent" { .resolved = "/Users/me/Projects/App/.lcc/worktrees/pe-101/App.xcodeproj", }; - var opened = row; - opened.attached.xcode = .{ .workspaces = &.{doc} }; - const open_label = try rowLabel(arena, &environ, opened, 22, 11, .{}); - try std.testing.expect(std.mem.indexOf(u8, open_label, "open in Xcode") != null); + var row = testRow("/Users/me/Projects/App/.lcc/worktrees/pe-101", "feature/pe-101", "abc"); + + row.attached.xcode = .{ .workspaces = &.{doc} }; + const opened = try cellsFor(app, row, .{}, 0); + try std.testing.expectEqualStrings(" lcc — open in Xcode", opened.note); + + row.attached.xcode = .{ .workspaces = &.{doc}, .unsaved = &.{doc} }; + const dirty = try cellsFor(app, row, .{}, 0); + try std.testing.expectEqualStrings(" lcc — unsaved in Xcode", dirty.note); +} + +test "every column is as wide as its widest cell, header included" { + var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const cells = [_]Cells{ + .{ + .branch = "feature/pe-256-app-hangs", + .merge = "merged #412", + .status = "clean", + .age = "2h", + .frees = "1.2 GB", + .spent = "53M", + .where = "~/Projects/App/.lcc/worktrees/pe-256", + .note = "", + }, + }; - var dirty = row; - dirty.attached.xcode = .{ .workspaces = &.{doc}, .unsaved = &.{doc} }; - const dirty_label = try rowLabel(arena, &environ, dirty, 22, 11, .{}); - try std.testing.expect(std.mem.indexOf(u8, dirty_label, "unsaved in Xcode") != null); + const w = measure(&cells); + try std.testing.expectEqual(@as(usize, "feature/pe-256-app-hangs".len), w.branch); + try std.testing.expectEqual(@as(usize, "merged #412".len), w.merge); + try std.testing.expectEqual(@as(usize, "STATUS".len), w.status); + try std.testing.expectEqual(@as(usize, "AGE".len), w.age); + try std.testing.expectEqual(@as(usize, "1.2 GB".len), w.frees); + try std.testing.expectEqual(@as(usize, "SPENT".len), w.spent); + + const header = try headerLine(arena, w); + const line = try rowLine(arena, cells[0], w); + try std.testing.expectEqual( + std.mem.indexOf(u8, header, "STATUS").?, + std.mem.indexOf(u8, line, "clean").?, + ); + try std.testing.expectEqual( + std.mem.indexOf(u8, header, "PATH").?, + std.mem.indexOf(u8, line, "~/Projects").?, + ); } diff --git a/src/prompt.zig b/src/prompt.zig index f8a3e3b..92683fb 100644 --- a/src/prompt.zig +++ b/src/prompt.zig @@ -17,9 +17,9 @@ const Terminal = term_mod.Terminal; const readKey = term_mod.readKey; const truncate = term_mod.truncate; -fn pageSize(term: Terminal) usize { +fn pageSize(term: Terminal, reserved: usize) usize { const rows = term.size().rows; - return @max(@as(usize, 5), @min(@as(usize, 30), rows -| 4)); + return @max(@as(usize, 5), @min(@as(usize, 30), rows -| reserved)); } fn score(haystack: []const u8, query: []const u8) ?i32 { @@ -145,7 +145,7 @@ pub fn search( while (true) { const dims = term.size(); - const page = pageSize(term); + const page = pageSize(term, 4); const width: usize = @max(@as(usize, 20), dims.cols); if (cursor >= filtered.items.len) cursor = filtered.items.len -| 1; @@ -336,6 +336,7 @@ pub fn checkbox( gpa: std.mem.Allocator, io: Io, message: []const u8, + header: []const u8, items: []const Item, checked_default: bool, ) Error!?[]usize { @@ -366,7 +367,7 @@ pub fn checkbox( while (true) { const dims = term.size(); - const page = pageSize(term); + const page = pageSize(term, if (header.len > 0) 5 else 4); const width: usize = @max(@as(usize, 20), dims.cols); if (cursor < offset) offset = cursor; @@ -379,6 +380,11 @@ pub fn checkbox( out.print("{s}?{s} {s}\n", .{ p.green, p.reset, message }) catch {}; lines += 1; + if (header.len > 0) { + out.print(" {s}{s}{s}\n", .{ p.dim, truncate(header, width -| 5), p.reset }) catch {}; + lines += 1; + } + const end = @min(offset + page, items.len); for (items[offset..end], offset..) |item, row| { const mark = if (checked[row]) "◉" else "◯";