1use anyhow::{bail, Context, Result};
2use crate::config::Config;
3use crate::worktree::{find_worktree_for_branch, ensure_worktree};
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7pub(crate) fn run(dir: &Path, args: &[&str]) -> Result<String> {
8 let out = Command::new("git")
9 .current_dir(dir)
10 .args(args)
11 .output()
12 .context("git not found")?;
13 if !out.status.success() {
14 anyhow::bail!("{}", String::from_utf8_lossy(&out.stderr).trim());
15 }
16 Ok(String::from_utf8(out.stdout)?.trim().to_string())
17}
18
19pub fn current_branch(root: &Path) -> Result<String> {
20 run(root, &["branch", "--show-current"])
21}
22
23pub fn has_commits(root: &Path) -> bool {
24 run(root, &["rev-parse", "HEAD"]).is_ok()
25}
26
27pub fn fetch_all(root: &Path) -> Result<()> {
28 run(root, &["fetch", "--all", "--quiet"]).map(|_| ())
29}
30
31pub fn read_from_branch(root: &Path, branch: &str, rel_path: &str) -> Result<String> {
35 run(root, &["show", &format!("{branch}:{rel_path}")])
36 .or_else(|_| run(root, &["show", &format!("origin/{branch}:{rel_path}")]))
37}
38
39pub fn read_from_branch_with_class(
50 root: &Path,
51 branch: &str,
52 rel_path: &str,
53) -> Result<(String, BranchClass)> {
54 let local_ref = format!("refs/heads/{branch}");
55 let remote_ref = format!("origin/{branch}");
56 let class = classify_branch(root, &local_ref, &remote_ref);
57 let content = match &class {
58 BranchClass::Behind | BranchClass::RemoteOnly | BranchClass::Equal => {
59 run(root, &["show", &format!("{remote_ref}:{rel_path}")])
60 .or_else(|_| run(root, &["show", &format!("{branch}:{rel_path}")]))?
61 }
62 BranchClass::Ahead | BranchClass::NoRemote | BranchClass::Diverged => {
63 run(root, &["show", &format!("{branch}:{rel_path}")])
64 .or_else(|_| run(root, &["show", &format!("{remote_ref}:{rel_path}")]))?
65 }
66 };
67 Ok((content, class))
68}
69
70pub fn ticket_branches(root: &Path) -> Result<Vec<String>> {
74 let mut seen = std::collections::HashSet::new();
75 let mut branches = Vec::new();
76
77 let local = run(root, &["branch", "--list", "ticket/*"]).unwrap_or_default();
78 for b in local.lines()
79 .map(|l| l.trim().trim_start_matches(['*', '+']).trim())
80 .filter(|l| !l.is_empty())
81 {
82 if seen.insert(b.to_string()) {
83 branches.push(b.to_string());
84 }
85 }
86
87 let remote = run(root, &["branch", "-r", "--list", "origin/ticket/*"]).unwrap_or_default();
88 for b in remote.lines()
89 .map(|l| l.trim().trim_start_matches("origin/").to_string())
90 .filter(|l| !l.is_empty())
91 {
92 if seen.insert(b.clone()) {
93 branches.push(b);
94 }
95 }
96
97 Ok(branches)
98}
99
100pub fn merged_into_main(root: &Path, default_branch: &str) -> Result<Vec<String>> {
103 let remote_ref = format!("refs/remotes/origin/{default_branch}");
104 let remote_merged = format!("origin/{default_branch}");
105
106 if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
107 let regular_out = run(
109 root,
110 &["branch", "-r", "--merged", &remote_merged, "--list", "origin/ticket/*"],
111 )
112 .unwrap_or_default();
113 let mut merged: Vec<String> = regular_out
114 .lines()
115 .map(|l| l.trim().trim_start_matches("origin/").to_string())
116 .filter(|l| !l.is_empty())
117 .collect();
118 let merged_set: std::collections::HashSet<String> = merged.iter().cloned().collect();
119
120 let all_remote = run(root, &["branch", "-r", "--list", "origin/ticket/*"])
123 .unwrap_or_default();
124 let remote_candidates: Vec<String> = all_remote
125 .lines()
126 .map(|l| l.trim().to_string())
127 .filter(|l| {
128 let stripped = l.strip_prefix("origin/").unwrap_or(l.as_str());
129 !l.is_empty() && !merged_set.contains(stripped)
130 })
131 .collect();
132 let remote_squashed = squash_merged(root, &remote_merged, remote_candidates)?;
133 merged.extend(remote_squashed.into_iter().map(|b| {
135 b.strip_prefix("origin/").unwrap_or(&b).to_string()
136 }));
137
138 let remote_stripped: std::collections::HashSet<String> = all_remote
141 .lines()
142 .map(|l| l.trim().trim_start_matches("origin/").to_string())
143 .filter(|l| !l.is_empty())
144 .collect();
145 let merged_now: std::collections::HashSet<String> = merged.iter().cloned().collect();
146 let all_local = run(root, &["branch", "--list", "ticket/*"]).unwrap_or_default();
147 let local_only: Vec<String> = all_local
148 .lines()
149 .map(|l| l.trim().trim_start_matches(['*', '+']).trim().to_string())
150 .filter(|l| {
151 !l.is_empty()
152 && !remote_stripped.contains(l)
153 && !merged_now.contains(l)
154 })
155 .collect();
156 merged.extend(squash_merged(root, &remote_merged, local_only)?);
157
158 let local_default_ref = format!("refs/heads/{default_branch}");
166 if run(root, &["rev-parse", "--verify", &local_default_ref]).is_ok() {
167 let already: std::collections::HashSet<String> = merged.iter().cloned().collect();
168 let local_regular = run(
169 root,
170 &["branch", "--merged", default_branch, "--list", "ticket/*"],
171 )
172 .unwrap_or_default();
173 for line in local_regular.lines() {
174 let b = line.trim().trim_start_matches(['*', '+']).trim().to_string();
175 if !b.is_empty() && !already.contains(&b) {
176 merged.push(b);
177 }
178 }
179 }
180
181 return Ok(merged);
182 }
183
184 let local_ref = format!("refs/heads/{default_branch}");
186 if run(root, &["rev-parse", "--verify", &local_ref]).is_err() {
187 return Ok(vec![]);
188 }
189 let regular_out = run(
190 root,
191 &["branch", "--merged", default_branch, "--list", "ticket/*"],
192 )
193 .unwrap_or_default();
194 let mut merged: Vec<String> = regular_out
195 .lines()
196 .map(|l| l.trim().trim_start_matches(['*', '+']).trim().to_string())
197 .filter(|l| !l.is_empty())
198 .collect();
199 let merged_set: std::collections::HashSet<&str> = merged.iter().map(|s| s.as_str()).collect();
200
201 let all_local = run(root, &["branch", "--list", "ticket/*"]).unwrap_or_default();
202 let candidates: Vec<String> = all_local
203 .lines()
204 .map(|l| l.trim().trim_start_matches(['*', '+']).trim().to_string())
205 .filter(|l| !l.is_empty() && !merged_set.contains(l.as_str()))
206 .collect();
207 merged.extend(squash_merged(root, default_branch, candidates)?);
208 Ok(merged)
209}
210
211fn squash_merged(root: &Path, main_ref: &str, candidates: Vec<String>) -> Result<Vec<String>> {
218 let mut result = Vec::new();
219 for branch in candidates {
220 let merge_base = match run(root, &["merge-base", main_ref, &branch]) {
221 Ok(mb) => mb,
222 Err(_) => continue,
223 };
224 let branch_tip = match run(root, &["rev-parse", &format!("{branch}^{{commit}}")]) {
225 Ok(t) => t,
226 Err(_) => continue,
227 };
228 if branch_tip == merge_base {
230 continue;
231 }
232 let squash_commit = match run(root, &[
234 "commit-tree", &format!("{branch}^{{tree}}"),
235 "-p", &merge_base,
236 "-m", "squash",
237 ]) {
238 Ok(c) => c,
239 Err(_) => continue,
240 };
241 let cherry_out = match run(root, &["cherry", main_ref, &squash_commit]) {
243 Ok(o) => o,
244 Err(_) => continue,
245 };
246 if cherry_out.trim().starts_with('-') {
247 result.push(branch);
248 }
249 }
250 Ok(result)
251}
252
253pub fn content_merged_into_main(
260 root: &Path,
261 main_ref: &str,
262 branch: &str,
263 tickets_dir: &str,
264) -> Result<bool> {
265 let merge_base = match run(root, &["merge-base", main_ref, branch]) {
267 Ok(mb) => mb,
268 Err(_) => return Ok(false),
269 };
270 let branch_tip = match run(root, &["rev-parse", &format!("{branch}^{{commit}}")]) {
272 Ok(t) => t,
273 Err(_) => return Ok(false),
274 };
275 if branch_tip == merge_base {
277 return Ok(false);
278 }
279 let log_out = match run(root, &["log", "--pretty=%H", branch, &format!("^{merge_base}")]) {
281 Ok(o) => o,
282 Err(_) => return Ok(false),
283 };
284 let tickets_prefix = format!("{tickets_dir}/");
286 let mut content_tip: Option<String> = None;
287 for sha in log_out.lines() {
288 let diff_out = match run(root, &["diff-tree", "--no-commit-id", "-r", "--name-only", sha]) {
289 Ok(o) => o,
290 Err(_) => continue,
291 };
292 let has_non_ticket = diff_out.lines().any(|path| !path.starts_with(&tickets_prefix));
293 if has_non_ticket {
294 content_tip = Some(sha.to_string());
295 break;
296 }
297 }
298 if content_tip.is_none() {
300 let parent_spec = format!("{merge_base}^1");
314 if let Ok(fp_log) = run(root, &[
315 "rev-list", "--first-parent", main_ref, &format!("^{parent_spec}"),
316 ]) {
317 let oldest = fp_log.lines().last().unwrap_or("").trim();
318 if !oldest.is_empty() && oldest != merge_base {
319 return Ok(true);
321 }
322 }
323 return Ok(false);
324 }
325 let content_tip = content_tip.unwrap();
326 if content_tip == branch_tip {
329 return Ok(false);
330 }
331 let squash_commit = match run(root, &[
333 "commit-tree", &format!("{content_tip}^{{tree}}"),
334 "-p", &merge_base,
335 "-m", "squash",
336 ]) {
337 Ok(c) => c,
338 Err(_) => return Ok(false),
339 };
340 let cherry_out = match run(root, &["cherry", main_ref, &squash_commit]) {
342 Ok(o) => o,
343 Err(_) => return Ok(false),
344 };
345 Ok(cherry_out.trim().starts_with('-'))
346}
347
348pub fn commit_to_branch(
354 root: &Path,
355 branch: &str,
356 rel_path: &str,
357 content: &str,
358 message: &str,
359) -> Result<()> {
360 if !has_commits(root) {
362 let local_path = root.join(rel_path);
363 if let Some(parent) = local_path.parent() {
364 std::fs::create_dir_all(parent)?;
365 }
366 std::fs::write(&local_path, content)?;
367 return Ok(());
368 }
369
370 if let Some(wt_path) = find_worktree_for_branch(root, branch) {
372 let remote_ref = format!("origin/{branch}");
376 if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
377 let _ = run(&wt_path, &["merge", "--ff-only", &remote_ref]);
378 }
379 let full_path = wt_path.join(rel_path);
380 if let Some(parent) = full_path.parent() {
381 std::fs::create_dir_all(parent)?;
382 }
383 std::fs::write(&full_path, content)?;
384 run(&wt_path, &["add", rel_path])
385 .with_context(|| format!("git add {rel_path} in worktree {} failed", wt_path.display()))?;
386 run(&wt_path, &["commit", "-m", message, "--", rel_path])
387 .with_context(|| format!("git commit on {branch} in worktree {} failed", wt_path.display()))?;
388 crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
389 return Ok(());
390 }
391
392 if current_branch(root).ok().as_deref() == Some(branch) {
394 let local_path = root.join(rel_path);
395 if let Some(parent) = local_path.parent() {
396 std::fs::create_dir_all(parent)?;
397 }
398 std::fs::write(&local_path, content)?;
399 run(root, &["add", rel_path])
400 .with_context(|| format!("git add {rel_path} failed"))?;
401 run(root, &["commit", "-m", message, "--", rel_path])
402 .with_context(|| format!("git commit on {branch} failed"))?;
403 crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
404 return Ok(());
405 }
406
407 let result = try_worktree_commit(root, branch, rel_path, content, message);
408 if result.is_ok() {
409 crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
410 }
411 result
412}
413
414fn try_worktree_commit(
415 root: &Path,
416 branch: &str,
417 rel_path: &str,
418 content: &str,
419 message: &str,
420) -> Result<()> {
421 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
422 let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
423 let wt_path = std::env::temp_dir().join(format!(
424 "apm-{}-{}-{}",
425 std::process::id(),
426 seq,
427 branch.replace('/', "-"),
428 ));
429
430 let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
431 let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
432
433 if has_remote {
434 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
435 let _ = run(&wt_path, &["checkout", "-B", branch]);
436 } else if has_local {
437 let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
439 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
440 let _ = run(&wt_path, &["checkout", "-B", branch]);
441 } else {
442 run(root, &["worktree", "add", "-b", branch, &wt_path.to_string_lossy(), "HEAD"])?;
443 }
444
445 let result = (|| -> Result<()> {
446 let full_path = wt_path.join(rel_path);
447 if let Some(parent) = full_path.parent() {
448 std::fs::create_dir_all(parent)?;
449 }
450 std::fs::write(&full_path, content)?;
451 run(&wt_path, &["add", rel_path])?;
452 run(&wt_path, &["commit", "-m", message, "--", rel_path])?;
453 Ok(())
454 })();
455
456 let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
457 let _ = std::fs::remove_dir_all(&wt_path);
458
459 result
460}
461
462
463pub fn push_ticket_branches(root: &Path, warnings: &mut Vec<String>) {
466 if run(root, &["remote", "get-url", "origin"]).is_err() {
467 return;
468 }
469 let out = match run(root, &["branch", "--list", "ticket/*"]) {
470 Ok(o) => o,
471 Err(_) => return,
472 };
473 for branch in out.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
474 let range = format!("origin/{branch}..{branch}");
475 let count = run(root, &["rev-list", "--count", &range])
476 .ok()
477 .and_then(|s| s.trim().parse::<u32>().ok())
478 .unwrap_or(0);
479 if count > 0 {
480 if let Err(e) = run(root, &["push", "origin", branch]) {
481 warnings.push(format!("warning: push {branch} failed: {e:#}"));
482 }
483 }
484 }
485}
486
487pub fn sync_non_checked_out_refs(root: &Path, warnings: &mut Vec<String>) -> Vec<String> {
515 let checked_out: std::collections::HashSet<String> = {
518 let mut set = std::collections::HashSet::new();
519 if let Ok(out) = run(root, &["worktree", "list", "--porcelain"]) {
520 for line in out.lines() {
521 if let Some(b) = line.strip_prefix("branch refs/heads/") {
522 set.insert(b.to_string());
523 }
524 }
525 }
526 set
527 };
528
529 const MANAGED_NAMESPACES: &[&str] = &["ticket", "epic"];
532
533 let mut remote_refs: Vec<String> = Vec::new();
535 for ns in MANAGED_NAMESPACES {
536 let pattern = format!("refs/remotes/origin/{ns}/");
537 if let Ok(out) = run(root, &["for-each-ref", "--format=%(refname:short)", &pattern]) {
538 for line in out.lines().filter(|l| !l.is_empty()) {
539 remote_refs.push(line.to_string());
540 }
541 }
542 }
543
544 let mut ahead_branches: Vec<String> = Vec::new();
545
546 for remote_name in remote_refs {
547 let branch = match remote_name.strip_prefix("origin/") {
550 Some(b) => b.to_string(),
551 None => continue,
552 };
553
554 if checked_out.contains(&branch) {
556 continue;
557 }
558
559 let local_ref = format!("refs/heads/{branch}");
560 let remote_ref_full = format!("refs/remotes/{remote_name}");
562
563 match classify_branch(root, &local_ref, &remote_name) {
566 BranchClass::RemoteOnly => {
567 let sha = match run(root, &["rev-parse", &remote_ref_full]) {
570 Ok(s) => s,
571 Err(_) => continue,
572 };
573 if let Err(e) = run(root, &["update-ref", &local_ref, &sha]) {
574 warnings.push(format!("warning: could not create local ref {branch}: {e:#}"));
575 }
576 }
577 BranchClass::Equal => {
578 }
580 BranchClass::Behind => {
581 let sha = match run(root, &["rev-parse", &remote_ref_full]) {
584 Ok(s) => s,
585 Err(_) => continue,
586 };
587 if let Err(e) = run(root, &["update-ref", &local_ref, &sha]) {
588 warnings.push(format!("warning: could not fast-forward {branch}: {e:#}"));
589 }
590 }
591 BranchClass::Ahead => {
592 warnings.push(crate::sync_guidance::TICKET_OR_EPIC_AHEAD.replace("<slug>", &branch));
598 ahead_branches.push(branch);
599 }
600 BranchClass::Diverged => {
601 let msg = crate::sync_guidance::TICKET_OR_EPIC_DIVERGED
604 .replace("<slug>", &branch);
605 warnings.push(msg);
606 }
607 BranchClass::NoRemote => {
608 }
611 }
612 }
613
614 ahead_branches
615}
616
617pub fn list_files_on_branch(root: &Path, branch: &str, dir: &str) -> Result<Vec<String>> {
619 let tree_ref = format!("{branch}:{dir}");
620 let out = run(root, &["ls-tree", "--name-only", &tree_ref])
621 .or_else(|_| run(root, &["ls-tree", "--name-only", &format!("origin/{branch}:{dir}")]))?;
622 Ok(out.lines()
623 .filter(|l| !l.is_empty())
624 .map(|l| format!("{dir}/{l}"))
625 .collect())
626}
627
628pub fn commit_files_to_branch(
630 root: &Path,
631 branch: &str,
632 files: &[(&str, String)],
633 message: &str,
634) -> Result<()> {
635 if !has_commits(root) {
636 for (rel_path, content) in files {
637 let local_path = root.join(rel_path);
638 if let Some(parent) = local_path.parent() {
639 std::fs::create_dir_all(parent)?;
640 }
641 std::fs::write(&local_path, content)?;
642 }
643 return Ok(());
644 }
645
646 if let Some(wt_path) = find_worktree_for_branch(root, branch) {
647 for (rel_path, content) in files {
648 let full_path = wt_path.join(rel_path);
649 if let Some(parent) = full_path.parent() {
650 std::fs::create_dir_all(parent)?;
651 }
652 std::fs::write(&full_path, content)?;
653 let _ = run(&wt_path, &["add", rel_path]);
654 }
655 run(&wt_path, &["commit", "-m", message])?;
656 crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
657 return Ok(());
658 }
659
660 if current_branch(root).ok().as_deref() == Some(branch) {
661 for (rel_path, content) in files {
662 let local_path = root.join(rel_path);
663 if let Some(parent) = local_path.parent() {
664 std::fs::create_dir_all(parent)?;
665 }
666 std::fs::write(&local_path, content)?;
667 let _ = run(root, &["add", rel_path]);
668 }
669 run(root, &["commit", "-m", message])?;
670 crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
671 return Ok(());
672 }
673
674 let unique = std::time::SystemTime::now()
675 .duration_since(std::time::UNIX_EPOCH)
676 .map(|d| d.subsec_nanos())
677 .unwrap_or(0);
678 let wt_path = std::env::temp_dir().join(format!(
679 "apm-{}-{}-{}",
680 std::process::id(),
681 unique,
682 branch.replace('/', "-"),
683 ));
684
685 let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
686 let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
687
688 if has_remote {
689 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
690 let _ = run(&wt_path, &["checkout", "-B", branch]);
691 } else if has_local {
692 let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
693 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
694 let _ = run(&wt_path, &["checkout", "-B", branch]);
695 } else {
696 run(root, &["worktree", "add", &wt_path.to_string_lossy(), branch])?;
697 }
698
699 let result = (|| -> Result<()> {
700 for (rel_path, content) in files {
701 let full_path = wt_path.join(rel_path);
702 if let Some(parent) = full_path.parent() {
703 std::fs::create_dir_all(parent)?;
704 }
705 std::fs::write(&full_path, content)?;
706 run(&wt_path, &["add", rel_path])?;
707 }
708 run(&wt_path, &["commit", "-m", message])?;
709 Ok(())
710 })();
711
712 let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
713 let _ = std::fs::remove_dir_all(&wt_path);
714
715 if result.is_ok() {
716 crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
717 }
718 result
719}
720
721pub fn branch_tip(root: &Path, branch: &str) -> Option<String> {
723 run(root, &["rev-parse", &format!("refs/heads/{branch}")]).ok()
724}
725
726pub fn resolve_branch_sha(root: &Path, branch: &str) -> Result<String> {
729 run(root, &["rev-parse", &format!("origin/{branch}")])
730 .or_else(|_| run(root, &["rev-parse", branch]))
731 .with_context(|| format!("branch '{branch}' not found locally or on origin"))
732}
733
734pub fn create_branch_at(root: &Path, branch: &str, sha: &str) -> Result<()> {
736 run(root, &["branch", branch, sha]).map(|_| ())
737}
738
739pub fn remote_branch_tip(root: &Path, branch: &str) -> Option<String> {
741 run(root, &["rev-parse", &format!("refs/remotes/origin/{branch}")]).ok()
742}
743
744pub fn is_ancestor(root: &Path, commit: &str, of_ref: &str) -> bool {
747 run(root, &["merge-base", "--is-ancestor", commit, of_ref]).is_ok()
748}
749
750pub fn is_branch_merged_into(root: &Path, branch: &str, target_ref: &str) -> Result<bool> {
756 if is_ancestor(root, branch, target_ref) {
758 return Ok(true);
759 }
760 let merge_base = match run(root, &["merge-base", target_ref, branch]) {
762 Ok(mb) => mb,
763 Err(_) => return Ok(false),
764 };
765 let branch_tip = match run(root, &["rev-parse", &format!("{branch}^{{commit}}")]) {
766 Ok(t) => t,
767 Err(_) => return Ok(false),
768 };
769 if branch_tip == merge_base {
771 return Ok(true);
772 }
773 let squash_commit = match run(root, &[
775 "commit-tree", &format!("{branch}^{{tree}}"),
776 "-p", &merge_base,
777 "-m", "squash",
778 ]) {
779 Ok(c) => c,
780 Err(_) => return Ok(false),
781 };
782 let cherry_out = match run(root, &["cherry", target_ref, &squash_commit]) {
784 Ok(o) => o,
785 Err(_) => return Ok(false),
786 };
787 Ok(cherry_out.trim().starts_with('-'))
788}
789
790pub fn is_branch_content_merged(root: &Path, default_branch: &str, branch: &str) -> Result<bool> {
797 if is_branch_merged_into(root, branch, default_branch)? {
799 return Ok(true);
800 }
801 let remote_ref = format!("refs/remotes/origin/{default_branch}");
803 if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
804 return is_branch_merged_into(root, branch, &format!("origin/{default_branch}"));
805 }
806 Ok(false)
807}
808
809pub enum BranchClass {
819 Equal,
820 Behind,
821 Ahead,
822 Diverged,
823 RemoteOnly,
825 NoRemote,
827}
828
829pub fn classify_branch(root: &Path, local: &str, remote: &str) -> BranchClass {
837 let local_sha = match run(root, &["rev-parse", local]) {
838 Ok(s) => s,
839 Err(_) => {
840 return if run(root, &["rev-parse", remote]).is_ok() {
844 BranchClass::RemoteOnly
845 } else {
846 BranchClass::NoRemote
847 };
848 }
849 };
850 let remote_sha = match run(root, &["rev-parse", remote]) {
851 Ok(s) => s,
852 Err(_) => return BranchClass::NoRemote,
853 };
854
855 if local_sha == remote_sha {
856 return BranchClass::Equal;
857 }
858
859 let local_is_ancestor_of_remote = is_ancestor(root, local, remote);
862
863 let remote_is_ancestor_of_local = is_ancestor(root, remote, local);
866
867 match (local_is_ancestor_of_remote, remote_is_ancestor_of_local) {
868 (true, false) => BranchClass::Behind, (false, true) => BranchClass::Ahead, (false, false) => BranchClass::Diverged, (true, true) => BranchClass::Equal, }
873}
874
875pub fn sync_default_branch(root: &Path, default: &str, warnings: &mut Vec<String>) -> bool {
900 let remote = format!("origin/{default}");
901 match classify_branch(root, default, &remote) {
902 BranchClass::Equal => {
903 }
905
906 BranchClass::Behind => {
907 let wt = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
910 if run(&wt, &["merge", "--ff-only", &remote]).is_err() {
911 let msg = crate::sync_guidance::MAIN_BEHIND_DIRTY_OVERLAP
915 .replace("<default>", default);
916 warnings.push(msg);
917 }
918 }
919
920 BranchClass::Ahead => {
921 let count = run(root, &["rev-list", "--count", &format!("{remote}..{default}")])
924 .ok()
925 .and_then(|s| s.trim().parse::<u64>().ok())
926 .unwrap_or(0);
927 let msg = crate::sync_guidance::MAIN_AHEAD
928 .replace("<default>", default)
929 .replace("<remote>", &remote)
930 .replace("<count>", &count.to_string())
931 .replace("<commits>", if count == 1 { "commit" } else { "commits" });
932 warnings.push(msg);
933 return true;
934 }
935
936 BranchClass::Diverged => {
937 let wt = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
940 let guidance = if is_worktree_dirty(&wt) {
941 crate::sync_guidance::MAIN_DIVERGED_DIRTY.replace("<default>", default)
942 } else {
943 crate::sync_guidance::MAIN_DIVERGED_CLEAN.replace("<default>", default)
944 };
945 warnings.push(guidance);
946 }
947
948 BranchClass::RemoteOnly => {
949 }
953
954 BranchClass::NoRemote => {
955 }
959 }
960 false
961}
962
963pub fn fetch_branch(root: &Path, branch: &str) -> anyhow::Result<()> {
964 run(root, &["fetch", "origin", branch]).map(|_| ())
965}
966
967pub fn push_branch(root: &Path, branch: &str) -> anyhow::Result<()> {
968 run(root, &["push", "origin", &format!("{branch}:{branch}")]).map(|_| ())
969}
970
971pub fn push_branch_tracking(root: &Path, branch: &str) -> anyhow::Result<()> {
972 let out = std::process::Command::new("git")
973 .args(["push", "--set-upstream", "origin", &format!("{branch}:{branch}")])
974 .current_dir(root)
975 .output()?;
976 if !out.status.success() {
977 anyhow::bail!("git push failed: {}", String::from_utf8_lossy(&out.stderr).trim());
978 }
979 Ok(())
980}
981
982pub fn has_remote(root: &Path) -> bool {
983 run(root, &["remote", "get-url", "origin"]).is_ok()
984}
985
986pub fn remote_ticket_branches_with_dates(
991 root: &Path,
992) -> Result<Vec<(String, chrono::DateTime<chrono::Utc>)>> {
993 use chrono::{TimeZone, Utc};
994 let out = Command::new("git")
995 .current_dir(root)
996 .args([
997 "for-each-ref",
998 "refs/remotes/origin/ticket/",
999 "--format=%(refname:short) %(creatordate:unix)",
1000 ])
1001 .output()
1002 .context("git for-each-ref failed")?;
1003 let stdout = String::from_utf8_lossy(&out.stdout);
1004 let mut result = Vec::new();
1005 for line in stdout.lines() {
1006 let mut parts = line.splitn(2, ' ');
1007 let refname = parts.next().unwrap_or("").trim();
1008 let ts_str = parts.next().unwrap_or("").trim();
1009 let branch = refname.trim_start_matches("origin/");
1010 if branch.is_empty() {
1011 continue;
1012 }
1013 if let Ok(ts) = ts_str.parse::<i64>() {
1014 if let Some(dt) = Utc.timestamp_opt(ts, 0).single() {
1015 result.push((branch.to_string(), dt));
1016 }
1017 }
1018 }
1019 Ok(result)
1020}
1021
1022pub fn list_remote_ticket_branches(root: &Path) -> std::collections::HashSet<String> {
1028 let mut set = std::collections::HashSet::new();
1029 let out = match run(root, &["ls-remote", "--heads", "origin", "ticket/*"]) {
1030 Ok(o) => o,
1031 Err(_) => return set,
1032 };
1033 for line in out.lines() {
1034 if let Some(refname) = line.split('\t').nth(1) {
1035 if let Some(branch) = refname.trim().strip_prefix("refs/heads/") {
1036 set.insert(branch.to_string());
1037 }
1038 }
1039 }
1040 set
1041}
1042
1043pub fn delete_remote_branch(root: &Path, branch: &str) -> Result<()> {
1045 run(root, &["push", "origin", "--delete", branch])
1046 .map(|_| ())
1047 .context("git push origin --delete failed")
1048}
1049
1050pub struct DeleteBranchesOutput {
1051 pub deleted: Vec<String>,
1052 pub failed: Vec<(String, String)>,
1053}
1054
1055pub fn delete_remote_branches(root: &Path, branches: &[&str]) -> Result<DeleteBranchesOutput> {
1062 if branches.is_empty() {
1063 return Ok(DeleteBranchesOutput { deleted: vec![], failed: vec![] });
1064 }
1065
1066 let mut cmd = Command::new("git");
1067 cmd.current_dir(root)
1068 .args(["push", "--porcelain", "origin", "--delete"]);
1069 for b in branches {
1070 cmd.arg(format!("refs/heads/{b}"));
1071 }
1072
1073 let out = cmd.output().context("git not found")?;
1074
1075 let stdout = String::from_utf8_lossy(&out.stdout);
1076 let mut deleted = Vec::new();
1077 let mut failed = Vec::new();
1078
1079 for line in stdout.lines() {
1080 if let Some(rest) = line.strip_prefix("-\t") {
1081 let mut parts = rest.splitn(2, '\t');
1082 if let Some(ref_field) = parts.next() {
1083 let branch = ref_field
1084 .trim_start_matches(':')
1085 .trim_start_matches("refs/heads/")
1086 .to_string();
1087 if !branch.is_empty() {
1088 deleted.push(branch);
1089 }
1090 }
1091 } else if let Some(rest) = line.strip_prefix("!\t") {
1092 let mut parts = rest.splitn(2, '\t');
1093 let ref_field = parts.next().unwrap_or("")
1094 .trim_start_matches(':')
1095 .trim_start_matches("refs/heads/")
1096 .to_string();
1097 let reason = parts.next().unwrap_or("").to_string();
1098 if !ref_field.is_empty() {
1099 failed.push((ref_field, reason));
1100 }
1101 }
1102 }
1103
1104 if deleted.is_empty() && failed.is_empty() && !out.status.success() {
1106 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1107 for b in branches {
1108 failed.push((b.to_string(), stderr.clone()));
1109 }
1110 }
1111
1112 Ok(DeleteBranchesOutput { deleted, failed })
1113}
1114
1115pub fn move_files_on_branch(
1120 root: &Path,
1121 branch: &str,
1122 moves: &[(&str, &str, &str)],
1123 message: &str,
1124) -> Result<()> {
1125 if !has_commits(root) {
1126 for (old, new, content) in moves {
1127 let new_path = root.join(new);
1128 if let Some(parent) = new_path.parent() {
1129 std::fs::create_dir_all(parent)?;
1130 }
1131 std::fs::write(&new_path, content)?;
1132 let old_path = root.join(old);
1133 let _ = std::fs::remove_file(&old_path);
1134 }
1135 return Ok(());
1136 }
1137
1138 let do_moves = |wt: &Path| -> Result<()> {
1139 for (old, new, content) in moves {
1140 let new_path = wt.join(new);
1141 if let Some(parent) = new_path.parent() {
1142 std::fs::create_dir_all(parent)?;
1143 }
1144 std::fs::write(&new_path, content)?;
1145 run(wt, &["add", new])?;
1146 run(wt, &["rm", "--force", "--quiet", old])?;
1147 }
1148 run(wt, &["commit", "-m", message])?;
1149 Ok(())
1150 };
1151
1152 if let Some(wt_path) = find_worktree_for_branch(root, branch) {
1153 let remote_ref = format!("origin/{branch}");
1154 if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
1155 let _ = run(&wt_path, &["merge", "--ff-only", &remote_ref]);
1156 }
1157 let result = do_moves(&wt_path);
1158 if result.is_ok() {
1159 crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1160 }
1161 return result;
1162 }
1163
1164 if current_branch(root).ok().as_deref() == Some(branch) {
1165 let result = do_moves(root);
1166 if result.is_ok() {
1167 crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1168 }
1169 return result;
1170 }
1171
1172 let unique = std::time::SystemTime::now()
1173 .duration_since(std::time::UNIX_EPOCH)
1174 .map(|d| d.subsec_nanos())
1175 .unwrap_or(0);
1176 let wt_path = std::env::temp_dir().join(format!(
1177 "apm-{}-{}-{}",
1178 std::process::id(),
1179 unique,
1180 branch.replace('/', "-"),
1181 ));
1182
1183 let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
1184 let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
1185
1186 if has_remote {
1187 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
1188 let _ = run(&wt_path, &["checkout", "-B", branch]);
1189 } else if has_local {
1190 let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
1191 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
1192 let _ = run(&wt_path, &["checkout", "-B", branch]);
1193 } else {
1194 run(root, &["worktree", "add", &wt_path.to_string_lossy(), branch])?;
1195 }
1196
1197 let result = do_moves(&wt_path);
1198 let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
1199 let _ = std::fs::remove_dir_all(&wt_path);
1200 if result.is_ok() {
1201 crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1202 }
1203 result
1204}
1205
1206pub fn merge_branch_into_default(root: &Path, branch: &str, default_branch: &str, warnings: &mut Vec<String>) -> Result<()> {
1207 let _ = run(root, &["fetch", "origin", default_branch]);
1208
1209 let main_root = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
1210 let merge_dir = if current_branch(&main_root).ok().as_deref() == Some(default_branch) {
1211 main_root
1212 } else {
1213 find_worktree_for_branch(root, default_branch).unwrap_or_else(|| root.to_path_buf())
1214 };
1215
1216 if let Err(e) = run(&merge_dir, &["merge", "--no-ff", branch, "--no-edit"]) {
1217 let _ = run(&merge_dir, &["merge", "--abort"]);
1218 anyhow::bail!("merge failed: {e:#}");
1219 }
1220
1221 if has_remote(root) {
1222 if let Err(e) = run(&merge_dir, &["push", "origin", default_branch]) {
1223 warnings.push(format!("warning: push {default_branch} failed: {e:#}"));
1224 }
1225 }
1226 Ok(())
1227}
1228
1229pub fn merge_into_default(root: &Path, config: &Config, branch: &str, default_branch: &str, skip_push: bool, messages: &mut Vec<String>, _warnings: &mut Vec<String>) -> Result<()> {
1230 let _ = run(root, &["fetch", "origin", default_branch]);
1231
1232 let main_root = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
1233 let merge_dir = if current_branch(&main_root).ok().as_deref() == Some(default_branch) {
1234 main_root.clone()
1235 } else {
1236 let worktrees_base = main_root.join(&config.worktrees.dir);
1237 ensure_worktree(root, &worktrees_base, default_branch)?
1238 };
1239
1240 let out = std::process::Command::new("git")
1241 .args(["merge", "--no-ff", branch, "--no-edit"])
1242 .current_dir(&merge_dir)
1243 .output()?;
1244
1245 if !out.status.success() {
1246 let _ = run(&merge_dir, &["merge", "--abort"]);
1247 bail!(
1248 "merge conflict — resolve manually and push: {}",
1249 String::from_utf8_lossy(&out.stderr).trim()
1250 );
1251 }
1252
1253 if skip_push {
1254 messages.push(format!("Merged {branch} into {default_branch} (local only)."));
1255 } else {
1256 push_branch(&merge_dir, default_branch)?;
1257 messages.push(format!("Merged {branch} into {default_branch} and pushed to origin."));
1258 }
1259 Ok(())
1260}
1261
1262pub fn pull_default(root: &Path, default_branch: &str, warnings: &mut Vec<String>) -> Result<()> {
1263 let fetch = std::process::Command::new("git")
1264 .args(["fetch", "origin", default_branch])
1265 .current_dir(root)
1266 .output();
1267
1268 match fetch {
1269 Err(e) => {
1270 warnings.push(format!("warning: fetch failed: {e:#}"));
1271 return Ok(());
1272 }
1273 Ok(out) if !out.status.success() => {
1274 warnings.push(format!(
1275 "warning: fetch failed: {}",
1276 String::from_utf8_lossy(&out.stderr).trim()
1277 ));
1278 return Ok(());
1279 }
1280 _ => {}
1281 }
1282
1283 let current = std::process::Command::new("git")
1284 .args(["rev-parse", "--abbrev-ref", "HEAD"])
1285 .current_dir(root)
1286 .output()?;
1287 let current_branch = String::from_utf8_lossy(¤t.stdout).trim().to_string();
1288
1289 let merge_dir = if current_branch == default_branch {
1290 root.to_path_buf()
1291 } else {
1292 find_worktree_for_branch(root, default_branch)
1293 .unwrap_or_else(|| root.to_path_buf())
1294 };
1295
1296 let remote_ref = format!("origin/{default_branch}");
1297 let out = std::process::Command::new("git")
1298 .args(["merge", "--ff-only", &remote_ref])
1299 .current_dir(&merge_dir)
1300 .output()?;
1301
1302 if !out.status.success() {
1303 warnings.push(format!("warning: could not fast-forward {default_branch} — pull manually"));
1304 }
1305
1306 Ok(())
1307}
1308
1309pub fn is_worktree_dirty(path: &Path) -> bool {
1310 let Ok(out) = Command::new("git")
1311 .args(["-C", &path.to_string_lossy(), "status", "--porcelain"])
1312 .output()
1313 else {
1314 return false;
1315 };
1316 !out.stdout.is_empty()
1317}
1318
1319pub fn is_worktree_dirty_for_sync(path: &Path) -> bool {
1322 const TEMP_FILES: &[&str] = &[".apm-worker.log", ".apm-worker.pid"];
1323 let Ok(out) = Command::new("git")
1324 .args(["-C", &path.to_string_lossy(), "status", "--porcelain"])
1325 .output()
1326 else {
1327 return false;
1328 };
1329 let stdout = String::from_utf8_lossy(&out.stdout);
1330 stdout.lines().filter(|l| !l.is_empty()).any(|l| {
1331 let fname = l.get(3..).unwrap_or("").trim();
1333 !TEMP_FILES.contains(&fname)
1334 })
1335}
1336
1337fn dirty_files_for_sync(path: &Path) -> Vec<String> {
1341 const TEMP_FILES: &[&str] = &[".apm-worker.log", ".apm-worker.pid"];
1342 let Ok(out) = Command::new("git")
1343 .args(["-C", &path.to_string_lossy(), "status", "--porcelain"])
1344 .output()
1345 else {
1346 return Vec::new();
1347 };
1348 let stdout = String::from_utf8_lossy(&out.stdout);
1349 stdout
1350 .lines()
1351 .filter(|l| !l.is_empty())
1352 .filter_map(|l| {
1353 let fname = l.get(3..)?.trim();
1354 if TEMP_FILES.contains(&fname) { None } else { Some(fname.to_string()) }
1355 })
1356 .collect()
1357}
1358
1359pub struct WorktreeSyncResult {
1361 pub fast_forwarded: Vec<(PathBuf, String)>,
1363 pub skipped_dirty: Vec<(PathBuf, String, Vec<String>)>,
1365 pub skipped_ahead: Vec<(PathBuf, String)>,
1367 pub skipped_diverged: Vec<(PathBuf, String)>,
1369}
1370
1371pub fn sync_checked_out_worktrees(root: &Path, warnings: &mut Vec<String>) -> WorktreeSyncResult {
1384 let mut result = WorktreeSyncResult {
1385 fast_forwarded: Vec::new(),
1386 skipped_dirty: Vec::new(),
1387 skipped_ahead: Vec::new(),
1388 skipped_diverged: Vec::new(),
1389 };
1390
1391 let worktrees = match crate::worktree::list_ticket_worktrees(root) {
1392 Ok(w) => w,
1393 Err(_) => return result,
1394 };
1395
1396 for (wt_path, branch) in worktrees {
1397 let local_ref = format!("refs/heads/{branch}");
1398 let remote_ref = format!("origin/{branch}");
1399 match classify_branch(root, &local_ref, &remote_ref) {
1400 BranchClass::Behind => {
1401 if is_worktree_dirty_for_sync(&wt_path) {
1402 let dirty = dirty_files_for_sync(&wt_path);
1403 result.skipped_dirty.push((wt_path, branch, dirty));
1404 } else {
1405 match run(&wt_path, &["merge", "--ff-only", &remote_ref]) {
1406 Ok(_) => result.fast_forwarded.push((wt_path, branch)),
1407 Err(e) => warnings.push(format!(
1408 "warning: fast-forward {} failed: {e:#}",
1409 wt_path.display()
1410 )),
1411 }
1412 }
1413 }
1414 BranchClass::Ahead => {
1415 result.skipped_ahead.push((wt_path, branch));
1416 }
1417 BranchClass::Diverged => {
1418 result.skipped_diverged.push((wt_path, branch));
1419 }
1420 BranchClass::Equal | BranchClass::NoRemote | BranchClass::RemoteOnly => {
1421 }
1423 }
1424 }
1425
1426 result
1427}
1428
1429pub fn local_branch_exists(root: &Path, branch: &str) -> bool {
1430 Command::new("git")
1431 .args(["-C", &root.to_string_lossy(), "rev-parse", "--verify", &format!("refs/heads/{branch}")])
1432 .output()
1433 .map(|o| o.status.success())
1434 .unwrap_or(false)
1435}
1436
1437pub fn delete_local_branch(root: &Path, branch: &str, warnings: &mut Vec<String>) {
1438 let Ok(out) = Command::new("git")
1439 .args(["-C", &root.to_string_lossy(), "branch", "-D", branch])
1440 .output()
1441 else {
1442 warnings.push(format!("warning: could not delete branch {branch}: command failed"));
1443 return;
1444 };
1445 if !out.status.success() {
1446 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1447 warnings.push(format!("warning: could not delete branch {branch}: {stderr}"));
1448 }
1449}
1450
1451pub fn prune_remote_tracking(root: &Path, branch: &str) {
1452 let _ = Command::new("git")
1453 .args(["-C", &root.to_string_lossy(), "branch", "-dr", &format!("origin/{branch}")])
1454 .output();
1455}
1456
1457pub fn stage_files(root: &Path, files: &[&str]) -> Result<()> {
1458 let mut args = vec!["add"];
1459 args.extend_from_slice(files);
1460 run(root, &args).map(|_| ())
1461}
1462
1463pub fn commit(root: &Path, message: &str) -> Result<()> {
1464 run(root, &["commit", "-m", message]).map(|_| ())
1465}
1466
1467pub fn git_config_get(root: &Path, key: &str) -> Option<String> {
1468 let out = Command::new("git")
1469 .args(["-C", &root.to_string_lossy(), "config", key])
1470 .output()
1471 .ok()?;
1472 if !out.status.success() {
1473 return None;
1474 }
1475 let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
1476 if value.is_empty() { None } else { Some(value) }
1477}
1478
1479fn clear_stale_unmerged_entries(dir: &Path, warnings: &mut Vec<String>) {
1485 let out = match Command::new("git")
1486 .args(["-C", &dir.to_string_lossy(), "ls-files", "-u"])
1487 .output()
1488 {
1489 Ok(o) if o.status.success() => o,
1490 _ => return,
1491 };
1492 let stdout = String::from_utf8_lossy(&out.stdout);
1493 let mut paths: std::collections::HashSet<String> = std::collections::HashSet::new();
1494 for line in stdout.lines() {
1495 if let Some(path) = line.split('\t').nth(1) {
1497 paths.insert(path.to_string());
1498 }
1499 }
1500 if paths.is_empty() {
1501 return;
1502 }
1503 warnings.push(format!(
1504 "warning: clearing {} stale unmerged index entr{} in {} (left by an earlier failed merge)",
1505 paths.len(),
1506 if paths.len() == 1 { "y" } else { "ies" },
1507 dir.display(),
1508 ));
1509 for path in &paths {
1510 let _ = Command::new("git")
1511 .args(["-C", &dir.to_string_lossy(), "reset", "HEAD", "--", path])
1512 .output();
1513 }
1514}
1515
1516pub fn merge_ref(dir: &Path, refname: &str, warnings: &mut Vec<String>) -> Option<String> {
1517 clear_stale_unmerged_entries(dir, warnings);
1522
1523 let out = match Command::new("git")
1530 .args([
1531 "-C", &dir.to_string_lossy(),
1532 "-c", "merge.directoryRenames=false",
1533 "merge", refname, "--no-edit",
1534 ])
1535 .output()
1536 {
1537 Ok(o) => o,
1538 Err(e) => {
1539 warnings.push(format!("warning: merge {refname} failed: {e}"));
1540 return None;
1541 }
1542 };
1543 if out.status.success() {
1544 let stdout = String::from_utf8_lossy(&out.stdout);
1545 if stdout.contains("Already up to date") {
1546 None
1547 } else {
1548 Some(format!("Merged {refname} into branch."))
1549 }
1550 } else {
1551 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1552 warnings.push(format!("warning: merge {refname} failed: {stderr}"));
1553 if detect_mid_merge_state(dir).is_some() {
1559 let abort = Command::new("git")
1560 .args(["-C", &dir.to_string_lossy(), "merge", "--abort"])
1561 .output();
1562 match abort {
1563 Ok(o) if !o.status.success() => {
1564 let aborterr = String::from_utf8_lossy(&o.stderr).trim().to_string();
1565 warnings.push(format!(
1566 "warning: could not abort merge of {refname} in {}: {aborterr}",
1567 dir.display()
1568 ));
1569 }
1570 Err(e) => {
1571 warnings.push(format!(
1572 "warning: could not abort merge of {refname} in {}: {e}",
1573 dir.display()
1574 ));
1575 }
1576 Ok(_) => {}
1577 }
1578 }
1579 None
1580 }
1581}
1582
1583pub fn is_file_tracked(root: &Path, path: &str) -> bool {
1584 Command::new("git")
1585 .args(["ls-files", "--error-unmatch", path])
1586 .current_dir(root)
1587 .stdout(std::process::Stdio::null())
1588 .stderr(std::process::Stdio::null())
1589 .status()
1590 .map(|s| s.success())
1591 .unwrap_or(false)
1592}
1593
1594pub enum MidMergeState {
1598 Merge,
1600 RebaseMerge,
1602 RebaseApply,
1604 CherryPick,
1606}
1607
1608pub fn detect_mid_merge_state(root: &Path) -> Option<MidMergeState> {
1617 let git_dir = root.join(".git");
1618 if git_dir.join("MERGE_HEAD").exists() {
1619 return Some(MidMergeState::Merge);
1620 }
1621 if git_dir.join("rebase-merge").is_dir() {
1622 return Some(MidMergeState::RebaseMerge);
1623 }
1624 if git_dir.join("rebase-apply").is_dir() {
1625 return Some(MidMergeState::RebaseApply);
1626 }
1627 if git_dir.join("CHERRY_PICK_HEAD").exists() {
1628 return Some(MidMergeState::CherryPick);
1629 }
1630 None
1631}
1632
1633pub fn merge_base(root: &Path, ref1: &str, ref2: &str) -> Result<String> {
1635 run(root, &["merge-base", ref1, ref2])
1636}
1637
1638pub fn main_worktree_root(root: &Path) -> Option<PathBuf> {
1639 let out = run(root, &["worktree", "list", "--porcelain"]).ok()?;
1640 out.lines()
1641 .next()
1642 .and_then(|line| line.strip_prefix("worktree "))
1643 .map(PathBuf::from)
1644}
1645
1646pub fn check_leaked_files(
1659 root: &Path,
1660 ticket_branch: &str,
1661 target_branch: &str,
1662) -> Result<Vec<String>> {
1663 let current = Command::new("git")
1665 .args(["rev-parse", "--abbrev-ref", "HEAD"])
1666 .current_dir(root)
1667 .output()?;
1668 let current_branch = String::from_utf8_lossy(¤t.stdout).trim().to_string();
1669
1670 let merge_dir = if current_branch == target_branch {
1671 root.to_path_buf()
1672 } else {
1673 match crate::worktree::find_worktree_for_branch(root, target_branch) {
1674 Some(p) => p,
1675 None => return Ok(vec![]), }
1677 };
1678
1679 let base = match merge_base(root, target_branch, ticket_branch) {
1681 Ok(s) => s.trim().to_string(),
1682 Err(_) => return Ok(vec![]), };
1684 if base.is_empty() {
1685 return Ok(vec![]);
1686 }
1687
1688 let diff_out = Command::new("git")
1691 .args(["diff", "--name-only", &base, ticket_branch])
1692 .current_dir(root)
1693 .output()?;
1694 let ticket_files: std::collections::HashSet<String> =
1695 String::from_utf8_lossy(&diff_out.stdout)
1696 .lines()
1697 .map(|s| s.to_string())
1698 .collect();
1699
1700 let status_out = Command::new("git")
1709 .args(["status", "--porcelain", "--untracked-files=all"])
1710 .current_dir(&merge_dir)
1711 .output()?;
1712 let dirty_files: std::collections::HashSet<String> =
1713 String::from_utf8_lossy(&status_out.stdout)
1714 .lines()
1715 .filter_map(|line| {
1716 if line.len() < 3 {
1717 return None;
1718 }
1719 let x = line.as_bytes()[0] as char;
1720 let y = line.as_bytes()[1] as char;
1721 if x == 'R' || x == 'C' || y == 'R' || y == 'C' {
1723 return None;
1724 }
1725 Some(line[3..].to_string())
1726 })
1727 .collect();
1728
1729 let mut overlap: Vec<String> = ticket_files
1731 .intersection(&dirty_files)
1732 .cloned()
1733 .collect();
1734 overlap.sort();
1735 Ok(overlap)
1736}
1737
1738#[cfg(test)]
1739mod tests {
1740 use super::*;
1741 use std::process::Command as Cmd;
1742 use tempfile::TempDir;
1743
1744 fn git_init() -> TempDir {
1745 let dir = tempfile::tempdir().unwrap();
1746 let p = dir.path();
1747 Cmd::new("git").args(["init", "-q", "-b", "main"]).current_dir(p).status().unwrap();
1748 Cmd::new("git").args(["config", "user.email", "t@t.com"]).current_dir(p).status().unwrap();
1749 Cmd::new("git").args(["config", "user.name", "test"]).current_dir(p).status().unwrap();
1750 dir
1751 }
1752
1753 fn git_cmd(dir: &Path, args: &[&str]) {
1754 Cmd::new("git")
1755 .args(args)
1756 .current_dir(dir)
1757 .env("GIT_AUTHOR_NAME", "test")
1758 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1759 .env("GIT_COMMITTER_NAME", "test")
1760 .env("GIT_COMMITTER_EMAIL", "t@t.com")
1761 .status()
1762 .unwrap();
1763 }
1764
1765 fn make_commit(dir: &Path, filename: &str, content: &str) {
1766 let full = dir.join(filename);
1767 if let Some(parent) = full.parent() {
1768 std::fs::create_dir_all(parent).unwrap();
1769 }
1770 std::fs::write(full, content).unwrap();
1771 git_cmd(dir, &["add", filename]);
1772 git_cmd(dir, &["commit", "-m", "init"]);
1773 }
1774
1775 #[test]
1776 fn is_worktree_dirty_clean() {
1777 let dir = git_init();
1778 make_commit(dir.path(), "f.txt", "hi");
1779 assert!(!is_worktree_dirty(dir.path()));
1780 }
1781
1782 #[test]
1783 fn is_worktree_dirty_dirty() {
1784 let dir = git_init();
1785 make_commit(dir.path(), "f.txt", "hi");
1786 std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1787 assert!(is_worktree_dirty(dir.path()));
1788 }
1789
1790 #[test]
1791 fn is_worktree_dirty_for_sync_clean() {
1792 let dir = git_init();
1793 make_commit(dir.path(), "f.txt", "hi");
1794 assert!(!is_worktree_dirty_for_sync(dir.path()));
1795 }
1796
1797 #[test]
1798 fn is_worktree_dirty_for_sync_temp_files_only_is_clean() {
1799 let dir = git_init();
1800 make_commit(dir.path(), "f.txt", "hi");
1801 std::fs::write(dir.path().join(".apm-worker.log"), "log").unwrap();
1803 std::fs::write(dir.path().join(".apm-worker.pid"), "123").unwrap();
1804 assert!(!is_worktree_dirty_for_sync(dir.path()));
1805 assert!(is_worktree_dirty(dir.path()));
1807 }
1808
1809 #[test]
1810 fn is_worktree_dirty_for_sync_real_change_is_dirty() {
1811 let dir = git_init();
1812 make_commit(dir.path(), "f.txt", "hi");
1813 std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1814 assert!(is_worktree_dirty_for_sync(dir.path()));
1815 }
1816
1817 #[test]
1818 fn is_worktree_dirty_for_sync_temp_plus_real_is_dirty() {
1819 let dir = git_init();
1820 make_commit(dir.path(), "f.txt", "hi");
1821 std::fs::write(dir.path().join(".apm-worker.log"), "log").unwrap();
1822 std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1823 assert!(is_worktree_dirty_for_sync(dir.path()));
1824 }
1825
1826 #[test]
1827 fn dirty_files_for_sync_excludes_temp_files() {
1828 let dir = git_init();
1829 make_commit(dir.path(), "f.txt", "hi");
1830 std::fs::write(dir.path().join(".apm-worker.log"), "log").unwrap();
1831 std::fs::write(dir.path().join(".apm-worker.pid"), "123").unwrap();
1832 std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1833 let dirty = dirty_files_for_sync(dir.path());
1834 assert!(dirty.contains(&"f.txt".to_string()), "f.txt should be in dirty; got {dirty:?}");
1835 assert!(!dirty.iter().any(|f| f.contains(".apm-worker")), "temp files should be excluded; got {dirty:?}");
1836 }
1837
1838 #[test]
1839 fn sync_checked_out_worktrees_behind_clean_fast_forwards() {
1840 let origin_tmp = git_init();
1842 let origin = origin_tmp.path();
1843 make_commit(origin, "README", "v1");
1844 git_cmd(origin, &["checkout", "-b", "ticket/test-ff"]);
1846 make_commit(origin, "impl.rs", "v1");
1847 git_cmd(origin, &["checkout", "main"]);
1848
1849 let clone_tmp = tempfile::tempdir().unwrap();
1851 let clone = clone_tmp.path();
1852 Cmd::new("git")
1853 .args(["clone", &origin.to_string_lossy(), &clone.to_string_lossy()])
1854 .env("GIT_AUTHOR_NAME", "test").env("GIT_AUTHOR_EMAIL", "t@t.com")
1855 .env("GIT_COMMITTER_NAME", "test").env("GIT_COMMITTER_EMAIL", "t@t.com")
1856 .status().unwrap();
1857 git_cmd(clone, &["config", "user.email", "t@t.com"]);
1859 git_cmd(clone, &["config", "user.name", "test"]);
1860 let wt_path = clone.join("wt-test-ff");
1862 Cmd::new("git")
1863 .args(["worktree", "add", &wt_path.to_string_lossy(), "ticket/test-ff"])
1864 .current_dir(clone).status().unwrap();
1865
1866 git_cmd(origin, &["checkout", "ticket/test-ff"]);
1868 make_commit(origin, "impl.rs", "v2");
1869 git_cmd(origin, &["checkout", "main"]);
1870 git_cmd(clone, &["fetch", "origin"]);
1872
1873 let mut warnings = Vec::new();
1874 let result = sync_checked_out_worktrees(clone, &mut warnings);
1875
1876 assert_eq!(result.fast_forwarded.len(), 1, "should have fast-forwarded 1 worktree; warnings: {warnings:?}");
1877 assert!(result.skipped_dirty.is_empty());
1878 assert!(result.skipped_ahead.is_empty());
1879 assert!(result.skipped_diverged.is_empty());
1880 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1881
1882 let content = std::fs::read_to_string(wt_path.join("impl.rs")).unwrap();
1884 assert_eq!(content.trim(), "v2", "worktree should have v2 after fast-forward");
1885 }
1886
1887 #[test]
1888 fn sync_checked_out_worktrees_dirty_skips() {
1889 let origin_tmp = git_init();
1890 let origin = origin_tmp.path();
1891 make_commit(origin, "README", "v1");
1892 git_cmd(origin, &["checkout", "-b", "ticket/test-dirty"]);
1893 make_commit(origin, "impl.rs", "v1");
1894 git_cmd(origin, &["checkout", "main"]);
1895
1896 let clone_tmp = tempfile::tempdir().unwrap();
1897 let clone = clone_tmp.path();
1898 Cmd::new("git")
1899 .args(["clone", &origin.to_string_lossy(), &clone.to_string_lossy()])
1900 .env("GIT_AUTHOR_NAME", "test").env("GIT_AUTHOR_EMAIL", "t@t.com")
1901 .env("GIT_COMMITTER_NAME", "test").env("GIT_COMMITTER_EMAIL", "t@t.com")
1902 .status().unwrap();
1903 git_cmd(clone, &["config", "user.email", "t@t.com"]);
1904 git_cmd(clone, &["config", "user.name", "test"]);
1905 let wt_path = clone.join("wt-test-dirty");
1906 Cmd::new("git")
1907 .args(["worktree", "add", &wt_path.to_string_lossy(), "ticket/test-dirty"])
1908 .current_dir(clone).status().unwrap();
1909
1910 git_cmd(origin, &["checkout", "ticket/test-dirty"]);
1912 make_commit(origin, "impl.rs", "v2");
1913 git_cmd(origin, &["checkout", "main"]);
1914 git_cmd(clone, &["fetch", "origin"]);
1915
1916 std::fs::write(wt_path.join("impl.rs"), "local change").unwrap();
1918
1919 let mut warnings = Vec::new();
1920 let result = sync_checked_out_worktrees(clone, &mut warnings);
1921
1922 assert!(result.fast_forwarded.is_empty(), "should not fast-forward dirty worktree");
1923 assert_eq!(result.skipped_dirty.len(), 1);
1924 let (_, _, ref dirty_files) = result.skipped_dirty[0];
1925 assert!(dirty_files.contains(&"impl.rs".to_string()), "impl.rs should be in dirty files; got {dirty_files:?}");
1926 }
1927
1928 #[test]
1929 fn sync_checked_out_worktrees_temp_only_is_clean() {
1930 let origin_tmp = git_init();
1931 let origin = origin_tmp.path();
1932 make_commit(origin, "README", "v1");
1933 git_cmd(origin, &["checkout", "-b", "ticket/test-temponly"]);
1934 make_commit(origin, "impl.rs", "v1");
1935 git_cmd(origin, &["checkout", "main"]);
1936
1937 let clone_tmp = tempfile::tempdir().unwrap();
1938 let clone = clone_tmp.path();
1939 Cmd::new("git")
1940 .args(["clone", &origin.to_string_lossy(), &clone.to_string_lossy()])
1941 .env("GIT_AUTHOR_NAME", "test").env("GIT_AUTHOR_EMAIL", "t@t.com")
1942 .env("GIT_COMMITTER_NAME", "test").env("GIT_COMMITTER_EMAIL", "t@t.com")
1943 .status().unwrap();
1944 git_cmd(clone, &["config", "user.email", "t@t.com"]);
1945 git_cmd(clone, &["config", "user.name", "test"]);
1946 let wt_path = clone.join("wt-test-temponly");
1947 Cmd::new("git")
1948 .args(["worktree", "add", &wt_path.to_string_lossy(), "ticket/test-temponly"])
1949 .current_dir(clone).status().unwrap();
1950
1951 git_cmd(origin, &["checkout", "ticket/test-temponly"]);
1953 make_commit(origin, "impl.rs", "v2");
1954 git_cmd(origin, &["checkout", "main"]);
1955 git_cmd(clone, &["fetch", "origin"]);
1956
1957 std::fs::write(wt_path.join(".apm-worker.log"), "log").unwrap();
1959 std::fs::write(wt_path.join(".apm-worker.pid"), "123").unwrap();
1960
1961 let mut warnings = Vec::new();
1962 let result = sync_checked_out_worktrees(clone, &mut warnings);
1963
1964 assert_eq!(result.fast_forwarded.len(), 1, "temp-only worktree should be fast-forwarded; warnings: {warnings:?}");
1965 assert!(result.skipped_dirty.is_empty());
1966 }
1967
1968 #[test]
1969 fn sync_checked_out_worktrees_no_worktrees_returns_empty() {
1970 let dir = git_init();
1971 make_commit(dir.path(), "f.txt", "hi");
1972 let mut warnings = Vec::new();
1973 let result = sync_checked_out_worktrees(dir.path(), &mut warnings);
1974 assert!(result.fast_forwarded.is_empty());
1975 assert!(result.skipped_dirty.is_empty());
1976 assert!(result.skipped_ahead.is_empty());
1977 assert!(result.skipped_diverged.is_empty());
1978 assert!(warnings.is_empty());
1979 }
1980
1981 #[test]
1982 fn local_branch_exists_present_and_absent() {
1983 let dir = git_init();
1984 make_commit(dir.path(), "f.txt", "hi");
1985 let on_main = local_branch_exists(dir.path(), "main");
1986 let on_master = local_branch_exists(dir.path(), "master");
1987 assert!(on_main || on_master);
1988 assert!(!local_branch_exists(dir.path(), "no-such-branch"));
1989 }
1990
1991 #[test]
1992 fn delete_local_branch_success() {
1993 let dir = git_init();
1994 make_commit(dir.path(), "f.txt", "hi");
1995 git_cmd(dir.path(), &["branch", "to-delete"]);
1996 let mut warnings = Vec::new();
1997 delete_local_branch(dir.path(), "to-delete", &mut warnings);
1998 assert!(warnings.is_empty());
1999 assert!(!local_branch_exists(dir.path(), "to-delete"));
2000 }
2001
2002 #[test]
2003 fn delete_local_branch_failure_adds_warning() {
2004 let dir = git_init();
2005 make_commit(dir.path(), "f.txt", "hi");
2006 let mut warnings = Vec::new();
2007 delete_local_branch(dir.path(), "nonexistent", &mut warnings);
2008 assert!(!warnings.is_empty());
2009 assert!(warnings[0].contains("warning:"));
2010 }
2011
2012 #[test]
2013 fn prune_remote_tracking_no_panic() {
2014 let dir = git_init();
2015 make_commit(dir.path(), "f.txt", "hi");
2016 prune_remote_tracking(dir.path(), "nonexistent-branch");
2018 }
2019
2020 #[test]
2021 fn stage_files_ok_and_err() {
2022 let dir = git_init();
2023 make_commit(dir.path(), "f.txt", "hi");
2024 std::fs::write(dir.path().join("new.txt"), "new").unwrap();
2025 assert!(stage_files(dir.path(), &["new.txt"]).is_ok());
2026 assert!(stage_files(dir.path(), &["missing.txt"]).is_err());
2027 }
2028
2029 #[test]
2030 fn commit_ok_and_err() {
2031 let dir = git_init();
2032 make_commit(dir.path(), "f.txt", "hi");
2033 std::fs::write(dir.path().join("new.txt"), "new").unwrap();
2034 git_cmd(dir.path(), &["add", "new.txt"]);
2035 assert!(commit(dir.path(), "test commit").is_ok());
2036 assert!(commit(dir.path(), "empty commit").is_err());
2038 }
2039
2040 #[test]
2041 fn git_config_get_some_and_none() {
2042 let dir = git_init();
2043 make_commit(dir.path(), "f.txt", "hi");
2044 let val = git_config_get(dir.path(), "user.email");
2045 assert_eq!(val, Some("t@t.com".to_string()));
2046 let missing = git_config_get(dir.path(), "no.such.key");
2047 assert!(missing.is_none());
2048 }
2049
2050 #[test]
2051 fn merge_ref_already_up_to_date() {
2052 let dir = git_init();
2053 make_commit(dir.path(), "f.txt", "hi");
2054 let branch = {
2055 let out = Cmd::new("git").args(["branch", "--show-current"]).current_dir(dir.path()).output().unwrap();
2056 String::from_utf8_lossy(&out.stdout).trim().to_string()
2057 };
2058 let mut warnings = Vec::new();
2059 let result = merge_ref(dir.path(), &branch, &mut warnings);
2061 assert!(result.is_none());
2062 assert!(warnings.is_empty());
2063 }
2064
2065 #[test]
2066 fn merge_ref_success() {
2067 let dir = git_init();
2068 make_commit(dir.path(), "f.txt", "hi");
2069 git_cmd(dir.path(), &["checkout", "-b", "feature"]);
2070 make_commit(dir.path(), "g.txt", "there");
2071 git_cmd(dir.path(), &["checkout", "main"]);
2072 let mut warnings = Vec::new();
2073 let result = merge_ref(dir.path(), "feature", &mut warnings);
2074 assert!(result.is_some());
2075 assert!(warnings.is_empty());
2076 }
2077
2078 #[test]
2079 fn merge_ref_does_not_speculate_directory_renames() {
2080 let dir = git_init();
2087 let p = dir.path();
2088 std::fs::create_dir_all(p.join("a")).unwrap();
2090 for name in &["1.md", "2.md", "3.md", "4.md"] {
2091 std::fs::write(p.join("a").join(name), "seed\n").unwrap();
2092 }
2093 git_cmd(p, &["add", "a"]);
2094 git_cmd(p, &["commit", "-m", "seed"]);
2095
2096 std::fs::create_dir_all(p.join("b")).unwrap();
2098 for name in &["1.md", "2.md", "3.md", "4.md"] {
2099 std::fs::rename(p.join("a").join(name), p.join("b").join(name)).unwrap();
2100 }
2101 git_cmd(p, &["add", "-A"]);
2102 git_cmd(p, &["commit", "-m", "archive sweep"]);
2103
2104 git_cmd(p, &["checkout", "-b", "feature", "HEAD~1"]);
2106 std::fs::write(p.join("a/new.md"), "active\n").unwrap();
2107 git_cmd(p, &["add", "a/new.md"]);
2108 git_cmd(p, &["commit", "-m", "add active ticket"]);
2109
2110 let mut warnings = Vec::new();
2111 let result = merge_ref(p, "main", &mut warnings);
2112
2113 assert!(result.is_some(), "merge should succeed without directory-rename inference; warnings: {warnings:?}");
2114 assert!(detect_mid_merge_state(p).is_none(), "should not be left mid-merge");
2115 assert!(p.join("a/new.md").exists(), "new.md should stay at a/");
2117 assert!(!p.join("b/new.md").exists(), "new.md must NOT have been speculatively renamed into b/");
2118 }
2119
2120 #[test]
2121 fn merge_ref_clears_stale_unmerged_index_entries() {
2122 let dir = git_init();
2126 let p = dir.path();
2127 make_commit(p, "f.txt", "hi");
2128
2129 git_cmd(p, &["checkout", "-b", "other"]);
2132 make_commit(p, "g.txt", "there");
2133 git_cmd(p, &["checkout", "main"]);
2134
2135 std::fs::write(p.join("conflict.md"), "main\n").unwrap();
2137 git_cmd(p, &["add", "conflict.md"]);
2138 git_cmd(p, &["commit", "-m", "main version"]);
2139
2140 git_cmd(p, &["checkout", "-b", "feature", "HEAD~1"]);
2141 std::fs::write(p.join("conflict.md"), "feature\n").unwrap();
2142 git_cmd(p, &["add", "conflict.md"]);
2143 git_cmd(p, &["commit", "-m", "feature version"]);
2144
2145 git_cmd(p, &["checkout", "main"]);
2146 let _ = Cmd::new("git")
2147 .args(["-C", &p.to_string_lossy(), "merge", "feature", "--no-edit"])
2148 .output();
2149 let _ = std::fs::remove_file(p.join(".git/MERGE_HEAD"));
2151 let _ = std::fs::remove_file(p.join(".git/MERGE_MSG"));
2152
2153 let pre = String::from_utf8_lossy(
2154 &Cmd::new("git").args(["-C", &p.to_string_lossy(), "ls-files", "-u"])
2155 .output().unwrap().stdout
2156 ).to_string();
2157 assert!(!pre.trim().is_empty(), "precondition: unmerged index entries should be present; got: {pre:?}");
2158
2159 let mut warnings = Vec::new();
2161 let result = merge_ref(p, "other", &mut warnings);
2162
2163 assert!(result.is_some(), "merge should succeed after preflight; warnings: {warnings:?}");
2164 assert!(
2165 warnings.iter().any(|w| w.contains("stale unmerged index")),
2166 "expected stale-entry warning; got: {warnings:?}"
2167 );
2168 }
2169
2170 #[test]
2171 fn merge_ref_conflict_aborts_and_warns() {
2172 let dir = git_init();
2173 let p = dir.path();
2174 make_commit(p, "f.txt", "main version\n");
2177 git_cmd(p, &["checkout", "-b", "feature"]);
2178 std::fs::write(p.join("f.txt"), "feature version\n").unwrap();
2179 git_cmd(p, &["add", "f.txt"]);
2180 git_cmd(p, &["commit", "-m", "feature change"]);
2181 git_cmd(p, &["checkout", "main"]);
2182 std::fs::write(p.join("f.txt"), "main change\n").unwrap();
2183 git_cmd(p, &["add", "f.txt"]);
2184 git_cmd(p, &["commit", "-m", "main change"]);
2185
2186 let mut warnings = Vec::new();
2187 let result = merge_ref(p, "feature", &mut warnings);
2188
2189 assert!(result.is_none(), "merge should report failure");
2190 assert!(
2191 warnings.iter().any(|w| w.contains("merge feature failed")),
2192 "expected merge-failure warning; got: {warnings:?}"
2193 );
2194 assert!(
2196 detect_mid_merge_state(p).is_none(),
2197 "merge_ref must abort on conflict so MERGE_HEAD does not persist"
2198 );
2199 }
2200
2201 #[test]
2202 fn detect_mid_merge_none_on_clean_repo() {
2203 let dir = git_init();
2204 make_commit(dir.path(), "f.txt", "hi");
2205 assert!(detect_mid_merge_state(dir.path()).is_none());
2206 }
2207
2208 #[test]
2209 fn detect_mid_merge_on_merge_head() {
2210 let dir = git_init();
2211 make_commit(dir.path(), "f.txt", "hi");
2212 std::fs::write(dir.path().join(".git/MERGE_HEAD"), "abc").unwrap();
2213 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::Merge)));
2214 }
2215
2216 #[test]
2217 fn detect_mid_merge_on_rebase_merge() {
2218 let dir = git_init();
2219 make_commit(dir.path(), "f.txt", "hi");
2220 std::fs::create_dir(dir.path().join(".git/rebase-merge")).unwrap();
2221 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseMerge)));
2222 }
2223
2224 #[test]
2225 fn detect_mid_merge_on_rebase_apply() {
2226 let dir = git_init();
2227 make_commit(dir.path(), "f.txt", "hi");
2228 std::fs::create_dir(dir.path().join(".git/rebase-apply")).unwrap();
2229 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseApply)));
2230 }
2231
2232 #[test]
2233 fn detect_mid_merge_on_cherry_pick() {
2234 let dir = git_init();
2235 make_commit(dir.path(), "f.txt", "hi");
2236 std::fs::write(dir.path().join(".git/CHERRY_PICK_HEAD"), "abc").unwrap();
2237 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::CherryPick)));
2238 }
2239
2240 #[test]
2241 fn is_file_tracked_tracked_and_untracked() {
2242 let dir = git_init();
2243 make_commit(dir.path(), "tracked.txt", "hi");
2244 assert!(is_file_tracked(dir.path(), "tracked.txt"));
2245 std::fs::write(dir.path().join("untracked.txt"), "new").unwrap();
2246 assert!(!is_file_tracked(dir.path(), "untracked.txt"));
2247 }
2248
2249 #[test]
2250 fn check_leaked_files_detects_overlap() {
2251 let dir = git_init();
2252 let p = dir.path();
2253 std::fs::create_dir_all(p.join("src")).unwrap();
2254 std::fs::write(p.join("src/foo.rs"), "original").unwrap();
2255 git_cmd(p, &["add", "src/foo.rs"]);
2256 git_cmd(p, &["commit", "-m", "add foo"]);
2257
2258 git_cmd(p, &["checkout", "-b", "ticket/overlap-test"]);
2259 std::fs::write(p.join("src/foo.rs"), "ticket-change").unwrap();
2260 git_cmd(p, &["add", "src/foo.rs"]);
2261 git_cmd(p, &["commit", "-m", "ticket: change foo"]);
2262 git_cmd(p, &["checkout", "main"]);
2263
2264 std::fs::write(p.join("src/foo.rs"), "leaked").unwrap();
2266
2267 let leaked = check_leaked_files(p, "ticket/overlap-test", "main").unwrap();
2268 assert_eq!(leaked, vec!["src/foo.rs".to_string()]);
2269 }
2270
2271 #[test]
2272 fn check_leaked_files_no_overlap() {
2273 let dir = git_init();
2274 let p = dir.path();
2275 std::fs::create_dir_all(p.join("src")).unwrap();
2276 std::fs::write(p.join("src/foo.rs"), "original").unwrap();
2277 std::fs::write(p.join("src/bar.rs"), "bar").unwrap();
2278 git_cmd(p, &["add", "src/foo.rs", "src/bar.rs"]);
2279 git_cmd(p, &["commit", "-m", "add foo and bar"]);
2280
2281 git_cmd(p, &["checkout", "-b", "ticket/no-overlap"]);
2283 std::fs::write(p.join("src/foo.rs"), "ticket-change").unwrap();
2284 git_cmd(p, &["add", "src/foo.rs"]);
2285 git_cmd(p, &["commit", "-m", "ticket: change foo"]);
2286 git_cmd(p, &["checkout", "main"]);
2287
2288 std::fs::write(p.join("src/bar.rs"), "dirty").unwrap();
2290
2291 let leaked = check_leaked_files(p, "ticket/no-overlap", "main").unwrap();
2292 assert!(leaked.is_empty(), "no overlap expected; got {leaked:?}");
2293 }
2294
2295 #[test]
2296 fn check_leaked_files_detects_untracked_overlap() {
2297 let dir = git_init();
2298 let p = dir.path();
2299 make_commit(p, "existing.rs", "base");
2300
2301 git_cmd(p, &["checkout", "-b", "ticket/untracked-overlap"]);
2303 std::fs::create_dir_all(p.join("src")).unwrap();
2304 std::fs::write(p.join("src/new.rs"), "new file").unwrap();
2305 git_cmd(p, &["add", "src/new.rs"]);
2306 git_cmd(p, &["commit", "-m", "ticket: add new file"]);
2307 git_cmd(p, &["checkout", "main"]);
2308
2309 std::fs::create_dir_all(p.join("src")).unwrap();
2311 std::fs::write(p.join("src/new.rs"), "leaked untracked").unwrap();
2312
2313 let leaked = check_leaked_files(p, "ticket/untracked-overlap", "main").unwrap();
2314 assert_eq!(leaked, vec!["src/new.rs".to_string()]);
2315 }
2316
2317 fn commit_file(dir: &Path, name: &str, content: &str) {
2321 std::fs::write(dir.join(name), content).unwrap();
2322 git_cmd(dir, &["add", name]);
2323 git_cmd(dir, &["commit", "-m", &format!("add {name}")]);
2324 }
2325
2326 #[test]
2329 fn content_merged_into_main_regular_merge_with_state_commit() {
2330 let dir = git_init();
2331 let p = dir.path();
2332
2333 commit_file(p, "README", "base");
2335
2336 git_cmd(p, &["checkout", "-b", "ticket/foo"]);
2338 std::fs::create_dir_all(p.join("src")).unwrap();
2339 commit_file(p, "src/lib.rs", "impl");
2340
2341 git_cmd(p, &["checkout", "main"]);
2343 git_cmd(p, &["merge", "--no-ff", "ticket/foo", "-m", "Merge ticket/foo"]);
2344
2345 git_cmd(p, &["checkout", "ticket/foo"]);
2347 std::fs::create_dir_all(p.join("tickets")).unwrap();
2348 commit_file(p, "tickets/foo.md", "state: implemented");
2349
2350 let result = content_merged_into_main(p, "main", "ticket/foo", "tickets").unwrap();
2352 assert!(result, "should detect that content was merged despite trailing state commit");
2353 }
2354
2355 #[test]
2358 fn content_merged_into_main_squash_merge_with_state_commit() {
2359 let dir = git_init();
2360 let p = dir.path();
2361
2362 commit_file(p, "README", "base");
2363
2364 git_cmd(p, &["checkout", "-b", "ticket/bar"]);
2366 std::fs::create_dir_all(p.join("src")).unwrap();
2367 commit_file(p, "src/lib.rs", "impl");
2368
2369 git_cmd(p, &["checkout", "main"]);
2371 git_cmd(p, &["merge", "--squash", "ticket/bar"]);
2372 git_cmd(p, &["commit", "-m", "Squash ticket/bar"]);
2373
2374 git_cmd(p, &["checkout", "ticket/bar"]);
2376 std::fs::create_dir_all(p.join("tickets")).unwrap();
2377 commit_file(p, "tickets/bar.md", "state: implemented");
2378
2379 let result = content_merged_into_main(p, "main", "ticket/bar", "tickets").unwrap();
2380 assert!(result, "should detect squash-merged content despite trailing state commit");
2381 }
2382
2383 #[test]
2386 fn content_merged_into_main_returns_false_when_ancestor() {
2387 let dir = git_init();
2388 let p = dir.path();
2389 commit_file(p, "README", "base");
2390 git_cmd(p, &["checkout", "-b", "ticket/anc"]);
2392 git_cmd(p, &["checkout", "main"]);
2394 let result = content_merged_into_main(p, "main", "ticket/anc", "tickets").unwrap();
2395 assert!(!result);
2396 }
2397
2398 #[test]
2400 fn content_merged_into_main_not_detected_when_non_ticket_file_modified_after_merge() {
2401 let dir = git_init();
2402 let p = dir.path();
2403 commit_file(p, "README", "base");
2404
2405 git_cmd(p, &["checkout", "-b", "ticket/extra"]);
2407 std::fs::create_dir_all(p.join("src")).unwrap();
2408 commit_file(p, "src/lib.rs", "impl");
2409
2410 git_cmd(p, &["checkout", "main"]);
2412 git_cmd(p, &["merge", "--squash", "ticket/extra"]);
2413 git_cmd(p, &["commit", "-m", "Squash ticket/extra"]);
2414
2415 git_cmd(p, &["checkout", "ticket/extra"]);
2417 std::fs::create_dir_all(p.join("tickets")).unwrap();
2418 commit_file(p, "tickets/extra.md", "state: implemented");
2419 commit_file(p, "src/extra.rs", "extra code");
2422
2423 let result = content_merged_into_main(p, "main", "ticket/extra", "tickets").unwrap();
2424 assert!(!result, "branch with non-ticket changes after merge must not be detected");
2425 }
2426
2427 #[test]
2430 fn content_merged_into_main_all_ticket_only_commits_returns_false() {
2431 let dir = git_init();
2432 let p = dir.path();
2433 commit_file(p, "README", "base");
2434
2435 git_cmd(p, &["checkout", "-b", "ticket/ticketonly"]);
2437 std::fs::create_dir_all(p.join("tickets")).unwrap();
2438 commit_file(p, "tickets/ticketonly.md", "state: new");
2439 git_cmd(p, &["checkout", "main"]);
2440
2441 let result = content_merged_into_main(p, "main", "ticket/ticketonly", "tickets").unwrap();
2442 assert!(!result, "all-ticket-only commits should return false");
2443 }
2444
2445 #[test]
2452 fn merged_into_main_detects_local_regular_merge_when_remote_deleted() {
2453 let dir = git_init();
2454 let p = dir.path();
2455 make_commit(p, "f.txt", "base");
2456
2457 git_cmd(p, &["checkout", "-b", "ticket/foo"]);
2459 std::fs::write(p.join("f.txt"), "ticket-change").unwrap();
2460 git_cmd(p, &["add", "f.txt"]);
2461 git_cmd(p, &["commit", "-m", "ticket: change"]);
2462
2463 git_cmd(p, &["checkout", "main"]);
2465 git_cmd(p, &["merge", "--no-ff", "ticket/foo", "-m", "Merge ticket/foo"]);
2466
2467 let main_sha = run(p, &["rev-parse", "main"]).unwrap();
2470 Cmd::new("git")
2471 .args(["update-ref", "refs/remotes/origin/main", main_sha.trim()])
2472 .current_dir(p)
2473 .status()
2474 .unwrap();
2475 let merged = merged_into_main(p, "main").unwrap();
2478 assert!(
2479 merged.iter().any(|b| b == "ticket/foo"),
2480 "expected ticket/foo in merged set; got {merged:?}"
2481 );
2482 }
2483
2484 fn git_init_with_remote() -> (TempDir, TempDir) {
2489 let bare = tempfile::tempdir().unwrap();
2491 Cmd::new("git")
2492 .args(["init", "--bare", "-q"])
2493 .current_dir(bare.path())
2494 .status()
2495 .unwrap();
2496
2497 let local = tempfile::tempdir().unwrap();
2499 let p = local.path();
2500 Cmd::new("git")
2501 .args(["clone", "-q", &bare.path().to_string_lossy(), "."])
2502 .current_dir(p)
2503 .env("GIT_AUTHOR_NAME", "test")
2504 .env("GIT_AUTHOR_EMAIL", "t@t.com")
2505 .env("GIT_COMMITTER_NAME", "test")
2506 .env("GIT_COMMITTER_EMAIL", "t@t.com")
2507 .status()
2508 .unwrap();
2509 git_cmd(p, &["config", "user.name", "test"]);
2510 git_cmd(p, &["config", "user.email", "t@t.com"]);
2511
2512 (bare, local)
2513 }
2514
2515 #[test]
2516 fn read_from_branch_with_class_behind_returns_origin_content() {
2517 let (bare, local) = git_init_with_remote();
2518 let p = local.path();
2519
2520 make_commit(p, "README", "base");
2522 git_cmd(p, &["push", "origin", "main"]);
2523
2524 git_cmd(p, &["checkout", "-b", "ticket/abc"]);
2526 make_commit(p, "tickets/abc.md", "state: ready\n");
2527 git_cmd(p, &["push", "origin", "ticket/abc"]);
2528
2529 let remote2 = tempfile::tempdir().unwrap();
2531 let r2 = remote2.path();
2532 Cmd::new("git")
2533 .args(["clone", "-q", &bare.path().to_string_lossy(), "."])
2534 .current_dir(r2)
2535 .env("GIT_AUTHOR_NAME", "test")
2536 .env("GIT_AUTHOR_EMAIL", "t@t.com")
2537 .env("GIT_COMMITTER_NAME", "test")
2538 .env("GIT_COMMITTER_EMAIL", "t@t.com")
2539 .status()
2540 .unwrap();
2541 git_cmd(r2, &["config", "user.name", "test"]);
2542 git_cmd(r2, &["config", "user.email", "t@t.com"]);
2543 git_cmd(r2, &["checkout", "ticket/abc"]);
2544 make_commit(r2, "tickets/abc.md", "state: in_progress\n");
2545 git_cmd(r2, &["push", "origin", "ticket/abc"]);
2546
2547 git_cmd(p, &["fetch", "--all", "--quiet"]);
2549
2550 let (content, class) = read_from_branch_with_class(p, "ticket/abc", "tickets/abc.md").unwrap();
2552 assert!(
2553 matches!(class, BranchClass::Behind),
2554 "expected Behind; got something else"
2555 );
2556 assert!(
2557 content.contains("in_progress"),
2558 "expected origin content 'in_progress'; got: {content:?}"
2559 );
2560 }
2561
2562 #[test]
2563 fn read_from_branch_with_class_ahead_returns_local_content() {
2564 let (_bare, local) = git_init_with_remote();
2565 let p = local.path();
2566
2567 make_commit(p, "README", "base");
2568 git_cmd(p, &["push", "origin", "main"]);
2569
2570 git_cmd(p, &["checkout", "-b", "ticket/xyz"]);
2571 make_commit(p, "tickets/xyz.md", "state: ready\n");
2572 git_cmd(p, &["push", "origin", "ticket/xyz"]);
2573
2574 make_commit(p, "tickets/xyz.md", "state: in_progress\n");
2576
2577 git_cmd(p, &["fetch", "--all", "--quiet"]);
2579
2580 let (content, class) = read_from_branch_with_class(p, "ticket/xyz", "tickets/xyz.md").unwrap();
2581 assert!(
2582 matches!(class, BranchClass::Ahead),
2583 "expected Ahead"
2584 );
2585 assert!(
2586 content.contains("in_progress"),
2587 "expected local content; got: {content:?}"
2588 );
2589 }
2590
2591 #[test]
2592 fn read_from_branch_with_class_equal_returns_content() {
2593 let (_bare, local) = git_init_with_remote();
2594 let p = local.path();
2595
2596 make_commit(p, "README", "base");
2597 git_cmd(p, &["push", "origin", "main"]);
2598
2599 git_cmd(p, &["checkout", "-b", "ticket/eq"]);
2600 make_commit(p, "tickets/eq.md", "state: ready\n");
2601 git_cmd(p, &["push", "origin", "ticket/eq"]);
2602
2603 let (content, class) = read_from_branch_with_class(p, "ticket/eq", "tickets/eq.md").unwrap();
2604 assert!(
2605 matches!(class, BranchClass::Equal),
2606 "expected Equal"
2607 );
2608 assert!(content.contains("ready"), "expected ready content; got: {content:?}");
2609 }
2610
2611 #[test]
2614 fn is_branch_content_merged_regular_merge_returns_true() {
2615 let dir = git_init();
2616 let p = dir.path();
2617 make_commit(p, "README", "base");
2618 git_cmd(p, &["checkout", "-b", "epic/aa000001-feature"]);
2620 make_commit(p, "feature.md", "feature");
2621 git_cmd(p, &["checkout", "main"]);
2622 git_cmd(p, &["merge", "--no-ff", "epic/aa000001-feature", "-m", "merge epic"]);
2624 assert!(is_branch_content_merged(p, "main", "epic/aa000001-feature").unwrap());
2625 }
2626
2627 #[test]
2628 fn is_branch_content_merged_squash_merge_returns_true() {
2629 let dir = git_init();
2630 let p = dir.path();
2631 make_commit(p, "README", "base");
2632 git_cmd(p, &["checkout", "-b", "epic/bb000002-feature"]);
2634 make_commit(p, "feature.md", "feature");
2635 git_cmd(p, &["checkout", "main"]);
2636 git_cmd(p, &["merge", "--squash", "epic/bb000002-feature"]);
2638 git_cmd(p, &["commit", "-m", "squash merge epic"]);
2639 assert!(is_branch_content_merged(p, "main", "epic/bb000002-feature").unwrap());
2640 }
2641
2642 #[test]
2643 fn is_branch_content_merged_unmerged_returns_false() {
2644 let dir = git_init();
2645 let p = dir.path();
2646 make_commit(p, "README", "base");
2647 git_cmd(p, &["checkout", "-b", "epic/cc000003-feature"]);
2648 make_commit(p, "feature.md", "feature");
2649 git_cmd(p, &["checkout", "main"]);
2650 assert!(!is_branch_content_merged(p, "main", "epic/cc000003-feature").unwrap());
2652 }
2653
2654 #[test]
2655 fn is_branch_content_merged_no_remote_falls_back_to_local() {
2656 let dir = git_init();
2658 let p = dir.path();
2659 make_commit(p, "README", "base");
2660 git_cmd(p, &["checkout", "-b", "epic/dd000004-feature"]);
2661 make_commit(p, "feature.md", "feature");
2662 git_cmd(p, &["checkout", "main"]);
2663 git_cmd(p, &["merge", "--no-ff", "epic/dd000004-feature", "-m", "merge epic"]);
2664 assert!(is_branch_content_merged(p, "main", "epic/dd000004-feature").unwrap());
2666 }
2667
2668 #[test]
2669 fn is_branch_content_merged_merged_into_both_returns_true() {
2670 let (bare, local) = git_init_with_remote();
2671 let p = local.path();
2672 make_commit(p, "README", "base");
2673 git_cmd(p, &["push", "origin", "main"]);
2674 git_cmd(p, &["checkout", "-b", "epic/ee000005-feature"]);
2676 make_commit(p, "feature.md", "feature");
2677 git_cmd(p, &["push", "origin", "epic/ee000005-feature"]);
2678 git_cmd(p, &["checkout", "main"]);
2679 git_cmd(p, &["merge", "--no-ff", "epic/ee000005-feature", "-m", "merge epic"]);
2681 git_cmd(p, &["push", "origin", "main"]);
2682 assert!(is_branch_content_merged(p, "main", "epic/ee000005-feature").unwrap());
2684 drop(bare); }
2686
2687 #[test]
2688 fn is_branch_content_merged_local_merge_origin_behind_returns_true() {
2689 let (bare, local) = git_init_with_remote();
2692 let p = local.path();
2693 make_commit(p, "README", "base");
2694 git_cmd(p, &["push", "origin", "main"]);
2695 git_cmd(p, &["checkout", "-b", "epic/ff000006-feature"]);
2697 make_commit(p, "feature.md", "feature");
2698 git_cmd(p, &["checkout", "main"]);
2699 git_cmd(p, &["merge", "--no-ff", "epic/ff000006-feature", "-m", "merge epic"]);
2700 assert!(is_branch_content_merged(p, "main", "epic/ff000006-feature").unwrap());
2703 drop(bare); }
2705
2706 #[test]
2707 fn read_from_branch_with_class_remote_only_returns_origin_content() {
2708 let (bare, local) = git_init_with_remote();
2709 let p = local.path();
2710
2711 make_commit(p, "README", "base");
2712 git_cmd(p, &["push", "origin", "main"]);
2713
2714 let remote2 = tempfile::tempdir().unwrap();
2716 let r2 = remote2.path();
2717 Cmd::new("git")
2718 .args(["clone", "-q", &bare.path().to_string_lossy(), "."])
2719 .current_dir(r2)
2720 .env("GIT_AUTHOR_NAME", "test")
2721 .env("GIT_AUTHOR_EMAIL", "t@t.com")
2722 .env("GIT_COMMITTER_NAME", "test")
2723 .env("GIT_COMMITTER_EMAIL", "t@t.com")
2724 .status()
2725 .unwrap();
2726 git_cmd(r2, &["config", "user.name", "test"]);
2727 git_cmd(r2, &["config", "user.email", "t@t.com"]);
2728 git_cmd(r2, &["checkout", "-b", "ticket/ro"]);
2729 make_commit(r2, "tickets/ro.md", "state: ready\n");
2730 git_cmd(r2, &["push", "origin", "ticket/ro"]);
2731
2732 git_cmd(p, &["fetch", "--all", "--quiet"]);
2734
2735 let (content, class) = read_from_branch_with_class(p, "ticket/ro", "tickets/ro.md").unwrap();
2736 assert!(
2737 matches!(class, BranchClass::RemoteOnly),
2738 "expected RemoteOnly"
2739 );
2740 assert!(content.contains("ready"), "expected ready content; got: {content:?}");
2741 }
2742
2743 #[test]
2744 fn delete_remote_branches_empty_slice_returns_ok() {
2745 let dir = git_init();
2746 let result = delete_remote_branches(dir.path(), &[]);
2747 let out = result.expect("empty slice should return Ok");
2748 assert!(out.deleted.is_empty(), "no branches should be deleted");
2749 assert!(out.failed.is_empty(), "no failures expected");
2750 }
2751}