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> {
796 let remote_ref = format!("refs/remotes/origin/{default_branch}");
797 let main_ref = if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
798 format!("origin/{default_branch}")
799 } else {
800 default_branch.to_string()
801 };
802 is_branch_merged_into(root, branch, &main_ref)
803}
804
805pub enum BranchClass {
815 Equal,
816 Behind,
817 Ahead,
818 Diverged,
819 RemoteOnly,
821 NoRemote,
823}
824
825pub fn classify_branch(root: &Path, local: &str, remote: &str) -> BranchClass {
833 let local_sha = match run(root, &["rev-parse", local]) {
834 Ok(s) => s,
835 Err(_) => {
836 return if run(root, &["rev-parse", remote]).is_ok() {
840 BranchClass::RemoteOnly
841 } else {
842 BranchClass::NoRemote
843 };
844 }
845 };
846 let remote_sha = match run(root, &["rev-parse", remote]) {
847 Ok(s) => s,
848 Err(_) => return BranchClass::NoRemote,
849 };
850
851 if local_sha == remote_sha {
852 return BranchClass::Equal;
853 }
854
855 let local_is_ancestor_of_remote = is_ancestor(root, local, remote);
858
859 let remote_is_ancestor_of_local = is_ancestor(root, remote, local);
862
863 match (local_is_ancestor_of_remote, remote_is_ancestor_of_local) {
864 (true, false) => BranchClass::Behind, (false, true) => BranchClass::Ahead, (false, false) => BranchClass::Diverged, (true, true) => BranchClass::Equal, }
869}
870
871pub fn sync_default_branch(root: &Path, default: &str, warnings: &mut Vec<String>) -> bool {
896 let remote = format!("origin/{default}");
897 match classify_branch(root, default, &remote) {
898 BranchClass::Equal => {
899 }
901
902 BranchClass::Behind => {
903 let wt = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
906 if run(&wt, &["merge", "--ff-only", &remote]).is_err() {
907 let msg = crate::sync_guidance::MAIN_BEHIND_DIRTY_OVERLAP
911 .replace("<default>", default);
912 warnings.push(msg);
913 }
914 }
915
916 BranchClass::Ahead => {
917 let count = run(root, &["rev-list", "--count", &format!("{remote}..{default}")])
920 .ok()
921 .and_then(|s| s.trim().parse::<u64>().ok())
922 .unwrap_or(0);
923 let msg = crate::sync_guidance::MAIN_AHEAD
924 .replace("<default>", default)
925 .replace("<remote>", &remote)
926 .replace("<count>", &count.to_string())
927 .replace("<commits>", if count == 1 { "commit" } else { "commits" });
928 warnings.push(msg);
929 return true;
930 }
931
932 BranchClass::Diverged => {
933 let wt = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
936 let guidance = if is_worktree_dirty(&wt) {
937 crate::sync_guidance::MAIN_DIVERGED_DIRTY.replace("<default>", default)
938 } else {
939 crate::sync_guidance::MAIN_DIVERGED_CLEAN.replace("<default>", default)
940 };
941 warnings.push(guidance);
942 }
943
944 BranchClass::RemoteOnly => {
945 }
949
950 BranchClass::NoRemote => {
951 }
955 }
956 false
957}
958
959pub fn fetch_branch(root: &Path, branch: &str) -> anyhow::Result<()> {
960 run(root, &["fetch", "origin", branch]).map(|_| ())
961}
962
963pub fn push_branch(root: &Path, branch: &str) -> anyhow::Result<()> {
964 run(root, &["push", "origin", &format!("{branch}:{branch}")]).map(|_| ())
965}
966
967pub fn push_branch_tracking(root: &Path, branch: &str) -> anyhow::Result<()> {
968 let out = std::process::Command::new("git")
969 .args(["push", "--set-upstream", "origin", &format!("{branch}:{branch}")])
970 .current_dir(root)
971 .output()?;
972 if !out.status.success() {
973 anyhow::bail!("git push failed: {}", String::from_utf8_lossy(&out.stderr).trim());
974 }
975 Ok(())
976}
977
978pub fn has_remote(root: &Path) -> bool {
979 run(root, &["remote", "get-url", "origin"]).is_ok()
980}
981
982pub fn remote_ticket_branches_with_dates(
987 root: &Path,
988) -> Result<Vec<(String, chrono::DateTime<chrono::Utc>)>> {
989 use chrono::{TimeZone, Utc};
990 let out = Command::new("git")
991 .current_dir(root)
992 .args([
993 "for-each-ref",
994 "refs/remotes/origin/ticket/",
995 "--format=%(refname:short) %(creatordate:unix)",
996 ])
997 .output()
998 .context("git for-each-ref failed")?;
999 let stdout = String::from_utf8_lossy(&out.stdout);
1000 let mut result = Vec::new();
1001 for line in stdout.lines() {
1002 let mut parts = line.splitn(2, ' ');
1003 let refname = parts.next().unwrap_or("").trim();
1004 let ts_str = parts.next().unwrap_or("").trim();
1005 let branch = refname.trim_start_matches("origin/");
1006 if branch.is_empty() {
1007 continue;
1008 }
1009 if let Ok(ts) = ts_str.parse::<i64>() {
1010 if let Some(dt) = Utc.timestamp_opt(ts, 0).single() {
1011 result.push((branch.to_string(), dt));
1012 }
1013 }
1014 }
1015 Ok(result)
1016}
1017
1018pub fn list_remote_ticket_branches(root: &Path) -> std::collections::HashSet<String> {
1024 let mut set = std::collections::HashSet::new();
1025 let out = match run(root, &["ls-remote", "--heads", "origin", "ticket/*"]) {
1026 Ok(o) => o,
1027 Err(_) => return set,
1028 };
1029 for line in out.lines() {
1030 if let Some(refname) = line.split('\t').nth(1) {
1031 if let Some(branch) = refname.trim().strip_prefix("refs/heads/") {
1032 set.insert(branch.to_string());
1033 }
1034 }
1035 }
1036 set
1037}
1038
1039pub fn delete_remote_branch(root: &Path, branch: &str) -> Result<()> {
1041 run(root, &["push", "origin", "--delete", branch])
1042 .map(|_| ())
1043 .context("git push origin --delete failed")
1044}
1045
1046pub fn move_files_on_branch(
1051 root: &Path,
1052 branch: &str,
1053 moves: &[(&str, &str, &str)],
1054 message: &str,
1055) -> Result<()> {
1056 if !has_commits(root) {
1057 for (old, new, content) in moves {
1058 let new_path = root.join(new);
1059 if let Some(parent) = new_path.parent() {
1060 std::fs::create_dir_all(parent)?;
1061 }
1062 std::fs::write(&new_path, content)?;
1063 let old_path = root.join(old);
1064 let _ = std::fs::remove_file(&old_path);
1065 }
1066 return Ok(());
1067 }
1068
1069 let do_moves = |wt: &Path| -> Result<()> {
1070 for (old, new, content) in moves {
1071 let new_path = wt.join(new);
1072 if let Some(parent) = new_path.parent() {
1073 std::fs::create_dir_all(parent)?;
1074 }
1075 std::fs::write(&new_path, content)?;
1076 run(wt, &["add", new])?;
1077 run(wt, &["rm", "--force", "--quiet", old])?;
1078 }
1079 run(wt, &["commit", "-m", message])?;
1080 Ok(())
1081 };
1082
1083 if let Some(wt_path) = find_worktree_for_branch(root, branch) {
1084 let remote_ref = format!("origin/{branch}");
1085 if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
1086 let _ = run(&wt_path, &["merge", "--ff-only", &remote_ref]);
1087 }
1088 let result = do_moves(&wt_path);
1089 if result.is_ok() {
1090 crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1091 }
1092 return result;
1093 }
1094
1095 if current_branch(root).ok().as_deref() == Some(branch) {
1096 let result = do_moves(root);
1097 if result.is_ok() {
1098 crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1099 }
1100 return result;
1101 }
1102
1103 let unique = std::time::SystemTime::now()
1104 .duration_since(std::time::UNIX_EPOCH)
1105 .map(|d| d.subsec_nanos())
1106 .unwrap_or(0);
1107 let wt_path = std::env::temp_dir().join(format!(
1108 "apm-{}-{}-{}",
1109 std::process::id(),
1110 unique,
1111 branch.replace('/', "-"),
1112 ));
1113
1114 let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
1115 let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
1116
1117 if has_remote {
1118 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
1119 let _ = run(&wt_path, &["checkout", "-B", branch]);
1120 } else if has_local {
1121 let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
1122 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
1123 let _ = run(&wt_path, &["checkout", "-B", branch]);
1124 } else {
1125 run(root, &["worktree", "add", &wt_path.to_string_lossy(), branch])?;
1126 }
1127
1128 let result = do_moves(&wt_path);
1129 let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
1130 let _ = std::fs::remove_dir_all(&wt_path);
1131 if result.is_ok() {
1132 crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1133 }
1134 result
1135}
1136
1137pub fn merge_branch_into_default(root: &Path, branch: &str, default_branch: &str, warnings: &mut Vec<String>) -> Result<()> {
1138 let _ = run(root, &["fetch", "origin", default_branch]);
1139
1140 let main_root = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
1141 let merge_dir = if current_branch(&main_root).ok().as_deref() == Some(default_branch) {
1142 main_root
1143 } else {
1144 find_worktree_for_branch(root, default_branch).unwrap_or_else(|| root.to_path_buf())
1145 };
1146
1147 if let Err(e) = run(&merge_dir, &["merge", "--no-ff", branch, "--no-edit"]) {
1148 let _ = run(&merge_dir, &["merge", "--abort"]);
1149 anyhow::bail!("merge failed: {e:#}");
1150 }
1151
1152 if has_remote(root) {
1153 if let Err(e) = run(&merge_dir, &["push", "origin", default_branch]) {
1154 warnings.push(format!("warning: push {default_branch} failed: {e:#}"));
1155 }
1156 }
1157 Ok(())
1158}
1159
1160pub 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<()> {
1161 let _ = run(root, &["fetch", "origin", default_branch]);
1162
1163 let main_root = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
1164 let merge_dir = if current_branch(&main_root).ok().as_deref() == Some(default_branch) {
1165 main_root.clone()
1166 } else {
1167 let worktrees_base = main_root.join(&config.worktrees.dir);
1168 ensure_worktree(root, &worktrees_base, default_branch)?
1169 };
1170
1171 let out = std::process::Command::new("git")
1172 .args(["merge", "--no-ff", branch, "--no-edit"])
1173 .current_dir(&merge_dir)
1174 .output()?;
1175
1176 if !out.status.success() {
1177 let _ = run(&merge_dir, &["merge", "--abort"]);
1178 bail!(
1179 "merge conflict — resolve manually and push: {}",
1180 String::from_utf8_lossy(&out.stderr).trim()
1181 );
1182 }
1183
1184 if skip_push {
1185 messages.push(format!("Merged {branch} into {default_branch} (local only)."));
1186 } else {
1187 push_branch(&merge_dir, default_branch)?;
1188 messages.push(format!("Merged {branch} into {default_branch} and pushed to origin."));
1189 }
1190 Ok(())
1191}
1192
1193pub fn pull_default(root: &Path, default_branch: &str, warnings: &mut Vec<String>) -> Result<()> {
1194 let fetch = std::process::Command::new("git")
1195 .args(["fetch", "origin", default_branch])
1196 .current_dir(root)
1197 .output();
1198
1199 match fetch {
1200 Err(e) => {
1201 warnings.push(format!("warning: fetch failed: {e:#}"));
1202 return Ok(());
1203 }
1204 Ok(out) if !out.status.success() => {
1205 warnings.push(format!(
1206 "warning: fetch failed: {}",
1207 String::from_utf8_lossy(&out.stderr).trim()
1208 ));
1209 return Ok(());
1210 }
1211 _ => {}
1212 }
1213
1214 let current = std::process::Command::new("git")
1215 .args(["rev-parse", "--abbrev-ref", "HEAD"])
1216 .current_dir(root)
1217 .output()?;
1218 let current_branch = String::from_utf8_lossy(¤t.stdout).trim().to_string();
1219
1220 let merge_dir = if current_branch == default_branch {
1221 root.to_path_buf()
1222 } else {
1223 find_worktree_for_branch(root, default_branch)
1224 .unwrap_or_else(|| root.to_path_buf())
1225 };
1226
1227 let remote_ref = format!("origin/{default_branch}");
1228 let out = std::process::Command::new("git")
1229 .args(["merge", "--ff-only", &remote_ref])
1230 .current_dir(&merge_dir)
1231 .output()?;
1232
1233 if !out.status.success() {
1234 warnings.push(format!("warning: could not fast-forward {default_branch} — pull manually"));
1235 }
1236
1237 Ok(())
1238}
1239
1240pub fn is_worktree_dirty(path: &Path) -> bool {
1241 let Ok(out) = Command::new("git")
1242 .args(["-C", &path.to_string_lossy(), "status", "--porcelain"])
1243 .output()
1244 else {
1245 return false;
1246 };
1247 !out.stdout.is_empty()
1248}
1249
1250pub fn is_worktree_dirty_for_sync(path: &Path) -> bool {
1253 const TEMP_FILES: &[&str] = &[".apm-worker.log", ".apm-worker.pid"];
1254 let Ok(out) = Command::new("git")
1255 .args(["-C", &path.to_string_lossy(), "status", "--porcelain"])
1256 .output()
1257 else {
1258 return false;
1259 };
1260 let stdout = String::from_utf8_lossy(&out.stdout);
1261 stdout.lines().filter(|l| !l.is_empty()).any(|l| {
1262 let fname = l.get(3..).unwrap_or("").trim();
1264 !TEMP_FILES.contains(&fname)
1265 })
1266}
1267
1268fn dirty_files_for_sync(path: &Path) -> Vec<String> {
1272 const TEMP_FILES: &[&str] = &[".apm-worker.log", ".apm-worker.pid"];
1273 let Ok(out) = Command::new("git")
1274 .args(["-C", &path.to_string_lossy(), "status", "--porcelain"])
1275 .output()
1276 else {
1277 return Vec::new();
1278 };
1279 let stdout = String::from_utf8_lossy(&out.stdout);
1280 stdout
1281 .lines()
1282 .filter(|l| !l.is_empty())
1283 .filter_map(|l| {
1284 let fname = l.get(3..)?.trim();
1285 if TEMP_FILES.contains(&fname) { None } else { Some(fname.to_string()) }
1286 })
1287 .collect()
1288}
1289
1290pub struct WorktreeSyncResult {
1292 pub fast_forwarded: Vec<(PathBuf, String)>,
1294 pub skipped_dirty: Vec<(PathBuf, String, Vec<String>)>,
1296 pub skipped_ahead: Vec<(PathBuf, String)>,
1298 pub skipped_diverged: Vec<(PathBuf, String)>,
1300}
1301
1302pub fn sync_checked_out_worktrees(root: &Path, warnings: &mut Vec<String>) -> WorktreeSyncResult {
1315 let mut result = WorktreeSyncResult {
1316 fast_forwarded: Vec::new(),
1317 skipped_dirty: Vec::new(),
1318 skipped_ahead: Vec::new(),
1319 skipped_diverged: Vec::new(),
1320 };
1321
1322 let worktrees = match crate::worktree::list_ticket_worktrees(root) {
1323 Ok(w) => w,
1324 Err(_) => return result,
1325 };
1326
1327 for (wt_path, branch) in worktrees {
1328 let local_ref = format!("refs/heads/{branch}");
1329 let remote_ref = format!("origin/{branch}");
1330 match classify_branch(root, &local_ref, &remote_ref) {
1331 BranchClass::Behind => {
1332 if is_worktree_dirty_for_sync(&wt_path) {
1333 let dirty = dirty_files_for_sync(&wt_path);
1334 result.skipped_dirty.push((wt_path, branch, dirty));
1335 } else {
1336 match run(&wt_path, &["merge", "--ff-only", &remote_ref]) {
1337 Ok(_) => result.fast_forwarded.push((wt_path, branch)),
1338 Err(e) => warnings.push(format!(
1339 "warning: fast-forward {} failed: {e:#}",
1340 wt_path.display()
1341 )),
1342 }
1343 }
1344 }
1345 BranchClass::Ahead => {
1346 result.skipped_ahead.push((wt_path, branch));
1347 }
1348 BranchClass::Diverged => {
1349 result.skipped_diverged.push((wt_path, branch));
1350 }
1351 BranchClass::Equal | BranchClass::NoRemote | BranchClass::RemoteOnly => {
1352 }
1354 }
1355 }
1356
1357 result
1358}
1359
1360pub fn local_branch_exists(root: &Path, branch: &str) -> bool {
1361 Command::new("git")
1362 .args(["-C", &root.to_string_lossy(), "rev-parse", "--verify", &format!("refs/heads/{branch}")])
1363 .output()
1364 .map(|o| o.status.success())
1365 .unwrap_or(false)
1366}
1367
1368pub fn delete_local_branch(root: &Path, branch: &str, warnings: &mut Vec<String>) {
1369 let Ok(out) = Command::new("git")
1370 .args(["-C", &root.to_string_lossy(), "branch", "-D", branch])
1371 .output()
1372 else {
1373 warnings.push(format!("warning: could not delete branch {branch}: command failed"));
1374 return;
1375 };
1376 if !out.status.success() {
1377 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1378 warnings.push(format!("warning: could not delete branch {branch}: {stderr}"));
1379 }
1380}
1381
1382pub fn prune_remote_tracking(root: &Path, branch: &str) {
1383 let _ = Command::new("git")
1384 .args(["-C", &root.to_string_lossy(), "branch", "-dr", &format!("origin/{branch}")])
1385 .output();
1386}
1387
1388pub fn stage_files(root: &Path, files: &[&str]) -> Result<()> {
1389 let mut args = vec!["add"];
1390 args.extend_from_slice(files);
1391 run(root, &args).map(|_| ())
1392}
1393
1394pub fn commit(root: &Path, message: &str) -> Result<()> {
1395 run(root, &["commit", "-m", message]).map(|_| ())
1396}
1397
1398pub fn git_config_get(root: &Path, key: &str) -> Option<String> {
1399 let out = Command::new("git")
1400 .args(["-C", &root.to_string_lossy(), "config", key])
1401 .output()
1402 .ok()?;
1403 if !out.status.success() {
1404 return None;
1405 }
1406 let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
1407 if value.is_empty() { None } else { Some(value) }
1408}
1409
1410fn clear_stale_unmerged_entries(dir: &Path, warnings: &mut Vec<String>) {
1416 let out = match Command::new("git")
1417 .args(["-C", &dir.to_string_lossy(), "ls-files", "-u"])
1418 .output()
1419 {
1420 Ok(o) if o.status.success() => o,
1421 _ => return,
1422 };
1423 let stdout = String::from_utf8_lossy(&out.stdout);
1424 let mut paths: std::collections::HashSet<String> = std::collections::HashSet::new();
1425 for line in stdout.lines() {
1426 if let Some(path) = line.split('\t').nth(1) {
1428 paths.insert(path.to_string());
1429 }
1430 }
1431 if paths.is_empty() {
1432 return;
1433 }
1434 warnings.push(format!(
1435 "warning: clearing {} stale unmerged index entr{} in {} (left by an earlier failed merge)",
1436 paths.len(),
1437 if paths.len() == 1 { "y" } else { "ies" },
1438 dir.display(),
1439 ));
1440 for path in &paths {
1441 let _ = Command::new("git")
1442 .args(["-C", &dir.to_string_lossy(), "reset", "HEAD", "--", path])
1443 .output();
1444 }
1445}
1446
1447pub fn merge_ref(dir: &Path, refname: &str, warnings: &mut Vec<String>) -> Option<String> {
1448 clear_stale_unmerged_entries(dir, warnings);
1453
1454 let out = match Command::new("git")
1461 .args([
1462 "-C", &dir.to_string_lossy(),
1463 "-c", "merge.directoryRenames=false",
1464 "merge", refname, "--no-edit",
1465 ])
1466 .output()
1467 {
1468 Ok(o) => o,
1469 Err(e) => {
1470 warnings.push(format!("warning: merge {refname} failed: {e}"));
1471 return None;
1472 }
1473 };
1474 if out.status.success() {
1475 let stdout = String::from_utf8_lossy(&out.stdout);
1476 if stdout.contains("Already up to date") {
1477 None
1478 } else {
1479 Some(format!("Merged {refname} into branch."))
1480 }
1481 } else {
1482 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1483 warnings.push(format!("warning: merge {refname} failed: {stderr}"));
1484 if detect_mid_merge_state(dir).is_some() {
1490 let abort = Command::new("git")
1491 .args(["-C", &dir.to_string_lossy(), "merge", "--abort"])
1492 .output();
1493 match abort {
1494 Ok(o) if !o.status.success() => {
1495 let aborterr = String::from_utf8_lossy(&o.stderr).trim().to_string();
1496 warnings.push(format!(
1497 "warning: could not abort merge of {refname} in {}: {aborterr}",
1498 dir.display()
1499 ));
1500 }
1501 Err(e) => {
1502 warnings.push(format!(
1503 "warning: could not abort merge of {refname} in {}: {e}",
1504 dir.display()
1505 ));
1506 }
1507 Ok(_) => {}
1508 }
1509 }
1510 None
1511 }
1512}
1513
1514pub fn is_file_tracked(root: &Path, path: &str) -> bool {
1515 Command::new("git")
1516 .args(["ls-files", "--error-unmatch", path])
1517 .current_dir(root)
1518 .stdout(std::process::Stdio::null())
1519 .stderr(std::process::Stdio::null())
1520 .status()
1521 .map(|s| s.success())
1522 .unwrap_or(false)
1523}
1524
1525pub enum MidMergeState {
1529 Merge,
1531 RebaseMerge,
1533 RebaseApply,
1535 CherryPick,
1537}
1538
1539pub fn detect_mid_merge_state(root: &Path) -> Option<MidMergeState> {
1548 let git_dir = root.join(".git");
1549 if git_dir.join("MERGE_HEAD").exists() {
1550 return Some(MidMergeState::Merge);
1551 }
1552 if git_dir.join("rebase-merge").is_dir() {
1553 return Some(MidMergeState::RebaseMerge);
1554 }
1555 if git_dir.join("rebase-apply").is_dir() {
1556 return Some(MidMergeState::RebaseApply);
1557 }
1558 if git_dir.join("CHERRY_PICK_HEAD").exists() {
1559 return Some(MidMergeState::CherryPick);
1560 }
1561 None
1562}
1563
1564pub fn merge_base(root: &Path, ref1: &str, ref2: &str) -> Result<String> {
1566 run(root, &["merge-base", ref1, ref2])
1567}
1568
1569pub fn main_worktree_root(root: &Path) -> Option<PathBuf> {
1570 let out = run(root, &["worktree", "list", "--porcelain"]).ok()?;
1571 out.lines()
1572 .next()
1573 .and_then(|line| line.strip_prefix("worktree "))
1574 .map(PathBuf::from)
1575}
1576
1577pub fn check_leaked_files(
1590 root: &Path,
1591 ticket_branch: &str,
1592 target_branch: &str,
1593) -> Result<Vec<String>> {
1594 let current = Command::new("git")
1596 .args(["rev-parse", "--abbrev-ref", "HEAD"])
1597 .current_dir(root)
1598 .output()?;
1599 let current_branch = String::from_utf8_lossy(¤t.stdout).trim().to_string();
1600
1601 let merge_dir = if current_branch == target_branch {
1602 root.to_path_buf()
1603 } else {
1604 match crate::worktree::find_worktree_for_branch(root, target_branch) {
1605 Some(p) => p,
1606 None => return Ok(vec![]), }
1608 };
1609
1610 let base = match merge_base(root, target_branch, ticket_branch) {
1612 Ok(s) => s.trim().to_string(),
1613 Err(_) => return Ok(vec![]), };
1615 if base.is_empty() {
1616 return Ok(vec![]);
1617 }
1618
1619 let diff_out = Command::new("git")
1622 .args(["diff", "--name-only", &base, ticket_branch])
1623 .current_dir(root)
1624 .output()?;
1625 let ticket_files: std::collections::HashSet<String> =
1626 String::from_utf8_lossy(&diff_out.stdout)
1627 .lines()
1628 .map(|s| s.to_string())
1629 .collect();
1630
1631 let status_out = Command::new("git")
1640 .args(["status", "--porcelain", "--untracked-files=all"])
1641 .current_dir(&merge_dir)
1642 .output()?;
1643 let dirty_files: std::collections::HashSet<String> =
1644 String::from_utf8_lossy(&status_out.stdout)
1645 .lines()
1646 .filter_map(|line| {
1647 if line.len() < 3 {
1648 return None;
1649 }
1650 let x = line.as_bytes()[0] as char;
1651 let y = line.as_bytes()[1] as char;
1652 if x == 'R' || x == 'C' || y == 'R' || y == 'C' {
1654 return None;
1655 }
1656 Some(line[3..].to_string())
1657 })
1658 .collect();
1659
1660 let mut overlap: Vec<String> = ticket_files
1662 .intersection(&dirty_files)
1663 .cloned()
1664 .collect();
1665 overlap.sort();
1666 Ok(overlap)
1667}
1668
1669#[cfg(test)]
1670mod tests {
1671 use super::*;
1672 use std::process::Command as Cmd;
1673 use tempfile::TempDir;
1674
1675 fn git_init() -> TempDir {
1676 let dir = tempfile::tempdir().unwrap();
1677 let p = dir.path();
1678 Cmd::new("git").args(["init", "-q", "-b", "main"]).current_dir(p).status().unwrap();
1679 Cmd::new("git").args(["config", "user.email", "t@t.com"]).current_dir(p).status().unwrap();
1680 Cmd::new("git").args(["config", "user.name", "test"]).current_dir(p).status().unwrap();
1681 dir
1682 }
1683
1684 fn git_cmd(dir: &Path, args: &[&str]) {
1685 Cmd::new("git")
1686 .args(args)
1687 .current_dir(dir)
1688 .env("GIT_AUTHOR_NAME", "test")
1689 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1690 .env("GIT_COMMITTER_NAME", "test")
1691 .env("GIT_COMMITTER_EMAIL", "t@t.com")
1692 .status()
1693 .unwrap();
1694 }
1695
1696 fn make_commit(dir: &Path, filename: &str, content: &str) {
1697 let full = dir.join(filename);
1698 if let Some(parent) = full.parent() {
1699 std::fs::create_dir_all(parent).unwrap();
1700 }
1701 std::fs::write(full, content).unwrap();
1702 git_cmd(dir, &["add", filename]);
1703 git_cmd(dir, &["commit", "-m", "init"]);
1704 }
1705
1706 #[test]
1707 fn is_worktree_dirty_clean() {
1708 let dir = git_init();
1709 make_commit(dir.path(), "f.txt", "hi");
1710 assert!(!is_worktree_dirty(dir.path()));
1711 }
1712
1713 #[test]
1714 fn is_worktree_dirty_dirty() {
1715 let dir = git_init();
1716 make_commit(dir.path(), "f.txt", "hi");
1717 std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1718 assert!(is_worktree_dirty(dir.path()));
1719 }
1720
1721 #[test]
1722 fn is_worktree_dirty_for_sync_clean() {
1723 let dir = git_init();
1724 make_commit(dir.path(), "f.txt", "hi");
1725 assert!(!is_worktree_dirty_for_sync(dir.path()));
1726 }
1727
1728 #[test]
1729 fn is_worktree_dirty_for_sync_temp_files_only_is_clean() {
1730 let dir = git_init();
1731 make_commit(dir.path(), "f.txt", "hi");
1732 std::fs::write(dir.path().join(".apm-worker.log"), "log").unwrap();
1734 std::fs::write(dir.path().join(".apm-worker.pid"), "123").unwrap();
1735 assert!(!is_worktree_dirty_for_sync(dir.path()));
1736 assert!(is_worktree_dirty(dir.path()));
1738 }
1739
1740 #[test]
1741 fn is_worktree_dirty_for_sync_real_change_is_dirty() {
1742 let dir = git_init();
1743 make_commit(dir.path(), "f.txt", "hi");
1744 std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1745 assert!(is_worktree_dirty_for_sync(dir.path()));
1746 }
1747
1748 #[test]
1749 fn is_worktree_dirty_for_sync_temp_plus_real_is_dirty() {
1750 let dir = git_init();
1751 make_commit(dir.path(), "f.txt", "hi");
1752 std::fs::write(dir.path().join(".apm-worker.log"), "log").unwrap();
1753 std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1754 assert!(is_worktree_dirty_for_sync(dir.path()));
1755 }
1756
1757 #[test]
1758 fn dirty_files_for_sync_excludes_temp_files() {
1759 let dir = git_init();
1760 make_commit(dir.path(), "f.txt", "hi");
1761 std::fs::write(dir.path().join(".apm-worker.log"), "log").unwrap();
1762 std::fs::write(dir.path().join(".apm-worker.pid"), "123").unwrap();
1763 std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1764 let dirty = dirty_files_for_sync(dir.path());
1765 assert!(dirty.contains(&"f.txt".to_string()), "f.txt should be in dirty; got {dirty:?}");
1766 assert!(!dirty.iter().any(|f| f.contains(".apm-worker")), "temp files should be excluded; got {dirty:?}");
1767 }
1768
1769 #[test]
1770 fn sync_checked_out_worktrees_behind_clean_fast_forwards() {
1771 let origin_tmp = git_init();
1773 let origin = origin_tmp.path();
1774 make_commit(origin, "README", "v1");
1775 git_cmd(origin, &["checkout", "-b", "ticket/test-ff"]);
1777 make_commit(origin, "impl.rs", "v1");
1778 git_cmd(origin, &["checkout", "main"]);
1779
1780 let clone_tmp = tempfile::tempdir().unwrap();
1782 let clone = clone_tmp.path();
1783 Cmd::new("git")
1784 .args(["clone", &origin.to_string_lossy(), &clone.to_string_lossy()])
1785 .env("GIT_AUTHOR_NAME", "test").env("GIT_AUTHOR_EMAIL", "t@t.com")
1786 .env("GIT_COMMITTER_NAME", "test").env("GIT_COMMITTER_EMAIL", "t@t.com")
1787 .status().unwrap();
1788 git_cmd(clone, &["config", "user.email", "t@t.com"]);
1790 git_cmd(clone, &["config", "user.name", "test"]);
1791 let wt_path = clone.join("wt-test-ff");
1793 Cmd::new("git")
1794 .args(["worktree", "add", &wt_path.to_string_lossy(), "ticket/test-ff"])
1795 .current_dir(clone).status().unwrap();
1796
1797 git_cmd(origin, &["checkout", "ticket/test-ff"]);
1799 make_commit(origin, "impl.rs", "v2");
1800 git_cmd(origin, &["checkout", "main"]);
1801 git_cmd(clone, &["fetch", "origin"]);
1803
1804 let mut warnings = Vec::new();
1805 let result = sync_checked_out_worktrees(clone, &mut warnings);
1806
1807 assert_eq!(result.fast_forwarded.len(), 1, "should have fast-forwarded 1 worktree; warnings: {warnings:?}");
1808 assert!(result.skipped_dirty.is_empty());
1809 assert!(result.skipped_ahead.is_empty());
1810 assert!(result.skipped_diverged.is_empty());
1811 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1812
1813 let content = std::fs::read_to_string(wt_path.join("impl.rs")).unwrap();
1815 assert_eq!(content.trim(), "v2", "worktree should have v2 after fast-forward");
1816 }
1817
1818 #[test]
1819 fn sync_checked_out_worktrees_dirty_skips() {
1820 let origin_tmp = git_init();
1821 let origin = origin_tmp.path();
1822 make_commit(origin, "README", "v1");
1823 git_cmd(origin, &["checkout", "-b", "ticket/test-dirty"]);
1824 make_commit(origin, "impl.rs", "v1");
1825 git_cmd(origin, &["checkout", "main"]);
1826
1827 let clone_tmp = tempfile::tempdir().unwrap();
1828 let clone = clone_tmp.path();
1829 Cmd::new("git")
1830 .args(["clone", &origin.to_string_lossy(), &clone.to_string_lossy()])
1831 .env("GIT_AUTHOR_NAME", "test").env("GIT_AUTHOR_EMAIL", "t@t.com")
1832 .env("GIT_COMMITTER_NAME", "test").env("GIT_COMMITTER_EMAIL", "t@t.com")
1833 .status().unwrap();
1834 git_cmd(clone, &["config", "user.email", "t@t.com"]);
1835 git_cmd(clone, &["config", "user.name", "test"]);
1836 let wt_path = clone.join("wt-test-dirty");
1837 Cmd::new("git")
1838 .args(["worktree", "add", &wt_path.to_string_lossy(), "ticket/test-dirty"])
1839 .current_dir(clone).status().unwrap();
1840
1841 git_cmd(origin, &["checkout", "ticket/test-dirty"]);
1843 make_commit(origin, "impl.rs", "v2");
1844 git_cmd(origin, &["checkout", "main"]);
1845 git_cmd(clone, &["fetch", "origin"]);
1846
1847 std::fs::write(wt_path.join("impl.rs"), "local change").unwrap();
1849
1850 let mut warnings = Vec::new();
1851 let result = sync_checked_out_worktrees(clone, &mut warnings);
1852
1853 assert!(result.fast_forwarded.is_empty(), "should not fast-forward dirty worktree");
1854 assert_eq!(result.skipped_dirty.len(), 1);
1855 let (_, _, ref dirty_files) = result.skipped_dirty[0];
1856 assert!(dirty_files.contains(&"impl.rs".to_string()), "impl.rs should be in dirty files; got {dirty_files:?}");
1857 }
1858
1859 #[test]
1860 fn sync_checked_out_worktrees_temp_only_is_clean() {
1861 let origin_tmp = git_init();
1862 let origin = origin_tmp.path();
1863 make_commit(origin, "README", "v1");
1864 git_cmd(origin, &["checkout", "-b", "ticket/test-temponly"]);
1865 make_commit(origin, "impl.rs", "v1");
1866 git_cmd(origin, &["checkout", "main"]);
1867
1868 let clone_tmp = tempfile::tempdir().unwrap();
1869 let clone = clone_tmp.path();
1870 Cmd::new("git")
1871 .args(["clone", &origin.to_string_lossy(), &clone.to_string_lossy()])
1872 .env("GIT_AUTHOR_NAME", "test").env("GIT_AUTHOR_EMAIL", "t@t.com")
1873 .env("GIT_COMMITTER_NAME", "test").env("GIT_COMMITTER_EMAIL", "t@t.com")
1874 .status().unwrap();
1875 git_cmd(clone, &["config", "user.email", "t@t.com"]);
1876 git_cmd(clone, &["config", "user.name", "test"]);
1877 let wt_path = clone.join("wt-test-temponly");
1878 Cmd::new("git")
1879 .args(["worktree", "add", &wt_path.to_string_lossy(), "ticket/test-temponly"])
1880 .current_dir(clone).status().unwrap();
1881
1882 git_cmd(origin, &["checkout", "ticket/test-temponly"]);
1884 make_commit(origin, "impl.rs", "v2");
1885 git_cmd(origin, &["checkout", "main"]);
1886 git_cmd(clone, &["fetch", "origin"]);
1887
1888 std::fs::write(wt_path.join(".apm-worker.log"), "log").unwrap();
1890 std::fs::write(wt_path.join(".apm-worker.pid"), "123").unwrap();
1891
1892 let mut warnings = Vec::new();
1893 let result = sync_checked_out_worktrees(clone, &mut warnings);
1894
1895 assert_eq!(result.fast_forwarded.len(), 1, "temp-only worktree should be fast-forwarded; warnings: {warnings:?}");
1896 assert!(result.skipped_dirty.is_empty());
1897 }
1898
1899 #[test]
1900 fn sync_checked_out_worktrees_no_worktrees_returns_empty() {
1901 let dir = git_init();
1902 make_commit(dir.path(), "f.txt", "hi");
1903 let mut warnings = Vec::new();
1904 let result = sync_checked_out_worktrees(dir.path(), &mut warnings);
1905 assert!(result.fast_forwarded.is_empty());
1906 assert!(result.skipped_dirty.is_empty());
1907 assert!(result.skipped_ahead.is_empty());
1908 assert!(result.skipped_diverged.is_empty());
1909 assert!(warnings.is_empty());
1910 }
1911
1912 #[test]
1913 fn local_branch_exists_present_and_absent() {
1914 let dir = git_init();
1915 make_commit(dir.path(), "f.txt", "hi");
1916 let on_main = local_branch_exists(dir.path(), "main");
1917 let on_master = local_branch_exists(dir.path(), "master");
1918 assert!(on_main || on_master);
1919 assert!(!local_branch_exists(dir.path(), "no-such-branch"));
1920 }
1921
1922 #[test]
1923 fn delete_local_branch_success() {
1924 let dir = git_init();
1925 make_commit(dir.path(), "f.txt", "hi");
1926 git_cmd(dir.path(), &["branch", "to-delete"]);
1927 let mut warnings = Vec::new();
1928 delete_local_branch(dir.path(), "to-delete", &mut warnings);
1929 assert!(warnings.is_empty());
1930 assert!(!local_branch_exists(dir.path(), "to-delete"));
1931 }
1932
1933 #[test]
1934 fn delete_local_branch_failure_adds_warning() {
1935 let dir = git_init();
1936 make_commit(dir.path(), "f.txt", "hi");
1937 let mut warnings = Vec::new();
1938 delete_local_branch(dir.path(), "nonexistent", &mut warnings);
1939 assert!(!warnings.is_empty());
1940 assert!(warnings[0].contains("warning:"));
1941 }
1942
1943 #[test]
1944 fn prune_remote_tracking_no_panic() {
1945 let dir = git_init();
1946 make_commit(dir.path(), "f.txt", "hi");
1947 prune_remote_tracking(dir.path(), "nonexistent-branch");
1949 }
1950
1951 #[test]
1952 fn stage_files_ok_and_err() {
1953 let dir = git_init();
1954 make_commit(dir.path(), "f.txt", "hi");
1955 std::fs::write(dir.path().join("new.txt"), "new").unwrap();
1956 assert!(stage_files(dir.path(), &["new.txt"]).is_ok());
1957 assert!(stage_files(dir.path(), &["missing.txt"]).is_err());
1958 }
1959
1960 #[test]
1961 fn commit_ok_and_err() {
1962 let dir = git_init();
1963 make_commit(dir.path(), "f.txt", "hi");
1964 std::fs::write(dir.path().join("new.txt"), "new").unwrap();
1965 git_cmd(dir.path(), &["add", "new.txt"]);
1966 assert!(commit(dir.path(), "test commit").is_ok());
1967 assert!(commit(dir.path(), "empty commit").is_err());
1969 }
1970
1971 #[test]
1972 fn git_config_get_some_and_none() {
1973 let dir = git_init();
1974 make_commit(dir.path(), "f.txt", "hi");
1975 let val = git_config_get(dir.path(), "user.email");
1976 assert_eq!(val, Some("t@t.com".to_string()));
1977 let missing = git_config_get(dir.path(), "no.such.key");
1978 assert!(missing.is_none());
1979 }
1980
1981 #[test]
1982 fn merge_ref_already_up_to_date() {
1983 let dir = git_init();
1984 make_commit(dir.path(), "f.txt", "hi");
1985 let branch = {
1986 let out = Cmd::new("git").args(["branch", "--show-current"]).current_dir(dir.path()).output().unwrap();
1987 String::from_utf8_lossy(&out.stdout).trim().to_string()
1988 };
1989 let mut warnings = Vec::new();
1990 let result = merge_ref(dir.path(), &branch, &mut warnings);
1992 assert!(result.is_none());
1993 assert!(warnings.is_empty());
1994 }
1995
1996 #[test]
1997 fn merge_ref_success() {
1998 let dir = git_init();
1999 make_commit(dir.path(), "f.txt", "hi");
2000 git_cmd(dir.path(), &["checkout", "-b", "feature"]);
2001 make_commit(dir.path(), "g.txt", "there");
2002 git_cmd(dir.path(), &["checkout", "main"]);
2003 let mut warnings = Vec::new();
2004 let result = merge_ref(dir.path(), "feature", &mut warnings);
2005 assert!(result.is_some());
2006 assert!(warnings.is_empty());
2007 }
2008
2009 #[test]
2010 fn merge_ref_does_not_speculate_directory_renames() {
2011 let dir = git_init();
2018 let p = dir.path();
2019 std::fs::create_dir_all(p.join("a")).unwrap();
2021 for name in &["1.md", "2.md", "3.md", "4.md"] {
2022 std::fs::write(p.join("a").join(name), "seed\n").unwrap();
2023 }
2024 git_cmd(p, &["add", "a"]);
2025 git_cmd(p, &["commit", "-m", "seed"]);
2026
2027 std::fs::create_dir_all(p.join("b")).unwrap();
2029 for name in &["1.md", "2.md", "3.md", "4.md"] {
2030 std::fs::rename(p.join("a").join(name), p.join("b").join(name)).unwrap();
2031 }
2032 git_cmd(p, &["add", "-A"]);
2033 git_cmd(p, &["commit", "-m", "archive sweep"]);
2034
2035 git_cmd(p, &["checkout", "-b", "feature", "HEAD~1"]);
2037 std::fs::write(p.join("a/new.md"), "active\n").unwrap();
2038 git_cmd(p, &["add", "a/new.md"]);
2039 git_cmd(p, &["commit", "-m", "add active ticket"]);
2040
2041 let mut warnings = Vec::new();
2042 let result = merge_ref(p, "main", &mut warnings);
2043
2044 assert!(result.is_some(), "merge should succeed without directory-rename inference; warnings: {warnings:?}");
2045 assert!(detect_mid_merge_state(p).is_none(), "should not be left mid-merge");
2046 assert!(p.join("a/new.md").exists(), "new.md should stay at a/");
2048 assert!(!p.join("b/new.md").exists(), "new.md must NOT have been speculatively renamed into b/");
2049 }
2050
2051 #[test]
2052 fn merge_ref_clears_stale_unmerged_index_entries() {
2053 let dir = git_init();
2057 let p = dir.path();
2058 make_commit(p, "f.txt", "hi");
2059
2060 git_cmd(p, &["checkout", "-b", "other"]);
2063 make_commit(p, "g.txt", "there");
2064 git_cmd(p, &["checkout", "main"]);
2065
2066 std::fs::write(p.join("conflict.md"), "main\n").unwrap();
2068 git_cmd(p, &["add", "conflict.md"]);
2069 git_cmd(p, &["commit", "-m", "main version"]);
2070
2071 git_cmd(p, &["checkout", "-b", "feature", "HEAD~1"]);
2072 std::fs::write(p.join("conflict.md"), "feature\n").unwrap();
2073 git_cmd(p, &["add", "conflict.md"]);
2074 git_cmd(p, &["commit", "-m", "feature version"]);
2075
2076 git_cmd(p, &["checkout", "main"]);
2077 let _ = Cmd::new("git")
2078 .args(["-C", &p.to_string_lossy(), "merge", "feature", "--no-edit"])
2079 .output();
2080 let _ = std::fs::remove_file(p.join(".git/MERGE_HEAD"));
2082 let _ = std::fs::remove_file(p.join(".git/MERGE_MSG"));
2083
2084 let pre = String::from_utf8_lossy(
2085 &Cmd::new("git").args(["-C", &p.to_string_lossy(), "ls-files", "-u"])
2086 .output().unwrap().stdout
2087 ).to_string();
2088 assert!(!pre.trim().is_empty(), "precondition: unmerged index entries should be present; got: {pre:?}");
2089
2090 let mut warnings = Vec::new();
2092 let result = merge_ref(p, "other", &mut warnings);
2093
2094 assert!(result.is_some(), "merge should succeed after preflight; warnings: {warnings:?}");
2095 assert!(
2096 warnings.iter().any(|w| w.contains("stale unmerged index")),
2097 "expected stale-entry warning; got: {warnings:?}"
2098 );
2099 }
2100
2101 #[test]
2102 fn merge_ref_conflict_aborts_and_warns() {
2103 let dir = git_init();
2104 let p = dir.path();
2105 make_commit(p, "f.txt", "main version\n");
2108 git_cmd(p, &["checkout", "-b", "feature"]);
2109 std::fs::write(p.join("f.txt"), "feature version\n").unwrap();
2110 git_cmd(p, &["add", "f.txt"]);
2111 git_cmd(p, &["commit", "-m", "feature change"]);
2112 git_cmd(p, &["checkout", "main"]);
2113 std::fs::write(p.join("f.txt"), "main change\n").unwrap();
2114 git_cmd(p, &["add", "f.txt"]);
2115 git_cmd(p, &["commit", "-m", "main change"]);
2116
2117 let mut warnings = Vec::new();
2118 let result = merge_ref(p, "feature", &mut warnings);
2119
2120 assert!(result.is_none(), "merge should report failure");
2121 assert!(
2122 warnings.iter().any(|w| w.contains("merge feature failed")),
2123 "expected merge-failure warning; got: {warnings:?}"
2124 );
2125 assert!(
2127 detect_mid_merge_state(p).is_none(),
2128 "merge_ref must abort on conflict so MERGE_HEAD does not persist"
2129 );
2130 }
2131
2132 #[test]
2133 fn detect_mid_merge_none_on_clean_repo() {
2134 let dir = git_init();
2135 make_commit(dir.path(), "f.txt", "hi");
2136 assert!(detect_mid_merge_state(dir.path()).is_none());
2137 }
2138
2139 #[test]
2140 fn detect_mid_merge_on_merge_head() {
2141 let dir = git_init();
2142 make_commit(dir.path(), "f.txt", "hi");
2143 std::fs::write(dir.path().join(".git/MERGE_HEAD"), "abc").unwrap();
2144 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::Merge)));
2145 }
2146
2147 #[test]
2148 fn detect_mid_merge_on_rebase_merge() {
2149 let dir = git_init();
2150 make_commit(dir.path(), "f.txt", "hi");
2151 std::fs::create_dir(dir.path().join(".git/rebase-merge")).unwrap();
2152 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseMerge)));
2153 }
2154
2155 #[test]
2156 fn detect_mid_merge_on_rebase_apply() {
2157 let dir = git_init();
2158 make_commit(dir.path(), "f.txt", "hi");
2159 std::fs::create_dir(dir.path().join(".git/rebase-apply")).unwrap();
2160 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseApply)));
2161 }
2162
2163 #[test]
2164 fn detect_mid_merge_on_cherry_pick() {
2165 let dir = git_init();
2166 make_commit(dir.path(), "f.txt", "hi");
2167 std::fs::write(dir.path().join(".git/CHERRY_PICK_HEAD"), "abc").unwrap();
2168 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::CherryPick)));
2169 }
2170
2171 #[test]
2172 fn is_file_tracked_tracked_and_untracked() {
2173 let dir = git_init();
2174 make_commit(dir.path(), "tracked.txt", "hi");
2175 assert!(is_file_tracked(dir.path(), "tracked.txt"));
2176 std::fs::write(dir.path().join("untracked.txt"), "new").unwrap();
2177 assert!(!is_file_tracked(dir.path(), "untracked.txt"));
2178 }
2179
2180 #[test]
2181 fn check_leaked_files_detects_overlap() {
2182 let dir = git_init();
2183 let p = dir.path();
2184 std::fs::create_dir_all(p.join("src")).unwrap();
2185 std::fs::write(p.join("src/foo.rs"), "original").unwrap();
2186 git_cmd(p, &["add", "src/foo.rs"]);
2187 git_cmd(p, &["commit", "-m", "add foo"]);
2188
2189 git_cmd(p, &["checkout", "-b", "ticket/overlap-test"]);
2190 std::fs::write(p.join("src/foo.rs"), "ticket-change").unwrap();
2191 git_cmd(p, &["add", "src/foo.rs"]);
2192 git_cmd(p, &["commit", "-m", "ticket: change foo"]);
2193 git_cmd(p, &["checkout", "main"]);
2194
2195 std::fs::write(p.join("src/foo.rs"), "leaked").unwrap();
2197
2198 let leaked = check_leaked_files(p, "ticket/overlap-test", "main").unwrap();
2199 assert_eq!(leaked, vec!["src/foo.rs".to_string()]);
2200 }
2201
2202 #[test]
2203 fn check_leaked_files_no_overlap() {
2204 let dir = git_init();
2205 let p = dir.path();
2206 std::fs::create_dir_all(p.join("src")).unwrap();
2207 std::fs::write(p.join("src/foo.rs"), "original").unwrap();
2208 std::fs::write(p.join("src/bar.rs"), "bar").unwrap();
2209 git_cmd(p, &["add", "src/foo.rs", "src/bar.rs"]);
2210 git_cmd(p, &["commit", "-m", "add foo and bar"]);
2211
2212 git_cmd(p, &["checkout", "-b", "ticket/no-overlap"]);
2214 std::fs::write(p.join("src/foo.rs"), "ticket-change").unwrap();
2215 git_cmd(p, &["add", "src/foo.rs"]);
2216 git_cmd(p, &["commit", "-m", "ticket: change foo"]);
2217 git_cmd(p, &["checkout", "main"]);
2218
2219 std::fs::write(p.join("src/bar.rs"), "dirty").unwrap();
2221
2222 let leaked = check_leaked_files(p, "ticket/no-overlap", "main").unwrap();
2223 assert!(leaked.is_empty(), "no overlap expected; got {leaked:?}");
2224 }
2225
2226 #[test]
2227 fn check_leaked_files_detects_untracked_overlap() {
2228 let dir = git_init();
2229 let p = dir.path();
2230 make_commit(p, "existing.rs", "base");
2231
2232 git_cmd(p, &["checkout", "-b", "ticket/untracked-overlap"]);
2234 std::fs::create_dir_all(p.join("src")).unwrap();
2235 std::fs::write(p.join("src/new.rs"), "new file").unwrap();
2236 git_cmd(p, &["add", "src/new.rs"]);
2237 git_cmd(p, &["commit", "-m", "ticket: add new file"]);
2238 git_cmd(p, &["checkout", "main"]);
2239
2240 std::fs::create_dir_all(p.join("src")).unwrap();
2242 std::fs::write(p.join("src/new.rs"), "leaked untracked").unwrap();
2243
2244 let leaked = check_leaked_files(p, "ticket/untracked-overlap", "main").unwrap();
2245 assert_eq!(leaked, vec!["src/new.rs".to_string()]);
2246 }
2247
2248 fn commit_file(dir: &Path, name: &str, content: &str) {
2252 std::fs::write(dir.join(name), content).unwrap();
2253 git_cmd(dir, &["add", name]);
2254 git_cmd(dir, &["commit", "-m", &format!("add {name}")]);
2255 }
2256
2257 #[test]
2260 fn content_merged_into_main_regular_merge_with_state_commit() {
2261 let dir = git_init();
2262 let p = dir.path();
2263
2264 commit_file(p, "README", "base");
2266
2267 git_cmd(p, &["checkout", "-b", "ticket/foo"]);
2269 std::fs::create_dir_all(p.join("src")).unwrap();
2270 commit_file(p, "src/lib.rs", "impl");
2271
2272 git_cmd(p, &["checkout", "main"]);
2274 git_cmd(p, &["merge", "--no-ff", "ticket/foo", "-m", "Merge ticket/foo"]);
2275
2276 git_cmd(p, &["checkout", "ticket/foo"]);
2278 std::fs::create_dir_all(p.join("tickets")).unwrap();
2279 commit_file(p, "tickets/foo.md", "state: implemented");
2280
2281 let result = content_merged_into_main(p, "main", "ticket/foo", "tickets").unwrap();
2283 assert!(result, "should detect that content was merged despite trailing state commit");
2284 }
2285
2286 #[test]
2289 fn content_merged_into_main_squash_merge_with_state_commit() {
2290 let dir = git_init();
2291 let p = dir.path();
2292
2293 commit_file(p, "README", "base");
2294
2295 git_cmd(p, &["checkout", "-b", "ticket/bar"]);
2297 std::fs::create_dir_all(p.join("src")).unwrap();
2298 commit_file(p, "src/lib.rs", "impl");
2299
2300 git_cmd(p, &["checkout", "main"]);
2302 git_cmd(p, &["merge", "--squash", "ticket/bar"]);
2303 git_cmd(p, &["commit", "-m", "Squash ticket/bar"]);
2304
2305 git_cmd(p, &["checkout", "ticket/bar"]);
2307 std::fs::create_dir_all(p.join("tickets")).unwrap();
2308 commit_file(p, "tickets/bar.md", "state: implemented");
2309
2310 let result = content_merged_into_main(p, "main", "ticket/bar", "tickets").unwrap();
2311 assert!(result, "should detect squash-merged content despite trailing state commit");
2312 }
2313
2314 #[test]
2317 fn content_merged_into_main_returns_false_when_ancestor() {
2318 let dir = git_init();
2319 let p = dir.path();
2320 commit_file(p, "README", "base");
2321 git_cmd(p, &["checkout", "-b", "ticket/anc"]);
2323 git_cmd(p, &["checkout", "main"]);
2325 let result = content_merged_into_main(p, "main", "ticket/anc", "tickets").unwrap();
2326 assert!(!result);
2327 }
2328
2329 #[test]
2331 fn content_merged_into_main_not_detected_when_non_ticket_file_modified_after_merge() {
2332 let dir = git_init();
2333 let p = dir.path();
2334 commit_file(p, "README", "base");
2335
2336 git_cmd(p, &["checkout", "-b", "ticket/extra"]);
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", "--squash", "ticket/extra"]);
2344 git_cmd(p, &["commit", "-m", "Squash ticket/extra"]);
2345
2346 git_cmd(p, &["checkout", "ticket/extra"]);
2348 std::fs::create_dir_all(p.join("tickets")).unwrap();
2349 commit_file(p, "tickets/extra.md", "state: implemented");
2350 commit_file(p, "src/extra.rs", "extra code");
2353
2354 let result = content_merged_into_main(p, "main", "ticket/extra", "tickets").unwrap();
2355 assert!(!result, "branch with non-ticket changes after merge must not be detected");
2356 }
2357
2358 #[test]
2361 fn content_merged_into_main_all_ticket_only_commits_returns_false() {
2362 let dir = git_init();
2363 let p = dir.path();
2364 commit_file(p, "README", "base");
2365
2366 git_cmd(p, &["checkout", "-b", "ticket/ticketonly"]);
2368 std::fs::create_dir_all(p.join("tickets")).unwrap();
2369 commit_file(p, "tickets/ticketonly.md", "state: new");
2370 git_cmd(p, &["checkout", "main"]);
2371
2372 let result = content_merged_into_main(p, "main", "ticket/ticketonly", "tickets").unwrap();
2373 assert!(!result, "all-ticket-only commits should return false");
2374 }
2375
2376 #[test]
2383 fn merged_into_main_detects_local_regular_merge_when_remote_deleted() {
2384 let dir = git_init();
2385 let p = dir.path();
2386 make_commit(p, "f.txt", "base");
2387
2388 git_cmd(p, &["checkout", "-b", "ticket/foo"]);
2390 std::fs::write(p.join("f.txt"), "ticket-change").unwrap();
2391 git_cmd(p, &["add", "f.txt"]);
2392 git_cmd(p, &["commit", "-m", "ticket: change"]);
2393
2394 git_cmd(p, &["checkout", "main"]);
2396 git_cmd(p, &["merge", "--no-ff", "ticket/foo", "-m", "Merge ticket/foo"]);
2397
2398 let main_sha = run(p, &["rev-parse", "main"]).unwrap();
2401 Cmd::new("git")
2402 .args(["update-ref", "refs/remotes/origin/main", main_sha.trim()])
2403 .current_dir(p)
2404 .status()
2405 .unwrap();
2406 let merged = merged_into_main(p, "main").unwrap();
2409 assert!(
2410 merged.iter().any(|b| b == "ticket/foo"),
2411 "expected ticket/foo in merged set; got {merged:?}"
2412 );
2413 }
2414
2415 fn git_init_with_remote() -> (TempDir, TempDir) {
2420 let bare = tempfile::tempdir().unwrap();
2422 Cmd::new("git")
2423 .args(["init", "--bare", "-q"])
2424 .current_dir(bare.path())
2425 .status()
2426 .unwrap();
2427
2428 let local = tempfile::tempdir().unwrap();
2430 let p = local.path();
2431 Cmd::new("git")
2432 .args(["clone", "-q", &bare.path().to_string_lossy(), "."])
2433 .current_dir(p)
2434 .env("GIT_AUTHOR_NAME", "test")
2435 .env("GIT_AUTHOR_EMAIL", "t@t.com")
2436 .env("GIT_COMMITTER_NAME", "test")
2437 .env("GIT_COMMITTER_EMAIL", "t@t.com")
2438 .status()
2439 .unwrap();
2440 git_cmd(p, &["config", "user.name", "test"]);
2441 git_cmd(p, &["config", "user.email", "t@t.com"]);
2442
2443 (bare, local)
2444 }
2445
2446 #[test]
2447 fn read_from_branch_with_class_behind_returns_origin_content() {
2448 let (bare, local) = git_init_with_remote();
2449 let p = local.path();
2450
2451 make_commit(p, "README", "base");
2453 git_cmd(p, &["push", "origin", "main"]);
2454
2455 git_cmd(p, &["checkout", "-b", "ticket/abc"]);
2457 make_commit(p, "tickets/abc.md", "state: ready\n");
2458 git_cmd(p, &["push", "origin", "ticket/abc"]);
2459
2460 let remote2 = tempfile::tempdir().unwrap();
2462 let r2 = remote2.path();
2463 Cmd::new("git")
2464 .args(["clone", "-q", &bare.path().to_string_lossy(), "."])
2465 .current_dir(r2)
2466 .env("GIT_AUTHOR_NAME", "test")
2467 .env("GIT_AUTHOR_EMAIL", "t@t.com")
2468 .env("GIT_COMMITTER_NAME", "test")
2469 .env("GIT_COMMITTER_EMAIL", "t@t.com")
2470 .status()
2471 .unwrap();
2472 git_cmd(r2, &["config", "user.name", "test"]);
2473 git_cmd(r2, &["config", "user.email", "t@t.com"]);
2474 git_cmd(r2, &["checkout", "ticket/abc"]);
2475 make_commit(r2, "tickets/abc.md", "state: in_progress\n");
2476 git_cmd(r2, &["push", "origin", "ticket/abc"]);
2477
2478 git_cmd(p, &["fetch", "--all", "--quiet"]);
2480
2481 let (content, class) = read_from_branch_with_class(p, "ticket/abc", "tickets/abc.md").unwrap();
2483 assert!(
2484 matches!(class, BranchClass::Behind),
2485 "expected Behind; got something else"
2486 );
2487 assert!(
2488 content.contains("in_progress"),
2489 "expected origin content 'in_progress'; got: {content:?}"
2490 );
2491 }
2492
2493 #[test]
2494 fn read_from_branch_with_class_ahead_returns_local_content() {
2495 let (_bare, local) = git_init_with_remote();
2496 let p = local.path();
2497
2498 make_commit(p, "README", "base");
2499 git_cmd(p, &["push", "origin", "main"]);
2500
2501 git_cmd(p, &["checkout", "-b", "ticket/xyz"]);
2502 make_commit(p, "tickets/xyz.md", "state: ready\n");
2503 git_cmd(p, &["push", "origin", "ticket/xyz"]);
2504
2505 make_commit(p, "tickets/xyz.md", "state: in_progress\n");
2507
2508 git_cmd(p, &["fetch", "--all", "--quiet"]);
2510
2511 let (content, class) = read_from_branch_with_class(p, "ticket/xyz", "tickets/xyz.md").unwrap();
2512 assert!(
2513 matches!(class, BranchClass::Ahead),
2514 "expected Ahead"
2515 );
2516 assert!(
2517 content.contains("in_progress"),
2518 "expected local content; got: {content:?}"
2519 );
2520 }
2521
2522 #[test]
2523 fn read_from_branch_with_class_equal_returns_content() {
2524 let (_bare, local) = git_init_with_remote();
2525 let p = local.path();
2526
2527 make_commit(p, "README", "base");
2528 git_cmd(p, &["push", "origin", "main"]);
2529
2530 git_cmd(p, &["checkout", "-b", "ticket/eq"]);
2531 make_commit(p, "tickets/eq.md", "state: ready\n");
2532 git_cmd(p, &["push", "origin", "ticket/eq"]);
2533
2534 let (content, class) = read_from_branch_with_class(p, "ticket/eq", "tickets/eq.md").unwrap();
2535 assert!(
2536 matches!(class, BranchClass::Equal),
2537 "expected Equal"
2538 );
2539 assert!(content.contains("ready"), "expected ready content; got: {content:?}");
2540 }
2541
2542 #[test]
2545 fn is_branch_content_merged_regular_merge_returns_true() {
2546 let dir = git_init();
2547 let p = dir.path();
2548 make_commit(p, "README", "base");
2549 git_cmd(p, &["checkout", "-b", "epic/aa000001-feature"]);
2551 make_commit(p, "feature.md", "feature");
2552 git_cmd(p, &["checkout", "main"]);
2553 git_cmd(p, &["merge", "--no-ff", "epic/aa000001-feature", "-m", "merge epic"]);
2555 assert!(is_branch_content_merged(p, "main", "epic/aa000001-feature").unwrap());
2556 }
2557
2558 #[test]
2559 fn is_branch_content_merged_squash_merge_returns_true() {
2560 let dir = git_init();
2561 let p = dir.path();
2562 make_commit(p, "README", "base");
2563 git_cmd(p, &["checkout", "-b", "epic/bb000002-feature"]);
2565 make_commit(p, "feature.md", "feature");
2566 git_cmd(p, &["checkout", "main"]);
2567 git_cmd(p, &["merge", "--squash", "epic/bb000002-feature"]);
2569 git_cmd(p, &["commit", "-m", "squash merge epic"]);
2570 assert!(is_branch_content_merged(p, "main", "epic/bb000002-feature").unwrap());
2571 }
2572
2573 #[test]
2574 fn is_branch_content_merged_unmerged_returns_false() {
2575 let dir = git_init();
2576 let p = dir.path();
2577 make_commit(p, "README", "base");
2578 git_cmd(p, &["checkout", "-b", "epic/cc000003-feature"]);
2579 make_commit(p, "feature.md", "feature");
2580 git_cmd(p, &["checkout", "main"]);
2581 assert!(!is_branch_content_merged(p, "main", "epic/cc000003-feature").unwrap());
2583 }
2584
2585 #[test]
2586 fn is_branch_content_merged_no_remote_falls_back_to_local() {
2587 let dir = git_init();
2589 let p = dir.path();
2590 make_commit(p, "README", "base");
2591 git_cmd(p, &["checkout", "-b", "epic/dd000004-feature"]);
2592 make_commit(p, "feature.md", "feature");
2593 git_cmd(p, &["checkout", "main"]);
2594 git_cmd(p, &["merge", "--no-ff", "epic/dd000004-feature", "-m", "merge epic"]);
2595 assert!(is_branch_content_merged(p, "main", "epic/dd000004-feature").unwrap());
2597 }
2598
2599 #[test]
2600 fn is_branch_content_merged_prefers_origin_when_present() {
2601 let (bare, local) = git_init_with_remote();
2602 let p = local.path();
2603 make_commit(p, "README", "base");
2604 git_cmd(p, &["push", "origin", "main"]);
2605 git_cmd(p, &["checkout", "-b", "epic/ee000005-feature"]);
2607 make_commit(p, "feature.md", "feature");
2608 git_cmd(p, &["push", "origin", "epic/ee000005-feature"]);
2609 git_cmd(p, &["checkout", "main"]);
2610 git_cmd(p, &["merge", "--no-ff", "epic/ee000005-feature", "-m", "merge epic"]);
2612 git_cmd(p, &["push", "origin", "main"]);
2613 assert!(is_branch_content_merged(p, "main", "epic/ee000005-feature").unwrap());
2615 drop(bare); }
2617
2618 #[test]
2619 fn read_from_branch_with_class_remote_only_returns_origin_content() {
2620 let (bare, local) = git_init_with_remote();
2621 let p = local.path();
2622
2623 make_commit(p, "README", "base");
2624 git_cmd(p, &["push", "origin", "main"]);
2625
2626 let remote2 = tempfile::tempdir().unwrap();
2628 let r2 = remote2.path();
2629 Cmd::new("git")
2630 .args(["clone", "-q", &bare.path().to_string_lossy(), "."])
2631 .current_dir(r2)
2632 .env("GIT_AUTHOR_NAME", "test")
2633 .env("GIT_AUTHOR_EMAIL", "t@t.com")
2634 .env("GIT_COMMITTER_NAME", "test")
2635 .env("GIT_COMMITTER_EMAIL", "t@t.com")
2636 .status()
2637 .unwrap();
2638 git_cmd(r2, &["config", "user.name", "test"]);
2639 git_cmd(r2, &["config", "user.email", "t@t.com"]);
2640 git_cmd(r2, &["checkout", "-b", "ticket/ro"]);
2641 make_commit(r2, "tickets/ro.md", "state: ready\n");
2642 git_cmd(r2, &["push", "origin", "ticket/ro"]);
2643
2644 git_cmd(p, &["fetch", "--all", "--quiet"]);
2646
2647 let (content, class) = read_from_branch_with_class(p, "ticket/ro", "tickets/ro.md").unwrap();
2648 assert!(
2649 matches!(class, BranchClass::RemoteOnly),
2650 "expected RemoteOnly"
2651 );
2652 assert!(content.contains("ready"), "expected ready content; got: {content:?}");
2653 }
2654}