Skip to main content

omne_cli/
worktree.rs

1//! Per-run git worktree management.
2//!
3//! Every `omne run` creates a detached-HEAD worktree at
4//! `.omne/wt/<run_id>/` via `git worktree add --detach`. Detached HEAD
5//! avoids git's "branch already checked out" refusal on back-to-back
6//! runs from the same branch (plan R5).
7//!
8//! The module is a thin wrapper over the `git` binary (subprocess), not
9//! libgit2, to stay consistent with the user's own git installation and
10//! hook configuration. The process pattern mirrors `crate::github` for
11//! spawn-and-capture handling, with two local additions:
12//!
13//! - `LC_ALL=C` is forced on every git invocation so stderr classifier
14//!   substrings like `"not a git repository"` stay locale-independent.
15//! - A `GIT_TIMEOUT` bounds each subprocess so a wedged git (corrupted
16//!   index, stalled network filesystem) cannot hang `omne run` forever.
17//!
18//! Path-length preflight: Windows' default `MAX_PATH` is 260 characters;
19//! worktree creation can fail with cryptic "Filename too long" errors on
20//! long volume roots. `preflight_volume_path_length` warns at 80 chars
21//! and errors at 120 before any state mutation happens.
22
23#![allow(dead_code)]
24
25use std::collections::BTreeSet;
26use std::io::Read;
27use std::path::{Path, PathBuf};
28use std::process::{Command, Output, Stdio};
29use std::time::Duration;
30
31use thiserror::Error;
32use wait_timeout::ChildExt;
33
34use crate::volume::{wt_dir, wt_for};
35
36/// Volume path length at which a warning is emitted (plan R5 preflight).
37pub const PATH_LENGTH_WARN: usize = 80;
38
39/// Volume path length at which preflight fails outright.
40pub const PATH_LENGTH_ERROR: usize = 120;
41
42/// Upper bound on any single `git worktree` subprocess. Enough slack for
43/// slow NFS/SMB volumes without letting a wedged git stall the runner.
44pub const GIT_TIMEOUT: Duration = Duration::from_secs(30);
45
46/// Whether `remove` may discard uncommitted changes in the worktree.
47///
48/// Expressed as a named enum rather than a `bool` so call sites like
49/// `remove(root, id, RemoveMode::Force)` stay self-documenting at the
50/// call boundary.
51#[derive(Debug, Clone, Copy, Eq, PartialEq)]
52pub enum RemoveMode {
53    /// Mirror git's default: refuse to remove a worktree with
54    /// uncommitted changes or a lock.
55    Normal,
56    /// Pass `--force` to `git worktree remove`, discarding local
57    /// modifications. Reserved for teardown paths where the executor
58    /// has already captured the interesting output.
59    Force,
60}
61
62#[derive(Debug, Error)]
63pub enum Error {
64    /// The target worktree directory already exists on disk, or `git
65    /// worktree add` reported an existing path. Raised both from the
66    /// pre-spawn `exists()` check and from the stderr classifier so a
67    /// TOCTOU race between the two calls still surfaces as
68    /// `AlreadyExists` rather than a generic `GitCommandFailed`.
69    #[error(
70        "worktree already exists at {path}\n\
71         hint: `git worktree remove --force {}` to reclaim the slot",
72        path.display()
73    )]
74    AlreadyExists { path: PathBuf },
75
76    /// The volume root is not inside a git repository (or cannot reach
77    /// `.git`). Plan-mandated preflight for `omne run`.
78    #[error(
79        "{path} is not a git repository\n\
80         hint: run `git init` in the volume root before `omne run`"
81    )]
82    NotAGitRepo { path: PathBuf },
83
84    /// `run_id` contains characters that would let the worktree path
85    /// escape `.omne/wt/` (path separators, `..`, null, leading `-`).
86    /// Raised before any filesystem or git call so no partial state
87    /// leaks on rejection.
88    #[error(
89        "invalid run_id {run_id:?}: must not be empty, contain path separators \
90         or null bytes, equal \".\" or \"..\", or start with '-'"
91    )]
92    InvalidRunId { run_id: String },
93
94    /// Volume path is at or above `PATH_LENGTH_ERROR` characters.
95    /// Windows-only concern in practice, but the threshold is
96    /// platform-agnostic so tests behave the same on every host.
97    ///
98    /// Note: this threshold measures the **volume root** only. Pipelines
99    /// that write deeply-nested files inside the worktree can still
100    /// blow past `MAX_PATH` even when the volume root passes preflight.
101    /// The threshold exists to catch the most common failure mode (long
102    /// user-profile paths), not to guarantee every downstream file
103    /// operation stays under 260.
104    #[error(
105        "volume path {length} chars is at or above max {limit}: {}\n\
106         hint: shorten the volume root, or enable Windows Long Paths \
107         (`LongPathsEnabled=1` in HKLM\\SYSTEM\\CurrentControlSet\\Control\\FileSystem)",
108        path.display()
109    )]
110    PathTooLong {
111        path: PathBuf,
112        length: usize,
113        limit: usize,
114    },
115
116    /// `git` could not be launched (missing binary, permission denied,
117    /// etc). Distinct from `GitCommandFailed` (which is git itself
118    /// returning non-zero).
119    #[error("failed to launch git: {source}")]
120    Io {
121        #[source]
122        source: std::io::Error,
123    },
124
125    /// `git` ran past `GIT_TIMEOUT` without exiting. The child is
126    /// killed before this error is returned so no zombie subprocess
127    /// leaks.
128    #[error("git {args:?} did not exit within {elapsed:?}")]
129    GitTimeout {
130        args: Vec<String>,
131        elapsed: Duration,
132    },
133
134    /// `git` ran but exited non-zero. `stderr` is preserved verbatim for
135    /// the command handler to surface.
136    #[error("git {args:?} failed: {stderr}")]
137    GitCommandFailed { args: Vec<String>, stderr: String },
138}
139
140/// Cross-checked enumeration of per-run worktrees.
141///
142/// Three buckets because the two sources of truth (filesystem and
143/// `git worktree list`) can diverge, and downstream consumers (plan
144/// Unit 13 `omne status`, future cleanup path) need to know which:
145///
146/// - `paired` — fs dir AND git record both present. Healthy run.
147/// - `fs_only` — fs dir present, git never knew or forgot (manual
148///   `git worktree remove` without cleaning the dir, partial-state
149///   leak from an interrupted `create`).
150/// - `git_only` — git still tracks but fs dir is gone (manual `rm -rf`
151///   without `git worktree prune`). `git worktree prune` is the fix.
152#[derive(Debug, Default, Clone, Eq, PartialEq)]
153pub struct WorktreeList {
154    pub paired: Vec<String>,
155    pub fs_only: Vec<String>,
156    pub git_only: Vec<String>,
157}
158
159/// Check volume root path length against the plan's thresholds.
160///
161/// Returns `Ok(())` below `PATH_LENGTH_WARN`, `Ok(())` with a stderr
162/// warning when the length is at or above `PATH_LENGTH_WARN` but below
163/// `PATH_LENGTH_ERROR`, and `Err(PathTooLong)` when the length is at
164/// or above `PATH_LENGTH_ERROR`. The thresholds are inclusive at the
165/// boundary: the 120-char case fails; the 80-char case warns.
166///
167/// Length is measured in bytes of the `OsStr` representation. On ASCII
168/// paths (the overwhelming majority of volume roots) this equals the
169/// character count. On non-ASCII paths the threshold is conservative
170/// relative to Unicode length, which is the right direction for a
171/// headroom check against Windows `MAX_PATH`.
172pub fn preflight_volume_path_length(volume_root: &Path) -> Result<(), Error> {
173    let length = volume_root.as_os_str().len();
174    if length >= PATH_LENGTH_ERROR {
175        return Err(Error::PathTooLong {
176            path: volume_root.to_path_buf(),
177            length,
178            limit: PATH_LENGTH_ERROR,
179        });
180    }
181    if length >= PATH_LENGTH_WARN {
182        eprintln!(
183            "warning: volume path is {length} characters (>= {PATH_LENGTH_WARN}); \
184             git worktree may hit Windows MAX_PATH limits"
185        );
186    }
187    Ok(())
188}
189
190/// Create a detached-HEAD worktree at `.omne/wt/<run_id>`.
191///
192/// Returns the absolute (or volume-relative, as `git` prints it)
193/// worktree path on success. The parent `.omne/wt/` directory is
194/// created on demand so `create` is safe to call before scaffold has
195/// ever touched the worktree root (useful in tests).
196///
197/// `create` does **not** call `preflight_volume_path_length` —
198/// `omne run` runs preflight earlier so failures surface before any
199/// ULID is minted. Callers invoking `create` directly must run
200/// preflight themselves if they want early path-length diagnosis.
201///
202/// Errors:
203/// - `AlreadyExists` if the target path exists before the git call.
204/// - `NotAGitRepo` if `git` reports the volume root is not a repo.
205/// - `GitTimeout` if the subprocess exceeds `GIT_TIMEOUT`.
206/// - `GitCommandFailed` for any other non-zero `git` exit.
207/// - `Io` if the `git` binary cannot be launched.
208pub fn create(volume_root: &Path, run_id: &str) -> Result<PathBuf, Error> {
209    validate_run_id(run_id)?;
210    let wt_path = wt_for(volume_root, run_id);
211    if wt_path.exists() {
212        return Err(Error::AlreadyExists { path: wt_path });
213    }
214
215    let parent = wt_dir(volume_root);
216    std::fs::create_dir_all(&parent).map_err(|source| Error::Io { source })?;
217
218    let wt_path_str = wt_path.to_string_lossy();
219    let args: [&str; 4] = ["worktree", "add", "--detach", wt_path_str.as_ref()];
220    let output = run_git(volume_root, &args)?;
221
222    if !output.status.success() {
223        // Classify TOCTOU: another process created the same path
224        // between the pre-spawn `exists()` check and this spawn.
225        // `LC_ALL=C` is pinned by `run_git`, so the substring is stable.
226        let stderr = String::from_utf8_lossy(&output.stderr);
227        if stderr.contains("already exists") {
228            return Err(Error::AlreadyExists { path: wt_path });
229        }
230        return Err(classify_git_error(volume_root, &args, &output.stderr));
231    }
232    Ok(wt_path)
233}
234
235/// Remove a per-run worktree via `git worktree remove`.
236///
237/// `RemoveMode::Force` passes `--force` to git, allowing removal of a
238/// worktree that has uncommitted changes or is locked.
239pub fn remove(volume_root: &Path, run_id: &str, mode: RemoveMode) -> Result<(), Error> {
240    validate_run_id(run_id)?;
241    let wt_path = wt_for(volume_root, run_id);
242    let wt_path_str = wt_path.to_string_lossy();
243    let mut args: Vec<&str> = vec!["worktree", "remove"];
244    if matches!(mode, RemoveMode::Force) {
245        args.push("--force");
246    }
247    args.push(wt_path_str.as_ref());
248
249    let output = run_git(volume_root, &args)?;
250    if !output.status.success() {
251        return Err(classify_git_error(volume_root, &args, &output.stderr));
252    }
253    Ok(())
254}
255
256/// Enumerate per-run worktrees under `.omne/wt/`, partitioned into
257/// healthy pairs plus filesystem-only and git-only orphans.
258///
259/// Three sources of truth are reconciled:
260/// - child directories physically present under `.omne/wt/`,
261/// - paths reported by `git worktree list --porcelain` resolving under
262///   `.omne/wt/`,
263/// - their intersection.
264///
265/// Unit 13 `omne status` uses the orphan buckets to flag unpaired
266/// runs; a future cleanup path uses them to drive `git worktree prune`
267/// or stray-directory removal. Returning only the intersection (as an
268/// earlier draft did) silently leaked orphan state past consumers —
269/// hence this struct-return shape.
270///
271/// If `.omne/wt/` exists but cannot be canonicalized (permission,
272/// reparse-point anomaly), the error is surfaced as `Error::Io`
273/// rather than being silently downgraded to empty buckets.
274pub fn list(volume_root: &Path) -> Result<WorktreeList, Error> {
275    let args: [&str; 3] = ["worktree", "list", "--porcelain"];
276    let output = run_git(volume_root, &args)?;
277
278    if !output.status.success() {
279        return Err(classify_git_error(volume_root, &args, &output.stderr));
280    }
281    let stdout = String::from_utf8_lossy(&output.stdout);
282
283    let wt_root = wt_dir(volume_root);
284    let canonical_wt_root = if wt_root.is_dir() {
285        Some(
286            wt_root
287                .canonicalize()
288                .map_err(|source| Error::Io { source })?,
289        )
290    } else {
291        None
292    };
293
294    // Parse porcelain output: each worktree record starts with a
295    // `worktree <path>` line, followed by metadata lines. Empty lines
296    // separate records.
297    let mut git_ids: BTreeSet<String> = BTreeSet::new();
298    for line in stdout.lines() {
299        let Some(rest) = line.strip_prefix("worktree ") else {
300            continue;
301        };
302        let p = Path::new(rest.trim());
303        let Some(ref root) = canonical_wt_root else {
304            continue;
305        };
306        let Ok(canon_p) = p.canonicalize() else {
307            continue;
308        };
309        let Ok(rel) = canon_p.strip_prefix(root) else {
310            continue;
311        };
312        if let Some(first) = rel.components().next() {
313            git_ids.insert(first.as_os_str().to_string_lossy().into_owned());
314        }
315    }
316
317    // Filesystem enumeration: direct children of `.omne/wt/` that are
318    // directories. Missing wt_root is not an error — just no worktrees.
319    let mut fs_ids: BTreeSet<String> = BTreeSet::new();
320    if wt_root.is_dir() {
321        let entries = std::fs::read_dir(&wt_root).map_err(|source| Error::Io { source })?;
322        for entry in entries {
323            let entry = entry.map_err(|source| Error::Io { source })?;
324            let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
325            if is_dir {
326                fs_ids.insert(entry.file_name().to_string_lossy().into_owned());
327            }
328        }
329    }
330
331    let paired: Vec<String> = git_ids.intersection(&fs_ids).cloned().collect();
332    let fs_only: Vec<String> = fs_ids.difference(&git_ids).cloned().collect();
333    let git_only: Vec<String> = git_ids.difference(&fs_ids).cloned().collect();
334    Ok(WorktreeList {
335        paired,
336        fs_only,
337        git_only,
338    })
339}
340
341/// Reject a `run_id` that could be misinterpreted by downstream path
342/// joining or by `git worktree add`. Called from `create` and `remove`
343/// before any filesystem or subprocess effect so rejection leaves no
344/// state behind.
345///
346/// `Path::join` on a segment containing `..` or an absolute prefix
347/// escapes the `.omne/wt/` parent — accepting such a `run_id` would
348/// let a caller drive the worktree machinery against arbitrary paths.
349/// A leading `-` additionally risks git misinterpreting the path as a
350/// flag.
351fn validate_run_id(run_id: &str) -> Result<(), Error> {
352    let invalid = run_id.is_empty()
353        || run_id == "."
354        || run_id == ".."
355        || run_id.starts_with('-')
356        || run_id.contains('/')
357        || run_id.contains('\\')
358        || run_id.contains('\0');
359    if invalid {
360        return Err(Error::InvalidRunId {
361            run_id: run_id.to_string(),
362        });
363    }
364    Ok(())
365}
366
367/// Spawn `git` with the module's fixed environment and a wall-clock
368/// deadline, then capture stdout/stderr.
369///
370/// The child inherits the parent env, plus:
371/// - `LC_ALL=C` so stderr substrings stay locale-independent for
372///   `classify_git_error`.
373/// - `LANGUAGE=` to suppress the GNU-specific localization variable
374///   that otherwise wins over `LC_ALL` on some distros.
375///
376/// If the subprocess does not exit within `GIT_TIMEOUT`, the child is
377/// killed and `Error::GitTimeout` is returned. Stdout and stderr are
378/// captured via `Stdio::piped()` and fully read after the child exits;
379/// the short, bounded output of `git worktree` commands makes this
380/// safe without a reader thread.
381fn run_git(volume_root: &Path, args: &[&str]) -> Result<Output, Error> {
382    let mut child = Command::new("git")
383        .current_dir(volume_root)
384        .env("LC_ALL", "C")
385        .env("LANGUAGE", "")
386        .args(args)
387        .stdout(Stdio::piped())
388        .stderr(Stdio::piped())
389        .spawn()
390        .map_err(|source| Error::Io { source })?;
391
392    // Drain pipes before waiting to prevent deadlock when git output
393    // fills the OS pipe buffer.
394    let mut stdout = Vec::new();
395    if let Some(mut s) = child.stdout.take() {
396        s.read_to_end(&mut stdout)
397            .map_err(|source| Error::Io { source })?;
398    }
399    let mut stderr = Vec::new();
400    if let Some(mut s) = child.stderr.take() {
401        s.read_to_end(&mut stderr)
402            .map_err(|source| Error::Io { source })?;
403    }
404
405    let status = match child
406        .wait_timeout(GIT_TIMEOUT)
407        .map_err(|source| Error::Io { source })?
408    {
409        Some(status) => status,
410        None => {
411            let _ = child.kill();
412            let _ = child.wait();
413            return Err(Error::GitTimeout {
414                args: args.iter().map(|s| s.to_string()).collect(),
415                elapsed: GIT_TIMEOUT,
416            });
417        }
418    };
419
420    Ok(Output {
421        status,
422        stdout,
423        stderr,
424    })
425}
426
427fn classify_git_error(volume_root: &Path, args: &[&str], stderr: &[u8]) -> Error {
428    let stderr = String::from_utf8_lossy(stderr).into_owned();
429    // `run_git` pins `LC_ALL=C`, so the English substring is stable.
430    if stderr.contains("not a git repository") {
431        return Error::NotAGitRepo {
432            path: volume_root.to_path_buf(),
433        };
434    }
435    Error::GitCommandFailed {
436        args: args.iter().map(|s| s.to_string()).collect(),
437        stderr,
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use std::path::PathBuf;
445
446    #[test]
447    fn preflight_passes_under_warn_threshold() {
448        let short = PathBuf::from("C:/o");
449        preflight_volume_path_length(&short).expect("under warn threshold should pass");
450    }
451
452    #[test]
453    fn preflight_warns_at_exact_warn_threshold() {
454        // Exactly PATH_LENGTH_WARN chars: warns (stderr), does not err.
455        let at_warn = PathBuf::from("x".repeat(PATH_LENGTH_WARN));
456        preflight_volume_path_length(&at_warn).expect("at warn boundary must warn, not err");
457    }
458
459    #[test]
460    fn preflight_warns_between_thresholds_without_error() {
461        let mid = PathBuf::from("x".repeat(PATH_LENGTH_WARN + 10));
462        preflight_volume_path_length(&mid).expect("mid-range should warn, not error");
463    }
464
465    #[test]
466    fn preflight_errors_at_exact_error_threshold() {
467        // Exactly PATH_LENGTH_ERROR chars: must err (inclusive threshold).
468        let at_err = PathBuf::from("x".repeat(PATH_LENGTH_ERROR));
469        let err = preflight_volume_path_length(&at_err).unwrap_err();
470        assert!(
471            matches!(
472                err,
473                Error::PathTooLong { length, limit, .. }
474                    if length == PATH_LENGTH_ERROR && limit == PATH_LENGTH_ERROR
475            ),
476            "expected PathTooLong at exact threshold, got {err:?}"
477        );
478    }
479
480    #[test]
481    fn validate_run_id_accepts_typical_pipe_ulid_shape() {
482        validate_run_id("faber-01arz3ndektsv4rrffq69g5fav").expect("typical run_id should pass");
483        validate_run_id("x").expect("single-char run_id should pass");
484    }
485
486    #[test]
487    fn validate_run_id_rejects_traversal_and_separators() {
488        for bad in ["", ".", "..", "../escape", "a/b", "a\\b", "a\0b", "-flag"] {
489            let err = validate_run_id(bad).unwrap_err();
490            assert!(
491                matches!(err, Error::InvalidRunId { .. }),
492                "expected InvalidRunId for {bad:?}, got {err:?}"
493            );
494        }
495    }
496
497    #[test]
498    fn preflight_errors_above_error_threshold() {
499        let long = PathBuf::from("x".repeat(PATH_LENGTH_ERROR + 1));
500        let err = preflight_volume_path_length(&long).unwrap_err();
501        assert!(
502            matches!(
503                err,
504                Error::PathTooLong { length, limit, .. }
505                    if length == PATH_LENGTH_ERROR + 1 && limit == PATH_LENGTH_ERROR
506            ),
507            "expected PathTooLong, got {err:?}"
508        );
509    }
510}