Skip to main content

omni_dev/cli/git/
worktree.rs

1//! Logged wrappers over `git worktree` (issue #1392).
2//!
3//! Each verb is a thin shell over the real `git worktree <verb>`; the value is
4//! the `kind: "worktree"` request-log record it writes, carrying the
5//! recovery-relevant metadata (path/branch/commit/dirtiness) that lets
6//! `omni-dev log --command 'git worktree remove'` answer "what worktree did I
7//! just remove?" — the row needed to run `git branch <branch> <commit>` and
8//! re-attach. Metadata capture is strictly best-effort: enrichment failures
9//! never change the wrapped git operation's behaviour or exit status.
10//!
11//! Distinct from the top-level `omni-dev worktrees` command, which lists the
12//! worktrees open across VS Code windows via the daemon.
13
14use std::collections::BTreeMap;
15use std::io::Write;
16use std::path::{Path, PathBuf};
17use std::process::Command;
18use std::time::{Duration, Instant};
19
20use anyhow::{Context, Result};
21use clap::{Parser, Subcommand};
22use serde::Serialize;
23
24use crate::request_log::{self, WorktreeOutcome};
25
26/// Worktree operations: logged wrappers over `git worktree`.
27#[derive(Parser)]
28#[command(
29    long_about = "Worktree operations: thin wrappers over `git worktree` that record \
30recovery-relevant metadata (path, branch, commit, uncommitted status) to the omni-dev \
31request log. If a worktree is removed by mistake, `omni-dev log --command 'git worktree \
32remove'` returns the row needed to recover the branch.\n\nNot to be confused with \
33`omni-dev worktrees`, which lists worktrees open across VS Code windows via the daemon."
34)]
35pub struct WorktreeCommand {
36    /// Worktree subcommand to execute.
37    #[command(subcommand)]
38    pub command: WorktreeSubcommands,
39}
40
41/// Worktree subcommands.
42#[derive(Subcommand)]
43pub enum WorktreeSubcommands {
44    /// Creates a worktree at `<path>` (wraps `git worktree add`).
45    Add(AddArgs),
46    /// Removes a worktree, recording branch/commit/dirtiness first (wraps `git worktree remove`).
47    Remove(RemoveArgs),
48    /// Lists worktrees (wraps `git worktree list`).
49    List(ListArgs),
50    /// Moves a worktree to a new location (wraps `git worktree move`).
51    Move(MoveArgs),
52    /// Prunes stale worktree metadata, recording what was pruned (wraps `git worktree prune`).
53    Prune(PruneArgs),
54    /// Repairs worktree administrative files (wraps `git worktree repair`).
55    Repair(RepairArgs),
56}
57
58/// Arguments for `git worktree add`.
59#[derive(Parser)]
60pub struct AddArgs {
61    /// Path for the new worktree.
62    pub path: PathBuf,
63    /// Commit-ish to check out (defaults to HEAD).
64    pub commit_ish: Option<String>,
65    /// Create a new branch for the worktree.
66    #[arg(short = 'b', long = "branch", value_name = "BRANCH")]
67    pub branch: Option<String>,
68    /// Checkout even if another worktree holds the branch.
69    #[arg(short = 'f', long)]
70    pub force: bool,
71    /// Detach HEAD in the new worktree.
72    #[arg(long)]
73    pub detach: bool,
74}
75
76/// Arguments for `git worktree remove`.
77#[derive(Parser)]
78pub struct RemoveArgs {
79    /// Path of the worktree to remove.
80    pub path: PathBuf,
81    /// Remove even if the worktree is dirty or locked.
82    #[arg(short = 'f', long)]
83    pub force: bool,
84}
85
86/// Arguments for `git worktree list`.
87#[derive(Parser)]
88pub struct ListArgs {
89    /// Emit git's machine-readable porcelain output verbatim.
90    #[arg(long, conflicts_with = "output_json")]
91    pub porcelain: bool,
92    /// Emit the porcelain data as JSON.
93    #[arg(long)]
94    pub output_json: bool,
95}
96
97/// Arguments for `git worktree move`.
98#[derive(Parser)]
99pub struct MoveArgs {
100    /// Current path of the worktree.
101    pub from: PathBuf,
102    /// New path for the worktree.
103    pub to: PathBuf,
104    /// Move even if the worktree is dirty or locked.
105    #[arg(short = 'f', long)]
106    pub force: bool,
107}
108
109/// Arguments for `git worktree prune`.
110#[derive(Parser)]
111pub struct PruneArgs {
112    /// Do not remove anything; report what would be removed.
113    #[arg(short = 'n', long)]
114    pub dry_run: bool,
115    /// Report all removals.
116    #[arg(short = 'v', long)]
117    pub verbose: bool,
118    /// Expire working trees older than TIME.
119    #[arg(long, value_name = "TIME")]
120    pub expire: Option<String>,
121}
122
123/// Arguments for `git worktree repair`.
124#[derive(Parser)]
125pub struct RepairArgs {
126    /// Worktree paths to repair (all, when omitted).
127    pub paths: Vec<PathBuf>,
128}
129
130impl WorktreeCommand {
131    /// Executes the worktree command.
132    ///
133    /// `repo` is the CLI-level `--repo` location; git runs with it as the
134    /// working directory, so relative worktree paths resolve against it
135    /// (matching `git -C` semantics).
136    pub fn execute(self, repo: Option<&Path>) -> Result<()> {
137        let base = base_dir(repo)?;
138        match self.command {
139            WorktreeSubcommands::Add(args) => run_add(&base, &args),
140            WorktreeSubcommands::Remove(args) => run_remove(&base, &args),
141            WorktreeSubcommands::List(args) => run_list(&base, &args),
142            WorktreeSubcommands::Move(args) => run_move(&base, &args),
143            WorktreeSubcommands::Prune(args) => run_prune(&base, &args),
144            WorktreeSubcommands::Repair(args) => run_repair(&base, &args),
145        }
146    }
147}
148
149// --- per-verb flows -------------------------------------------------------
150
151fn run_add(base: &Path, args: &AddArgs) -> Result<()> {
152    let argv = add_argv(args);
153    let mut context = base_context(base);
154    let abs = absolutize(base, &args.path);
155    let run = run_git(base, &argv);
156    echo(&run);
157    context.insert("path".to_string(), display_canonical(&abs));
158    if run.exit_code == Some(0) {
159        // The branch and HEAD only exist after a successful checkout.
160        insert_snapshot(&mut context, &snapshot(&abs), false);
161    }
162    finish("add", argv, run, context)
163}
164
165fn run_remove(base: &Path, args: &RemoveArgs) -> Result<()> {
166    let argv = remove_argv(args);
167    let mut context = base_context(base);
168    let abs = absolutize(base, &args.path);
169    // Captured BEFORE git deletes the working directory — this is the record's
170    // entire recovery value; on failure it is still written.
171    context.insert("path".to_string(), display_canonical(&abs));
172    context.insert("used_force".to_string(), args.force.to_string());
173    let snap = snapshot(&abs);
174    insert_snapshot(&mut context, &snap, true);
175    let run = run_git(base, &argv);
176    echo(&run);
177    finish("remove", argv, run, context)
178}
179
180fn run_list(base: &Path, args: &ListArgs) -> Result<()> {
181    let porcelain = args.porcelain || args.output_json;
182    let argv = list_argv(porcelain);
183    let mut context = base_context(base);
184    let run = run_git(base, &argv);
185    if args.output_json {
186        // JSON replaces git's stdout; stderr still passes through.
187        echo_stderr(&run);
188        if run.exit_code == Some(0) {
189            let stdout = String::from_utf8_lossy(&run.stdout);
190            let entries = parse_worktree_porcelain(&stdout);
191            context.insert("count".to_string(), entries.len().to_string());
192            let json = serde_json::to_string_pretty(&entries)
193                .context("Failed to serialize worktree list as JSON")?;
194            println!("{json}");
195        }
196    } else {
197        echo(&run);
198        if run.exit_code == Some(0) {
199            let stdout = String::from_utf8_lossy(&run.stdout);
200            let count = if porcelain {
201                parse_worktree_porcelain(&stdout).len()
202            } else {
203                stdout.lines().filter(|l| !l.trim().is_empty()).count()
204            };
205            context.insert("count".to_string(), count.to_string());
206        }
207    }
208    finish("list", argv, run, context)
209}
210
211fn run_move(base: &Path, args: &MoveArgs) -> Result<()> {
212    let argv = move_argv(args);
213    let mut context = base_context(base);
214    let from_abs = absolutize(base, &args.from);
215    context.insert("from_path".to_string(), display_canonical(&from_abs));
216    context.insert(
217        "to_path".to_string(),
218        absolutize(base, &args.to).display().to_string(),
219    );
220    // Branch captured before the move, in case the move fails midway.
221    let snap = snapshot(&from_abs);
222    if let Some(branch) = &snap.branch {
223        context.insert("branch".to_string(), branch.clone());
224    }
225    let run = run_git(base, &argv);
226    echo(&run);
227    finish("move", argv, run, context)
228}
229
230fn run_prune(base: &Path, args: &PruneArgs) -> Result<()> {
231    let argv = prune_argv(args);
232    let mut context = base_context(base);
233    if args.dry_run {
234        context.insert("dry_run".to_string(), "true".to_string());
235    }
236    // Diffing the porcelain list before vs after yields the pruned entries
237    // with the branch/commit that git still reports for prunable stanzas.
238    let before = list_entries(base);
239    let run = run_git(base, &argv);
240    echo(&run);
241    if let (Some(before), Some(after)) = (before, list_entries(base)) {
242        let pruned = pruned_entries(&before, &after);
243        match serde_json::to_string(&pruned) {
244            Ok(json) => {
245                context.insert("pruned".to_string(), json);
246            }
247            Err(e) => tracing::debug!("Cannot serialize pruned worktree list: {e}"),
248        }
249    }
250    finish("prune", argv, run, context)
251}
252
253fn run_repair(base: &Path, args: &RepairArgs) -> Result<()> {
254    let argv = repair_argv(args);
255    let mut context = base_context(base);
256    let run = run_git(base, &argv);
257    echo(&run);
258    // git reports repairs on both streams depending on the verb/version.
259    let combined = format!(
260        "{}{}",
261        String::from_utf8_lossy(&run.stdout),
262        String::from_utf8_lossy(&run.stderr)
263    );
264    let repaired = parse_repair_paths(&combined);
265    if !repaired.is_empty() {
266        match serde_json::to_string(&repaired) {
267            Ok(json) => {
268                context.insert("repaired".to_string(), json);
269            }
270            Err(e) => tracing::debug!("Cannot serialize repaired worktree list: {e}"),
271        }
272    }
273    finish("repair", argv, run, context)
274}
275
276/// Records the outcome and converts the subprocess result into the command's
277/// result: success on exit 0, an error (non-zero omni-dev exit) otherwise.
278fn finish(
279    verb: &str,
280    argv: Vec<String>,
281    run: GitRun,
282    context: BTreeMap<String, String>,
283) -> Result<()> {
284    request_log::record_worktree(WorktreeOutcome {
285        verb: verb.to_string(),
286        argv,
287        exit_code: run.exit_code,
288        duration: run.duration,
289        error: run.error.clone(),
290        context,
291    });
292    match (run.exit_code, run.error) {
293        (Some(0), _) => Ok(()),
294        // Stderr was already echoed, so the error chain stays short.
295        (Some(code), _) => anyhow::bail!("git worktree {verb} failed with exit code {code}"),
296        (None, err) => anyhow::bail!(
297            "Failed to run git worktree {verb}: {}",
298            err.unwrap_or_else(|| "unknown spawn error".to_string())
299        ),
300    }
301}
302
303// --- argv builders (pure, unit-tested) ------------------------------------
304
305fn add_argv(args: &AddArgs) -> Vec<String> {
306    let mut argv = vec!["worktree".to_string(), "add".to_string()];
307    if args.force {
308        argv.push("--force".to_string());
309    }
310    if args.detach {
311        argv.push("--detach".to_string());
312    }
313    if let Some(branch) = &args.branch {
314        argv.push("-b".to_string());
315        argv.push(branch.clone());
316    }
317    argv.push(args.path.display().to_string());
318    if let Some(commit_ish) = &args.commit_ish {
319        argv.push(commit_ish.clone());
320    }
321    argv
322}
323
324fn remove_argv(args: &RemoveArgs) -> Vec<String> {
325    let mut argv = vec!["worktree".to_string(), "remove".to_string()];
326    if args.force {
327        argv.push("--force".to_string());
328    }
329    argv.push(args.path.display().to_string());
330    argv
331}
332
333fn list_argv(porcelain: bool) -> Vec<String> {
334    if porcelain {
335        // `core.quotePath=false` keeps non-ASCII paths literal for the parser.
336        vec![
337            "-c".to_string(),
338            "core.quotePath=false".to_string(),
339            "worktree".to_string(),
340            "list".to_string(),
341            "--porcelain".to_string(),
342        ]
343    } else {
344        vec!["worktree".to_string(), "list".to_string()]
345    }
346}
347
348fn move_argv(args: &MoveArgs) -> Vec<String> {
349    let mut argv = vec!["worktree".to_string(), "move".to_string()];
350    if args.force {
351        argv.push("--force".to_string());
352    }
353    argv.push(args.from.display().to_string());
354    argv.push(args.to.display().to_string());
355    argv
356}
357
358fn prune_argv(args: &PruneArgs) -> Vec<String> {
359    let mut argv = vec!["worktree".to_string(), "prune".to_string()];
360    if args.dry_run {
361        argv.push("--dry-run".to_string());
362    }
363    if args.verbose {
364        argv.push("--verbose".to_string());
365    }
366    if let Some(expire) = &args.expire {
367        argv.push("--expire".to_string());
368        argv.push(expire.clone());
369    }
370    argv
371}
372
373fn repair_argv(args: &RepairArgs) -> Vec<String> {
374    let mut argv = vec!["worktree".to_string(), "repair".to_string()];
375    argv.extend(args.paths.iter().map(|p| p.display().to_string()));
376    argv
377}
378
379// --- subprocess runner ----------------------------------------------------
380
381/// The observed result of one `git` subprocess.
382struct GitRun {
383    exit_code: Option<i32>,
384    duration: Duration,
385    error: Option<String>,
386    stdout: Vec<u8>,
387    stderr: Vec<u8>,
388}
389
390/// Builds a `git` [`Command`] whose child receives a snapshot of the current
391/// environment (`env_clear` + `envs`), keeping the spawn out of the data race
392/// against concurrent `std::env::set_var` (issue #1022; same idiom as
393/// `crate::cli::ai::claude::skills`).
394fn git_command() -> Command {
395    let mut cmd = Command::new("git");
396    cmd.env_clear();
397    cmd.envs(std::env::vars_os());
398    cmd
399}
400
401/// Runs `git <argv>` in `base`, timing it. Never fails: a spawn error lands in
402/// [`GitRun::error`] so the caller can record it before reporting.
403fn run_git(base: &Path, argv: &[String]) -> GitRun {
404    let started = Instant::now();
405    let result = git_command().args(argv).current_dir(base).output();
406    let duration = started.elapsed();
407    match result {
408        Ok(output) => GitRun {
409            exit_code: output.status.code(),
410            duration,
411            error: None,
412            stdout: output.stdout,
413            stderr: output.stderr,
414        },
415        Err(e) => GitRun {
416            exit_code: None,
417            duration,
418            error: Some(e.to_string()),
419            stdout: Vec::new(),
420            stderr: Vec::new(),
421        },
422    }
423}
424
425/// Echoes the captured git output to our own streams (thin-wrapper UX).
426fn echo(run: &GitRun) {
427    // Best-effort: a write failure here (e.g. EPIPE from a closed pager) is
428    // the downstream consumer's condition, not ours.
429    let _ = std::io::stdout().write_all(&run.stdout);
430    echo_stderr(run);
431}
432
433/// Echoes only git's stderr (used when stdout is replaced, e.g. `--output-json`).
434fn echo_stderr(run: &GitRun) {
435    // Best-effort, as in `echo`.
436    let _ = std::io::stderr().write_all(&run.stderr);
437}
438
439// --- metadata capture (best-effort) ---------------------------------------
440
441/// Resolves the directory git runs in: `--repo` when given, else the cwd.
442fn base_dir(repo: Option<&Path>) -> Result<PathBuf> {
443    match repo {
444        Some(p) => Ok(p.to_path_buf()),
445        None => std::env::current_dir().context("Failed to resolve working directory"),
446    }
447}
448
449/// Context common to every verb: the main worktree root, when resolvable.
450fn base_context(base: &Path) -> BTreeMap<String, String> {
451    let mut context = BTreeMap::new();
452    if let Some(toplevel) = toplevel(base) {
453        context.insert("repo".to_string(), toplevel);
454    }
455    context
456}
457
458/// Best-effort `git rev-parse --show-toplevel` from `base`.
459fn toplevel(base: &Path) -> Option<String> {
460    let output = git_command()
461        .args(["rev-parse", "--show-toplevel"])
462        .current_dir(base)
463        .output()
464        .ok()?;
465    if !output.status.success() {
466        return None;
467    }
468    let stdout = String::from_utf8(output.stdout).ok()?;
469    let trimmed = stdout.trim();
470    (!trimmed.is_empty()).then(|| trimmed.to_string())
471}
472
473/// Best-effort git2 snapshot of a worktree; every field degrades to absent.
474#[derive(Default)]
475struct WtSnapshot {
476    branch: Option<String>,
477    detached: bool,
478    commit: Option<String>,
479    dirty: Option<bool>,
480}
481
482/// Reads branch, HEAD commit, and dirtiness from the worktree at `path`.
483fn snapshot(path: &Path) -> WtSnapshot {
484    let mut snap = WtSnapshot::default();
485    let repo = match git2::Repository::open(path) {
486        Ok(repo) => repo,
487        Err(e) => {
488            tracing::debug!("Cannot open {} as a git repository: {e}", path.display());
489            return snap;
490        }
491    };
492    match repo.head() {
493        Ok(head) => {
494            if head.is_branch() {
495                // Err = non-UTF-8 ref name; omit the branch rather than mangle it.
496                snap.branch = head.shorthand().ok().map(str::to_string);
497            } else {
498                snap.detached = true;
499            }
500            snap.commit = head.target().map(|oid| oid.to_string());
501        }
502        Err(e) => tracing::debug!("Cannot read HEAD of {}: {e}", path.display()),
503    }
504    // Untracked files count as dirty: they are exactly what `remove --force`
505    // destroys. Same StatusOptions recipe as the daemon worktrees service.
506    let mut opts = git2::StatusOptions::new();
507    opts.include_untracked(true)
508        .recurse_untracked_dirs(true)
509        .include_ignored(false)
510        .exclude_submodules(true);
511    match repo.statuses(Some(&mut opts)) {
512        Ok(statuses) => snap.dirty = Some(!statuses.is_empty()),
513        Err(e) => tracing::debug!("Cannot read status of {}: {e}", path.display()),
514    }
515    snap
516}
517
518/// Inserts the snapshot's fields into `context`, skipping unknown ones.
519fn insert_snapshot(context: &mut BTreeMap<String, String>, snap: &WtSnapshot, with_dirty: bool) {
520    if let Some(branch) = &snap.branch {
521        context.insert("branch".to_string(), branch.clone());
522    }
523    if snap.detached {
524        context.insert("detached".to_string(), "true".to_string());
525    }
526    if let Some(commit) = &snap.commit {
527        context.insert("commit".to_string(), commit.clone());
528    }
529    if with_dirty {
530        if let Some(dirty) = snap.dirty {
531            context.insert("had_uncommitted".to_string(), dirty.to_string());
532        }
533    }
534}
535
536/// Resolves `path` against `base` when relative (matching `git -C` semantics).
537fn absolutize(base: &Path, path: &Path) -> PathBuf {
538    if path.is_absolute() {
539        path.to_path_buf()
540    } else {
541        base.join(path)
542    }
543}
544
545/// Canonical display form of `path`; falls back to the joined path when the
546/// target does not exist (yet, or any more).
547fn display_canonical(path: &Path) -> String {
548    std::fs::canonicalize(path)
549        .unwrap_or_else(|_| path.to_path_buf())
550        .display()
551        .to_string()
552}
553
554// --- porcelain parsing (pure, unit-tested) --------------------------------
555
556/// One stanza of `git worktree list --porcelain`.
557#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
558pub struct WorktreeEntry {
559    /// Absolute worktree root path.
560    pub path: String,
561    /// HEAD commit sha.
562    #[serde(skip_serializing_if = "Option::is_none")]
563    pub head: Option<String>,
564    /// Checked-out branch, `refs/heads/` prefix stripped.
565    #[serde(skip_serializing_if = "Option::is_none")]
566    pub branch: Option<String>,
567    /// True when HEAD is detached.
568    #[serde(skip_serializing_if = "std::ops::Not::not")]
569    pub detached: bool,
570    /// True for a bare checkout.
571    #[serde(skip_serializing_if = "std::ops::Not::not")]
572    pub bare: bool,
573    /// Lock reason; empty string when locked without a reason.
574    #[serde(skip_serializing_if = "Option::is_none")]
575    pub locked: Option<String>,
576    /// Prunable reason.
577    #[serde(skip_serializing_if = "Option::is_none")]
578    pub prunable: Option<String>,
579}
580
581impl WorktreeEntry {
582    fn new(path: String) -> Self {
583        Self {
584            path,
585            head: None,
586            branch: None,
587            detached: false,
588            bare: false,
589            locked: None,
590            prunable: None,
591        }
592    }
593}
594
595/// Parses `git worktree list --porcelain` output. Unknown attributes are
596/// ignored so future git versions cannot break the parse.
597fn parse_worktree_porcelain(out: &str) -> Vec<WorktreeEntry> {
598    let mut entries = Vec::new();
599    let mut current: Option<WorktreeEntry> = None;
600    for line in out.lines() {
601        if let Some(path) = line.strip_prefix("worktree ") {
602            entries.extend(current.take());
603            current = Some(WorktreeEntry::new(path.to_string()));
604            continue;
605        }
606        let Some(entry) = current.as_mut() else {
607            continue;
608        };
609        if let Some(head) = line.strip_prefix("HEAD ") {
610            entry.head = Some(head.to_string());
611        } else if let Some(branch) = line.strip_prefix("branch ") {
612            entry.branch = Some(
613                branch
614                    .strip_prefix("refs/heads/")
615                    .unwrap_or(branch)
616                    .to_string(),
617            );
618        } else if line == "detached" {
619            entry.detached = true;
620        } else if line == "bare" {
621            entry.bare = true;
622        } else if line == "locked" {
623            entry.locked = Some(String::new());
624        } else if let Some(reason) = line.strip_prefix("locked ") {
625            entry.locked = Some(reason.to_string());
626        } else if let Some(reason) = line.strip_prefix("prunable ") {
627            entry.prunable = Some(reason.to_string());
628        }
629        // Blank lines end a stanza implicitly; the next `worktree ` flushes.
630    }
631    entries.extend(current);
632    entries
633}
634
635/// Best-effort porcelain listing of the repo at `base` (for the prune diff).
636fn list_entries(base: &Path) -> Option<Vec<WorktreeEntry>> {
637    let output = git_command()
638        .args([
639            "-c",
640            "core.quotePath=false",
641            "worktree",
642            "list",
643            "--porcelain",
644        ])
645        .current_dir(base)
646        .output()
647        .ok()?;
648    if !output.status.success() {
649        return None;
650    }
651    Some(parse_worktree_porcelain(&String::from_utf8_lossy(
652        &output.stdout,
653    )))
654}
655
656/// A pruned worktree, as recorded in the `pruned` context field.
657#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
658struct PrunedEntry {
659    path: String,
660    #[serde(skip_serializing_if = "Option::is_none")]
661    branch: Option<String>,
662    #[serde(skip_serializing_if = "Option::is_none")]
663    commit: Option<String>,
664}
665
666/// The entries present in `before` but gone from `after` — what prune removed,
667/// with the branch/commit git still reported for the prunable stanzas.
668fn pruned_entries(before: &[WorktreeEntry], after: &[WorktreeEntry]) -> Vec<PrunedEntry> {
669    before
670        .iter()
671        .filter(|b| !after.iter().any(|a| a.path == b.path))
672        .map(|b| PrunedEntry {
673            path: b.path.clone(),
674            branch: b.branch.clone(),
675            commit: b.head.clone(),
676        })
677        .collect()
678}
679
680/// Extracts the paths from `git worktree repair` report lines, best-effort.
681/// The messages are human-readable (`repair: <description>: <path>`), so an
682/// upstream wording change yields an empty list rather than an error.
683fn parse_repair_paths(out: &str) -> Vec<String> {
684    out.lines()
685        .filter_map(|line| line.strip_prefix("repair: "))
686        .filter_map(|rest| rest.rsplit_once(": ").map(|(_, path)| path.to_string()))
687        .collect()
688}
689
690#[cfg(test)]
691#[allow(clippy::unwrap_used, clippy::expect_used)]
692mod tests {
693    use super::*;
694
695    fn argv(args: &[&str]) -> Vec<String> {
696        args.iter().copied().map(String::from).collect()
697    }
698
699    // --- porcelain parser ---
700
701    #[test]
702    fn porcelain_parses_main_linked_and_detached_stanzas() {
703        let out = "\
704worktree /repo
705HEAD 1111111111111111111111111111111111111111
706branch refs/heads/main
707
708worktree /wt/feature
709HEAD 2222222222222222222222222222222222222222
710branch refs/heads/issue-1392
711
712worktree /wt/detached
713HEAD 3333333333333333333333333333333333333333
714detached
715";
716        let entries = parse_worktree_porcelain(out);
717        assert_eq!(entries.len(), 3);
718        assert_eq!(entries[0].path, "/repo");
719        assert_eq!(entries[0].branch.as_deref(), Some("main"));
720        assert_eq!(
721            entries[0].head.as_deref(),
722            Some("1111111111111111111111111111111111111111")
723        );
724        assert_eq!(entries[1].branch.as_deref(), Some("issue-1392"));
725        assert!(entries[2].detached);
726        assert!(entries[2].branch.is_none());
727    }
728
729    #[test]
730    fn porcelain_parses_bare_locked_prunable_and_ignores_unknown() {
731        let out = "\
732worktree /bare
733bare
734
735worktree /locked-no-reason
736HEAD 4444444444444444444444444444444444444444
737locked
738
739worktree /locked-with-reason
740locked worktree is on a portable device
741future-attribute some value
742
743worktree /stale
744prunable gitdir file points to non-existent location";
745        let entries = parse_worktree_porcelain(out);
746        assert_eq!(entries.len(), 4);
747        assert!(entries[0].bare);
748        assert_eq!(entries[1].locked.as_deref(), Some(""));
749        assert_eq!(
750            entries[2].locked.as_deref(),
751            Some("worktree is on a portable device")
752        );
753        assert_eq!(
754            entries[3].prunable.as_deref(),
755            Some("gitdir file points to non-existent location")
756        );
757    }
758
759    #[test]
760    fn porcelain_entry_serializes_compactly() {
761        let entries = parse_worktree_porcelain("worktree /repo\nbranch refs/heads/main\n");
762        let json = serde_json::to_string(&entries).unwrap();
763        // Absent/default fields are skipped.
764        assert_eq!(json, r#"[{"path":"/repo","branch":"main"}]"#);
765    }
766
767    #[test]
768    fn porcelain_ignores_attributes_before_the_first_stanza() {
769        // A stray attribute line with no open stanza is dropped, not a panic.
770        let entries = parse_worktree_porcelain("HEAD 9999\nworktree /a\n");
771        assert_eq!(entries.len(), 1);
772        assert_eq!(entries[0].path, "/a");
773        assert!(entries[0].head.is_none());
774    }
775
776    // --- metadata capture ---
777
778    #[test]
779    fn snapshot_degrades_on_non_repo_unborn_and_detached_trees() {
780        let tmp = tempfile::tempdir().unwrap();
781
782        // Not a repository: every field degrades to absent.
783        let snap = snapshot(tmp.path());
784        assert!(snap.branch.is_none());
785        assert!(snap.commit.is_none());
786        assert!(snap.dirty.is_none());
787        assert!(!snap.detached);
788
789        // Unborn HEAD (init, no commit): head() fails, statuses still answer.
790        let unborn = tmp.path().join("unborn");
791        git2::Repository::init(&unborn).unwrap();
792        let snap = snapshot(&unborn);
793        assert!(snap.branch.is_none());
794        assert!(snap.commit.is_none());
795        assert_eq!(snap.dirty, Some(false));
796
797        // Detached HEAD: branch omitted, detached flagged, commit present.
798        let detached = tmp.path().join("detached");
799        let repo = git2::Repository::init(&detached).unwrap();
800        let sig = git2::Signature::now("Test User", "test@example.com").unwrap();
801        let tree_id = repo.index().unwrap().write_tree().unwrap();
802        let tree = repo.find_tree(tree_id).unwrap();
803        let oid = repo
804            .commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
805            .unwrap();
806        repo.set_head_detached(oid).unwrap();
807        let snap = snapshot(&detached);
808        assert!(snap.detached);
809        assert!(snap.branch.is_none());
810        assert_eq!(snap.commit, Some(oid.to_string()));
811    }
812
813    #[test]
814    fn insert_snapshot_records_detached_and_gates_dirty() {
815        let snap = WtSnapshot {
816            branch: None,
817            detached: true,
818            commit: Some("abc".to_string()),
819            dirty: Some(false),
820        };
821        let mut ctx = BTreeMap::new();
822        insert_snapshot(&mut ctx, &snap, false);
823        assert_eq!(ctx.get("detached").map(String::as_str), Some("true"));
824        assert_eq!(ctx.get("commit").map(String::as_str), Some("abc"));
825        assert!(!ctx.contains_key("branch"));
826        assert!(!ctx.contains_key("had_uncommitted"));
827
828        insert_snapshot(&mut ctx, &snap, true);
829        assert_eq!(
830            ctx.get("had_uncommitted").map(String::as_str),
831            Some("false")
832        );
833    }
834
835    #[test]
836    fn absolutize_and_display_canonical_handle_relative_and_missing_paths() {
837        let base = Path::new("/base");
838        assert_eq!(absolutize(base, Path::new("/abs")), PathBuf::from("/abs"));
839        assert_eq!(
840            absolutize(base, Path::new("rel")),
841            PathBuf::from("/base/rel")
842        );
843        // A nonexistent path falls back to the un-canonicalized form verbatim.
844        assert_eq!(
845            display_canonical(Path::new("/no/such/dir/x")),
846            "/no/such/dir/x"
847        );
848    }
849
850    // --- argv builders ---
851
852    #[test]
853    fn add_argv_covers_flags_positionals_and_order() {
854        let args = AddArgs {
855            path: PathBuf::from("../wt"),
856            commit_ish: Some("origin/main".to_string()),
857            branch: Some("issue-1".to_string()),
858            force: true,
859            detach: false,
860        };
861        assert_eq!(
862            add_argv(&args),
863            argv(&[
864                "worktree",
865                "add",
866                "--force",
867                "-b",
868                "issue-1",
869                "../wt",
870                "origin/main"
871            ])
872        );
873        let minimal = AddArgs {
874            path: PathBuf::from("wt"),
875            commit_ish: None,
876            branch: None,
877            force: false,
878            detach: true,
879        };
880        assert_eq!(
881            add_argv(&minimal),
882            argv(&["worktree", "add", "--detach", "wt"])
883        );
884    }
885
886    #[test]
887    fn remove_move_prune_repair_argv() {
888        assert_eq!(
889            remove_argv(&RemoveArgs {
890                path: PathBuf::from("/wt"),
891                force: true,
892            }),
893            argv(&["worktree", "remove", "--force", "/wt"])
894        );
895        assert_eq!(
896            move_argv(&MoveArgs {
897                from: PathBuf::from("a"),
898                to: PathBuf::from("b"),
899                force: false,
900            }),
901            argv(&["worktree", "move", "a", "b"])
902        );
903        assert_eq!(
904            move_argv(&MoveArgs {
905                from: PathBuf::from("a"),
906                to: PathBuf::from("b"),
907                force: true,
908            }),
909            argv(&["worktree", "move", "--force", "a", "b"])
910        );
911        assert_eq!(
912            prune_argv(&PruneArgs {
913                dry_run: true,
914                verbose: true,
915                expire: Some("1.day".to_string()),
916            }),
917            argv(&[
918                "worktree",
919                "prune",
920                "--dry-run",
921                "--verbose",
922                "--expire",
923                "1.day"
924            ])
925        );
926        assert_eq!(
927            repair_argv(&RepairArgs {
928                paths: vec![PathBuf::from("a"), PathBuf::from("b")],
929            }),
930            argv(&["worktree", "repair", "a", "b"])
931        );
932    }
933
934    #[test]
935    fn list_argv_adds_quote_path_only_for_porcelain() {
936        assert_eq!(list_argv(false), argv(&["worktree", "list"]));
937        assert_eq!(
938            list_argv(true),
939            argv(&[
940                "-c",
941                "core.quotePath=false",
942                "worktree",
943                "list",
944                "--porcelain"
945            ])
946        );
947    }
948
949    // --- prune diff + repair parse ---
950
951    #[test]
952    fn pruned_entries_diffs_by_path_and_keeps_branch_commit() {
953        let before = parse_worktree_porcelain(
954            "worktree /repo\nbranch refs/heads/main\n\n\
955             worktree /stale\nHEAD 5555555555555555555555555555555555555555\n\
956             branch refs/heads/gone\nprunable gitdir gone\n",
957        );
958        let after = parse_worktree_porcelain("worktree /repo\nbranch refs/heads/main\n");
959        let pruned = pruned_entries(&before, &after);
960        assert_eq!(pruned.len(), 1);
961        assert_eq!(pruned[0].path, "/stale");
962        assert_eq!(pruned[0].branch.as_deref(), Some("gone"));
963        assert_eq!(
964            pruned[0].commit.as_deref(),
965            Some("5555555555555555555555555555555555555555")
966        );
967        assert!(pruned_entries(&after, &after).is_empty());
968    }
969
970    #[test]
971    fn repair_paths_parsed_from_report_lines_best_effort() {
972        let out = "\
973repair: gitdir file points to non-existent location: /wt/one/.git
974not a repair line
975repair: .git file broken: /wt/two/.git";
976        assert_eq!(
977            parse_repair_paths(out),
978            argv(&["/wt/one/.git", "/wt/two/.git"])
979        );
980        assert!(parse_repair_paths("nothing to repair").is_empty());
981    }
982
983    // --- clap surface ---
984
985    #[derive(Parser)]
986    struct Wrapper {
987        #[command(subcommand)]
988        cmd: WorktreeSubcommands,
989    }
990
991    #[test]
992    fn clap_parses_every_verb() {
993        for args in [
994            vec!["omni-dev", "add", "wt", "-b", "branch", "origin/main"],
995            vec!["omni-dev", "remove", "--force", "wt"],
996            vec!["omni-dev", "list", "--porcelain"],
997            vec!["omni-dev", "list", "--output-json"],
998            vec!["omni-dev", "move", "a", "b"],
999            vec!["omni-dev", "prune", "-n", "--expire", "1.day"],
1000            vec!["omni-dev", "repair", "a", "b"],
1001            vec!["omni-dev", "repair"],
1002        ] {
1003            Wrapper::try_parse_from(&args)
1004                .unwrap_or_else(|e| panic!("failed to parse {args:?}: {e}"));
1005        }
1006    }
1007
1008    #[test]
1009    fn clap_rejects_porcelain_with_output_json() {
1010        assert!(
1011            Wrapper::try_parse_from(["omni-dev", "list", "--porcelain", "--output-json"]).is_err()
1012        );
1013    }
1014}