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])
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])
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])?;
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 enum BranchClass {
800 Equal,
801 Behind,
802 Ahead,
803 Diverged,
804 RemoteOnly,
806 NoRemote,
808}
809
810pub fn classify_branch(root: &Path, local: &str, remote: &str) -> BranchClass {
818 let local_sha = match run(root, &["rev-parse", local]) {
819 Ok(s) => s,
820 Err(_) => {
821 return if run(root, &["rev-parse", remote]).is_ok() {
825 BranchClass::RemoteOnly
826 } else {
827 BranchClass::NoRemote
828 };
829 }
830 };
831 let remote_sha = match run(root, &["rev-parse", remote]) {
832 Ok(s) => s,
833 Err(_) => return BranchClass::NoRemote,
834 };
835
836 if local_sha == remote_sha {
837 return BranchClass::Equal;
838 }
839
840 let local_is_ancestor_of_remote = is_ancestor(root, local, remote);
843
844 let remote_is_ancestor_of_local = is_ancestor(root, remote, local);
847
848 match (local_is_ancestor_of_remote, remote_is_ancestor_of_local) {
849 (true, false) => BranchClass::Behind, (false, true) => BranchClass::Ahead, (false, false) => BranchClass::Diverged, (true, true) => BranchClass::Equal, }
854}
855
856pub fn sync_default_branch(root: &Path, default: &str, warnings: &mut Vec<String>) -> bool {
881 let remote = format!("origin/{default}");
882 match classify_branch(root, default, &remote) {
883 BranchClass::Equal => {
884 }
886
887 BranchClass::Behind => {
888 let wt = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
891 if run(&wt, &["merge", "--ff-only", &remote]).is_err() {
892 let msg = crate::sync_guidance::MAIN_BEHIND_DIRTY_OVERLAP
896 .replace("<default>", default);
897 warnings.push(msg);
898 }
899 }
900
901 BranchClass::Ahead => {
902 let count = run(root, &["rev-list", "--count", &format!("{remote}..{default}")])
905 .ok()
906 .and_then(|s| s.trim().parse::<u64>().ok())
907 .unwrap_or(0);
908 let msg = crate::sync_guidance::MAIN_AHEAD
909 .replace("<default>", default)
910 .replace("<remote>", &remote)
911 .replace("<count>", &count.to_string())
912 .replace("<commits>", if count == 1 { "commit" } else { "commits" });
913 warnings.push(msg);
914 return true;
915 }
916
917 BranchClass::Diverged => {
918 let wt = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
921 let guidance = if is_worktree_dirty(&wt) {
922 crate::sync_guidance::MAIN_DIVERGED_DIRTY.replace("<default>", default)
923 } else {
924 crate::sync_guidance::MAIN_DIVERGED_CLEAN.replace("<default>", default)
925 };
926 warnings.push(guidance);
927 }
928
929 BranchClass::RemoteOnly => {
930 }
934
935 BranchClass::NoRemote => {
936 }
940 }
941 false
942}
943
944pub fn fetch_branch(root: &Path, branch: &str) -> anyhow::Result<()> {
945 run(root, &["fetch", "origin", branch]).map(|_| ())
946}
947
948pub fn push_branch(root: &Path, branch: &str) -> anyhow::Result<()> {
949 run(root, &["push", "origin", &format!("{branch}:{branch}")]).map(|_| ())
950}
951
952pub fn push_branch_tracking(root: &Path, branch: &str) -> anyhow::Result<()> {
953 let out = std::process::Command::new("git")
954 .args(["push", "--set-upstream", "origin", &format!("{branch}:{branch}")])
955 .current_dir(root)
956 .output()?;
957 if !out.status.success() {
958 anyhow::bail!("git push failed: {}", String::from_utf8_lossy(&out.stderr).trim());
959 }
960 Ok(())
961}
962
963pub fn has_remote(root: &Path) -> bool {
964 run(root, &["remote", "get-url", "origin"]).is_ok()
965}
966
967pub fn remote_ticket_branches_with_dates(
972 root: &Path,
973) -> Result<Vec<(String, chrono::DateTime<chrono::Utc>)>> {
974 use chrono::{TimeZone, Utc};
975 let out = Command::new("git")
976 .current_dir(root)
977 .args([
978 "for-each-ref",
979 "refs/remotes/origin/ticket/",
980 "--format=%(refname:short) %(creatordate:unix)",
981 ])
982 .output()
983 .context("git for-each-ref failed")?;
984 let stdout = String::from_utf8_lossy(&out.stdout);
985 let mut result = Vec::new();
986 for line in stdout.lines() {
987 let mut parts = line.splitn(2, ' ');
988 let refname = parts.next().unwrap_or("").trim();
989 let ts_str = parts.next().unwrap_or("").trim();
990 let branch = refname.trim_start_matches("origin/");
991 if branch.is_empty() {
992 continue;
993 }
994 if let Ok(ts) = ts_str.parse::<i64>() {
995 if let Some(dt) = Utc.timestamp_opt(ts, 0).single() {
996 result.push((branch.to_string(), dt));
997 }
998 }
999 }
1000 Ok(result)
1001}
1002
1003pub fn list_remote_ticket_branches(root: &Path) -> std::collections::HashSet<String> {
1009 let mut set = std::collections::HashSet::new();
1010 let out = match run(root, &["ls-remote", "--heads", "origin", "ticket/*"]) {
1011 Ok(o) => o,
1012 Err(_) => return set,
1013 };
1014 for line in out.lines() {
1015 if let Some(refname) = line.split('\t').nth(1) {
1016 if let Some(branch) = refname.trim().strip_prefix("refs/heads/") {
1017 set.insert(branch.to_string());
1018 }
1019 }
1020 }
1021 set
1022}
1023
1024pub fn delete_remote_branch(root: &Path, branch: &str) -> Result<()> {
1026 run(root, &["push", "origin", "--delete", branch])
1027 .map(|_| ())
1028 .context("git push origin --delete failed")
1029}
1030
1031pub fn move_files_on_branch(
1036 root: &Path,
1037 branch: &str,
1038 moves: &[(&str, &str, &str)],
1039 message: &str,
1040) -> Result<()> {
1041 if !has_commits(root) {
1042 for (old, new, content) in moves {
1043 let new_path = root.join(new);
1044 if let Some(parent) = new_path.parent() {
1045 std::fs::create_dir_all(parent)?;
1046 }
1047 std::fs::write(&new_path, content)?;
1048 let old_path = root.join(old);
1049 let _ = std::fs::remove_file(&old_path);
1050 }
1051 return Ok(());
1052 }
1053
1054 let do_moves = |wt: &Path| -> Result<()> {
1055 for (old, new, content) in moves {
1056 let new_path = wt.join(new);
1057 if let Some(parent) = new_path.parent() {
1058 std::fs::create_dir_all(parent)?;
1059 }
1060 std::fs::write(&new_path, content)?;
1061 run(wt, &["add", new])?;
1062 run(wt, &["rm", "--force", "--quiet", old])?;
1063 }
1064 run(wt, &["commit", "-m", message])?;
1065 Ok(())
1066 };
1067
1068 if let Some(wt_path) = find_worktree_for_branch(root, branch) {
1069 let remote_ref = format!("origin/{branch}");
1070 if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
1071 let _ = run(&wt_path, &["merge", "--ff-only", &remote_ref]);
1072 }
1073 let result = do_moves(&wt_path);
1074 if result.is_ok() {
1075 crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1076 }
1077 return result;
1078 }
1079
1080 if current_branch(root).ok().as_deref() == Some(branch) {
1081 let result = do_moves(root);
1082 if result.is_ok() {
1083 crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1084 }
1085 return result;
1086 }
1087
1088 let unique = std::time::SystemTime::now()
1089 .duration_since(std::time::UNIX_EPOCH)
1090 .map(|d| d.subsec_nanos())
1091 .unwrap_or(0);
1092 let wt_path = std::env::temp_dir().join(format!(
1093 "apm-{}-{}-{}",
1094 std::process::id(),
1095 unique,
1096 branch.replace('/', "-"),
1097 ));
1098
1099 let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
1100 let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
1101
1102 if has_remote {
1103 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
1104 let _ = run(&wt_path, &["checkout", "-B", branch]);
1105 } else if has_local {
1106 let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
1107 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
1108 let _ = run(&wt_path, &["checkout", "-B", branch]);
1109 } else {
1110 run(root, &["worktree", "add", &wt_path.to_string_lossy(), branch])?;
1111 }
1112
1113 let result = do_moves(&wt_path);
1114 let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
1115 let _ = std::fs::remove_dir_all(&wt_path);
1116 if result.is_ok() {
1117 crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1118 }
1119 result
1120}
1121
1122pub fn merge_branch_into_default(root: &Path, branch: &str, default_branch: &str, warnings: &mut Vec<String>) -> Result<()> {
1123 let _ = run(root, &["fetch", "origin", default_branch]);
1124
1125 let main_root = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
1126 let merge_dir = if current_branch(&main_root).ok().as_deref() == Some(default_branch) {
1127 main_root
1128 } else {
1129 find_worktree_for_branch(root, default_branch).unwrap_or_else(|| root.to_path_buf())
1130 };
1131
1132 if let Err(e) = run(&merge_dir, &["merge", "--no-ff", branch, "--no-edit"]) {
1133 let _ = run(&merge_dir, &["merge", "--abort"]);
1134 anyhow::bail!("merge failed: {e:#}");
1135 }
1136
1137 if has_remote(root) {
1138 if let Err(e) = run(&merge_dir, &["push", "origin", default_branch]) {
1139 warnings.push(format!("warning: push {default_branch} failed: {e:#}"));
1140 }
1141 }
1142 Ok(())
1143}
1144
1145pub 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<()> {
1146 let _ = run(root, &["fetch", "origin", default_branch]);
1147
1148 let main_root = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
1149 let merge_dir = if current_branch(&main_root).ok().as_deref() == Some(default_branch) {
1150 main_root.clone()
1151 } else {
1152 let worktrees_base = main_root.join(&config.worktrees.dir);
1153 ensure_worktree(root, &worktrees_base, default_branch)?
1154 };
1155
1156 let out = std::process::Command::new("git")
1157 .args(["merge", "--no-ff", branch, "--no-edit"])
1158 .current_dir(&merge_dir)
1159 .output()?;
1160
1161 if !out.status.success() {
1162 let _ = run(&merge_dir, &["merge", "--abort"]);
1163 bail!(
1164 "merge conflict — resolve manually and push: {}",
1165 String::from_utf8_lossy(&out.stderr).trim()
1166 );
1167 }
1168
1169 if skip_push {
1170 messages.push(format!("Merged {branch} into {default_branch} (local only)."));
1171 } else {
1172 push_branch(&merge_dir, default_branch)?;
1173 messages.push(format!("Merged {branch} into {default_branch} and pushed to origin."));
1174 }
1175 Ok(())
1176}
1177
1178pub fn pull_default(root: &Path, default_branch: &str, warnings: &mut Vec<String>) -> Result<()> {
1179 let fetch = std::process::Command::new("git")
1180 .args(["fetch", "origin", default_branch])
1181 .current_dir(root)
1182 .output();
1183
1184 match fetch {
1185 Err(e) => {
1186 warnings.push(format!("warning: fetch failed: {e:#}"));
1187 return Ok(());
1188 }
1189 Ok(out) if !out.status.success() => {
1190 warnings.push(format!(
1191 "warning: fetch failed: {}",
1192 String::from_utf8_lossy(&out.stderr).trim()
1193 ));
1194 return Ok(());
1195 }
1196 _ => {}
1197 }
1198
1199 let current = std::process::Command::new("git")
1200 .args(["rev-parse", "--abbrev-ref", "HEAD"])
1201 .current_dir(root)
1202 .output()?;
1203 let current_branch = String::from_utf8_lossy(¤t.stdout).trim().to_string();
1204
1205 let merge_dir = if current_branch == default_branch {
1206 root.to_path_buf()
1207 } else {
1208 find_worktree_for_branch(root, default_branch)
1209 .unwrap_or_else(|| root.to_path_buf())
1210 };
1211
1212 let remote_ref = format!("origin/{default_branch}");
1213 let out = std::process::Command::new("git")
1214 .args(["merge", "--ff-only", &remote_ref])
1215 .current_dir(&merge_dir)
1216 .output()?;
1217
1218 if !out.status.success() {
1219 warnings.push(format!("warning: could not fast-forward {default_branch} — pull manually"));
1220 }
1221
1222 Ok(())
1223}
1224
1225pub fn is_worktree_dirty(path: &Path) -> bool {
1226 let Ok(out) = Command::new("git")
1227 .args(["-C", &path.to_string_lossy(), "status", "--porcelain"])
1228 .output()
1229 else {
1230 return false;
1231 };
1232 !out.stdout.is_empty()
1233}
1234
1235pub fn is_worktree_dirty_for_sync(path: &Path) -> bool {
1238 const TEMP_FILES: &[&str] = &[".apm-worker.log", ".apm-worker.pid"];
1239 let Ok(out) = Command::new("git")
1240 .args(["-C", &path.to_string_lossy(), "status", "--porcelain"])
1241 .output()
1242 else {
1243 return false;
1244 };
1245 let stdout = String::from_utf8_lossy(&out.stdout);
1246 stdout.lines().filter(|l| !l.is_empty()).any(|l| {
1247 let fname = l.get(3..).unwrap_or("").trim();
1249 !TEMP_FILES.contains(&fname)
1250 })
1251}
1252
1253fn dirty_files_for_sync(path: &Path) -> Vec<String> {
1257 const TEMP_FILES: &[&str] = &[".apm-worker.log", ".apm-worker.pid"];
1258 let Ok(out) = Command::new("git")
1259 .args(["-C", &path.to_string_lossy(), "status", "--porcelain"])
1260 .output()
1261 else {
1262 return Vec::new();
1263 };
1264 let stdout = String::from_utf8_lossy(&out.stdout);
1265 stdout
1266 .lines()
1267 .filter(|l| !l.is_empty())
1268 .filter_map(|l| {
1269 let fname = l.get(3..)?.trim();
1270 if TEMP_FILES.contains(&fname) { None } else { Some(fname.to_string()) }
1271 })
1272 .collect()
1273}
1274
1275pub struct WorktreeSyncResult {
1277 pub fast_forwarded: Vec<(PathBuf, String)>,
1279 pub skipped_dirty: Vec<(PathBuf, String, Vec<String>)>,
1281 pub skipped_ahead: Vec<(PathBuf, String)>,
1283 pub skipped_diverged: Vec<(PathBuf, String)>,
1285}
1286
1287pub fn sync_checked_out_worktrees(root: &Path, warnings: &mut Vec<String>) -> WorktreeSyncResult {
1300 let mut result = WorktreeSyncResult {
1301 fast_forwarded: Vec::new(),
1302 skipped_dirty: Vec::new(),
1303 skipped_ahead: Vec::new(),
1304 skipped_diverged: Vec::new(),
1305 };
1306
1307 let worktrees = match crate::worktree::list_ticket_worktrees(root) {
1308 Ok(w) => w,
1309 Err(_) => return result,
1310 };
1311
1312 for (wt_path, branch) in worktrees {
1313 let local_ref = format!("refs/heads/{branch}");
1314 let remote_ref = format!("origin/{branch}");
1315 match classify_branch(root, &local_ref, &remote_ref) {
1316 BranchClass::Behind => {
1317 if is_worktree_dirty_for_sync(&wt_path) {
1318 let dirty = dirty_files_for_sync(&wt_path);
1319 result.skipped_dirty.push((wt_path, branch, dirty));
1320 } else {
1321 match run(&wt_path, &["merge", "--ff-only", &remote_ref]) {
1322 Ok(_) => result.fast_forwarded.push((wt_path, branch)),
1323 Err(e) => warnings.push(format!(
1324 "warning: fast-forward {} failed: {e:#}",
1325 wt_path.display()
1326 )),
1327 }
1328 }
1329 }
1330 BranchClass::Ahead => {
1331 result.skipped_ahead.push((wt_path, branch));
1332 }
1333 BranchClass::Diverged => {
1334 result.skipped_diverged.push((wt_path, branch));
1335 }
1336 BranchClass::Equal | BranchClass::NoRemote | BranchClass::RemoteOnly => {
1337 }
1339 }
1340 }
1341
1342 result
1343}
1344
1345pub fn local_branch_exists(root: &Path, branch: &str) -> bool {
1346 Command::new("git")
1347 .args(["-C", &root.to_string_lossy(), "rev-parse", "--verify", &format!("refs/heads/{branch}")])
1348 .output()
1349 .map(|o| o.status.success())
1350 .unwrap_or(false)
1351}
1352
1353pub fn delete_local_branch(root: &Path, branch: &str, warnings: &mut Vec<String>) {
1354 let Ok(out) = Command::new("git")
1355 .args(["-C", &root.to_string_lossy(), "branch", "-D", branch])
1356 .output()
1357 else {
1358 warnings.push(format!("warning: could not delete branch {branch}: command failed"));
1359 return;
1360 };
1361 if !out.status.success() {
1362 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1363 warnings.push(format!("warning: could not delete branch {branch}: {stderr}"));
1364 }
1365}
1366
1367pub fn prune_remote_tracking(root: &Path, branch: &str) {
1368 let _ = Command::new("git")
1369 .args(["-C", &root.to_string_lossy(), "branch", "-dr", &format!("origin/{branch}")])
1370 .output();
1371}
1372
1373pub fn stage_files(root: &Path, files: &[&str]) -> Result<()> {
1374 let mut args = vec!["add"];
1375 args.extend_from_slice(files);
1376 run(root, &args).map(|_| ())
1377}
1378
1379pub fn commit(root: &Path, message: &str) -> Result<()> {
1380 run(root, &["commit", "-m", message]).map(|_| ())
1381}
1382
1383pub fn git_config_get(root: &Path, key: &str) -> Option<String> {
1384 let out = Command::new("git")
1385 .args(["-C", &root.to_string_lossy(), "config", key])
1386 .output()
1387 .ok()?;
1388 if !out.status.success() {
1389 return None;
1390 }
1391 let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
1392 if value.is_empty() { None } else { Some(value) }
1393}
1394
1395fn clear_stale_unmerged_entries(dir: &Path, warnings: &mut Vec<String>) {
1401 let out = match Command::new("git")
1402 .args(["-C", &dir.to_string_lossy(), "ls-files", "-u"])
1403 .output()
1404 {
1405 Ok(o) if o.status.success() => o,
1406 _ => return,
1407 };
1408 let stdout = String::from_utf8_lossy(&out.stdout);
1409 let mut paths: std::collections::HashSet<String> = std::collections::HashSet::new();
1410 for line in stdout.lines() {
1411 if let Some(path) = line.split('\t').nth(1) {
1413 paths.insert(path.to_string());
1414 }
1415 }
1416 if paths.is_empty() {
1417 return;
1418 }
1419 warnings.push(format!(
1420 "warning: clearing {} stale unmerged index entr{} in {} (left by an earlier failed merge)",
1421 paths.len(),
1422 if paths.len() == 1 { "y" } else { "ies" },
1423 dir.display(),
1424 ));
1425 for path in &paths {
1426 let _ = Command::new("git")
1427 .args(["-C", &dir.to_string_lossy(), "reset", "HEAD", "--", path])
1428 .output();
1429 }
1430}
1431
1432pub fn merge_ref(dir: &Path, refname: &str, warnings: &mut Vec<String>) -> Option<String> {
1433 clear_stale_unmerged_entries(dir, warnings);
1438
1439 let out = match Command::new("git")
1446 .args([
1447 "-C", &dir.to_string_lossy(),
1448 "-c", "merge.directoryRenames=false",
1449 "merge", refname, "--no-edit",
1450 ])
1451 .output()
1452 {
1453 Ok(o) => o,
1454 Err(e) => {
1455 warnings.push(format!("warning: merge {refname} failed: {e}"));
1456 return None;
1457 }
1458 };
1459 if out.status.success() {
1460 let stdout = String::from_utf8_lossy(&out.stdout);
1461 if stdout.contains("Already up to date") {
1462 None
1463 } else {
1464 Some(format!("Merged {refname} into branch."))
1465 }
1466 } else {
1467 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1468 warnings.push(format!("warning: merge {refname} failed: {stderr}"));
1469 if detect_mid_merge_state(dir).is_some() {
1475 let abort = Command::new("git")
1476 .args(["-C", &dir.to_string_lossy(), "merge", "--abort"])
1477 .output();
1478 match abort {
1479 Ok(o) if !o.status.success() => {
1480 let aborterr = String::from_utf8_lossy(&o.stderr).trim().to_string();
1481 warnings.push(format!(
1482 "warning: could not abort merge of {refname} in {}: {aborterr}",
1483 dir.display()
1484 ));
1485 }
1486 Err(e) => {
1487 warnings.push(format!(
1488 "warning: could not abort merge of {refname} in {}: {e}",
1489 dir.display()
1490 ));
1491 }
1492 Ok(_) => {}
1493 }
1494 }
1495 None
1496 }
1497}
1498
1499pub fn is_file_tracked(root: &Path, path: &str) -> bool {
1500 Command::new("git")
1501 .args(["ls-files", "--error-unmatch", path])
1502 .current_dir(root)
1503 .stdout(std::process::Stdio::null())
1504 .stderr(std::process::Stdio::null())
1505 .status()
1506 .map(|s| s.success())
1507 .unwrap_or(false)
1508}
1509
1510pub enum MidMergeState {
1514 Merge,
1516 RebaseMerge,
1518 RebaseApply,
1520 CherryPick,
1522}
1523
1524pub fn detect_mid_merge_state(root: &Path) -> Option<MidMergeState> {
1533 let git_dir = root.join(".git");
1534 if git_dir.join("MERGE_HEAD").exists() {
1535 return Some(MidMergeState::Merge);
1536 }
1537 if git_dir.join("rebase-merge").is_dir() {
1538 return Some(MidMergeState::RebaseMerge);
1539 }
1540 if git_dir.join("rebase-apply").is_dir() {
1541 return Some(MidMergeState::RebaseApply);
1542 }
1543 if git_dir.join("CHERRY_PICK_HEAD").exists() {
1544 return Some(MidMergeState::CherryPick);
1545 }
1546 None
1547}
1548
1549pub fn merge_base(root: &Path, ref1: &str, ref2: &str) -> Result<String> {
1551 run(root, &["merge-base", ref1, ref2])
1552}
1553
1554pub fn main_worktree_root(root: &Path) -> Option<PathBuf> {
1555 let out = run(root, &["worktree", "list", "--porcelain"]).ok()?;
1556 out.lines()
1557 .next()
1558 .and_then(|line| line.strip_prefix("worktree "))
1559 .map(PathBuf::from)
1560}
1561
1562pub fn check_leaked_files(
1575 root: &Path,
1576 ticket_branch: &str,
1577 target_branch: &str,
1578) -> Result<Vec<String>> {
1579 let current = Command::new("git")
1581 .args(["rev-parse", "--abbrev-ref", "HEAD"])
1582 .current_dir(root)
1583 .output()?;
1584 let current_branch = String::from_utf8_lossy(¤t.stdout).trim().to_string();
1585
1586 let merge_dir = if current_branch == target_branch {
1587 root.to_path_buf()
1588 } else {
1589 match crate::worktree::find_worktree_for_branch(root, target_branch) {
1590 Some(p) => p,
1591 None => return Ok(vec![]), }
1593 };
1594
1595 let base = match merge_base(root, target_branch, ticket_branch) {
1597 Ok(s) => s.trim().to_string(),
1598 Err(_) => return Ok(vec![]), };
1600 if base.is_empty() {
1601 return Ok(vec![]);
1602 }
1603
1604 let diff_out = Command::new("git")
1607 .args(["diff", "--name-only", &base, ticket_branch])
1608 .current_dir(root)
1609 .output()?;
1610 let ticket_files: std::collections::HashSet<String> =
1611 String::from_utf8_lossy(&diff_out.stdout)
1612 .lines()
1613 .map(|s| s.to_string())
1614 .collect();
1615
1616 let status_out = Command::new("git")
1625 .args(["status", "--porcelain", "--untracked-files=all"])
1626 .current_dir(&merge_dir)
1627 .output()?;
1628 let dirty_files: std::collections::HashSet<String> =
1629 String::from_utf8_lossy(&status_out.stdout)
1630 .lines()
1631 .filter_map(|line| {
1632 if line.len() < 3 {
1633 return None;
1634 }
1635 let x = line.as_bytes()[0] as char;
1636 let y = line.as_bytes()[1] as char;
1637 if x == 'R' || x == 'C' || y == 'R' || y == 'C' {
1639 return None;
1640 }
1641 Some(line[3..].to_string())
1642 })
1643 .collect();
1644
1645 let mut overlap: Vec<String> = ticket_files
1647 .intersection(&dirty_files)
1648 .cloned()
1649 .collect();
1650 overlap.sort();
1651 Ok(overlap)
1652}
1653
1654#[cfg(test)]
1655mod tests {
1656 use super::*;
1657 use std::process::Command as Cmd;
1658 use tempfile::TempDir;
1659
1660 fn git_init() -> TempDir {
1661 let dir = tempfile::tempdir().unwrap();
1662 let p = dir.path();
1663 Cmd::new("git").args(["init", "-q", "-b", "main"]).current_dir(p).status().unwrap();
1664 Cmd::new("git").args(["config", "user.email", "t@t.com"]).current_dir(p).status().unwrap();
1665 Cmd::new("git").args(["config", "user.name", "test"]).current_dir(p).status().unwrap();
1666 dir
1667 }
1668
1669 fn git_cmd(dir: &Path, args: &[&str]) {
1670 Cmd::new("git")
1671 .args(args)
1672 .current_dir(dir)
1673 .env("GIT_AUTHOR_NAME", "test")
1674 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1675 .env("GIT_COMMITTER_NAME", "test")
1676 .env("GIT_COMMITTER_EMAIL", "t@t.com")
1677 .status()
1678 .unwrap();
1679 }
1680
1681 fn make_commit(dir: &Path, filename: &str, content: &str) {
1682 let full = dir.join(filename);
1683 if let Some(parent) = full.parent() {
1684 std::fs::create_dir_all(parent).unwrap();
1685 }
1686 std::fs::write(full, content).unwrap();
1687 git_cmd(dir, &["add", filename]);
1688 git_cmd(dir, &["commit", "-m", "init"]);
1689 }
1690
1691 #[test]
1692 fn is_worktree_dirty_clean() {
1693 let dir = git_init();
1694 make_commit(dir.path(), "f.txt", "hi");
1695 assert!(!is_worktree_dirty(dir.path()));
1696 }
1697
1698 #[test]
1699 fn is_worktree_dirty_dirty() {
1700 let dir = git_init();
1701 make_commit(dir.path(), "f.txt", "hi");
1702 std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1703 assert!(is_worktree_dirty(dir.path()));
1704 }
1705
1706 #[test]
1707 fn is_worktree_dirty_for_sync_clean() {
1708 let dir = git_init();
1709 make_commit(dir.path(), "f.txt", "hi");
1710 assert!(!is_worktree_dirty_for_sync(dir.path()));
1711 }
1712
1713 #[test]
1714 fn is_worktree_dirty_for_sync_temp_files_only_is_clean() {
1715 let dir = git_init();
1716 make_commit(dir.path(), "f.txt", "hi");
1717 std::fs::write(dir.path().join(".apm-worker.log"), "log").unwrap();
1719 std::fs::write(dir.path().join(".apm-worker.pid"), "123").unwrap();
1720 assert!(!is_worktree_dirty_for_sync(dir.path()));
1721 assert!(is_worktree_dirty(dir.path()));
1723 }
1724
1725 #[test]
1726 fn is_worktree_dirty_for_sync_real_change_is_dirty() {
1727 let dir = git_init();
1728 make_commit(dir.path(), "f.txt", "hi");
1729 std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1730 assert!(is_worktree_dirty_for_sync(dir.path()));
1731 }
1732
1733 #[test]
1734 fn is_worktree_dirty_for_sync_temp_plus_real_is_dirty() {
1735 let dir = git_init();
1736 make_commit(dir.path(), "f.txt", "hi");
1737 std::fs::write(dir.path().join(".apm-worker.log"), "log").unwrap();
1738 std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1739 assert!(is_worktree_dirty_for_sync(dir.path()));
1740 }
1741
1742 #[test]
1743 fn dirty_files_for_sync_excludes_temp_files() {
1744 let dir = git_init();
1745 make_commit(dir.path(), "f.txt", "hi");
1746 std::fs::write(dir.path().join(".apm-worker.log"), "log").unwrap();
1747 std::fs::write(dir.path().join(".apm-worker.pid"), "123").unwrap();
1748 std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1749 let dirty = dirty_files_for_sync(dir.path());
1750 assert!(dirty.contains(&"f.txt".to_string()), "f.txt should be in dirty; got {dirty:?}");
1751 assert!(!dirty.iter().any(|f| f.contains(".apm-worker")), "temp files should be excluded; got {dirty:?}");
1752 }
1753
1754 #[test]
1755 fn sync_checked_out_worktrees_behind_clean_fast_forwards() {
1756 let origin_tmp = git_init();
1758 let origin = origin_tmp.path();
1759 make_commit(origin, "README", "v1");
1760 git_cmd(origin, &["checkout", "-b", "ticket/test-ff"]);
1762 make_commit(origin, "impl.rs", "v1");
1763 git_cmd(origin, &["checkout", "main"]);
1764
1765 let clone_tmp = tempfile::tempdir().unwrap();
1767 let clone = clone_tmp.path();
1768 Cmd::new("git")
1769 .args(["clone", &origin.to_string_lossy(), &clone.to_string_lossy()])
1770 .env("GIT_AUTHOR_NAME", "test").env("GIT_AUTHOR_EMAIL", "t@t.com")
1771 .env("GIT_COMMITTER_NAME", "test").env("GIT_COMMITTER_EMAIL", "t@t.com")
1772 .status().unwrap();
1773 git_cmd(clone, &["config", "user.email", "t@t.com"]);
1775 git_cmd(clone, &["config", "user.name", "test"]);
1776 let wt_path = clone.join("wt-test-ff");
1778 Cmd::new("git")
1779 .args(["worktree", "add", &wt_path.to_string_lossy(), "ticket/test-ff"])
1780 .current_dir(clone).status().unwrap();
1781
1782 git_cmd(origin, &["checkout", "ticket/test-ff"]);
1784 make_commit(origin, "impl.rs", "v2");
1785 git_cmd(origin, &["checkout", "main"]);
1786 git_cmd(clone, &["fetch", "origin"]);
1788
1789 let mut warnings = Vec::new();
1790 let result = sync_checked_out_worktrees(clone, &mut warnings);
1791
1792 assert_eq!(result.fast_forwarded.len(), 1, "should have fast-forwarded 1 worktree; warnings: {warnings:?}");
1793 assert!(result.skipped_dirty.is_empty());
1794 assert!(result.skipped_ahead.is_empty());
1795 assert!(result.skipped_diverged.is_empty());
1796 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1797
1798 let content = std::fs::read_to_string(wt_path.join("impl.rs")).unwrap();
1800 assert_eq!(content.trim(), "v2", "worktree should have v2 after fast-forward");
1801 }
1802
1803 #[test]
1804 fn sync_checked_out_worktrees_dirty_skips() {
1805 let origin_tmp = git_init();
1806 let origin = origin_tmp.path();
1807 make_commit(origin, "README", "v1");
1808 git_cmd(origin, &["checkout", "-b", "ticket/test-dirty"]);
1809 make_commit(origin, "impl.rs", "v1");
1810 git_cmd(origin, &["checkout", "main"]);
1811
1812 let clone_tmp = tempfile::tempdir().unwrap();
1813 let clone = clone_tmp.path();
1814 Cmd::new("git")
1815 .args(["clone", &origin.to_string_lossy(), &clone.to_string_lossy()])
1816 .env("GIT_AUTHOR_NAME", "test").env("GIT_AUTHOR_EMAIL", "t@t.com")
1817 .env("GIT_COMMITTER_NAME", "test").env("GIT_COMMITTER_EMAIL", "t@t.com")
1818 .status().unwrap();
1819 git_cmd(clone, &["config", "user.email", "t@t.com"]);
1820 git_cmd(clone, &["config", "user.name", "test"]);
1821 let wt_path = clone.join("wt-test-dirty");
1822 Cmd::new("git")
1823 .args(["worktree", "add", &wt_path.to_string_lossy(), "ticket/test-dirty"])
1824 .current_dir(clone).status().unwrap();
1825
1826 git_cmd(origin, &["checkout", "ticket/test-dirty"]);
1828 make_commit(origin, "impl.rs", "v2");
1829 git_cmd(origin, &["checkout", "main"]);
1830 git_cmd(clone, &["fetch", "origin"]);
1831
1832 std::fs::write(wt_path.join("impl.rs"), "local change").unwrap();
1834
1835 let mut warnings = Vec::new();
1836 let result = sync_checked_out_worktrees(clone, &mut warnings);
1837
1838 assert!(result.fast_forwarded.is_empty(), "should not fast-forward dirty worktree");
1839 assert_eq!(result.skipped_dirty.len(), 1);
1840 let (_, _, ref dirty_files) = result.skipped_dirty[0];
1841 assert!(dirty_files.contains(&"impl.rs".to_string()), "impl.rs should be in dirty files; got {dirty_files:?}");
1842 }
1843
1844 #[test]
1845 fn sync_checked_out_worktrees_temp_only_is_clean() {
1846 let origin_tmp = git_init();
1847 let origin = origin_tmp.path();
1848 make_commit(origin, "README", "v1");
1849 git_cmd(origin, &["checkout", "-b", "ticket/test-temponly"]);
1850 make_commit(origin, "impl.rs", "v1");
1851 git_cmd(origin, &["checkout", "main"]);
1852
1853 let clone_tmp = tempfile::tempdir().unwrap();
1854 let clone = clone_tmp.path();
1855 Cmd::new("git")
1856 .args(["clone", &origin.to_string_lossy(), &clone.to_string_lossy()])
1857 .env("GIT_AUTHOR_NAME", "test").env("GIT_AUTHOR_EMAIL", "t@t.com")
1858 .env("GIT_COMMITTER_NAME", "test").env("GIT_COMMITTER_EMAIL", "t@t.com")
1859 .status().unwrap();
1860 git_cmd(clone, &["config", "user.email", "t@t.com"]);
1861 git_cmd(clone, &["config", "user.name", "test"]);
1862 let wt_path = clone.join("wt-test-temponly");
1863 Cmd::new("git")
1864 .args(["worktree", "add", &wt_path.to_string_lossy(), "ticket/test-temponly"])
1865 .current_dir(clone).status().unwrap();
1866
1867 git_cmd(origin, &["checkout", "ticket/test-temponly"]);
1869 make_commit(origin, "impl.rs", "v2");
1870 git_cmd(origin, &["checkout", "main"]);
1871 git_cmd(clone, &["fetch", "origin"]);
1872
1873 std::fs::write(wt_path.join(".apm-worker.log"), "log").unwrap();
1875 std::fs::write(wt_path.join(".apm-worker.pid"), "123").unwrap();
1876
1877 let mut warnings = Vec::new();
1878 let result = sync_checked_out_worktrees(clone, &mut warnings);
1879
1880 assert_eq!(result.fast_forwarded.len(), 1, "temp-only worktree should be fast-forwarded; warnings: {warnings:?}");
1881 assert!(result.skipped_dirty.is_empty());
1882 }
1883
1884 #[test]
1885 fn sync_checked_out_worktrees_no_worktrees_returns_empty() {
1886 let dir = git_init();
1887 make_commit(dir.path(), "f.txt", "hi");
1888 let mut warnings = Vec::new();
1889 let result = sync_checked_out_worktrees(dir.path(), &mut warnings);
1890 assert!(result.fast_forwarded.is_empty());
1891 assert!(result.skipped_dirty.is_empty());
1892 assert!(result.skipped_ahead.is_empty());
1893 assert!(result.skipped_diverged.is_empty());
1894 assert!(warnings.is_empty());
1895 }
1896
1897 #[test]
1898 fn local_branch_exists_present_and_absent() {
1899 let dir = git_init();
1900 make_commit(dir.path(), "f.txt", "hi");
1901 let on_main = local_branch_exists(dir.path(), "main");
1902 let on_master = local_branch_exists(dir.path(), "master");
1903 assert!(on_main || on_master);
1904 assert!(!local_branch_exists(dir.path(), "no-such-branch"));
1905 }
1906
1907 #[test]
1908 fn delete_local_branch_success() {
1909 let dir = git_init();
1910 make_commit(dir.path(), "f.txt", "hi");
1911 git_cmd(dir.path(), &["branch", "to-delete"]);
1912 let mut warnings = Vec::new();
1913 delete_local_branch(dir.path(), "to-delete", &mut warnings);
1914 assert!(warnings.is_empty());
1915 assert!(!local_branch_exists(dir.path(), "to-delete"));
1916 }
1917
1918 #[test]
1919 fn delete_local_branch_failure_adds_warning() {
1920 let dir = git_init();
1921 make_commit(dir.path(), "f.txt", "hi");
1922 let mut warnings = Vec::new();
1923 delete_local_branch(dir.path(), "nonexistent", &mut warnings);
1924 assert!(!warnings.is_empty());
1925 assert!(warnings[0].contains("warning:"));
1926 }
1927
1928 #[test]
1929 fn prune_remote_tracking_no_panic() {
1930 let dir = git_init();
1931 make_commit(dir.path(), "f.txt", "hi");
1932 prune_remote_tracking(dir.path(), "nonexistent-branch");
1934 }
1935
1936 #[test]
1937 fn stage_files_ok_and_err() {
1938 let dir = git_init();
1939 make_commit(dir.path(), "f.txt", "hi");
1940 std::fs::write(dir.path().join("new.txt"), "new").unwrap();
1941 assert!(stage_files(dir.path(), &["new.txt"]).is_ok());
1942 assert!(stage_files(dir.path(), &["missing.txt"]).is_err());
1943 }
1944
1945 #[test]
1946 fn commit_ok_and_err() {
1947 let dir = git_init();
1948 make_commit(dir.path(), "f.txt", "hi");
1949 std::fs::write(dir.path().join("new.txt"), "new").unwrap();
1950 git_cmd(dir.path(), &["add", "new.txt"]);
1951 assert!(commit(dir.path(), "test commit").is_ok());
1952 assert!(commit(dir.path(), "empty commit").is_err());
1954 }
1955
1956 #[test]
1957 fn git_config_get_some_and_none() {
1958 let dir = git_init();
1959 make_commit(dir.path(), "f.txt", "hi");
1960 let val = git_config_get(dir.path(), "user.email");
1961 assert_eq!(val, Some("t@t.com".to_string()));
1962 let missing = git_config_get(dir.path(), "no.such.key");
1963 assert!(missing.is_none());
1964 }
1965
1966 #[test]
1967 fn merge_ref_already_up_to_date() {
1968 let dir = git_init();
1969 make_commit(dir.path(), "f.txt", "hi");
1970 let branch = {
1971 let out = Cmd::new("git").args(["branch", "--show-current"]).current_dir(dir.path()).output().unwrap();
1972 String::from_utf8_lossy(&out.stdout).trim().to_string()
1973 };
1974 let mut warnings = Vec::new();
1975 let result = merge_ref(dir.path(), &branch, &mut warnings);
1977 assert!(result.is_none());
1978 assert!(warnings.is_empty());
1979 }
1980
1981 #[test]
1982 fn merge_ref_success() {
1983 let dir = git_init();
1984 make_commit(dir.path(), "f.txt", "hi");
1985 git_cmd(dir.path(), &["checkout", "-b", "feature"]);
1986 make_commit(dir.path(), "g.txt", "there");
1987 git_cmd(dir.path(), &["checkout", "main"]);
1988 let mut warnings = Vec::new();
1989 let result = merge_ref(dir.path(), "feature", &mut warnings);
1990 assert!(result.is_some());
1991 assert!(warnings.is_empty());
1992 }
1993
1994 #[test]
1995 fn merge_ref_does_not_speculate_directory_renames() {
1996 let dir = git_init();
2003 let p = dir.path();
2004 std::fs::create_dir_all(p.join("a")).unwrap();
2006 for name in &["1.md", "2.md", "3.md", "4.md"] {
2007 std::fs::write(p.join("a").join(name), "seed\n").unwrap();
2008 }
2009 git_cmd(p, &["add", "a"]);
2010 git_cmd(p, &["commit", "-m", "seed"]);
2011
2012 std::fs::create_dir_all(p.join("b")).unwrap();
2014 for name in &["1.md", "2.md", "3.md", "4.md"] {
2015 std::fs::rename(p.join("a").join(name), p.join("b").join(name)).unwrap();
2016 }
2017 git_cmd(p, &["add", "-A"]);
2018 git_cmd(p, &["commit", "-m", "archive sweep"]);
2019
2020 git_cmd(p, &["checkout", "-b", "feature", "HEAD~1"]);
2022 std::fs::write(p.join("a/new.md"), "active\n").unwrap();
2023 git_cmd(p, &["add", "a/new.md"]);
2024 git_cmd(p, &["commit", "-m", "add active ticket"]);
2025
2026 let mut warnings = Vec::new();
2027 let result = merge_ref(p, "main", &mut warnings);
2028
2029 assert!(result.is_some(), "merge should succeed without directory-rename inference; warnings: {warnings:?}");
2030 assert!(detect_mid_merge_state(p).is_none(), "should not be left mid-merge");
2031 assert!(p.join("a/new.md").exists(), "new.md should stay at a/");
2033 assert!(!p.join("b/new.md").exists(), "new.md must NOT have been speculatively renamed into b/");
2034 }
2035
2036 #[test]
2037 fn merge_ref_clears_stale_unmerged_index_entries() {
2038 let dir = git_init();
2042 let p = dir.path();
2043 make_commit(p, "f.txt", "hi");
2044
2045 git_cmd(p, &["checkout", "-b", "other"]);
2048 make_commit(p, "g.txt", "there");
2049 git_cmd(p, &["checkout", "main"]);
2050
2051 std::fs::write(p.join("conflict.md"), "main\n").unwrap();
2053 git_cmd(p, &["add", "conflict.md"]);
2054 git_cmd(p, &["commit", "-m", "main version"]);
2055
2056 git_cmd(p, &["checkout", "-b", "feature", "HEAD~1"]);
2057 std::fs::write(p.join("conflict.md"), "feature\n").unwrap();
2058 git_cmd(p, &["add", "conflict.md"]);
2059 git_cmd(p, &["commit", "-m", "feature version"]);
2060
2061 git_cmd(p, &["checkout", "main"]);
2062 let _ = Cmd::new("git")
2063 .args(["-C", &p.to_string_lossy(), "merge", "feature", "--no-edit"])
2064 .output();
2065 let _ = std::fs::remove_file(p.join(".git/MERGE_HEAD"));
2067 let _ = std::fs::remove_file(p.join(".git/MERGE_MSG"));
2068
2069 let pre = String::from_utf8_lossy(
2070 &Cmd::new("git").args(["-C", &p.to_string_lossy(), "ls-files", "-u"])
2071 .output().unwrap().stdout
2072 ).to_string();
2073 assert!(!pre.trim().is_empty(), "precondition: unmerged index entries should be present; got: {pre:?}");
2074
2075 let mut warnings = Vec::new();
2077 let result = merge_ref(p, "other", &mut warnings);
2078
2079 assert!(result.is_some(), "merge should succeed after preflight; warnings: {warnings:?}");
2080 assert!(
2081 warnings.iter().any(|w| w.contains("stale unmerged index")),
2082 "expected stale-entry warning; got: {warnings:?}"
2083 );
2084 }
2085
2086 #[test]
2087 fn merge_ref_conflict_aborts_and_warns() {
2088 let dir = git_init();
2089 let p = dir.path();
2090 make_commit(p, "f.txt", "main version\n");
2093 git_cmd(p, &["checkout", "-b", "feature"]);
2094 std::fs::write(p.join("f.txt"), "feature version\n").unwrap();
2095 git_cmd(p, &["add", "f.txt"]);
2096 git_cmd(p, &["commit", "-m", "feature change"]);
2097 git_cmd(p, &["checkout", "main"]);
2098 std::fs::write(p.join("f.txt"), "main change\n").unwrap();
2099 git_cmd(p, &["add", "f.txt"]);
2100 git_cmd(p, &["commit", "-m", "main change"]);
2101
2102 let mut warnings = Vec::new();
2103 let result = merge_ref(p, "feature", &mut warnings);
2104
2105 assert!(result.is_none(), "merge should report failure");
2106 assert!(
2107 warnings.iter().any(|w| w.contains("merge feature failed")),
2108 "expected merge-failure warning; got: {warnings:?}"
2109 );
2110 assert!(
2112 detect_mid_merge_state(p).is_none(),
2113 "merge_ref must abort on conflict so MERGE_HEAD does not persist"
2114 );
2115 }
2116
2117 #[test]
2118 fn detect_mid_merge_none_on_clean_repo() {
2119 let dir = git_init();
2120 make_commit(dir.path(), "f.txt", "hi");
2121 assert!(detect_mid_merge_state(dir.path()).is_none());
2122 }
2123
2124 #[test]
2125 fn detect_mid_merge_on_merge_head() {
2126 let dir = git_init();
2127 make_commit(dir.path(), "f.txt", "hi");
2128 std::fs::write(dir.path().join(".git/MERGE_HEAD"), "abc").unwrap();
2129 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::Merge)));
2130 }
2131
2132 #[test]
2133 fn detect_mid_merge_on_rebase_merge() {
2134 let dir = git_init();
2135 make_commit(dir.path(), "f.txt", "hi");
2136 std::fs::create_dir(dir.path().join(".git/rebase-merge")).unwrap();
2137 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseMerge)));
2138 }
2139
2140 #[test]
2141 fn detect_mid_merge_on_rebase_apply() {
2142 let dir = git_init();
2143 make_commit(dir.path(), "f.txt", "hi");
2144 std::fs::create_dir(dir.path().join(".git/rebase-apply")).unwrap();
2145 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseApply)));
2146 }
2147
2148 #[test]
2149 fn detect_mid_merge_on_cherry_pick() {
2150 let dir = git_init();
2151 make_commit(dir.path(), "f.txt", "hi");
2152 std::fs::write(dir.path().join(".git/CHERRY_PICK_HEAD"), "abc").unwrap();
2153 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::CherryPick)));
2154 }
2155
2156 #[test]
2157 fn is_file_tracked_tracked_and_untracked() {
2158 let dir = git_init();
2159 make_commit(dir.path(), "tracked.txt", "hi");
2160 assert!(is_file_tracked(dir.path(), "tracked.txt"));
2161 std::fs::write(dir.path().join("untracked.txt"), "new").unwrap();
2162 assert!(!is_file_tracked(dir.path(), "untracked.txt"));
2163 }
2164
2165 #[test]
2166 fn check_leaked_files_detects_overlap() {
2167 let dir = git_init();
2168 let p = dir.path();
2169 std::fs::create_dir_all(p.join("src")).unwrap();
2170 std::fs::write(p.join("src/foo.rs"), "original").unwrap();
2171 git_cmd(p, &["add", "src/foo.rs"]);
2172 git_cmd(p, &["commit", "-m", "add foo"]);
2173
2174 git_cmd(p, &["checkout", "-b", "ticket/overlap-test"]);
2175 std::fs::write(p.join("src/foo.rs"), "ticket-change").unwrap();
2176 git_cmd(p, &["add", "src/foo.rs"]);
2177 git_cmd(p, &["commit", "-m", "ticket: change foo"]);
2178 git_cmd(p, &["checkout", "main"]);
2179
2180 std::fs::write(p.join("src/foo.rs"), "leaked").unwrap();
2182
2183 let leaked = check_leaked_files(p, "ticket/overlap-test", "main").unwrap();
2184 assert_eq!(leaked, vec!["src/foo.rs".to_string()]);
2185 }
2186
2187 #[test]
2188 fn check_leaked_files_no_overlap() {
2189 let dir = git_init();
2190 let p = dir.path();
2191 std::fs::create_dir_all(p.join("src")).unwrap();
2192 std::fs::write(p.join("src/foo.rs"), "original").unwrap();
2193 std::fs::write(p.join("src/bar.rs"), "bar").unwrap();
2194 git_cmd(p, &["add", "src/foo.rs", "src/bar.rs"]);
2195 git_cmd(p, &["commit", "-m", "add foo and bar"]);
2196
2197 git_cmd(p, &["checkout", "-b", "ticket/no-overlap"]);
2199 std::fs::write(p.join("src/foo.rs"), "ticket-change").unwrap();
2200 git_cmd(p, &["add", "src/foo.rs"]);
2201 git_cmd(p, &["commit", "-m", "ticket: change foo"]);
2202 git_cmd(p, &["checkout", "main"]);
2203
2204 std::fs::write(p.join("src/bar.rs"), "dirty").unwrap();
2206
2207 let leaked = check_leaked_files(p, "ticket/no-overlap", "main").unwrap();
2208 assert!(leaked.is_empty(), "no overlap expected; got {leaked:?}");
2209 }
2210
2211 #[test]
2212 fn check_leaked_files_detects_untracked_overlap() {
2213 let dir = git_init();
2214 let p = dir.path();
2215 make_commit(p, "existing.rs", "base");
2216
2217 git_cmd(p, &["checkout", "-b", "ticket/untracked-overlap"]);
2219 std::fs::create_dir_all(p.join("src")).unwrap();
2220 std::fs::write(p.join("src/new.rs"), "new file").unwrap();
2221 git_cmd(p, &["add", "src/new.rs"]);
2222 git_cmd(p, &["commit", "-m", "ticket: add new file"]);
2223 git_cmd(p, &["checkout", "main"]);
2224
2225 std::fs::create_dir_all(p.join("src")).unwrap();
2227 std::fs::write(p.join("src/new.rs"), "leaked untracked").unwrap();
2228
2229 let leaked = check_leaked_files(p, "ticket/untracked-overlap", "main").unwrap();
2230 assert_eq!(leaked, vec!["src/new.rs".to_string()]);
2231 }
2232
2233 fn commit_file(dir: &Path, name: &str, content: &str) {
2237 std::fs::write(dir.join(name), content).unwrap();
2238 git_cmd(dir, &["add", name]);
2239 git_cmd(dir, &["commit", "-m", &format!("add {name}")]);
2240 }
2241
2242 #[test]
2245 fn content_merged_into_main_regular_merge_with_state_commit() {
2246 let dir = git_init();
2247 let p = dir.path();
2248
2249 commit_file(p, "README", "base");
2251
2252 git_cmd(p, &["checkout", "-b", "ticket/foo"]);
2254 std::fs::create_dir_all(p.join("src")).unwrap();
2255 commit_file(p, "src/lib.rs", "impl");
2256
2257 git_cmd(p, &["checkout", "main"]);
2259 git_cmd(p, &["merge", "--no-ff", "ticket/foo", "-m", "Merge ticket/foo"]);
2260
2261 git_cmd(p, &["checkout", "ticket/foo"]);
2263 std::fs::create_dir_all(p.join("tickets")).unwrap();
2264 commit_file(p, "tickets/foo.md", "state: implemented");
2265
2266 let result = content_merged_into_main(p, "main", "ticket/foo", "tickets").unwrap();
2268 assert!(result, "should detect that content was merged despite trailing state commit");
2269 }
2270
2271 #[test]
2274 fn content_merged_into_main_squash_merge_with_state_commit() {
2275 let dir = git_init();
2276 let p = dir.path();
2277
2278 commit_file(p, "README", "base");
2279
2280 git_cmd(p, &["checkout", "-b", "ticket/bar"]);
2282 std::fs::create_dir_all(p.join("src")).unwrap();
2283 commit_file(p, "src/lib.rs", "impl");
2284
2285 git_cmd(p, &["checkout", "main"]);
2287 git_cmd(p, &["merge", "--squash", "ticket/bar"]);
2288 git_cmd(p, &["commit", "-m", "Squash ticket/bar"]);
2289
2290 git_cmd(p, &["checkout", "ticket/bar"]);
2292 std::fs::create_dir_all(p.join("tickets")).unwrap();
2293 commit_file(p, "tickets/bar.md", "state: implemented");
2294
2295 let result = content_merged_into_main(p, "main", "ticket/bar", "tickets").unwrap();
2296 assert!(result, "should detect squash-merged content despite trailing state commit");
2297 }
2298
2299 #[test]
2302 fn content_merged_into_main_returns_false_when_ancestor() {
2303 let dir = git_init();
2304 let p = dir.path();
2305 commit_file(p, "README", "base");
2306 git_cmd(p, &["checkout", "-b", "ticket/anc"]);
2308 git_cmd(p, &["checkout", "main"]);
2310 let result = content_merged_into_main(p, "main", "ticket/anc", "tickets").unwrap();
2311 assert!(!result);
2312 }
2313
2314 #[test]
2316 fn content_merged_into_main_not_detected_when_non_ticket_file_modified_after_merge() {
2317 let dir = git_init();
2318 let p = dir.path();
2319 commit_file(p, "README", "base");
2320
2321 git_cmd(p, &["checkout", "-b", "ticket/extra"]);
2323 std::fs::create_dir_all(p.join("src")).unwrap();
2324 commit_file(p, "src/lib.rs", "impl");
2325
2326 git_cmd(p, &["checkout", "main"]);
2328 git_cmd(p, &["merge", "--squash", "ticket/extra"]);
2329 git_cmd(p, &["commit", "-m", "Squash ticket/extra"]);
2330
2331 git_cmd(p, &["checkout", "ticket/extra"]);
2333 std::fs::create_dir_all(p.join("tickets")).unwrap();
2334 commit_file(p, "tickets/extra.md", "state: implemented");
2335 commit_file(p, "src/extra.rs", "extra code");
2338
2339 let result = content_merged_into_main(p, "main", "ticket/extra", "tickets").unwrap();
2340 assert!(!result, "branch with non-ticket changes after merge must not be detected");
2341 }
2342
2343 #[test]
2346 fn content_merged_into_main_all_ticket_only_commits_returns_false() {
2347 let dir = git_init();
2348 let p = dir.path();
2349 commit_file(p, "README", "base");
2350
2351 git_cmd(p, &["checkout", "-b", "ticket/ticketonly"]);
2353 std::fs::create_dir_all(p.join("tickets")).unwrap();
2354 commit_file(p, "tickets/ticketonly.md", "state: new");
2355 git_cmd(p, &["checkout", "main"]);
2356
2357 let result = content_merged_into_main(p, "main", "ticket/ticketonly", "tickets").unwrap();
2358 assert!(!result, "all-ticket-only commits should return false");
2359 }
2360
2361 #[test]
2368 fn merged_into_main_detects_local_regular_merge_when_remote_deleted() {
2369 let dir = git_init();
2370 let p = dir.path();
2371 make_commit(p, "f.txt", "base");
2372
2373 git_cmd(p, &["checkout", "-b", "ticket/foo"]);
2375 std::fs::write(p.join("f.txt"), "ticket-change").unwrap();
2376 git_cmd(p, &["add", "f.txt"]);
2377 git_cmd(p, &["commit", "-m", "ticket: change"]);
2378
2379 git_cmd(p, &["checkout", "main"]);
2381 git_cmd(p, &["merge", "--no-ff", "ticket/foo", "-m", "Merge ticket/foo"]);
2382
2383 let main_sha = run(p, &["rev-parse", "main"]).unwrap();
2386 Cmd::new("git")
2387 .args(["update-ref", "refs/remotes/origin/main", main_sha.trim()])
2388 .current_dir(p)
2389 .status()
2390 .unwrap();
2391 let merged = merged_into_main(p, "main").unwrap();
2394 assert!(
2395 merged.iter().any(|b| b == "ticket/foo"),
2396 "expected ticket/foo in merged set; got {merged:?}"
2397 );
2398 }
2399
2400 fn git_init_with_remote() -> (TempDir, TempDir) {
2405 let bare = tempfile::tempdir().unwrap();
2407 Cmd::new("git")
2408 .args(["init", "--bare", "-q"])
2409 .current_dir(bare.path())
2410 .status()
2411 .unwrap();
2412
2413 let local = tempfile::tempdir().unwrap();
2415 let p = local.path();
2416 Cmd::new("git")
2417 .args(["clone", "-q", &bare.path().to_string_lossy(), "."])
2418 .current_dir(p)
2419 .env("GIT_AUTHOR_NAME", "test")
2420 .env("GIT_AUTHOR_EMAIL", "t@t.com")
2421 .env("GIT_COMMITTER_NAME", "test")
2422 .env("GIT_COMMITTER_EMAIL", "t@t.com")
2423 .status()
2424 .unwrap();
2425 git_cmd(p, &["config", "user.name", "test"]);
2426 git_cmd(p, &["config", "user.email", "t@t.com"]);
2427
2428 (bare, local)
2429 }
2430
2431 #[test]
2432 fn read_from_branch_with_class_behind_returns_origin_content() {
2433 let (bare, local) = git_init_with_remote();
2434 let p = local.path();
2435
2436 make_commit(p, "README", "base");
2438 git_cmd(p, &["push", "origin", "main"]);
2439
2440 git_cmd(p, &["checkout", "-b", "ticket/abc"]);
2442 make_commit(p, "tickets/abc.md", "state: ready\n");
2443 git_cmd(p, &["push", "origin", "ticket/abc"]);
2444
2445 let remote2 = tempfile::tempdir().unwrap();
2447 let r2 = remote2.path();
2448 Cmd::new("git")
2449 .args(["clone", "-q", &bare.path().to_string_lossy(), "."])
2450 .current_dir(r2)
2451 .env("GIT_AUTHOR_NAME", "test")
2452 .env("GIT_AUTHOR_EMAIL", "t@t.com")
2453 .env("GIT_COMMITTER_NAME", "test")
2454 .env("GIT_COMMITTER_EMAIL", "t@t.com")
2455 .status()
2456 .unwrap();
2457 git_cmd(r2, &["config", "user.name", "test"]);
2458 git_cmd(r2, &["config", "user.email", "t@t.com"]);
2459 git_cmd(r2, &["checkout", "ticket/abc"]);
2460 make_commit(r2, "tickets/abc.md", "state: in_progress\n");
2461 git_cmd(r2, &["push", "origin", "ticket/abc"]);
2462
2463 git_cmd(p, &["fetch", "--all", "--quiet"]);
2465
2466 let (content, class) = read_from_branch_with_class(p, "ticket/abc", "tickets/abc.md").unwrap();
2468 assert!(
2469 matches!(class, BranchClass::Behind),
2470 "expected Behind; got something else"
2471 );
2472 assert!(
2473 content.contains("in_progress"),
2474 "expected origin content 'in_progress'; got: {content:?}"
2475 );
2476 }
2477
2478 #[test]
2479 fn read_from_branch_with_class_ahead_returns_local_content() {
2480 let (_bare, local) = git_init_with_remote();
2481 let p = local.path();
2482
2483 make_commit(p, "README", "base");
2484 git_cmd(p, &["push", "origin", "main"]);
2485
2486 git_cmd(p, &["checkout", "-b", "ticket/xyz"]);
2487 make_commit(p, "tickets/xyz.md", "state: ready\n");
2488 git_cmd(p, &["push", "origin", "ticket/xyz"]);
2489
2490 make_commit(p, "tickets/xyz.md", "state: in_progress\n");
2492
2493 git_cmd(p, &["fetch", "--all", "--quiet"]);
2495
2496 let (content, class) = read_from_branch_with_class(p, "ticket/xyz", "tickets/xyz.md").unwrap();
2497 assert!(
2498 matches!(class, BranchClass::Ahead),
2499 "expected Ahead"
2500 );
2501 assert!(
2502 content.contains("in_progress"),
2503 "expected local content; got: {content:?}"
2504 );
2505 }
2506
2507 #[test]
2508 fn read_from_branch_with_class_equal_returns_content() {
2509 let (_bare, local) = git_init_with_remote();
2510 let p = local.path();
2511
2512 make_commit(p, "README", "base");
2513 git_cmd(p, &["push", "origin", "main"]);
2514
2515 git_cmd(p, &["checkout", "-b", "ticket/eq"]);
2516 make_commit(p, "tickets/eq.md", "state: ready\n");
2517 git_cmd(p, &["push", "origin", "ticket/eq"]);
2518
2519 let (content, class) = read_from_branch_with_class(p, "ticket/eq", "tickets/eq.md").unwrap();
2520 assert!(
2521 matches!(class, BranchClass::Equal),
2522 "expected Equal"
2523 );
2524 assert!(content.contains("ready"), "expected ready content; got: {content:?}");
2525 }
2526
2527 #[test]
2528 fn read_from_branch_with_class_remote_only_returns_origin_content() {
2529 let (bare, local) = git_init_with_remote();
2530 let p = local.path();
2531
2532 make_commit(p, "README", "base");
2533 git_cmd(p, &["push", "origin", "main"]);
2534
2535 let remote2 = tempfile::tempdir().unwrap();
2537 let r2 = remote2.path();
2538 Cmd::new("git")
2539 .args(["clone", "-q", &bare.path().to_string_lossy(), "."])
2540 .current_dir(r2)
2541 .env("GIT_AUTHOR_NAME", "test")
2542 .env("GIT_AUTHOR_EMAIL", "t@t.com")
2543 .env("GIT_COMMITTER_NAME", "test")
2544 .env("GIT_COMMITTER_EMAIL", "t@t.com")
2545 .status()
2546 .unwrap();
2547 git_cmd(r2, &["config", "user.name", "test"]);
2548 git_cmd(r2, &["config", "user.email", "t@t.com"]);
2549 git_cmd(r2, &["checkout", "-b", "ticket/ro"]);
2550 make_commit(r2, "tickets/ro.md", "state: ready\n");
2551 git_cmd(r2, &["push", "origin", "ticket/ro"]);
2552
2553 git_cmd(p, &["fetch", "--all", "--quiet"]);
2555
2556 let (content, class) = read_from_branch_with_class(p, "ticket/ro", "tickets/ro.md").unwrap();
2557 assert!(
2558 matches!(class, BranchClass::RemoteOnly),
2559 "expected RemoteOnly"
2560 );
2561 assert!(content.contains("ready"), "expected ready content; got: {content:?}");
2562 }
2563}