Skip to main content

atman_runtime/
git_workspace.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use fs2::FileExt;
6use serde::{Deserialize, Serialize};
7
8use crate::git::{GitCli, GitError};
9
10#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
11pub enum WorkspacePolicy {
12    #[default]
13    None,
14    Auto,
15    Retain,
16}
17
18impl std::str::FromStr for WorkspacePolicy {
19    type Err = WorkspaceError;
20
21    fn from_str(value: &str) -> Result<Self, Self::Err> {
22        match value {
23            "none" => Ok(Self::None),
24            "auto" => Ok(Self::Auto),
25            "retain" => Ok(Self::Retain),
26            other => Err(WorkspaceError::Invalid(format!(
27                "unknown workspace policy {other:?}; expected none, auto, or retain"
28            ))),
29        }
30    }
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum WorkspaceState {
35    Allocating,
36    Active,
37    TerminalPending,
38    Retained,
39    Dirty,
40    Orphaned,
41    Released,
42    Unknown(String),
43}
44
45impl WorkspaceState {
46    pub fn parse(value: &str) -> Self {
47        match value {
48            "allocating" => Self::Allocating,
49            "active" => Self::Active,
50            "terminal_pending" => Self::TerminalPending,
51            "retained" => Self::Retained,
52            "dirty" => Self::Dirty,
53            "orphaned" => Self::Orphaned,
54            "released" => Self::Released,
55            other => Self::Unknown(other.to_owned()),
56        }
57    }
58
59    pub fn as_str(&self) -> &str {
60        match self {
61            Self::Allocating => "allocating",
62            Self::Active => "active",
63            Self::TerminalPending => "terminal_pending",
64            Self::Retained => "retained",
65            Self::Dirty => "dirty",
66            Self::Orphaned => "orphaned",
67            Self::Released => "released",
68            Self::Unknown(value) => value,
69        }
70    }
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
74pub struct WorkspaceBinding {
75    pub workspace_id: String,
76    pub path: PathBuf,
77    pub repository_root: PathBuf,
78    pub branch: Option<String>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
82pub struct WorkspaceLease {
83    pub daemon_generation: String,
84    pub acquired_at: chrono::DateTime<chrono::Utc>,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum WorkspaceFinalizeOutcome {
89    Released(WorkspaceRecord),
90    Dirty(WorkspaceRecord),
91    Retained(WorkspaceRecord),
92    AlreadyReleased(WorkspaceRecord),
93}
94
95const REGISTRY_VERSION: u32 = 1;
96
97#[derive(Debug, thiserror::Error)]
98pub enum WorkspaceError {
99    #[error("git: {0}")]
100    Git(#[from] GitError),
101    #[error("workspace I/O: {0}")]
102    Io(#[from] std::io::Error),
103    #[error("workspace registry: {0}")]
104    Json(#[from] serde_json::Error),
105    #[error("invalid workspace: {0}")]
106    Invalid(String),
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
110pub struct WorkspaceRecord {
111    pub id: String,
112    pub repository_root: PathBuf,
113    pub worktree_path: PathBuf,
114    pub branch: Option<String>,
115    pub owner_session: Option<String>,
116    pub owner_flow: Option<String>,
117    pub state: String,
118    pub retained: bool,
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub allocation_base: Option<String>,
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub allocation_policy: Option<String>,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub lease: Option<WorkspaceLease>,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub reconciled_at: Option<chrono::DateTime<chrono::Utc>>,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub reconciliation_reason: Option<String>,
129}
130
131impl WorkspaceRecord {
132    pub fn lifecycle_state(&self) -> WorkspaceState {
133        WorkspaceState::parse(&self.state)
134    }
135
136    pub fn binding(&self) -> WorkspaceBinding {
137        WorkspaceBinding {
138            workspace_id: self.id.clone(),
139            path: self.worktree_path.clone(),
140            repository_root: self.repository_root.clone(),
141            branch: self.branch.clone(),
142        }
143    }
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147struct WorkspaceRegistry {
148    version: u32,
149    workspaces: Vec<WorkspaceRecord>,
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153enum AllocationStage {
154    BeforeWorktreeAdd,
155    AfterWorktreeAdd,
156    BeforeFinalSave,
157}
158
159pub struct WorkspaceManager {
160    repository_root: PathBuf,
161    managed_root: PathBuf,
162    registry_path: PathBuf,
163    registry_lock_path: PathBuf,
164}
165
166impl WorkspaceManager {
167    pub fn at(cwd: &Path, external_root: Option<&Path>) -> Result<Self, WorkspaceError> {
168        let (manager, common, is_bare) = Self::resolve(cwd, external_root)?;
169        fs::create_dir_all(&manager.managed_root)?;
170        let ignore = manager.managed_root.join(".gitignore");
171        if !ignore.exists() {
172            fs::write(&ignore, "*\n")?;
173        }
174        if !is_bare {
175            let exclude = common.join("info").join("exclude");
176            fs::create_dir_all(exclude.parent().expect("exclude parent"))?;
177            add_exclude(&exclude, "/.atman/worktrees/")?;
178        }
179        Ok(manager)
180    }
181
182    pub fn open_existing(
183        cwd: &Path,
184        external_root: Option<&Path>,
185    ) -> Result<Option<Self>, WorkspaceError> {
186        let (manager, _, _) = Self::resolve(cwd, external_root)?;
187        Ok(manager.registry_path.exists().then_some(manager))
188    }
189
190    fn resolve(
191        cwd: &Path,
192        external_root: Option<&Path>,
193    ) -> Result<(Self, PathBuf, bool), WorkspaceError> {
194        let git_common = git_output(cwd, &["rev-parse", "--git-common-dir"])?;
195        let common = canonicalize_from(cwd, Path::new(git_common.trim()));
196        let is_bare = git_output(cwd, &["rev-parse", "--is-bare-repository"])?.trim() == "true";
197        let repository_root = if is_bare {
198            common.clone()
199        } else {
200            common
201                .parent()
202                .ok_or_else(|| {
203                    WorkspaceError::Invalid("git common directory has no parent".into())
204                })?
205                .to_path_buf()
206        };
207        let storage_root = if is_bare {
208            external_root
209                .ok_or_else(|| {
210                    WorkspaceError::Invalid("bare repositories require external_root".into())
211                })?
212                .canonicalize()?
213        } else {
214            repository_root.clone()
215        };
216        Ok((
217            Self {
218                repository_root,
219                managed_root: storage_root.join(".atman").join("worktrees"),
220                registry_path: storage_root.join(".atman").join("workspaces.json"),
221                registry_lock_path: storage_root.join(".atman").join("workspaces.lock"),
222            },
223            common,
224            is_bare,
225        ))
226    }
227
228    pub fn repository_root(&self) -> &Path {
229        &self.repository_root
230    }
231    pub fn managed_root(&self) -> &Path {
232        &self.managed_root
233    }
234
235    pub fn create(
236        &self,
237        id: &str,
238        branch: Option<&str>,
239        base: Option<&str>,
240        create_branch: bool,
241        owner_session: Option<&str>,
242        owner_flow: Option<&str>,
243    ) -> Result<WorkspaceRecord, WorkspaceError> {
244        validate_id(id)?;
245        let _lock = self.lock_registry()?;
246        let mut registry = self.load()?;
247        if let Some(existing) = registry.workspaces.iter().find(|w| w.id == id) {
248            return Ok(existing.clone());
249        }
250        let path = self.managed_root.join(id);
251        ensure_inside(&path, &self.managed_root)?;
252        let record = GitCli::at(&self.repository_root).worktree_add(
253            &path,
254            branch,
255            base,
256            create_branch,
257            false,
258        )?;
259        let item = WorkspaceRecord {
260            id: id.into(),
261            repository_root: self.repository_root.clone(),
262            worktree_path: canonicalize(&record.path),
263            branch: record.branch,
264            owner_session: owner_session.map(str::to_owned),
265            owner_flow: owner_flow.map(str::to_owned),
266            state: WorkspaceState::Active.as_str().into(),
267            retained: false,
268            allocation_base: base.map(str::to_owned),
269            allocation_policy: None,
270            lease: None,
271            reconciled_at: None,
272            reconciliation_reason: None,
273        };
274        registry.workspaces.push(item.clone());
275        self.save(&registry)?;
276        Ok(item)
277    }
278
279    pub fn create_managed(
280        &self,
281        id: &str,
282        owner_session: &str,
283        owner_flow: &str,
284        daemon_generation: &str,
285        retained: bool,
286        base_oid: &str,
287    ) -> Result<WorkspaceRecord, WorkspaceError> {
288        self.create_managed_with_hook(
289            id,
290            owner_session,
291            owner_flow,
292            daemon_generation,
293            retained,
294            base_oid,
295            |_| Ok(()),
296        )
297    }
298
299    #[allow(clippy::too_many_arguments)]
300    fn create_managed_with_hook(
301        &self,
302        id: &str,
303        owner_session: &str,
304        owner_flow: &str,
305        daemon_generation: &str,
306        retained: bool,
307        base_oid: &str,
308        mut hook: impl FnMut(AllocationStage) -> Result<(), WorkspaceError>,
309    ) -> Result<WorkspaceRecord, WorkspaceError> {
310        if owner_session.is_empty() || owner_flow.is_empty() || daemon_generation.is_empty() {
311            return Err(WorkspaceError::Invalid(
312                "managed workspace ownership and daemon generation must be non-empty".into(),
313            ));
314        }
315        validate_id(id)?;
316        let _lock = self.lock_registry()?;
317        let mut registry = self.load()?;
318        if let Some(item) = registry.workspaces.iter().find(|item| item.id == id) {
319            validate_ownership(item, Some(owner_session), Some(owner_flow))?;
320            let same_generation = item
321                .lease
322                .as_ref()
323                .is_some_and(|lease| lease.daemon_generation == daemon_generation);
324            if item.lifecycle_state() == WorkspaceState::Active
325                && item.retained == retained
326                && same_generation
327            {
328                return Ok(item.clone());
329            }
330            return Err(WorkspaceError::Invalid(format!(
331                "workspace {id} already exists with a different lifecycle lease"
332            )));
333        }
334
335        let path = self.managed_root.join(id);
336        ensure_inside(&path, &self.managed_root)?;
337        let item = WorkspaceRecord {
338            id: id.into(),
339            repository_root: self.repository_root.clone(),
340            worktree_path: path.clone(),
341            branch: None,
342            owner_session: Some(owner_session.to_owned()),
343            owner_flow: Some(owner_flow.to_owned()),
344            state: WorkspaceState::Allocating.as_str().into(),
345            retained,
346            allocation_base: Some(base_oid.to_owned()),
347            allocation_policy: Some(if retained { "retain" } else { "auto" }.into()),
348            lease: Some(WorkspaceLease {
349                daemon_generation: daemon_generation.to_owned(),
350                acquired_at: chrono::Utc::now(),
351            }),
352            reconciled_at: None,
353            reconciliation_reason: None,
354        };
355        registry.workspaces.push(item);
356        self.save(&registry)?;
357        hook(AllocationStage::BeforeWorktreeAdd)?;
358
359        let worktree = match GitCli::at(&self.repository_root).worktree_add(
360            &path,
361            None,
362            Some(base_oid),
363            false,
364            false,
365        ) {
366            Ok(worktree) => worktree,
367            Err(error) => {
368                let index = registry.workspaces.len() - 1;
369                let registered = GitCli::at(&self.repository_root)
370                    .worktree_list()
371                    .map(|worktrees| {
372                        worktrees
373                            .iter()
374                            .any(|worktree| canonicalize(&worktree.path) == canonicalize(&path))
375                    })
376                    .unwrap_or(true);
377                let record = &mut registry.workspaces[index];
378                record.reconciled_at = Some(chrono::Utc::now());
379                if !path.exists() && !registered {
380                    record.state = WorkspaceState::Released.as_str().into();
381                    record.lease = None;
382                    record.reconciliation_reason = Some(format!(
383                        "allocation failed before creating worktree: {error}"
384                    ));
385                } else {
386                    record.state = WorkspaceState::Orphaned.as_str().into();
387                    record.reconciliation_reason = Some(format!(
388                        "allocation failed with residual path or Git registration: {error}"
389                    ));
390                }
391                self.save(&registry)?;
392                return Err(error.into());
393            }
394        };
395        hook(AllocationStage::AfterWorktreeAdd)?;
396
397        let index = registry.workspaces.len() - 1;
398        registry.workspaces[index].worktree_path = canonicalize(&worktree.path);
399        registry.workspaces[index].branch = worktree.branch;
400        registry.workspaces[index].state = WorkspaceState::Active.as_str().into();
401        hook(AllocationStage::BeforeFinalSave)?;
402        self.save(&registry)?;
403        Ok(registry.workspaces[index].clone())
404    }
405
406    pub fn finalize_managed(
407        &self,
408        id: &str,
409        owner_session: &str,
410        owner_flow: &str,
411    ) -> Result<WorkspaceFinalizeOutcome, WorkspaceError> {
412        let _lock = self.lock_registry()?;
413        let mut registry = self.load()?;
414        let index = registry
415            .workspaces
416            .iter()
417            .position(|item| item.id == id)
418            .ok_or_else(|| WorkspaceError::Invalid(format!("workspace {id} not found")))?;
419        validate_ownership(
420            &registry.workspaces[index],
421            Some(owner_session),
422            Some(owner_flow),
423        )?;
424
425        let state = registry.workspaces[index].lifecycle_state();
426        if state == WorkspaceState::Released {
427            return Ok(WorkspaceFinalizeOutcome::AlreadyReleased(
428                registry.workspaces[index].clone(),
429            ));
430        }
431        if registry.workspaces[index].retained || state == WorkspaceState::Retained {
432            registry.workspaces[index].retained = true;
433            registry.workspaces[index].state = WorkspaceState::Retained.as_str().into();
434            registry.workspaces[index].lease = None;
435            let result = registry.workspaces[index].clone();
436            self.save(&registry)?;
437            return Ok(WorkspaceFinalizeOutcome::Retained(result));
438        }
439        if matches!(
440            state,
441            WorkspaceState::Allocating | WorkspaceState::Orphaned | WorkspaceState::Unknown(_)
442        ) {
443            return Err(WorkspaceError::Invalid(format!(
444                "workspace {id} in state {} requires explicit recovery",
445                state.as_str()
446            )));
447        }
448
449        if state == WorkspaceState::TerminalPending {
450            let path = &registry.workspaces[index].worktree_path;
451            let registered = GitCli::at(&self.repository_root)
452                .worktree_list()?
453                .iter()
454                .any(|worktree| canonicalize(&worktree.path) == canonicalize(path));
455            if !path.exists() && !registered {
456                registry.workspaces[index].state = WorkspaceState::Released.as_str().into();
457                registry.workspaces[index].lease = None;
458                let result = registry.workspaces[index].clone();
459                self.save(&registry)?;
460                return Ok(WorkspaceFinalizeOutcome::Released(result));
461            }
462        }
463
464        registry.workspaces[index].state = WorkspaceState::TerminalPending.as_str().into();
465        self.save(&registry)?;
466
467        if crate::git::has_changes(&registry.workspaces[index].worktree_path)? {
468            registry.workspaces[index].state = WorkspaceState::Dirty.as_str().into();
469            registry.workspaces[index].lease = None;
470            let result = registry.workspaces[index].clone();
471            self.save(&registry)?;
472            return Ok(WorkspaceFinalizeOutcome::Dirty(result));
473        }
474
475        GitCli::at(&self.repository_root)
476            .worktree_remove(&registry.workspaces[index].worktree_path, false)?;
477        registry.workspaces[index].state = WorkspaceState::Released.as_str().into();
478        registry.workspaces[index].lease = None;
479        let result = registry.workspaces[index].clone();
480        self.save(&registry)?;
481        Ok(WorkspaceFinalizeOutcome::Released(result))
482    }
483
484    pub fn reconcile_generation(
485        &self,
486        daemon_generation: &str,
487    ) -> Result<Vec<WorkspaceRecord>, WorkspaceError> {
488        if daemon_generation.is_empty() {
489            return Err(WorkspaceError::Invalid(
490                "daemon generation must be non-empty".into(),
491            ));
492        }
493        let _lock = self.lock_registry()?;
494        let mut registry = self.load()?;
495        let reconciled_at = chrono::Utc::now();
496        let worktrees = GitCli::at(&self.repository_root).worktree_list()?;
497        let mut changed = Vec::new();
498        for item in &mut registry.workspaces {
499            let Some(lease) = &item.lease else {
500                continue;
501            };
502            let previous_generation = lease.daemon_generation.clone();
503            if previous_generation == daemon_generation {
504                continue;
505            }
506            match item.lifecycle_state() {
507                WorkspaceState::Allocating => {
508                    let path_exists = item.worktree_path.exists();
509                    let registered = worktrees.iter().any(|worktree| {
510                        canonicalize(&worktree.path) == canonicalize(&item.worktree_path)
511                    });
512                    item.reconciled_at = Some(reconciled_at);
513                    if !path_exists && !registered {
514                        item.state = WorkspaceState::Released.as_str().into();
515                        item.lease = None;
516                        item.reconciliation_reason = Some(format!(
517                            "allocation from older daemon generation {} left no path or Git registration",
518                            previous_generation
519                        ));
520                    } else {
521                        item.state = WorkspaceState::Orphaned.as_str().into();
522                        item.reconciliation_reason = Some(format!(
523                            "allocation from older daemon generation {} has residual state (path_exists={path_exists}, git_registered={registered})",
524                            previous_generation
525                        ));
526                    }
527                    changed.push(item.clone());
528                }
529                WorkspaceState::Active => {
530                    item.state = WorkspaceState::Orphaned.as_str().into();
531                    item.reconciled_at = Some(reconciled_at);
532                    item.reconciliation_reason = Some(format!(
533                        "active lease belongs to older daemon generation {}",
534                        previous_generation
535                    ));
536                    changed.push(item.clone());
537                }
538                _ => {}
539            }
540        }
541        if !changed.is_empty() {
542            self.save(&registry)?;
543        }
544        Ok(changed)
545    }
546
547    pub fn list(&self) -> Result<Vec<WorkspaceRecord>, WorkspaceError> {
548        Ok(self.load()?.workspaces)
549    }
550    pub fn get(&self, id: &str) -> Result<WorkspaceRecord, WorkspaceError> {
551        self.load()?
552            .workspaces
553            .into_iter()
554            .find(|w| w.id == id)
555            .ok_or_else(|| WorkspaceError::Invalid(format!("workspace {id} not found")))
556    }
557
558    pub fn retain(
559        &self,
560        id: &str,
561        retained: bool,
562        owner_session: Option<&str>,
563        owner_flow: Option<&str>,
564    ) -> Result<WorkspaceRecord, WorkspaceError> {
565        let _lock = self.lock_registry()?;
566        let mut registry = self.load()?;
567        let item = registry
568            .workspaces
569            .iter_mut()
570            .find(|w| w.id == id)
571            .ok_or_else(|| WorkspaceError::Invalid(format!("workspace {id} not found")))?;
572        validate_ownership(item, owner_session, owner_flow)?;
573        match (item.lifecycle_state(), retained) {
574            (WorkspaceState::Active, true) => {
575                item.retained = true;
576                item.state = WorkspaceState::Retained.as_str().into();
577                item.lease = None;
578            }
579            (WorkspaceState::Active, false) | (WorkspaceState::Retained, true) => {}
580            (WorkspaceState::Retained, false) => {
581                return Err(WorkspaceError::Invalid(format!(
582                    "workspace {id} cannot become active without explicit adoption"
583                )));
584            }
585            (state, _) => {
586                return Err(WorkspaceError::Invalid(format!(
587                    "workspace {id} in state {} cannot change retention",
588                    state.as_str()
589                )));
590            }
591        }
592        let result = item.clone();
593        self.save(&registry)?;
594        Ok(result)
595    }
596
597    pub fn release(
598        &self,
599        id: &str,
600        owner_session: Option<&str>,
601        owner_flow: Option<&str>,
602        force: bool,
603    ) -> Result<WorkspaceRecord, WorkspaceError> {
604        let _lock = self.lock_registry()?;
605        let mut registry = self.load()?;
606        let index = registry
607            .workspaces
608            .iter()
609            .position(|w| w.id == id)
610            .ok_or_else(|| WorkspaceError::Invalid(format!("workspace {id} not found")))?;
611        let item = &registry.workspaces[index];
612        validate_ownership(item, owner_session, owner_flow)?;
613        let state = item.lifecycle_state();
614        if !matches!(state, WorkspaceState::Active | WorkspaceState::Retained) {
615            return Err(WorkspaceError::Invalid(format!(
616                "workspace {id} in state {} cannot be released",
617                state.as_str()
618            )));
619        }
620        if !force && crate::git::has_changes(&item.worktree_path)? {
621            return Err(WorkspaceError::Invalid(
622                "dirty workspace requires force=true".into(),
623            ));
624        }
625        GitCli::at(&self.repository_root).worktree_remove(&item.worktree_path, force)?;
626        let released = &mut registry.workspaces[index];
627        released.state = WorkspaceState::Released.as_str().into();
628        released.retained = false;
629        released.lease = None;
630        released.reconciled_at = Some(chrono::Utc::now());
631        released.reconciliation_reason = Some("released by explicit workspace action".into());
632        let result = released.clone();
633        self.save(&registry)?;
634        Ok(result)
635    }
636
637    pub fn prune(&self, dry_run: bool) -> Result<Vec<WorkspaceRecord>, WorkspaceError> {
638        let _lock = self.lock_registry()?;
639        let mut registry = self.load()?;
640        let worktrees = GitCli::at(&self.repository_root).worktree_list()?;
641        let mut candidates = Vec::new();
642        for item in &registry.workspaces {
643            if !item.retained && item.lifecycle_state() == WorkspaceState::Orphaned {
644                candidates.push(item.clone());
645            }
646        }
647        if dry_run {
648            return Ok(candidates);
649        }
650
651        for item in &candidates {
652            let path_exists = item.worktree_path.exists();
653            let registered = worktrees
654                .iter()
655                .any(|worktree| canonicalize(&worktree.path) == canonicalize(&item.worktree_path));
656            if path_exists != registered {
657                let surviving_side = if path_exists {
658                    "filesystem path"
659                } else {
660                    "Git registration"
661                };
662                return Err(WorkspaceError::Invalid(format!(
663                    "workspace {} has a surviving {surviving_side}; refusing to prune one-sided state",
664                    item.id
665                )));
666            }
667        }
668
669        for item in &candidates {
670            if item.worktree_path.exists() {
671                GitCli::at(&self.repository_root).worktree_remove(&item.worktree_path, false)?;
672            }
673            let still_registered = GitCli::at(&self.repository_root)
674                .worktree_list()?
675                .iter()
676                .any(|worktree| canonicalize(&worktree.path) == canonicalize(&item.worktree_path));
677            if item.worktree_path.exists() || still_registered {
678                return Err(WorkspaceError::Invalid(format!(
679                    "workspace {} was not fully removed; preserving its lifecycle state",
680                    item.id
681                )));
682            }
683            if let Some(found) = registry.workspaces.iter_mut().find(|w| w.id == item.id) {
684                found.state = WorkspaceState::Released.as_str().into();
685                found.retained = false;
686                found.lease = None;
687                found.reconciled_at = Some(chrono::Utc::now());
688                found.reconciliation_reason = Some("released by explicit orphan prune".into());
689            }
690        }
691        self.save(&registry)?;
692        Ok(candidates)
693    }
694
695    fn lock_registry(&self) -> Result<fs::File, WorkspaceError> {
696        fs::create_dir_all(
697            self.registry_lock_path
698                .parent()
699                .unwrap_or_else(|| Path::new(".")),
700        )?;
701        let lock = fs::OpenOptions::new()
702            .read(true)
703            .write(true)
704            .create(true)
705            .truncate(false)
706            .open(&self.registry_lock_path)?;
707        lock.lock_exclusive()?;
708        Ok(lock)
709    }
710
711    fn load(&self) -> Result<WorkspaceRegistry, WorkspaceError> {
712        if !self.registry_path.exists() {
713            return Ok(WorkspaceRegistry {
714                version: REGISTRY_VERSION,
715                workspaces: Vec::new(),
716            });
717        }
718        let mut registry: WorkspaceRegistry =
719            serde_json::from_slice(&fs::read(&self.registry_path)?)?;
720        if registry.version != REGISTRY_VERSION {
721            return Err(WorkspaceError::Invalid(format!(
722                "unsupported registry version {}",
723                registry.version
724            )));
725        }
726        for item in &mut registry.workspaces {
727            if item.retained && item.allocation_policy.is_none() {
728                item.state = WorkspaceState::Retained.as_str().into();
729            } else if item.lifecycle_state() == WorkspaceState::Retained {
730                item.retained = true;
731            }
732        }
733        Ok(registry)
734    }
735
736    fn save(&self, registry: &WorkspaceRegistry) -> Result<(), WorkspaceError> {
737        let parent = self
738            .registry_path
739            .parent()
740            .unwrap_or_else(|| Path::new("."));
741        fs::create_dir_all(parent)?;
742        let tmp = parent.join(format!(
743            ".workspaces.json.{}.{}.tmp",
744            std::process::id(),
745            uuid::Uuid::new_v4()
746        ));
747        let result = (|| {
748            fs::write(&tmp, serde_json::to_vec_pretty(registry)?)?;
749            fs::rename(&tmp, &self.registry_path)?;
750            Ok(())
751        })();
752        if result.is_err() {
753            let _ = fs::remove_file(&tmp);
754        }
755        result
756    }
757}
758
759fn validate_ownership(
760    item: &WorkspaceRecord,
761    owner_session: Option<&str>,
762    owner_flow: Option<&str>,
763) -> Result<(), WorkspaceError> {
764    if item.owner_session.as_deref() != owner_session || item.owner_flow.as_deref() != owner_flow {
765        return Err(WorkspaceError::Invalid(
766            "workspace ownership mismatch".into(),
767        ));
768    }
769    Ok(())
770}
771
772fn git_output(cwd: &Path, args: &[&str]) -> Result<String, WorkspaceError> {
773    let output = Command::new("git").args(args).current_dir(cwd).output()?;
774    if !output.status.success() {
775        return Err(WorkspaceError::Git(GitError::ExitNonZero {
776            args: args.join(" "),
777            code: output.status.code().unwrap_or(-1),
778            stderr: String::from_utf8_lossy(&output.stderr).trim().into(),
779        }));
780    }
781    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
782}
783fn canonicalize(path: &Path) -> PathBuf {
784    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
785}
786fn canonicalize_from(base: &Path, path: &Path) -> PathBuf {
787    if path.is_absolute() {
788        canonicalize(path)
789    } else {
790        canonicalize(&base.join(path))
791    }
792}
793fn ensure_inside(path: &Path, root: &Path) -> Result<(), WorkspaceError> {
794    if canonicalize(path).starts_with(canonicalize(root)) {
795        Ok(())
796    } else {
797        Err(WorkspaceError::Invalid(
798            "workspace path escapes managed root".into(),
799        ))
800    }
801}
802fn validate_id(id: &str) -> Result<(), WorkspaceError> {
803    if id.is_empty() || id == "." || id == ".." || id.contains('/') || id.contains('\\') {
804        Err(WorkspaceError::Invalid(
805            "workspace id must be a single safe path component".into(),
806        ))
807    } else {
808        Ok(())
809    }
810}
811fn add_exclude(path: &Path, entry: &str) -> Result<(), WorkspaceError> {
812    let old = fs::read_to_string(path).unwrap_or_default();
813    if !old.lines().any(|line| line.trim() == entry) {
814        let mut next = old;
815        if !next.is_empty() && !next.ends_with('\n') {
816            next.push('\n');
817        }
818        next.push_str(entry);
819        next.push('\n');
820        fs::write(path, next)?;
821    }
822    Ok(())
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828
829    fn git(cwd: &Path, args: &[&str]) -> String {
830        let output = Command::new("git")
831            .args(args)
832            .current_dir(cwd)
833            .output()
834            .unwrap();
835        assert!(
836            output.status.success(),
837            "git {} failed: {}",
838            args.join(" "),
839            String::from_utf8_lossy(&output.stderr)
840        );
841        String::from_utf8(output.stdout).unwrap().trim().to_owned()
842    }
843
844    fn repo() -> tempfile::TempDir {
845        let tmp = tempfile::tempdir().unwrap();
846        git(tmp.path(), &["init", "-q"]);
847        git(tmp.path(), &["config", "user.name", "Atman Test"]);
848        git(
849            tmp.path(),
850            &["config", "user.email", "atman@example.invalid"],
851        );
852        git(tmp.path(), &["config", "commit.gpgsign", "false"]);
853        fs::write(tmp.path().join("README.md"), "committed\n").unwrap();
854        git(tmp.path(), &["add", "README.md"]);
855        git(tmp.path(), &["commit", "-q", "-m", "initial"]);
856        tmp
857    }
858
859    #[test]
860    fn parses_workspace_policies_and_rejects_unknown_values() {
861        assert_eq!(
862            "none".parse::<WorkspacePolicy>().unwrap(),
863            WorkspacePolicy::None
864        );
865        assert_eq!(
866            "auto".parse::<WorkspacePolicy>().unwrap(),
867            WorkspacePolicy::Auto
868        );
869        assert_eq!(
870            "retain".parse::<WorkspacePolicy>().unwrap(),
871            WorkspacePolicy::Retain
872        );
873        assert!("always".parse::<WorkspacePolicy>().is_err());
874    }
875
876    #[test]
877    fn reads_v1_records_without_lease_and_normalizes_retention() {
878        let tmp = repo();
879        let manager = WorkspaceManager::at(tmp.path(), None).unwrap();
880        let old = serde_json::json!({
881            "version": 1,
882            "workspaces": [{
883                "id": "old",
884                "repository_root": tmp.path(),
885                "worktree_path": tmp.path().join("old"),
886                "branch": null,
887                "owner_session": "session",
888                "owner_flow": "flow",
889                "state": "active",
890                "retained": true
891            }]
892        });
893        fs::write(
894            &manager.registry_path,
895            serde_json::to_vec_pretty(&old).unwrap(),
896        )
897        .unwrap();
898
899        let record = manager.get("old").unwrap();
900        assert_eq!(record.lifecycle_state(), WorkspaceState::Retained);
901        assert!(record.retained);
902        assert_eq!(record.lease, None);
903    }
904
905    #[test]
906    fn restart_reconciliation_only_orphans_active_older_generation_leases() {
907        let tmp = repo();
908        let manager = WorkspaceManager::at(tmp.path(), None).unwrap();
909        let stale = manager
910            .create_managed(
911                "stale",
912                "session",
913                "stale",
914                "old-generation",
915                false,
916                &GitCli::at(tmp.path()).head_oid().unwrap(),
917            )
918            .unwrap();
919        manager
920            .create_managed(
921                "current",
922                "session",
923                "current",
924                "new-generation",
925                false,
926                &GitCli::at(tmp.path()).head_oid().unwrap(),
927            )
928            .unwrap();
929        manager
930            .create_managed(
931                "retained",
932                "session",
933                "retained",
934                "old-generation",
935                true,
936                &GitCli::at(tmp.path()).head_oid().unwrap(),
937            )
938            .unwrap();
939        assert!(matches!(
940            manager
941                .finalize_managed("retained", "session", "retained")
942                .unwrap(),
943            WorkspaceFinalizeOutcome::Retained(_)
944        ));
945        manager
946            .create_managed(
947                "dirty",
948                "session",
949                "dirty",
950                "old-generation",
951                false,
952                &GitCli::at(tmp.path()).head_oid().unwrap(),
953            )
954            .unwrap();
955        let mut registry = manager.load().unwrap();
956        registry
957            .workspaces
958            .iter_mut()
959            .find(|item| item.id == "dirty")
960            .unwrap()
961            .state = WorkspaceState::Dirty.as_str().into();
962        manager.save(&registry).unwrap();
963
964        let changed = manager.reconcile_generation("new-generation").unwrap();
965        assert_eq!(changed.len(), 1);
966        assert_eq!(changed[0].id, "stale");
967        assert!(stale.worktree_path.exists());
968        let orphaned = manager.get("stale").unwrap();
969        assert_eq!(orphaned.lifecycle_state(), WorkspaceState::Orphaned);
970        assert_eq!(
971            orphaned
972                .lease
973                .as_ref()
974                .map(|lease| lease.daemon_generation.as_str()),
975            Some("old-generation")
976        );
977        assert!(orphaned.reconciled_at.is_some());
978        assert!(orphaned.reconciliation_reason.is_some());
979        assert_eq!(
980            manager.get("current").unwrap().lifecycle_state(),
981            WorkspaceState::Active
982        );
983        assert_eq!(
984            manager.get("retained").unwrap().lifecycle_state(),
985            WorkspaceState::Retained
986        );
987        assert_eq!(
988            manager.get("dirty").unwrap().lifecycle_state(),
989            WorkspaceState::Dirty
990        );
991
992        assert!(
993            manager
994                .reconcile_generation("new-generation")
995                .unwrap()
996                .is_empty()
997        );
998        assert_eq!(manager.get("stale").unwrap(), orphaned);
999    }
1000
1001    #[test]
1002    fn managed_create_is_idempotent_and_retain_starts_active() {
1003        let tmp = repo();
1004        let manager = WorkspaceManager::at(tmp.path(), None).unwrap();
1005        let first = manager
1006            .create_managed(
1007                "managed",
1008                "session",
1009                "flow",
1010                "generation",
1011                false,
1012                &GitCli::at(tmp.path()).head_oid().unwrap(),
1013            )
1014            .unwrap();
1015        let count = GitCli::at(tmp.path()).worktree_list().unwrap().len();
1016        let second = manager
1017            .create_managed(
1018                "managed",
1019                "session",
1020                "flow",
1021                "generation",
1022                false,
1023                &GitCli::at(tmp.path()).head_oid().unwrap(),
1024            )
1025            .unwrap();
1026        assert_eq!(second, first);
1027        assert_eq!(GitCli::at(tmp.path()).worktree_list().unwrap().len(), count);
1028        assert!(
1029            manager
1030                .create_managed(
1031                    "managed",
1032                    "session",
1033                    "flow",
1034                    "other-generation",
1035                    false,
1036                    &GitCli::at(tmp.path()).head_oid().unwrap()
1037                )
1038                .is_err()
1039        );
1040        assert_eq!(GitCli::at(tmp.path()).worktree_list().unwrap().len(), count);
1041
1042        let retained = manager
1043            .create_managed(
1044                "retained-active",
1045                "session",
1046                "retained",
1047                "generation",
1048                true,
1049                &GitCli::at(tmp.path()).head_oid().unwrap(),
1050            )
1051            .unwrap();
1052        assert!(retained.retained);
1053        assert_eq!(retained.lifecycle_state(), WorkspaceState::Active);
1054        assert_eq!(retained.allocation_policy.as_deref(), Some("retain"));
1055    }
1056
1057    #[test]
1058    fn managed_allocation_failure_preserves_residual_directory() {
1059        let tmp = repo();
1060        let manager = WorkspaceManager::at(tmp.path(), None).unwrap();
1061        let path = manager.managed_root().join("failed");
1062        fs::create_dir_all(&path).unwrap();
1063        let sentinel = path.join("sentinel.txt");
1064        fs::write(&sentinel, "must survive\n").unwrap();
1065
1066        assert!(
1067            manager
1068                .create_managed(
1069                    "failed",
1070                    "session",
1071                    "flow",
1072                    "generation",
1073                    false,
1074                    &GitCli::at(tmp.path()).head_oid().unwrap()
1075                )
1076                .is_err()
1077        );
1078        assert_eq!(fs::read_to_string(sentinel).unwrap(), "must survive\n");
1079        let record = manager.get("failed").unwrap();
1080        assert_eq!(record.lifecycle_state(), WorkspaceState::Orphaned);
1081        assert!(record.reconciliation_reason.is_some());
1082    }
1083
1084    #[test]
1085    fn managed_allocation_crash_windows_reconcile_without_deleting_residuals() {
1086        for (stage, expect_residual) in [
1087            (AllocationStage::BeforeWorktreeAdd, false),
1088            (AllocationStage::AfterWorktreeAdd, true),
1089            (AllocationStage::BeforeFinalSave, true),
1090        ] {
1091            let tmp = repo();
1092            let manager = WorkspaceManager::at(tmp.path(), None).unwrap();
1093            let result = manager.create_managed_with_hook(
1094                "crash",
1095                "session",
1096                "flow",
1097                "old-generation",
1098                false,
1099                &GitCli::at(tmp.path()).head_oid().unwrap(),
1100                |current| {
1101                    if current == stage {
1102                        Err(WorkspaceError::Invalid("injected crash".into()))
1103                    } else {
1104                        Ok(())
1105                    }
1106                },
1107            );
1108            assert!(result.is_err());
1109            let allocating = manager.get("crash").unwrap();
1110            assert_eq!(allocating.lifecycle_state(), WorkspaceState::Allocating);
1111            assert_eq!(allocating.worktree_path.exists(), expect_residual);
1112
1113            let changed = manager.reconcile_generation("new-generation").unwrap();
1114            assert_eq!(changed.len(), 1);
1115            let recovered = manager.get("crash").unwrap();
1116            if expect_residual {
1117                assert_eq!(recovered.lifecycle_state(), WorkspaceState::Orphaned);
1118                assert!(recovered.worktree_path.exists());
1119                assert!(
1120                    GitCli::at(tmp.path())
1121                        .worktree_list()
1122                        .unwrap()
1123                        .iter()
1124                        .any(|worktree| canonicalize(&worktree.path)
1125                            == canonicalize(&recovered.worktree_path))
1126                );
1127            } else {
1128                assert_eq!(recovered.lifecycle_state(), WorkspaceState::Released);
1129                assert!(!recovered.worktree_path.exists());
1130            }
1131        }
1132    }
1133
1134    #[test]
1135    fn concurrent_registry_mutations_do_not_lose_records() {
1136        let tmp = repo();
1137        let root = tmp.path().to_path_buf();
1138        let barrier = std::sync::Arc::new(std::sync::Barrier::new(9));
1139        let mut threads = Vec::new();
1140        for index in 0..8 {
1141            let root = root.clone();
1142            let barrier = barrier.clone();
1143            threads.push(std::thread::spawn(move || {
1144                let manager = WorkspaceManager::at(&root, None).unwrap();
1145                barrier.wait();
1146                manager
1147                    .create(
1148                        &format!("concurrent-{index}"),
1149                        None,
1150                        None,
1151                        false,
1152                        None,
1153                        None,
1154                    )
1155                    .unwrap();
1156            }));
1157        }
1158        barrier.wait();
1159        for thread in threads {
1160            thread.join().unwrap();
1161        }
1162
1163        let manager = WorkspaceManager::at(tmp.path(), None).unwrap();
1164        let records = manager.list().unwrap();
1165        assert_eq!(records.len(), 8);
1166        for index in 0..8 {
1167            assert!(
1168                records
1169                    .iter()
1170                    .any(|item| item.id == format!("concurrent-{index}"))
1171            );
1172        }
1173    }
1174
1175    #[test]
1176    fn managed_finalize_recovers_after_worktree_removal_before_released_save() {
1177        let tmp = repo();
1178        let manager = WorkspaceManager::at(tmp.path(), None).unwrap();
1179        let record = manager
1180            .create_managed(
1181                "recover",
1182                "session",
1183                "flow",
1184                "generation",
1185                false,
1186                &GitCli::at(tmp.path()).head_oid().unwrap(),
1187            )
1188            .unwrap();
1189
1190        let mut registry = manager.load().unwrap();
1191        let persisted = registry
1192            .workspaces
1193            .iter_mut()
1194            .find(|item| item.id == record.id)
1195            .unwrap();
1196        persisted.state = WorkspaceState::TerminalPending.as_str().into();
1197        manager.save(&registry).unwrap();
1198        GitCli::at(tmp.path())
1199            .worktree_remove(&record.worktree_path, false)
1200            .unwrap();
1201
1202        let outcome = manager
1203            .finalize_managed("recover", "session", "flow")
1204            .unwrap();
1205        let WorkspaceFinalizeOutcome::Released(released) = outcome else {
1206            panic!("expected recovered release, got {outcome:?}");
1207        };
1208        assert_eq!(released.lifecycle_state(), WorkspaceState::Released);
1209        assert_eq!(manager.get("recover").unwrap(), released);
1210        assert!(matches!(
1211            manager
1212                .finalize_managed("recover", "session", "flow")
1213                .unwrap(),
1214            WorkspaceFinalizeOutcome::AlreadyReleased(_)
1215        ));
1216    }
1217
1218    #[test]
1219    fn managed_finalize_preserves_unregistered_workspace_directory() {
1220        let tmp = repo();
1221        let manager = WorkspaceManager::at(tmp.path(), None).unwrap();
1222        let record = manager
1223            .create_managed(
1224                "residual",
1225                "session",
1226                "flow",
1227                "generation",
1228                false,
1229                &GitCli::at(tmp.path()).head_oid().unwrap(),
1230            )
1231            .unwrap();
1232
1233        let mut registry = manager.load().unwrap();
1234        let persisted = registry
1235            .workspaces
1236            .iter_mut()
1237            .find(|item| item.id == record.id)
1238            .unwrap();
1239        persisted.state = WorkspaceState::TerminalPending.as_str().into();
1240        manager.save(&registry).unwrap();
1241        GitCli::at(tmp.path())
1242            .worktree_remove(&record.worktree_path, false)
1243            .unwrap();
1244        fs::create_dir_all(&record.worktree_path).unwrap();
1245        let residual = record.worktree_path.join("residual.txt");
1246        fs::write(&residual, "must survive\n").unwrap();
1247
1248        assert!(
1249            manager
1250                .finalize_managed("residual", "session", "flow")
1251                .is_err()
1252        );
1253        assert_eq!(fs::read_to_string(residual).unwrap(), "must survive\n");
1254        assert_eq!(
1255            manager.get("residual").unwrap().lifecycle_state(),
1256            WorkspaceState::TerminalPending
1257        );
1258    }
1259
1260    #[test]
1261    fn creates_lists_gets_and_reloads_committed_workspace_with_ignore_policy() {
1262        let tmp = repo();
1263        let head = git(tmp.path(), &["rev-parse", "HEAD"]);
1264        let manager = WorkspaceManager::at(tmp.path(), None).unwrap();
1265        let item = manager
1266            .create("one", None, None, false, Some("session"), Some("flow"))
1267            .unwrap();
1268
1269        assert_eq!(
1270            manager.managed_root(),
1271            tmp.path().join(".atman/worktrees").canonicalize().unwrap()
1272        );
1273        assert_eq!(
1274            fs::read_to_string(item.worktree_path.join("README.md")).unwrap(),
1275            "committed\n"
1276        );
1277        assert_eq!(git(&item.worktree_path, &["rev-parse", "HEAD"]), head);
1278        assert_eq!(
1279            fs::read_to_string(manager.managed_root().join(".gitignore")).unwrap(),
1280            "*\n"
1281        );
1282        assert_eq!(manager.list().unwrap(), vec![item.clone()]);
1283        assert_eq!(manager.get("one").unwrap(), item);
1284
1285        let reloaded = WorkspaceManager::at(&item.worktree_path, None).unwrap();
1286        assert_eq!(reloaded.repository_root(), manager.repository_root());
1287        assert_eq!(reloaded.managed_root(), manager.managed_root());
1288        assert_eq!(reloaded.get("one").unwrap(), item);
1289
1290        WorkspaceManager::at(tmp.path(), None).unwrap();
1291        let exclude = fs::read_to_string(tmp.path().join(".git/info/exclude")).unwrap();
1292        assert_eq!(
1293            exclude
1294                .lines()
1295                .filter(|line| line.trim() == "/.atman/worktrees/")
1296                .count(),
1297            1
1298        );
1299    }
1300
1301    #[test]
1302    fn normalizes_full_and_short_branch_names() {
1303        let tmp = repo();
1304        let manager = WorkspaceManager::at(tmp.path(), None).unwrap();
1305        let short = manager
1306            .create("short", Some("probe"), None, true, None, None)
1307            .unwrap();
1308        assert_eq!(short.branch.as_deref(), Some("refs/heads/probe"));
1309        manager.release("short", None, None, false).unwrap();
1310
1311        let full = manager
1312            .create("full", Some("refs/heads/probe"), None, false, None, None)
1313            .unwrap();
1314        assert_eq!(full.branch.as_deref(), Some("refs/heads/probe"));
1315    }
1316
1317    #[test]
1318    fn retain_and_release_validate_session_and_flow_owners() {
1319        let tmp = repo();
1320        let manager = WorkspaceManager::at(tmp.path(), None).unwrap();
1321        manager
1322            .create("owned", None, None, false, Some("session"), Some("flow"))
1323            .unwrap();
1324
1325        assert!(
1326            manager
1327                .retain("owned", true, Some("other"), Some("flow"))
1328                .is_err()
1329        );
1330        assert!(
1331            manager
1332                .retain("owned", true, Some("session"), Some("other"))
1333                .is_err()
1334        );
1335        manager
1336            .retain("owned", true, Some("session"), Some("flow"))
1337            .unwrap();
1338        assert!(
1339            manager
1340                .release("owned", Some("session"), Some("other"), false)
1341                .is_err()
1342        );
1343        manager
1344            .release("owned", Some("session"), Some("flow"), false)
1345            .unwrap();
1346    }
1347
1348    #[test]
1349    fn conservative_states_reject_retain_and_release_without_mutation() {
1350        for (index, state) in [
1351            "unknown-legacy",
1352            "allocating",
1353            "orphaned",
1354            "dirty",
1355            "terminal_pending",
1356            "released",
1357        ]
1358        .into_iter()
1359        .enumerate()
1360        {
1361            let tmp = repo();
1362            let manager = WorkspaceManager::at(tmp.path(), None).unwrap();
1363            let id = format!("state-{index}");
1364            manager
1365                .create(&id, None, None, false, Some("session"), Some("flow"))
1366                .unwrap();
1367            let mut registry = manager.load().unwrap();
1368            let item = registry
1369                .workspaces
1370                .iter_mut()
1371                .find(|item| item.id == id)
1372                .unwrap();
1373            item.state = state.into();
1374            item.retained = false;
1375            item.lease = Some(WorkspaceLease {
1376                daemon_generation: "old-generation".into(),
1377                acquired_at: chrono::Utc::now(),
1378            });
1379            let expected = item.clone();
1380            manager.save(&registry).unwrap();
1381
1382            assert!(
1383                manager
1384                    .retain(&id, false, Some("session"), Some("flow"))
1385                    .is_err(),
1386                "retain(false) accepted {state}"
1387            );
1388            assert_eq!(manager.get(&id).unwrap(), expected);
1389            assert!(
1390                manager
1391                    .release(&id, Some("session"), Some("flow"), true)
1392                    .is_err(),
1393                "release accepted {state}"
1394            );
1395            assert_eq!(manager.get(&id).unwrap(), expected);
1396            assert!(expected.worktree_path.exists());
1397        }
1398    }
1399
1400    #[test]
1401    fn retain_transitions_never_reactivate_retained_workspaces() {
1402        let tmp = repo();
1403        let manager = WorkspaceManager::at(tmp.path(), None).unwrap();
1404        let active = manager
1405            .create("active-retain", None, None, false, None, None)
1406            .unwrap();
1407        let mut registry = manager.load().unwrap();
1408        registry
1409            .workspaces
1410            .iter_mut()
1411            .find(|item| item.id == active.id)
1412            .unwrap()
1413            .lease = Some(WorkspaceLease {
1414            daemon_generation: "generation".into(),
1415            acquired_at: chrono::Utc::now(),
1416        });
1417        manager.save(&registry).unwrap();
1418
1419        let retained = manager.retain(&active.id, true, None, None).unwrap();
1420        assert_eq!(retained.lifecycle_state(), WorkspaceState::Retained);
1421        assert!(retained.retained);
1422        assert!(retained.lease.is_none());
1423        assert!(manager.retain(&active.id, false, None, None).is_err());
1424
1425        let mut registry = manager.load().unwrap();
1426        registry
1427            .workspaces
1428            .iter_mut()
1429            .find(|item| item.id == active.id)
1430            .unwrap()
1431            .lease = Some(WorkspaceLease {
1432            daemon_generation: "generation".into(),
1433            acquired_at: chrono::Utc::now(),
1434        });
1435        manager.save(&registry).unwrap();
1436        let expected = manager.get(&active.id).unwrap();
1437        assert!(manager.retain(&active.id, false, None, None).is_err());
1438        assert_eq!(manager.get(&active.id).unwrap(), expected);
1439        assert!(active.worktree_path.exists());
1440
1441        let released = manager.release(&active.id, None, None, false).unwrap();
1442        assert_eq!(released.lifecycle_state(), WorkspaceState::Released);
1443        assert!(released.lease.is_none());
1444        assert!(released.reconciliation_reason.is_some());
1445
1446        let retained = manager
1447            .create("retained-release", None, None, false, None, None)
1448            .unwrap();
1449        manager.retain(&retained.id, true, None, None).unwrap();
1450        let released = manager.release(&retained.id, None, None, false).unwrap();
1451        assert_eq!(released.lifecycle_state(), WorkspaceState::Released);
1452        assert!(!released.retained);
1453    }
1454
1455    #[test]
1456    fn dirty_workspace_requires_force_to_release() {
1457        let tmp = repo();
1458        let manager = WorkspaceManager::at(tmp.path(), None).unwrap();
1459        let item = manager
1460            .create("dirty", None, None, false, None, None)
1461            .unwrap();
1462        fs::write(item.worktree_path.join("dirty.txt"), "dirty\n").unwrap();
1463
1464        assert!(manager.release("dirty", None, None, false).is_err());
1465        assert!(item.worktree_path.exists());
1466        let released = manager.release("dirty", None, None, true).unwrap();
1467        assert_eq!(released.state, "released");
1468        assert!(!item.worktree_path.exists());
1469    }
1470
1471    #[test]
1472    fn prune_only_releases_explicit_orphans_dry_run_then_real() {
1473        let tmp = repo();
1474        let manager = WorkspaceManager::at(tmp.path(), None).unwrap();
1475        let active = manager
1476            .create("active", None, None, false, Some(""), Some(""))
1477            .unwrap();
1478        let missing = manager
1479            .create("missing", None, None, false, None, None)
1480            .unwrap();
1481        let mut registry = manager.load().unwrap();
1482        registry
1483            .workspaces
1484            .iter_mut()
1485            .find(|item| item.id == missing.id)
1486            .unwrap()
1487            .state = WorkspaceState::Orphaned.as_str().into();
1488        manager.save(&registry).unwrap();
1489        GitCli::at(tmp.path())
1490            .worktree_remove(&missing.worktree_path, false)
1491            .unwrap();
1492
1493        let dry_run = manager.prune(true).unwrap();
1494        assert_eq!(
1495            dry_run
1496                .iter()
1497                .map(|item| item.id.as_str())
1498                .collect::<Vec<_>>(),
1499            vec!["missing"]
1500        );
1501        assert_eq!(manager.get("missing").unwrap().state, "orphaned");
1502        assert!(active.worktree_path.exists());
1503
1504        let pruned = manager.prune(false).unwrap();
1505        assert_eq!(
1506            pruned
1507                .iter()
1508                .map(|item| item.id.as_str())
1509                .collect::<Vec<_>>(),
1510            vec!["missing"]
1511        );
1512        assert_eq!(manager.get("missing").unwrap().state, "released");
1513        assert_eq!(manager.get("active").unwrap().state, "active");
1514    }
1515
1516    #[test]
1517    fn prune_preserves_unknown_state_when_both_physical_sides_are_missing() {
1518        let tmp = repo();
1519        let manager = WorkspaceManager::at(tmp.path(), None).unwrap();
1520        let item = manager
1521            .create("unknown-missing", None, None, false, None, None)
1522            .unwrap();
1523        let mut registry = manager.load().unwrap();
1524        let record = registry
1525            .workspaces
1526            .iter_mut()
1527            .find(|record| record.id == item.id)
1528            .unwrap();
1529        record.state = "unknown-legacy".into();
1530        record.lease = Some(WorkspaceLease {
1531            daemon_generation: "old-generation".into(),
1532            acquired_at: chrono::Utc::now(),
1533        });
1534        let expected = record.clone();
1535        manager.save(&registry).unwrap();
1536        GitCli::at(tmp.path())
1537            .worktree_remove(&item.worktree_path, false)
1538            .unwrap();
1539
1540        assert!(manager.prune(true).unwrap().is_empty());
1541        assert!(manager.prune(false).unwrap().is_empty());
1542        assert_eq!(manager.get(&item.id).unwrap(), expected);
1543        assert!(!item.worktree_path.exists());
1544        assert!(
1545            !GitCli::at(tmp.path())
1546                .worktree_list()
1547                .unwrap()
1548                .iter()
1549                .any(|worktree| canonicalize(&worktree.path) == canonicalize(&item.worktree_path))
1550        );
1551    }
1552
1553    #[test]
1554    fn prune_preserves_one_sided_workspace_states() {
1555        for git_only in [false, true] {
1556            let tmp = repo();
1557            let manager = WorkspaceManager::at(tmp.path(), None).unwrap();
1558            let id = if git_only { "git-only" } else { "path-only" };
1559            let item = manager.create(id, None, None, false, None, None).unwrap();
1560            let mut registry = manager.load().unwrap();
1561            let record = registry
1562                .workspaces
1563                .iter_mut()
1564                .find(|record| record.id == id)
1565                .unwrap();
1566            record.state = WorkspaceState::Orphaned.as_str().into();
1567            record.lease = Some(WorkspaceLease {
1568                daemon_generation: "old-generation".into(),
1569                acquired_at: chrono::Utc::now(),
1570            });
1571            let expected = record.clone();
1572            manager.save(&registry).unwrap();
1573
1574            if git_only {
1575                fs::remove_dir_all(&item.worktree_path).unwrap();
1576            } else {
1577                GitCli::at(tmp.path())
1578                    .worktree_remove(&item.worktree_path, false)
1579                    .unwrap();
1580                fs::create_dir_all(&item.worktree_path).unwrap();
1581                fs::write(item.worktree_path.join("survivor.txt"), "keep\n").unwrap();
1582            }
1583
1584            assert_eq!(manager.prune(true).unwrap(), vec![expected.clone()]);
1585            assert!(manager.prune(false).is_err());
1586            assert_eq!(manager.get(id).unwrap(), expected);
1587            if git_only {
1588                assert!(
1589                    GitCli::at(tmp.path())
1590                        .worktree_list()
1591                        .unwrap()
1592                        .iter()
1593                        .any(|worktree| canonicalize(&worktree.path)
1594                            == canonicalize(&item.worktree_path))
1595                );
1596            } else {
1597                assert_eq!(
1598                    fs::read_to_string(item.worktree_path.join("survivor.txt")).unwrap(),
1599                    "keep\n"
1600                );
1601            }
1602        }
1603    }
1604
1605    #[test]
1606    fn prune_releases_intact_orphan_and_clears_lease() {
1607        let tmp = repo();
1608        let manager = WorkspaceManager::at(tmp.path(), None).unwrap();
1609        let item = manager
1610            .create("orphan", None, None, false, None, None)
1611            .unwrap();
1612        let mut registry = manager.load().unwrap();
1613        let record = registry
1614            .workspaces
1615            .iter_mut()
1616            .find(|record| record.id == item.id)
1617            .unwrap();
1618        record.state = WorkspaceState::Orphaned.as_str().into();
1619        record.lease = Some(WorkspaceLease {
1620            daemon_generation: "old-generation".into(),
1621            acquired_at: chrono::Utc::now(),
1622        });
1623        manager.save(&registry).unwrap();
1624
1625        let pruned = manager.prune(false).unwrap();
1626        assert_eq!(pruned.len(), 1);
1627        let released = manager.get(&item.id).unwrap();
1628        assert_eq!(released.lifecycle_state(), WorkspaceState::Released);
1629        assert!(released.lease.is_none());
1630        assert!(released.reconciled_at.is_some());
1631        assert_eq!(
1632            released.reconciliation_reason.as_deref(),
1633            Some("released by explicit orphan prune")
1634        );
1635        assert!(!item.worktree_path.exists());
1636        assert!(
1637            !GitCli::at(tmp.path())
1638                .worktree_list()
1639                .unwrap()
1640                .iter()
1641                .any(|worktree| canonicalize(&worktree.path) == canonicalize(&item.worktree_path))
1642        );
1643    }
1644
1645    #[test]
1646    fn bare_repository_requires_and_uses_external_root() {
1647        let source = repo();
1648        let bare_parent = tempfile::tempdir().unwrap();
1649        let bare = bare_parent.path().join("repo.git");
1650        git(
1651            source.path(),
1652            &["clone", "-q", "--bare", ".", bare.to_str().unwrap()],
1653        );
1654        let external = tempfile::tempdir().unwrap();
1655
1656        assert!(WorkspaceManager::at(&bare, None).is_err());
1657        let manager = WorkspaceManager::at(&bare, Some(external.path())).unwrap();
1658        let item = manager
1659            .create("bare-workspace", None, None, false, None, None)
1660            .unwrap();
1661
1662        assert!(
1663            item.worktree_path
1664                .starts_with(external.path().canonicalize().unwrap())
1665        );
1666        assert_eq!(
1667            fs::read_to_string(item.worktree_path.join("README.md")).unwrap(),
1668            "committed\n"
1669        );
1670        let exclude = bare.join("info/exclude");
1671        if exclude.exists() {
1672            assert!(
1673                !fs::read_to_string(exclude)
1674                    .unwrap()
1675                    .lines()
1676                    .any(|line| line.trim() == "/.atman/worktrees/")
1677            );
1678        }
1679    }
1680}