1use std::{
5 path::{Path, PathBuf},
6 process::Output,
7 time::SystemTime,
8};
9
10use tracing::instrument;
11use zeph_config::{WorktreeBaseRef, WorktreeConfig};
12
13use crate::{
14 error::WorktreeError,
15 git_runner::GitRunner,
16 handle::WorktreeHandle,
17 sanitize::{canonicalize_root, validate_branch_component},
18};
19
20pub struct WorktreeManager<R: GitRunner> {
39 repo_root: PathBuf,
41 config: WorktreeConfig,
43 runner: R,
45 handles: std::sync::Mutex<Vec<WorktreeHandle>>,
47}
48
49impl<R: GitRunner> WorktreeManager<R> {
50 pub async fn new(
81 repo_root: PathBuf,
82 config: WorktreeConfig,
83 runner: R,
84 ) -> Result<Self, WorktreeError> {
85 let root = PathBuf::from(&config.root);
88 let repo = repo_root.clone();
89 tokio::task::spawn_blocking(move || canonicalize_root(&root, &repo))
90 .await
91 .map_err(|e| WorktreeError::Io(std::io::Error::other(e)))??;
92
93 Ok(Self {
94 repo_root,
95 config,
96 runner,
97 handles: std::sync::Mutex::new(Vec::new()),
98 })
99 }
100
101 #[must_use]
103 pub fn repo_root(&self) -> &Path {
104 &self.repo_root
105 }
106
107 #[instrument(name = "worktree.create", skip(self), fields(subagent_id = %subagent_id))]
136 pub async fn create(&self, subagent_id: &str) -> Result<WorktreeHandle, WorktreeError> {
137 validate_branch_component(subagent_id)?;
138
139 let branch_name = format!("{}{}", self.config.branch_prefix, subagent_id);
140 let root = PathBuf::from(&self.config.root);
141 let repo = self.repo_root.clone();
142 let worktree_root = tokio::task::spawn_blocking(move || canonicalize_root(&root, &repo))
143 .await
144 .map_err(|e| WorktreeError::Io(std::io::Error::other(e)))??;
145 let path = worktree_root.join(subagent_id);
146
147 if path.exists() {
148 return Err(WorktreeError::PathExists(path));
149 }
150
151 let (base_ref_resolved, commitish) = if let WorktreeBaseRef::Fresh = &self.config.base_ref {
153 let branch = self.resolve_default_branch().await?;
154 self.fetch_origin(&branch).await?;
155 self.verify_commitish(&format!("origin/{branch}")).await?;
156 let resolved = format!("origin/{branch}");
157 (resolved.clone(), resolved)
158 } else {
159 self.check_dirty_tree().await;
160 ("HEAD".to_string(), "HEAD".to_string())
161 };
162
163 let path_str = path.to_string_lossy();
164 self.git_worktree_add(&branch_name, &path_str, &commitish)
165 .await?;
166
167 let handle = WorktreeHandle {
168 path,
169 branch_name,
170 base_ref_resolved,
171 subagent_id: subagent_id.to_string(),
172 created_at: SystemTime::now(),
173 };
174
175 self.handles
176 .lock()
177 .unwrap_or_else(std::sync::PoisonError::into_inner)
178 .push(handle.clone());
179 Ok(handle)
180 }
181
182 #[instrument(name = "worktree.remove", skip(self), fields(branch = %handle.branch_name))]
209 pub async fn remove(
210 &self,
211 handle: &WorktreeHandle,
212 prune_branch: bool,
213 ) -> Result<(), WorktreeError> {
214 let path_str = handle.path.to_string_lossy().to_string();
215
216 let out = self
217 .runner
218 .run(
219 &["worktree", "remove", "--force", "--", &path_str],
220 &self.repo_root,
221 )
222 .await?;
223 check_git_status(&out, "worktree remove")?;
224
225 self.handles
229 .lock()
230 .unwrap_or_else(std::sync::PoisonError::into_inner)
231 .retain(|h| h.path != handle.path);
232
233 if prune_branch {
234 let branch = &handle.branch_name;
235 let out = self
236 .runner
237 .run(&["branch", "-D", "--", branch], &self.repo_root)
238 .await?;
239 check_git_status(&out, "branch -D")?;
240 }
241
242 Ok(())
243 }
244
245 pub fn list(&self) -> Vec<WorktreeHandle> {
260 self.handles
261 .lock()
262 .unwrap_or_else(std::sync::PoisonError::into_inner)
263 .clone()
264 }
265
266 #[instrument(name = "worktree.reconcile", skip(self))]
288 pub async fn reconcile(&self) -> Result<Vec<WorktreeHandle>, WorktreeError> {
289 let out = self
290 .runner
291 .run(&["worktree", "list", "--porcelain"], &self.repo_root)
292 .await?;
293 check_git_status(&out, "worktree list")?;
294
295 let output_str = String::from_utf8_lossy(&out.stdout);
296 let git_worktrees = parse_worktree_list_porcelain(&output_str);
297
298 let session_paths: std::collections::HashSet<PathBuf> = self
299 .handles
300 .lock()
301 .unwrap_or_else(std::sync::PoisonError::into_inner)
302 .iter()
303 .map(|h| h.path.clone())
304 .collect();
305
306 let stale = git_worktrees
307 .into_iter()
308 .filter(|h| !session_paths.contains(&h.path))
309 .filter(|h| h.path != self.repo_root)
311 .collect();
312
313 Ok(stale)
314 }
315}
316
317fn parse_worktree_list_porcelain(output: &str) -> Vec<WorktreeHandle> {
327 let mut result = Vec::new();
328 let mut path: Option<PathBuf> = None;
329 let mut branch: Option<String> = None;
330
331 for line in output.lines() {
332 if let Some(p) = line.strip_prefix("worktree ") {
333 if let (Some(wt_path), Some(br)) = (path.take(), branch.take()) {
335 result.push(WorktreeHandle {
336 path: wt_path,
337 branch_name: br,
338 base_ref_resolved: String::new(),
339 subagent_id: String::new(),
340 created_at: SystemTime::UNIX_EPOCH,
341 });
342 }
343 path = Some(PathBuf::from(p));
344 } else if let Some(b) = line.strip_prefix("branch refs/heads/") {
345 branch = Some(b.to_string());
346 }
347 }
348
349 if let (Some(wt_path), Some(br)) = (path, branch) {
351 result.push(WorktreeHandle {
352 path: wt_path,
353 branch_name: br,
354 base_ref_resolved: String::new(),
355 subagent_id: String::new(),
356 created_at: SystemTime::UNIX_EPOCH,
357 });
358 }
359
360 result
361}
362
363impl<R: GitRunner> WorktreeManager<R> {
366 #[instrument(name = "worktree.dirty_check", skip(self))]
368 async fn check_dirty_tree(&self) {
369 match self
370 .runner
371 .run(&["status", "--porcelain"], &self.repo_root)
372 .await
373 {
374 Ok(out) if !out.stdout.is_empty() => {
375 tracing::warn!(
376 "creating a head worktree on a dirty working tree; \
377 uncommitted changes will NOT be visible in the worktree"
378 );
379 }
380 _ => {}
381 }
382 }
383
384 #[instrument(name = "worktree.resolve_branch", skip(self))]
386 async fn resolve_default_branch(&self) -> Result<String, WorktreeError> {
387 if !self.config.default_branch.is_empty() {
388 return Ok(self.config.default_branch.clone());
389 }
390
391 let out = self
392 .runner
393 .run(
394 &["symbolic-ref", "refs/remotes/origin/HEAD"],
395 &self.repo_root,
396 )
397 .await?;
398
399 if out.status.success() {
400 let raw = String::from_utf8_lossy(&out.stdout);
401 let trimmed = raw.trim();
402 if let Some(branch) = trimmed.strip_prefix("refs/remotes/origin/") {
403 return Ok(branch.to_string());
404 }
405 }
406
407 Err(WorktreeError::BaseRefUnresolved {
408 attempted: "symbolic-ref refs/remotes/origin/HEAD".to_string(),
409 })
410 }
411
412 #[instrument(name = "worktree.fetch", skip(self), fields(branch = %branch))]
414 async fn fetch_origin(&self, branch: &str) -> Result<(), WorktreeError> {
415 let out = self
416 .runner
417 .run(&["fetch", "origin", "--", branch], &self.repo_root)
418 .await?;
419 check_git_status(&out, "fetch")?;
420
421 Ok(())
422 }
423
424 #[instrument(name = "worktree.verify_commitish", skip(self), err)]
426 async fn verify_commitish(&self, commitish: &str) -> Result<(), WorktreeError> {
427 let out = self
428 .runner
429 .run(&["rev-parse", "--verify", "--", commitish], &self.repo_root)
430 .await?;
431 check_git_status(&out, &format!("rev-parse --verify {commitish}"))?;
432
433 Ok(())
434 }
435
436 #[instrument(name = "worktree.git_worktree_add", skip(self), err)]
438 async fn git_worktree_add(
439 &self,
440 branch: &str,
441 path: &str,
442 commitish: &str,
443 ) -> Result<(), WorktreeError> {
444 let out = self
445 .runner
446 .run(
447 &["worktree", "add", "-b", branch, "--", path, commitish],
448 &self.repo_root,
449 )
450 .await?;
451 check_git_status(&out, "worktree add")?;
452
453 Ok(())
454 }
455}
456
457fn check_git_status(out: &Output, op: &str) -> Result<(), WorktreeError> {
463 if !out.status.success() {
464 let stderr = String::from_utf8_lossy(&out.stderr).to_string();
465 tracing::debug!(op, %stderr, "git command failed");
466 return Err(WorktreeError::GitCommand {
467 op: op.to_string(),
468 stderr,
469 });
470 }
471 Ok(())
472}
473
474#[instrument(name = "worktree.probe_capabilities", skip(runner), err)]
498pub async fn probe_capabilities<R: GitRunner>(
499 runner: &R,
500 repo_root: &Path,
501) -> Result<(), WorktreeError> {
502 let out = runner.run(&["--version"], repo_root).await?;
504 if !out.status.success() {
505 return Err(WorktreeError::GitCommand {
506 op: "--version".to_string(),
507 stderr: String::from_utf8_lossy(&out.stderr).to_string(),
508 });
509 }
510
511 let version_output = String::from_utf8_lossy(&out.stdout);
512 if let Some(version) = parse_git_version(&version_output)
513 && version < (2, 5)
514 {
515 return Err(WorktreeError::GitCommand {
516 op: "--version".to_string(),
517 stderr: format!(
518 "git \u{2265} 2.5 is required for worktree support (found: {}.{}). \
519 Upgrade git or set `worktree.enabled = false`.",
520 version.0, version.1
521 ),
522 });
523 }
524
525 let out = runner
527 .run(&["rev-parse", "--is-inside-work-tree"], repo_root)
528 .await?;
529
530 if !out.status.success() {
531 return Err(WorktreeError::NotAGitRepo);
532 }
533
534 Ok(())
535}
536
537fn parse_git_version(output: &str) -> Option<(u32, u32)> {
539 let version_str = output.trim().strip_prefix("git version ")?;
540 let mut parts = version_str.split('.');
541 let major: u32 = parts.next()?.parse().ok()?;
542 let minor: u32 = parts.next()?.parse().ok()?;
543 Some((major, minor))
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549 use crate::git_runner::FakeGitRunner;
550 use std::assert_matches;
551 use std::sync::Arc;
552 use zeph_config::WorktreeConfig;
553
554 fn test_config() -> WorktreeConfig {
555 WorktreeConfig {
556 enabled: true,
557 root: "worktrees".to_string(),
558 branch_prefix: "agent/".to_string(),
559 ..WorktreeConfig::default()
560 }
561 }
562
563 fn make_repo() -> tempfile::TempDir {
564 let dir = tempfile::tempdir().unwrap();
565 std::fs::create_dir_all(dir.path().join(".git")).unwrap();
567 dir
568 }
569
570 async fn make_manager(
571 dir: &tempfile::TempDir,
572 runner: FakeGitRunner,
573 ) -> WorktreeManager<FakeGitRunner> {
574 WorktreeManager::new(dir.path().to_path_buf(), test_config(), runner)
575 .await
576 .unwrap()
577 }
578
579 #[tokio::test]
582 async fn probe_succeeds_on_valid_git() {
583 let dir = make_repo();
584 let runner = FakeGitRunner::new();
585 runner.push_ok(b"git version 2.43.0\n" as &[u8]);
587 runner.push_ok(b"true\n" as &[u8]);
589 probe_capabilities(&runner, dir.path()).await.unwrap();
590
591 let calls = runner.calls.lock().unwrap();
592 assert!(calls[0].0.contains(&"--version".to_string()));
594 assert!(calls[1].0.contains(&"--is-inside-work-tree".to_string()));
595 }
596
597 #[tokio::test]
598 async fn probe_rejects_old_git() {
599 let dir = make_repo();
600 let runner = FakeGitRunner::new();
601 runner.push_ok(b"git version 2.4.0\n" as &[u8]);
602 let err = probe_capabilities(&runner, dir.path()).await.unwrap_err();
603 assert_matches!(err, WorktreeError::GitCommand { .. });
604 }
605
606 #[tokio::test]
607 async fn probe_rejects_non_repo() {
608 let dir = make_repo();
609 let runner = FakeGitRunner::new();
610 runner.push_ok(b"git version 2.44.0\n" as &[u8]);
611 runner.push_err(b"not a git repo\n" as &[u8]);
612 let err = probe_capabilities(&runner, dir.path()).await.unwrap_err();
613 assert_matches!(err, WorktreeError::NotAGitRepo);
614 }
615
616 #[tokio::test]
619 async fn create_head_mode_passes_double_dash() {
620 let dir = make_repo();
621 let runner = FakeGitRunner::new();
622 runner.push_ok(b"" as &[u8]);
624 runner.push_ok(b"" as &[u8]);
626
627 let mgr = make_manager(&dir, runner).await;
628
629 let result = mgr.create("agent-42").await;
641 match result {
645 Ok(_) | Err(WorktreeError::GitCommand { .. }) => {}
646 Err(e) => panic!("unexpected error: {e}"),
647 }
648 }
649
650 #[tokio::test]
651 async fn create_rejects_invalid_branch_component() {
652 let dir = make_repo();
653 let runner = FakeGitRunner::new();
654 let mgr = make_manager(&dir, runner).await;
655 let err = mgr.create("../escape").await.unwrap_err();
656 assert_matches!(err, WorktreeError::InvalidBranchName(_));
657 }
658
659 #[tokio::test]
660 async fn create_rejects_leading_dash() {
661 let dir = make_repo();
662 let runner = FakeGitRunner::new();
663 let mgr = make_manager(&dir, runner).await;
664 let err = mgr.create("-bad-id").await.unwrap_err();
665 assert_matches!(err, WorktreeError::InvalidBranchName(_));
666 }
667
668 #[tokio::test]
671 async fn create_fresh_resolves_default_branch_from_config() {
672 let dir = make_repo();
673 let runner = FakeGitRunner::new();
674 runner.push_ok(b"" as &[u8]);
676 runner.push_ok(b"deadbeef\n" as &[u8]);
678 runner.push_ok(b"" as &[u8]);
680
681 let config = WorktreeConfig {
682 enabled: true,
683 base_ref: zeph_config::WorktreeBaseRef::Fresh,
684 default_branch: "main".to_string(),
685 root: "worktrees".to_string(),
686 branch_prefix: "agent/".to_string(),
687 ..WorktreeConfig::default()
688 };
689 let mgr = WorktreeManager::new(dir.path().to_path_buf(), config, runner)
690 .await
691 .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)
716 .await
717 .unwrap();
718 let err = mgr.create("agent-fresh").await.unwrap_err();
719 assert_matches!(err, WorktreeError::GitCommand { .. });
720 }
721
722 #[tokio::test]
723 async fn create_fresh_fails_when_symbolic_ref_unset() {
724 let dir = make_repo();
725 let runner = FakeGitRunner::new();
726 runner.push_err(b"symbolic-ref: not a ref\n" as &[u8]);
728
729 let config = WorktreeConfig {
730 enabled: true,
731 base_ref: zeph_config::WorktreeBaseRef::Fresh,
732 default_branch: String::new(), root: "worktrees".to_string(),
734 branch_prefix: "agent/".to_string(),
735 ..WorktreeConfig::default()
736 };
737 let mgr = WorktreeManager::new(dir.path().to_path_buf(), config, runner)
738 .await
739 .unwrap();
740 let err = mgr.create("agent-fresh").await.unwrap_err();
741 assert_matches!(err, WorktreeError::BaseRefUnresolved { .. });
742 }
743
744 #[tokio::test]
747 async fn remove_without_branch_prune() {
748 let dir = make_repo();
749 let runner = FakeGitRunner::new();
750 runner.push_ok(b"" as &[u8]);
752
753 let mgr = make_manager(&dir, runner).await;
754 let handle = WorktreeHandle {
755 path: dir.path().join("worktrees/agent-99"),
756 branch_name: "agent/agent-99".to_string(),
757 base_ref_resolved: "HEAD".to_string(),
758 subagent_id: "agent-99".to_string(),
759 created_at: SystemTime::now(),
760 };
761
762 mgr.remove(&handle, false).await.unwrap();
763 }
764
765 #[tokio::test]
766 async fn remove_with_branch_prune_issues_two_git_calls() {
767 let dir = make_repo();
768 let runner = FakeGitRunner::new();
769 runner.push_ok(b"" as &[u8]);
771 runner.push_ok(b"" as &[u8]);
773
774 let mgr = make_manager(&dir, runner).await;
775 let handle = WorktreeHandle {
776 path: dir.path().join("worktrees/agent-99"),
777 branch_name: "agent/agent-99".to_string(),
778 base_ref_resolved: "HEAD".to_string(),
779 subagent_id: "agent-99".to_string(),
780 created_at: SystemTime::now(),
781 };
782
783 mgr.remove(&handle, true).await.unwrap();
784 }
785
786 #[tokio::test]
791 async fn remove_drops_handle_even_when_branch_prune_fails() {
792 let dir = make_repo();
793 let runner = FakeGitRunner::new();
794 runner.push_ok(b"" as &[u8]);
796 runner.push_err(b"error: branch 'agent/agent-99' not fully merged\n" as &[u8]);
798
799 let mgr = make_manager(&dir, runner).await;
800 let handle = WorktreeHandle {
801 path: dir.path().join("worktrees/agent-99"),
802 branch_name: "agent/agent-99".to_string(),
803 base_ref_resolved: "HEAD".to_string(),
804 subagent_id: "agent-99".to_string(),
805 created_at: SystemTime::now(),
806 };
807
808 mgr.handles.lock().unwrap().push(handle.clone());
812 assert_eq!(mgr.list().len(), 1, "precondition: handle is tracked");
813
814 let err = mgr.remove(&handle, true).await.unwrap_err();
815 assert_matches!(
816 err,
817 WorktreeError::GitCommand { ref op, .. } if op == "branch -D"
818 );
819
820 assert!(
823 mgr.list().is_empty(),
824 "handle must be dropped once `worktree remove` succeeded, \
825 independent of the branch -D outcome"
826 );
827 }
828
829 #[tokio::test]
832 async fn reconcile_parses_porcelain_output() {
833 let dir = make_repo();
834 let runner = FakeGitRunner::new();
835 let porcelain = format!(
836 "worktree {0}\nHEAD abc123\nbranch refs/heads/main\n\nworktree {0}/worktrees/agent-1\nHEAD def456\nbranch refs/heads/agent/agent-1\n\n",
837 dir.path().display()
838 );
839 runner.push_ok(porcelain.into_bytes());
840
841 let mgr = make_manager(&dir, runner).await;
842 let stale = mgr.reconcile().await.unwrap();
843 assert_eq!(stale.len(), 1);
845 assert_eq!(stale[0].branch_name, "agent/agent-1");
846 }
847
848 #[test]
851 fn parse_version_standard() {
852 assert_eq!(parse_git_version("git version 2.43.0"), Some((2, 43)));
853 }
854
855 #[test]
856 fn parse_version_old() {
857 assert_eq!(parse_git_version("git version 2.4.1"), Some((2, 4)));
858 }
859
860 #[test]
861 fn parse_version_invalid() {
862 assert_eq!(parse_git_version("not git output"), None);
863 }
864
865 #[tokio::test]
868 async fn remove_uses_double_dash_separator() {
869 let dir = make_repo();
870 let runner = Arc::new(FakeGitRunner::new());
871 runner.push_ok(b"" as &[u8]);
872
873 let mgr =
875 WorktreeManager::new(dir.path().to_path_buf(), test_config(), Arc::clone(&runner))
876 .await
877 .unwrap();
878
879 let handle = WorktreeHandle {
880 path: dir.path().join("worktrees/x"),
881 branch_name: "agent/x".to_string(),
882 base_ref_resolved: "HEAD".to_string(),
883 subagent_id: "x".to_string(),
884 created_at: SystemTime::now(),
885 };
886
887 let _ = mgr.remove(&handle, false).await;
888 let calls = runner.calls.lock().unwrap();
889 let has_sep = calls[0].0.iter().any(|a| a == "--");
891 assert!(
892 has_sep,
893 "expected '--' separator in git args: {:?}",
894 calls[0].0
895 );
896 }
897
898 #[tokio::test]
904 async fn create_head_mode_proceeds_on_dirty_tree() {
905 let dir = make_repo();
906 let runner = FakeGitRunner::new();
907 runner.push_ok(b" M some-file.txt\n" as &[u8]);
909 runner.push_err(b"fake error\n" as &[u8]);
912
913 let mgr = make_manager(&dir, runner).await;
914 let result = mgr.create("dirty-agent").await;
915 assert!(
918 matches!(result, Err(WorktreeError::GitCommand { .. })),
919 "expected GitCommand error from fake runner, not an early abort: {result:?}"
920 );
921 }
922}