From 39f26061d6c92a97481fc5dcb59abcff7c442000 Mon Sep 17 00:00:00 2001 From: Daniels-Main Date: Tue, 7 Jul 2026 23:56:20 +0200 Subject: [PATCH 1/2] feat(worktrees): merge & clean up, health badges, archive-before-remove snapshots The second half of the agent-worktree loop: retiring a worktree safely once its work lands (improvements.md W1-W3). Engine (worktree.rs): - worktree_health: detected base, ahead-of-base, can-fast-forward, upstream/unpushed, and merged-detection via a containment scan across all local branches (detect_base_branch alone names a sibling when several worktrees sit at one fork point and would hide "merged"). - integrate_worktree_branch: squash / merge-commit / ff into the base, run in whichever worktree holds it (tracked-changes-only dirty guard, conflict auto-abort) or as a pure ref fast-forward when unheld. - Archive snapshots: archive_worktree_state captures HEAD + staged + unstaged + untracked into refs/strand/archive// via a throwaway GIT_INDEX_FILE; restore puts the original directory and branch back when free (subject records the branch, body the path), else falls back detached; list/delete counterparts. prunable reason parsed alongside locked. IPC: six new repo_worktree_* commands; add/remove/prune now route through run_blocking (they wait on subprocesses). UI (Worktrees overview): merged/unpushed/unmerged badges with lock/prune reasons as tooltips, merged hero metric, Clean up (N) for clean+merged worktrees, Prune stale button, WorktreeMergeDialog with exact-command preview + editable base picker, removeWorktree archives first on every path, and a restorable "Archived snapshots" strip. +5 engine tests (health w/ siblings, ff/merge/squash, archive round-trip incl. identity restore and untracked bystanders). --- README.md | 5 + ROADMAP.md | 33 ++ TASKS.md | 42 ++ crates/strand-core/src/worktree.rs | 611 ++++++++++++++++++++++++++- crates/strand-tauri/src/commands.rs | 87 +++- crates/strand-tauri/src/main.rs | 6 + docs/improvements.md | 202 +++++++++ ui/src/lib/repoIdentity.test.ts | 1 + ui/src/lib/tauri.ts | 21 + ui/src/lib/types.ts | 39 ++ ui/src/stores/repo.ts | 10 + ui/src/styles/features.css | 113 +++++ ui/src/views/WorktreeMergeDialog.tsx | 301 +++++++++++++ ui/src/views/Worktrees.tsx | 375 +++++++++++++++- 14 files changed, 1818 insertions(+), 28 deletions(-) create mode 100644 ui/src/views/WorktreeMergeDialog.tsx diff --git a/README.md b/README.md index cd5ce90..427ef50 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,11 @@ keyboard alone, and the mouse stays first-class. repo naming, branch/session labels, dirty count, ahead/behind, last commit, and one-click Review pinned where the branch diverged from main. Worktree tabs of one repo group together instead of looking like separate repos. + Rows badge merged / unpushed work against the detected base; **Merge & + clean up** lands a worktree's branch (squash / merge / fast-forward, exact + commands previewed) and retires the worktree + branch in one motion, and + every removal first archives a full snapshot — uncommitted and untracked + files included — restorable later as a new worktree. - **Everyday Git** — staging with per-change-block stage / discard / unstage inline in the diff, fetch / pull / push with streaming progress, branches, tags, stashes, remotes, cherry-pick, revert, merge, and a fully diff --git a/ROADMAP.md b/ROADMAP.md index fca597c..56574a5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1428,6 +1428,39 @@ end-to-end WebView2 CDP pass on the running app (tracked file drag → git `R`, untracked drag stays untracked, dialog rename of a modified file → `RM` with the edit preserved, ghost text + cleanup asserted). +**Worktree lifecycle: merge & clean up, health badges, archive snapshots +(2026-07-07):** The worktree overview grew the missing second half of the +agent-worktree loop — retiring a worktree safely once its work lands. Engine +(`worktree.rs`): `worktree_health` (detected base, ahead-of-base, +can-fast-forward, upstream/unpushed, and merged-detection via a containment +scan across all local branches — the detect-base heuristic alone names a +sibling when several worktrees sit at one fork point, exactly the +parallel-agent shape, and would hide "merged"), `integrate_worktree_branch` +(squash / merge-commit / ff into the detected base, run in whichever worktree +holds the base with a clean-workdir guard and conflict auto-abort, or as a pure +ref fast-forward when the base isn't checked out), and archive snapshots: +`archive_worktree_state` captures HEAD+staged+unstaged+untracked into +`refs/strand/archive//` via a throwaway `GIT_INDEX_FILE` (real +index untouched), with list / restore / delete counterparts — restore puts +the original identity back (recorded directory when free; branch recreated +or re-attached when it's unheld and still at the archived commit; fallback +dir/detached otherwise), archived changes returning as uncommitted state on +the original commit; `prunable`'s reason is now parsed alongside `locked`'s. Six new +IPC commands, and the existing worktree add/remove/prune commands now route +through `run_blocking` (they wait on subprocesses — `add` even runs a full +checkout). UI: every `removeWorktree` archives first (best-effort), so force +remove is always recoverable; overview rows badge **merged** / +**unpushed** / **unmerged** with lock/prune reasons as tooltips; the hero +gains a merged metric, **Clean up (N)** (confirm-listed removal of +clean+merged worktrees incl. branch deletion), and **Prune stale**; +`WorktreeMergeDialog` previews the exact git commands Crystal-style before +merging, has an editable base picker (detected base preselected, per-base +ff-possibility via `repoMergeBase`), and optionally removes worktree + branch +in the same motion; a collapsible "Archived snapshots" strip offers +Restore / Delete. Verified: +`cargo test -p strand-core` (102, +4 worktree), workspace `clippy` clean, +`tsc`, `vitest` (200). + --- ## 1.1+ — Post-1.0 diff --git a/TASKS.md b/TASKS.md index 3fe135c..8fd844c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -853,6 +853,48 @@ Legend: ☐ not started · ◐ in progress · ☑ done · ✗ blocked on the Review view in session mode, so committed + uncommitted work since the fork point shows in one diff via the existing `diff_since`. The main worktree, or a failed merge-base (toast), falls back to Local Changes as before.) +- ☑ Worktree health + dirty-aware cleanup (W2 — `Repo::worktree_health` in + `worktree.rs`: detected base, ahead-of-base, can-fast-forward, + upstream/unpushed, and **merged via a containment scan across all local + branches** (`merged_into`) — detect_base_branch alone misses a branch merged + into main when sibling worktrees sit at the fork, the canonical + parallel-agent shape; `repo_worktree_health` IPC; overview rows + fetch it lazily and badge **merged** / **unpushed** / **unmerged**, with + `lock_reason` + new `prune_reason` surfaced as badge tooltips; hero gains a + merged metric, a **Clean up (N)** action that lists clean+merged worktrees in + a confirm dialog and removes them + deletes their branches, and a **Prune + stale** button.) +- ☑ Merge & clean up (W1 — `Repo::integrate_worktree_branch` in `worktree.rs`: + squash / merge-commit / ff of a worktree branch into its detected base, + running in whichever worktree holds the base (clean-workdir guard, conflict + auto-abort) or as a pure ref fast-forward when the base isn't checked out; + `repo_worktree_integrate` IPC; `views/WorktreeMergeDialog.tsx` previews the + exact git commands, warns about uncommitted files, offers an editable base + picker (detected base preselected; per-base ff-possibility recomputed via + `repoMergeBase`) since the detection heuristic can name a sibling, and + optionally removes the worktree + deletes the branch after merging. + +3 engine tests.) +- ☑ Archive-before-remove snapshots (W3 — `Repo::archive_worktree_state` in + `worktree.rs` snapshots HEAD+staged+unstaged+untracked into + `refs/strand/archive//` via a throwaway `GIT_INDEX_FILE`, without + touching the real index; `worktree_archives` / `restore_worktree_archive` + (puts the original identity back: recorded directory when free, branch + recreated-or-reattached when unambiguous — commit subject carries the exact + branch, body a `Path:` line — else fallback dir/detached; archived changes + return as uncommitted state on the original commit) / + `delete_worktree_archive`; four + `repo_worktree_archive*` IPC commands; the store's `removeWorktree` archives + best-effort before every removal, and the overview grows a collapsible + "Archived snapshots" strip with Restore / Delete. +1 engine test.) +- ☐ Surface "Merge & clean up" beyond the overview (sidebar worktree context + menu + worktree review header) and add palette entries for Clean up / Prune. +- ☐ Auto-prune old worktree archive snapshots (keep last N per slug or + age-based) instead of manual-delete only. +- ☐ `detect_base_branch` tie-break revisit: with several sibling branches at + one fork point it picks a sibling (smallest `base_ahead`) over the real + parent. Harmless for review baselines (same merge-base) but it seeds the + merge dialog's base picker; consider preferring the branch another worktree + has checked out, or main-ish names, on exact rank ties. ### File view (4-tab) - ☑ Tab strip + header (opened via `selectFile` from the Files tab / palette; diff --git a/crates/strand-core/src/worktree.rs b/crates/strand-core/src/worktree.rs index 972dd98..a3a86e8 100644 --- a/crates/strand-core/src/worktree.rs +++ b/crates/strand-core/src/worktree.rs @@ -15,6 +15,7 @@ //! only owns the worktree *registry* (list + lifecycle). use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use crate::{ @@ -39,12 +40,74 @@ pub struct Worktree { pub lock_reason: Option, /// `git` considers this worktree's directory missing/removable. pub is_prunable: bool, + /// Why git considers it prunable, when a reason was given + /// (e.g. `gitdir file points to non-existent location`). + pub prune_reason: Option, /// The primary worktree (the one holding the repo's own `.git` dir). pub is_main: bool, /// Matches the currently-open repo path (`self.path`). pub is_current: bool, } +/// Ref-level health of a worktree's branch relative to its detected base — +/// powers the overview's merged/unpushed badges and the merge dialog's mode +/// choices. Purely read-only graph walks on the shared object DB. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorktreeHealth { + /// The branch this one forked from, per + /// [`detect_base_branch`](Repo::detect_base_branch); `None` when nothing + /// resolvable was found. + pub base_branch: Option, + /// Every commit of this branch lives in some other local branch — the + /// cleanup-safety question. Not derived from `base_branch` alone: + /// [`detect_base_branch`](Repo::detect_base_branch) deliberately ranks + /// containing branches last (reviewing against them shows nothing), so a + /// branch merged into main would otherwise detect a sibling as base and + /// read as unmerged. (Also true for a fresh branch that never diverged; + /// the UI copy stays honest about that: "no commits of its own".) + pub merged: bool, + /// The branch `merged` refers to — the detected base when everything is + /// in it, otherwise the first other local branch containing the tip. + pub merged_into: Option, + /// Commits on the branch that are not in the base. + pub ahead_of_base: usize, + /// The base tip *is* the fork point, so integrating is a pure + /// fast-forward of the base ref. + pub can_fast_forward: bool, + pub has_upstream: bool, + /// Commits not on the upstream; 0 when `has_upstream` is false. + pub unpushed: usize, +} + +/// Namespace for worktree snapshot refs. Kept out of `refs/heads`/`refs/tags` +/// so archives never show up as branches, but still reachable — a snapshot +/// protects its objects from gc. +const ARCHIVE_NS: &str = "refs/strand/archive/"; + +/// One archived worktree snapshot under [`ARCHIVE_NS`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorktreeArchive { + /// Full ref name, e.g. `refs/strand/archive/feature-x/1751871234`. + pub ref_name: String, + /// Slug segment — the branch (or `detached`) at archive time. + pub name: String, + /// Snapshot commit oid. + pub oid: String, + /// Creation time (Unix seconds), from the ref path segment. + pub time_unix: i64, + pub subject: String, +} + +/// Where [`restore_worktree_archive`](Repo::restore_worktree_archive) put the +/// worktree and whether it could re-attach the original branch. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RestoredWorktree { + pub path: String, + /// The re-attached branch; `None` when the restore stayed detached (the + /// original branch is checked out elsewhere or has moved on). + pub branch: Option, +} + impl Repo { /// List every worktree (main + linked) via `git worktree list --porcelain`. /// The first record is always the main worktree. @@ -103,6 +166,337 @@ impl Repo { run_git(&self.path, &["worktree", "prune"])?; Ok(()) } + + /// Compute [`WorktreeHealth`] for the branch `target` (a worktree's + /// checked-out branch). Callable from any worktree of the family — refs + /// and objects are shared. + pub fn worktree_health(&self, target: &str) -> Result { + let repo = self.git2()?; + let target_tip = repo.revparse_single(target)?.peel_to_commit()?.id(); + + let base = self.detect_base_branch(target)?; + let (base_branch, ahead_of_base, can_fast_forward) = match &base { + Some(hit) => { + let base_tip = repo + .revparse_single(&format!("refs/heads/{}", hit.name))? + .peel_to_commit()? + .id(); + let (ahead, _) = repo.graph_ahead_behind(target_tip, base_tip)?; + (Some(hit.name.clone()), ahead, hit.merge_base == base_tip.to_string()) + } + None => (None, 0, false), + }; + + // Merged = contained in *some* other local branch, not just the + // detected base: with several sibling worktrees cut from one commit + // (the canonical parallel-agent setup), detect_base_branch names a + // sibling after the branch lands in main — the containment scan is + // what answers "is this safe to retire". + let mut merged_into = match (&base_branch, ahead_of_base) { + (Some(b), 0) => Some(b.clone()), + _ => None, + }; + if merged_into.is_none() { + let mut containing: Vec = Vec::new(); + if let Ok(branches) = repo.branches(Some(git2::BranchType::Local)) { + for (b, _) in branches.flatten() { + let name = match b.name() { + Ok(Some(n)) if n != target => n.to_string(), + _ => continue, + }; + let Some(tip) = b.get().target() else { continue }; + let contains = tip == target_tip + || repo + .graph_ahead_behind(target_tip, tip) + .map(|(ahead, _)| ahead == 0) + .unwrap_or(false); + if contains { + containing.push(name); + } + } + } + containing.sort(); + merged_into = containing.into_iter().next(); + } + + let (has_upstream, unpushed) = repo + .find_branch(target, git2::BranchType::Local) + .ok() + .and_then(|b| b.upstream().ok()) + .and_then(|up| up.get().target()) + .and_then(|up_tip| repo.graph_ahead_behind(target_tip, up_tip).ok()) + .map(|(ahead, _)| (true, ahead)) + .unwrap_or((false, 0)); + + Ok(WorktreeHealth { + base_branch, + merged: merged_into.is_some(), + merged_into, + ahead_of_base, + can_fast_forward, + has_upstream, + unpushed, + }) + } + + /// Integrate a worktree's `branch` into `base` — the "merge & clean up" + /// core. `mode` is `"ff"`, `"merge"`, or `"squash"`. + /// + /// When some worktree has `base` checked out, the merge runs *in that + /// directory* (its workdir must be clean; a failed merge is aborted so no + /// half-merged state is left behind). When no worktree holds `base`, only + /// `"ff"` is possible, done as a pure ref move after verifying the base + /// tip is an ancestor of the branch tip — moving a checked-out branch's + /// ref without updating its workdir would desync that worktree. + pub fn integrate_worktree_branch(&self, branch: &str, base: &str, mode: &str) -> Result { + reject_dash("branch", branch)?; + reject_dash("base branch", base)?; + + let holder = self + .worktrees()? + .into_iter() + .find(|w| w.branch.as_deref() == Some(base)); + + let Some(holder) = holder else { + if mode != "ff" { + return Err(Error::Other(format!( + "'{base}' is not checked out in any worktree — a {mode} merge needs a working tree. Check out '{base}' first, or fast-forward instead." + ))); + } + let repo = self.git2()?; + let branch_tip = repo.revparse_single(branch)?.peel_to_commit()?.id(); + let base_ref = format!("refs/heads/{base}"); + let base_tip = repo.revparse_single(&base_ref)?.peel_to_commit()?.id(); + if repo.merge_base(branch_tip, base_tip)? != base_tip { + return Err(Error::Other(format!( + "'{base}' has moved since '{branch}' forked — fast-forward is not possible. Check out '{base}' to merge." + ))); + } + repo.reference( + &base_ref, + branch_tip, + true, + &format!("strand: fast-forward {base} to {branch}"), + )?; + return Ok(format!("Fast-forwarded {base} to {branch}")); + }; + + // Tracked changes only (`-uno`): staged work would silently fold into + // a squash commit and unstaged edits confuse the merge result, but + // untracked files are safe — git itself refuses a merge that would + // clobber one, and neither `merge` nor the squash `commit` touches + // them otherwise. + let dir = Path::new(&holder.path); + let dirty = run_git(dir, &["status", "--porcelain", "-uno"])?; + if !dirty.is_empty() { + return Err(Error::Other(format!( + "'{base}' is checked out at {} with uncommitted changes to tracked files — commit or stash them first", + holder.path + ))); + } + + match mode { + "ff" => run_git(dir, &["merge", "--ff-only", branch]), + "merge" => run_git(dir, &["merge", "--no-ff", "--no-edit", branch]).inspect_err(|_| { + // A conflicted merge leaves MERGE_HEAD; abort so the base + // worktree comes back clean instead of stranded mid-merge. + let _ = run_git(dir, &["merge", "--abort"]); + }), + "squash" => { + run_git(dir, &["merge", "--squash", branch]).inspect_err(|_| { + // --squash conflicts have no MERGE_HEAD; reset --merge + // restores the pre-merge state. + let _ = run_git(dir, &["reset", "--merge"]); + })?; + // git wrote SQUASH_MSG; --no-edit commits it without an editor. + run_git(dir, &["commit", "--no-edit"]).inspect_err(|_| { + let _ = run_git(dir, &["reset", "--merge"]); + }) + } + other => Err(Error::Other(format!("unknown merge mode: {other}"))), + } + } + + /// Snapshot this worktree's full state — HEAD, staged, unstaged, and + /// untracked (ignore rules respected) — into a ref under + /// `refs/strand/archive/`, without touching the working tree or index. + /// The safety net behind every worktree removal: the snapshot commit's + /// tree is the working directory as-is, parented on HEAD, so nothing is + /// lost when the directory goes away. Returns the created ref name. + /// + /// Must be called on a `Repo` opened *at the worktree being archived* + /// (the tree is read from `self.path`). + pub fn archive_worktree_state(&self) -> Result { + let head = run_git(&self.path, &["rev-parse", "HEAD"])?; + // Branch label for the ref slug + subject; detached HEAD has none. + let label = run_git(&self.path, &["symbolic-ref", "--short", "-q", "HEAD"]) + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "detached".to_string()); + + // Build the workdir tree in a throwaway index so the real one is + // never touched. Seed it from the live index when possible — `add -A` + // then reuses the stat cache instead of re-hashing every file. + let tmp = std::env::temp_dir().join(format!( + "strand-archive-index-{}-{}", + std::process::id(), + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos() + )); + let _ = std::fs::copy(self.git_dir().join("index"), &tmp); + let tmp_str = tmp.to_string_lossy().to_string(); + + let result = (|| { + let env: &[(&str, &str)] = &[("GIT_INDEX_FILE", &tmp_str)]; + run_git_env(&self.path, env, &["add", "-A"])?; + let tree = run_git_env(&self.path, env, &["write-tree"])?; + // Always a synthetic commit, even for a clean tree — restore can + // then uniformly unwrap one commit (`reset --mixed HEAD^`). The + // subject names the exact branch and the body the original + // directory, so restore can put both back. + let subject = format!("Worktree archive: {label}"); + let path_note = format!("Path: {}", self.path.to_string_lossy().replace('\\', "/")); + let commit = run_git( + &self.path, + &["commit-tree", &tree, "-p", &head, "-m", &subject, "-m", &path_note], + )?; + let secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + let ref_name = format!("{ARCHIVE_NS}{}/{secs}", slug(&label)); + run_git(&self.path, &["update-ref", &ref_name, &commit])?; + Ok(ref_name) + })(); + let _ = std::fs::remove_file(&tmp); + result + } + + /// List archived worktree snapshots, newest first. + pub fn worktree_archives(&self) -> Result> { + let raw = run_git( + &self.path, + &[ + "for-each-ref", + "--format=%(refname)%00%(objectname)%00%(subject)", + &ARCHIVE_NS[..ARCHIVE_NS.len() - 1], + ], + )?; + let mut out = Vec::new(); + for line in raw.lines() { + let mut parts = line.split('\0'); + let (Some(ref_name), Some(oid), subject) = + (parts.next(), parts.next(), parts.next().unwrap_or("")) + else { + continue; + }; + let tail = ref_name.strip_prefix(ARCHIVE_NS).unwrap_or(ref_name); + // `/`; tolerate slugs that contain '/' themselves. + let (name, secs) = tail.rsplit_once('/').unwrap_or((tail, "0")); + out.push(WorktreeArchive { + ref_name: ref_name.to_string(), + name: name.to_string(), + oid: oid.to_string(), + time_unix: secs.parse().unwrap_or(0), + subject: subject.to_string(), + }); + } + out.sort_by(|a, b| b.time_unix.cmp(&a.time_unix).then(b.ref_name.cmp(&a.ref_name))); + Ok(out) + } + + /// Restore an archived snapshot as a worktree that looks like the one + /// that was removed: check out the snapshot commit detached, unwrap it + /// (`reset --mixed HEAD^`) so the working directory holds the archived + /// state as uncommitted changes on the original HEAD, then put the + /// original identity back where that's unambiguous — + /// + /// - **directory**: the recorded original path when it's free, else the + /// caller's `fallback_dest`, else `-restored`; + /// - **branch**: recreated at the original commit when it was deleted, + /// re-attached when it still exists, isn't held by another worktree, + /// and still points at the archived commit; detached otherwise. + /// + /// The staged/unstaged split is the one thing a snapshot can't preserve. + pub fn restore_worktree_archive( + &self, + ref_name: &str, + fallback_dest: &str, + ) -> Result { + reject_dash("worktree path", fallback_dest)?; + if !ref_name.starts_with(ARCHIVE_NS) { + return Err(Error::Other(format!("not an archive ref: {ref_name}"))); + } + + // Recorded identity: subject names the branch, body the original path + // (older archives lack the path line — they fall through to the + // caller's destination). + let meta = run_git(&self.path, &["show", "-s", "--format=%s%n%b", ref_name])?; + let mut lines = meta.lines(); + let label = lines + .next() + .and_then(|s| s.strip_prefix("Worktree archive: ")) + .map(str::to_string); + let source = lines.find_map(|l| l.strip_prefix("Path: ")).map(str::to_string); + + let suffixed = format!("{fallback_dest}-restored"); + let dest = [source.as_deref(), Some(fallback_dest), Some(suffixed.as_str())] + .into_iter() + .flatten() + .find(|p| !Path::new(p).exists()) + .ok_or_else(|| Error::Other(format!("destination already exists: {fallback_dest}")))? + .to_string(); + + run_git(&self.path, &["worktree", "add", "--detach", &dest, ref_name])?; + let dest_dir = Path::new(&dest); + run_git(dest_dir, &["reset", "--mixed", "HEAD^"])?; + + // Best-effort branch re-attach; any failure just leaves the restore + // detached, which is always a valid state. + let mut attached = None; + if let Some(branch) = label.filter(|l| l != "detached" && !l.starts_with('-')) { + let branch_ref = format!("refs/heads/{branch}"); + let exists = + run_git(&self.path, &["show-ref", "--verify", "--quiet", &branch_ref]).is_ok(); + let ok = if exists { + let held = self + .worktrees()? + .into_iter() + .any(|w| w.branch.as_deref() == Some(branch.as_str())); + let tip = run_git(&self.path, &["rev-parse", &branch_ref]).unwrap_or_default(); + let head = run_git(dest_dir, &["rev-parse", "HEAD"]).unwrap_or_default(); + // Same-commit checkout attaches HEAD without touching the + // restored (dirty) working tree. + !held && !tip.is_empty() && tip == head + && run_git(dest_dir, &["checkout", "-q", &branch]).is_ok() + } else { + run_git(dest_dir, &["checkout", "-q", "-b", &branch]).is_ok() + }; + if ok { + attached = Some(branch); + } + } + + Ok(RestoredWorktree { path: dest, branch: attached }) + } + + /// Delete an archived snapshot ref. The snapshot commit becomes + /// unreachable and is eventually gc'd. + pub fn delete_worktree_archive(&self, ref_name: &str) -> Result<()> { + if !ref_name.starts_with(ARCHIVE_NS) { + return Err(Error::Other(format!("not an archive ref: {ref_name}"))); + } + run_git(&self.path, &["update-ref", "-d", ref_name])?; + Ok(()) + } +} + +/// Reduce a branch label to a safe ref-name segment: anything outside +/// `[A-Za-z0-9._/-]` becomes `-`, and leading dots/dashes are trimmed so the +/// segment can't start a `..`/option-looking component. +fn slug(label: &str) -> String { + let cleaned: String = label + .chars() + .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/') { c } else { '-' }) + .collect(); + let trimmed = cleaned.trim_matches(|c| c == '.' || c == '-' || c == '/'); + if trimmed.is_empty() { "worktree".to_string() } else { trimmed.to_string() } } /// Parse one porcelain record into a [`Worktree`]. Lines seen: @@ -118,6 +512,7 @@ fn parse_record(record: &str, is_main: bool, current: Option<&Path>) -> Option) -> Option) -> Option Result<()> { /// failure. A module-local free fn (not a `Repo` method) so it doesn't collide /// with `stash`'s same-named inherent helper — the shape matches `history`'s. fn run_git(cwd: &Path, args: &[&str]) -> Result { - let out = crate::git_command() - .current_dir(cwd) + run_git_env(cwd, &[], args) +} + +/// [`run_git`] with extra environment variables — the archive path uses a +/// throwaway `GIT_INDEX_FILE` to build a tree without touching the real index. +fn run_git_env(cwd: &Path, envs: &[(&str, &str)], args: &[&str]) -> Result { + let mut cmd = crate::git_command(); + cmd.current_dir(cwd) .env("GIT_TERMINAL_PROMPT", "0") .stdin(std::process::Stdio::null()) .args(crate::GIT_SAFE_CONFIG) - .args(args) + .args(args); + for (k, v) in envs { + cmd.env(k, v); + } + let out = cmd .output() .map_err(|e| Error::Other(format!("spawn git failed: {e}")))?; if !out.status.success() { @@ -259,6 +666,204 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + /// Fresh repo on `main` with one commit; returns the repo dir. + fn setup(tag: &str) -> std::path::PathBuf { + let base = std::env::temp_dir().join(format!( + "strand-wt-{tag}-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&base); + let main = base.join("main"); + std::fs::create_dir_all(&main).unwrap(); + git(&main, &["init", "-q", "-b", "main"]); + git(&main, &["config", "user.name", "Test"]); + git(&main, &["config", "user.email", "test@example.com"]); + git(&main, &["config", "commit.gpgsign", "false"]); + std::fs::write(main.join("a.txt"), "a\n").unwrap(); + git(&main, &["add", "a.txt"]); + git(&main, &["commit", "-q", "-m", "init"]); + main + } + + fn commit_file(dir: &Path, name: &str, content: &str, msg: &str) { + std::fs::write(dir.join(name), content).unwrap(); + git(dir, &["add", name]); + git(dir, &["commit", "-q", "-m", msg]); + } + + #[test] + fn health_reports_merged_ahead_and_can_ff() { + let main = setup("health"); + let feature = main.parent().unwrap().join("feature"); + let repo = Repo::discover(main.to_str().unwrap()).unwrap(); + repo.add_worktree(feature.to_str().unwrap(), "feature", true).unwrap(); + commit_file(&feature, "f.txt", "f\n", "feature work"); + + let h = repo.worktree_health("feature").unwrap(); + assert_eq!(h.base_branch.as_deref(), Some("main")); + assert!(!h.merged); + assert_eq!(h.ahead_of_base, 1); + assert!(h.can_fast_forward, "base has not moved"); + assert!(!h.has_upstream); + + // Base moves on → still unmerged, but no longer a pure fast-forward. + commit_file(&main, "m.txt", "m\n", "main moved on"); + let h = repo.worktree_health("feature").unwrap(); + assert!(!h.merged); + assert!(!h.can_fast_forward); + + // Merge the branch into the base → merged. Sibling branches sitting + // at the fork point (the canonical parallel-agent setup) must not + // hide it: detect_base_branch will name a sibling as base, but the + // containment scan still finds the branch fully in main. + git(&main, &["branch", "sibling-a", "HEAD^"]); + git(&main, &["branch", "sibling-b", "HEAD^"]); + git(&main, &["merge", "-q", "--no-edit", "feature"]); + let h = repo.worktree_health("feature").unwrap(); + assert!(h.merged, "merged despite sibling branches at the fork"); + assert_eq!(h.merged_into.as_deref(), Some("main")); + + let _ = std::fs::remove_dir_all(main.parent().unwrap()); + } + + #[test] + fn integrate_fast_forwards_an_unheld_base() { + let main = setup("ff"); + git(&main, &["branch", "release"]); + let feature = main.parent().unwrap().join("feature"); + let repo = Repo::discover(main.to_str().unwrap()).unwrap(); + repo.add_worktree(feature.to_str().unwrap(), "feature", true).unwrap(); + commit_file(&feature, "f.txt", "f\n", "feature work"); + let feature_tip = git(&feature, &["rev-parse", "HEAD"]); + + // `release` isn't checked out anywhere: a merge needs a worktree… + let err = repo.integrate_worktree_branch("feature", "release", "squash").unwrap_err(); + assert!(err.to_string().contains("not checked out"), "{err}"); + // …but a fast-forward is a pure ref move. + repo.integrate_worktree_branch("feature", "release", "ff").unwrap(); + assert_eq!(git(&main, &["rev-parse", "release"]), feature_tip); + + // A diverged base refuses the ref-move fast-forward. + commit_file(&main, "m.txt", "m\n", "main moved on"); + git(&main, &["branch", "-f", "release2", "main"]); + let err = repo.integrate_worktree_branch("feature", "release2", "ff").unwrap_err(); + assert!(err.to_string().contains("has moved"), "{err}"); + + let _ = std::fs::remove_dir_all(main.parent().unwrap()); + } + + #[test] + fn integrate_merges_and_squashes_in_the_base_worktree() { + let main = setup("merge"); + let repo = Repo::discover(main.to_str().unwrap()).unwrap(); + + // Diverge: two commits on feature, one on main (disjoint files). + let feature = main.parent().unwrap().join("feature"); + repo.add_worktree(feature.to_str().unwrap(), "feature", true).unwrap(); + commit_file(&feature, "f1.txt", "1\n", "feature 1"); + commit_file(&feature, "f2.txt", "2\n", "feature 2"); + commit_file(&main, "m.txt", "m\n", "main moved on"); + let main_tip = git(&main, &["rev-parse", "HEAD"]); + + // Tracked changes in the base worktree refuse the merge… + std::fs::write(main.join("a.txt"), "edited\n").unwrap(); + let err = repo.integrate_worktree_branch("feature", "main", "squash").unwrap_err(); + assert!(err.to_string().contains("uncommitted"), "{err}"); + git(&main, &["checkout", "--", "a.txt"]); + // …but untracked files don't block it (git guards clobbering itself). + std::fs::write(main.join("note.txt"), "keep\n").unwrap(); + + // Squash: exactly one new commit on main, feature's files present. + repo.integrate_worktree_branch("feature", "main", "squash").unwrap(); + assert_eq!(git(&main, &["rev-parse", "HEAD^"]), main_tip, "one commit on top"); + assert!(main.join("f1.txt").exists() && main.join("f2.txt").exists()); + assert!(main.join("note.txt").exists(), "untracked bystander untouched"); + assert_eq!(git(&main, &["status", "--porcelain", "-uno"]), ""); + + // Merge (no-ff): the new HEAD is a real merge commit. + let feature2 = main.parent().unwrap().join("feature2"); + repo.add_worktree(feature2.to_str().unwrap(), "feature2", true).unwrap(); + commit_file(&feature2, "g.txt", "g\n", "feature2 work"); + repo.integrate_worktree_branch("feature2", "main", "merge").unwrap(); + assert!(!git(&main, &["rev-parse", "HEAD^2"]).is_empty(), "merge commit has two parents"); + + let _ = std::fs::remove_dir_all(main.parent().unwrap()); + } + + #[test] + fn archive_snapshots_and_restores_a_worktree() { + let main = setup("archive"); + // Ignore rules must hold in the snapshot too. + std::fs::write(main.join(".gitignore"), "ignored.txt\n").unwrap(); + git(&main, &["add", ".gitignore"]); + git(&main, &["commit", "-q", "-m", "ignore rules"]); + + let repo = Repo::discover(main.to_str().unwrap()).unwrap(); + let feature = main.parent().unwrap().join("feature"); + repo.add_worktree(feature.to_str().unwrap(), "feature", true).unwrap(); + commit_file(&feature, "f.txt", "committed\n", "feature work"); + let feature_tip = git(&feature, &["rev-parse", "HEAD"]); + + // Dirty state: modified tracked + untracked + ignored. + std::fs::write(feature.join("f.txt"), "modified\n").unwrap(); + std::fs::write(feature.join("new.txt"), "untracked\n").unwrap(); + std::fs::write(feature.join("ignored.txt"), "noise\n").unwrap(); + + let wt_repo = Repo::discover(feature.to_str().unwrap()).unwrap(); + let ref_name = wt_repo.archive_worktree_state().unwrap(); + assert!(ref_name.starts_with("refs/strand/archive/feature/"), "{ref_name}"); + + // The worktree itself was not disturbed by archiving. + let porcelain = git(&feature, &["status", "--porcelain"]); + assert!(porcelain.contains("f.txt") && porcelain.contains("new.txt"), "{porcelain}"); + + let archives = repo.worktree_archives().unwrap(); + assert_eq!(archives.len(), 1); + assert_eq!(archives[0].name, "feature"); + assert_eq!(archives[0].ref_name, ref_name); + assert!(archives[0].subject.contains("feature")); + + // Blow the worktree away, then restore the snapshot. The original + // directory is free again and the branch still points at the + // archived commit, so the restore puts both identities back. + repo.remove_worktree(feature.to_str().unwrap(), true).unwrap(); + let fallback = main.parent().unwrap().join("restored"); + let res = repo.restore_worktree_archive(&ref_name, fallback.to_str().unwrap()).unwrap(); + let restored = Path::new(&res.path).to_path_buf(); + assert_eq!( + restored.canonicalize().unwrap(), + feature.canonicalize().unwrap(), + "restored into the original directory" + ); + assert_eq!(res.branch.as_deref(), Some("feature")); + assert_eq!(git(&restored, &["symbolic-ref", "--short", "HEAD"]), "feature"); + assert_eq!(git(&restored, &["rev-parse", "HEAD"]), feature_tip, "back on the original commit"); + // trim: checkout may rewrite line endings (core.autocrlf on Windows). + assert_eq!(std::fs::read_to_string(restored.join("f.txt")).unwrap().trim(), "modified"); + assert_eq!(std::fs::read_to_string(restored.join("new.txt")).unwrap().trim(), "untracked"); + assert!(!restored.join("ignored.txt").exists(), "ignored files stay out of snapshots"); + let porcelain = git(&restored, &["status", "--porcelain"]); + assert!(porcelain.contains("f.txt") && porcelain.contains("new.txt"), "{porcelain}"); + + // A second restore of the same snapshot: the original directory and + // branch are taken now, so it lands at the fallback, detached. + let res2 = repo.restore_worktree_archive(&ref_name, fallback.to_str().unwrap()).unwrap(); + assert_eq!( + Path::new(&res2.path).canonicalize().unwrap(), + fallback.canonicalize().unwrap(), + "fell back to the caller's destination" + ); + assert!(res2.branch.is_none(), "branch is held by the first restore"); + + // Guarded deletion: only archive refs may be deleted. + assert!(repo.delete_worktree_archive("refs/heads/main").is_err()); + repo.delete_worktree_archive(&ref_name).unwrap(); + assert!(repo.worktree_archives().unwrap().is_empty()); + + let _ = std::fs::remove_dir_all(main.parent().unwrap()); + } + #[test] fn rejects_dash_leading_args() { let base = std::env::temp_dir().join(format!( diff --git a/crates/strand-tauri/src/commands.rs b/crates/strand-tauri/src/commands.rs index 38d55eb..34eda01 100644 --- a/crates/strand-tauri/src/commands.rs +++ b/crates/strand-tauri/src/commands.rs @@ -23,7 +23,8 @@ use strand_core::{ reflog::ReflogEntry, refs::{BaseBranch, Refs}, repo::RepoMeta, reset::{ResetMode, ResetOutcome}, snapshot::Snapshot, stash::{Stash, StashOutcome}, - status::FileStatus, submodule::Submodule, tree::WorkTreeEntry, worktree::Worktree, Repo, + status::FileStatus, submodule::Submodule, tree::WorkTreeEntry, + worktree::{RestoredWorktree, Worktree, WorktreeArchive, WorktreeHealth}, Repo, }; use tauri::ipc::Channel; use tauri::{Emitter, State}; @@ -546,24 +547,96 @@ pub async fn repo_worktrees(path: String) -> CmdResult> { run_blocking("worktrees", move || Ok(Repo::discover(&path)?.worktrees()?)).await } +// The worktree lifecycle commands all wait on `git` subprocesses (add even +// runs a full checkout + the user's post-checkout hook), so they route +// through `run_blocking` like the other subprocess-backed commands. #[tauri::command(async)] -pub fn repo_worktree_add( +pub async fn repo_worktree_add( path: String, dest: String, branch: String, new_branch: bool, ) -> CmdResult<()> { - Ok(Repo::discover(&path)?.add_worktree(&dest, &branch, new_branch)?) + run_blocking("worktree add", move || { + Ok(Repo::discover(&path)?.add_worktree(&dest, &branch, new_branch)?) + }) + .await +} + +#[tauri::command(async)] +pub async fn repo_worktree_remove(path: String, dest: String, force: bool) -> CmdResult<()> { + run_blocking("worktree remove", move || { + Ok(Repo::discover(&path)?.remove_worktree(&dest, force)?) + }) + .await +} + +#[tauri::command(async)] +pub async fn repo_worktree_prune(path: String) -> CmdResult<()> { + run_blocking("worktree prune", move || Ok(Repo::discover(&path)?.prune_worktrees()?)).await +} + +/// Ref-level health of a worktree's branch (merged into base? unpushed? +/// fast-forwardable?) — the overview's badge + cleanup data. +#[tauri::command(async)] +pub async fn repo_worktree_health(path: String, target: String) -> CmdResult { + run_blocking("worktree health", move || { + Ok(Repo::discover(&path)?.worktree_health(&target)?) + }) + .await +} + +/// Merge a worktree's branch into its base (`mode`: "ff" | "merge" | +/// "squash") — the "merge & clean up" flow's integration step. +#[tauri::command(async)] +pub async fn repo_worktree_integrate( + path: String, + branch: String, + base: String, + mode: String, +) -> CmdResult { + run_blocking("worktree integrate", move || { + Ok(Repo::discover(&path)?.integrate_worktree_branch(&branch, &base, &mode)?) + }) + .await +} + +/// Snapshot the worktree at `path` (HEAD + staged + unstaged + untracked) +/// into an archive ref; the safety net taken before any worktree removal. +#[tauri::command(async)] +pub async fn repo_worktree_archive(path: String) -> CmdResult { + run_blocking("worktree archive", move || { + Ok(Repo::discover(&path)?.archive_worktree_state()?) + }) + .await } #[tauri::command(async)] -pub fn repo_worktree_remove(path: String, dest: String, force: bool) -> CmdResult<()> { - Ok(Repo::discover(&path)?.remove_worktree(&dest, force)?) +pub async fn repo_worktree_archives(path: String) -> CmdResult> { + run_blocking("worktree archives", move || Ok(Repo::discover(&path)?.worktree_archives()?)).await } +/// Restore an archived snapshot as a worktree — original directory + branch +/// when they're free, `dest`/detached as fallbacks; archived changes come +/// back as uncommitted workdir state. #[tauri::command(async)] -pub fn repo_worktree_prune(path: String) -> CmdResult<()> { - Ok(Repo::discover(&path)?.prune_worktrees()?) +pub async fn repo_worktree_archive_restore( + path: String, + ref_name: String, + dest: String, +) -> CmdResult { + run_blocking("worktree restore", move || { + Ok(Repo::discover(&path)?.restore_worktree_archive(&ref_name, &dest)?) + }) + .await +} + +#[tauri::command(async)] +pub async fn repo_worktree_archive_delete(path: String, ref_name: String) -> CmdResult<()> { + run_blocking("worktree archive delete", move || { + Ok(Repo::discover(&path)?.delete_worktree_archive(&ref_name)?) + }) + .await } #[tauri::command(async)] diff --git a/crates/strand-tauri/src/main.rs b/crates/strand-tauri/src/main.rs index 6d5a371..960f694 100644 --- a/crates/strand-tauri/src/main.rs +++ b/crates/strand-tauri/src/main.rs @@ -109,6 +109,12 @@ fn main() { commands::repo_worktree_add, commands::repo_worktree_remove, commands::repo_worktree_prune, + commands::repo_worktree_health, + commands::repo_worktree_integrate, + commands::repo_worktree_archive, + commands::repo_worktree_archives, + commands::repo_worktree_archive_restore, + commands::repo_worktree_archive_delete, commands::repo_branch_create, commands::repo_branch_delete, commands::repo_branch_rename, diff --git a/docs/improvements.md b/docs/improvements.md index 7e996a5..e98c11d 100644 --- a/docs/improvements.md +++ b/docs/improvements.md @@ -312,3 +312,205 @@ escape hatch (PRD §6.4 P1) keeps the modal simple while unblocking the hard cas Items 2–4 together are a coherent 0.6 theme: **"watch the agent work, review it fast, accept or reject it safely."** That's a positioning no mainstream git client (Tower, Fork, GitKraken) currently owns. + +--- + +# Worktrees pass (2026-07-07) — proposals + +> From a research sweep across (a) Strand's current worktree code, (b) how other +> tools expose worktrees (Fork, Tower, GitKraken Agent Mode, SmartGit, lazygit, +> Zed, JetBrains 2026.1, Conductor, Vibe Kanban, Claude Squad, Cursor, Claude +> Code's own `--worktree`), and (c) how developers actually run parallel AI +> agents on worktrees. Menu, not commitment. + +> **Implementation status (2026-07-07):** W1 + W2 + W3 landed the same day — +> `Repo::worktree_health` / `integrate_worktree_branch` / +> `archive_worktree_state` + restore/list/delete in `worktree.rs`, six new IPC +> commands, merged/unpushed badges + Clean up + Prune on the overview, +> `WorktreeMergeDialog` with exact-command preview, archive-before-every-remove +> in the store, and a restorable "Archived snapshots" strip. The +> `run_blocking` fix from W9 rode along (the other W9 items and W1's +> sidebar/review-header entry points are follow-up `☐`s in TASKS.md, as is +> auto-pruning old archives). W4–W8 remain open proposals. + +**Where the market landed (2025–2026).** Every agent orchestrator (Conductor, +Vibe Kanban, Cursor, GitKraken Agent Mode, Claude Code itself) rebuilt the same +Git-client subset: worktree list → diff review → merge+cleanup. The pure +worktree multiplexers died or pivoted (Crystal deprecated, Vibe Kanban shut +down, Terragon shut down, Sculptor pivoted); the survivors are the +**review-centric** ones. Strand approaches from the other side — it already has +the review surface — so the play is to own the worktree *lifecycle around +review*, not to become an agent launcher. + +**What Strand already has** (all shipped): worktrees dashboard (Mod+5) with +per-row Review at the detected merge-base (DAN-14), create dialog with sibling +`.worktrees/` default, family grouping by `common_dir` everywhere +(rail, tabs, workspaces), branch-held-elsewhere → navigate-to-worktree instead +of erroring (the Fork/SmartGit pattern), per-worktree workspace review members. +That's already ahead of Fork/Tower. The gaps are lifecycle (setup, cleanup, +merge-back) and fleet legibility. + +## W1. Close the review loop: merge-and-clean-up as one action (P0) + +The canonical agent-worktree session ends with "this is good — land it and make +the worktree go away". Today that's N manual steps across two tabs. Every tool +studied converged on one command (Worktrunk `wt merge`, Crystal's +"Squash and rebase to main", Vibe Kanban's one-click merge, Cursor +`/apply-worktree`). + +- After a worktree review, offer **"Merge into & remove worktree"**: + squash or merge the worktree branch onto its detected base branch + (`refs.rs::detect_base_branch` already knows the parent), fast-forward the + parent's worktree if clean, then `worktree remove` + `branch -d`. +- Crystal's touch worth copying: the confirm dialog **previews the exact git + commands** it will run. Trust through legibility. +- Guard rails: refuse (or downgrade to "merge only") when the worktree has + uncommitted changes; take a snapshot ref first (W3). +- Surface it in three places: the Worktrees overview row, the review header + when reviewing a worktree session, and the sidebar worktree context menu. + +## W2. Dirty-aware cleanup + merged detection (P0) + +Stale worktrees pile up because deletion is scary — some hold unpushed agent +work. The safety pattern that separates "trustworthy" from "scary" +(Claude Code's exit policy, GitKraken's merged-PR pill): + +- Classify each worktree: **clean+merged** (branch fully merged into its base — + cheap `merge_base == branch tip` check), **clean+unmerged**, + **dirty**, **has unpushed commits**. Show it as a badge; GitKraken's + "merged" pill is the cleanup cue users act on. +- Dashboard-level **"Clean up" action**: one click removes all clean+merged + worktrees and their branches; dirty/unpushed ones are listed but require + explicit per-row confirmation. Never blind-sweep. +- Surface `prunable ` from `worktree list --porcelain` (already parsed + into `is_prunable`, reason is dropped — `worktree.rs` keeps only the flag) and + add a dashboard **Prune** button; today prune hides in a sidebar context menu + that only appears on prunable rows (`Sidebar.tsx:705-707`). +- Display `lock_reason` — parsed, typed, carried to the frontend + (`types.ts:270`) and never rendered anywhere. Dead data today. + +## W3. Archive-before-remove: snapshot ref safety net (P1) + +Conductor's best idea: before removing a worktree, snapshot **committed + +uncommitted + untracked** state into a git ref, restorable later. Removes all +fear from cleanup, which is the root cause of stale pile-up. + +- Strand already has the no-touch snapshot-stash infrastructure (`stash.rs`, + used by the discard safety net). Extend: on any worktree remove, create a + snapshot (e.g. `refs/strand/worktree-archive/@`), keep last N, + auto-prune after a few weeks. +- A small "Archive" section (or filter on the Worktrees overview) lists + snapshots with one-click **Restore as new worktree**. +- This also unlocks Claude Squad's cheap **pause/resume**: "Shelve worktree" = + snapshot + remove directory + keep branch; "Resume" = re-add worktree from + branch. Reclaims disk (the #3 complaint: 9.8 GB for two worktrees of a 2 GB + repo) without losing anything. + +## W4. Setup: copy-list + honor agent-tool conventions (P1) + +The single most-cited worktree pain everywhere: gitignored files (`.env`, +`settings.local`, `.venv`) don't exist in a fresh worktree, so agents can't +even run tests. The field converged on a declarative copy-list + post-create +script: + +- On worktree create, **copy gitignored files matching a pattern list** from + the source worktree. Honor existing config the user already has — + `.worktreeinclude` (Claude Code), `git.worktreeIncludeFiles`-style globs — + before inventing a Strand-only format; fall back to a per-repo setting with + a sane default offer ("Copy .env* into the new worktree?"). +- Optional **post-create command** (per-repo setting, e.g. `pnpm i`), run with + `STRAND_WORKTREE_PATH` / `STRAND_ROOT_PATH` env vars, output streamed into + the progress toast. Explicitly opt-in — Strand is a client, not a launcher. +- Recognize the naming conventions agents generate so the dashboard reads + well: `worktree-` / `.claude/worktrees/` (Claude Code), `vk/-slug` + (Vibe Kanban), `/` prefixes — strip the noise in `worktreeName` labels + and badge the tool that created it when detectable. + +## W5. Fleet dashboard: status at a glance (P1) + +The wishlist item users state most often: "which worktree has which task, and +is anything happening in it?" The overview (`views/Worktrees.tsx`) already has +per-row dirty/ahead-behind/drift; missing: + +- **Last-activity time** (mtime of index / newest workdir change) — "touched + 3 min ago" is a decent proxy for "agent is working" without process + spying; sort recent-first. +- **Merged / unpushed / prunable-reason / lock-reason badges** (from W2). +- **Per-worktree disk size** (background `du`, cached) — makes the cost of + stale trees visible and motivates cleanup. +- **Uncommitted-summary line** ("+412 −38 across 23 files") so triage doesn't + require opening each tab. The existing lazy `repoStatus` per-row fetch + already has the data. +- Optional later: detect a running agent by lock holder or heuristics + (Claude Code holds `git worktree lock` while a session runs — read the + reason string). + +## W6. Best-of-N: compare sibling attempts (P1, review differentiator) + +Running the same task in 2–4 worktrees and picking the winner is now a +first-class pattern (Cursor `/best-of-n` productized it). Nobody offers a good +*comparison* UI — that's a review problem, i.e. Strand's home turf. + +- Let the user **select 2+ worktrees of one family** in the overview and open + a compare view: same baseline (shared merge-base), side-by-side or tabbed + per-attempt diffs, file-list intersection highlighted ("both touched + `auth.ts`, only attempt B touched migrations"). +- Verdict: **pick winner** → offer W1 merge for it and W2/W3 cleanup for the + losers in one flow. +- Cheap v1: even just "open N worktree review sessions as adjacent tabs with + synchronized file focus" beats everything on the market. + +## W7. Cross-worktree overlap warning (P2, nobody does this) + +Parallel agents silently diverge and conflicts only surface at merge time +(GitButler's core critique of worktrees). Strand sees all worktrees of a +family and their diffs-vs-base: + +- Compute pairwise **file-set overlap** between dirty worktrees of a family; + badge the dashboard rows ("overlaps fix-auth: 3 files") and warn in the W1 + merge dialog when a sibling worktree touches the same files. +- Full conflict prediction (merge simulation) is overkill; path-set + intersection catches most of it for free from data already fetched. + +## W8. Create-from-anything (P2) + +The create dialog is HEAD-only (`WorktreeDialog.tsx:142`; `add_worktree` +hard-codes `-b `). Lazygit puts "new worktree" in seven panels; +GitKraken creates from a PR. For Strand: + +- **Start-point picker** in the dialog: any local/remote branch, tag, or + commit; remote branches create with `--track`. +- **"New worktree from here"** in the branch and commit context menus (the + branch menu already grew "Review vs this" — same pattern). +- **Fetch-first option**: "base on origin/HEAD (fetch first)" checkbox — the + Conductor/Claude Code default that avoids building on a stale local main. +- Engine: extend `add_worktree` with start-point + track flags; add + `worktree lock/unlock` (reason string), `move`, `repair` while in there — + all missing, all trivial shell-outs (`worktree.rs`). + +## W9. Small fixes surfaced by the audit (P2, quick) + +- `repo_worktree_add/remove/prune` are `#[tauri::command(async)]` but call the + blocking git shell-out directly (`commands.rs:549-567`) — route through + `run_blocking` like `repo_worktrees`; a slow post-checkout hook currently + blocks an executor thread. +- "Review vs base" is only reachable from the overview; add it to the sidebar + worktree context menu (`Sidebar.tsx:690-709`) and rail/tab menus. +- Keybinding copy drift: Worktrees is Mod+5 (`keys.ts:81`) but ROADMAP/prose + still say ⌘4. + +## Suggested sequencing (worktrees) + +1. **W9 + W2** — safety and legibility first; small engine surface + (merged-check, prunable reason, lock reason) with immediate dashboard value. +2. **W1 merge-and-cleanup** riding on W2's classification, with W3's snapshot + as its guard rail (build W3's snapshot op first, UI later). +3. **W4 setup copy-list** — table stakes for the agent audience; honor + `.worktreeinclude` for free adoption. +4. **W5 fleet dashboard** polish, then **W6 best-of-N** as the flagship + review differentiator, with **W7 overlap warnings** feeding both. +5. **W8** whenever the dialog is next touched. + +W1–W3 together make the pitch: **"the only Git client where cleaning up after +an agent is one safe click."** W6+W7 make the second pitch: **"the only place +to compare N agent attempts and land the winner."** diff --git a/ui/src/lib/repoIdentity.test.ts b/ui/src/lib/repoIdentity.test.ts index f363e7e..67d7201 100644 --- a/ui/src/lib/repoIdentity.test.ts +++ b/ui/src/lib/repoIdentity.test.ts @@ -37,6 +37,7 @@ function worktree(overrides: Partial): Worktree { is_locked: false, lock_reason: null, is_prunable: false, + prune_reason: null, is_main: false, is_current: false, ...overrides, diff --git a/ui/src/lib/tauri.ts b/ui/src/lib/tauri.ts index 3da085e..d885636 100644 --- a/ui/src/lib/tauri.ts +++ b/ui/src/lib/tauri.ts @@ -28,12 +28,15 @@ import type { RepoMeta, ResetMode, ResetOutcome, + RestoredWorktree, Snapshot, Stash, StashOutcome, Submodule, WorkTreeEntry, Worktree, + WorktreeArchive, + WorktreeHealth, } from './types'; /** @@ -200,6 +203,24 @@ export const tauri = { repoWorktreeRemove: (path: string, dest: string, force: boolean) => invoke('repo_worktree_remove', { path, dest, force }), repoWorktreePrune: (path: string) => invoke('repo_worktree_prune', { path }), + // Ref-level health of a worktree's branch: merged into its base? unpushed + // work? fast-forwardable? Powers the overview badges + merge dialog. + repoWorktreeHealth: (path: string, target: string) => + invoke('repo_worktree_health', { path, target }), + // Merge a worktree branch into its base ("ff" | "merge" | "squash"). + repoWorktreeIntegrate: (path: string, branch: string, base: string, mode: string) => + invoke('repo_worktree_integrate', { path, branch, base, mode }), + // Snapshot the worktree at `path` into an archive ref (safety net before + // removal); returns the created ref name. + repoWorktreeArchive: (path: string) => invoke('repo_worktree_archive', { path }), + repoWorktreeArchives: (path: string) => + invoke('repo_worktree_archives', { path }), + // `dest` is only the fallback — restore prefers the snapshot's recorded + // original directory and re-attaches its branch when both are free. + repoWorktreeArchiveRestore: (path: string, refName: string, dest: string) => + invoke('repo_worktree_archive_restore', { path, refName, dest }), + repoWorktreeArchiveDelete: (path: string, refName: string) => + invoke('repo_worktree_archive_delete', { path, refName }), repoBranchCreate: ( path: string, name: string, diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index 34033df..fcef939 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -270,12 +270,51 @@ export interface Worktree { lock_reason: string | null; /** git considers this worktree's directory missing/removable. */ is_prunable: boolean; + /** Why git considers it prunable, when a reason was given. */ + prune_reason: string | null; /** The primary worktree (holds the repo's own `.git`). */ is_main: boolean; /** Matches the currently-open repo path. */ is_current: boolean; } +/** Ref-level health of a worktree's branch relative to its detected base. */ +export interface WorktreeHealth { + /** The branch this one forked from; `null` when undetectable. */ + base_branch: string | null; + /** Every commit lives in some other local branch (safe to retire). */ + merged: boolean; + /** The branch `merged` refers to; `null` when not merged. */ + merged_into: string | null; + /** Commits on the branch that are not in the base. */ + ahead_of_base: number; + /** Base tip is still the fork point, so integrating is a pure fast-forward. */ + can_fast_forward: boolean; + has_upstream: boolean; + /** Commits not on the upstream; 0 when `has_upstream` is false. */ + unpushed: number; +} + +/** Where a snapshot restore put the worktree, and the re-attached branch. */ +export interface RestoredWorktree { + path: string; + /** `null` when the restore stayed detached (branch held or moved on). */ + branch: string | null; +} + +/** One archived worktree snapshot under `refs/strand/archive/`. */ +export interface WorktreeArchive { + /** Full ref name, e.g. `refs/strand/archive/feature-x/1751871234`. */ + ref_name: string; + /** Slug segment — the branch (or `detached`) at archive time. */ + name: string; + /** Snapshot commit oid. */ + oid: string; + /** Creation time (Unix seconds). */ + time_unix: number; + subject: string; +} + /** One line of `git blame` output for a file at HEAD. */ export interface BlameLine { /** 1-based line number. */ diff --git a/ui/src/stores/repo.ts b/ui/src/stores/repo.ts index e7acac0..2dcebd5 100644 --- a/ui/src/stores/repo.ts +++ b/ui/src/stores/repo.ts @@ -1292,6 +1292,16 @@ export const useRepo = create((set, get) => ({ async removeWorktree(dest, force) { const path = get().activePath; if (!path) throw new Error('no repo open'); + // Safety net: snapshot the worktree's full state (HEAD + uncommitted + + // untracked) into an archive ref before the directory goes away — a + // force-remove is then always recoverable from the Worktrees overview. + // Best-effort: a prunable entry has no directory to archive, and git's + // own dirty guard still protects the non-force path. + try { + await tauri.repoWorktreeArchive(dest); + } catch (e) { + console.warn('worktree archive before remove failed', e); + } await tauri.repoWorktreeRemove(path, dest, force); // The worktree's directory is gone now — close its tab if it was open, so a // dead tab doesn't linger pointing at a removed worktree. diff --git a/ui/src/styles/features.css b/ui/src/styles/features.css index e093553..3fb94f8 100644 --- a/ui/src/styles/features.css +++ b/ui/src/styles/features.css @@ -5308,6 +5308,119 @@ select.clone-input { cursor: pointer; } +/* Hero action stack: create + clean-up + prune in the hero's third column. */ +.wt-hero-actions { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 6px; +} +.wt-hero-actions .btn { + justify-content: center; +} + +/* Health badges (W2): merged = safe to retire, warn = work only exists here. */ +.wt-tag.merged { + color: var(--add); +} +.wt-tag.warn { + color: var(--warn); +} + +/* Merge dialog: exact-commands preview + dirty-files warning. */ +.wt-merge-preview { + margin: 4px 0 0; + padding: 8px 10px; + border: 0.5px solid var(--border); + border-radius: 8px; + background: var(--bg-elev); + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.5; + color: var(--text-muted); + white-space: pre-wrap; + word-break: break-all; +} +.wt-merge-warn { + color: var(--warn); +} +.stash-check.disabled { + opacity: 0.55; +} + +/* Cleanup confirm: the list of worktrees about to be retired. */ +.wt-cleanup-list { + margin: 4px 0 0; + padding: 0 0 0 18px; + font-size: var(--type-ui-sm); +} +.wt-cleanup-list li { + margin: 3px 0; + overflow-wrap: anywhere; +} + +/* Archived snapshots (W3): collapsible strip under the worktree list. */ +.wt-archives { + flex-shrink: 0; + border-top: 0.5px solid var(--border); + max-height: 32%; + display: flex; + flex-direction: column; +} +.wt-archives-head { + display: flex; + align-items: center; + gap: 7px; + padding: 8px 16px; + border: none; + background: none; + color: var(--text-2); + font-size: var(--type-ui-sm); + cursor: pointer; + text-align: left; +} +.wt-archives-head:hover { + color: var(--text); +} +.wt-archives-caret { + width: 10px; + color: var(--text-dim); +} +.wt-archives-count { + padding: 0 6px; + border-radius: 8px; + background: var(--bg-elev); + border: 0.5px solid var(--border); + font-size: 10px; +} +.wt-archives-list { + overflow-y: auto; + padding: 0 12px 10px; + display: flex; + flex-direction: column; + gap: 4px; +} +.wt-archive-row { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 8px; + border-radius: 8px; + border: 0.5px solid var(--border); + background: var(--bg-elev); + font-size: var(--type-ui-sm); +} +.wt-archive-name { + font-family: var(--font-mono); + color: var(--text-2); +} +.wt-archive-actions { + margin-left: auto; + display: flex; + align-items: center; + gap: 6px; +} + /* ─── In-diff text search (⌘F, Local Changes + Review) ─────────────────── */ /* Positioning anchor wrapping the diff pane, so the floating bar pins to its diff --git a/ui/src/views/WorktreeMergeDialog.tsx b/ui/src/views/WorktreeMergeDialog.tsx new file mode 100644 index 0000000..0a3bb4a --- /dev/null +++ b/ui/src/views/WorktreeMergeDialog.tsx @@ -0,0 +1,301 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { Icon } from '../components/Icon'; +import { errMessage, tauri } from '../lib/tauri'; +import { useRepo } from '../stores/repo'; +import type { Worktree, WorktreeHealth } from '../lib/types'; + +type Mode = 'squash' | 'merge' | 'ff'; + +/** + * "Merge & clean up" — land a worktree's branch on its detected base and + * (optionally) retire the worktree + branch in the same motion. The preview + * box shows the exact git commands that will run: trust through legibility. + * + * The merge itself runs in whichever worktree has the base checked out (its + * workdir must be clean); when none does, only a pure ref fast-forward is + * offered. Cleanup always archives a full snapshot first (see + * `removeWorktree` in the repo store), so nothing is unrecoverable. + */ +export function WorktreeMergeDialog({ + worktree, + health, + dirty, + onClose, + onToast, +}: { + worktree: Worktree; + health: WorktreeHealth; + /** Uncommitted files in the worktree — they won't be part of the merge. */ + dirty: number; + onClose: () => void; + onToast: (msg: string, kind?: 'success' | 'error') => void; +}) { + const activePath = useRepo((s) => s.activePath); + const worktrees = useRepo((s) => s.worktrees); + const refs = useRepo((s) => s.refs); + const removeWorktree = useRepo((s) => s.removeWorktree); + const refreshWorktrees = useRepo((s) => s.refreshWorktrees); + const refreshRefs = useRepo((s) => s.refreshRefs); + const refreshLocalChanges = useRepo((s) => s.refreshLocalChanges); + + const branch = worktree.branch ?? ''; + // The detected base is a heuristic — with several sibling worktrees cut + // from one commit it can name a sibling — so the target is user-editable. + const [base, setBase] = useState(health.base_branch ?? ''); + const baseOptions = useMemo( + () => refs.branches.map((b) => b.name).filter((n) => n !== branch), + [refs, branch], + ); + const baseTip = refs.branches.find((b) => b.name === base)?.target ?? null; + // Where the merge will run; without a checkout of the base, only a pure + // ref fast-forward is possible. + const holder = useMemo( + () => worktrees.find((w) => w.branch === base) ?? null, + [worktrees, base], + ); + + const [mode, setMode] = useState(holder ? 'squash' : 'ff'); + const [cleanup, setCleanup] = useState(!worktree.is_current); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + // Fast-forward is possible iff the base tip is still the fork point — + // recomputed per selected base (null while the merge-base lookup runs). + const [ffPossible, setFfPossible] = useState(null); + useEffect(() => { + let cancelled = false; + setFfPossible(null); + if (!activePath || !branch || !base || !baseTip) return; + void tauri + .repoMergeBase(activePath, branch, base) + .then((mb) => { if (!cancelled) setFfPossible(mb === baseTip); }) + .catch(() => { if (!cancelled) setFfPossible(false); }); + return () => { cancelled = true; }; + }, [activePath, branch, base, baseTip]); + const dialogRef = useRef(null); + const mountedRef = useRef(true); + // Re-arm on mount — StrictMode's dev remount reuses the same ref, so a + // cleanup-only effect would leave it permanently false (frozen busy state). + useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); + + // Restore focus to the opener on close. + useEffect(() => { + const prev = document.activeElement as HTMLElement | null; + return () => prev?.focus?.(); + }, []); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !busy) onClose(); + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [busy, onClose]); + + function onTrapKeyDown(e: React.KeyboardEvent) { + if (e.key !== 'Tab' || !dialogRef.current) return; + const focusables = Array.from( + dialogRef.current.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])', + ), + ); + if (focusables.length === 0) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + + // Changing the base can invalidate the chosen mode: no checkout of the + // base leaves only ff, and a moved base rules ff out. + useEffect(() => { + if (!holder && mode !== 'ff') setMode('ff'); + else if (holder && mode === 'ff' && ffPossible === false) setMode('squash'); + }, [holder, mode, ffPossible]); + + const canSubmit = !busy && !!branch && !!base && (mode !== 'ff' || ffPossible === true); + + const commands = useMemo(() => { + const lines: string[] = []; + if (holder) { + const cd = `git -C ${holder.path}`; + if (mode === 'squash') { + lines.push(`${cd} merge --squash ${branch}`, `${cd} commit --no-edit`); + } else if (mode === 'merge') { + lines.push(`${cd} merge --no-ff --no-edit ${branch}`); + } else { + lines.push(`${cd} merge --ff-only ${branch}`); + } + } else { + lines.push(`git update-ref refs/heads/${base} ${branch} # ${base} is not checked out`); + } + if (cleanup) { + lines.push('# full snapshot → refs/strand/archive/… (restorable)'); + lines.push(`git worktree remove --force ${worktree.path}`); + lines.push(`git branch -D ${branch}`); + } + return lines; + }, [holder, mode, cleanup, branch, base, worktree.path]); + + async function submit() { + if (!canSubmit || !activePath) return; + setBusy(true); + setError(null); + try { + await tauri.repoWorktreeIntegrate(activePath, branch, base, mode); + if (cleanup) { + // removeWorktree archives the worktree's full state first. + await removeWorktree(worktree.path, true); + // -D: a squashed branch never reads as "merged" to git's own check. + await tauri.repoBranchDelete(activePath, branch, true); + } + await Promise.all([refreshWorktrees(), refreshRefs(), refreshLocalChanges()]); + onToast( + cleanup + ? `Merged ${branch} into ${base} and removed the worktree` + : `Merged ${branch} into ${base}`, + ); + onClose(); + } catch (e) { + if (mountedRef.current) setError(errMessage(e)); + } finally { + if (mountedRef.current) setBusy(false); + } + } + + const modeOption = (value: Mode, label: string, hint: string, disabled: boolean) => ( + + ); + + return ( +
{ + if (e.target === e.currentTarget && !busy) onClose(); + }} + > +
+
+ + Merge & clean up + +
+ +
+

