Skip to main content

opendev_tools_impl/
worktree.rs

1//! Git worktree management for isolated agent workspaces.
2//!
3//! Mirrors `opendev/core/git/worktree.py`.
4//!
5//! Provides [`WorktreeManager`] for creating, listing, removing, and
6//! cleaning up git worktrees.  Each worktree gives an agent an isolated
7//! checkout where it can make changes without interfering with other
8//! sessions.
9
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12
13use thiserror::Error;
14use tokio::process::Command;
15use tracing::{debug, warn};
16
17// ── Naming ──────────────────────────────────────────────────────────────────
18
19const ADJECTIVES: &[&str] = &[
20    "swift", "bright", "calm", "bold", "keen", "warm", "cool", "deep", "fair", "fine", "glad",
21    "pure", "safe", "wise", "neat",
22];
23
24const NOUNS: &[&str] = &[
25    "branch", "patch", "spike", "draft", "build", "probe", "trial", "craft", "forge", "bloom",
26    "spark", "quest", "grove", "ridge", "haven",
27];
28
29/// Generate a random adjective-noun worktree name.
30fn random_name() -> String {
31    use std::time::SystemTime;
32    // Simple deterministic-enough RNG from timestamp nanos
33    let seed = SystemTime::now()
34        .duration_since(SystemTime::UNIX_EPOCH)
35        .unwrap_or_default()
36        .subsec_nanos() as usize;
37    let adj = ADJECTIVES[seed % ADJECTIVES.len()];
38    let noun = NOUNS[(seed / ADJECTIVES.len()) % NOUNS.len()];
39    format!("{adj}-{noun}")
40}
41
42// ── Errors ──────────────────────────────────────────────────────────────────
43
44#[derive(Debug, Error)]
45pub enum WorktreeError {
46    #[error("git command failed: {0}")]
47    GitError(String),
48    #[error("worktree not found: {0}")]
49    NotFound(String),
50    #[error("I/O error: {0}")]
51    Io(#[from] std::io::Error),
52    #[error("worktree already exists: {0}")]
53    AlreadyExists(String),
54}
55
56// ── WorktreeInfo ────────────────────────────────────────────────────────────
57
58/// Information about a single git worktree.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct WorktreeInfo {
61    /// Absolute path to the worktree directory.
62    pub path: String,
63    /// Branch checked out in this worktree.
64    pub branch: String,
65    /// HEAD commit hash.
66    pub commit: String,
67    /// Whether this is the main (bare) worktree.
68    pub is_main: bool,
69}
70
71impl std::fmt::Display for WorktreeInfo {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        let suffix = if self.is_main { " (main)" } else { "" };
74        write!(f, "Worktree({}{}, {})", self.branch, suffix, self.path)
75    }
76}
77
78// ── WorktreeManager ─────────────────────────────────────────────────────────
79
80/// Manages git worktrees for a project.
81pub struct WorktreeManager {
82    /// Root directory of the git repository.
83    project_dir: PathBuf,
84    /// Base directory for storing worktrees.
85    worktree_base: PathBuf,
86    /// Tracked worktrees in session state: name -> WorktreeInfo.
87    tracked: HashMap<String, WorktreeInfo>,
88}
89
90impl WorktreeManager {
91    /// Create a new manager for the given project directory.
92    ///
93    /// Worktrees are stored under `~/.opendev/data/worktree/`.
94    pub fn new(project_dir: impl Into<PathBuf>) -> Self {
95        let project_dir = project_dir.into();
96        let worktree_base = dirs::home_dir()
97            .unwrap_or_else(|| PathBuf::from("/tmp"))
98            .join(".opendev")
99            .join("data")
100            .join("worktree");
101        Self {
102            project_dir,
103            worktree_base,
104            tracked: HashMap::new(),
105        }
106    }
107
108    /// Create a new manager with a custom worktree base directory.
109    ///
110    /// Primarily useful for tests.
111    pub fn with_base(project_dir: impl Into<PathBuf>, worktree_base: impl Into<PathBuf>) -> Self {
112        Self {
113            project_dir: project_dir.into(),
114            worktree_base: worktree_base.into(),
115            tracked: HashMap::new(),
116        }
117    }
118
119    /// Get the project directory.
120    pub fn project_dir(&self) -> &Path {
121        &self.project_dir
122    }
123
124    /// Get the worktree base directory.
125    pub fn worktree_base(&self) -> &Path {
126        &self.worktree_base
127    }
128
129    /// Create a new worktree.
130    ///
131    /// - `name`: worktree name (auto-generated if `None`)
132    /// - `branch`: branch name (defaults to `worktree-{name}`)
133    /// - `base_branch`: base commit/branch to start from (defaults to `"HEAD"`)
134    pub async fn create(
135        &mut self,
136        name: Option<&str>,
137        branch: Option<&str>,
138        base_branch: &str,
139    ) -> Result<WorktreeInfo, WorktreeError> {
140        let name = name.map(String::from).unwrap_or_else(random_name);
141        let branch = branch
142            .map(String::from)
143            .unwrap_or_else(|| format!("worktree-{name}"));
144        let worktree_path = self.worktree_base.join(&name);
145
146        if worktree_path.exists() {
147            return Err(WorktreeError::AlreadyExists(name));
148        }
149
150        // Ensure parent exists
151        if let Some(parent) = worktree_path.parent() {
152            tokio::fs::create_dir_all(parent).await?;
153        }
154
155        let output = Command::new("git")
156            .args(["worktree", "add", "-b", &branch])
157            .arg(&worktree_path)
158            .arg(base_branch)
159            .current_dir(&self.project_dir)
160            .output()
161            .await?;
162
163        if !output.status.success() {
164            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
165            warn!("Failed to create worktree: {stderr}");
166            return Err(WorktreeError::GitError(stderr));
167        }
168
169        // Read HEAD commit in the new worktree
170        let commit = self
171            .git_output(&["rev-parse", "HEAD"], Some(&worktree_path))
172            .await
173            .unwrap_or_default();
174
175        let info = WorktreeInfo {
176            path: worktree_path.to_string_lossy().to_string(),
177            branch,
178            commit,
179            is_main: false,
180        };
181
182        debug!("Created worktree: {info}");
183        self.tracked.insert(name, info.clone());
184        Ok(info)
185    }
186
187    /// List all worktrees for the project (from `git worktree list --porcelain`).
188    pub async fn list(&self) -> Result<Vec<WorktreeInfo>, WorktreeError> {
189        let raw = self
190            .git_output(&["worktree", "list", "--porcelain"], None)
191            .await
192            .ok_or_else(|| WorktreeError::GitError("git worktree list failed".into()))?;
193
194        Ok(parse_porcelain_output(&raw))
195    }
196
197    /// Remove a worktree by name (or absolute path).
198    pub async fn remove(&mut self, name: &str, force: bool) -> Result<(), WorktreeError> {
199        let worktree_path = self.resolve_worktree_path(name);
200
201        let mut args = vec!["worktree", "remove"];
202        if force {
203            args.push("--force");
204        }
205        let path_str = worktree_path.to_string_lossy().to_string();
206        args.push(&path_str);
207
208        let output = Command::new("git")
209            .args(&args)
210            .current_dir(&self.project_dir)
211            .output()
212            .await?;
213
214        if !output.status.success() {
215            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
216            warn!("Failed to remove worktree: {stderr}");
217            return Err(WorktreeError::GitError(stderr));
218        }
219
220        self.tracked.remove(name);
221        debug!("Removed worktree: {name}");
222        Ok(())
223    }
224
225    /// Clean up stale/prunable worktree references.
226    pub async fn cleanup(&self) -> Result<String, WorktreeError> {
227        let output = Command::new("git")
228            .args(["worktree", "prune"])
229            .current_dir(&self.project_dir)
230            .output()
231            .await?;
232
233        if !output.status.success() {
234            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
235            return Err(WorktreeError::GitError(stderr));
236        }
237
238        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
239        debug!("Worktree cleanup done");
240        Ok(stdout)
241    }
242
243    /// Get tracked worktrees in session state.
244    pub fn tracked(&self) -> &HashMap<String, WorktreeInfo> {
245        &self.tracked
246    }
247
248    /// Track a worktree in session state.
249    pub fn track(&mut self, name: String, info: WorktreeInfo) {
250        self.tracked.insert(name, info);
251    }
252
253    /// Untrack a worktree from session state.
254    pub fn untrack(&mut self, name: &str) -> Option<WorktreeInfo> {
255        self.tracked.remove(name)
256    }
257
258    // ── internal helpers ────────────────────────────────────────────────────
259
260    fn resolve_worktree_path(&self, name: &str) -> PathBuf {
261        let candidate = self.worktree_base.join(name);
262        if candidate.exists() {
263            candidate
264        } else {
265            // Try treating as absolute path
266            PathBuf::from(name)
267        }
268    }
269
270    async fn git_output(&self, args: &[&str], cwd: Option<&Path>) -> Option<String> {
271        let cwd = cwd.unwrap_or(&self.project_dir);
272        let output = Command::new("git")
273            .args(args)
274            .current_dir(cwd)
275            .output()
276            .await
277            .ok()?;
278
279        if !output.status.success() {
280            return None;
281        }
282
283        Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
284    }
285}
286
287// ── Parsing ─────────────────────────────────────────────────────────────────
288
289/// Parse `git worktree list --porcelain` output into [`WorktreeInfo`] entries.
290fn parse_porcelain_output(raw: &str) -> Vec<WorktreeInfo> {
291    let mut worktrees = Vec::new();
292    let mut path = String::new();
293    let mut commit = String::new();
294    let mut branch = String::new();
295    let mut is_main = false;
296    let mut has_entry = false;
297
298    for line in raw.lines() {
299        if let Some(rest) = line.strip_prefix("worktree ") {
300            // Flush previous entry
301            if has_entry {
302                worktrees.push(WorktreeInfo {
303                    path: std::mem::take(&mut path),
304                    branch: if branch.is_empty() {
305                        "detached".to_string()
306                    } else {
307                        std::mem::take(&mut branch)
308                    },
309                    commit: std::mem::take(&mut commit),
310                    is_main,
311                });
312                is_main = false;
313            }
314            path = rest.to_string();
315            has_entry = true;
316        } else if let Some(rest) = line.strip_prefix("HEAD ") {
317            commit = rest.to_string();
318        } else if let Some(rest) = line.strip_prefix("branch ") {
319            branch = rest.replace("refs/heads/", "");
320        } else if line == "bare" {
321            is_main = true;
322        }
323    }
324
325    // Flush last entry
326    if has_entry {
327        worktrees.push(WorktreeInfo {
328            path,
329            branch: if branch.is_empty() {
330                "detached".to_string()
331            } else {
332                branch
333            },
334            commit,
335            is_main,
336        });
337    }
338
339    worktrees
340}
341
342// ── Tests ───────────────────────────────────────────────────────────────────
343
344#[cfg(test)]
345#[path = "worktree_tests.rs"]
346mod tests;