1use anyhow::{anyhow, Result};
7use std::path::PathBuf;
8use std::process::Command;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum MergeResult {
13 Success,
15 Conflict { files: Vec<String> },
17 NothingToCommit,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct WorktreeInfo {
24 pub main_path: PathBuf,
26 pub worktree_path: PathBuf,
28 pub branch: String,
30}
31
32#[derive(Debug)]
34struct WorktreeEntry {
35 path: PathBuf,
36 branch: Option<String>,
37}
38
39fn parse_worktree_list(output: &str) -> Vec<WorktreeEntry> {
52 let mut entries = Vec::new();
53 let mut current_path: Option<PathBuf> = None;
54 let mut current_branch: Option<String> = None;
55
56 for line in output.lines() {
57 if let Some(path) = line.strip_prefix("worktree ") {
58 if let Some(path) = current_path.take() {
60 entries.push(WorktreeEntry {
61 path,
62 branch: current_branch.take(),
63 });
64 }
65 current_path = Some(PathBuf::from(path));
66 current_branch = None;
67 } else if let Some(branch_ref) = line.strip_prefix("branch ") {
68 current_branch = Some(
70 branch_ref
71 .strip_prefix("refs/heads/")
72 .unwrap_or(branch_ref)
73 .to_string(),
74 );
75 }
76 }
78
79 if let Some(path) = current_path {
81 entries.push(WorktreeEntry {
82 path,
83 branch: current_branch,
84 });
85 }
86
87 entries
88}
89
90pub fn detect_worktree(cwd: &std::path::Path) -> Result<Option<WorktreeInfo>> {
101 let output = Command::new("git")
103 .args(["worktree", "list", "--porcelain"])
104 .current_dir(cwd)
105 .output();
106
107 let output = match output {
108 Ok(o) => o,
109 Err(_) => return Ok(None), };
111
112 if !output.status.success() {
113 return Ok(None);
115 }
116
117 let stdout = String::from_utf8_lossy(&output.stdout);
118 let entries = parse_worktree_list(&stdout);
119
120 if entries.is_empty() {
121 return Ok(None);
122 }
123
124 let main_entry = &entries[0];
126 let main_path = &main_entry.path;
127
128 let mut current_entry: Option<&WorktreeEntry> = None;
131 for entry in &entries {
132 if cwd.starts_with(&entry.path) {
133 match current_entry {
134 None => current_entry = Some(entry),
135 Some(prev) if entry.path.as_os_str().len() > prev.path.as_os_str().len() => {
136 current_entry = Some(entry)
137 }
138 _ => {}
139 }
140 }
141 }
142
143 let current_entry = match current_entry {
144 Some(e) => e,
145 None => return Ok(None), };
147
148 if current_entry.path == *main_path {
150 return Ok(None);
151 }
152
153 Ok(Some(WorktreeInfo {
155 main_path: main_path.clone(),
156 worktree_path: current_entry.path.clone(),
157 branch: current_entry.branch.clone().unwrap_or_default(),
158 }))
159}
160
161pub fn commit_worktree_changes(cwd: &std::path::Path, message: &str) -> Result<bool> {
171 let add_output = Command::new("git")
173 .args(["add", "-A"])
174 .current_dir(cwd)
175 .output()?;
176
177 if !add_output.status.success() {
178 return Err(anyhow!(
179 "git add failed: {}",
180 String::from_utf8_lossy(&add_output.stderr)
181 ));
182 }
183
184 let commit_output = Command::new("git")
186 .args(["commit", "-m", message])
187 .current_dir(cwd)
188 .output()?;
189
190 if commit_output.status.success() {
191 return Ok(true);
192 }
193
194 let stderr = String::from_utf8_lossy(&commit_output.stderr);
196 let stdout = String::from_utf8_lossy(&commit_output.stdout);
197 if stderr.contains("nothing to commit")
198 || stdout.contains("nothing to commit")
199 || stderr.contains("no changes added")
200 || stdout.contains("no changes added")
201 {
202 return Ok(false);
203 }
204
205 Err(anyhow!("git commit failed: {}", stderr))
206}
207
208pub fn merge_to_main(info: &WorktreeInfo, unit_id: &str) -> Result<MergeResult> {
223 let main_path = &info.main_path;
224 let branch = &info.branch;
225
226 if branch.is_empty() {
227 return Err(anyhow!("Worktree has no branch (detached HEAD?)"));
228 }
229
230 let merge_message = format!("Merge branch '{}' (unit {})", branch, unit_id);
232 let merge_output = Command::new("git")
233 .args(["-C", main_path.to_str().unwrap_or(".")])
234 .args(["merge", branch, "--no-ff", "-m", &merge_message])
235 .output()?;
236
237 if merge_output.status.success() {
238 return Ok(MergeResult::Success);
239 }
240
241 let stderr = String::from_utf8_lossy(&merge_output.stderr);
242 let stdout = String::from_utf8_lossy(&merge_output.stdout);
243
244 if stdout.contains("Already up to date") || stderr.contains("Already up to date") {
246 return Ok(MergeResult::NothingToCommit);
247 }
248
249 if stdout.contains("CONFLICT") || stderr.contains("CONFLICT") {
251 let conflicts = parse_conflict_files(&stdout, &stderr);
253
254 let _ = Command::new("git")
256 .args(["-C", main_path.to_str().unwrap_or(".")])
257 .args(["merge", "--abort"])
258 .output();
259
260 return Ok(MergeResult::Conflict { files: conflicts });
261 }
262
263 Err(anyhow!("git merge failed: {}", stderr))
264}
265
266fn parse_conflict_files(stdout: &str, stderr: &str) -> Vec<String> {
268 let combined = format!("{}\n{}", stdout, stderr);
269 let mut files = Vec::new();
270
271 for line in combined.lines() {
272 if let Some(idx) = line.find("Merge conflict in ") {
274 let file = line[idx + "Merge conflict in ".len()..].trim();
275 files.push(file.to_string());
276 }
277 else if line.starts_with("CONFLICT") {
280 if let Some(colon_idx) = line.find("):") {
282 let rest = &line[colon_idx + 2..].trim();
283 if let Some(word) = rest.split_whitespace().next() {
285 if !word.is_empty() && word != "Merge" && !files.contains(&word.to_string()) {
286 files.push(word.to_string());
287 }
288 }
289 }
290 }
291 }
292
293 files
294}
295
296pub fn cleanup_worktree(info: &WorktreeInfo) -> Result<()> {
303 let main_path = &info.main_path;
304 let worktree_path = &info.worktree_path;
305 let branch = &info.branch;
306
307 let remove_output = Command::new("git")
309 .args(["-C", main_path.to_str().unwrap_or(".")])
310 .args(["worktree", "remove", worktree_path.to_str().unwrap_or(".")])
311 .output()?;
312
313 if !remove_output.status.success() {
314 let force_output = Command::new("git")
316 .args(["-C", main_path.to_str().unwrap_or(".")])
317 .args([
318 "worktree",
319 "remove",
320 "--force",
321 worktree_path.to_str().unwrap_or("."),
322 ])
323 .output()?;
324
325 if !force_output.status.success() {
326 return Err(anyhow!(
327 "Failed to remove worktree: {}",
328 String::from_utf8_lossy(&force_output.stderr)
329 ));
330 }
331 }
332
333 if !branch.is_empty() {
335 let delete_output = Command::new("git")
336 .args(["-C", main_path.to_str().unwrap_or(".")])
337 .args(["branch", "-d", branch])
338 .output()?;
339
340 if !delete_output.status.success() {
341 let force_delete = Command::new("git")
343 .args(["-C", main_path.to_str().unwrap_or(".")])
344 .args(["branch", "-D", branch])
345 .output()?;
346
347 if !force_delete.status.success() {
348 return Err(anyhow!(
349 "Failed to delete branch '{}': {}",
350 branch,
351 String::from_utf8_lossy(&force_delete.stderr)
352 ));
353 }
354 }
355 }
356
357 Ok(())
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 #[test]
365 fn test_parse_worktree_list_single() {
366 let output = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n";
367 let entries = parse_worktree_list(output);
368
369 assert_eq!(entries.len(), 1);
370 assert_eq!(entries[0].path, PathBuf::from("/home/user/project"));
371 assert_eq!(entries[0].branch, Some("main".to_string()));
372 }
373
374 #[test]
375 fn test_parse_worktree_list_multiple() {
376 let output = r#"worktree /home/user/project
377HEAD abc123
378branch refs/heads/main
379
380worktree /home/user/project-feature
381HEAD def456
382branch refs/heads/feature-x
383"#;
384 let entries = parse_worktree_list(output);
385
386 assert_eq!(entries.len(), 2);
387 assert_eq!(entries[0].path, PathBuf::from("/home/user/project"));
388 assert_eq!(entries[0].branch, Some("main".to_string()));
389 assert_eq!(entries[1].path, PathBuf::from("/home/user/project-feature"));
390 assert_eq!(entries[1].branch, Some("feature-x".to_string()));
391 }
392
393 #[test]
394 fn test_parse_worktree_list_detached_head() {
395 let output = "worktree /home/user/project\nHEAD abc123\ndetached\n";
396 let entries = parse_worktree_list(output);
397
398 assert_eq!(entries.len(), 1);
399 assert_eq!(entries[0].path, PathBuf::from("/home/user/project"));
400 assert_eq!(entries[0].branch, None);
401 }
402
403 #[test]
404 fn detect_worktree_runs_without_panic() {
405 let cwd = std::env::current_dir().unwrap();
408 let result = detect_worktree(&cwd);
409 assert!(result.is_ok());
410 }
411
412 mod merge {
414 use super::*;
415
416 #[test]
417 fn test_merge_result_variants() {
418 let success = MergeResult::Success;
420 let conflict = MergeResult::Conflict {
421 files: vec!["file1.txt".to_string(), "file2.txt".to_string()],
422 };
423 let nothing = MergeResult::NothingToCommit;
424
425 assert_eq!(success, MergeResult::Success);
426 assert_eq!(nothing, MergeResult::NothingToCommit);
427
428 if let MergeResult::Conflict { files } = conflict {
429 assert_eq!(files.len(), 2);
430 assert!(files.contains(&"file1.txt".to_string()));
431 } else {
432 unreachable!("Expected Conflict variant");
433 }
434 }
435
436 #[test]
437 fn test_parse_conflict_files_content_conflict() {
438 let stdout =
439 "Auto-merging src/lib.rs\nCONFLICT (content): Merge conflict in src/lib.rs\n";
440 let stderr = "";
441 let files = parse_conflict_files(stdout, stderr);
442 assert_eq!(files, vec!["src/lib.rs"]);
443 }
444
445 #[test]
446 fn test_parse_conflict_files_multiple() {
447 let stdout = r#"Auto-merging file1.txt
448CONFLICT (content): Merge conflict in file1.txt
449Auto-merging file2.txt
450CONFLICT (content): Merge conflict in file2.txt
451"#;
452 let files = parse_conflict_files(stdout, "");
453 assert_eq!(files.len(), 2);
454 assert!(files.contains(&"file1.txt".to_string()));
455 assert!(files.contains(&"file2.txt".to_string()));
456 }
457
458 #[test]
459 fn test_parse_conflict_files_empty() {
460 let files = parse_conflict_files("", "");
461 assert!(files.is_empty());
462 }
463
464 #[test]
465 fn test_parse_conflict_files_no_conflicts() {
466 let stdout = "Already up to date.\n";
467 let files = parse_conflict_files(stdout, "");
468 assert!(files.is_empty());
469 }
470
471 #[test]
472 fn test_worktree_info_for_merge() {
473 let info = WorktreeInfo {
475 main_path: PathBuf::from("/home/user/project"),
476 worktree_path: PathBuf::from("/home/user/project-feature"),
477 branch: "feature-branch".to_string(),
478 };
479
480 assert_eq!(info.branch, "feature-branch");
481 assert_eq!(info.main_path, PathBuf::from("/home/user/project"));
482 assert_eq!(
483 info.worktree_path,
484 PathBuf::from("/home/user/project-feature")
485 );
486 }
487
488 #[test]
489 fn test_merge_to_main_requires_branch() {
490 let info = WorktreeInfo {
492 main_path: PathBuf::from("/tmp/nonexistent"),
493 worktree_path: PathBuf::from("/tmp/nonexistent-wt"),
494 branch: String::new(), };
496
497 let result = merge_to_main(&info, "test-unit");
498 assert!(result.is_err());
499 let err = result.unwrap_err();
500 assert!(err.to_string().contains("no branch"));
501 }
502
503 #[test]
504 fn test_commit_worktree_changes_type_signature() {
505 let cwd = std::env::current_dir().unwrap();
509 let result = commit_worktree_changes(&cwd, "test message");
510 let _ = result;
512 }
513
514 #[test]
515 fn test_cleanup_worktree_type_signature() {
516 let info = WorktreeInfo {
518 main_path: PathBuf::from("/tmp/nonexistent-main"),
519 worktree_path: PathBuf::from("/tmp/nonexistent-wt"),
520 branch: "test-branch".to_string(),
521 };
522
523 let result = cleanup_worktree(&info);
525 assert!(result.is_err()); }
527 }
528}