diff --git a/CLAUDE.md b/CLAUDE.md index 15ea9e0..c86d1e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,11 +60,14 @@ empaquetada en **Tauri 2.x**. Estado: Fases 0, 1 y 2 completas (parser + UI web lo detecta y **re-ancla al repo principal** (lee el puntero, sin invocar git), así un cwd dentro de `/.claude/worktrees//` NO aparece como repo fantasma. Un submódulo (`.git/modules/…`) NO se re-ancla (es su propio repo). -- **"Worktrees inventory"** (botón `Worktrees` en la topbar, **Tauri-only**, read-only): lista/clasifica +- **"Worktrees inventory + Clean"** (botón `Worktrees` en la topbar, **Tauri-only**): lista/clasifica los worktrees de git por repo (active / stale ≥10 min / "merged → safe to remove") con tamaños bajo - demanda y `copy cmd`. **NO borra nada** (el botón `Clean` se difirió — ver ROADMAP). Todo el shelling a + demanda y `copy cmd`. El botón **`Clean`** (`remove_worktree`/`prune_worktrees` en `worktrees.rs`) es la + **ÚNICA acción que muta disco** en arrow: corre `git worktree remove` **sin `--force`** (git rehúsa un + worktree locked/sucio) tras **dry-run + confirmación**, solo en filas que git limpiaría (merged+clean o + prunable), y emite `report-changed` al terminar. Todo el shelling a git vive en `src-tauri/src/worktrees.rs` (Tauri-only, clona `editor.rs`): el parser `src/lib.rs` **sigue - sin invocar git**. argv directo sin shell, comandos read-only con timeout. Honestidad: rama por defecto + sin invocar git**. argv directo sin shell, lectura read-only con timeout. Honestidad: rama por defecto **resuelta dinámicamente** (nunca hardcodeada); "merged" en verde **solo** si el tip es ancestro (squash/rebase ⇒ "can't tell", nunca verde falso); active/stale = edición reciente de archivos, NO proceso vivo; tamaño aproximado. active/stale se deriva en el **frontend** desde `lastTouched` (ventana diff --git a/README.md b/README.md index b7eb046..6ffd11d 100644 --- a/README.md +++ b/README.md @@ -201,14 +201,18 @@ cargo tauri build arrow never embeds an editor or a language server (the mirror image of Claude Code's `/ide`). Honest: it opens the **current on-disk file** (and is disabled when that file is gone), never the reconstructed snapshot. -- **Worktrees inventory**: a `Worktrees` button in the topbar opens a read-only inventory of the git +- **Worktrees inventory + cleanup**: a `Worktrees` button in the topbar opens an inventory of the git worktrees Claude Code creates per session (under `/.claude/worktrees/`), grouped by repo and flagged **active / stale (≥10 min no edits) / merged → safe to remove**, with on-demand disk sizes. - It **never deletes anything** — each row copies the exact `git worktree remove` command for *you* to - run. Honest by construction: "merged" is shown only when the branch is provably an ancestor of the - repo's (dynamically resolved) default branch — a squash/rebase merge reads as *can't confirm*, never - a false green; "active" means recent edits on disk, not a running process; sizes are approximate. All - git shelling stays in the Tauri backend, so the parser library remains git-free. + Each row offers `copy cmd` (the exact `git worktree remove` for *you* to run) and, in the desktop app, + a **`Clean`** button that runs it for you — `git worktree remove` **without `--force`** (so git refuses + a locked or dirty worktree), gated behind a dry-run and a confirmation. It's the only action in arrow + that mutates disk, and it's offered only on rows git would actually clean (merged+clean, or a phantom + to prune) — never the main, dirty, locked, or active worktree. Honest by construction: "merged" is + shown only when the branch is provably an ancestor of the repo's (dynamically resolved) default branch + — a squash/rebase merge reads as *can't confirm*, never a false green; "active" means recent edits on + disk, not a running process; sizes are approximate. All git shelling stays in the Tauri backend, so the + parser library remains git-free. - **Window and zoom**: a custom titlebar (`decorations:false`) with minimize/maximize/close buttons, drag, and double-click to maximize — guaranteeing *cross-distro* controls (on GNOME/Pop!_OS the WM doesn't paint them reliably). VSCode-style UI zoom with `Ctrl +/−/0`: native to the webview diff --git a/ROADMAP.md b/ROADMAP.md index 2e14407..9d55d7a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -109,8 +109,9 @@ están en [CLAUDE.md](CLAUDE.md); el contrato de datos en [SPEC.md](SPEC.md). - **"Worktrees inventory"** (nuevo, read-only, Tauri-only — botón `Worktrees` en la topbar → modal): lista los **git worktrees** que Claude Code va creando por sesión, agrupados por repo, y los clasifica en **active / stale / merged ("safe to remove")**, con tamaño en disco **bajo demanda** y un `copy cmd` por - fila. **NO borra nada desde la app** (el botón `Clean` se difiere — ver backlog); la imagen-espejo del - problema que "Open in editor" resolvió para la edición, pero para la **higiene de disco**. + fila. Un botón **`Clean`** (Tauri-only) ejecuta la limpieza tras dry-run + confirmación — ver + "Worktree `Clean`" abajo; la imagen-espejo del problema que "Open in editor" resolvió para la edición, + pero para la **higiene de disco**. - **Por qué se agrega (el dolor real):** la app de escritorio de Claude Code crea un worktree por sesión en `/.claude/worktrees//` (nombre random `adjetivo-verbo-sustantivo`, ej. `wise-plotting-graham`). @@ -155,8 +156,9 @@ están en [CLAUDE.md](CLAUDE.md); el contrato de datos en [SPEC.md](SPEC.md). locked no se ofrece como "safe to remove" (`worktree remove` lo rechaza); prunable sugiere `git worktree prune`, no `remove`; el **worktree principal** se marca y nunca se ofrece para borrar. - **Tamaño = aproximado** (bytes aparentes vía `walkdir`, sin seguir symlinks); se etiqueta como tal. - - **Read-only:** `copy cmd` copia el `git worktree remove ` para que **el usuario** lo corra; arrow - no muta el estado de git. (El botón `Clean` in-app se difiere a propósito — ver backlog.) + - **Limpieza (`Clean`):** además del `copy cmd`, un botón **`Clean`** (Tauri-only) corre + `git worktree remove` **sin `--force`** tras dry-run + confirmación; es la **única** acción que muta + disco en arrow y solo se ofrece en filas que git realmente limpiaría. Detalle abajo en "Worktree `Clean`". - **Fix colateral en el parser (`git_root`, `src/lib.rs`):** un worktree enlazado tiene un `.git` que es un **FILE** (`gitdir: …/.git/worktrees/`), y `git_root` usaba `dir.join(".git").exists()` (true también @@ -207,16 +209,16 @@ todas deben respetar la honestidad del producto (no afirmar más de lo que el da - **Atajos de teclado** — navegar el árbol y abrir diffs sin ratón (j/k, enter, etc.). (Los atajos de **zoom** `Ctrl +/−/0` ya están; faltan los de navegación. Reusarían el mismo `keydown` global de `App.svelte` (`onKey`).) -- **Botón `Clean` in-app para worktrees** (diferido del "Worktrees inventory"; sería la **única acción - destructiva** de arrow, así que cruza la línea read-only y encaja en la Fase 4 de edición). Hoy el modal - solo lista + `copy cmd`. Un `Clean` exigiría **blindaje fuerte**, según la auditoría de seguridad: correr - `git worktree remove` (nunca `rm -rf`, luego `worktree prune`); **rechazar** worktrees con - `git status --porcelain` no vacío (sucios/untracked — p.ej. el `.deb` sin trackear de este repo se - perdería con `--force`); **nunca** `--force` ni `branch -d/-D` automático (una rama sin upstream es la - única copia); tratar detached/locked como hard-stop; nunca el worktree principal; y mostrar el comando - exacto antes de ejecutarlo. **Riesgo central** (reproducido): un detector de "merged" laxo (que confíe en - `git cherry` en vez de ancestro estricto o el forge) + un borrado de rama destruiría trabajo no fusionado. - Por eso el MVP se queda en read-only. +- **Worktree `Clean` (in-app)** — ✅ implementado (Tauri-only; la **única acción que muta disco** en arrow, + por eso va detrás de dry-run + confirmación explícita). Backend en `src-tauri/src/worktrees.rs` + (`remove_worktree`/`prune_worktrees` → `CleanupResult`, comandos Tauri `remove_worktree`/`prune_worktrees` + que emiten `report-changed` al terminar). Blindaje aplicado: corre `git worktree remove` **sin `--force`** + (git mismo rechaza un worktree locked/sucio/untracked — p.ej. el `.deb` sin trackear se salva), **nunca** + `rm -rf` ni `branch -d/-D`; el botón solo aparece en filas que git limpiaría (merged+clean+unlocked, o + prunable), nunca en el worktree principal/dirty/locked/activo; muestra el comando exacto antes de correrlo + y surfacea el mensaje de git verbatim si rehúsa. UI: botón `Clean`/`Prune` + barra de confirmación en + `WorktreesModal.svelte`; en el navegador (dev) degrada a `copy cmd` (no se expone por HTTP). + Test: `remove_deletes_a_real_worktree_without_force`. - **Detección de "merged" más fina para squash/rebase** — hoy un squash-merge se reporta honesto como "can't tell" (la rama no es ancestro aunque el trabajo esté en `main`). Mejora: consultar el forge (`gh pr view --json mergedAt`) o `range-diff` para **subir** la confianza a "likely merged", sin que deje diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b73ac44..66fe872 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -58,8 +58,8 @@ fn open_in_editor(editor_id: String, file: String, line: i64, col: i64) -> Resul /// Worktree inventory for the given repo roots (the ones the report already /// knows). Read-only: lists + classifies git worktrees (merged/active/stale is -/// finished off in the frontend); arrow never removes one. Tauri-only — the -/// browser dev-server has no equivalent. +/// finished off in the frontend). Removal is the separate, opt-in `remove_worktree` +/// command. Tauri-only — the browser dev-server has no equivalent. #[tauri::command] fn worktrees(repo_roots: Vec) -> Vec { worktrees::list_worktrees(&repo_roots) @@ -105,6 +105,37 @@ fn open_url(url: String) -> Result<(), String> { .map_err(|e| format!("couldn't open the browser: {e}")) } +/// Remove a linked worktree (the "Clean" action). The ONLY mutating command in +/// arrow, reached only by an explicit, confirmed user action. Runs `git worktree +/// remove` WITHOUT `--force`, so git refuses a locked/dirty worktree. `dry_run` +/// previews the command without touching disk. On a real success it emits +/// `report-changed` so the UI re-scans. Tauri-only — not exposed over HTTP. +#[tauri::command] +fn remove_worktree( + app: AppHandle, + repo: String, + path: String, + dry_run: bool, +) -> worktrees::CleanupResult { + let res = worktrees::remove_worktree(&repo, &path, dry_run); + if res.ok && !res.dry_run { + let _ = app.emit("report-changed", ()); + } + res +} + +/// Prune phantom worktree entries of a repo (`git worktree prune`). Mutating but +/// safe — it only drops entries whose working dir is already gone. `dry_run` uses +/// git's native `-n`. Emits `report-changed` on a real run. Tauri-only. +#[tauri::command] +fn prune_worktrees(app: AppHandle, repo: String, dry_run: bool) -> worktrees::CleanupResult { + let res = worktrees::prune_worktrees(&repo, dry_run); + if res.ok && !res.dry_run { + let _ = app.emit("report-changed", ()); + } + res +} + /// Watcher nativo: vigila `~/.claude/projects` y, con debounce, emite /// `report-changed` para que el frontend refresque sin polling. /// @@ -192,7 +223,9 @@ pub fn run() { worktrees, worktree_sizes, check_update, - open_url + open_url, + remove_worktree, + prune_worktrees ]) .setup(|app| { spawn_watcher(app.handle().clone()); diff --git a/src-tauri/src/worktrees.rs b/src-tauri/src/worktrees.rs index 0f679fb..0e94967 100644 --- a/src-tauri/src/worktrees.rs +++ b/src-tauri/src/worktrees.rs @@ -2,8 +2,10 @@ //! creates per session (under `/.claude/worktrees//`), so the user //! can spot stale or already-merged ones eating disk. //! -//! READ-ONLY: arrow never removes a worktree. It reports git facts and lets the -//! UI hand the user a `git worktree remove` command to run themselves. Tauri-only +//! Listing/classifying is READ-ONLY. The one exception is the opt-in "Clean" +//! action (`remove_worktree`/`prune_worktrees` below) — the only place in arrow +//! that mutates disk — gated behind a dry-run + confirmation in the UI and run +//! WITHOUT `--force`, so git itself refuses a locked or dirty worktree. Tauri-only //! (the browser dev-server has no equivalent) and — exactly like `editor.rs` — //! ALL the git shelling lives here, so the parser lib (`src/lib.rs`) stays //! git-free (the product premise: "without depending on git or hooks"). @@ -324,6 +326,109 @@ fn dir_size(path: &str) -> u64 { .sum() } +// --- Stage 2: removal (the ONLY part of arrow that mutates the disk) --------- +// +// READ-ONLY is the default everywhere else in arrow; this is the single, opt-in +// exception, reached only by an explicit user action (the "Clean" button) and +// only after a dry-run + confirmation in the UI. `remove` runs WITHOUT `--force`, +// so git itself refuses to drop a worktree that is locked or has uncommitted / +// untracked changes — a free safety net we deliberately keep (matching the +// honesty rules above: we never promise a removal git would refuse). + +/// Outcome of a removal/prune attempt (or its dry-run preview). +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CleanupResult { + /// git exited 0 (for a dry-run, always `true` — nothing was attempted). + pub ok: bool, + /// This was a preview only; the disk was not touched. + pub dry_run: bool, + /// The exact command, shell-quoted, for display / copy / an audit log. + pub command: String, + /// What git said (stdout + stderr), trimmed. Surfaced verbatim so a refusal + /// ("contains modified or untracked files") is shown honestly, not hidden. + pub output: String, +} + +/// Quote a path for the DISPLAYED command only (the real call is argv-direct, so +/// it never goes through a shell — this just makes the copy-paste string correct +/// when a path has spaces). +fn shell_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + +/// Run `git -C ` capturing stdout+stderr and success. Unlike the +/// read-only `run_git`, this keeps stderr: a removal that git refuses must show +/// its reason. Mutating ops are user-initiated, local, and tiny, so the blocking +/// `.output()` (no timeout) is acceptable here. +fn run_git_capture(repo: &str, args: &[&str]) -> (bool, String) { + match Command::new("git").arg("-C").arg(repo).args(args).output() { + Ok(o) => { + let mut s = String::from_utf8_lossy(&o.stdout).trim().to_string(); + let err = String::from_utf8_lossy(&o.stderr); + let err = err.trim(); + if !err.is_empty() { + if !s.is_empty() { + s.push('\n'); + } + s.push_str(err); + } + (o.status.success(), s) + } + Err(e) => (false, format!("failed to run git: {e}")), + } +} + +/// Remove a linked worktree. No `--force` (git refuses on locked / uncommitted / +/// untracked changes). `git worktree remove` has no native dry-run, so `dry_run` +/// reports the command without touching anything. +pub fn remove_worktree(repo: &str, path: &str, dry_run: bool) -> CleanupResult { + let command = format!( + "git -C {} worktree remove {}", + shell_quote(repo), + shell_quote(path) + ); + if dry_run { + return CleanupResult { + ok: true, + dry_run: true, + command, + output: "dry run — not executed (git would refuse if the worktree is \ + locked or has uncommitted/untracked changes)" + .to_string(), + }; + } + let (ok, output) = run_git_capture(repo, &["worktree", "remove", path]); + CleanupResult { + ok, + dry_run: false, + command, + output, + } +} + +/// Prune phantom worktree entries (their directories are gone). Uses git's native +/// dry-run (`-n`) to preview, so the dry-run is a real git report, not a guess. +pub fn prune_worktrees(repo: &str, dry_run: bool) -> CleanupResult { + let args: &[&str] = if dry_run { + &["worktree", "prune", "-n", "-v"] + } else { + &["worktree", "prune", "-v"] + }; + let command = format!( + "git -C {} worktree prune -v{}", + shell_quote(repo), + if dry_run { " -n" } else { "" } + ); + let (ok, output) = run_git_capture(repo, args); + CleanupResult { + ok, + dry_run, + command, + output, + } +} + #[cfg(test)] mod tests { use super::*; @@ -378,4 +483,79 @@ some-future-field whatever assert_eq!(e.len(), 1, "the bare worktree is dropped"); assert_eq!(e[0].branch.as_deref(), Some("main")); } + + #[test] + fn remove_dry_run_quotes_and_does_not_touch_disk() { + // Pure: a dry-run for `remove` never shells out, so it needs no git. + let res = remove_worktree("/repo with space", "/repo with space/wt", true); + assert!(res.ok && res.dry_run); + assert_eq!( + res.command, "git -C '/repo with space' worktree remove '/repo with space/wt'", + "the displayed command is shell-quoted for safe copy-paste" + ); + } + + #[test] + fn remove_deletes_a_real_worktree_without_force() { + // Hermetic integration: a real temp git repo + a linked worktree, then + // remove it. Skips cleanly if git is unavailable in the test environment. + if run_git(".", &["--version"]).is_none() { + return; + } + let base = std::env::temp_dir().join(format!("arrow-wt-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let repo = base.join("repo"); + std::fs::create_dir_all(&repo).unwrap(); + let git_in = |dir: &Path, args: &[&str]| { + Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .unwrap(); + }; + git_in(&repo, &["init", "-q", "-b", "main"]); + git_in(&repo, &["config", "user.email", "t@t"]); + git_in(&repo, &["config", "user.name", "t"]); + std::fs::write(repo.join("a.txt"), "x\n").unwrap(); + git_in(&repo, &["add", "-A"]); + git_in(&repo, &["commit", "-q", "-m", "init"]); + let wt = base.join("wt-feature"); + git_in( + &repo, + &[ + "worktree", + "add", + "-q", + wt.to_str().unwrap(), + "-b", + "feature", + ], + ); + + let repo_s = repo.to_string_lossy().to_string(); + let repos = list_worktrees(std::slice::from_ref(&repo_s)); + assert_eq!( + repos.len(), + 1, + "the repo with one linked worktree is listed" + ); + assert!(repos[0].worktrees.iter().any(|w| !w.is_main)); + + // Dry-run does not touch disk. + let dry = remove_worktree(&repo_s, wt.to_str().unwrap(), true); + assert!(dry.ok && dry.dry_run); + assert!(wt.exists(), "dry-run must NOT remove the worktree"); + + // Real removal (no uncommitted changes → git allows it without --force). + let res = remove_worktree(&repo_s, wt.to_str().unwrap(), false); + assert!(res.ok, "remove should succeed: {}", res.output); + assert!(!wt.exists(), "the worktree must be gone"); + + // prune dry-run runs cleanly even with no phantoms. + let pr = prune_worktrees(&repo_s, true); + assert!(pr.ok && pr.dry_run); + + let _ = std::fs::remove_dir_all(&base); + } } diff --git a/web/src/components/WorktreesModal.svelte b/web/src/components/WorktreesModal.svelte index 02d295d..76410b0 100644 --- a/web/src/components/WorktreesModal.svelte +++ b/web/src/components/WorktreesModal.svelte @@ -1,6 +1,6 @@