1use std::fmt;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10use super::{SnapshotResult, WorkspaceBackend, WorkspaceStatus};
11use crate::config::ManifoldConfig;
12use crate::model::types::{
13 EpochId, GitOid, WorkspaceId, WorkspaceInfo, WorkspaceMode, WorkspaceState,
14};
15use crate::refs as manifold_refs;
16
17#[derive(Debug)]
23pub enum GitBackendError {
24 GitCommand {
26 command: String,
27 stderr: String,
28 exit_code: Option<i32>,
29 },
30 Io(std::io::Error),
32 NotFound { name: String },
34 #[allow(dead_code)]
36 NotImplemented(&'static str),
37}
38
39impl fmt::Display for GitBackendError {
40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41 match self {
42 Self::GitCommand {
43 command,
44 stderr,
45 exit_code,
46 } => {
47 write!(f, "`{command}` failed")?;
48 if let Some(code) = exit_code {
49 write!(f, " (exit code {code})")?;
50 }
51 if !stderr.is_empty() {
52 write!(f, ": {stderr}")?;
53 }
54 Ok(())
55 }
56 Self::Io(e) => write!(f, "I/O error: {e}"),
57 Self::NotFound { name } => write!(f, "workspace '{name}' not found"),
58 Self::NotImplemented(method) => write!(f, "{method} not yet implemented"),
59 }
60 }
61}
62
63impl std::error::Error for GitBackendError {
64 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
65 match self {
66 Self::Io(e) => Some(e),
67 _ => None,
68 }
69 }
70}
71
72impl From<std::io::Error> for GitBackendError {
73 fn from(e: std::io::Error) -> Self {
74 Self::Io(e)
75 }
76}
77
78pub struct GitWorktreeBackend {
84 root: PathBuf,
86}
87
88impl GitWorktreeBackend {
89 #[must_use]
91 pub const fn new(root: PathBuf) -> Self {
92 Self { root }
93 }
94
95 fn workspaces_dir(&self) -> PathBuf {
97 self.root.join("ws")
98 }
99
100 fn git_stdout(&self, args: &[&str]) -> Result<String, GitBackendError> {
102 let output = Command::new("git")
103 .args(args)
104 .current_dir(&self.root)
105 .output()
106 .map_err(GitBackendError::Io)?;
107
108 if output.status.success() {
109 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
110 } else {
111 Err(GitBackendError::GitCommand {
112 command: format!("git {}", args.join(" ")),
113 stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
114 exit_code: output.status.code(),
115 })
116 }
117 }
118
119 fn git_stdout_in(dir: &std::path::Path, args: &[&str]) -> Result<String, GitBackendError> {
121 let output = Command::new("git")
122 .args(args)
123 .current_dir(dir)
124 .output()
125 .map_err(GitBackendError::Io)?;
126
127 if output.status.success() {
128 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
129 } else {
130 Err(GitBackendError::GitCommand {
131 command: format!("git {}", args.join(" ")),
132 stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
133 exit_code: output.status.code(),
134 })
135 }
136 }
137
138 fn current_epoch_opt(&self) -> Option<EpochId> {
142 let output = Command::new("git")
143 .args(["rev-parse", "refs/manifold/epoch/current"])
144 .current_dir(&self.root)
145 .output()
146 .ok()?;
147 if output.status.success() {
148 let oid_str = String::from_utf8_lossy(&output.stdout).trim().to_owned();
149 EpochId::new(&oid_str).ok()
150 } else {
151 None
152 }
153 }
154
155 fn is_ancestor(&self, ancestor: &str, descendant: &str) -> bool {
159 Command::new("git")
160 .args(["merge-base", "--is-ancestor", ancestor, descendant])
161 .current_dir(&self.root)
162 .status()
163 .map(|s| s.success())
164 .unwrap_or(false)
165 }
166
167 fn count_commits_between(&self, from_oid: &str, to_oid: &str) -> Option<u32> {
172 let range = format!("{from_oid}..{to_oid}");
173 let output = Command::new("git")
174 .args(["rev-list", "--count", &range])
175 .current_dir(&self.root)
176 .output()
177 .ok()?;
178 if output.status.success() {
179 String::from_utf8_lossy(&output.stdout).trim().parse().ok()
180 } else {
181 None
182 }
183 }
184
185 fn git_compat_refs_enabled(&self) -> bool {
189 let config_path = self.root.join(".manifold").join("config.toml");
190 ManifoldConfig::load(&config_path)
191 .map(|cfg| cfg.workspace.git_compat_refs)
192 .unwrap_or(true)
193 }
194
195 fn refresh_workspace_state_ref(
202 &self,
203 name: &WorkspaceId,
204 ws_path: &Path,
205 ) -> Result<(), GitBackendError> {
206 if !self.git_compat_refs_enabled() {
207 return Ok(());
208 }
209
210 let ref_name = manifold_refs::workspace_state_ref(name.as_str());
211
212 let stash_oid = Self::git_stdout_in(ws_path, &["stash", "create"])?;
213 let oid_str = stash_oid.trim();
214
215 let materialized = if oid_str.is_empty() {
216 Self::git_stdout_in(ws_path, &["rev-parse", "HEAD"])?
217 } else {
218 stash_oid
219 };
220 let materialized = materialized.trim();
221
222 let oid = GitOid::new(materialized).map_err(|e| GitBackendError::GitCommand {
223 command: "git stash create / git rev-parse HEAD".to_owned(),
224 stderr: format!("invalid OID while materializing workspace ref: {e}"),
225 exit_code: None,
226 })?;
227
228 manifold_refs::write_ref(&self.root, &ref_name, &oid).map_err(|e| {
229 GitBackendError::GitCommand {
230 command: format!("git update-ref {ref_name} {}", oid.as_str()),
231 stderr: e.to_string(),
232 exit_code: None,
233 }
234 })
235 }
236}
237
238impl WorkspaceBackend for GitWorktreeBackend {
239 type Error = GitBackendError;
240
241 fn create(&self, name: &WorkspaceId, epoch: &EpochId) -> Result<WorkspaceInfo, Self::Error> {
242 let path = self.workspace_path(name);
243
244 if self.exists(name) {
246 return Ok(WorkspaceInfo {
247 id: name.clone(),
248 path,
249 epoch: epoch.clone(),
250 state: WorkspaceState::Active,
251 mode: WorkspaceMode::default(),
252 commits_ahead: 0,
253 });
254 }
255
256 if path.exists() {
258 std::fs::remove_dir_all(&path)?;
259 }
260
261 let _ = Command::new("git")
263 .args(["worktree", "prune"])
264 .current_dir(&self.root)
265 .output();
266
267 let ws_dir = self.workspaces_dir();
269 std::fs::create_dir_all(&ws_dir)?;
270
271 let path_str = path.to_str().unwrap();
273 let output = Command::new("git")
274 .args(["worktree", "add", "--detach", path_str, epoch.as_str()])
275 .current_dir(&self.root)
276 .output()
277 .map_err(GitBackendError::Io)?;
278
279 if !output.status.success() {
280 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
281
282 if path.exists() {
284 let _ = std::fs::remove_dir_all(&path);
285 }
286
287 return Err(GitBackendError::GitCommand {
288 command: "git worktree add".to_owned(),
289 stderr,
290 exit_code: output.status.code(),
291 });
292 }
293
294 let epoch_ref = manifold_refs::workspace_epoch_ref(name.as_str());
297 let epoch_oid = GitOid::new(epoch.as_str()).map_err(|e| GitBackendError::GitCommand {
298 command: "record workspace epoch".to_owned(),
299 stderr: format!("invalid epoch OID: {e}"),
300 exit_code: None,
301 })?;
302 manifold_refs::write_ref(&self.root, &epoch_ref, &epoch_oid).map_err(|e| {
303 GitBackendError::GitCommand {
304 command: format!("git update-ref {epoch_ref}"),
305 stderr: e.to_string(),
306 exit_code: None,
307 }
308 })?;
309
310 Ok(WorkspaceInfo {
311 id: name.clone(),
312 path,
313 epoch: epoch.clone(),
314 state: WorkspaceState::Active,
315 mode: WorkspaceMode::default(),
316 commits_ahead: 0,
317 })
318 }
319
320 fn destroy(&self, name: &WorkspaceId) -> Result<(), Self::Error> {
331 let path = self.workspace_path(name);
332
333 if path.exists() {
336 let path_str = path.to_str().unwrap();
337 let output = Command::new("git")
338 .args(["worktree", "remove", "--force", path_str])
339 .current_dir(&self.root)
340 .output()
341 .map_err(GitBackendError::Io)?;
342
343 if !output.status.success() {
344 if path.exists() {
347 std::fs::remove_dir_all(&path)?;
348 }
349 }
350 }
351
352 let _ = Command::new("git")
356 .args(["worktree", "prune"])
357 .current_dir(&self.root)
358 .output();
359
360 let ws_ref = manifold_refs::workspace_state_ref(name.as_str());
362 let _ = manifold_refs::delete_ref(&self.root, &ws_ref);
363
364 let epoch_ref = manifold_refs::workspace_epoch_ref(name.as_str());
366 let _ = manifold_refs::delete_ref(&self.root, &epoch_ref);
367
368 Ok(())
369 }
370
371 fn list(&self) -> Result<Vec<WorkspaceInfo>, Self::Error> {
381 let output = self.git_stdout(&["worktree", "list", "--porcelain"])?;
382 let current_epoch = self.current_epoch_opt();
383 let ws_dir = self.workspaces_dir();
384
385 let mut infos = Vec::new();
386
387 for block in output.split("\n\n") {
389 let block = block.trim();
390 if block.is_empty() {
391 continue;
392 }
393
394 let mut wt_path: Option<PathBuf> = None;
395 let mut wt_head: Option<String> = None;
396 let mut is_bare = false;
397
398 for line in block.lines() {
399 if let Some(p) = line.strip_prefix("worktree ") {
400 wt_path = Some(PathBuf::from(p));
401 } else if let Some(h) = line.strip_prefix("HEAD ") {
402 wt_head = Some(h.to_owned());
403 } else if line.trim() == "bare" {
404 is_bare = true;
405 }
406 }
407
408 if is_bare {
410 continue;
411 }
412
413 let (Some(path), Some(head_str)) = (wt_path, wt_head) else {
414 continue;
416 };
417
418 let Ok(rel) = path.strip_prefix(&ws_dir) else {
420 continue;
421 };
422
423 let components: Vec<_> = rel.components().collect();
425 if components.len() != 1 {
426 continue;
427 }
428 let Some(name_str) = components[0].as_os_str().to_str() else {
429 continue;
430 };
431
432 let Ok(id) = WorkspaceId::new(name_str) else {
433 continue;
435 };
436
437 let Ok(head_epoch) = EpochId::new(head_str.trim()) else {
438 continue;
440 };
441
442 let epoch_ref = manifold_refs::workspace_epoch_ref(name_str);
446 let epoch = match manifold_refs::read_ref(&self.root, &epoch_ref) {
447 Ok(Some(oid)) => EpochId::new(oid.as_str()).unwrap_or(head_epoch.clone()),
448 _ => head_epoch.clone(),
449 };
450
451 let (state, commits_ahead) = match ¤t_epoch {
462 Some(current) if epoch == *current => {
463 let ahead = if head_epoch != epoch {
465 self.count_commits_between(epoch.as_str(), head_epoch.as_str())
466 .unwrap_or(1)
467 } else {
468 0
469 };
470 (WorkspaceState::Active, ahead)
471 }
472 Some(current) => {
473 if self.is_ancestor(current.as_str(), epoch.as_str()) {
474 let ahead = self
475 .count_commits_between(current.as_str(), head_epoch.as_str())
476 .unwrap_or(1);
477 (WorkspaceState::Active, ahead)
478 } else {
479 let behind = self
480 .count_commits_between(epoch.as_str(), current.as_str())
481 .unwrap_or(1);
482 (WorkspaceState::Stale { behind_epochs: behind }, 0)
483 }
484 }
485 None => (WorkspaceState::Active, 0),
486 };
487
488 infos.push(WorkspaceInfo {
489 id,
490 path,
491 epoch,
492 state,
493 mode: WorkspaceMode::default(),
494 commits_ahead,
495 });
496 }
497
498 Ok(infos)
499 }
500
501 fn status(&self, name: &WorkspaceId) -> Result<WorkspaceStatus, Self::Error> {
509 let ws_path = self.workspace_path(name);
510
511 if !ws_path.exists() {
512 return Err(GitBackendError::NotFound {
513 name: name.as_str().to_owned(),
514 });
515 }
516
517 let base_epoch = {
526 let epoch_ref = manifold_refs::workspace_epoch_ref(name.as_str());
527 match manifold_refs::read_ref(&self.root, &epoch_ref) {
528 Ok(Some(oid)) => {
529 EpochId::new(oid.as_str()).map_err(|e| GitBackendError::GitCommand {
530 command: format!("read {epoch_ref}"),
531 stderr: format!("invalid OID from workspace epoch ref: {e}"),
532 exit_code: None,
533 })?
534 }
535 _ => {
536 let head_str = Self::git_stdout_in(&ws_path, &["rev-parse", "HEAD"])?;
538 EpochId::new(head_str.trim()).map_err(|e| GitBackendError::GitCommand {
539 command: "git rev-parse HEAD".to_owned(),
540 stderr: format!("invalid OID from HEAD: {e}"),
541 exit_code: None,
542 })?
543 }
544 }
545 };
546
547 let status_output = Self::git_stdout_in(&ws_path, &["status", "--porcelain"])?;
549 let dirty_files = parse_porcelain_status(&status_output);
550
551 let is_stale = self.current_epoch_opt().is_some_and(|current| {
564 if base_epoch == current {
565 return false;
566 }
567 let result = Command::new("git")
571 .args(["merge-base", "--is-ancestor", current.as_str(), "HEAD"])
572 .current_dir(&ws_path)
573 .status();
574 match result {
575 Ok(status) => !status.success(), Err(_) => true, }
578 });
579
580 self.refresh_workspace_state_ref(name, &ws_path)?;
582
583 Ok(WorkspaceStatus::new(base_epoch, dirty_files, is_stale))
584 }
585
586 fn snapshot(&self, name: &WorkspaceId) -> Result<SnapshotResult, Self::Error> {
603 let ws_path = self.workspace_path(name);
604 if !ws_path.exists() {
605 return Err(GitBackendError::NotFound {
606 name: name.as_str().to_owned(),
607 });
608 }
609
610 let base_oid = match self.current_epoch_opt() {
613 Some(epoch) => epoch.as_str().to_owned(),
614 None => {
615 let head = Self::git_stdout_in(&ws_path, &["rev-parse", "HEAD"])?;
616 head.trim().to_owned()
617 }
618 };
619
620 let mut added = Vec::new();
621 let mut modified = Vec::new();
622 let mut deleted = Vec::new();
623
624 let diff_output =
628 Self::git_stdout_in(&ws_path, &["diff", "--name-status", &base_oid])?;
629
630 parse_name_status(&diff_output, &mut added, &mut modified, &mut deleted);
631
632 let untracked_output =
634 Self::git_stdout_in(&ws_path, &["ls-files", "--others", "--exclude-standard"])?;
635
636 for line in untracked_output.lines() {
637 let path = line.trim();
638 if !path.is_empty() {
639 let p = PathBuf::from(path);
640 if !added.contains(&p) {
641 added.push(p);
642 }
643 }
644 }
645
646 added.sort();
648 added.dedup();
649 modified.sort();
650 modified.dedup();
651 deleted.sort();
652 deleted.dedup();
653
654 modified.retain(|p| !added.contains(p));
656
657 self.refresh_workspace_state_ref(name, &ws_path)?;
659
660 Ok(SnapshotResult::new(added, modified, deleted))
661 }
662
663 fn workspace_path(&self, name: &WorkspaceId) -> PathBuf {
664 self.workspaces_dir().join(name.as_str())
665 }
666
667 fn exists(&self, name: &WorkspaceId) -> bool {
668 let path = self.workspace_path(name);
669 if !path.exists() {
670 return false;
671 }
672
673 let output = Command::new("git")
675 .args(["worktree", "list", "--porcelain"])
676 .current_dir(&self.root)
677 .output();
678
679 if let Ok(out) = output {
680 let stdout = String::from_utf8_lossy(&out.stdout);
681 let path_str = path.to_str().unwrap_or_default();
682 for line in stdout.lines() {
683 if let Some(wt_path) = line.strip_prefix("worktree ")
684 && wt_path == path_str
685 {
686 return true;
687 }
688 }
689 }
690
691 false
692 }
693}
694
695#[cfg(test)]
701#[derive(Debug, Default)]
702struct WorktreeEntry {
703 path: String,
705 head: Option<String>,
707 #[allow(dead_code)]
709 branch: Option<String>,
710}
711
712#[cfg(test)]
724fn parse_worktree_porcelain(raw: &str) -> Vec<WorktreeEntry> {
725 let mut entries = Vec::new();
726 let mut current = WorktreeEntry::default();
727 let mut in_entry = false;
728
729 for line in raw.lines() {
730 if line.is_empty() {
731 if in_entry && !current.path.is_empty() {
732 entries.push(current);
733 current = WorktreeEntry::default();
734 in_entry = false;
735 }
736 continue;
737 }
738
739 if let Some(path) = line.strip_prefix("worktree ") {
740 current.path = path.trim().to_owned();
741 in_entry = true;
742 } else if let Some(head) = line.strip_prefix("HEAD ") {
743 current.head = Some(head.trim().to_owned());
744 } else if let Some(branch) = line.strip_prefix("branch ") {
745 current.branch = Some(branch.trim().to_owned());
746 }
747 }
749
750 if in_entry && !current.path.is_empty() {
752 entries.push(current);
753 }
754
755 entries
756}
757
758fn parse_porcelain_status(output: &str) -> Vec<PathBuf> {
767 let mut paths = Vec::new();
768 for line in output.lines() {
769 if line.len() < 4 {
771 continue;
772 }
773 let path_str = &line[3..];
775 if !path_str.is_empty() {
776 let path_part = if line.starts_with('R') || line.starts_with('C') {
779 path_str.split(" -> ").last().unwrap_or(path_str)
780 } else {
781 path_str
782 };
783
784 let path_part = path_part
786 .strip_prefix('"')
787 .and_then(|s| s.strip_suffix('"'))
788 .unwrap_or(path_part);
789 paths.push(PathBuf::from(path_part));
790 }
791 }
792 paths
793}
794fn parse_name_status(
796 output: &str,
797 added: &mut Vec<PathBuf>,
798 modified: &mut Vec<PathBuf>,
799 deleted: &mut Vec<PathBuf>,
800) {
801 for line in output.lines() {
802 let line = line.trim();
803 if line.is_empty() {
804 continue;
805 }
806 let (status, path) = if let Some(rest) =
808 line.strip_prefix("A\t").or_else(|| line.strip_prefix("A "))
809 {
810 ('A', rest.trim())
811 } else if let Some(rest) = line.strip_prefix("M\t").or_else(|| line.strip_prefix("M ")) {
812 ('M', rest.trim())
813 } else if let Some(rest) = line.strip_prefix("D\t").or_else(|| line.strip_prefix("D ")) {
814 ('D', rest.trim())
815 } else if line.starts_with('R') {
816 let parts: Vec<&str> = line.split('\t').collect();
819 if parts.len() >= 3 {
820 deleted.push(PathBuf::from(parts[1].trim()));
821 added.push(PathBuf::from(parts[2].trim()));
822 }
823 continue;
824 } else {
825 continue;
827 };
828
829 let p = PathBuf::from(path);
830 match status {
831 'A' => added.push(p),
832 'M' => modified.push(p),
833 'D' => deleted.push(p),
834 _ => {}
835 }
836 }
837}
838
839#[cfg(test)]
844#[allow(clippy::redundant_clone)]
845mod tests {
846 use super::*;
847 use std::fs;
848 use tempfile::TempDir;
849
850 fn setup_git_repo() -> (TempDir, EpochId) {
852 let temp_dir = TempDir::new().unwrap();
853 let root = temp_dir.path();
854
855 Command::new("git")
856 .args(["init"])
857 .current_dir(root)
858 .output()
859 .unwrap();
860
861 Command::new("git")
862 .args(["config", "user.name", "Test User"])
863 .current_dir(root)
864 .output()
865 .unwrap();
866 Command::new("git")
867 .args(["config", "user.email", "test@example.com"])
868 .current_dir(root)
869 .output()
870 .unwrap();
871 Command::new("git")
872 .args(["config", "commit.gpgsign", "false"])
873 .current_dir(root)
874 .output()
875 .unwrap();
876
877 fs::write(root.join("README.md"), "# Test Repo").unwrap();
878 Command::new("git")
879 .args(["add", "README.md"])
880 .current_dir(root)
881 .output()
882 .unwrap();
883 Command::new("git")
884 .args(["commit", "-m", "Initial commit"])
885 .current_dir(root)
886 .output()
887 .unwrap();
888
889 let output = Command::new("git")
890 .args(["rev-parse", "HEAD"])
891 .current_dir(root)
892 .output()
893 .unwrap();
894 let oid_str = String::from_utf8(output.stdout).unwrap().trim().to_string();
895 let epoch = EpochId::new(&oid_str).unwrap();
896
897 (temp_dir, epoch)
898 }
899
900 fn read_ws_ref(root: &std::path::Path, ws: &str) -> Option<String> {
901 let ref_name = manifold_refs::workspace_state_ref(ws);
902 let out = Command::new("git")
903 .args(["rev-parse", &ref_name])
904 .current_dir(root)
905 .output()
906 .unwrap();
907 if !out.status.success() {
908 return None;
909 }
910 Some(String::from_utf8(out.stdout).unwrap().trim().to_owned())
911 }
912
913 #[test]
916 fn test_create_workspace() {
917 let (temp_dir, epoch) = setup_git_repo();
918 let root = temp_dir.path().to_path_buf();
919 let backend = GitWorktreeBackend::new(root.clone());
920 let ws_name = WorkspaceId::new("test-ws").unwrap();
921
922 let info = backend.create(&ws_name, &epoch).unwrap();
923 assert_eq!(info.id, ws_name);
924 assert_eq!(info.path, root.join("ws").join("test-ws"));
925 assert!(info.path.exists());
926 assert!(info.path.join(".git").exists());
927
928 let info2 = backend.create(&ws_name, &epoch).unwrap();
930 assert_eq!(info2.path, info.path);
931 }
932
933 #[test]
934 fn test_create_cleanup_stale_directory() {
935 let (temp_dir, epoch) = setup_git_repo();
936 let root = temp_dir.path().to_path_buf();
937 let backend = GitWorktreeBackend::new(root.clone());
938 let ws_name = WorkspaceId::new("fail-ws").unwrap();
939
940 let ws_path = root.join("ws").join("fail-ws");
941 fs::create_dir_all(&ws_path).unwrap();
942 fs::write(ws_path.join("garbage.txt"), "garbage").unwrap();
943
944 let info = backend.create(&ws_name, &epoch).unwrap();
945 assert!(info.path.exists());
946 assert!(!ws_path.join("garbage.txt").exists());
947 }
948
949 #[test]
952 fn test_exists_false_for_nonexistent() {
953 let (temp_dir, _epoch) = setup_git_repo();
954 let backend = GitWorktreeBackend::new(temp_dir.path().to_path_buf());
955 assert!(!backend.exists(&WorkspaceId::new("nope").unwrap()));
956 }
957
958 #[test]
959 fn test_exists_true_after_create() {
960 let (temp_dir, epoch) = setup_git_repo();
961 let backend = GitWorktreeBackend::new(temp_dir.path().to_path_buf());
962 let ws_name = WorkspaceId::new("exists-ws").unwrap();
963
964 backend.create(&ws_name, &epoch).unwrap();
965 assert!(backend.exists(&ws_name));
966 }
967
968 #[test]
971 fn test_workspace_path() {
972 let (temp_dir, _epoch) = setup_git_repo();
973 let root = temp_dir.path().to_path_buf();
974 let backend = GitWorktreeBackend::new(root.clone());
975 let ws_name = WorkspaceId::new("path-test").unwrap();
976
977 assert_eq!(backend.workspace_path(&ws_name), root.join("ws/path-test"));
978 }
979
980 #[test]
983 fn test_snapshot_empty() {
984 let (temp_dir, epoch) = setup_git_repo();
985 let backend = GitWorktreeBackend::new(temp_dir.path().to_path_buf());
986 let ws_name = WorkspaceId::new("snap-empty").unwrap();
987 backend.create(&ws_name, &epoch).unwrap();
988
989 let snap = backend.snapshot(&ws_name).unwrap();
990 assert!(snap.is_empty(), "no changes expected: {snap:?}");
991 }
992
993 #[test]
994 fn test_snapshot_added_file() {
995 let (temp_dir, epoch) = setup_git_repo();
996 let root = temp_dir.path().to_path_buf();
997 let backend = GitWorktreeBackend::new(root.clone());
998 let ws_name = WorkspaceId::new("snap-add").unwrap();
999 let info = backend.create(&ws_name, &epoch).unwrap();
1000
1001 fs::write(info.path.join("newfile.txt"), "hello").unwrap();
1003
1004 let snap = backend.snapshot(&ws_name).unwrap();
1005 assert_eq!(snap.added.len(), 1, "expected 1 added: {snap:?}");
1006 assert_eq!(snap.added[0], PathBuf::from("newfile.txt"));
1007 assert!(snap.modified.is_empty());
1008 assert!(snap.deleted.is_empty());
1009 }
1010
1011 #[test]
1012 fn test_snapshot_modified_file() {
1013 let (temp_dir, epoch) = setup_git_repo();
1014 let backend = GitWorktreeBackend::new(temp_dir.path().to_path_buf());
1015 let ws_name = WorkspaceId::new("snap-mod").unwrap();
1016 let info = backend.create(&ws_name, &epoch).unwrap();
1017
1018 fs::write(info.path.join("README.md"), "# Modified").unwrap();
1020
1021 let snap = backend.snapshot(&ws_name).unwrap();
1022 assert!(snap.added.is_empty(), "no adds: {snap:?}");
1023 assert_eq!(snap.modified.len(), 1, "expected 1 modified: {snap:?}");
1024 assert_eq!(snap.modified[0], PathBuf::from("README.md"));
1025 assert!(snap.deleted.is_empty());
1026 }
1027
1028 #[test]
1029 fn test_snapshot_deleted_file() {
1030 let (temp_dir, epoch) = setup_git_repo();
1031 let backend = GitWorktreeBackend::new(temp_dir.path().to_path_buf());
1032 let ws_name = WorkspaceId::new("snap-del").unwrap();
1033 let info = backend.create(&ws_name, &epoch).unwrap();
1034
1035 fs::remove_file(info.path.join("README.md")).unwrap();
1037
1038 let snap = backend.snapshot(&ws_name).unwrap();
1039 assert!(snap.added.is_empty());
1040 assert!(snap.modified.is_empty());
1041 assert_eq!(snap.deleted.len(), 1, "expected 1 deleted: {snap:?}");
1042 assert_eq!(snap.deleted[0], PathBuf::from("README.md"));
1043 }
1044
1045 #[test]
1046 fn test_snapshot_mixed_changes() {
1047 let (temp_dir, epoch) = setup_git_repo();
1048 let backend = GitWorktreeBackend::new(temp_dir.path().to_path_buf());
1049 let ws_name = WorkspaceId::new("snap-mix").unwrap();
1050 let info = backend.create(&ws_name, &epoch).unwrap();
1051
1052 fs::write(info.path.join("new.rs"), "fn main() {}").unwrap();
1054 fs::write(info.path.join("README.md"), "# Changed").unwrap();
1055 let snap = backend.snapshot(&ws_name).unwrap();
1058 assert_eq!(snap.added.len(), 1);
1059 assert_eq!(snap.modified.len(), 1);
1060 assert_eq!(snap.change_count(), 2);
1061 }
1062
1063 #[test]
1064 fn test_snapshot_ignores_gitignored() {
1065 let (temp_dir, epoch) = setup_git_repo();
1066 let backend = GitWorktreeBackend::new(temp_dir.path().to_path_buf());
1067 let ws_name = WorkspaceId::new("snap-ignore").unwrap();
1068 let info = backend.create(&ws_name, &epoch).unwrap();
1069
1070 fs::write(info.path.join(".gitignore"), "*.log\n").unwrap();
1072 fs::write(info.path.join("debug.log"), "log data").unwrap();
1073
1074 let snap = backend.snapshot(&ws_name).unwrap();
1075 let has_log = snap.added.iter().any(|p| p.to_str() == Some("debug.log"));
1077 assert!(!has_log, "gitignored file should not appear: {snap:?}");
1078 let has_gitignore = snap.added.iter().any(|p| p.to_str() == Some(".gitignore"));
1079 assert!(has_gitignore, ".gitignore should appear: {snap:?}");
1080 }
1081
1082 #[test]
1083 fn test_snapshot_nonexistent_workspace() {
1084 let (temp_dir, _epoch) = setup_git_repo();
1085 let backend = GitWorktreeBackend::new(temp_dir.path().to_path_buf());
1086 let ws_name = WorkspaceId::new("nope").unwrap();
1087
1088 let err = backend.snapshot(&ws_name).unwrap_err();
1089 assert!(
1090 matches!(err, GitBackendError::NotFound { .. }),
1091 "should be NotFound: {err}"
1092 );
1093 }
1094
1095 #[test]
1096 fn test_snapshot_materializes_workspace_state_ref() {
1097 let (temp_dir, epoch) = setup_git_repo();
1098 let root = temp_dir.path().to_path_buf();
1099 let backend = GitWorktreeBackend::new(root.clone());
1100 let ws_name = WorkspaceId::new("snap-ref").unwrap();
1101 let info = backend.create(&ws_name, &epoch).unwrap();
1102
1103 fs::write(info.path.join("README.md"), "# changed from workspace").unwrap();
1104 let _snap = backend.snapshot(&ws_name).unwrap();
1105
1106 let ref_oid = read_ws_ref(&root, ws_name.as_str()).expect("workspace ref should exist");
1107 let head_oid = Command::new("git")
1108 .args(["rev-parse", "HEAD"])
1109 .current_dir(&root)
1110 .output()
1111 .unwrap();
1112 let head_oid = String::from_utf8(head_oid.stdout)
1113 .unwrap()
1114 .trim()
1115 .to_owned();
1116 assert_ne!(
1117 ref_oid, head_oid,
1118 "dirty workspace should materialize non-HEAD commit"
1119 );
1120
1121 let ref_name = manifold_refs::workspace_state_ref(ws_name.as_str());
1122 let diff_out = Command::new("git")
1123 .args(["diff", "--name-only", &format!("HEAD..{ref_name}")])
1124 .current_dir(&root)
1125 .output()
1126 .unwrap();
1127 let diff = String::from_utf8(diff_out.stdout).unwrap();
1128 assert!(
1129 diff.lines().any(|l| l.trim() == "README.md"),
1130 "diff should include README.md: {diff}"
1131 );
1132 }
1133
1134 #[test]
1135 fn test_snapshot_skips_workspace_state_ref_when_disabled_in_config() {
1136 let (temp_dir, epoch) = setup_git_repo();
1137 let root = temp_dir.path().to_path_buf();
1138 std::fs::create_dir_all(root.join(".manifold")).unwrap();
1139 std::fs::write(
1140 root.join(".manifold").join("config.toml"),
1141 "[workspace]\ngit_compat_refs = false\n",
1142 )
1143 .unwrap();
1144
1145 let backend = GitWorktreeBackend::new(root.clone());
1146 let ws_name = WorkspaceId::new("snap-no-ref").unwrap();
1147 let info = backend.create(&ws_name, &epoch).unwrap();
1148
1149 fs::write(
1150 info.path.join("README.md"),
1151 "# changed with compat disabled",
1152 )
1153 .unwrap();
1154 let _snap = backend.snapshot(&ws_name).unwrap();
1155
1156 assert!(
1157 read_ws_ref(&root, ws_name.as_str()).is_none(),
1158 "workspace ref should not be created when disabled"
1159 );
1160 }
1161
1162 #[test]
1165 fn test_destroy_workspace() {
1166 let (temp_dir, epoch) = setup_git_repo();
1167 let root = temp_dir.path().to_path_buf();
1168 let backend = GitWorktreeBackend::new(root.clone());
1169 let ws_name = WorkspaceId::new("destroy-ws").unwrap();
1170
1171 let info = backend.create(&ws_name, &epoch).unwrap();
1173 assert!(info.path.exists());
1174
1175 fs::write(info.path.join("README.md"), "# dirty before destroy").unwrap();
1177 let _ = backend.snapshot(&ws_name).unwrap();
1178 assert!(read_ws_ref(&root, ws_name.as_str()).is_some());
1179
1180 backend.destroy(&ws_name).unwrap();
1181 assert!(!info.path.exists(), "directory should be gone");
1182 assert!(!backend.exists(&ws_name), "should not exist in git");
1183 assert!(
1184 read_ws_ref(&root, ws_name.as_str()).is_none(),
1185 "workspace ref should be pruned on destroy"
1186 );
1187 }
1188
1189 #[test]
1190 fn test_destroy_idempotent() {
1191 let (temp_dir, epoch) = setup_git_repo();
1192 let backend = GitWorktreeBackend::new(temp_dir.path().to_path_buf());
1193 let ws_name = WorkspaceId::new("destroy-idem").unwrap();
1194
1195 backend.create(&ws_name, &epoch).unwrap();
1196
1197 backend.destroy(&ws_name).unwrap();
1199 backend.destroy(&ws_name).unwrap();
1200 }
1201
1202 #[test]
1203 fn test_destroy_never_existed() {
1204 let (temp_dir, _epoch) = setup_git_repo();
1205 let backend = GitWorktreeBackend::new(temp_dir.path().to_path_buf());
1206 let ws_name = WorkspaceId::new("no-such-ws").unwrap();
1207
1208 backend.destroy(&ws_name).unwrap();
1210 }
1211
1212 #[test]
1213 fn test_destroy_with_dirty_files() {
1214 let (temp_dir, epoch) = setup_git_repo();
1215 let backend = GitWorktreeBackend::new(temp_dir.path().to_path_buf());
1216 let ws_name = WorkspaceId::new("dirty-destroy").unwrap();
1217
1218 let info = backend.create(&ws_name, &epoch).unwrap();
1219
1220 fs::write(info.path.join("dirty.txt"), "uncommitted").unwrap();
1222 fs::write(info.path.join("README.md"), "modified").unwrap();
1223
1224 backend.destroy(&ws_name).unwrap();
1226 assert!(!info.path.exists());
1227 assert!(!backend.exists(&ws_name));
1228 }
1229
1230 #[test]
1231 fn test_destroy_manual_dir_removal() {
1232 let (temp_dir, epoch) = setup_git_repo();
1233 let backend = GitWorktreeBackend::new(temp_dir.path().to_path_buf());
1234 let ws_name = WorkspaceId::new("manual-rm").unwrap();
1235
1236 let info = backend.create(&ws_name, &epoch).unwrap();
1237
1238 fs::remove_dir_all(&info.path).unwrap();
1240 assert!(!info.path.exists());
1241
1242 backend.destroy(&ws_name).unwrap();
1244 assert!(!backend.exists(&ws_name));
1245 }
1246
1247 #[test]
1248 fn test_create_after_destroy() {
1249 let (temp_dir, epoch) = setup_git_repo();
1250 let backend = GitWorktreeBackend::new(temp_dir.path().to_path_buf());
1251 let ws_name = WorkspaceId::new("recreate-ws").unwrap();
1252
1253 backend.create(&ws_name, &epoch).unwrap();
1255 backend.destroy(&ws_name).unwrap();
1256 let info = backend.create(&ws_name, &epoch).unwrap();
1257 assert!(info.path.exists());
1258 assert!(backend.exists(&ws_name));
1259 }
1260
1261 #[test]
1264 fn test_parse_name_status() {
1265 let mut added = Vec::new();
1266 let mut modified = Vec::new();
1267 let mut deleted = Vec::new();
1268
1269 let output = "A\tsrc/new.rs\nM\tsrc/main.rs\nD\told.rs\n";
1270 parse_name_status(output, &mut added, &mut modified, &mut deleted);
1271
1272 assert_eq!(added, vec![PathBuf::from("src/new.rs")]);
1273 assert_eq!(modified, vec![PathBuf::from("src/main.rs")]);
1274 assert_eq!(deleted, vec![PathBuf::from("old.rs")]);
1275 }
1276
1277 #[test]
1278 fn test_parse_name_status_rename() {
1279 let mut added = Vec::new();
1280 let mut modified = Vec::new();
1281 let mut deleted = Vec::new();
1282
1283 let output = "R100\told_name.rs\tnew_name.rs\n";
1285 parse_name_status(output, &mut added, &mut modified, &mut deleted);
1286
1287 assert_eq!(added, vec![PathBuf::from("new_name.rs")]);
1289 }
1290
1291 #[test]
1292 fn test_parse_porcelain_status_rename() {
1293 let output = "R old.rs -> new.rs\n";
1295 let paths = parse_porcelain_status(output);
1296
1297 assert_eq!(paths, vec![PathBuf::from("new.rs")]);
1299 }
1300
1301 #[test]
1302 fn test_parse_name_status_empty() {
1303 let mut added = Vec::new();
1304 let mut modified = Vec::new();
1305 let mut deleted = Vec::new();
1306
1307 parse_name_status("", &mut added, &mut modified, &mut deleted);
1308 assert!(added.is_empty());
1309 assert!(modified.is_empty());
1310 assert!(deleted.is_empty());
1311 }
1312
1313 #[test]
1316 fn test_parse_porcelain_status_empty() {
1317 let paths = parse_porcelain_status("");
1318 assert!(paths.is_empty());
1319 }
1320
1321 #[test]
1322 fn test_parse_porcelain_status_modified() {
1323 let output = " M src/main.rs\n";
1324 let paths = parse_porcelain_status(output);
1325 assert_eq!(paths, vec![PathBuf::from("src/main.rs")]);
1326 }
1327
1328 #[test]
1329 fn test_parse_porcelain_status_staged() {
1330 let output = "M src/lib.rs\n";
1331 let paths = parse_porcelain_status(output);
1332 assert_eq!(paths, vec![PathBuf::from("src/lib.rs")]);
1333 }
1334
1335 #[test]
1336 fn test_parse_porcelain_status_untracked() {
1337 let output = "?? new_file.txt\n";
1338 let paths = parse_porcelain_status(output);
1339 assert_eq!(paths, vec![PathBuf::from("new_file.txt")]);
1340 }
1341
1342 #[test]
1343 fn test_parse_porcelain_status_deleted() {
1344 let output = " D old_file.rs\n";
1345 let paths = parse_porcelain_status(output);
1346 assert_eq!(paths, vec![PathBuf::from("old_file.rs")]);
1347 }
1348
1349 #[test]
1350 fn test_parse_porcelain_status_mixed() {
1351 let output = " M src/main.rs\n?? untracked.txt\n D gone.rs\n";
1352 let paths = parse_porcelain_status(output);
1353 assert_eq!(paths.len(), 3);
1354 assert!(paths.contains(&PathBuf::from("src/main.rs")));
1355 assert!(paths.contains(&PathBuf::from("untracked.txt")));
1356 assert!(paths.contains(&PathBuf::from("gone.rs")));
1357 }
1358
1359 #[test]
1360 fn test_parse_porcelain_status_quoted_path() {
1361 let output = "?? \"path with spaces.txt\"\n";
1363 let paths = parse_porcelain_status(output);
1364 assert_eq!(paths, vec![PathBuf::from("path with spaces.txt")]);
1365 }
1366
1367 #[test]
1370 fn test_list_empty_no_workspaces() {
1371 let (temp_dir, _epoch) = setup_git_repo();
1372 let backend = GitWorktreeBackend::new(temp_dir.path().to_path_buf());
1373
1374 let infos = backend.list().unwrap();
1375 assert!(infos.is_empty(), "no workspaces under ws/ yet: {infos:?}");
1376 }
1377
1378 #[test]
1379 fn test_list_single_workspace() {
1380 let (temp_dir, epoch) = setup_git_repo();
1381 let root = temp_dir.path().to_path_buf();
1382 let backend = GitWorktreeBackend::new(root.clone());
1383 let ws_name = WorkspaceId::new("list-ws").unwrap();
1384
1385 backend.create(&ws_name, &epoch).unwrap();
1386
1387 let infos = backend.list().unwrap();
1388 assert_eq!(infos.len(), 1, "expected 1 workspace: {infos:?}");
1389 assert_eq!(infos[0].id, ws_name);
1390 assert_eq!(infos[0].path, root.join("ws/list-ws"));
1391 assert_eq!(infos[0].epoch, epoch);
1392 assert!(infos[0].state.is_active(), "no epoch ref → active");
1393 }
1394
1395 #[test]
1396 fn test_list_multiple_workspaces() {
1397 let (temp_dir, epoch) = setup_git_repo();
1398 let backend = GitWorktreeBackend::new(temp_dir.path().to_path_buf());
1399
1400 let a = WorkspaceId::new("alpha").unwrap();
1401 let b = WorkspaceId::new("beta").unwrap();
1402 backend.create(&a, &epoch).unwrap();
1403 backend.create(&b, &epoch).unwrap();
1404
1405 let mut infos = backend.list().unwrap();
1406 assert_eq!(infos.len(), 2, "expected 2 workspaces: {infos:?}");
1407
1408 infos.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str()));
1410 assert_eq!(infos[0].id.as_str(), "alpha");
1411 assert_eq!(infos[1].id.as_str(), "beta");
1412 }
1413
1414 #[test]
1415 fn test_list_excludes_repo_root() {
1416 let (temp_dir, epoch) = setup_git_repo();
1418 let backend = GitWorktreeBackend::new(temp_dir.path().to_path_buf());
1419 let ws_name = WorkspaceId::new("my-ws").unwrap();
1420 backend.create(&ws_name, &epoch).unwrap();
1421
1422 let infos = backend.list().unwrap();
1423 for info in &infos {
1424 assert_ne!(
1425 info.path,
1426 temp_dir.path(),
1427 "repo root should not appear in list"
1428 );
1429 }
1430 }
1431
1432 #[test]
1433 fn test_list_excludes_destroyed_workspace() {
1434 let (temp_dir, epoch) = setup_git_repo();
1435 let backend = GitWorktreeBackend::new(temp_dir.path().to_path_buf());
1436
1437 let ws_name = WorkspaceId::new("gone-ws").unwrap();
1438 backend.create(&ws_name, &epoch).unwrap();
1439 backend.destroy(&ws_name).unwrap();
1440
1441 let infos = backend.list().unwrap();
1442 assert!(
1443 infos.is_empty(),
1444 "destroyed workspace should not appear: {infos:?}"
1445 );
1446 }
1447
1448 #[test]
1449 fn test_list_active_when_epoch_matches() {
1450 let (temp_dir, epoch) = setup_git_repo();
1451 let root = temp_dir.path().to_path_buf();
1452 let backend = GitWorktreeBackend::new(root.clone());
1453 let ws_name = WorkspaceId::new("current-ws").unwrap();
1454 backend.create(&ws_name, &epoch).unwrap();
1455
1456 Command::new("git")
1458 .args(["update-ref", "refs/manifold/epoch/current", epoch.as_str()])
1459 .current_dir(&root)
1460 .output()
1461 .unwrap();
1462
1463 let infos = backend.list().unwrap();
1464 assert_eq!(infos.len(), 1);
1465 assert!(
1466 infos[0].state.is_active(),
1467 "workspace at current epoch should be active: {:?}",
1468 infos[0].state
1469 );
1470 }
1471
1472 #[test]
1473 fn test_list_stale_when_epoch_advanced() {
1474 let (temp_dir, epoch0) = setup_git_repo();
1475 let root = temp_dir.path().to_path_buf();
1476 let backend = GitWorktreeBackend::new(root.clone());
1477 let ws_name = WorkspaceId::new("stale-ws").unwrap();
1478 backend.create(&ws_name, &epoch0).unwrap();
1479
1480 let new_file = root.join("advance.md");
1482 fs::write(&new_file, "epoch 1").unwrap();
1483 Command::new("git")
1484 .args(["add", "advance.md"])
1485 .current_dir(&root)
1486 .output()
1487 .unwrap();
1488 Command::new("git")
1489 .args(["commit", "-m", "Advance epoch"])
1490 .current_dir(&root)
1491 .output()
1492 .unwrap();
1493 let head_out = Command::new("git")
1494 .args(["rev-parse", "HEAD"])
1495 .current_dir(&root)
1496 .output()
1497 .unwrap();
1498 let epoch1_str = String::from_utf8(head_out.stdout)
1499 .unwrap()
1500 .trim()
1501 .to_string();
1502
1503 Command::new("git")
1505 .args(["update-ref", "refs/manifold/epoch/current", &epoch1_str])
1506 .current_dir(&root)
1507 .output()
1508 .unwrap();
1509
1510 let infos = backend.list().unwrap();
1511 assert_eq!(infos.len(), 1);
1512 assert!(
1513 infos[0].state.is_stale(),
1514 "workspace at old epoch should be stale: {:?}",
1515 infos[0].state
1516 );
1517 if let WorkspaceState::Stale { behind_epochs } = infos[0].state {
1519 assert_eq!(behind_epochs, 1, "should be 1 epoch behind");
1520 }
1521 }
1522
1523 #[test]
1524 fn test_list_active_when_workspace_has_commits_ahead_of_epoch() {
1525 let (temp_dir, epoch) = setup_git_repo();
1529 let root = temp_dir.path().to_path_buf();
1530 let backend = GitWorktreeBackend::new(root.clone());
1531 let ws_name = WorkspaceId::new("ahead-ws").unwrap();
1532 let info = backend.create(&ws_name, &epoch).unwrap();
1533
1534 Command::new("git")
1536 .args(["update-ref", "refs/manifold/epoch/current", epoch.as_str()])
1537 .current_dir(&root)
1538 .output()
1539 .unwrap();
1540
1541 fs::write(info.path.join("work.rs"), "fn worker() {}").unwrap();
1543 Command::new("git")
1544 .args(["add", "work.rs"])
1545 .current_dir(&info.path)
1546 .output()
1547 .unwrap();
1548 Command::new("git")
1549 .args(["commit", "-m", "worker commit"])
1550 .current_dir(&info.path)
1551 .output()
1552 .unwrap();
1553
1554 let infos = backend.list().unwrap();
1556 assert_eq!(infos.len(), 1);
1557 assert!(
1558 !infos[0].state.is_stale(),
1559 "workspace with committed work ahead of epoch should be Active, not stale: {:?}",
1560 infos[0].state
1561 );
1562 }
1563
1564 #[test]
1565 fn test_list_stale_when_epoch_advanced_past_workspace_with_committed_work() {
1566 let (temp_dir, epoch0) = setup_git_repo();
1570 let root = temp_dir.path().to_path_buf();
1571 let backend = GitWorktreeBackend::new(root.clone());
1572 let ws_name = WorkspaceId::new("diverged-ws").unwrap();
1573 let info = backend.create(&ws_name, &epoch0).unwrap();
1574
1575 fs::write(info.path.join("work.rs"), "fn worker() {}").unwrap();
1577 Command::new("git")
1578 .args(["add", "work.rs"])
1579 .current_dir(&info.path)
1580 .output()
1581 .unwrap();
1582 Command::new("git")
1583 .args(["commit", "-m", "worker commit"])
1584 .current_dir(&info.path)
1585 .output()
1586 .unwrap();
1587
1588 fs::write(root.join("other.md"), "other workspace work").unwrap();
1590 Command::new("git")
1591 .args(["add", "other.md"])
1592 .current_dir(&root)
1593 .output()
1594 .unwrap();
1595 Command::new("git")
1596 .args(["commit", "-m", "other workspace merged"])
1597 .current_dir(&root)
1598 .output()
1599 .unwrap();
1600 let head_out = Command::new("git")
1601 .args(["rev-parse", "HEAD"])
1602 .current_dir(&root)
1603 .output()
1604 .unwrap();
1605 let epoch1_str = String::from_utf8(head_out.stdout)
1606 .unwrap()
1607 .trim()
1608 .to_string();
1609 Command::new("git")
1610 .args(["update-ref", "refs/manifold/epoch/current", &epoch1_str])
1611 .current_dir(&root)
1612 .output()
1613 .unwrap();
1614
1615 let infos = backend.list().unwrap();
1618 assert_eq!(infos.len(), 1);
1619 assert!(
1620 infos[0].state.is_stale(),
1621 "workspace diverged from epoch should be stale: {:?}",
1622 infos[0].state
1623 );
1624 }
1625
1626 #[test]
1629 fn test_status_nonexistent_workspace() {
1630 let (temp_dir, _epoch) = setup_git_repo();
1631 let backend = GitWorktreeBackend::new(temp_dir.path().to_path_buf());
1632 let ws_name = WorkspaceId::new("no-such").unwrap();
1633
1634 let err = backend.status(&ws_name).unwrap_err();
1635 assert!(
1636 matches!(err, GitBackendError::NotFound { .. }),
1637 "expected NotFound: {err}"
1638 );
1639 }
1640
1641 #[test]
1642 fn test_status_clean_workspace() {
1643 let (temp_dir, epoch) = setup_git_repo();
1644 let backend = GitWorktreeBackend::new(temp_dir.path().to_path_buf());
1645 let ws_name = WorkspaceId::new("clean-ws").unwrap();
1646 backend.create(&ws_name, &epoch).unwrap();
1647
1648 let status = backend.status(&ws_name).unwrap();
1649 assert_eq!(
1650 status.base_epoch, epoch,
1651 "base epoch should match creation epoch"
1652 );
1653 assert!(
1654 status.is_clean(),
1655 "no changes expected: {:?}",
1656 status.dirty_files
1657 );
1658 assert!(!status.is_stale, "no epoch ref yet → not stale");
1659 }
1660
1661 #[test]
1662 fn test_status_modified_file() {
1663 let (temp_dir, epoch) = setup_git_repo();
1664 let backend = GitWorktreeBackend::new(temp_dir.path().to_path_buf());
1665 let ws_name = WorkspaceId::new("mod-ws").unwrap();
1666 let info = backend.create(&ws_name, &epoch).unwrap();
1667
1668 fs::write(info.path.join("README.md"), "# Changed").unwrap();
1669
1670 let status = backend.status(&ws_name).unwrap();
1671 assert_eq!(
1672 status.dirty_count(),
1673 1,
1674 "expected 1 dirty file: {:?}",
1675 status.dirty_files
1676 );
1677 assert!(
1678 status
1679 .dirty_files
1680 .iter()
1681 .any(|p| p == &PathBuf::from("README.md")),
1682 "README.md should be dirty: {:?}",
1683 status.dirty_files
1684 );
1685 }
1686
1687 #[test]
1688 fn test_status_untracked_file() {
1689 let (temp_dir, epoch) = setup_git_repo();
1690 let backend = GitWorktreeBackend::new(temp_dir.path().to_path_buf());
1691 let ws_name = WorkspaceId::new("untracked-ws").unwrap();
1692 let info = backend.create(&ws_name, &epoch).unwrap();
1693
1694 fs::write(info.path.join("new_file.txt"), "new").unwrap();
1695
1696 let status = backend.status(&ws_name).unwrap();
1697 assert_eq!(status.dirty_count(), 1);
1698 assert!(
1699 status
1700 .dirty_files
1701 .iter()
1702 .any(|p| p == &PathBuf::from("new_file.txt")),
1703 "new_file.txt should be dirty: {:?}",
1704 status.dirty_files
1705 );
1706 }
1707
1708 #[test]
1709 fn test_status_not_stale_when_epoch_matches() {
1710 let (temp_dir, epoch) = setup_git_repo();
1711 let root = temp_dir.path().to_path_buf();
1712 let backend = GitWorktreeBackend::new(root.clone());
1713 let ws_name = WorkspaceId::new("not-stale").unwrap();
1714 backend.create(&ws_name, &epoch).unwrap();
1715
1716 Command::new("git")
1718 .args(["update-ref", "refs/manifold/epoch/current", epoch.as_str()])
1719 .current_dir(&root)
1720 .output()
1721 .unwrap();
1722
1723 let status = backend.status(&ws_name).unwrap();
1724 assert!(
1725 !status.is_stale,
1726 "workspace should not be stale when epoch matches"
1727 );
1728 }
1729
1730 #[test]
1731 fn test_status_stale_when_epoch_advanced() {
1732 let (temp_dir, epoch0) = setup_git_repo();
1733 let root = temp_dir.path().to_path_buf();
1734 let backend = GitWorktreeBackend::new(root.clone());
1735 let ws_name = WorkspaceId::new("stale-status").unwrap();
1736 backend.create(&ws_name, &epoch0).unwrap();
1737
1738 fs::write(root.join("advance.md"), "epoch 1").unwrap();
1740 Command::new("git")
1741 .args(["add", "advance.md"])
1742 .current_dir(&root)
1743 .output()
1744 .unwrap();
1745 Command::new("git")
1746 .args(["commit", "-m", "Advance"])
1747 .current_dir(&root)
1748 .output()
1749 .unwrap();
1750 let head_out = Command::new("git")
1751 .args(["rev-parse", "HEAD"])
1752 .current_dir(&root)
1753 .output()
1754 .unwrap();
1755 let epoch1_str = String::from_utf8(head_out.stdout)
1756 .unwrap()
1757 .trim()
1758 .to_string();
1759
1760 Command::new("git")
1761 .args(["update-ref", "refs/manifold/epoch/current", &epoch1_str])
1762 .current_dir(&root)
1763 .output()
1764 .unwrap();
1765
1766 let status = backend.status(&ws_name).unwrap();
1767 assert!(
1768 status.is_stale,
1769 "workspace should be stale after epoch advance"
1770 );
1771 assert_eq!(status.base_epoch, epoch0, "base epoch unchanged");
1772 }
1773
1774 #[test]
1777 fn test_parse_worktree_porcelain_single() {
1778 let raw = "worktree /tmp/repo\nHEAD aabbccdd00112233aabbccdd00112233aabbccdd\nbranch refs/heads/main\n\n";
1779 let entries = parse_worktree_porcelain(raw);
1780 assert_eq!(entries.len(), 1);
1781 assert_eq!(entries[0].path, "/tmp/repo");
1782 assert_eq!(
1783 entries[0].head.as_deref(),
1784 Some("aabbccdd00112233aabbccdd00112233aabbccdd")
1785 );
1786 assert_eq!(entries[0].branch.as_deref(), Some("refs/heads/main"));
1787 }
1788
1789 #[test]
1790 fn test_parse_worktree_porcelain_multiple() {
1791 let raw = "worktree /repo\nHEAD aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nbranch refs/heads/main\n\nworktree /repo/ws/agent-1\nHEAD bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\ndetached\n\n";
1792 let entries = parse_worktree_porcelain(raw);
1793 assert_eq!(entries.len(), 2);
1794 assert_eq!(entries[0].path, "/repo");
1795 assert_eq!(entries[1].path, "/repo/ws/agent-1");
1796 assert!(
1797 entries[1].branch.is_none(),
1798 "detached worktree should have no branch"
1799 );
1800 }
1801 #[test]
1804 fn test_error_display() {
1805 let err = GitBackendError::GitCommand {
1806 command: "git worktree add".to_owned(),
1807 stderr: "fatal: bad ref".to_owned(),
1808 exit_code: Some(128),
1809 };
1810 let msg = format!("{err}");
1811 assert!(msg.contains("git worktree add"));
1812 assert!(msg.contains("128"));
1813 assert!(msg.contains("fatal: bad ref"));
1814
1815 let err = GitBackendError::NotFound {
1816 name: "missing".to_owned(),
1817 };
1818 assert!(format!("{err}").contains("missing"));
1819
1820 let err = GitBackendError::NotImplemented("destroy");
1821 assert!(format!("{err}").contains("destroy"));
1822 }
1823
1824 #[test]
1827 fn test_create_records_workspace_epoch_ref() {
1828 let (temp_dir, epoch) = setup_git_repo();
1829 let root = temp_dir.path().to_path_buf();
1830 let backend = GitWorktreeBackend::new(root.clone());
1831 let ws_name = WorkspaceId::new("epoch-ref-ws").unwrap();
1832
1833 backend.create(&ws_name, &epoch).unwrap();
1834
1835 let epoch_ref = manifold_refs::workspace_epoch_ref("epoch-ref-ws");
1837 let stored = manifold_refs::read_ref(&root, &epoch_ref).unwrap();
1838 assert_eq!(
1839 stored,
1840 Some(epoch.oid().clone()),
1841 "workspace epoch ref should be set to creation epoch"
1842 );
1843
1844 backend.destroy(&ws_name).unwrap();
1846 let stored = manifold_refs::read_ref(&root, &epoch_ref).unwrap();
1847 assert!(stored.is_none(), "workspace epoch ref should be pruned on destroy");
1848 }
1849
1850 #[test]
1851 fn test_status_base_epoch_stable_after_agent_commit() {
1852 let (temp_dir, epoch) = setup_git_repo();
1857 let root = temp_dir.path().to_path_buf();
1858 let backend = GitWorktreeBackend::new(root.clone());
1859 let ws_name = WorkspaceId::new("commit-ws").unwrap();
1860 let info = backend.create(&ws_name, &epoch).unwrap();
1861
1862 fs::write(info.path.join("work.rs"), "fn work() {}").unwrap();
1864 Command::new("git")
1865 .args(["add", "work.rs"])
1866 .current_dir(&info.path)
1867 .output()
1868 .unwrap();
1869 Command::new("git")
1870 .args(["commit", "-m", "agent work"])
1871 .current_dir(&info.path)
1872 .output()
1873 .unwrap();
1874
1875 let status = backend.status(&ws_name).unwrap();
1878 assert_eq!(
1879 status.base_epoch, epoch,
1880 "base_epoch should be the creation epoch, not HEAD"
1881 );
1882
1883 let head_str = GitWorktreeBackend::git_stdout_in(&info.path, &["rev-parse", "HEAD"]).unwrap();
1885 let head_epoch = EpochId::new(head_str.trim()).unwrap();
1886 assert_ne!(
1887 head_epoch, epoch,
1888 "HEAD should have advanced beyond creation epoch"
1889 );
1890 }
1891}