+ Land {branch} + {base === health.base_branch + ? ` (${health.ahead_of_base} commit${health.ahead_of_base === 1 ? '' : 's'})` + : ''}{' '} + on the branch below + {holder ? '' : ` — ${base} isn't checked out anywhere, so only a fast-forward is possible`}. +

+ + + + {modeOption( + 'squash', + 'Squash into one commit', + 'One tidy commit on the base — agent WIP history stays out.', + !holder, + )} + {modeOption( + 'merge', + 'Merge commit', + 'Keeps every commit and records the merge.', + !holder, + )} + {modeOption( + 'ff', + 'Fast-forward only', + ffPossible === null + ? 'Checking whether the base has moved since the fork…' + : ffPossible + ? `${base} hasn't moved since the fork — the ref just moves up.` + : `${base} has moved since the fork — fast-forward isn't possible.`, + ffPossible !== true, + )} + + + + {dirty > 0 && ( +

+ {dirty} uncommitted file{dirty === 1 ? '' : 's'} in this worktree won't be merged + {cleanup ? ' — they are kept in the archived snapshot' : ''}. +

+ )} + +
+            {commands.join('\n')}
+          
+ + {error ?
{error}
: null} +
+ +
+ + +
+
+
+ ); +} diff --git a/ui/src/views/Worktrees.tsx b/ui/src/views/Worktrees.tsx index efc1bc7..bfa97b8 100644 --- a/ui/src/views/Worktrees.tsx +++ b/ui/src/views/Worktrees.tsx @@ -1,11 +1,12 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { KeyboardEvent } from 'react'; import { Icon } from '../components/Icon'; import { pathLeaf, repoFamilyName, worktreeName } from '../lib/repoIdentity'; import { errMessage, tauri } from '../lib/tauri'; import { useRepo } from '../stores/repo'; -import type { Worktree } from '../lib/types'; +import type { Worktree, WorktreeArchive, WorktreeHealth } from '../lib/types'; +import { WorktreeMergeDialog } from './WorktreeMergeDialog'; interface WtStats { loading: boolean; @@ -14,6 +15,8 @@ interface WtStats { behind: number; lastSubject: string | null; lastTime: number | null; + /** Ref-level health vs the detected base; `null` for main/detached rows. */ + health: WorktreeHealth | null; } const EMPTY_STATS: WtStats = { @@ -23,8 +26,17 @@ const EMPTY_STATS: WtStats = { behind: 0, lastSubject: null, lastTime: null, + health: null, }; +/** Snapshot of the row a merge dialog was opened for — kept stable while the + * underlying lists refresh mid-flow. */ +interface MergeTarget { + worktree: Worktree; + health: WorktreeHealth; + dirty: number; +} + /** * Worktrees overview — the triage surface for "what is each agent doing?". * @@ -43,8 +55,10 @@ export function Worktrees({ const activePath = useRepo((s) => s.activePath); const worktrees = useRepo((s) => s.worktrees); const refreshWorktrees = useRepo((s) => s.refreshWorktrees); + const refreshRefs = useRepo((s) => s.refreshRefs); const openWorktree = useRepo((s) => s.openWorktree); const removeWorktree = useRepo((s) => s.removeWorktree); + const pruneWorktrees = useRepo((s) => s.pruneWorktrees); const setBaseline = useRepo((s) => s.setBaseline); const setView = useRepo((s) => s.setView); @@ -53,6 +67,13 @@ export function Worktrees({ // swaps the trash button for an explicit Force remove / Cancel pair. const [forcePath, setForcePath] = useState(null); const [focused, setFocused] = useState(0); + const [mergeTarget, setMergeTarget] = useState(null); + const [showCleanup, setShowCleanup] = useState(false); + const [cleanupBusy, setCleanupBusy] = useState(false); + const [archives, setArchives] = useState([]); + const [archivesOpen, setArchivesOpen] = useState(false); + // Archive ref pending delete; its row shows an explicit confirm pair. + const [archiveConfirm, setArchiveConfirm] = useState(null); const focusedRowRef = useRef(null); const repoName = repoFamilyName(meta); @@ -77,10 +98,14 @@ export function Worktrees({ for (const w of worktrees) { void (async () => { try { - const [status, m, log] = await Promise.all([ + const [status, m, log, health] = await Promise.all([ tauri.repoStatus(w.path), tauri.repoMeta(w.path), tauri.repoLog(w.path, 1), + // Health only means something for a linked worktree on a branch. + !w.is_main && w.branch + ? tauri.repoWorktreeHealth(w.path, w.branch).catch(() => null) + : Promise.resolve(null), ]); if (cancelled) return; setStats((s) => ({ @@ -92,6 +117,7 @@ export function Worktrees({ behind: m.behind, lastSubject: log[0]?.subject ?? null, lastTime: log[0]?.time_unix ?? null, + health, }, })); } catch { @@ -105,6 +131,22 @@ export function Worktrees({ return () => { cancelled = true; }; }, [pathsKey]); // eslint-disable-line react-hooks/exhaustive-deps + const refreshArchives = useCallback(async () => { + const path = activePath; + if (!path) return; + try { + const list = await tauri.repoWorktreeArchives(path); + setArchives(list); + } catch { + // Non-fatal: the section just stays as-is. + } + }, [activePath]); + + // Every removal archives first, so re-list whenever the worktree set moves. + useEffect(() => { + void refreshArchives(); + }, [refreshArchives, pathsKey]); + const orderedWorktrees = useMemo(() => { const rank = (w: Worktree): number => { if (w.is_current) return 0; @@ -129,8 +171,29 @@ export function Worktrees({ focusedRowRef.current?.scrollIntoView({ block: 'nearest' }); }, [focused]); + useEffect(() => { + if (!showCleanup) return; + const onKey = (e: globalThis.KeyboardEvent) => { + if (e.key === 'Escape' && !cleanupBusy) setShowCleanup(false); + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [showCleanup, cleanupBusy]); + const dirtyWorktrees = orderedWorktrees.filter((w) => (stats[w.path]?.dirty ?? 0) > 0).length; - const lockedWorktrees = orderedWorktrees.filter((w) => w.is_locked).length; + const mergedWorktrees = orderedWorktrees.filter((w) => stats[w.path]?.health?.merged).length; + + // Safe to retire without losing anything: clean, on a branch, and every + // commit already lives in the base. + const cleanupCandidates = useMemo( + () => + worktrees.filter((w) => { + if (w.is_main || w.is_current || !w.branch) return false; + const st = stats[w.path]; + return !!st && !st.loading && st.dirty === 0 && !!st.health?.merged; + }), + [worktrees, stats], + ); const review = (w: Worktree) => { void (async () => { @@ -168,7 +231,8 @@ export function Worktrees({ try { await removeWorktree(w.path, force); setForcePath(null); - onToast(`Removed worktree ${worktreeName(w)}`); + void refreshArchives(); + onToast(`Removed worktree ${worktreeName(w)} — snapshot archived`); } catch (e) { const msg = errMessage(e); // git refuses dirty/locked worktrees without --force; surface the @@ -181,6 +245,85 @@ export function Worktrees({ })(); }; + const prune = () => { + void (async () => { + try { + await pruneWorktrees(); + onToast('Pruned stale worktree entries'); + } catch (e) { + onToast(`Prune failed: ${errMessage(e)}`, 'error'); + } + })(); + }; + + const runCleanup = () => { + const path = activePath; + if (!path) return; + void (async () => { + setCleanupBusy(true); + let removed = 0; + const errors: string[] = []; + for (const w of cleanupCandidates) { + try { + // removeWorktree archives the full state first (safety net). + await removeWorktree(w.path, true); + if (w.branch) await tauri.repoBranchDelete(path, w.branch, true); + removed += 1; + } catch (e) { + errors.push(`${worktreeName(w)}: ${errMessage(e)}`); + } + } + await Promise.all([refreshWorktrees(), refreshRefs()]); + void refreshArchives(); + setCleanupBusy(false); + setShowCleanup(false); + if (errors.length > 0) { + onToast(`Cleaned up ${removed}, but: ${errors[0]}`, 'error'); + } else { + onToast(`Cleaned up ${removed} worktree${removed === 1 ? '' : 's'} — snapshots archived`); + } + })(); + }; + + const restoreArchive = (a: WorktreeArchive) => { + const path = activePath; + if (!path) return; + void (async () => { + // Fallback only — restore prefers the snapshot's original directory + // and re-attaches its branch when both are free. + const mainPath = worktrees.find((w) => w.is_main)?.path ?? path; + const parent = mainPath.replace(/[\\/][^\\/]*$/, ''); + const dest = `${parent}/${repoFamilyName(meta)}.worktrees/${a.name.replace(/\//g, '-')}`; + try { + const res = await tauri.repoWorktreeArchiveRestore(path, a.ref_name, dest); + await Promise.all([refreshWorktrees(), refreshRefs()]); + onToast( + res.branch + ? `Restored ${a.name} on ${res.branch} at ${res.path}` + : `Restored ${a.name} (detached) at ${res.path}`, + ); + void openWorktree(res.path); + } catch (e) { + onToast(`Restore failed: ${errMessage(e)}`, 'error'); + } + })(); + }; + + const deleteArchive = (a: WorktreeArchive) => { + const path = activePath; + if (!path) return; + void (async () => { + try { + await tauri.repoWorktreeArchiveDelete(path, a.ref_name); + setArchives((s) => s.filter((x) => x.ref_name !== a.ref_name)); + setArchiveConfirm(null); + onToast('Snapshot deleted'); + } catch (e) { + onToast(`Delete failed: ${errMessage(e)}`, 'error'); + } + })(); + }; + const onKeyDown = (e: KeyboardEvent) => { if (orderedWorktrees.length === 0) return; if (e.key === 'ArrowDown') { @@ -210,12 +353,36 @@ export function Worktrees({
- + +
+
+ + {cleanupCandidates.length > 0 && ( + + )} + {worktrees.some((w) => w.is_prunable) && ( + + )}
- {orderedWorktrees.length === 0 ? ( @@ -243,6 +410,7 @@ export function Worktrees({ const st = stats[w.path] ?? EMPTY_STATS; const name = worktreeName(w); const detail = w.is_main ? 'Main checkout' : pathLeaf(w.path); + const baseName = st.health?.base_branch ?? mainBranch; return (
{name} {detail} - +
@@ -289,13 +457,26 @@ export function Worktrees({ className="btn primary" onClick={(e) => { e.stopPropagation(); review(w); }} title={ - !w.is_main && mainBranch - ? `Review changes since this worktree diverged from ${mainBranch}` + !w.is_main && baseName + ? `Review changes since this worktree diverged from ${baseName}` : 'Open this worktree on Local Changes' } > {w.is_main ? 'Open' : 'Review'} + {!w.is_main && w.branch && st.health?.base_branch && !st.health.merged && ( + + )} {!w.is_current && (
)} + + {archives.length > 0 && ( +
+ + {archivesOpen && ( +
+ {archives.map((a) => ( +
+ + {a.name} + {relTime(a.time_unix)} · {a.oid.slice(0, 7)} +
+ + {archiveConfirm === a.ref_name ? ( + <> + + + + ) : ( + + )} +
+
+ ))} +
+ )} +
+ )} + + {mergeTarget && ( + setMergeTarget(null)} + onToast={onToast} + /> + )} + + {showCleanup && ( +
{ + if (e.target === e.currentTarget && !cleanupBusy) setShowCleanup(false); + }} + > +
+
+ + Clean up merged worktrees + +
+
+

+ These worktrees are clean and every commit is already in their base + branch. Each one is snapshotted to the archive before its directory + and branch are removed. +

+
    + {cleanupCandidates.map((w) => { + const h = stats[w.path]?.health; + return ( +
  • + {w.branch} + — in {h?.merged_into ?? h?.base_branch} · {w.path} +
  • + ); + })} +
+
+
+ + +
+
+
+ )} ); } @@ -356,14 +662,47 @@ function Metric({ value, label }: { value: number; label: string }) { ); } -function Tags({ worktree }: { worktree: Worktree }) { +function Tags({ worktree, health }: { worktree: Worktree; health: WorktreeHealth | null }) { + // Work that exists only in this worktree: unmerged commits with no + // upstream, or an upstream that hasn't seen them yet. + const atRisk = + !!health && + !health.merged && + (health.has_upstream ? health.unpushed > 0 : health.ahead_of_base > 0); return ( <> {worktree.is_current && current} {worktree.is_main && main} - {worktree.is_locked && locked} + {health?.merged && ( + + merged + + )} + {atRisk && health && ( + + {health.has_upstream ? 'unpushed' : 'unmerged'} + + )} + {worktree.is_locked && ( + + locked + + )} {worktree.is_detached && detached} - {worktree.is_prunable && stale} + {worktree.is_prunable && ( + stale + )} ); } From 61214169a8b92f7c1578bade83743480c2be203f Mon Sep 17 00:00:00 2001 From: Daniels-Main Date: Tue, 7 Jul 2026 23:56:32 +0200 Subject: [PATCH 2/2] fix(ui): re-arm dialog mountedRef on mount so StrictMode cannot freeze busy state Every dialog guards post-await setState with a mountedRef, but armed it only via the useRef initializer with a cleanup-only effect. StrictMode's dev remount reuses the same ref object, so the simulated unmount left it permanently false on a mounted dialog: the first submit error skipped both setError and the finally setBusy(false), freezing the dialog on its busy label (found as "Merging..." stuck in WorktreeMergeDialog; the same latent bug sat in eleven other dialogs). Re-arm the ref in the effect body. Documented in docs/learnings.md; dev-only, production builds do not double-mount. --- docs/learnings.md | 27 +++++++++++++++++++++++++++ ui/src/views/BranchDialog.tsx | 4 +++- ui/src/views/IgnoreDialog.tsx | 4 +++- ui/src/views/MergeDialog.tsx | 4 +++- ui/src/views/RebaseEditor.tsx | 9 +++++++-- ui/src/views/RemoteDialog.tsx | 4 +++- ui/src/views/RenameBranchDialog.tsx | 4 +++- ui/src/views/RenameFileDialog.tsx | 4 +++- ui/src/views/ResetDialog.tsx | 4 +++- ui/src/views/StashDialog.tsx | 4 +++- ui/src/views/TagDialog.tsx | 4 +++- ui/src/views/WorktreeDialog.tsx | 4 +++- 12 files changed, 64 insertions(+), 12 deletions(-) diff --git a/docs/learnings.md b/docs/learnings.md index 07c2ca2..bf7e3c6 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -1043,3 +1043,30 @@ lesson as `strand_core::git_command`). `bin::spawn_detached` — never `Command::new` directly in provider modules. Exception mirror: `spawn_detached` (login flows) keeps a visible console on purpose, because sign-in may need an interactive picker. + +--- + +## Dialog `mountedRef` must re-arm in the effect body (StrictMode) + +**Rule.** The dialog pattern `const mountedRef = useRef(true)` guarding +`setError`/`setBusy` after an await must set the ref back to `true` in the +mount effect's *body*, not rely on the initializer: +`useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, [])`. +A cleanup-only effect (`useEffect(() => () => { ... }, [])`) is wrong. + +**Why.** The app runs in ``, which in dev mounts → unmounts → +remounts every component. The ref *object* survives that remount, so the +simulated unmount's cleanup leaves `mountedRef.current === false` on a fully +mounted dialog. Every guarded state update is then silently skipped — in +practice the `finally { if (mountedRef.current) setBusy(false) }` never runs +and the dialog freezes on its busy label the first time its submit errors +(found 2026-07-07: "Merging…" frozen in `WorktreeMergeDialog` when the base +worktree refused the merge; the same latent bug sat in eleven other dialogs). +Production builds don't double-mount, so the bug is dev-only and invisible on +the happy path. + +**How to apply.** Copying an existing dialog is how the bug spread — all +dialogs were fixed in one pass (2026-07-07), so copy from any of them now, but +check for the re-arm line whenever you see `mountedRef`. Same trap for any +`useRef` flag that a cleanup mutates: initializers run once per fiber, not per +mount. diff --git a/ui/src/views/BranchDialog.tsx b/ui/src/views/BranchDialog.tsx index 2ad5ade..ff1207f 100644 --- a/ui/src/views/BranchDialog.tsx +++ b/ui/src/views/BranchDialog.tsx @@ -32,7 +32,9 @@ export function BranchDialog({ const [error, setError] = useState(null); const dialogRef = useRef(null); const mountedRef = useRef(true); - useEffect(() => () => { mountedRef.current = false; }, []); + // Re-arm on mount — StrictMode's dev remount reuses the same ref, so a + // cleanup-only effect would leave it permanently false (frozen busy state). + useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); // Restore focus to whatever opened the dialog when it closes, so keyboard // flow returns to the graph/sidebar instead of falling to . diff --git a/ui/src/views/IgnoreDialog.tsx b/ui/src/views/IgnoreDialog.tsx index b87f3d0..1a7b123 100644 --- a/ui/src/views/IgnoreDialog.tsx +++ b/ui/src/views/IgnoreDialog.tsx @@ -31,7 +31,9 @@ export function IgnoreDialog({ const dialogRef = useRef(null); const inputRef = useRef(null); const mountedRef = useRef(true); - useEffect(() => () => { mountedRef.current = false; }, []); + // Re-arm on mount — StrictMode's dev remount reuses the same ref, so a + // cleanup-only effect would leave it permanently false (frozen busy state). + useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); // Restore focus to the opener (the context menu / row) when the dialog closes. useEffect(() => { diff --git a/ui/src/views/MergeDialog.tsx b/ui/src/views/MergeDialog.tsx index 615896d..271d92a 100644 --- a/ui/src/views/MergeDialog.tsx +++ b/ui/src/views/MergeDialog.tsx @@ -48,7 +48,9 @@ export function MergeDialog({ const [error, setError] = useState(null); const dialogRef = useRef(null); const mountedRef = useRef(true); - useEffect(() => () => { mountedRef.current = false; }, []); + // Re-arm on mount — StrictMode's dev remount reuses the same ref, so a + // cleanup-only effect would leave it permanently false (frozen busy state). + useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); // Restore focus to whatever opened the dialog when it closes, so keyboard // flow returns to the graph/sidebar instead of falling to . diff --git a/ui/src/views/RebaseEditor.tsx b/ui/src/views/RebaseEditor.tsx index 2eb5f79..8e014df 100644 --- a/ui/src/views/RebaseEditor.tsx +++ b/ui/src/views/RebaseEditor.tsx @@ -78,8 +78,13 @@ export function RebaseEditor({ const mountedRef = useRef(true); // Original commit order, to detect a no-op plan (all pick + unchanged order). const origOrderRef = useRef([]); - useEffect(() => () => { - mountedRef.current = false; + // Re-arm on mount — StrictMode's dev remount reuses the same ref, so a + // cleanup-only effect would leave it permanently false (frozen busy state). + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; }, []); // Restore focus to whatever opened the dialog when it closes. diff --git a/ui/src/views/RemoteDialog.tsx b/ui/src/views/RemoteDialog.tsx index 26d78b7..a37d8b1 100644 --- a/ui/src/views/RemoteDialog.tsx +++ b/ui/src/views/RemoteDialog.tsx @@ -35,7 +35,9 @@ export function RemoteDialog({ const [error, setError] = useState(null); const dialogRef = useRef(null); const mountedRef = useRef(true); - useEffect(() => () => { mountedRef.current = false; }, []); + // Re-arm on mount — StrictMode's dev remount reuses the same ref, so a + // cleanup-only effect would leave it permanently false (frozen busy state). + useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); // Restore focus to whatever opened the dialog when it closes, so keyboard // flow returns to the sidebar/palette instead of falling to . diff --git a/ui/src/views/RenameBranchDialog.tsx b/ui/src/views/RenameBranchDialog.tsx index f318996..43768bc 100644 --- a/ui/src/views/RenameBranchDialog.tsx +++ b/ui/src/views/RenameBranchDialog.tsx @@ -26,7 +26,9 @@ export function RenameBranchDialog({ const [error, setError] = useState(null); const dialogRef = useRef(null); const mountedRef = useRef(true); - useEffect(() => () => { mountedRef.current = false; }, []); + // Re-arm on mount — StrictMode's dev remount reuses the same ref, so a + // cleanup-only effect would leave it permanently false (frozen busy state). + useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); // Restore focus to whatever opened the dialog when it closes, so keyboard // flow returns to the sidebar/palette instead of falling to . diff --git a/ui/src/views/RenameFileDialog.tsx b/ui/src/views/RenameFileDialog.tsx index c046d4b..c3be43e 100644 --- a/ui/src/views/RenameFileDialog.tsx +++ b/ui/src/views/RenameFileDialog.tsx @@ -30,7 +30,9 @@ export function RenameFileDialog({ const dialogRef = useRef(null); const inputRef = useRef(null); const mountedRef = useRef(true); - useEffect(() => () => { mountedRef.current = false; }, []); + // Re-arm on mount — StrictMode's dev remount reuses the same ref, so a + // cleanup-only effect would leave it permanently false (frozen busy state). + useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); // Restore focus to the opener (the context menu / row) when the dialog closes. useEffect(() => { diff --git a/ui/src/views/ResetDialog.tsx b/ui/src/views/ResetDialog.tsx index 3f9e5cc..57ca02c 100644 --- a/ui/src/views/ResetDialog.tsx +++ b/ui/src/views/ResetDialog.tsx @@ -34,7 +34,9 @@ export function ResetDialog({ const [error, setError] = useState(null); const dialogRef = useRef(null); const mountedRef = useRef(true); - useEffect(() => () => { mountedRef.current = false; }, []); + // Re-arm on mount — StrictMode's dev remount reuses the same ref, so a + // cleanup-only effect would leave it permanently false (frozen busy state). + useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); // Restore focus to whatever opened the dialog when it closes, so keyboard // flow returns to the graph/reflog instead of falling to . diff --git a/ui/src/views/StashDialog.tsx b/ui/src/views/StashDialog.tsx index e296bb5..f2b15a4 100644 --- a/ui/src/views/StashDialog.tsx +++ b/ui/src/views/StashDialog.tsx @@ -46,7 +46,9 @@ export function StashDialog({ const [note, setNote] = useState(null); const dialogRef = useRef(null); const mountedRef = useRef(true); - useEffect(() => () => { mountedRef.current = false; }, []); + // Re-arm on mount — StrictMode's dev remount reuses the same ref, so a + // cleanup-only effect would leave it permanently false (frozen busy state). + useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); const conflictSet = useMemo(() => { const set = new Set(); diff --git a/ui/src/views/TagDialog.tsx b/ui/src/views/TagDialog.tsx index dba64ea..c80c86f 100644 --- a/ui/src/views/TagDialog.tsx +++ b/ui/src/views/TagDialog.tsx @@ -32,7 +32,9 @@ export function TagDialog({ const [error, setError] = useState(null); const dialogRef = useRef(null); const mountedRef = useRef(true); - useEffect(() => () => { mountedRef.current = false; }, []); + // Re-arm on mount — StrictMode's dev remount reuses the same ref, so a + // cleanup-only effect would leave it permanently false (frozen busy state). + useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); // Restore focus to whatever opened the dialog when it closes, so keyboard // flow returns to the graph/sidebar instead of falling to . diff --git a/ui/src/views/WorktreeDialog.tsx b/ui/src/views/WorktreeDialog.tsx index 3a67db6..4f89524 100644 --- a/ui/src/views/WorktreeDialog.tsx +++ b/ui/src/views/WorktreeDialog.tsx @@ -34,7 +34,9 @@ export function WorktreeDialog({ onClose }: { onClose: () => void }) { const [error, setError] = useState(null); const dialogRef = useRef(null); const mountedRef = useRef(true); - useEffect(() => () => { mountedRef.current = false; }, []); + // Re-arm on mount — StrictMode's dev remount reuses the same ref, so a + // cleanup-only effect would leave it permanently false (frozen busy state). + useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); const chosenBranch = newBranch ? branch.trim() : existing;