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}