1use std::path::{Path, PathBuf};
2
3use crate::error::WorktreeError;
4use crate::git;
5use crate::guards;
6use crate::ports;
7use crate::state::{self, ActiveWorktreeEntry};
8use crate::types::{
9 AttachOptions, Config, CopyOutcome, CreateOptions, DeleteOptions, EcosystemAdapter, GcOptions,
10 GcReport, GitCapabilities, PortLease, WorktreeHandle, WorktreeState,
11};
12use crate::util;
13
14#[cfg(unix)]
16fn is_pid_alive(pid: u32) -> bool {
17 unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
19}
20
21#[cfg(not(unix))]
22fn is_pid_alive(_pid: u32) -> bool {
23 true }
25
26fn calculate_dir_size(path: &Path) -> u64 {
29 util::dir_size_skipping_git([path].iter().copied())
30}
31
32pub struct Manager {
38 repo_root: PathBuf,
39 config: Config,
40 capabilities: GitCapabilities,
41 consecutive_git_failures: std::cell::Cell<u32>,
43 adapter: Option<Box<dyn EcosystemAdapter>>,
45}
46
47impl Manager {
48 pub fn new(
57 repo_root: impl AsRef<Path>,
58 config: Config,
59 ) -> Result<Self, WorktreeError> {
60 Self::with_adapter(repo_root, config, None)
61 }
62
63 pub fn with_adapter(
68 repo_root: impl AsRef<Path>,
69 config: Config,
70 adapter: Option<Box<dyn EcosystemAdapter>>,
71 ) -> Result<Self, WorktreeError> {
72 let capabilities = git::detect_git_version()?;
74 let repo_root = dunce::canonicalize(repo_root.as_ref()).map_err(WorktreeError::Io)?;
75
76 state::ensure_state_dir(&repo_root, config.home_override.as_deref())?;
77
78 let mgr = Self {
79 repo_root,
80 config,
81 capabilities,
82 consecutive_git_failures: std::cell::Cell::new(0),
83 adapter,
84 };
85
86 if let Ok(worktrees) = mgr.list_raw() {
88 let orphan_paths: Vec<PathBuf> = worktrees
89 .iter()
90 .filter(|wt| wt.state == WorktreeState::Orphaned)
91 .map(|wt| wt.path.clone())
92 .collect();
93 if !orphan_paths.is_empty() {
94 eprintln!(
95 "[iso-code] WARNING: {} orphaned worktree(s) detected at startup",
96 orphan_paths.len()
97 );
98 }
99 }
100
101 if let Err(e) = mgr.with_state(|s| {
103 let now = chrono::Utc::now();
104 ports::sweep_expired_leases(&mut s.port_leases, now);
105 Ok(())
106 }) {
107 eprintln!("[iso-code] WARNING: startup port lease sweep failed: {e}");
108 }
109
110 Ok(mgr)
111 }
112
113 fn with_state<F>(&self, f: F) -> Result<state::StateV2, WorktreeError>
115 where
116 F: FnOnce(&mut state::StateV2) -> Result<(), WorktreeError>,
117 {
118 state::with_state_timeout(
119 &self.repo_root,
120 self.config.home_override.as_deref(),
121 self.config.lock_timeout_ms,
122 f,
123 )
124 }
125
126 fn check_circuit_breaker(&self) -> Result<(), WorktreeError> {
128 let failures = self.consecutive_git_failures.get();
129 if failures >= self.config.circuit_breaker_threshold {
130 return Err(WorktreeError::CircuitBreakerOpen {
131 consecutive_failures: failures,
132 });
133 }
134 Ok(())
135 }
136
137 fn record_git_success(&self) {
139 self.consecutive_git_failures.set(0);
140 }
141
142 fn record_git_failure(&self) {
144 self.consecutive_git_failures.set(self.consecutive_git_failures.get() + 1);
145 }
146
147 pub fn git_capabilities(&self) -> &GitCapabilities {
149 &self.capabilities
150 }
151
152 pub fn repo_root(&self) -> &Path {
154 &self.repo_root
155 }
156
157 pub fn config(&self) -> &Config {
159 &self.config
160 }
161
162 fn list_raw(&self) -> Result<Vec<WorktreeHandle>, WorktreeError> {
164 self.check_circuit_breaker()?;
165 match git::run_worktree_list(&self.repo_root, &self.capabilities) {
166 Ok(result) => {
167 self.record_git_success();
168 Ok(result)
169 }
170 Err(e) => {
171 self.record_git_failure();
172 Err(e)
173 }
174 }
175 }
176
177 pub fn list(&self) -> Result<Vec<WorktreeHandle>, WorktreeError> {
186 let mut git_worktrees = self.list_raw()?;
187
188 let state = match state::read_state(
190 &self.repo_root,
191 self.config.home_override.as_deref(),
192 ) {
193 Ok(s) => s,
194 Err(_) => return Ok(git_worktrees),
195 };
196
197 for wt in &mut git_worktrees {
201 if let Some(entry) = state.active_worktrees.get(&wt.branch) {
202 wt.base_commit.clone_from(&entry.base_commit);
203 wt.created_at = entry.created_at.to_rfc3339();
204 wt.creator_pid = entry.creator_pid;
205 wt.creator_name.clone_from(&entry.creator_name);
206 wt.session_uuid.clone_from(&entry.session_uuid);
207 wt.adapter.clone_from(&entry.adapter);
208 wt.setup_complete = entry.setup_complete;
209 wt.port = entry.port;
210 }
211 }
212
213 let git_branches: std::collections::HashSet<String> =
215 git_worktrees.iter().map(|wt| wt.branch.clone()).collect();
216 let now = chrono::Utc::now();
217
218 if let Err(e) = self.with_state(|s| {
219 let orphaned_keys: Vec<String> = s
226 .active_worktrees
227 .iter()
228 .filter(|&(k, v)| {
229 !git_branches.contains(k)
230 && !matches!(
231 v.state,
232 WorktreeState::Creating | WorktreeState::Pending
233 )
234 })
235 .map(|(k, _)| k.clone())
236 .collect();
237
238 for key in orphaned_keys {
239 if let Some(entry) = s.active_worktrees.remove(&key) {
240 s.stale_worktrees.insert(
241 key,
242 state::StaleWorktreeEntry {
243 original_path: entry.path,
244 branch: entry.branch,
245 base_commit: entry.base_commit,
246 creator_name: entry.creator_name,
247 session_uuid: entry.session_uuid,
248 port: entry.port,
249 last_activity: entry.last_activity,
250 evicted_at: now,
251 eviction_reason: "reconciliation: not in git worktree list"
252 .to_string(),
253 expires_at: now
254 + chrono::Duration::days(
255 i64::from(self.config.stale_metadata_ttl_days),
256 ),
257 extra: std::collections::HashMap::new(),
258 },
259 );
260 }
261 }
262
263 s.stale_worktrees.retain(|_, v| v.expires_at > now);
265
266 ports::sweep_expired_leases(&mut s.port_leases, now);
268
269 Ok(())
270 },
271 ) {
272 eprintln!("[iso-code] WARNING: list reconciliation failed: {e}");
273 }
274
275 Ok(git_worktrees)
276 }
277
278 pub fn create(
292 &self,
293 branch: impl Into<String>,
294 path: impl AsRef<Path>,
295 options: CreateOptions,
296 ) -> Result<(WorktreeHandle, CopyOutcome), WorktreeError> {
297 let branch = branch.into();
298 let target_path = path.as_ref().to_path_buf();
299
300 let existing = self.list().unwrap_or_default();
302 let crypt_status = guards::run_pre_create_guards(guards::PreCreateArgs {
303 repo: &self.repo_root,
304 branch: &branch,
305 target_path: &target_path,
306 caps: &self.capabilities,
307 existing_worktrees: &existing,
308 max_worktrees: self.config.max_worktrees,
309 min_free_disk_mb: self.config.min_free_disk_mb,
310 max_total_disk_bytes: self.config.max_total_disk_bytes,
311 ignore_disk_limit: options.ignore_disk_limit,
312 disk_threshold_percent: Some(self.config.disk_threshold_percent),
313 })?;
314
315 match crypt_status {
320 crate::types::GitCryptStatus::Locked
321 | crate::types::GitCryptStatus::LockedNoKey => {
322 return Err(WorktreeError::GitCryptLocked);
323 }
324 _ => {}
325 }
326
327 let is_new_branch = !git::branch_exists(&self.repo_root, &branch)?;
333
334 let base_commit = if is_new_branch {
335 let base_ref = options.base.as_deref().unwrap_or("HEAD");
336 git::resolve_ref(&self.repo_root, base_ref)?
337 } else {
338 let branch_commit =
339 git::resolve_ref(&self.repo_root, &format!("refs/heads/{branch}"))?;
340 if let Some(requested_base) = options.base.as_deref() {
341 let requested_commit = git::resolve_ref(&self.repo_root, requested_base)?;
342 if requested_commit != branch_commit {
343 return Err(WorktreeError::BranchExistsWithDifferentBase {
344 branch: branch.clone(),
345 branch_commit,
346 requested_base: requested_base.to_string(),
347 requested_commit,
348 });
349 }
350 }
351 branch_commit
352 };
353
354 let session_uuid = uuid::Uuid::new_v4().to_string();
355 let created_at = chrono::Utc::now();
356 let creator_pid = std::process::id();
357
358 if let Err(e) = self.with_state(|s| {
361 s.active_worktrees.insert(
362 branch.clone(),
363 ActiveWorktreeEntry {
364 path: target_path.to_string_lossy().to_string(),
365 branch: branch.clone(),
366 base_commit: base_commit.clone(),
367 state: WorktreeState::Creating,
368 created_at,
369 last_activity: Some(created_at),
370 creator_pid,
371 creator_name: self.config.creator_name.clone(),
372 session_uuid: session_uuid.clone(),
373 adapter: None,
374 setup_complete: false,
375 port: None,
376 extra: std::collections::HashMap::new(),
377 },
378 );
379 Ok(())
380 },
381 ) {
382 eprintln!("[iso-code] WARNING: failed to persist Creating state: {e}");
383 }
384
385 let add_result = git::worktree_add(
387 &self.repo_root,
388 &target_path,
389 &branch,
390 options.base.as_deref(),
391 is_new_branch,
392 options.lock,
393 options.lock_reason.as_deref(),
394 );
395
396 if let Err(e) = add_result {
397 let _ = std::fs::remove_dir_all(&target_path);
401 if let Err(se) = self.with_state(|s| { s.active_worktrees.remove(&branch); Ok(()) }) {
403 eprintln!("[iso-code] WARNING: failed to clean up state after add failure: {se}");
404 }
405 return Err(e);
406 }
407
408 if let Err(e) = git::post_create_git_crypt_check(&target_path) {
410 let _ = git::worktree_remove_force(&self.repo_root, &target_path);
413 if let Err(se) = self.with_state(|s| { s.active_worktrees.remove(&branch); Ok(()) }) {
414 eprintln!("[iso-code] WARNING: failed to clean up state after git-crypt failure: {se}");
415 }
416 return Err(e);
417 }
418
419 let (adapter_name, setup_complete) = if options.setup {
421 let Some(ref adapter) = self.adapter else {
422 let _ = git::worktree_remove_force(&self.repo_root, &target_path);
424 if let Err(se) = self.with_state(|s| { s.active_worktrees.remove(&branch); Ok(()) }) {
425 eprintln!("[iso-code] WARNING: failed to clean up state after missing-adapter error: {se}");
426 }
427 return Err(WorktreeError::SetupRequestedWithoutAdapter);
428 };
429
430 let repo_root = self.repo_root.clone();
431 match adapter.setup(&target_path, &repo_root) {
432 Ok(()) => (Some(adapter.name().to_string()), true),
433 Err(e) => {
434 let _ = git::worktree_remove_force(&self.repo_root, &target_path);
437 if let Err(se) = self.with_state(|s| { s.active_worktrees.remove(&branch); Ok(()) }) {
438 eprintln!("[iso-code] WARNING: failed to clean up state after adapter failure: {se}");
439 }
440 return Err(e);
441 }
442 }
443 } else {
444 (None, false)
445 };
446
447 let final_state = if options.lock {
449 WorktreeState::Locked
450 } else {
451 WorktreeState::Active
452 };
453
454 let port = if options.allocate_port {
456 let repo_id = state::compute_repo_id(&self.repo_root);
457 self.with_state(|s| {
458 let p = ports::allocate_port(
459 &repo_id,
460 &branch,
461 &session_uuid,
462 self.config.port_range_start,
463 self.config.port_range_end,
464 &s.port_leases,
465 )?;
466 let lease = ports::make_lease(p, &branch, &session_uuid, creator_pid);
467 s.port_leases.insert(branch.clone(), lease);
468 Ok(())
469 })
470 .ok()
471 .and_then(|s| s.port_leases.get(&branch).map(|l| l.port))
472 } else {
473 None
474 };
475
476 let canon_path = dunce::canonicalize(&target_path).unwrap_or(target_path);
477
478 if let Err(e) = self.with_state(|s| {
480 if let Some(entry) = s.active_worktrees.get_mut(&branch) {
481 entry.state = final_state.clone();
482 entry.path = canon_path.to_string_lossy().to_string();
483 entry.port = port;
484 entry.adapter.clone_from(&adapter_name);
485 entry.setup_complete = setup_complete;
486 }
487 Ok(())
488 },
489 ) {
490 eprintln!("[iso-code] WARNING: failed to persist Active state: {e}");
491 }
492
493 let handle = WorktreeHandle::new(
494 canon_path,
495 branch,
496 base_commit,
497 final_state,
498 created_at.to_rfc3339(),
499 creator_pid,
500 self.config.creator_name.clone(),
501 adapter_name,
502 setup_complete,
503 port,
504 session_uuid,
505 );
506
507 Ok((handle, CopyOutcome::None))
508 }
509
510 pub fn attach(
518 &self,
519 path: impl AsRef<Path>,
520 options: AttachOptions,
521 ) -> Result<WorktreeHandle, WorktreeError> {
522 let target_path = dunce::canonicalize(path.as_ref()).map_err(WorktreeError::Io)?;
523
524 let worktrees = self.list()?;
526 let git_entry = worktrees
527 .iter()
528 .find(|wt| {
529 dunce::canonicalize(&wt.path)
531 .map(|p| p == target_path)
532 .unwrap_or(false)
533 })
534 .ok_or_else(|| WorktreeError::WorktreeNotInGitRegistry(target_path.clone()))?;
535
536 if self.capabilities.has_repair {
538 let _ = std::process::Command::new("git")
539 .args(["worktree", "repair"])
540 .arg(&target_path)
541 .current_dir(&self.repo_root)
542 .output();
543 }
544
545 let existing_state = state::read_state(
547 &self.repo_root,
548 self.config.home_override.as_deref(),
549 ).ok();
550
551 let path_str = target_path.to_string_lossy().to_string();
552 let branch = git_entry.branch.clone();
553
554 if let Some(ref st) = existing_state {
556 if let Some(entry) = st.active_worktrees.get(&branch) {
557 return Ok(WorktreeHandle::new(
558 target_path,
559 branch,
560 entry.base_commit.clone(),
561 git_entry.state.clone(),
562 entry.created_at.to_rfc3339(),
563 entry.creator_pid,
564 entry.creator_name.clone(),
565 entry.adapter.clone(),
566 entry.setup_complete,
567 entry.port,
568 entry.session_uuid.clone(),
569 ));
570 }
571 }
572
573 let recovered_stale_key: Option<String> = existing_state.as_ref().and_then(|st| {
579 st.stale_worktrees
580 .iter()
581 .find(|(_, v)| v.original_path == path_str)
582 .or_else(|| st.stale_worktrees.iter().find(|(_, v)| v.branch == branch))
583 .map(|(k, _)| k.clone())
584 });
585 let (session_uuid, port) = recovered_stale_key
586 .as_ref()
587 .and_then(|k| existing_state.as_ref()?.stale_worktrees.get(k))
588 .map(|stale| (stale.session_uuid.clone(), stale.port))
589 .unwrap_or_else(|| (uuid::Uuid::new_v4().to_string(), None));
590
591 let created_at = chrono::Utc::now();
592 let creator_pid = std::process::id();
593
594 let (adapter_name, setup_complete) = if options.setup {
596 let Some(ref adapter) = self.adapter else {
597 return Err(WorktreeError::SetupRequestedWithoutAdapter);
598 };
599 let repo_root = self.repo_root.clone();
600 match adapter.setup(&target_path, &repo_root) {
601 Ok(()) => (Some(adapter.name().to_string()), true),
602 Err(e) => {
603 eprintln!("[iso-code] WARNING: adapter setup failed during attach: {e}");
604 (Some(adapter.name().to_string()), false)
605 }
606 }
607 } else {
608 (None, false)
609 };
610
611 let handle = WorktreeHandle::new(
612 target_path.clone(),
613 branch.clone(),
614 git_entry.base_commit.clone(),
615 git_entry.state.clone(),
616 created_at.to_rfc3339(),
617 creator_pid,
618 self.config.creator_name.clone(),
619 adapter_name.clone(),
620 setup_complete,
621 port,
622 session_uuid.clone(),
623 );
624
625 if let Err(e) = self.with_state(|s| {
627 if let Some(ref k) = recovered_stale_key {
628 s.stale_worktrees.remove(k);
629 }
630
631 s.active_worktrees.insert(
633 branch.clone(),
634 ActiveWorktreeEntry {
635 path: path_str.clone(),
636 branch: branch.clone(),
637 base_commit: git_entry.base_commit.clone(),
638 state: git_entry.state.clone(),
639 created_at,
640 last_activity: Some(created_at),
641 creator_pid,
642 creator_name: self.config.creator_name.clone(),
643 session_uuid: session_uuid.clone(),
644 adapter: adapter_name,
645 setup_complete,
646 port,
647 extra: std::collections::HashMap::new(),
648 },
649 );
650
651 Ok(())
652 },
653 ) {
654 eprintln!("[iso-code] WARNING: failed to persist attach state: {e}");
655 }
656
657 Ok(handle)
658 }
659
660 pub fn delete(
671 &self,
672 handle: &WorktreeHandle,
673 options: DeleteOptions,
674 ) -> Result<(), WorktreeError> {
675 guards::check_not_cwd(&handle.path)?;
677
678 if !options.force_dirty {
680 guards::check_no_uncommitted_changes(&handle.path)?;
681 }
682
683 if !options.force {
685 guards::five_step_unmerged_check(&handle.branch, &self.repo_root, self.config.offline)?;
686 }
687
688 if !options.force_locked {
690 guards::check_not_locked(handle)?;
691 }
692
693 let branch = handle.branch.clone();
695 if let Err(e) = self.with_state(|s| {
696 if let Some(entry) = s.active_worktrees.get_mut(&branch) {
697 entry.state = WorktreeState::Deleting;
698 }
699 Ok(())
700 },
701 ) {
702 eprintln!("[iso-code] WARNING: failed to persist Deleting state: {e}");
703 }
704
705 if handle.setup_complete {
710 if let Some(ref adapter) = self.adapter {
711 if let Err(e) = adapter.teardown(&handle.path) {
712 eprintln!(
713 "[iso-code] WARNING: adapter teardown failed for {}: {e}",
714 handle.path.display()
715 );
716 }
717 }
718 }
719
720 #[cfg(target_os = "macos")]
723 {
724 let ds_store = handle.path.join(".DS_Store");
725 if ds_store.exists() {
726 let _ = std::fs::remove_file(&ds_store);
727 }
728 }
729
730 if options.force_locked {
731 git::worktree_remove_force(&self.repo_root, &handle.path)?;
732 } else {
733 git::worktree_remove(&self.repo_root, &handle.path)?;
734 }
735
736 if let Err(e) = self.with_state(|s| {
738 s.active_worktrees.remove(&branch);
739 s.port_leases.remove(&branch);
740 Ok(())
741 },
742 ) {
743 eprintln!("[iso-code] WARNING: failed to persist Deleted state: {e}");
744 }
745
746 Ok(())
747 }
748
749 pub fn gc(&self, options: GcOptions) -> Result<GcReport, WorktreeError> {
756 let max_age_days = options
757 .max_age_days
758 .unwrap_or(self.config.gc_max_age_days);
759
760 let mut git_worktrees = git::run_worktree_list(&self.repo_root, &self.capabilities)?;
762
763 if let Ok(state) = state::read_state(
769 &self.repo_root,
770 self.config.home_override.as_deref(),
771 ) {
772 for wt in &mut git_worktrees {
773 if let Some(entry) = state.active_worktrees.get(&wt.branch) {
774 wt.base_commit.clone_from(&entry.base_commit);
775 wt.created_at = entry.created_at.to_rfc3339();
776 wt.creator_pid = entry.creator_pid;
777 wt.creator_name.clone_from(&entry.creator_name);
778 wt.session_uuid.clone_from(&entry.session_uuid);
779 wt.port = entry.port;
780 }
781 }
782 }
783
784 let mut orphans: Vec<PathBuf> = Vec::new();
785 let mut removed_entries: Vec<(PathBuf, Option<String>)> = Vec::new();
787 let mut evicted_entries: Vec<(PathBuf, Option<String>)> = Vec::new();
788 let mut freed_bytes: u64 = 0;
789
790 let now = chrono::Utc::now();
791 let age_cutoff = now - chrono::Duration::days(i64::from(max_age_days));
792
793 for wt in &git_worktrees {
796 if wt.state == WorktreeState::Orphaned {
797 orphans.push(wt.path.clone());
798 }
799 }
800
801 for wt in &git_worktrees {
804 if wt.state == WorktreeState::Locked {
806 continue;
807 }
808
809 if wt.branch.is_empty() {
811 continue;
812 }
813
814 let is_old_enough = if wt.created_at.is_empty() {
817 false
818 } else {
819 chrono::DateTime::parse_from_rfc3339(&wt.created_at)
820 .map(|t| t.with_timezone(&chrono::Utc) < age_cutoff)
821 .unwrap_or(false)
822 };
823
824 if !is_old_enough && wt.state != WorktreeState::Orphaned {
825 continue;
826 }
827
828 if wt.state == WorktreeState::Active && wt.creator_pid != 0
830 && is_pid_alive(wt.creator_pid) {
831 continue;
832 }
833
834 if !options.force && wt.state != WorktreeState::Orphaned
836 && guards::five_step_unmerged_check(
837 &wt.branch,
838 &self.repo_root,
839 self.config.offline,
840 ).is_err() {
841 continue; }
843
844 let disk_usage = calculate_dir_size(&wt.path);
846
847 if !orphans.contains(&wt.path) {
848 evicted_entries.push((wt.path.clone(), Some(wt.branch.clone())));
849 }
850
851 if !options.dry_run {
852 #[cfg(target_os = "macos")]
854 {
855 let ds = wt.path.join(".DS_Store");
856 if ds.exists() {
857 let _ = std::fs::remove_file(&ds);
858 }
859 }
860
861 if git::worktree_remove(&self.repo_root, &wt.path).is_ok() {
862 removed_entries.push((wt.path.clone(), Some(wt.branch.clone())));
863 freed_bytes += disk_usage;
864 }
865 }
866 }
867
868 if !options.dry_run {
870 let _ = std::process::Command::new("git")
871 .args(["worktree", "prune"])
872 .current_dir(&self.repo_root)
873 .output();
874 }
875
876 let evicted: Vec<PathBuf> = evicted_entries.iter().map(|(p, _)| p.clone()).collect();
878 let removed: Vec<PathBuf> = removed_entries.iter().map(|(p, _)| p.clone()).collect();
879
880 let orphan_paths = orphans.clone();
887 let evicted_inputs = evicted_entries.clone();
888 let removed_inputs = removed_entries.clone();
889 if !options.dry_run || !evicted_inputs.is_empty() || !removed_inputs.is_empty() {
890 if let Err(e) = self.with_state(|s| {
891 let now = chrono::Utc::now();
892 let ttl_days = i64::from(self.config.stale_metadata_ttl_days);
893
894 let move_to_stale = |s: &mut state::StateV2,
897 branch_hint: Option<&str>,
898 path: &std::path::Path,
899 reason: &str| {
900 let key: Option<String> = match branch_hint {
901 Some(b) if s.active_worktrees.contains_key(b) => Some(b.to_string()),
902 _ => {
903 let canon_target = dunce::canonicalize(path).ok();
904 s.active_worktrees
905 .iter()
906 .find(|(_, v)| {
907 let v_path = std::path::Path::new(&v.path);
908 let canon_entry = dunce::canonicalize(v_path).ok();
909 match (&canon_target, &canon_entry) {
910 (Some(a), Some(b)) => a == b,
911 _ => v.path == path.to_string_lossy(),
912 }
913 })
914 .map(|(k, _)| k.clone())
915 }
916 };
917
918 if let Some(key) = key {
919 if let Some(entry) = s.active_worktrees.remove(&key) {
920 s.stale_worktrees.insert(
921 key.clone(),
922 state::StaleWorktreeEntry {
923 original_path: entry.path,
924 branch: entry.branch,
925 base_commit: entry.base_commit,
926 creator_name: entry.creator_name,
927 session_uuid: entry.session_uuid,
928 port: entry.port,
929 last_activity: entry.last_activity,
930 evicted_at: now,
931 eviction_reason: reason.to_string(),
932 expires_at: now + chrono::Duration::days(ttl_days),
933 extra: std::collections::HashMap::new(),
934 },
935 );
936 if let Some(lease) = s.port_leases.get_mut(&key) {
938 lease.status = "stale".to_string();
939 }
940 }
941 }
942 };
943
944 for (path, branch) in &evicted_inputs {
945 move_to_stale(s, branch.as_deref(), path, "gc: age exceeded");
946 }
947 for path in &orphan_paths {
948 if removed_inputs.iter().any(|(p, _)| p == path) {
951 move_to_stale(s, None, path, "gc: orphaned worktree");
952 }
953 }
954
955 let sweep_cutoff = now - chrono::Duration::minutes(10);
961 let sweep_keys: Vec<String> = s
962 .active_worktrees
963 .iter()
964 .filter(|(_, v)| {
965 matches!(
966 v.state,
967 WorktreeState::Creating | WorktreeState::Pending
968 ) && v.created_at < sweep_cutoff
969 && v.creator_pid != 0
970 && !is_pid_alive(v.creator_pid)
971 })
972 .map(|(k, _)| k.clone())
973 .collect();
974 for key in sweep_keys {
975 if let Some(entry) = s.active_worktrees.remove(&key) {
976 s.stale_worktrees.insert(
977 key.clone(),
978 state::StaleWorktreeEntry {
979 original_path: entry.path,
980 branch: entry.branch,
981 base_commit: entry.base_commit,
982 creator_name: entry.creator_name,
983 session_uuid: entry.session_uuid,
984 port: entry.port,
985 last_activity: entry.last_activity,
986 evicted_at: now,
987 eviction_reason: "gc: abandoned Creating entry"
988 .to_string(),
989 expires_at: now + chrono::Duration::days(ttl_days),
990 extra: std::collections::HashMap::new(),
991 },
992 );
993 if let Some(lease) = s.port_leases.get_mut(&key) {
994 lease.status = "stale".to_string();
995 }
996 }
997 }
998
999 if !options.dry_run {
1000 s.gc_history.push(state::GcHistoryEntry {
1001 timestamp: now,
1002 removed: removed_inputs.len() as u32,
1003 evicted: evicted_inputs.len() as u32,
1004 freed_mb: freed_bytes / (1024 * 1024),
1005 extra: std::collections::HashMap::new(),
1006 });
1007 }
1008
1009 Ok(())
1010 }) {
1011 eprintln!("[iso-code] WARNING: failed to persist GC state: {e}");
1012 }
1013 }
1014
1015 Ok(GcReport::new(orphans, removed, evicted, freed_bytes, options.dry_run))
1016 }
1017
1018 pub fn touch(&self, branch: &str) -> Result<(), WorktreeError> {
1025 let branch_owned = branch.to_string();
1026 self.with_state(|s| {
1027 match s.active_worktrees.get_mut(&branch_owned) {
1028 Some(entry) => {
1029 entry.last_activity = Some(chrono::Utc::now());
1030 Ok(())
1031 }
1032 None => Err(WorktreeError::StateCorrupted {
1033 reason: format!("touch: branch '{branch_owned}' not in active_worktrees"),
1034 }),
1035 }
1036 })?;
1037 Ok(())
1038 }
1039
1040 pub fn port_lease(&self, branch: &str) -> Option<PortLease> {
1042 let s = state::read_state(
1043 &self.repo_root,
1044 self.config.home_override.as_deref(),
1045 ).ok()?;
1046 let now = chrono::Utc::now();
1047 s.port_leases
1048 .get(branch)
1049 .filter(|l| !ports::is_lease_expired(l, now))
1050 .cloned()
1051 }
1052
1053 pub fn allocate_port(&self, branch: &str, session_uuid: &str) -> Result<u16, WorktreeError> {
1055 let repo_id = state::compute_repo_id(&self.repo_root);
1056 let mut allocated_port: u16 = 0;
1057 self.with_state(|s| {
1058 let port = ports::allocate_port(
1059 &repo_id,
1060 branch,
1061 session_uuid,
1062 self.config.port_range_start,
1063 self.config.port_range_end,
1064 &s.port_leases,
1065 )?;
1066 let lease = ports::make_lease(port, branch, session_uuid, std::process::id());
1067 s.port_leases.insert(branch.to_string(), lease);
1068 allocated_port = port;
1069 Ok(())
1070 })?;
1071 Ok(allocated_port)
1072 }
1073
1074 pub fn disk_usage(&self, path: &Path) -> u64 {
1077 calculate_dir_size(path)
1078 }
1079
1080 pub fn release_port(&self, branch: &str) -> Result<(), WorktreeError> {
1082 self.with_state(|s| {
1083 s.port_leases.remove(branch);
1084 Ok(())
1085 })?;
1086 Ok(())
1087 }
1088
1089 pub fn renew_port_lease(&self, branch: &str) -> Result<(), WorktreeError> {
1097 let branch_owned = branch.to_string();
1098 self.with_state(|s| {
1099 let now = chrono::Utc::now();
1100 match s.port_leases.get_mut(&branch_owned) {
1101 Some(lease) if !ports::is_lease_expired(lease, now) => {
1102 ports::renew_lease(lease);
1103 Ok(())
1104 }
1105 Some(_) => Err(WorktreeError::StateCorrupted {
1106 reason: format!(
1107 "renew_port_lease: lease for '{branch_owned}' is expired — reallocate instead"
1108 ),
1109 }),
1110 None => Err(WorktreeError::StateCorrupted {
1111 reason: format!("renew_port_lease: no active lease for '{branch_owned}'"),
1112 }),
1113 }
1114 })?;
1115 Ok(())
1116 }
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121 use super::*;
1122 use std::process::Command;
1123
1124 fn create_test_repo() -> tempfile::TempDir {
1126 let dir = tempfile::TempDir::new().unwrap();
1127 run_git(dir.path(), &["init", "-b", "main"]);
1128 run_git(dir.path(), &["config", "user.email", "test@example.com"]);
1131 run_git(dir.path(), &["config", "user.name", "Test"]);
1132 run_git(dir.path(), &["commit", "--allow-empty", "-m", "initial"]);
1133 dir
1134 }
1135
1136 fn run_git(dir: &std::path::Path, args: &[&str]) {
1138 let out = Command::new("git")
1139 .args(args)
1140 .current_dir(dir)
1141 .output()
1142 .unwrap_or_else(|e| panic!("failed to spawn git {args:?}: {e}"));
1143 if !out.status.success() {
1144 panic!(
1145 "git {args:?} failed: {}",
1146 String::from_utf8_lossy(&out.stderr)
1147 );
1148 }
1149 }
1150
1151 #[test]
1152 fn test_manager_new() {
1153 let repo = create_test_repo();
1154 let mgr = Manager::new(repo.path(), Config::default());
1155 assert!(mgr.is_ok());
1156 }
1157
1158 #[test]
1159 fn test_manager_list() {
1160 let repo = create_test_repo();
1161 let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1162 let list = mgr.list().unwrap();
1163 assert!(!list.is_empty()); }
1165
1166 #[test]
1167 fn test_create_and_delete_worktree() {
1168 let repo = create_test_repo();
1169 let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1170
1171 let wt_path = repo.path().join("test-wt");
1172 let (handle, outcome) = mgr
1173 .create("test-branch", &wt_path, CreateOptions::default())
1174 .unwrap();
1175
1176 assert!(wt_path.exists());
1177 assert_eq!(handle.branch, "test-branch");
1178 assert_eq!(handle.state, WorktreeState::Active);
1179 assert!(!handle.base_commit.is_empty());
1180 assert!(!handle.session_uuid.is_empty());
1181 assert!(handle.creator_pid > 0);
1182 assert!(!handle.created_at.is_empty());
1183 assert_eq!(outcome, CopyOutcome::None);
1184
1185 let list = mgr.list().unwrap();
1187 assert!(list.len() >= 2); mgr.delete(&handle, DeleteOptions::default()).unwrap();
1191 assert!(!wt_path.exists());
1192 }
1193
1194 #[test]
1195 fn test_create_worktree_cleanup_on_add_failure() {
1196 let repo = create_test_repo();
1197 let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1198
1199 let wt_path = repo.path().join("test-wt-fail");
1202 let result = mgr.create("main", &wt_path, CreateOptions::default());
1203 assert!(result.is_err());
1204 }
1205
1206 #[test]
1207 fn test_create_worktree_with_lock() {
1208 let repo = create_test_repo();
1209 let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1210
1211 let wt_path = repo.path().join("locked-wt");
1212 let opts = CreateOptions {
1213 lock: true,
1214 lock_reason: Some("testing".to_string()),
1215 ..Default::default()
1216 };
1217 let (handle, _) = mgr.create("locked-branch", &wt_path, opts).unwrap();
1218
1219 assert_eq!(handle.state, WorktreeState::Locked);
1220
1221 let _ = git::worktree_remove_force(&mgr.repo_root, &wt_path);
1223 }
1224
1225 #[test]
1226 fn test_delete_with_unmerged_commits_returns_error() {
1227 let repo = create_test_repo();
1228 let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1229
1230 let wt_path = repo.path().join("unmerged-wt");
1232 let (handle, _) = mgr
1233 .create("unmerged-branch", &wt_path, CreateOptions::default())
1234 .unwrap();
1235
1236 Command::new("git")
1238 .args(["commit", "--allow-empty", "-m", "unmerged work"])
1239 .current_dir(&wt_path)
1240 .output()
1241 .unwrap();
1242
1243 let result = mgr.delete(&handle, DeleteOptions::default());
1245 assert!(result.is_err());
1246 match result.unwrap_err() {
1247 WorktreeError::UnmergedCommits { branch, commit_count } => {
1248 assert_eq!(branch, "unmerged-branch");
1249 assert!(commit_count > 0);
1250 }
1251 other => panic!("expected UnmergedCommits, got: {other}"),
1252 }
1253
1254 let _ = mgr.delete(
1256 &handle,
1257 DeleteOptions { force: true, force_dirty: true, ..Default::default() },
1258 );
1259 }
1260
1261 #[test]
1262 fn test_delete_with_force_skips_unmerged_check() {
1263 let repo = create_test_repo();
1264 let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1265
1266 let wt_path = repo.path().join("force-wt");
1267 let (handle, _) = mgr
1268 .create("force-branch", &wt_path, CreateOptions::default())
1269 .unwrap();
1270
1271 Command::new("git")
1273 .args(["commit", "--allow-empty", "-m", "unmerged work"])
1274 .current_dir(&wt_path)
1275 .output()
1276 .unwrap();
1277
1278 let result = mgr.delete(
1280 &handle,
1281 DeleteOptions {
1282 force: true,
1283 ..Default::default()
1284 },
1285 );
1286 assert!(result.is_ok());
1287 assert!(!wt_path.exists());
1288 }
1289
1290 #[test]
1291 fn test_delete_merged_branch_succeeds() {
1292 let repo = create_test_repo();
1293 let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1294
1295 let wt_path = repo.path().join("merged-wt");
1296 let (handle, _) = mgr
1297 .create("merged-branch", &wt_path, CreateOptions::default())
1298 .unwrap();
1299
1300 let result = mgr.delete(&handle, DeleteOptions::default());
1303 assert!(result.is_ok());
1304 assert!(!wt_path.exists());
1305 }
1306
1307 #[test]
1308 fn test_delete_locked_worktree_returns_error() {
1309 let repo = create_test_repo();
1310 let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1311
1312 let wt_path = repo.path().join("locked-del-wt");
1313 let opts = CreateOptions {
1314 lock: true,
1315 lock_reason: Some("important work".to_string()),
1316 ..Default::default()
1317 };
1318 let (handle, _) = mgr.create("locked-del-branch", &wt_path, opts).unwrap();
1319
1320 let result = mgr.delete(&handle, DeleteOptions::default());
1322 assert!(result.is_err());
1323 assert!(matches!(
1324 result.unwrap_err(),
1325 WorktreeError::WorktreeLocked { .. }
1326 ));
1327
1328 let _ = git::worktree_remove_force(&mgr.repo_root, &wt_path);
1330 }
1331
1332 #[test]
1335 fn test_attach_manually_created_worktree() {
1336 let repo = create_test_repo();
1337 let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1338
1339 let wt_path = repo.path().join("manual-wt");
1341 let output = Command::new("git")
1342 .args(["worktree", "add", wt_path.to_str().unwrap(), "-b", "manual-branch"])
1343 .current_dir(repo.path())
1344 .output()
1345 .unwrap();
1346 assert!(output.status.success(), "git worktree add failed: {}", String::from_utf8_lossy(&output.stderr));
1347
1348 let handle = mgr.attach(&wt_path, AttachOptions::default()).unwrap();
1350
1351 assert_eq!(handle.branch, "manual-branch");
1352 assert!(!handle.base_commit.is_empty());
1353 assert!(!handle.session_uuid.is_empty());
1354 assert!(handle.creator_pid > 0);
1355 assert_eq!(handle.state, WorktreeState::Active);
1356
1357 let list = mgr.list().unwrap();
1359 assert!(list.iter().any(|wt| {
1360 dunce::canonicalize(&wt.path).ok() == dunce::canonicalize(&wt_path).ok()
1361 }));
1362
1363 let _ = git::worktree_remove_force(&mgr.repo_root, &wt_path);
1365 }
1366
1367 #[test]
1368 fn test_attach_nonexistent_path_errors() {
1369 let repo = create_test_repo();
1370 let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1371
1372 let bad_path = repo.path().join("does-not-exist");
1373 let result = mgr.attach(&bad_path, AttachOptions::default());
1374 assert!(result.is_err());
1375 }
1376
1377 #[test]
1378 fn test_attach_path_not_in_git_registry_errors() {
1379 let repo = create_test_repo();
1380 let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1381
1382 let dir_path = repo.path().join("just-a-dir");
1384 std::fs::create_dir_all(&dir_path).unwrap();
1385
1386 let result = mgr.attach(&dir_path, AttachOptions::default());
1387 assert!(result.is_err());
1388 let err = result.unwrap_err();
1390 assert!(
1391 err.to_string().contains("not found in git registry"),
1392 "Expected WorktreeNotInGitRegistry, got: {err}"
1393 );
1394 }
1395
1396 #[test]
1397 fn test_attach_idempotent() {
1398 let repo = create_test_repo();
1399 let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1400
1401 let wt_path = repo.path().join("idempotent-wt");
1403 let output = Command::new("git")
1404 .args(["worktree", "add", wt_path.to_str().unwrap(), "-b", "idem-branch"])
1405 .current_dir(repo.path())
1406 .output()
1407 .unwrap();
1408 assert!(output.status.success());
1409
1410 let handle1 = mgr.attach(&wt_path, AttachOptions::default()).unwrap();
1412 let handle2 = mgr.attach(&wt_path, AttachOptions::default()).unwrap();
1413
1414 assert_eq!(handle1.branch, handle2.branch);
1415 assert_eq!(handle1.base_commit, handle2.base_commit);
1416 assert_eq!(
1417 dunce::canonicalize(&handle1.path).unwrap(),
1418 dunce::canonicalize(&handle2.path).unwrap()
1419 );
1420
1421 let _ = git::worktree_remove_force(&mgr.repo_root, &wt_path);
1423 }
1424
1425 #[test]
1426 fn test_attach_after_create_and_delete() {
1427 let repo = create_test_repo();
1429 let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1430
1431 let wt_path = repo.path().join("reattach-wt");
1432
1433 let (handle, _) = mgr
1435 .create("reattach-branch", &wt_path, CreateOptions::default())
1436 .unwrap();
1437 mgr.delete(&handle, DeleteOptions::default()).unwrap();
1438 assert!(!wt_path.exists());
1439
1440 let output = Command::new("git")
1442 .args(["worktree", "add", wt_path.to_str().unwrap(), "reattach-branch"])
1443 .current_dir(repo.path())
1444 .output()
1445 .unwrap();
1446 assert!(output.status.success(), "git worktree add failed: {}", String::from_utf8_lossy(&output.stderr));
1447
1448 let attached = mgr.attach(&wt_path, AttachOptions::default()).unwrap();
1450 assert_eq!(attached.branch, "reattach-branch");
1451 assert!(!attached.session_uuid.is_empty());
1452
1453 let _ = git::worktree_remove_force(&mgr.repo_root, &wt_path);
1455 }
1456
1457 #[test]
1460 fn test_gc_dry_run_returns_report_without_deleting() {
1461 let repo = create_test_repo();
1462 let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1463
1464 let wt_path = repo.path().join("gc-test-wt");
1465 let (handle, _) = mgr
1466 .create("gc-branch", &wt_path, CreateOptions::default())
1467 .unwrap();
1468
1469 let report = mgr.gc(GcOptions::default()).unwrap();
1471 assert!(report.dry_run);
1472 assert!(report.removed.is_empty());
1473
1474 assert!(wt_path.exists());
1476
1477 mgr.delete(&handle, DeleteOptions { force: true, ..Default::default() }).unwrap();
1479 }
1480
1481 #[test]
1482 fn test_gc_locked_worktree_never_touched() {
1483 let repo = create_test_repo();
1484 let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1485
1486 let wt_path = repo.path().join("gc-locked-wt");
1487 let opts = CreateOptions {
1488 lock: true,
1489 ..Default::default()
1490 };
1491 let (_handle, _) = mgr.create("gc-locked-branch", &wt_path, opts).unwrap();
1492
1493 let report = mgr
1495 .gc(GcOptions { dry_run: false, force: true, ..Default::default() })
1496 .unwrap();
1497
1498 assert!(!report.removed.iter().any(|p| p == &wt_path));
1500 assert!(!report.evicted.iter().any(|p| p == &wt_path));
1501
1502 assert!(wt_path.exists());
1504
1505 let _ = git::worktree_remove_force(&mgr.repo_root, &wt_path);
1507 }
1508
1509 #[test]
1510 fn test_gc_default_is_dry_run() {
1511 assert!(GcOptions::default().dry_run);
1512 }
1513
1514 #[test]
1521 fn test_list_preserves_creating_entry_during_reconcile() {
1522 let repo = create_test_repo();
1523 let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1524
1525 state::with_state(mgr.repo_root(), None, |s| {
1527 s.active_worktrees.insert(
1528 "in-flight-branch".to_string(),
1529 ActiveWorktreeEntry {
1530 path: repo.path().join("in-flight").to_string_lossy().to_string(),
1531 branch: "in-flight-branch".to_string(),
1532 base_commit: "a".repeat(40),
1533 state: WorktreeState::Creating,
1534 created_at: chrono::Utc::now(),
1535 last_activity: Some(chrono::Utc::now()),
1536 creator_pid: std::process::id(),
1537 creator_name: "test".to_string(),
1538 session_uuid: "uuid-in-flight".to_string(),
1539 adapter: None,
1540 setup_complete: false,
1541 port: None,
1542 extra: std::collections::HashMap::new(),
1543 },
1544 );
1545 Ok(())
1546 })
1547 .unwrap();
1548
1549 let _ = mgr.list().unwrap();
1552
1553 let state_after = state::read_state(mgr.repo_root(), None).unwrap();
1554 assert!(
1555 state_after.active_worktrees.contains_key("in-flight-branch"),
1556 "Creating entry must remain in active_worktrees after list() reconciliation"
1557 );
1558 assert!(
1559 !state_after.stale_worktrees.contains_key("in-flight-branch"),
1560 "Creating entry must NOT be moved to stale_worktrees: {:?}",
1561 state_after.stale_worktrees.keys().collect::<Vec<_>>()
1562 );
1563 }
1564
1565 #[test]
1570 fn test_gc_evicts_old_worktree_with_dead_creator_pid() {
1571 let repo = create_test_repo();
1572 let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1573
1574 let wt_path = repo.path().join("aged-wt");
1575 let (handle, _) = mgr
1576 .create("aged-branch", &wt_path, CreateOptions::default())
1577 .unwrap();
1578
1579 state::with_state(mgr.repo_root(), None, |s| {
1581 let entry = s.active_worktrees.get_mut(&handle.branch).unwrap();
1582 entry.created_at = chrono::Utc::now() - chrono::Duration::days(30);
1583 entry.creator_pid = 99_999_999; Ok(())
1585 })
1586 .unwrap();
1587
1588 let report = mgr
1589 .gc(GcOptions { dry_run: true, force: true, ..Default::default() })
1590 .unwrap();
1591
1592 let canon_wt = dunce::canonicalize(&wt_path).unwrap();
1593 let is_evicted = report.evicted.iter().any(|p| {
1594 dunce::canonicalize(p).ok().as_deref() == Some(&canon_wt)
1595 });
1596 assert!(
1597 is_evicted,
1598 "Old worktree with dead creator_pid must be evicted by gc(), got evicted={:?}",
1599 report.evicted
1600 );
1601
1602 let _ = mgr.delete(
1604 &handle,
1605 DeleteOptions { force: true, force_dirty: true, ..Default::default() },
1606 );
1607 }
1608
1609 #[test]
1610 fn test_touch_updates_last_activity() {
1611 let repo = create_test_repo();
1612 let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1613
1614 let wt_path = repo.path().join("touch-wt");
1615 let (handle, _) = mgr
1616 .create("touch-branch", &wt_path, CreateOptions::default())
1617 .unwrap();
1618
1619 state::with_state(mgr.repo_root(), None, |s| {
1621 let e = s.active_worktrees.get_mut(&handle.branch).unwrap();
1622 e.last_activity = Some(chrono::Utc::now() - chrono::Duration::days(3));
1623 Ok(())
1624 })
1625 .unwrap();
1626
1627 mgr.touch(&handle.branch).unwrap();
1628 let after = state::read_state(mgr.repo_root(), None).unwrap();
1629 let entry = after.active_worktrees.get(&handle.branch).unwrap();
1630 let la = entry.last_activity.unwrap();
1631 assert!(chrono::Utc::now() - la < chrono::Duration::seconds(5));
1632
1633 mgr.delete(&handle, DeleteOptions { force: true, ..Default::default() })
1634 .unwrap();
1635 }
1636
1637 #[test]
1638 fn test_touch_unknown_branch_errors() {
1639 let repo = create_test_repo();
1640 let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1641 let result = mgr.touch("never-created");
1642 assert!(matches!(result, Err(WorktreeError::StateCorrupted { .. })));
1643 }
1644
1645 #[test]
1650 fn test_gc_sweeps_abandoned_creating_entries() {
1651 let repo = create_test_repo();
1652 let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1653
1654 state::with_state(mgr.repo_root(), None, |s| {
1656 s.active_worktrees.insert(
1657 "abandoned-branch".to_string(),
1658 ActiveWorktreeEntry {
1659 path: "/tmp/abandoned-wt".to_string(),
1660 branch: "abandoned-branch".to_string(),
1661 base_commit: "a".repeat(40),
1662 state: WorktreeState::Creating,
1663 created_at: chrono::Utc::now() - chrono::Duration::hours(1),
1664 last_activity: None,
1665 creator_pid: 99_999_999, creator_name: "test".to_string(),
1667 session_uuid: "uuid-abandoned".to_string(),
1668 adapter: None,
1669 setup_complete: false,
1670 port: None,
1671 extra: std::collections::HashMap::new(),
1672 },
1673 );
1674 Ok(())
1675 })
1676 .unwrap();
1677
1678 let _ = mgr
1681 .gc(GcOptions { dry_run: false, force: true, ..Default::default() })
1682 .unwrap();
1683
1684 let after = state::read_state(mgr.repo_root(), None).unwrap();
1685 assert!(
1686 !after.active_worktrees.contains_key("abandoned-branch"),
1687 "abandoned Creating entry must be removed from active_worktrees"
1688 );
1689 assert!(
1690 after.stale_worktrees.contains_key("abandoned-branch"),
1691 "abandoned Creating entry must land in stale_worktrees"
1692 );
1693 let stale = &after.stale_worktrees["abandoned-branch"];
1694 assert_eq!(stale.eviction_reason, "gc: abandoned Creating entry");
1695 }
1696
1697 #[test]
1699 fn test_gc_transitions_port_lease_to_stale() {
1700 let repo = create_test_repo();
1701 let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1702
1703 let wt_path = repo.path().join("lease-wt");
1704 let (handle, _) = mgr
1705 .create(
1706 "lease-branch",
1707 &wt_path,
1708 CreateOptions { allocate_port: true, ..Default::default() },
1709 )
1710 .unwrap();
1711 assert!(handle.port.is_some());
1712
1713 state::with_state(mgr.repo_root(), None, |s| {
1715 let entry = s.active_worktrees.get_mut(&handle.branch).unwrap();
1716 entry.created_at = chrono::Utc::now() - chrono::Duration::days(30);
1717 entry.creator_pid = 99_999_999;
1718 Ok(())
1719 })
1720 .unwrap();
1721
1722 let _ = mgr
1723 .gc(GcOptions { dry_run: false, force: true, ..Default::default() })
1724 .unwrap();
1725
1726 let after = state::read_state(mgr.repo_root(), None).unwrap();
1727 let lease = after
1728 .port_leases
1729 .get("lease-branch")
1730 .expect("lease should survive eviction with stale status");
1731 assert_eq!(lease.status, "stale");
1732 }
1733
1734 #[test]
1737 fn test_renew_port_lease_extends_expiry() {
1738 let repo = create_test_repo();
1739 let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1740
1741 let port = mgr.allocate_port("renew-branch", "uuid-renew").unwrap();
1742 let before = mgr.port_lease("renew-branch").unwrap().expires_at;
1743
1744 state::with_state(mgr.repo_root(), None, |s| {
1746 let lease = s.port_leases.get_mut("renew-branch").unwrap();
1747 lease.expires_at = chrono::Utc::now() + chrono::Duration::hours(1);
1748 Ok(())
1749 })
1750 .unwrap();
1751
1752 mgr.renew_port_lease("renew-branch").unwrap();
1753 let after = mgr.port_lease("renew-branch").unwrap().expires_at;
1754
1755 assert!(after > before, "renew should push expires_at forward");
1756 assert_eq!(mgr.port_lease("renew-branch").unwrap().port, port);
1757 }
1758
1759 #[test]
1760 fn test_renew_port_lease_unknown_branch_errors() {
1761 let repo = create_test_repo();
1762 let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1763 let err = mgr.renew_port_lease("no-such-branch").unwrap_err();
1764 assert!(matches!(err, WorktreeError::StateCorrupted { .. }));
1765 }
1766
1767 #[test]
1768 fn test_renew_port_lease_expired_errors() {
1769 let repo = create_test_repo();
1770 let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1771
1772 mgr.allocate_port("exp-branch", "uuid-exp").unwrap();
1773 state::with_state(mgr.repo_root(), None, |s| {
1775 let lease = s.port_leases.get_mut("exp-branch").unwrap();
1776 lease.expires_at = chrono::Utc::now() - chrono::Duration::hours(1);
1777 Ok(())
1778 })
1779 .unwrap();
1780
1781 let err = mgr.renew_port_lease("exp-branch").unwrap_err();
1782 assert!(matches!(err, WorktreeError::StateCorrupted { .. }));
1783 }
1784
1785 struct TeardownProbe {
1789 teardown_called: std::sync::Arc<std::sync::atomic::AtomicBool>,
1790 }
1791
1792 impl crate::types::EcosystemAdapter for TeardownProbe {
1793 fn name(&self) -> &str { "teardown-probe" }
1794 fn detect(&self, _worktree_path: &Path) -> bool { true }
1795 fn setup(&self, _worktree_path: &Path, _source: &Path) -> Result<(), WorktreeError> {
1796 Ok(())
1797 }
1798 fn teardown(&self, _worktree_path: &Path) -> Result<(), WorktreeError> {
1799 self.teardown_called
1800 .store(true, std::sync::atomic::Ordering::SeqCst);
1801 Ok(())
1802 }
1803 }
1804
1805 #[test]
1806 fn test_delete_invokes_teardown_when_setup_completed() {
1807 let repo = create_test_repo();
1808 let called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1809 let probe = Box::new(TeardownProbe { teardown_called: called.clone() });
1810 let mgr = Manager::with_adapter(repo.path(), Config::default(), Some(probe)).unwrap();
1811
1812 let wt_path = repo.path().join("teardown-wt");
1813 let (handle, _) = mgr
1814 .create(
1815 "teardown-branch",
1816 &wt_path,
1817 CreateOptions { setup: true, ..Default::default() },
1818 )
1819 .unwrap();
1820 assert!(handle.setup_complete, "setup should have completed");
1821
1822 mgr.delete(&handle, DeleteOptions::default()).unwrap();
1823 assert!(
1824 called.load(std::sync::atomic::Ordering::SeqCst),
1825 "teardown must be called when setup_complete is true"
1826 );
1827 }
1828
1829 #[test]
1830 fn test_delete_skips_teardown_when_setup_not_completed() {
1831 let repo = create_test_repo();
1832 let called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1833 let probe = Box::new(TeardownProbe { teardown_called: called.clone() });
1834 let mgr = Manager::with_adapter(repo.path(), Config::default(), Some(probe)).unwrap();
1835
1836 let wt_path = repo.path().join("no-setup-wt");
1837 let (handle, _) = mgr
1838 .create("no-setup-branch", &wt_path, CreateOptions::default())
1839 .unwrap();
1840 assert!(!handle.setup_complete);
1841
1842 mgr.delete(&handle, DeleteOptions::default()).unwrap();
1843 assert!(
1844 !called.load(std::sync::atomic::Ordering::SeqCst),
1845 "teardown must not fire when setup never ran"
1846 );
1847 }
1848
1849 #[test]
1853 fn test_gc_preserves_old_worktree_with_live_creator_pid() {
1854 let repo = create_test_repo();
1855 let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1856
1857 let wt_path = repo.path().join("live-wt");
1858 let (handle, _) = mgr
1859 .create("live-branch", &wt_path, CreateOptions::default())
1860 .unwrap();
1861
1862 state::with_state(mgr.repo_root(), None, |s| {
1864 let entry = s.active_worktrees.get_mut(&handle.branch).unwrap();
1865 entry.created_at = chrono::Utc::now() - chrono::Duration::days(30);
1866 assert_eq!(
1867 entry.creator_pid,
1868 std::process::id(),
1869 "fixture: expected creator_pid to be this process"
1870 );
1871 Ok(())
1872 })
1873 .unwrap();
1874
1875 let report = mgr
1876 .gc(GcOptions { dry_run: true, force: true, ..Default::default() })
1877 .unwrap();
1878
1879 let canon_wt = dunce::canonicalize(&wt_path).unwrap();
1880 let is_evicted = report.evicted.iter().any(|p| {
1881 dunce::canonicalize(p).ok().as_deref() == Some(&canon_wt)
1882 });
1883 assert!(
1884 !is_evicted,
1885 "Worktree with live creator_pid must NOT be evicted, got evicted={:?}",
1886 report.evicted
1887 );
1888
1889 let _ = mgr.delete(
1891 &handle,
1892 DeleteOptions { force: true, force_dirty: true, ..Default::default() },
1893 );
1894 }
1895}