Skip to main content

opendev_context/
worktree.rs

1//! Git worktree manager — create and manage isolated workspaces.
2//!
3//! Uses `git worktree` to create lightweight, isolated copies of the repository
4//! for subagent work. Each worktree gets its own branch and can be independently
5//! modified without affecting the main workspace.
6
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10use tracing::{debug, warn};
11
12/// Information about a git worktree.
13#[derive(Debug, Clone)]
14pub struct WorktreeInfo {
15    /// Absolute path to the worktree directory.
16    pub path: PathBuf,
17    /// Branch checked out in this worktree.
18    pub branch: String,
19    /// HEAD commit hash.
20    pub head: String,
21    /// Whether this is the main worktree (bare = false).
22    pub is_main: bool,
23}
24
25/// Manages git worktrees for workspace isolation.
26pub struct WorktreeManager {
27    /// Root of the main git repository.
28    repo_root: PathBuf,
29}
30
31impl WorktreeManager {
32    /// Create a new worktree manager for the given repository root.
33    pub fn new(repo_root: &Path) -> Self {
34        Self {
35            repo_root: repo_root.to_path_buf(),
36        }
37    }
38
39    /// Create a new worktree with an auto-generated branch name.
40    ///
41    /// Returns the worktree path and branch name.
42    pub fn create(&self, prefix: &str) -> Result<WorktreeInfo, String> {
43        let branch_name = format!("opendev/{prefix}/{}", generate_short_id());
44        let worktree_path = self.worktrees_dir().join(branch_name.replace('/', "_"));
45
46        self.create_at(&worktree_path, &branch_name)
47    }
48
49    /// Create a worktree at a specific path with a specific branch.
50    pub fn create_at(&self, path: &Path, branch: &str) -> Result<WorktreeInfo, String> {
51        if let Some(parent) = path.parent() {
52            std::fs::create_dir_all(parent)
53                .map_err(|e| format!("Failed to create parent dir: {e}"))?;
54        }
55
56        let output = Command::new("git")
57            .args(["worktree", "add", &path.to_string_lossy(), "-b", branch])
58            .current_dir(&self.repo_root)
59            .output()
60            .map_err(|e| format!("Failed to run git: {e}"))?;
61
62        if !output.status.success() {
63            let stderr = String::from_utf8_lossy(&output.stderr);
64            return Err(format!("git worktree add failed: {stderr}"));
65        }
66
67        debug!(
68            "Created worktree at {} on branch {}",
69            path.display(),
70            branch
71        );
72
73        // Get HEAD
74        let head = self
75            .git_in(path, &["rev-parse", "HEAD"])
76            .unwrap_or_else(|| "unknown".to_string());
77
78        Ok(WorktreeInfo {
79            path: path.to_path_buf(),
80            branch: branch.to_string(),
81            head: head.trim().to_string(),
82            is_main: false,
83        })
84    }
85
86    /// List all worktrees.
87    pub fn list(&self) -> Vec<WorktreeInfo> {
88        let output = match Command::new("git")
89            .args(["worktree", "list", "--porcelain"])
90            .current_dir(&self.repo_root)
91            .output()
92        {
93            Ok(o) => o,
94            Err(e) => {
95                warn!("Failed to list worktrees: {e}");
96                return Vec::new();
97            }
98        };
99
100        if !output.status.success() {
101            return Vec::new();
102        }
103
104        let stdout = String::from_utf8_lossy(&output.stdout);
105        parse_porcelain_output(&stdout)
106    }
107
108    /// Remove a worktree by path.
109    pub fn remove(&self, path: &Path) -> Result<(), String> {
110        let output = Command::new("git")
111            .args(["worktree", "remove", &path.to_string_lossy(), "--force"])
112            .current_dir(&self.repo_root)
113            .output()
114            .map_err(|e| format!("Failed to run git: {e}"))?;
115
116        if !output.status.success() {
117            let stderr = String::from_utf8_lossy(&output.stderr);
118            return Err(format!("git worktree remove failed: {stderr}"));
119        }
120
121        debug!("Removed worktree at {}", path.display());
122        Ok(())
123    }
124
125    /// Remove all stale/pruned worktrees.
126    pub fn prune(&self) -> Result<(), String> {
127        let output = Command::new("git")
128            .args(["worktree", "prune"])
129            .current_dir(&self.repo_root)
130            .output()
131            .map_err(|e| format!("Failed to run git: {e}"))?;
132
133        if !output.status.success() {
134            let stderr = String::from_utf8_lossy(&output.stderr);
135            return Err(format!("git worktree prune failed: {stderr}"));
136        }
137
138        Ok(())
139    }
140
141    /// Clean up all opendev-created worktrees.
142    pub fn cleanup_all(&self) -> Vec<String> {
143        let worktrees = self.list();
144        let mut removed = Vec::new();
145
146        for wt in worktrees {
147            if wt.is_main {
148                continue;
149            }
150            if wt.branch.starts_with("opendev/") {
151                match self.remove(&wt.path) {
152                    Ok(()) => removed.push(wt.path.to_string_lossy().to_string()),
153                    Err(e) => warn!("Failed to remove worktree {}: {}", wt.path.display(), e),
154                }
155            }
156        }
157
158        let _ = self.prune();
159        removed
160    }
161
162    fn worktrees_dir(&self) -> PathBuf {
163        self.repo_root.join(".git").join("opendev-worktrees")
164    }
165
166    fn git_in(&self, dir: &Path, args: &[&str]) -> Option<String> {
167        let output = Command::new("git")
168            .args(args)
169            .current_dir(dir)
170            .output()
171            .ok()?;
172
173        if output.status.success() {
174            Some(String::from_utf8_lossy(&output.stdout).to_string())
175        } else {
176            None
177        }
178    }
179}
180
181/// Parse `git worktree list --porcelain` output.
182fn parse_porcelain_output(output: &str) -> Vec<WorktreeInfo> {
183    let mut worktrees = Vec::new();
184    let mut path: Option<PathBuf> = None;
185    let mut head = String::new();
186    let mut branch = String::new();
187    let mut is_main = true;
188
189    for line in output.lines() {
190        if let Some(rest) = line.strip_prefix("worktree ") {
191            // Save previous entry
192            if let Some(p) = path.take() {
193                worktrees.push(WorktreeInfo {
194                    path: p,
195                    branch: std::mem::take(&mut branch),
196                    head: std::mem::take(&mut head),
197                    is_main,
198                });
199            }
200            path = Some(PathBuf::from(rest));
201            is_main = worktrees.is_empty(); // First worktree is main
202        } else if let Some(rest) = line.strip_prefix("HEAD ") {
203            head = rest.to_string();
204        } else if let Some(rest) = line.strip_prefix("branch ") {
205            // refs/heads/branch-name → branch-name
206            branch = rest.strip_prefix("refs/heads/").unwrap_or(rest).to_string();
207        }
208    }
209
210    // Push last entry
211    if let Some(p) = path {
212        worktrees.push(WorktreeInfo {
213            path: p,
214            branch,
215            head,
216            is_main,
217        });
218    }
219
220    worktrees
221}
222
223fn generate_short_id() -> String {
224    use std::collections::hash_map::DefaultHasher;
225    use std::hash::{Hash, Hasher};
226    use std::time::SystemTime;
227
228    let mut hasher = DefaultHasher::new();
229    SystemTime::now().hash(&mut hasher);
230    std::process::id().hash(&mut hasher);
231    format!("{:08x}", hasher.finish() as u32)
232}
233
234impl std::fmt::Debug for WorktreeManager {
235    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236        f.debug_struct("WorktreeManager")
237            .field("repo_root", &self.repo_root)
238            .finish()
239    }
240}
241
242#[cfg(test)]
243#[path = "worktree_tests.rs"]
244mod tests;