Skip to main content

iso_code/
manager.rs

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/// Check if a PID is alive via kill(pid, 0) on Unix.
15#[cfg(unix)]
16fn is_pid_alive(pid: u32) -> bool {
17    // SAFETY: kill(pid, 0) just checks existence, sends no signal.
18    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 // Conservative: assume alive on non-Unix
24}
25
26/// Calculate total disk size of a worktree tree. Thin wrapper over the shared
27/// helper so existing call sites in manager.rs don't need to change shape.
28fn calculate_dir_size(path: &Path) -> u64 {
29    util::dir_size_skipping_git([path].iter().copied())
30}
31
32/// Core worktree lifecycle manager. Entry point for all iso-code operations.
33///
34/// `Manager` is `Send` but not `Sync` — the circuit-breaker counter uses
35/// `Cell` for zero-overhead single-threaded access. To share a Manager
36/// across threads, wrap it in `Arc<Mutex<Manager>>`.
37pub struct Manager {
38    repo_root: PathBuf,
39    config: Config,
40    capabilities: GitCapabilities,
41    /// Tracks consecutive git command failures for circuit breaker.
42    consecutive_git_failures: std::cell::Cell<u32>,
43    /// Optional ecosystem adapter for post-create/attach setup (e.g., npm install).
44    adapter: Option<Box<dyn EcosystemAdapter>>,
45}
46
47impl Manager {
48    /// Construct a Manager for the given repository root.
49    ///
50    /// Construction performs, in order:
51    ///   1. Validate `git --version` is at least 2.20.
52    ///   2. Canonicalize `repo_root` and detect [`GitCapabilities`].
53    ///   3. Ensure `.git/iso-code/` exists and initialize `state.json` on first use.
54    ///   4. Scan for orphaned worktrees (non-fatal; emits warnings only).
55    ///   5. Sweep expired port leases.
56    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    /// Construct a Manager with an explicit EcosystemAdapter.
64    ///
65    /// The adapter's `setup()` method will be called after `create()` and `attach()`
66    /// when the corresponding options have `setup = true`.
67    pub fn with_adapter(
68        repo_root: impl AsRef<Path>,
69        config: Config,
70        adapter: Option<Box<dyn EcosystemAdapter>>,
71    ) -> Result<Self, WorktreeError> {
72        // Validate git, canonicalize the repo root, and probe capabilities.
73        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        // Startup orphan scan — non-fatal; surface as a warning only.
87        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        // Drop leases whose TTL elapsed while we were absent.
102        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    /// Read-modify-write state.json under the configured lock timeout.
114    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    /// Check if the circuit breaker is open (too many consecutive git failures).
127    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    /// Record a git command success — resets the failure counter.
138    fn record_git_success(&self) {
139        self.consecutive_git_failures.set(0);
140    }
141
142    /// Record a git command failure — increments the failure counter.
143    fn record_git_failure(&self) {
144        self.consecutive_git_failures.set(self.consecutive_git_failures.get() + 1);
145    }
146
147    /// Return the detected git capabilities.
148    pub fn git_capabilities(&self) -> &GitCapabilities {
149        &self.capabilities
150    }
151
152    /// Return the repository root path.
153    pub fn repo_root(&self) -> &Path {
154        &self.repo_root
155    }
156
157    /// Return the current configuration.
158    pub fn config(&self) -> &Config {
159        &self.config
160    }
161
162    /// Raw git worktree list — no state reconciliation.
163    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    /// List all worktrees, reconciling git porcelain output with state.json.
178    ///
179    /// Reconciliation runs on every call:
180    ///   1. Run `git worktree list --porcelain`.
181    ///   2. Enrich each handle with state.json metadata (created_at, session_uuid, ...).
182    ///   3. Move state entries missing from git output to `stale_worktrees`.
183    ///   4. Purge stale entries whose `expires_at` has passed.
184    ///   5. Sweep port leases: drop dead holders and expired leases.
185    pub fn list(&self) -> Result<Vec<WorktreeHandle>, WorktreeError> {
186        let mut git_worktrees = self.list_raw()?;
187
188        // Try to reconcile with state — if state read fails, just return git list
189        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        // Enrich git handles with state.json metadata. base_commit in the
198        // porcelain output is the current HEAD, but WorktreeHandle.base_commit
199        // is documented as the creation-time base — take the latter from state.
200        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        // Reconcile: move state entries not in git to stale_worktrees, sweep leases
214        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                // Move orphaned state entries to stale.
220                // Skip Pending/Creating entries: a concurrent Manager::create()
221                // may have written the entry but not yet completed
222                // `git worktree add`, so the branch legitimately isn't in git's
223                // registry yet. Evicting it here races the create() and leaves
224                // state.json pointing at the wrong bucket.
225                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                // Purge expired stale entries
264                s.stale_worktrees.retain(|_, v| v.expires_at > now);
265
266                // Sweep port leases
267                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    /// Create a new managed worktree.
279    ///
280    /// The sequence is ordered and must not be reshuffled:
281    ///   1. Run every pre-create guard.
282    ///   2. Write a `Creating` entry to state.json.
283    ///   3. Run `git worktree add`.
284    ///   4. Post-create git-crypt verification.
285    ///   5. Run [`EcosystemAdapter::setup`] if the caller requested it.
286    ///   6. Transition the entry to `Active` (or `Locked` if `options.lock`).
287    ///
288    /// If any step after `git worktree add` fails, the worktree is force-removed
289    /// with `git worktree remove --force` and the `Creating` entry is cleared
290    /// before the error propagates.
291    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        // Step 1: Run pre-create guards
301        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        // Guard 12 returns a status enum; Locked / LockedNoKey means any new
316        // worktree will inherit the encrypted blobs and git-crypt smudge
317        // filters won't run. Fail fast rather than relying on the post-create
318        // magic-byte check to catch it.
319        match crypt_status {
320            crate::types::GitCryptStatus::Locked
321            | crate::types::GitCryptStatus::LockedNoKey => {
322                return Err(WorktreeError::GitCryptLocked);
323            }
324            _ => {}
325        }
326
327        // Determine whether we'll create a new branch. This decides how base is used:
328        // - new branch: base is the starting point for `git worktree add -b branch path base`.
329        // - existing branch: `git worktree add` checks it out at its current tip. An
330        //   explicit base that doesn't match the tip is rejected so WorktreeHandle
331        //   .base_commit reflects what's actually on disk.
332        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        // Step 2: Persist a `Creating` entry so a crash before the worktree
359        // exists is still recoverable.
360        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        // Step 3: Materialize the worktree on disk.
386        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            // git worktree add may leave a half-created directory behind.
398            // Scrub it directly — calling `git worktree remove` on a path that
399            // was never registered would itself fail.
400            let _ = std::fs::remove_dir_all(&target_path);
401            // Clean up the Creating entry from state.json
402            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        // Step 4: Post-create git-crypt verification.
409        if let Err(e) = git::post_create_git_crypt_check(&target_path) {
410            // Roll back the successful `git worktree add` before surfacing the
411            // git-crypt failure, so we never leave a half-initialized worktree.
412            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        // Step 5: EcosystemAdapter::setup() if requested
420        let (adapter_name, setup_complete) = if options.setup {
421            let Some(ref adapter) = self.adapter else {
422                // setup=true but no adapter registered: fail loudly rather than silently.
423                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                    // Roll back the worktree so adapter failures don't leave a
435                    // half-configured checkout on disk.
436                    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        // Step 6: Build the handle and transition the entry to its final state.
448        let final_state = if options.lock {
449            WorktreeState::Locked
450        } else {
451            WorktreeState::Active
452        };
453
454        // Allocate port if requested
455        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        // Persist Active state to state.json
479        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    /// Attach an existing worktree (already in git's registry) under iso-code management.
511    ///
512    /// Never calls `git worktree add` — the worktree must already appear in
513    /// `git worktree list --porcelain`. If the path is already tracked in
514    /// `active_worktrees` the existing handle is returned (idempotent). If a
515    /// matching entry exists in `stale_worktrees`, its port and session_uuid
516    /// are recovered.
517    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        // Verify worktree exists in git's registry
525        let worktrees = self.list()?;
526        let git_entry = worktrees
527            .iter()
528            .find(|wt| {
529                // Compare canonicalized paths to handle symlinks/relative paths
530                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        // Run git worktree repair if available (Git >= 2.30) to fix broken gitdir links
537        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        // Try to recover session_uuid and port from stale_worktrees
546        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        // Check if already in active_worktrees (idempotent)
555        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        // Try stale recovery: look up session_uuid and port from stale_worktrees.
574        // Prefer an exact path match; only fall back to branch-name match if no
575        // path match exists. A bare `||` would return whichever iterator order
576        // surfaces first, which is non-deterministic and can donate port/UUID
577        // from an unrelated stale entry.
578        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        // EcosystemAdapter::setup() if requested
595        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        // Persist to state.json — remove only the specific recovered stale entry, add to active
626        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                // Add to active_worktrees
632                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    /// Delete a managed worktree.
661    ///
662    /// Ordered pre-flight checks (each may abort the delete):
663    ///   1. Refuse to delete the caller's current working directory.
664    ///   2. Reject a dirty working tree unless `options.force_dirty`.
665    ///   3. Reject unmerged commits unless `options.force`.
666    ///   4. Reject worktrees held by `git worktree lock`.
667    ///
668    /// Once the checks pass, the entry transitions to `Deleting`, `git worktree
669    /// remove` runs, and the entry is finally cleared from state.json.
670    pub fn delete(
671        &self,
672        handle: &WorktreeHandle,
673        options: DeleteOptions,
674    ) -> Result<(), WorktreeError> {
675        // Step 1: Not deleting CWD
676        guards::check_not_cwd(&handle.path)?;
677
678        // Step 2: Uncommitted changes check
679        if !options.force_dirty {
680            guards::check_no_uncommitted_changes(&handle.path)?;
681        }
682
683        // Step 3: Five-step unmerged commit check (skipped if force)
684        if !options.force {
685            guards::five_step_unmerged_check(&handle.branch, &self.repo_root, self.config.offline)?;
686        }
687
688        // Step 4: Not locked (skipped if force_locked).
689        if !options.force_locked {
690            guards::check_not_locked(handle)?;
691        }
692
693        // Step 5: Transition to Deleting in state.json
694        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        // Step 6: EcosystemAdapter::teardown() if setup ran during create/attach.
706        // Called before the worktree is physically removed so the adapter can
707        // still inspect files it owns. Errors are logged, not propagated —
708        // teardown failure shouldn't block the delete and leak a worktree.
709        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        // Step 7: Remove worktree
721        // Try removing .DS_Store first on macOS (it blocks git worktree remove)
722        #[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        // Steps 7-8: Transition to Deleted, release port lease, remove from active
737        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    /// Garbage collect orphaned and stale worktrees.
750    ///
751    /// The default [`GcOptions`] is dry-run. Locked worktrees are always
752    /// preserved, regardless of `options.force`. Evicted entries are moved to
753    /// `stale_worktrees` so their metadata can be recovered — `gc()` never
754    /// silently drops state.
755    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        // Get current git worktree list (source of truth).
761        let mut git_worktrees = git::run_worktree_list(&self.repo_root, &self.capabilities)?;
762
763        // Enrich handles from state.json so the age gate and PID-liveness gate
764        // below have values to read. The porcelain parser leaves created_at
765        // empty and creator_pid zero; without enrichment both gates silently
766        // skip every worktree. We do not reconcile here (that is list()'s job)
767        // so gc() stays side-effect-free until the mutation block further down.
768        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        // Parallel lists: path for reporting, branch (optional) for state lookup.
786        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        // Find orphans: worktrees that appear in git list but have broken state,
794        // or are prunable (git marked them as prunable)
795        for wt in &git_worktrees {
796            if wt.state == WorktreeState::Orphaned {
797                orphans.push(wt.path.clone());
798            }
799        }
800
801        // Also find old worktrees eligible for gc (non-main, non-locked, old enough)
802        // We skip the main worktree (no branch means bare/main) and locked ones
803        for wt in &git_worktrees {
804            // Locked worktrees are off-limits to gc, even under `force`.
805            if wt.state == WorktreeState::Locked {
806                continue;
807            }
808
809            // Skip the main worktree (empty path = main or bare)
810            if wt.branch.is_empty() {
811                continue;
812            }
813
814            // Check if worktree is old enough to be a gc candidate
815            // Parse created_at — if empty or unparseable, skip age check
816            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            // PID-liveness check: if creator_pid is still alive, skip eviction
829            if wt.state == WorktreeState::Active && wt.creator_pid != 0
830                && is_pid_alive(wt.creator_pid) {
831                continue;
832            }
833
834            // Five-step unmerged check (skip if force or orphaned)
835            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; // Has unmerged commits — skip
842            }
843
844            // Calculate disk usage before removal
845            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                // Remove .DS_Store first on macOS
853                #[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        // Call git worktree prune to clean stale git metadata (not dry_run gated)
869        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        // Projection used in the final report — path-only lists.
877        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        // Persist evictions to stale_worktrees and record GC history in state.json.
881        // Eviction never silently drops state — the entry migrates to
882        // stale_worktrees so callers can still recover ports and metadata.
883        // The same pass sweeps abandoned Creating/Pending entries whose creator
884        // is dead; these are the leftovers when create()'s cleanup path failed
885        // to reacquire the lock.
886        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                // Helper: move an active_worktrees entry to stale_worktrees by
895                // branch name (preferred) or canonicalized path fallback.
896                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                            // Transition any remaining port lease to "stale".
937                            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                    // Only persist the orphan→stale move once we've actually
949                    // removed it (orphans are just reported in dry-run).
950                    if removed_inputs.iter().any(|(p, _)| p == path) {
951                        move_to_stale(s, None, path, "gc: orphaned worktree");
952                    }
953                }
954
955                // Sweep abandoned Creating/Pending entries older than 10 minutes
956                // whose creator process is dead. These are leftovers from a
957                // create() that died between writing the Creating entry and
958                // wiring state to Active (and whose cleanup branch could not
959                // reacquire the lock).
960                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    /// Mark `branch` as recently active by bumping its `last_activity`
1019    /// timestamp in `state.json`. Callers wrap user-visible actions (shell
1020    /// into the worktree, run a build, etc.) with this so `gc()` can tell
1021    /// "idle since creation" apart from "recently used." Returns
1022    /// `InvalidStateTransition`-style `StateCorrupted` if the branch isn't
1023    /// tracked in `active_worktrees`.
1024    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    /// Return the active port lease for a branch, if any.
1041    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    /// Allocate a port lease for a branch without creating a worktree.
1054    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    /// Return the on-disk byte size of a worktree tree, skipping the `.git/`
1075    /// subtree and deduplicating hardlinks on Unix.
1076    pub fn disk_usage(&self, path: &Path) -> u64 {
1077        calculate_dir_size(path)
1078    }
1079
1080    /// Release a port lease explicitly.
1081    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    /// Extend an active port lease's TTL by another 8 hours from now.
1090    ///
1091    /// Callers running a long-lived dev server invoke this roughly every
1092    /// TTL/3 to keep the lease from expiring mid-session. Returns
1093    /// `StateCorrupted` if the branch has no active lease — expired leases
1094    /// must be re-allocated via [`Manager::allocate_port`] rather than
1095    /// renewed, since their port may have been reassigned.
1096    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    /// Create a temporary git repo for testing.
1125    fn create_test_repo() -> tempfile::TempDir {
1126        let dir = tempfile::TempDir::new().unwrap();
1127        run_git(dir.path(), &["init", "-b", "main"]);
1128        // CI runners typically have no global user.name/user.email; configure
1129        // locally so `git commit` below succeeds.
1130        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    /// Run a git command in `dir` and panic with stderr if it fails.
1137    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()); // At least the main worktree
1164    }
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        // Verify it shows up in git worktree list
1186        let list = mgr.list().unwrap();
1187        assert!(list.len() >= 2); // main + test-branch
1188
1189        // Delete it
1190        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        // Try to create a worktree for a branch that's already checked out (main)
1200        // This should fail with BranchAlreadyCheckedOut
1201        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        // Clean up — need force since it's locked
1222        let _ = git::worktree_remove_force(&mgr.repo_root, &wt_path);
1223    }
1224
1225    /// QA-R-001 companion (inline): unmerged commits without `force` is
1226    /// rejected with `UnmergedCommits`.
1227    #[test]
1228    fn test_delete_with_unmerged_commits_returns_error() {
1229        let repo = create_test_repo();
1230        let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1231
1232        // Create a worktree with a new branch
1233        let wt_path = repo.path().join("unmerged-wt");
1234        let (handle, _) = mgr
1235            .create("unmerged-branch", &wt_path, CreateOptions::default())
1236            .unwrap();
1237
1238        // Make an unmerged commit on the worktree branch
1239        Command::new("git")
1240            .args(["commit", "--allow-empty", "-m", "unmerged work"])
1241            .current_dir(&wt_path)
1242            .output()
1243            .unwrap();
1244
1245        // Attempt delete without force — should fail with UnmergedCommits
1246        let result = mgr.delete(&handle, DeleteOptions::default());
1247        assert!(result.is_err());
1248        match result.unwrap_err() {
1249            WorktreeError::UnmergedCommits { branch, commit_count } => {
1250                assert_eq!(branch, "unmerged-branch");
1251                assert!(commit_count > 0);
1252            }
1253            other => panic!("expected UnmergedCommits, got: {other}"),
1254        }
1255
1256        // Cleanup with force
1257        let _ = mgr.delete(
1258            &handle,
1259            DeleteOptions { force: true, force_dirty: true, ..Default::default() },
1260        );
1261    }
1262
1263    #[test]
1264    fn test_delete_with_force_skips_unmerged_check() {
1265        let repo = create_test_repo();
1266        let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1267
1268        let wt_path = repo.path().join("force-wt");
1269        let (handle, _) = mgr
1270            .create("force-branch", &wt_path, CreateOptions::default())
1271            .unwrap();
1272
1273        // Make an unmerged commit
1274        Command::new("git")
1275            .args(["commit", "--allow-empty", "-m", "unmerged work"])
1276            .current_dir(&wt_path)
1277            .output()
1278            .unwrap();
1279
1280        // Delete with force — should succeed despite unmerged commits
1281        let result = mgr.delete(
1282            &handle,
1283            DeleteOptions {
1284                force: true,
1285                ..Default::default()
1286            },
1287        );
1288        assert!(result.is_ok());
1289        assert!(!wt_path.exists());
1290    }
1291
1292    #[test]
1293    fn test_delete_merged_branch_succeeds() {
1294        let repo = create_test_repo();
1295        let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1296
1297        let wt_path = repo.path().join("merged-wt");
1298        let (handle, _) = mgr
1299            .create("merged-branch", &wt_path, CreateOptions::default())
1300            .unwrap();
1301
1302        // Don't make any new commits — branch is at same point as main
1303        // So merge-base --is-ancestor should return exit 0 (SAFE)
1304        let result = mgr.delete(&handle, DeleteOptions::default());
1305        assert!(result.is_ok());
1306        assert!(!wt_path.exists());
1307    }
1308
1309    /// QA-I-005 companion (inline): a locked worktree is refused by `delete()`
1310    /// unless `force_locked` is set.
1311    #[test]
1312    fn test_delete_locked_worktree_returns_error() {
1313        let repo = create_test_repo();
1314        let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1315
1316        let wt_path = repo.path().join("locked-del-wt");
1317        let opts = CreateOptions {
1318            lock: true,
1319            lock_reason: Some("important work".to_string()),
1320            ..Default::default()
1321        };
1322        let (handle, _) = mgr.create("locked-del-branch", &wt_path, opts).unwrap();
1323
1324        // Try to delete locked worktree — should fail
1325        let result = mgr.delete(&handle, DeleteOptions::default());
1326        assert!(result.is_err());
1327        assert!(matches!(
1328            result.unwrap_err(),
1329            WorktreeError::WorktreeLocked { .. }
1330        ));
1331
1332        // Cleanup
1333        let _ = git::worktree_remove_force(&mgr.repo_root, &wt_path);
1334    }
1335
1336    // ── attach() tests ──────────────────────────────────────────────────
1337
1338    #[test]
1339    fn test_attach_manually_created_worktree() {
1340        let repo = create_test_repo();
1341        let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1342
1343        // Create a worktree manually via git (outside iso-code)
1344        let wt_path = repo.path().join("manual-wt");
1345        let output = Command::new("git")
1346            .args(["worktree", "add", wt_path.to_str().unwrap(), "-b", "manual-branch"])
1347            .current_dir(repo.path())
1348            .output()
1349            .unwrap();
1350        assert!(output.status.success(), "git worktree add failed: {}", String::from_utf8_lossy(&output.stderr));
1351
1352        // Attach it via Manager
1353        let handle = mgr.attach(&wt_path, AttachOptions::default()).unwrap();
1354
1355        assert_eq!(handle.branch, "manual-branch");
1356        assert!(!handle.base_commit.is_empty());
1357        assert!(!handle.session_uuid.is_empty());
1358        assert!(handle.creator_pid > 0);
1359        assert_eq!(handle.state, WorktreeState::Active);
1360
1361        // Verify it appears in list
1362        let list = mgr.list().unwrap();
1363        assert!(list.iter().any(|wt| {
1364            dunce::canonicalize(&wt.path).ok() == dunce::canonicalize(&wt_path).ok()
1365        }));
1366
1367        // Clean up
1368        let _ = git::worktree_remove_force(&mgr.repo_root, &wt_path);
1369    }
1370
1371    #[test]
1372    fn test_attach_nonexistent_path_errors() {
1373        let repo = create_test_repo();
1374        let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1375
1376        let bad_path = repo.path().join("does-not-exist");
1377        let result = mgr.attach(&bad_path, AttachOptions::default());
1378        assert!(result.is_err());
1379    }
1380
1381    #[test]
1382    fn test_attach_path_not_in_git_registry_errors() {
1383        let repo = create_test_repo();
1384        let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1385
1386        // Create a regular directory (not a git worktree)
1387        let dir_path = repo.path().join("just-a-dir");
1388        std::fs::create_dir_all(&dir_path).unwrap();
1389
1390        let result = mgr.attach(&dir_path, AttachOptions::default());
1391        assert!(result.is_err());
1392        // Ensure it's specifically the WorktreeNotInGitRegistry error
1393        let err = result.unwrap_err();
1394        assert!(
1395            err.to_string().contains("not found in git registry"),
1396            "Expected WorktreeNotInGitRegistry, got: {err}"
1397        );
1398    }
1399
1400    #[test]
1401    fn test_attach_idempotent() {
1402        let repo = create_test_repo();
1403        let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1404
1405        // Create a worktree manually
1406        let wt_path = repo.path().join("idempotent-wt");
1407        let output = Command::new("git")
1408            .args(["worktree", "add", wt_path.to_str().unwrap(), "-b", "idem-branch"])
1409            .current_dir(repo.path())
1410            .output()
1411            .unwrap();
1412        assert!(output.status.success());
1413
1414        // Attach twice — both should succeed
1415        let handle1 = mgr.attach(&wt_path, AttachOptions::default()).unwrap();
1416        let handle2 = mgr.attach(&wt_path, AttachOptions::default()).unwrap();
1417
1418        assert_eq!(handle1.branch, handle2.branch);
1419        assert_eq!(handle1.base_commit, handle2.base_commit);
1420        assert_eq!(
1421            dunce::canonicalize(&handle1.path).unwrap(),
1422            dunce::canonicalize(&handle2.path).unwrap()
1423        );
1424
1425        // Clean up
1426        let _ = git::worktree_remove_force(&mgr.repo_root, &wt_path);
1427    }
1428
1429    #[test]
1430    fn test_attach_after_create_and_delete() {
1431        // Simulate: create via Manager, delete, re-create manually, then attach
1432        let repo = create_test_repo();
1433        let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1434
1435        let wt_path = repo.path().join("reattach-wt");
1436
1437        // Create and delete via Manager
1438        let (handle, _) = mgr
1439            .create("reattach-branch", &wt_path, CreateOptions::default())
1440            .unwrap();
1441        mgr.delete(&handle, DeleteOptions::default()).unwrap();
1442        assert!(!wt_path.exists());
1443
1444        // Re-create manually via git
1445        let output = Command::new("git")
1446            .args(["worktree", "add", wt_path.to_str().unwrap(), "reattach-branch"])
1447            .current_dir(repo.path())
1448            .output()
1449            .unwrap();
1450        assert!(output.status.success(), "git worktree add failed: {}", String::from_utf8_lossy(&output.stderr));
1451
1452        // Attach — should succeed with fresh session_uuid
1453        let attached = mgr.attach(&wt_path, AttachOptions::default()).unwrap();
1454        assert_eq!(attached.branch, "reattach-branch");
1455        assert!(!attached.session_uuid.is_empty());
1456
1457        // Clean up
1458        let _ = git::worktree_remove_force(&mgr.repo_root, &wt_path);
1459    }
1460
1461    // ── gc() tests ────────────────────────────────────────────────────────
1462
1463    #[test]
1464    fn test_gc_dry_run_returns_report_without_deleting() {
1465        let repo = create_test_repo();
1466        let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1467
1468        let wt_path = repo.path().join("gc-test-wt");
1469        let (handle, _) = mgr
1470            .create("gc-branch", &wt_path, CreateOptions::default())
1471            .unwrap();
1472
1473        // dry_run = true (default) — should not delete anything
1474        let report = mgr.gc(GcOptions::default()).unwrap();
1475        assert!(report.dry_run);
1476        assert!(report.removed.is_empty());
1477
1478        // Worktree should still exist
1479        assert!(wt_path.exists());
1480
1481        // Cleanup
1482        mgr.delete(&handle, DeleteOptions { force: true, ..Default::default() }).unwrap();
1483    }
1484
1485    /// QA-I-005 / PRD Appendix A rule 13 (inline): locked worktrees are
1486    /// exempt from GC regardless of `force`.
1487    #[test]
1488    fn test_gc_locked_worktree_never_touched() {
1489        let repo = create_test_repo();
1490        let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1491
1492        let wt_path = repo.path().join("gc-locked-wt");
1493        let opts = CreateOptions {
1494            lock: true,
1495            ..Default::default()
1496        };
1497        let (_handle, _) = mgr.create("gc-locked-branch", &wt_path, opts).unwrap();
1498
1499        // GC with force=true — a locked worktree must still survive.
1500        let report = mgr
1501            .gc(GcOptions { dry_run: false, force: true, ..Default::default() })
1502            .unwrap();
1503
1504        // The locked worktree must NOT appear in removed or evicted
1505        assert!(!report.removed.iter().any(|p| p == &wt_path));
1506        assert!(!report.evicted.iter().any(|p| p == &wt_path));
1507
1508        // Worktree still exists
1509        assert!(wt_path.exists());
1510
1511        // Cleanup
1512        let _ = git::worktree_remove_force(&mgr.repo_root, &wt_path);
1513    }
1514
1515    #[test]
1516    fn test_gc_default_is_dry_run() {
1517        assert!(GcOptions::default().dry_run);
1518    }
1519
1520    // ── Reconciliation and gc regression tests ────────────────────────────
1521
1522    /// `list()` reconciliation must not evict in-flight `Creating` entries.
1523    /// Simulates the window during `Manager::create()` between writing the
1524    /// `Creating` entry to state.json and `git worktree add` completing — a
1525    /// concurrent `list()` must leave that entry alone.
1526    #[test]
1527    fn test_list_preserves_creating_entry_during_reconcile() {
1528        let repo = create_test_repo();
1529        let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1530
1531        // Inject a Creating entry for a branch not in git's registry yet.
1532        state::with_state(mgr.repo_root(), None, |s| {
1533            s.active_worktrees.insert(
1534                "in-flight-branch".to_string(),
1535                ActiveWorktreeEntry {
1536                    path: repo.path().join("in-flight").to_string_lossy().to_string(),
1537                    branch: "in-flight-branch".to_string(),
1538                    base_commit: "a".repeat(40),
1539                    state: WorktreeState::Creating,
1540                    created_at: chrono::Utc::now(),
1541                    last_activity: Some(chrono::Utc::now()),
1542                    creator_pid: std::process::id(),
1543                    creator_name: "test".to_string(),
1544                    session_uuid: "uuid-in-flight".to_string(),
1545                    adapter: None,
1546                    setup_complete: false,
1547                    port: None,
1548                    extra: std::collections::HashMap::new(),
1549                },
1550            );
1551            Ok(())
1552        })
1553        .unwrap();
1554
1555        // list() triggers reconciliation against git output. The Creating
1556        // entry is absent from git but must survive.
1557        let _ = mgr.list().unwrap();
1558
1559        let state_after = state::read_state(mgr.repo_root(), None).unwrap();
1560        assert!(
1561            state_after.active_worktrees.contains_key("in-flight-branch"),
1562            "Creating entry must remain in active_worktrees after list() reconciliation"
1563        );
1564        assert!(
1565            !state_after.stale_worktrees.contains_key("in-flight-branch"),
1566            "Creating entry must NOT be moved to stale_worktrees: {:?}",
1567            state_after.stale_worktrees.keys().collect::<Vec<_>>()
1568        );
1569    }
1570
1571    /// `gc()` must evict worktrees older than `gc_max_age_days` once their
1572    /// `creator_pid` is no longer alive. The age gate relies on `created_at`
1573    /// being enriched from state.json — without that, git porcelain output
1574    /// alone lacks the timestamp and no worktree ever ages out.
1575    #[test]
1576    fn test_gc_evicts_old_worktree_with_dead_creator_pid() {
1577        let repo = create_test_repo();
1578        let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1579
1580        let wt_path = repo.path().join("aged-wt");
1581        let (handle, _) = mgr
1582            .create("aged-branch", &wt_path, CreateOptions::default())
1583            .unwrap();
1584
1585        // Backdate the worktree and mark the creator as dead.
1586        state::with_state(mgr.repo_root(), None, |s| {
1587            let entry = s.active_worktrees.get_mut(&handle.branch).unwrap();
1588            entry.created_at = chrono::Utc::now() - chrono::Duration::days(30);
1589            entry.creator_pid = 99_999_999; // definitely-dead PID
1590            Ok(())
1591        })
1592        .unwrap();
1593
1594        let report = mgr
1595            .gc(GcOptions { dry_run: true, force: true, ..Default::default() })
1596            .unwrap();
1597
1598        let canon_wt = dunce::canonicalize(&wt_path).unwrap();
1599        let is_evicted = report.evicted.iter().any(|p| {
1600            dunce::canonicalize(p).ok().as_deref() == Some(&canon_wt)
1601        });
1602        assert!(
1603            is_evicted,
1604            "Old worktree with dead creator_pid must be evicted by gc(), got evicted={:?}",
1605            report.evicted
1606        );
1607
1608        // Cleanup
1609        let _ = mgr.delete(
1610            &handle,
1611            DeleteOptions { force: true, force_dirty: true, ..Default::default() },
1612        );
1613    }
1614
1615    #[test]
1616    fn test_touch_updates_last_activity() {
1617        let repo = create_test_repo();
1618        let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1619
1620        let wt_path = repo.path().join("touch-wt");
1621        let (handle, _) = mgr
1622            .create("touch-branch", &wt_path, CreateOptions::default())
1623            .unwrap();
1624
1625        // Backdate last_activity so touch() has room to move it forward.
1626        state::with_state(mgr.repo_root(), None, |s| {
1627            let e = s.active_worktrees.get_mut(&handle.branch).unwrap();
1628            e.last_activity = Some(chrono::Utc::now() - chrono::Duration::days(3));
1629            Ok(())
1630        })
1631        .unwrap();
1632
1633        mgr.touch(&handle.branch).unwrap();
1634        let after = state::read_state(mgr.repo_root(), None).unwrap();
1635        let entry = after.active_worktrees.get(&handle.branch).unwrap();
1636        let la = entry.last_activity.unwrap();
1637        assert!(chrono::Utc::now() - la < chrono::Duration::seconds(5));
1638
1639        mgr.delete(&handle, DeleteOptions { force: true, ..Default::default() })
1640            .unwrap();
1641    }
1642
1643    #[test]
1644    fn test_touch_unknown_branch_errors() {
1645        let repo = create_test_repo();
1646        let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1647        let result = mgr.touch("never-created");
1648        assert!(matches!(result, Err(WorktreeError::StateCorrupted { .. })));
1649    }
1650
1651    /// gc() must sweep abandoned Creating/Pending entries whose creator is
1652    /// dead and whose created_at is older than the 10-minute grace window.
1653    /// These are leftovers from a crashed create() whose cleanup path failed
1654    /// to re-acquire the lock.
1655    #[test]
1656    fn test_gc_sweeps_abandoned_creating_entries() {
1657        let repo = create_test_repo();
1658        let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1659
1660        // Inject an abandoned Creating entry with a dead PID and old timestamp.
1661        state::with_state(mgr.repo_root(), None, |s| {
1662            s.active_worktrees.insert(
1663                "abandoned-branch".to_string(),
1664                ActiveWorktreeEntry {
1665                    path: "/tmp/abandoned-wt".to_string(),
1666                    branch: "abandoned-branch".to_string(),
1667                    base_commit: "a".repeat(40),
1668                    state: WorktreeState::Creating,
1669                    created_at: chrono::Utc::now() - chrono::Duration::hours(1),
1670                    last_activity: None,
1671                    creator_pid: 99_999_999, // dead
1672                    creator_name: "test".to_string(),
1673                    session_uuid: "uuid-abandoned".to_string(),
1674                    adapter: None,
1675                    setup_complete: false,
1676                    port: None,
1677                    extra: std::collections::HashMap::new(),
1678                },
1679            );
1680            Ok(())
1681        })
1682        .unwrap();
1683
1684        // Even a dry_run=false gc triggers the sweep. The entry isn't in git's
1685        // list so it isn't evicted the normal way — only the sweep reaches it.
1686        let _ = mgr
1687            .gc(GcOptions { dry_run: false, force: true, ..Default::default() })
1688            .unwrap();
1689
1690        let after = state::read_state(mgr.repo_root(), None).unwrap();
1691        assert!(
1692            !after.active_worktrees.contains_key("abandoned-branch"),
1693            "abandoned Creating entry must be removed from active_worktrees"
1694        );
1695        assert!(
1696            after.stale_worktrees.contains_key("abandoned-branch"),
1697            "abandoned Creating entry must land in stale_worktrees"
1698        );
1699        let stale = &after.stale_worktrees["abandoned-branch"];
1700        assert_eq!(stale.eviction_reason, "gc: abandoned Creating entry");
1701    }
1702
1703    /// gc() evicting a worktree must transition its port lease to "stale".
1704    #[test]
1705    fn test_gc_transitions_port_lease_to_stale() {
1706        let repo = create_test_repo();
1707        let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1708
1709        let wt_path = repo.path().join("lease-wt");
1710        let (handle, _) = mgr
1711            .create(
1712                "lease-branch",
1713                &wt_path,
1714                CreateOptions { allocate_port: true, ..Default::default() },
1715            )
1716            .unwrap();
1717        assert!(handle.port.is_some());
1718
1719        // Backdate + kill the creator so gc evicts.
1720        state::with_state(mgr.repo_root(), None, |s| {
1721            let entry = s.active_worktrees.get_mut(&handle.branch).unwrap();
1722            entry.created_at = chrono::Utc::now() - chrono::Duration::days(30);
1723            entry.creator_pid = 99_999_999;
1724            Ok(())
1725        })
1726        .unwrap();
1727
1728        let _ = mgr
1729            .gc(GcOptions { dry_run: false, force: true, ..Default::default() })
1730            .unwrap();
1731
1732        let after = state::read_state(mgr.repo_root(), None).unwrap();
1733        let lease = after
1734            .port_leases
1735            .get("lease-branch")
1736            .expect("lease should survive eviction with stale status");
1737        assert_eq!(lease.status, "stale");
1738    }
1739
1740    // ── Port lease renewal ────────────────────────────────────────────────
1741
1742    #[test]
1743    fn test_renew_port_lease_extends_expiry() {
1744        let repo = create_test_repo();
1745        let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1746
1747        let port = mgr.allocate_port("renew-branch", "uuid-renew").unwrap();
1748        let before = mgr.port_lease("renew-branch").unwrap().expires_at;
1749
1750        // Backdate the lease so renewal produces an observable change.
1751        state::with_state(mgr.repo_root(), None, |s| {
1752            let lease = s.port_leases.get_mut("renew-branch").unwrap();
1753            lease.expires_at = chrono::Utc::now() + chrono::Duration::hours(1);
1754            Ok(())
1755        })
1756        .unwrap();
1757
1758        mgr.renew_port_lease("renew-branch").unwrap();
1759        let after = mgr.port_lease("renew-branch").unwrap().expires_at;
1760
1761        assert!(after > before, "renew should push expires_at forward");
1762        assert_eq!(mgr.port_lease("renew-branch").unwrap().port, port);
1763    }
1764
1765    #[test]
1766    fn test_renew_port_lease_unknown_branch_errors() {
1767        let repo = create_test_repo();
1768        let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1769        let err = mgr.renew_port_lease("no-such-branch").unwrap_err();
1770        assert!(matches!(err, WorktreeError::StateCorrupted { .. }));
1771    }
1772
1773    #[test]
1774    fn test_renew_port_lease_expired_errors() {
1775        let repo = create_test_repo();
1776        let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1777
1778        mgr.allocate_port("exp-branch", "uuid-exp").unwrap();
1779        // Backdate the lease past its expiry
1780        state::with_state(mgr.repo_root(), None, |s| {
1781            let lease = s.port_leases.get_mut("exp-branch").unwrap();
1782            lease.expires_at = chrono::Utc::now() - chrono::Duration::hours(1);
1783            Ok(())
1784        })
1785        .unwrap();
1786
1787        let err = mgr.renew_port_lease("exp-branch").unwrap_err();
1788        assert!(matches!(err, WorktreeError::StateCorrupted { .. }));
1789    }
1790
1791    // ── EcosystemAdapter teardown wiring ──────────────────────────────────
1792
1793    /// A probe adapter that records whether teardown was invoked.
1794    struct TeardownProbe {
1795        teardown_called: std::sync::Arc<std::sync::atomic::AtomicBool>,
1796    }
1797
1798    impl crate::types::EcosystemAdapter for TeardownProbe {
1799        fn name(&self) -> &str { "teardown-probe" }
1800        fn detect(&self, _worktree_path: &Path) -> bool { true }
1801        fn setup(&self, _worktree_path: &Path, _source: &Path) -> Result<(), WorktreeError> {
1802            Ok(())
1803        }
1804        fn teardown(&self, _worktree_path: &Path) -> Result<(), WorktreeError> {
1805            self.teardown_called
1806                .store(true, std::sync::atomic::Ordering::SeqCst);
1807            Ok(())
1808        }
1809    }
1810
1811    #[test]
1812    fn test_delete_invokes_teardown_when_setup_completed() {
1813        let repo = create_test_repo();
1814        let called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1815        let probe = Box::new(TeardownProbe { teardown_called: called.clone() });
1816        let mgr = Manager::with_adapter(repo.path(), Config::default(), Some(probe)).unwrap();
1817
1818        let wt_path = repo.path().join("teardown-wt");
1819        let (handle, _) = mgr
1820            .create(
1821                "teardown-branch",
1822                &wt_path,
1823                CreateOptions { setup: true, ..Default::default() },
1824            )
1825            .unwrap();
1826        assert!(handle.setup_complete, "setup should have completed");
1827
1828        mgr.delete(&handle, DeleteOptions::default()).unwrap();
1829        assert!(
1830            called.load(std::sync::atomic::Ordering::SeqCst),
1831            "teardown must be called when setup_complete is true"
1832        );
1833    }
1834
1835    #[test]
1836    fn test_delete_skips_teardown_when_setup_not_completed() {
1837        let repo = create_test_repo();
1838        let called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1839        let probe = Box::new(TeardownProbe { teardown_called: called.clone() });
1840        let mgr = Manager::with_adapter(repo.path(), Config::default(), Some(probe)).unwrap();
1841
1842        let wt_path = repo.path().join("no-setup-wt");
1843        let (handle, _) = mgr
1844            .create("no-setup-branch", &wt_path, CreateOptions::default())
1845            .unwrap();
1846        assert!(!handle.setup_complete);
1847
1848        mgr.delete(&handle, DeleteOptions::default()).unwrap();
1849        assert!(
1850            !called.load(std::sync::atomic::Ordering::SeqCst),
1851            "teardown must not fire when setup never ran"
1852        );
1853    }
1854
1855    /// `gc()` must preserve worktrees whose `creator_pid` is still alive, even
1856    /// once the entry is older than `gc_max_age_days`. This guards against
1857    /// evicting a worktree another process is actively using.
1858    #[test]
1859    fn test_gc_preserves_old_worktree_with_live_creator_pid() {
1860        let repo = create_test_repo();
1861        let mgr = Manager::new(repo.path(), Config::default()).unwrap();
1862
1863        let wt_path = repo.path().join("live-wt");
1864        let (handle, _) = mgr
1865            .create("live-branch", &wt_path, CreateOptions::default())
1866            .unwrap();
1867
1868        // Backdate the worktree but keep creator_pid = this process (alive).
1869        state::with_state(mgr.repo_root(), None, |s| {
1870            let entry = s.active_worktrees.get_mut(&handle.branch).unwrap();
1871            entry.created_at = chrono::Utc::now() - chrono::Duration::days(30);
1872            assert_eq!(
1873                entry.creator_pid,
1874                std::process::id(),
1875                "fixture: expected creator_pid to be this process"
1876            );
1877            Ok(())
1878        })
1879        .unwrap();
1880
1881        let report = mgr
1882            .gc(GcOptions { dry_run: true, force: true, ..Default::default() })
1883            .unwrap();
1884
1885        let canon_wt = dunce::canonicalize(&wt_path).unwrap();
1886        let is_evicted = report.evicted.iter().any(|p| {
1887            dunce::canonicalize(p).ok().as_deref() == Some(&canon_wt)
1888        });
1889        assert!(
1890            !is_evicted,
1891            "Worktree with live creator_pid must NOT be evicted, got evicted={:?}",
1892            report.evicted
1893        );
1894
1895        // Cleanup
1896        let _ = mgr.delete(
1897            &handle,
1898            DeleteOptions { force: true, force_dirty: true, ..Default::default() },
1899        );
1900    }
1901}