Skip to main content

claude_wrapper/
worktrees.rs

1//! Read-side introspection for git worktrees.
2//!
3//! Claude Code's `--worktree [name]` flag (and its duplex equivalent
4//! [`crate::duplex::DuplexOptions::worktree`]) creates fresh git
5//! worktrees so an agent's writes can land somewhere other than the
6//! current working tree. Hosts that orchestrate worktree-isolated
7//! chats need a way to enumerate the worktrees that exist for a
8//! given repo, see what branches they're on, and notice when one is
9//! locked or prunable.
10//!
11//! This module is a thin Rust API over `git worktree list
12//! --porcelain`. It is read-only on purpose; mutations (pruning,
13//! removing worktrees) are tracked separately so consumers that
14//! only want to introspect don't opt into write semantics.
15//!
16//! # Why shell out
17//!
18//! Reading `.git/worktrees/` directly is brittle: git tracks
19//! `locked`, `prunable`, detached-HEAD, and bare-repo state in ways
20//! that have evolved across releases. Asking git itself is the
21//! cheap, correct option, and `git` is already a transitive
22//! dependency of any worktree-using workflow.
23//!
24//! # Example
25//!
26//! ```no_run
27//! use claude_wrapper::worktrees::WorktreeRoot;
28//!
29//! # fn example() -> claude_wrapper::Result<()> {
30//! let root = WorktreeRoot::for_repo(".");
31//! for wt in root.list()? {
32//!     println!(
33//!         "{}  {}  {}{}",
34//!         wt.path.display(),
35//!         wt.branch.as_deref().unwrap_or("(detached)"),
36//!         wt.head.as_deref().unwrap_or(""),
37//!         if wt.is_locked { "  [locked]" } else { "" },
38//!     );
39//! }
40//! # Ok(()) }
41//! ```
42
43use std::path::{Path, PathBuf};
44use std::process::Command;
45
46use serde::Serialize;
47
48use crate::error::{Error, Result};
49
50/// Entry point for listing git worktrees rooted at a repository.
51/// Construct with [`Self::for_repo`] pointing at any path inside the
52/// repo (typically the repo root); git resolves the actual `.git`
53/// from there.
54#[derive(Debug, Clone)]
55pub struct WorktreeRoot {
56    repo_path: PathBuf,
57}
58
59impl WorktreeRoot {
60    /// Address worktrees for the repository containing `path`.
61    ///
62    /// `path` can be any directory inside the repo; git's `-C`
63    /// handling resolves the `.git` itself. Doesn't validate that
64    /// `path` is in fact a git repo until you call [`Self::list`].
65    pub fn for_repo(path: impl Into<PathBuf>) -> Self {
66        Self {
67            repo_path: path.into(),
68        }
69    }
70
71    /// The configured repository path.
72    pub fn path(&self) -> &Path {
73        &self.repo_path
74    }
75
76    /// List every worktree git knows about for this repository.
77    ///
78    /// Spawns `git -C <repo_path> worktree list --porcelain` and
79    /// parses the output. The first entry is the main worktree.
80    /// Errors if `git` isn't on PATH, the path isn't a git repo, or
81    /// the porcelain output is malformed.
82    pub fn list(&self) -> Result<Vec<Worktree>> {
83        let output = Command::new("git")
84            .arg("-C")
85            .arg(&self.repo_path)
86            .arg("worktree")
87            .arg("list")
88            .arg("--porcelain")
89            .output()
90            .map_err(|e| Error::Worktrees {
91                message: format!("failed to spawn git: {e}"),
92            })?;
93
94        if !output.status.success() {
95            let stderr = String::from_utf8_lossy(&output.stderr);
96            return Err(Error::Worktrees {
97                message: format!(
98                    "git worktree list failed (exit {}): {}",
99                    output.status.code().unwrap_or(-1),
100                    stderr.trim()
101                ),
102            });
103        }
104
105        let stdout = String::from_utf8_lossy(&output.stdout);
106        Ok(parse_porcelain(&stdout))
107    }
108}
109
110/// One git worktree as reported by `git worktree list --porcelain`.
111#[derive(Debug, Clone, Serialize)]
112pub struct Worktree {
113    /// Absolute path to the worktree directory.
114    pub path: PathBuf,
115    /// Commit SHA at HEAD. None for bare worktrees.
116    pub head: Option<String>,
117    /// Branch name without `refs/heads/` prefix. None when the
118    /// worktree has detached HEAD (or is bare).
119    pub branch: Option<String>,
120    /// True for the primary worktree (the one git considers the
121    /// "main" worktree). Always the first in the list returned by
122    /// [`WorktreeRoot::list`].
123    pub is_main: bool,
124    /// True if HEAD is detached (no branch checked out).
125    pub is_detached: bool,
126    /// True for a bare repository worktree.
127    pub is_bare: bool,
128    /// True if `git worktree lock` has been applied.
129    pub is_locked: bool,
130    /// Optional human-readable lock reason if `is_locked`.
131    pub lock_reason: Option<String>,
132    /// True if git considers this worktree prunable (the directory
133    /// is missing or the lock file is stale).
134    pub is_prunable: bool,
135    /// Optional human-readable prunable reason if `is_prunable`.
136    pub prune_reason: Option<String>,
137}
138
139fn parse_porcelain(input: &str) -> Vec<Worktree> {
140    let mut out = Vec::new();
141    let mut current: Option<WorktreeBuilder> = None;
142    let mut is_first = true;
143
144    for line in input.lines() {
145        let line = line.trim_end_matches('\r');
146
147        if line.is_empty() {
148            if let Some(b) = current.take() {
149                let mut wt = b.build();
150                if is_first {
151                    wt.is_main = true;
152                    is_first = false;
153                }
154                out.push(wt);
155            }
156            continue;
157        }
158
159        let (key, value) = match line.split_once(' ') {
160            Some((k, v)) => (k, Some(v)),
161            None => (line, None),
162        };
163
164        match key {
165            "worktree" => {
166                // New block; flush any pending one.
167                if let Some(b) = current.take() {
168                    let mut wt = b.build();
169                    if is_first {
170                        wt.is_main = true;
171                        is_first = false;
172                    }
173                    out.push(wt);
174                }
175                current = Some(WorktreeBuilder::new(
176                    value.map(PathBuf::from).unwrap_or_default(),
177                ));
178            }
179            "HEAD" => {
180                if let Some(b) = current.as_mut() {
181                    b.head = value.map(str::to_string);
182                }
183            }
184            "branch" => {
185                if let Some(b) = current.as_mut() {
186                    b.branch = value.map(strip_branch_prefix);
187                }
188            }
189            "detached" => {
190                if let Some(b) = current.as_mut() {
191                    b.is_detached = true;
192                }
193            }
194            "bare" => {
195                if let Some(b) = current.as_mut() {
196                    b.is_bare = true;
197                }
198            }
199            "locked" => {
200                if let Some(b) = current.as_mut() {
201                    b.is_locked = true;
202                    b.lock_reason = value.map(str::to_string).filter(|s| !s.is_empty());
203                }
204            }
205            "prunable" => {
206                if let Some(b) = current.as_mut() {
207                    b.is_prunable = true;
208                    b.prune_reason = value.map(str::to_string).filter(|s| !s.is_empty());
209                }
210            }
211            _ => {
212                // Unknown key -- skip. Keeps the parser
213                // forward-compatible with future porcelain fields.
214            }
215        }
216    }
217
218    // Trailing block without a closing blank line.
219    if let Some(b) = current.take() {
220        let mut wt = b.build();
221        if is_first {
222            wt.is_main = true;
223        }
224        out.push(wt);
225    }
226
227    out
228}
229
230fn strip_branch_prefix(branch: &str) -> String {
231    branch
232        .strip_prefix("refs/heads/")
233        .unwrap_or(branch)
234        .to_string()
235}
236
237#[derive(Debug)]
238struct WorktreeBuilder {
239    path: PathBuf,
240    head: Option<String>,
241    branch: Option<String>,
242    is_detached: bool,
243    is_bare: bool,
244    is_locked: bool,
245    lock_reason: Option<String>,
246    is_prunable: bool,
247    prune_reason: Option<String>,
248}
249
250impl WorktreeBuilder {
251    fn new(path: PathBuf) -> Self {
252        Self {
253            path,
254            head: None,
255            branch: None,
256            is_detached: false,
257            is_bare: false,
258            is_locked: false,
259            lock_reason: None,
260            is_prunable: false,
261            prune_reason: None,
262        }
263    }
264
265    fn build(self) -> Worktree {
266        Worktree {
267            path: self.path,
268            head: self.head,
269            branch: self.branch,
270            is_main: false, // set by caller when applicable
271            is_detached: self.is_detached,
272            is_bare: self.is_bare,
273            is_locked: self.is_locked,
274            lock_reason: self.lock_reason,
275            is_prunable: self.is_prunable,
276            prune_reason: self.prune_reason,
277        }
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn parse_single_main_worktree() {
287        let raw = "\
288worktree /repo/main
289HEAD abc123
290branch refs/heads/main
291";
292        let out = parse_porcelain(raw);
293        assert_eq!(out.len(), 1);
294        let wt = &out[0];
295        assert_eq!(wt.path, PathBuf::from("/repo/main"));
296        assert_eq!(wt.head.as_deref(), Some("abc123"));
297        assert_eq!(wt.branch.as_deref(), Some("main"));
298        assert!(wt.is_main);
299        assert!(!wt.is_detached);
300        assert!(!wt.is_bare);
301        assert!(!wt.is_locked);
302        assert!(!wt.is_prunable);
303    }
304
305    #[test]
306    fn parse_multiple_worktrees_marks_first_as_main() {
307        let raw = "\
308worktree /repo/main
309HEAD aaa
310branch refs/heads/main
311
312worktree /repo/feature-x
313HEAD bbb
314branch refs/heads/feature-x
315
316worktree /repo/feature-y
317HEAD ccc
318branch refs/heads/feature-y
319";
320        let out = parse_porcelain(raw);
321        assert_eq!(out.len(), 3);
322        assert!(out[0].is_main);
323        assert!(!out[1].is_main);
324        assert!(!out[2].is_main);
325        assert_eq!(out[0].branch.as_deref(), Some("main"));
326        assert_eq!(out[1].branch.as_deref(), Some("feature-x"));
327        assert_eq!(out[2].branch.as_deref(), Some("feature-y"));
328    }
329
330    #[test]
331    fn parse_detached_head() {
332        let raw = "\
333worktree /repo/main
334HEAD aaa
335branch refs/heads/main
336
337worktree /repo/poking
338HEAD ddd
339detached
340";
341        let out = parse_porcelain(raw);
342        assert_eq!(out.len(), 2);
343        assert!(out[1].is_detached);
344        assert!(out[1].branch.is_none());
345        assert_eq!(out[1].head.as_deref(), Some("ddd"));
346    }
347
348    #[test]
349    fn parse_bare_worktree() {
350        let raw = "\
351worktree /repo/bare
352bare
353";
354        let out = parse_porcelain(raw);
355        assert_eq!(out.len(), 1);
356        assert!(out[0].is_bare);
357        assert!(out[0].head.is_none());
358        assert!(out[0].branch.is_none());
359    }
360
361    #[test]
362    fn parse_locked_with_reason() {
363        let raw = "\
364worktree /repo/main
365HEAD aaa
366branch refs/heads/main
367
368worktree /repo/release-prep
369HEAD bbb
370branch refs/heads/release-prep
371locked Cutting v2.0
372";
373        let out = parse_porcelain(raw);
374        assert_eq!(out.len(), 2);
375        assert!(out[1].is_locked);
376        assert_eq!(out[1].lock_reason.as_deref(), Some("Cutting v2.0"));
377    }
378
379    #[test]
380    fn parse_locked_without_reason() {
381        let raw = "\
382worktree /repo/main
383HEAD aaa
384branch refs/heads/main
385
386worktree /repo/wedged
387HEAD bbb
388branch refs/heads/wedged
389locked
390";
391        let out = parse_porcelain(raw);
392        assert_eq!(out.len(), 2);
393        assert!(out[1].is_locked);
394        assert!(out[1].lock_reason.is_none());
395    }
396
397    #[test]
398    fn parse_prunable_with_reason() {
399        let raw = "\
400worktree /repo/main
401HEAD aaa
402branch refs/heads/main
403
404worktree /repo/gone
405HEAD bbb
406branch refs/heads/gone
407prunable gitdir file points to non-existent location
408";
409        let out = parse_porcelain(raw);
410        assert_eq!(out.len(), 2);
411        assert!(out[1].is_prunable);
412        assert!(
413            out[1]
414                .prune_reason
415                .as_deref()
416                .unwrap_or("")
417                .contains("non-existent")
418        );
419    }
420
421    #[test]
422    fn parse_handles_trailing_block_without_blank_line() {
423        let raw = "\
424worktree /repo/main
425HEAD aaa
426branch refs/heads/main";
427        let out = parse_porcelain(raw);
428        assert_eq!(out.len(), 1);
429        assert_eq!(out[0].path, PathBuf::from("/repo/main"));
430    }
431
432    #[test]
433    fn parse_strips_refs_heads_prefix() {
434        let raw = "\
435worktree /repo/x
436HEAD aaa
437branch refs/heads/feature/long/path
438";
439        let out = parse_porcelain(raw);
440        assert_eq!(out[0].branch.as_deref(), Some("feature/long/path"));
441    }
442
443    #[test]
444    fn parse_unknown_keys_are_ignored() {
445        // Forward-compat: future porcelain fields shouldn't break us.
446        let raw = "\
447worktree /repo/main
448HEAD aaa
449branch refs/heads/main
450some-future-field who-knows
451";
452        let out = parse_porcelain(raw);
453        assert_eq!(out.len(), 1);
454        assert!(out[0].is_main);
455    }
456
457    #[test]
458    fn parse_empty_input_returns_empty() {
459        assert!(parse_porcelain("").is_empty());
460        assert!(parse_porcelain("\n\n\n").is_empty());
461    }
462
463    // -- live test against a real git binary ------------------------
464
465    #[test]
466    fn live_lists_at_least_the_main_worktree() {
467        // This test runs against the repo it lives in -- always at
468        // least the main worktree exists.
469        let root = WorktreeRoot::for_repo(env!("CARGO_MANIFEST_DIR"));
470        let wts = root.list().expect("git worktree list should work");
471        assert!(!wts.is_empty(), "expected at least the main worktree");
472        assert!(wts[0].is_main);
473        assert!(!wts[0].path.as_os_str().is_empty());
474    }
475
476    #[test]
477    fn list_errors_on_non_git_path() {
478        let tmp = tempfile::tempdir().expect("tempdir");
479        let root = WorktreeRoot::for_repo(tmp.path());
480        let err = root.list().unwrap_err();
481        assert!(
482            err.to_string().to_lowercase().contains("worktree")
483                || err.to_string().to_lowercase().contains("git"),
484            "unexpected error: {err}"
485        );
486    }
487}