Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<repo>/.claude/worktrees/<name>/` 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
Expand Down
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<repo>/.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
Expand Down
30 changes: 16 additions & 14 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<repo>/.claude/worktrees/<nombre>/` (nombre random `adjetivo-verbo-sustantivo`, ej. `wise-plotting-graham`).
Expand Down Expand Up @@ -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 <path>` 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/<name>`), y `git_root` usaba `dir.join(".git").exists()` (true también
Expand Down Expand Up @@ -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
Expand Down
39 changes: 36 additions & 3 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) -> Vec<worktrees::RepoWorktreesOut> {
worktrees::list_worktrees(&repo_roots)
Expand Down Expand Up @@ -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.
///
Expand Down Expand Up @@ -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());
Expand Down
184 changes: 182 additions & 2 deletions src-tauri/src/worktrees.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
//! creates per session (under `<repo>/.claude/worktrees/<name>/`), 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").
Expand Down Expand Up @@ -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 <repo> <args>` 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::*;
Expand Down Expand Up @@ -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);
}
}
Loading
Loading