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(®istry)?;
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(®istry)?;
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(®istry)?;
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(®istry)?;
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 ®istry.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(®istry)?;
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 = ®istry.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(®istry)?;
460 return Ok(WorkspaceFinalizeOutcome::Released(result));
461 }
462 }
463
464 registry.workspaces[index].state = WorkspaceState::TerminalPending.as_str().into();
465 self.save(®istry)?;
466
467 if crate::git::has_changes(®istry.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(®istry)?;
472 return Ok(WorkspaceFinalizeOutcome::Dirty(result));
473 }
474
475 GitCli::at(&self.repository_root)
476 .worktree_remove(®istry.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(®istry)?;
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(®istry)?;
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(®istry)?;
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 = ®istry.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(®istry)?;
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 ®istry.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(®istry)?;
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(®istry).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(®istry).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(®istry).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(®istry).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(®istry).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(®istry).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(®istry).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(®istry).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(®istry).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(®istry).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}