grove/
vcs.rs

1//! VCS backend abstraction for worktree and workspace management.
2//!
3//! Grove supports both git worktrees and jj workspaces through the [`VcsBackend`]
4//! trait. The backend is auto-detected: if the repository contains a `.jj/`
5//! directory, jj is used; otherwise git. Users can force git mode with `--vcs git`
6//! for colocated repos.
7
8use std::path::{Path, PathBuf};
9
10use crate::error::{Error, Result};
11
12/// Which VCS backend produced this worktree entry.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum VcsKind {
15    Git,
16    Jj,
17}
18
19/// Information about a worktree/workspace managed by a VCS backend.
20pub struct WorktreeInfo {
21    pub path: PathBuf,
22    pub branch: Option<String>,
23    pub vcs_kind: VcsKind,
24}
25
26/// Override for VCS backend selection via `--vcs` flag.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum VcsOverride {
29    Git,
30}
31
32/// Trait for VCS backends that manage worktrees/workspaces.
33pub trait VcsBackend {
34    fn create_worktree(&self, repo_path: &Path, worktree_path: &Path, name: &str) -> Result<()>;
35    fn remove_worktree(&self, repo_path: &Path, worktree_path: &Path, name: &str) -> Result<()>;
36    fn list_worktrees(&self, repo_path: &Path, worktree_base: &Path) -> Result<Vec<WorktreeInfo>>;
37}
38
39/// Detect the appropriate VCS backend for a repository.
40///
41/// If `vcs_override` is `Some(VcsOverride::Git)`, always uses git.
42/// Otherwise, checks for `.jj` directory first (colocated repos),
43/// then falls back to git.
44pub fn detect_backend(
45    repo_path: &Path,
46    vcs_override: Option<VcsOverride>,
47) -> Result<Box<dyn VcsBackend>> {
48    if vcs_override == Some(VcsOverride::Git) {
49        return Ok(Box::new(GitBackend));
50    }
51    if repo_path.join(".jj").is_dir() {
52        ensure_jj_installed()?;
53        return Ok(Box::new(JjBackend));
54    }
55    Ok(Box::new(GitBackend))
56}
57
58fn ensure_jj_installed() -> Result<()> {
59    match std::process::Command::new("jj").arg("--version").output() {
60        Ok(output) if output.status.success() => Ok(()),
61        _ => Err(Error::JjNotInstalled),
62    }
63}
64
65struct GitBackend;
66
67impl VcsBackend for GitBackend {
68    fn create_worktree(&self, repo_path: &Path, worktree_path: &Path, name: &str) -> Result<()> {
69        let output = std::process::Command::new("git")
70            .args([
71                "worktree",
72                "add",
73                worktree_path.to_str().unwrap(),
74                "-b",
75                name,
76            ])
77            .current_dir(repo_path)
78            .output()?;
79
80        if !output.status.success() {
81            let stderr = String::from_utf8_lossy(&output.stderr);
82            if stderr.contains("already exists") {
83                let output2 = std::process::Command::new("git")
84                    .args(["worktree", "add", worktree_path.to_str().unwrap(), name])
85                    .current_dir(repo_path)
86                    .output()?;
87
88                if !output2.status.success() {
89                    return Err(Error::VcsCommandFailed(
90                        String::from_utf8_lossy(&output2.stderr).to_string(),
91                    ));
92                }
93            } else {
94                return Err(Error::VcsCommandFailed(stderr.to_string()));
95            }
96        }
97
98        Ok(())
99    }
100
101    fn remove_worktree(&self, repo_path: &Path, worktree_path: &Path, _name: &str) -> Result<()> {
102        let output = std::process::Command::new("git")
103            .args(["worktree", "remove", worktree_path.to_str().unwrap()])
104            .current_dir(repo_path)
105            .output()?;
106
107        if !output.status.success() {
108            return Err(Error::VcsCommandFailed(
109                String::from_utf8_lossy(&output.stderr).to_string(),
110            ));
111        }
112
113        Ok(())
114    }
115
116    fn list_worktrees(&self, repo_path: &Path, _worktree_base: &Path) -> Result<Vec<WorktreeInfo>> {
117        // Check if .git/worktrees exists - if not, no worktrees
118        if !repo_path.join(".git/worktrees").exists() {
119            return Ok(Vec::new());
120        }
121
122        let output = std::process::Command::new("git")
123            .args(["worktree", "list", "--porcelain"])
124            .current_dir(repo_path)
125            .output()?;
126
127        if !output.status.success() {
128            return Err(Error::VcsCommandFailed(
129                String::from_utf8_lossy(&output.stderr).to_string(),
130            ));
131        }
132
133        let stdout = String::from_utf8_lossy(&output.stdout);
134        let mut worktrees = Vec::new();
135        let mut current_path: Option<PathBuf> = None;
136        let mut current_branch: Option<String> = None;
137        let mut is_first = true;
138
139        for line in stdout.lines() {
140            if let Some(path_str) = line.strip_prefix("worktree ") {
141                // Save previous worktree if any (skip first which is main repo)
142                if let Some(path) = current_path.take() {
143                    if !is_first {
144                        worktrees.push(WorktreeInfo {
145                            path,
146                            branch: current_branch.take(),
147                            vcs_kind: VcsKind::Git,
148                        });
149                    }
150                    is_first = false;
151                }
152                current_path = Some(PathBuf::from(path_str));
153                current_branch = None;
154            } else if let Some(branch_ref) = line.strip_prefix("branch refs/heads/") {
155                current_branch = Some(branch_ref.to_string());
156            }
157        }
158        // Don't forget the last worktree
159        if let Some(path) = current_path {
160            if !is_first {
161                worktrees.push(WorktreeInfo {
162                    path,
163                    branch: current_branch,
164                    vcs_kind: VcsKind::Git,
165                });
166            }
167        }
168
169        Ok(worktrees)
170    }
171}
172
173struct JjBackend;
174
175impl VcsBackend for JjBackend {
176    fn create_worktree(&self, repo_path: &Path, worktree_path: &Path, name: &str) -> Result<()> {
177        let output = std::process::Command::new("jj")
178            .args([
179                "workspace",
180                "add",
181                "--name",
182                name,
183                worktree_path.to_str().unwrap(),
184            ])
185            .current_dir(repo_path)
186            .output()?;
187
188        if !output.status.success() {
189            return Err(Error::VcsCommandFailed(
190                String::from_utf8_lossy(&output.stderr).to_string(),
191            ));
192        }
193
194        Ok(())
195    }
196
197    fn remove_worktree(&self, repo_path: &Path, worktree_path: &Path, name: &str) -> Result<()> {
198        let output = std::process::Command::new("jj")
199            .args(["workspace", "forget", name])
200            .current_dir(repo_path)
201            .output()?;
202
203        if !output.status.success() {
204            return Err(Error::VcsCommandFailed(
205                String::from_utf8_lossy(&output.stderr).to_string(),
206            ));
207        }
208
209        // jj workspace forget does not delete the directory
210        if worktree_path.exists() {
211            std::fs::remove_dir_all(worktree_path)?;
212        }
213
214        Ok(())
215    }
216
217    fn list_worktrees(&self, repo_path: &Path, worktree_base: &Path) -> Result<Vec<WorktreeInfo>> {
218        let output = std::process::Command::new("jj")
219            .args(["workspace", "list"])
220            .current_dir(repo_path)
221            .output()?;
222
223        if !output.status.success() {
224            return Err(Error::VcsCommandFailed(
225                String::from_utf8_lossy(&output.stderr).to_string(),
226            ));
227        }
228
229        let stdout = String::from_utf8_lossy(&output.stdout);
230        let mut worktrees = Vec::new();
231
232        for line in stdout.lines() {
233            // Format: <name>: <change_id> <description>
234            let Some((name, _rest)) = line.split_once(": ") else {
235                continue;
236            };
237
238            // Skip the default workspace (main working directory)
239            if name == "default" {
240                continue;
241            }
242
243            worktrees.push(WorktreeInfo {
244                path: worktree_base.join(name),
245                branch: None,
246                vcs_kind: VcsKind::Jj,
247            });
248        }
249
250        Ok(worktrees)
251    }
252}