Skip to main content

maw/backend/
git.rs

1//! Git worktree backend implementation.
2//!
3//! Implements [`WorkspaceBackend`] using `git worktree` for workspace
4//! isolation. Each workspace is a detached worktree under `ws/<name>/`.
5
6use 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// ---------------------------------------------------------------------------
18// Error type
19// ---------------------------------------------------------------------------
20
21/// Errors from the git worktree backend.
22#[derive(Debug)]
23pub enum GitBackendError {
24    /// A git command failed.
25    GitCommand {
26        command: String,
27        stderr: String,
28        exit_code: Option<i32>,
29    },
30    /// An I/O error occurred.
31    Io(std::io::Error),
32    /// Workspace not found.
33    NotFound { name: String },
34    /// Feature not yet implemented.
35    #[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
78// ---------------------------------------------------------------------------
79// GitWorktreeBackend
80// ---------------------------------------------------------------------------
81
82/// A workspace backend implementation using `git worktree`.
83pub struct GitWorktreeBackend {
84    /// The root directory of the repository (where .git is).
85    root: PathBuf,
86}
87
88impl GitWorktreeBackend {
89    /// Create a new `GitWorktreeBackend`.
90    #[must_use]
91    pub const fn new(root: PathBuf) -> Self {
92        Self { root }
93    }
94
95    /// Get the directory where workspaces are stored.
96    fn workspaces_dir(&self) -> PathBuf {
97        self.root.join("ws")
98    }
99
100    /// Run a git command and return its stdout.
101    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    /// Run a git command in a specific directory and return stdout.
120    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    /// Get the current epoch from `refs/manifold/epoch/current`, if it exists.
139    ///
140    /// Returns `None` if the ref doesn't exist (e.g., Manifold not yet initialized).
141    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    /// Returns true if `ancestor` is an ancestor of (or equal to) `descendant`.
156    ///
157    /// Uses `git merge-base --is-ancestor`; returns `false` on any error.
158    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    /// Count how many commits are reachable from `to_oid` but not from `from_oid`.
168    ///
169    /// Used to determine how many epoch advancements a workspace is behind.
170    /// Returns `None` on error (e.g., either OID is not reachable).
171    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    /// Whether Level 1 workspace refs are enabled in `.manifold/config.toml`.
186    ///
187    /// Missing config or parse/load failures fall back to enabled.
188    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    /// Refresh `refs/manifold/ws/<name>` to point at a commit representing the
196    /// current workspace state.
197    ///
198    /// Uses `git stash create` to lazily materialize a commit without mutating
199    /// the workspace's index or working tree. If there are no local changes,
200    /// falls back to `HEAD`.
201    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        // Idempotency: if valid workspace exists, return it
245        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        // Cleanup: if directory exists but not a valid workspace, remove it
257        if path.exists() {
258            std::fs::remove_dir_all(&path)?;
259        }
260
261        // Cleanup: if git thinks it exists but directory is gone (prune)
262        let _ = Command::new("git")
263            .args(["worktree", "prune"])
264            .current_dir(&self.root)
265            .output();
266
267        // Ensure parent directory exists
268        let ws_dir = self.workspaces_dir();
269        std::fs::create_dir_all(&ws_dir)?;
270
271        // Create the worktree: git worktree add --detach <path> <commit>
272        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            // Clean up partial state
283            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        // Record the creation epoch so status() can distinguish
295        // "HEAD advanced because the agent committed" from "HEAD is the epoch".
296        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    /// Destroy a workspace by removing its git worktree.
321    ///
322    /// This is atomic and idempotent:
323    /// - If the workspace doesn't exist (already destroyed), returns Ok(()).
324    /// - Step 1: `git worktree remove --force <path>` (handles dirty worktrees)
325    /// - Step 2: If that fails, remove the directory manually and prune.
326    /// - Step 3: `git worktree prune` to clean up stale references.
327    ///
328    /// Each step is individually idempotent, so a crash at any point can be
329    /// retried safely.
330    fn destroy(&self, name: &WorkspaceId) -> Result<(), Self::Error> {
331        let path = self.workspace_path(name);
332
333        // Step 1: Try `git worktree remove --force`
334        // --force allows removing even if there are uncommitted changes
335        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                // Step 2: If `git worktree remove` fails, fall back to manual cleanup.
345                // This handles cases where the worktree is in a broken state.
346                if path.exists() {
347                    std::fs::remove_dir_all(&path)?;
348                }
349            }
350        }
351
352        // Step 3: Prune stale worktree entries. This cleans up the
353        // .git/worktrees/<name> administrative directory even if
354        // the worktree directory was removed out of band.
355        let _ = Command::new("git")
356            .args(["worktree", "prune"])
357            .current_dir(&self.root)
358            .output();
359
360        // Prune Level 1 materialized workspace ref if present.
361        let ws_ref = manifold_refs::workspace_state_ref(name.as_str());
362        let _ = manifold_refs::delete_ref(&self.root, &ws_ref);
363
364        // Prune per-workspace creation epoch ref if present.
365        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    /// List all workspaces managed by this backend.
372    ///
373    /// Parses `git worktree list --porcelain` and filters to worktrees directly
374    /// under the `ws/` directory. The main worktree (repo root) and any
375    /// non-`ws/` worktrees are excluded.
376    ///
377    /// Staleness is determined by comparing each workspace's HEAD against
378    /// `refs/manifold/epoch/current`. If the epoch ref doesn't exist
379    /// (Manifold not yet initialized), all workspaces are reported as Active.
380    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        // git worktree list --porcelain separates entries with blank lines.
388        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            // Skip bare repo entries (the main git repo root).
409            if is_bare {
410                continue;
411            }
412
413            let (Some(path), Some(head_str)) = (wt_path, wt_head) else {
414                // Missing HEAD means the worktree is in a broken state; skip.
415                continue;
416            };
417
418            // Only include workspaces directly under ws/ (e.g., ws/agent-1).
419            let Ok(rel) = path.strip_prefix(&ws_dir) else {
420                continue;
421            };
422
423            // Exactly one path component (ws/<name>, not ws/<a>/<b>).
424            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                // Non-conforming directory name (e.g., uppercase); skip.
434                continue;
435            };
436
437            let Ok(head_epoch) = EpochId::new(head_str.trim()) else {
438                // Invalid OID (e.g., detached with no commits); skip.
439                continue;
440            };
441
442            // Resolve the base epoch: prefer the recorded per-workspace epoch
443            // ref (set at creation time), falling back to HEAD for backward
444            // compat with workspaces created before the ref was introduced.
445            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            // Use ancestry to distinguish cases:
452            //   1. Workspace HEAD == epoch                  → Active, 0 commits ahead
453            //   2. Epoch IS ancestor of HEAD (ahead)        → Active, N commits ahead (has work)
454            //   3. Epoch NOT ancestor of HEAD (behind/diverged) → Stale, M commits behind
455            //
456            // The old equality check treated case 2 as Stale {behind_epochs: 0},
457            // causing maw ws sync --all to wipe committed work.
458            //
459            // Note: staleness is checked against `epoch` (creation epoch), not
460            // HEAD, because HEAD may have advanced via agent commits.
461            let (state, commits_ahead) = match &current_epoch {
462                Some(current) if epoch == *current => {
463                    // Workspace is at the current epoch; count agent commits.
464                    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    /// Get the current status of a workspace.
502    ///
503    /// Reports dirty files (modified, added, deleted, untracked) by running
504    /// `git status --porcelain` inside the worktree directory.
505    ///
506    /// Staleness is determined by comparing the workspace's HEAD (the epoch
507    /// it was created at) against `refs/manifold/epoch/current`.
508    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        // Resolve the base epoch: prefer the recorded per-workspace epoch ref
518        // (set at creation time), falling back to HEAD for backward compat
519        // with workspaces created before this ref was introduced.
520        //
521        // The per-workspace epoch ref is critical because agents may commit
522        // inside a workspace, advancing HEAD beyond the creation epoch. Using
523        // HEAD as base_epoch would hide those committed changes from both the
524        // patchset guard and the pre-destroy capture.
525        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                    // Fallback: use HEAD (correct for workspaces without commits).
537                    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        // Collect dirty files: tracked modifications + untracked files.
548        let status_output = Self::git_stdout_in(&ws_path, &["status", "--porcelain"])?;
549        let dirty_files = parse_porcelain_status(&status_output);
550
551        // Stale = the current epoch is not in the workspace's ancestry.
552        //
553        // The original check (HEAD != epoch) was wrong: it treated a workspace
554        // that has commits *ahead* of the epoch as stale, causing auto-sync to
555        // wipe those commits via `git checkout --detach <epoch>`.
556        //
557        // Correct semantics:
558        //   - HEAD == epoch           → not stale (at epoch)
559        //   - epoch is ancestor of HEAD → not stale (workspace has commits on top)
560        //   - epoch is NOT ancestor of HEAD → stale (workspace is behind/diverged)
561        //
562        // `git merge-base --is-ancestor A B` exits 0 if A is an ancestor of B.
563        let is_stale = self.current_epoch_opt().is_some_and(|current| {
564            if base_epoch == current {
565                return false;
566            }
567            // Check whether current epoch is an ancestor of HEAD (or equal).
568            // Exit 0 → is-ancestor → workspace is at or ahead of epoch → not stale.
569            // Exit 1 → not an ancestor → workspace is behind/diverged → stale.
570            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(), // not-ancestor → stale
576                Err(_) => true,                  // can't tell → assume stale
577            }
578        });
579
580        // Lazily materialize Level 1 workspace state ref for git inspection.
581        self.refresh_workspace_state_ref(name, &ws_path)?;
582
583        Ok(WorkspaceStatus::new(base_epoch, dirty_files, is_stale))
584    }
585
586    /// Scan a workspace's working directory for changes relative to the base epoch.
587    ///
588    /// Detects added, modified, and deleted files by comparing the workspace's
589    /// working tree against the epoch commit. Also picks up untracked files
590    /// as additions.
591    ///
592    /// # Implementation
593    ///
594    /// The diff base is the **epoch** (from `refs/manifold/epoch/current`), NOT
595    /// the workspace's HEAD. Agents may commit changes inside a workspace,
596    /// which advances HEAD beyond the epoch. If we diffed against HEAD, those
597    /// committed changes would be invisible and the merge engine would see an
598    /// empty workspace.
599    ///
600    /// 1. `git diff --name-status <epoch>` — all changes (committed + uncommitted)
601    /// 2. `git ls-files --others --exclude-standard` — untracked files
602    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        // Use the epoch as the diff base, not HEAD.
611        // If the epoch ref is missing, fall back to HEAD (pre-Manifold compat).
612        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        // 1. All changes (committed + working tree) relative to the epoch.
625        // `git diff <epoch>` compares the epoch tree against the current working
626        // tree, capturing both committed and uncommitted modifications.
627        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        // 2. Untracked files (not in .gitignore)
633        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        // Deduplicate
647        added.sort();
648        added.dedup();
649        modified.sort();
650        modified.dedup();
651        deleted.sort();
652        deleted.dedup();
653
654        // Remove from modified/deleted if also in added (file was added then modified)
655        modified.retain(|p| !added.contains(p));
656
657        // Lazily materialize Level 1 workspace state ref for git inspection.
658        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        // Check if git worktree list knows about it
674        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// ---------------------------------------------------------------------------
696// Porcelain parsers
697// ---------------------------------------------------------------------------
698
699/// A single entry from `git worktree list --porcelain`.
700#[cfg(test)]
701#[derive(Debug, Default)]
702struct WorktreeEntry {
703    /// Absolute path to the worktree.
704    path: String,
705    /// HEAD commit OID (40 hex chars), or `None` if the worktree has no commits.
706    head: Option<String>,
707    /// Branch name (e.g., `"refs/heads/main"`), or `None` if detached.
708    #[allow(dead_code)]
709    branch: Option<String>,
710}
711
712/// Parse the `--porcelain` output of `git worktree list`.
713///
714/// Format (one blank line between entries):
715/// ```text
716/// worktree /absolute/path
717/// HEAD <40-char-oid>
718/// branch refs/heads/main      ← or "detached"
719///
720/// worktree /other/path
721/// ...
722/// ```
723#[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        // "detached" line: no branch, already handled by leaving branch as None
748    }
749
750    // Flush the last entry (no trailing blank line).
751    if in_entry && !current.path.is_empty() {
752        entries.push(current);
753    }
754
755    entries
756}
757
758/// Parse `git status --porcelain` v1 output to extract dirty file paths.
759///
760/// Each non-empty line has the format `XY path` where `X` is the index
761/// status, `Y` is the working-tree status, and the path starts at position 3.
762/// All lines are included (modified, added, deleted, untracked `??`).
763///
764/// Paths containing spaces are returned verbatim; quoted paths (git uses
765/// quoting for special characters) are returned with the quotes stripped.
766fn parse_porcelain_status(output: &str) -> Vec<PathBuf> {
767    let mut paths = Vec::new();
768    for line in output.lines() {
769        // Minimum valid line: "XY p" (4 chars: 2 status + space + 1 path char)
770        if line.len() < 4 {
771            continue;
772        }
773        // Path starts at byte offset 3 (after "XY ").
774        let path_str = &line[3..];
775        if !path_str.is_empty() {
776            // Handle renames/copies: "old -> new". We want the 'new' part.
777            // Git status porcelain v1 format: R  ORIG -> NEW
778            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            // Strip quotes if present (git quotes paths with special chars).
785            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}
794/// Parse `git diff --name-status` output into add/modify/delete lists.
795fn 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        // Format: "X\tpath" or "X path"
807        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            // Rename: "R100\told\tnew" — treat old as deleted, new as added
817            // Split into [status, old, new]
818            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            // Unknown status letter, skip
826            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// ---------------------------------------------------------------------------
840// Tests
841// ---------------------------------------------------------------------------
842
843#[cfg(test)]
844#[allow(clippy::redundant_clone)]
845mod tests {
846    use super::*;
847    use std::fs;
848    use tempfile::TempDir;
849
850    /// Helper: set up a fresh git repo with one commit.
851    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    // -- create tests --
914
915    #[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        // Idempotency
929        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    // -- exists tests --
950
951    #[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    // -- workspace_path tests --
969
970    #[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    // -- snapshot tests --
981
982    #[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        // Add a new file (untracked)
1002        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        // Modify existing tracked file
1019        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        // Delete tracked file
1036        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        // Add, modify, delete
1053        fs::write(info.path.join("new.rs"), "fn main() {}").unwrap();
1054        fs::write(info.path.join("README.md"), "# Changed").unwrap();
1055        // Can't delete and add in same snapshot cleanly without more files,
1056        // so just check add + modify
1057        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        // Create .gitignore and an ignored file
1071        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        // .gitignore itself should show up as added, but debug.log should not
1076        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    // -- destroy tests --
1163
1164    #[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        // Create then destroy
1172        let info = backend.create(&ws_name, &epoch).unwrap();
1173        assert!(info.path.exists());
1174
1175        // Materialize Level 1 ref, then ensure destroy prunes it.
1176        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        // Destroy twice: both should succeed
1198        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        // Destroying a workspace that never existed should succeed (idempotent)
1209        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        // Make dirty changes
1221        fs::write(info.path.join("dirty.txt"), "uncommitted").unwrap();
1222        fs::write(info.path.join("README.md"), "modified").unwrap();
1223
1224        // Should still destroy successfully (--force handles dirty state)
1225        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        // Simulate out-of-band directory removal (e.g., crash during previous destroy)
1239        fs::remove_dir_all(&info.path).unwrap();
1240        assert!(!info.path.exists());
1241
1242        // Destroy should still succeed and prune stale git worktree entry
1243        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        // Create, destroy, then create again
1254        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    // -- parse_name_status tests --
1262
1263    #[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        // git diff --name-status output for rename: R100\told\tnew
1284        let output = "R100\told_name.rs\tnew_name.rs\n";
1285        parse_name_status(output, &mut added, &mut modified, &mut deleted);
1286
1287        // Should interpret as: added "new_name.rs"
1288        assert_eq!(added, vec![PathBuf::from("new_name.rs")]);
1289    }
1290
1291    #[test]
1292    fn test_parse_porcelain_status_rename() {
1293        // git status --porcelain v1 output for rename: R  old -> new
1294        let output = "R  old.rs -> new.rs\n";
1295        let paths = parse_porcelain_status(output);
1296
1297        // Should return only the new path "new.rs"
1298        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    // -- parse_porcelain_status tests --
1314
1315    #[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        // git quotes paths with special characters
1362        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    // -- list tests --
1368
1369    #[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        // Sort by name for stable comparison
1409        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        // The main git worktree (the repo root) should never appear in list().
1417        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        // Set refs/manifold/epoch/current to the same epoch as the workspace
1457        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        // Advance the epoch: make a new commit on the main branch
1481        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        // Update epoch ref to epoch1
1504        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        // Should be 1 epoch behind (one commit separates them)
1518        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        // Regression: workspace with committed work ahead of epoch was previously
1526        // shown as "stale (behind by 0 epoch(s))", causing maw ws sync --all to
1527        // wipe those commits.
1528        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        // Set epoch ref to the creation epoch
1535        Command::new("git")
1536            .args(["update-ref", "refs/manifold/epoch/current", epoch.as_str()])
1537            .current_dir(&root)
1538            .output()
1539            .unwrap();
1540
1541        // Simulate a worker committing inside the workspace
1542        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        // Workspace HEAD is now ahead of epoch — must not be shown as stale
1555        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        // Workspace has committed work, then epoch advances laterally (another workspace merged).
1567        // Workspace is genuinely stale (epoch not ancestor of HEAD), but the commit count
1568        // between workspace HEAD and new epoch should reflect the divergence correctly.
1569        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        // Worker commits inside the workspace
1576        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        // Epoch advances on the main repo (simulating another workspace merging)
1589        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        // Now the workspace has diverged from epoch1: both have commits the other doesn't.
1616        // list() should show it as Stale (epoch is NOT ancestor of workspace HEAD).
1617        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    // -- status tests --
1627
1628    #[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        // Set epoch ref to the workspace's epoch
1717        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        // Advance the epoch
1739        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    // -- parse_worktree_porcelain tests --
1775
1776    #[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    // -- error display tests --
1802
1803    #[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    // -- workspace epoch ref tests --
1825
1826    #[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        // The per-workspace epoch ref should exist and match the creation epoch
1836        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        // After destroy, the ref should be cleaned up
1845        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        // Regression: status().base_epoch was derived from HEAD, so when an
1853        // agent committed work (advancing HEAD), base_epoch would advance too.
1854        // This caused capture_before_destroy to see HEAD == base_epoch and
1855        // skip the capture, losing committed work on destroy.
1856        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        // Simulate an agent committing inside the workspace
1863        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        // HEAD has advanced, but status().base_epoch must still be the
1876        // original creation epoch
1877        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        // The workspace HEAD should differ from base_epoch
1884        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}