claude_wrapper/
worktrees.rs1use std::path::{Path, PathBuf};
44use std::process::Command;
45
46use serde::Serialize;
47
48use crate::error::{Error, Result};
49
50#[derive(Debug, Clone)]
55pub struct WorktreeRoot {
56 repo_path: PathBuf,
57}
58
59impl WorktreeRoot {
60 pub fn for_repo(path: impl Into<PathBuf>) -> Self {
66 Self {
67 repo_path: path.into(),
68 }
69 }
70
71 pub fn path(&self) -> &Path {
73 &self.repo_path
74 }
75
76 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#[derive(Debug, Clone, Serialize)]
112pub struct Worktree {
113 pub path: PathBuf,
115 pub head: Option<String>,
117 pub branch: Option<String>,
120 pub is_main: bool,
124 pub is_detached: bool,
126 pub is_bare: bool,
128 pub is_locked: bool,
130 pub lock_reason: Option<String>,
132 pub is_prunable: bool,
135 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 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 }
215 }
216 }
217
218 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, 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 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 #[test]
466 fn live_lists_at_least_the_main_worktree() {
467 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}