opendev_context/
worktree.rs1use std::path::{Path, PathBuf};
8use std::process::Command;
9
10use tracing::{debug, warn};
11
12#[derive(Debug, Clone)]
14pub struct WorktreeInfo {
15 pub path: PathBuf,
17 pub branch: String,
19 pub head: String,
21 pub is_main: bool,
23}
24
25pub struct WorktreeManager {
27 repo_root: PathBuf,
29}
30
31impl WorktreeManager {
32 pub fn new(repo_root: &Path) -> Self {
34 Self {
35 repo_root: repo_root.to_path_buf(),
36 }
37 }
38
39 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 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 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 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 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 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 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
181fn 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 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(); } 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 branch = rest.strip_prefix("refs/heads/").unwrap_or(rest).to_string();
207 }
208 }
209
210 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;