1use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12
13use thiserror::Error;
14use tokio::process::Command;
15use tracing::{debug, warn};
16
17const 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
29fn random_name() -> String {
31 use std::time::SystemTime;
32 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct WorktreeInfo {
61 pub path: String,
63 pub branch: String,
65 pub commit: String,
67 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
78pub struct WorktreeManager {
82 project_dir: PathBuf,
84 worktree_base: PathBuf,
86 tracked: HashMap<String, WorktreeInfo>,
88}
89
90impl WorktreeManager {
91 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 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 pub fn project_dir(&self) -> &Path {
121 &self.project_dir
122 }
123
124 pub fn worktree_base(&self) -> &Path {
126 &self.worktree_base
127 }
128
129 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 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 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 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 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 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 pub fn tracked(&self) -> &HashMap<String, WorktreeInfo> {
245 &self.tracked
246 }
247
248 pub fn track(&mut self, name: String, info: WorktreeInfo) {
250 self.tracked.insert(name, info);
251 }
252
253 pub fn untrack(&mut self, name: &str) -> Option<WorktreeInfo> {
255 self.tracked.remove(name)
256 }
257
258 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 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
287fn 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 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 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#[cfg(test)]
345#[path = "worktree_tests.rs"]
346mod tests;