1use std::{
5 path::{Path, PathBuf},
6 time::SystemTime,
7};
8
9use tracing::instrument;
10use zeph_config::{WorktreeBaseRef, WorktreeConfig};
11
12use crate::{
13 error::WorktreeError,
14 git_runner::GitRunner,
15 handle::WorktreeHandle,
16 sanitize::{canonicalize_root, validate_branch_component},
17};
18
19pub struct WorktreeManager<R: GitRunner> {
38 repo_root: PathBuf,
40 config: WorktreeConfig,
42 runner: R,
44 handles: std::sync::Mutex<Vec<WorktreeHandle>>,
46}
47
48impl<R: GitRunner> WorktreeManager<R> {
49 pub fn new(
77 repo_root: PathBuf,
78 config: WorktreeConfig,
79 runner: R,
80 ) -> Result<Self, WorktreeError> {
81 let root = Path::new(&config.root);
83 let _ = canonicalize_root(root, &repo_root)?;
85
86 Ok(Self {
87 repo_root,
88 config,
89 runner,
90 handles: std::sync::Mutex::new(Vec::new()),
91 })
92 }
93
94 #[must_use]
96 pub fn repo_root(&self) -> &Path {
97 &self.repo_root
98 }
99
100 #[instrument(name = "worktree.create", skip(self), fields(subagent_id = %subagent_id))]
129 pub async fn create(&self, subagent_id: &str) -> Result<WorktreeHandle, WorktreeError> {
130 validate_branch_component(subagent_id)?;
131
132 let branch_name = format!("{}{}", self.config.branch_prefix, subagent_id);
133 let worktree_root = canonicalize_root(Path::new(&self.config.root), &self.repo_root)?;
134 let path = worktree_root.join(subagent_id);
135
136 if path.exists() {
137 return Err(WorktreeError::PathExists(path));
138 }
139
140 let (base_ref_resolved, commitish) = if let WorktreeBaseRef::Fresh = &self.config.base_ref {
142 let branch = self.resolve_default_branch().await?;
143 self.fetch_origin(&branch).await?;
144 self.verify_commitish(&format!("origin/{branch}")).await?;
145 let resolved = format!("origin/{branch}");
146 (resolved.clone(), resolved)
147 } else {
148 self.check_dirty_tree().await;
149 ("HEAD".to_string(), "HEAD".to_string())
150 };
151
152 let path_str = path.to_string_lossy();
153 self.git_worktree_add(&branch_name, &path_str, &commitish)
154 .await?;
155
156 let handle = WorktreeHandle {
157 path,
158 branch_name,
159 base_ref_resolved,
160 subagent_id: subagent_id.to_string(),
161 created_at: SystemTime::now(),
162 };
163
164 self.handles
165 .lock()
166 .unwrap_or_else(std::sync::PoisonError::into_inner)
167 .push(handle.clone());
168 Ok(handle)
169 }
170
171 #[instrument(name = "worktree.remove", skip(self), fields(branch = %handle.branch_name))]
189 pub async fn remove(
190 &self,
191 handle: &WorktreeHandle,
192 prune_branch: bool,
193 ) -> Result<(), WorktreeError> {
194 let path_str = handle.path.to_string_lossy().to_string();
195
196 let out = self
197 .runner
198 .run(
199 &["worktree", "remove", "--force", "--", &path_str],
200 &self.repo_root,
201 )
202 .await?;
203
204 if !out.status.success() {
205 let stderr = String::from_utf8_lossy(&out.stderr).to_string();
206 tracing::debug!(op = "worktree remove", %stderr, "git command failed");
207 return Err(WorktreeError::GitCommand {
208 op: "worktree remove".to_string(),
209 stderr,
210 });
211 }
212
213 if prune_branch {
214 let branch = &handle.branch_name;
215 let out = self
216 .runner
217 .run(&["branch", "-D", "--", branch], &self.repo_root)
218 .await?;
219
220 if !out.status.success() {
221 let stderr = String::from_utf8_lossy(&out.stderr).to_string();
222 tracing::debug!(op = "branch delete", %stderr, "git command failed");
223 return Err(WorktreeError::GitCommand {
224 op: "branch -D".to_string(),
225 stderr,
226 });
227 }
228 }
229
230 self.handles
232 .lock()
233 .unwrap_or_else(std::sync::PoisonError::into_inner)
234 .retain(|h| h.path != handle.path);
235
236 Ok(())
237 }
238
239 pub fn list(&self) -> Vec<WorktreeHandle> {
254 self.handles
255 .lock()
256 .unwrap_or_else(std::sync::PoisonError::into_inner)
257 .clone()
258 }
259
260 #[instrument(name = "worktree.reconcile", skip(self))]
282 pub async fn reconcile(&self) -> Result<Vec<WorktreeHandle>, WorktreeError> {
283 let out = self
284 .runner
285 .run(&["worktree", "list", "--porcelain"], &self.repo_root)
286 .await?;
287
288 if !out.status.success() {
289 let stderr = String::from_utf8_lossy(&out.stderr).to_string();
290 tracing::debug!(op = "worktree list", %stderr, "git command failed");
291 return Err(WorktreeError::GitCommand {
292 op: "worktree list".to_string(),
293 stderr,
294 });
295 }
296
297 let output_str = String::from_utf8_lossy(&out.stdout);
298 let git_worktrees = parse_worktree_list_porcelain(&output_str);
299
300 let session_paths: std::collections::HashSet<PathBuf> = self
301 .handles
302 .lock()
303 .unwrap_or_else(std::sync::PoisonError::into_inner)
304 .iter()
305 .map(|h| h.path.clone())
306 .collect();
307
308 let stale = git_worktrees
309 .into_iter()
310 .filter(|h| !session_paths.contains(&h.path))
311 .filter(|h| h.path != self.repo_root)
313 .collect();
314
315 Ok(stale)
316 }
317}
318
319fn parse_worktree_list_porcelain(output: &str) -> Vec<WorktreeHandle> {
329 let mut result = Vec::new();
330 let mut path: Option<PathBuf> = None;
331 let mut branch: Option<String> = None;
332
333 for line in output.lines() {
334 if let Some(p) = line.strip_prefix("worktree ") {
335 if let (Some(wt_path), Some(br)) = (path.take(), branch.take()) {
337 result.push(WorktreeHandle {
338 path: wt_path,
339 branch_name: br,
340 base_ref_resolved: String::new(),
341 subagent_id: String::new(),
342 created_at: SystemTime::UNIX_EPOCH,
343 });
344 }
345 path = Some(PathBuf::from(p));
346 } else if let Some(b) = line.strip_prefix("branch refs/heads/") {
347 branch = Some(b.to_string());
348 }
349 }
350
351 if let (Some(wt_path), Some(br)) = (path, branch) {
353 result.push(WorktreeHandle {
354 path: wt_path,
355 branch_name: br,
356 base_ref_resolved: String::new(),
357 subagent_id: String::new(),
358 created_at: SystemTime::UNIX_EPOCH,
359 });
360 }
361
362 result
363}
364
365impl<R: GitRunner> WorktreeManager<R> {
368 #[instrument(name = "worktree.dirty_check", skip(self))]
370 async fn check_dirty_tree(&self) {
371 match self
372 .runner
373 .run(&["status", "--porcelain"], &self.repo_root)
374 .await
375 {
376 Ok(out) if !out.stdout.is_empty() => {
377 tracing::warn!(
378 "creating a head worktree on a dirty working tree; \
379 uncommitted changes will NOT be visible in the worktree"
380 );
381 }
382 _ => {}
383 }
384 }
385
386 #[instrument(name = "worktree.resolve_branch", skip(self))]
388 async fn resolve_default_branch(&self) -> Result<String, WorktreeError> {
389 if !self.config.default_branch.is_empty() {
390 return Ok(self.config.default_branch.clone());
391 }
392
393 let out = self
394 .runner
395 .run(
396 &["symbolic-ref", "refs/remotes/origin/HEAD"],
397 &self.repo_root,
398 )
399 .await?;
400
401 if out.status.success() {
402 let raw = String::from_utf8_lossy(&out.stdout);
403 let trimmed = raw.trim();
404 if let Some(branch) = trimmed.strip_prefix("refs/remotes/origin/") {
405 return Ok(branch.to_string());
406 }
407 }
408
409 Err(WorktreeError::BaseRefUnresolved {
410 attempted: "symbolic-ref refs/remotes/origin/HEAD".to_string(),
411 })
412 }
413
414 #[instrument(name = "worktree.fetch", skip(self), fields(branch = %branch))]
416 async fn fetch_origin(&self, branch: &str) -> Result<(), WorktreeError> {
417 let out = self
418 .runner
419 .run(&["fetch", "origin", "--", branch], &self.repo_root)
420 .await?;
421
422 if !out.status.success() {
423 let stderr = String::from_utf8_lossy(&out.stderr).to_string();
424 tracing::debug!(op = "fetch", %stderr, "git fetch failed");
425 return Err(WorktreeError::GitCommand {
426 op: "fetch".to_string(),
427 stderr,
428 });
429 }
430
431 Ok(())
432 }
433
434 async fn verify_commitish(&self, commitish: &str) -> Result<(), WorktreeError> {
436 let out = self
437 .runner
438 .run(&["rev-parse", "--verify", "--", commitish], &self.repo_root)
439 .await?;
440
441 if !out.status.success() {
442 let stderr = String::from_utf8_lossy(&out.stderr).to_string();
443 return Err(WorktreeError::GitCommand {
444 op: format!("rev-parse --verify {commitish}"),
445 stderr,
446 });
447 }
448
449 Ok(())
450 }
451
452 async fn git_worktree_add(
454 &self,
455 branch: &str,
456 path: &str,
457 commitish: &str,
458 ) -> Result<(), WorktreeError> {
459 let out = self
460 .runner
461 .run(
462 &["worktree", "add", "-b", branch, "--", path, commitish],
463 &self.repo_root,
464 )
465 .await?;
466
467 if !out.status.success() {
468 let stderr = String::from_utf8_lossy(&out.stderr).to_string();
469 tracing::debug!(op = "worktree add", %stderr, "git worktree add failed");
470 return Err(WorktreeError::GitCommand {
471 op: "worktree add".to_string(),
472 stderr,
473 });
474 }
475
476 Ok(())
477 }
478}
479
480pub async fn probe_capabilities<R: GitRunner>(
504 runner: &R,
505 repo_root: &Path,
506) -> Result<(), WorktreeError> {
507 let out = runner.run(&["--version"], repo_root).await?;
509 if !out.status.success() {
510 return Err(WorktreeError::GitCommand {
511 op: "--version".to_string(),
512 stderr: String::from_utf8_lossy(&out.stderr).to_string(),
513 });
514 }
515
516 let version_output = String::from_utf8_lossy(&out.stdout);
517 if let Some(version) = parse_git_version(&version_output)
518 && version < (2, 5)
519 {
520 return Err(WorktreeError::GitCommand {
521 op: "--version".to_string(),
522 stderr: format!(
523 "git \u{2265} 2.5 is required for worktree support (found: {}.{}). \
524 Upgrade git or set `worktree.enabled = false`.",
525 version.0, version.1
526 ),
527 });
528 }
529
530 let out = runner
532 .run(&["rev-parse", "--is-inside-work-tree"], repo_root)
533 .await?;
534
535 if !out.status.success() {
536 return Err(WorktreeError::NotAGitRepo);
537 }
538
539 Ok(())
540}
541
542fn parse_git_version(output: &str) -> Option<(u32, u32)> {
544 let version_str = output.trim().strip_prefix("git version ")?;
545 let mut parts = version_str.split('.');
546 let major: u32 = parts.next()?.parse().ok()?;
547 let minor: u32 = parts.next()?.parse().ok()?;
548 Some((major, minor))
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554 use crate::git_runner::FakeGitRunner;
555 use std::sync::Arc;
556 use zeph_config::WorktreeConfig;
557
558 fn test_config() -> WorktreeConfig {
559 WorktreeConfig {
560 enabled: true,
561 root: "worktrees".to_string(),
562 branch_prefix: "agent/".to_string(),
563 ..WorktreeConfig::default()
564 }
565 }
566
567 fn make_repo() -> tempfile::TempDir {
568 let dir = tempfile::tempdir().unwrap();
569 std::fs::create_dir_all(dir.path().join(".git")).unwrap();
571 dir
572 }
573
574 fn make_manager(
575 dir: &tempfile::TempDir,
576 runner: FakeGitRunner,
577 ) -> WorktreeManager<FakeGitRunner> {
578 WorktreeManager::new(dir.path().to_path_buf(), test_config(), runner).unwrap()
579 }
580
581 #[tokio::test]
584 async fn probe_succeeds_on_valid_git() {
585 let dir = make_repo();
586 let runner = FakeGitRunner::new();
587 runner.push_ok(b"git version 2.43.0\n" as &[u8]);
589 runner.push_ok(b"true\n" as &[u8]);
591 probe_capabilities(&runner, dir.path()).await.unwrap();
592
593 let calls = runner.calls.lock().unwrap();
594 assert!(calls[0].0.contains(&"--version".to_string()));
596 assert!(calls[1].0.contains(&"--is-inside-work-tree".to_string()));
597 }
598
599 #[tokio::test]
600 async fn probe_rejects_old_git() {
601 let dir = make_repo();
602 let runner = FakeGitRunner::new();
603 runner.push_ok(b"git version 2.4.0\n" as &[u8]);
604 let err = probe_capabilities(&runner, dir.path()).await.unwrap_err();
605 assert!(matches!(err, WorktreeError::GitCommand { .. }));
606 }
607
608 #[tokio::test]
609 async fn probe_rejects_non_repo() {
610 let dir = make_repo();
611 let runner = FakeGitRunner::new();
612 runner.push_ok(b"git version 2.44.0\n" as &[u8]);
613 runner.push_err(b"not a git repo\n" as &[u8]);
614 let err = probe_capabilities(&runner, dir.path()).await.unwrap_err();
615 assert!(matches!(err, WorktreeError::NotAGitRepo));
616 }
617
618 #[tokio::test]
621 async fn create_head_mode_passes_double_dash() {
622 let dir = make_repo();
623 let runner = FakeGitRunner::new();
624 runner.push_ok(b"" as &[u8]);
626 runner.push_ok(b"" as &[u8]);
628
629 let mgr = make_manager(&dir, runner);
630
631 let result = mgr.create("agent-42").await;
643 match result {
647 Ok(_) | Err(WorktreeError::GitCommand { .. }) => {}
648 Err(e) => panic!("unexpected error: {e}"),
649 }
650 }
651
652 #[tokio::test]
653 async fn create_rejects_invalid_branch_component() {
654 let dir = make_repo();
655 let runner = FakeGitRunner::new();
656 let mgr = make_manager(&dir, runner);
657 let err = mgr.create("../escape").await.unwrap_err();
658 assert!(matches!(err, WorktreeError::InvalidBranchName(_)));
659 }
660
661 #[tokio::test]
662 async fn create_rejects_leading_dash() {
663 let dir = make_repo();
664 let runner = FakeGitRunner::new();
665 let mgr = make_manager(&dir, runner);
666 let err = mgr.create("-bad-id").await.unwrap_err();
667 assert!(matches!(err, WorktreeError::InvalidBranchName(_)));
668 }
669
670 #[tokio::test]
673 async fn create_fresh_resolves_default_branch_from_config() {
674 let dir = make_repo();
675 let runner = FakeGitRunner::new();
676 runner.push_ok(b"" as &[u8]);
678 runner.push_ok(b"deadbeef\n" as &[u8]);
680 runner.push_ok(b"" as &[u8]);
682
683 let config = WorktreeConfig {
684 enabled: true,
685 base_ref: zeph_config::WorktreeBaseRef::Fresh,
686 default_branch: "main".to_string(),
687 root: "worktrees".to_string(),
688 branch_prefix: "agent/".to_string(),
689 ..WorktreeConfig::default()
690 };
691 let mgr = WorktreeManager::new(dir.path().to_path_buf(), config, runner).unwrap();
692
693 let result = mgr.create("agent-fresh").await;
694 match result {
695 Ok(_) | Err(WorktreeError::GitCommand { .. }) => {}
696 Err(e) => panic!("unexpected error: {e}"),
697 }
698 }
699
700 #[tokio::test]
701 async fn create_fresh_fails_when_fetch_fails() {
702 let dir = make_repo();
703 let runner = FakeGitRunner::new();
704 runner.push_err(b"network error\n" as &[u8]);
706
707 let config = WorktreeConfig {
708 enabled: true,
709 base_ref: zeph_config::WorktreeBaseRef::Fresh,
710 default_branch: "main".to_string(),
711 root: "worktrees".to_string(),
712 branch_prefix: "agent/".to_string(),
713 ..WorktreeConfig::default()
714 };
715 let mgr = WorktreeManager::new(dir.path().to_path_buf(), config, runner).unwrap();
716 let err = mgr.create("agent-fresh").await.unwrap_err();
717 assert!(matches!(err, WorktreeError::GitCommand { .. }));
718 }
719
720 #[tokio::test]
721 async fn create_fresh_fails_when_symbolic_ref_unset() {
722 let dir = make_repo();
723 let runner = FakeGitRunner::new();
724 runner.push_err(b"symbolic-ref: not a ref\n" as &[u8]);
726
727 let config = WorktreeConfig {
728 enabled: true,
729 base_ref: zeph_config::WorktreeBaseRef::Fresh,
730 default_branch: String::new(), root: "worktrees".to_string(),
732 branch_prefix: "agent/".to_string(),
733 ..WorktreeConfig::default()
734 };
735 let mgr = WorktreeManager::new(dir.path().to_path_buf(), config, runner).unwrap();
736 let err = mgr.create("agent-fresh").await.unwrap_err();
737 assert!(matches!(err, WorktreeError::BaseRefUnresolved { .. }));
738 }
739
740 #[tokio::test]
743 async fn remove_without_branch_prune() {
744 let dir = make_repo();
745 let runner = FakeGitRunner::new();
746 runner.push_ok(b"" as &[u8]);
748
749 let mgr = make_manager(&dir, runner);
750 let handle = WorktreeHandle {
751 path: dir.path().join("worktrees/agent-99"),
752 branch_name: "agent/agent-99".to_string(),
753 base_ref_resolved: "HEAD".to_string(),
754 subagent_id: "agent-99".to_string(),
755 created_at: SystemTime::now(),
756 };
757
758 mgr.remove(&handle, false).await.unwrap();
759 }
760
761 #[tokio::test]
762 async fn remove_with_branch_prune_issues_two_git_calls() {
763 let dir = make_repo();
764 let runner = FakeGitRunner::new();
765 runner.push_ok(b"" as &[u8]);
767 runner.push_ok(b"" as &[u8]);
769
770 let mgr = make_manager(&dir, runner);
771 let handle = WorktreeHandle {
772 path: dir.path().join("worktrees/agent-99"),
773 branch_name: "agent/agent-99".to_string(),
774 base_ref_resolved: "HEAD".to_string(),
775 subagent_id: "agent-99".to_string(),
776 created_at: SystemTime::now(),
777 };
778
779 mgr.remove(&handle, true).await.unwrap();
780 }
781
782 #[tokio::test]
785 async fn reconcile_parses_porcelain_output() {
786 let dir = make_repo();
787 let runner = FakeGitRunner::new();
788 let porcelain = format!(
789 "worktree {0}\nHEAD abc123\nbranch refs/heads/main\n\nworktree {0}/worktrees/agent-1\nHEAD def456\nbranch refs/heads/agent/agent-1\n\n",
790 dir.path().display()
791 );
792 runner.push_ok(porcelain.into_bytes());
793
794 let mgr = make_manager(&dir, runner);
795 let stale = mgr.reconcile().await.unwrap();
796 assert_eq!(stale.len(), 1);
798 assert_eq!(stale[0].branch_name, "agent/agent-1");
799 }
800
801 #[test]
804 fn parse_version_standard() {
805 assert_eq!(parse_git_version("git version 2.43.0"), Some((2, 43)));
806 }
807
808 #[test]
809 fn parse_version_old() {
810 assert_eq!(parse_git_version("git version 2.4.1"), Some((2, 4)));
811 }
812
813 #[test]
814 fn parse_version_invalid() {
815 assert_eq!(parse_git_version("not git output"), None);
816 }
817
818 #[tokio::test]
821 async fn remove_uses_double_dash_separator() {
822 let dir = make_repo();
823 let runner = Arc::new(FakeGitRunner::new());
824 runner.push_ok(b"" as &[u8]);
825
826 let mgr =
828 WorktreeManager::new(dir.path().to_path_buf(), test_config(), Arc::clone(&runner))
829 .unwrap();
830
831 let handle = WorktreeHandle {
832 path: dir.path().join("worktrees/x"),
833 branch_name: "agent/x".to_string(),
834 base_ref_resolved: "HEAD".to_string(),
835 subagent_id: "x".to_string(),
836 created_at: SystemTime::now(),
837 };
838
839 let _ = mgr.remove(&handle, false).await;
840 let calls = runner.calls.lock().unwrap();
841 let has_sep = calls[0].0.iter().any(|a| a == "--");
843 assert!(
844 has_sep,
845 "expected '--' separator in git args: {:?}",
846 calls[0].0
847 );
848 }
849
850 #[tokio::test]
856 async fn create_head_mode_proceeds_on_dirty_tree() {
857 let dir = make_repo();
858 let runner = FakeGitRunner::new();
859 runner.push_ok(b" M some-file.txt\n" as &[u8]);
861 runner.push_err(b"fake error\n" as &[u8]);
864
865 let mgr = make_manager(&dir, runner);
866 let result = mgr.create("dirty-agent").await;
867 assert!(
870 matches!(result, Err(WorktreeError::GitCommand { .. })),
871 "expected GitCommand error from fake runner, not an early abort: {result:?}"
872 );
873 }
874}