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 enum BranchClass {
760 Equal,
761 Behind,
762 Ahead,
763 Diverged,
764 RemoteOnly,
766 NoRemote,
768}
769
770pub fn classify_branch(root: &Path, local: &str, remote: &str) -> BranchClass {
778 let local_sha = match run(root, &["rev-parse", local]) {
779 Ok(s) => s,
780 Err(_) => {
781 return if run(root, &["rev-parse", remote]).is_ok() {
785 BranchClass::RemoteOnly
786 } else {
787 BranchClass::NoRemote
788 };
789 }
790 };
791 let remote_sha = match run(root, &["rev-parse", remote]) {
792 Ok(s) => s,
793 Err(_) => return BranchClass::NoRemote,
794 };
795
796 if local_sha == remote_sha {
797 return BranchClass::Equal;
798 }
799
800 let local_is_ancestor_of_remote = is_ancestor(root, local, remote);
803
804 let remote_is_ancestor_of_local = is_ancestor(root, remote, local);
807
808 match (local_is_ancestor_of_remote, remote_is_ancestor_of_local) {
809 (true, false) => BranchClass::Behind, (false, true) => BranchClass::Ahead, (false, false) => BranchClass::Diverged, (true, true) => BranchClass::Equal, }
814}
815
816pub fn sync_default_branch(root: &Path, default: &str, warnings: &mut Vec<String>) -> bool {
841 let remote = format!("origin/{default}");
842 match classify_branch(root, default, &remote) {
843 BranchClass::Equal => {
844 }
846
847 BranchClass::Behind => {
848 let wt = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
851 if run(&wt, &["merge", "--ff-only", &remote]).is_err() {
852 let msg = crate::sync_guidance::MAIN_BEHIND_DIRTY_OVERLAP
856 .replace("<default>", default);
857 warnings.push(msg);
858 }
859 }
860
861 BranchClass::Ahead => {
862 let count = run(root, &["rev-list", "--count", &format!("{remote}..{default}")])
865 .ok()
866 .and_then(|s| s.trim().parse::<u64>().ok())
867 .unwrap_or(0);
868 let msg = crate::sync_guidance::MAIN_AHEAD
869 .replace("<default>", default)
870 .replace("<remote>", &remote)
871 .replace("<count>", &count.to_string())
872 .replace("<commits>", if count == 1 { "commit" } else { "commits" });
873 warnings.push(msg);
874 return true;
875 }
876
877 BranchClass::Diverged => {
878 let wt = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
881 let guidance = if is_worktree_dirty(&wt) {
882 crate::sync_guidance::MAIN_DIVERGED_DIRTY.replace("<default>", default)
883 } else {
884 crate::sync_guidance::MAIN_DIVERGED_CLEAN.replace("<default>", default)
885 };
886 warnings.push(guidance);
887 }
888
889 BranchClass::RemoteOnly => {
890 }
894
895 BranchClass::NoRemote => {
896 }
900 }
901 false
902}
903
904pub fn fetch_branch(root: &Path, branch: &str) -> anyhow::Result<()> {
905 run(root, &["fetch", "origin", branch]).map(|_| ())
906}
907
908pub fn push_branch(root: &Path, branch: &str) -> anyhow::Result<()> {
909 run(root, &["push", "origin", &format!("{branch}:{branch}")]).map(|_| ())
910}
911
912pub fn push_branch_tracking(root: &Path, branch: &str) -> anyhow::Result<()> {
913 let out = std::process::Command::new("git")
914 .args(["push", "--set-upstream", "origin", &format!("{branch}:{branch}")])
915 .current_dir(root)
916 .output()?;
917 if !out.status.success() {
918 anyhow::bail!("git push failed: {}", String::from_utf8_lossy(&out.stderr).trim());
919 }
920 Ok(())
921}
922
923pub fn has_remote(root: &Path) -> bool {
924 run(root, &["remote", "get-url", "origin"]).is_ok()
925}
926
927pub fn remote_ticket_branches_with_dates(
932 root: &Path,
933) -> Result<Vec<(String, chrono::DateTime<chrono::Utc>)>> {
934 use chrono::{TimeZone, Utc};
935 let out = Command::new("git")
936 .current_dir(root)
937 .args([
938 "for-each-ref",
939 "refs/remotes/origin/ticket/",
940 "--format=%(refname:short) %(creatordate:unix)",
941 ])
942 .output()
943 .context("git for-each-ref failed")?;
944 let stdout = String::from_utf8_lossy(&out.stdout);
945 let mut result = Vec::new();
946 for line in stdout.lines() {
947 let mut parts = line.splitn(2, ' ');
948 let refname = parts.next().unwrap_or("").trim();
949 let ts_str = parts.next().unwrap_or("").trim();
950 let branch = refname.trim_start_matches("origin/");
951 if branch.is_empty() {
952 continue;
953 }
954 if let Ok(ts) = ts_str.parse::<i64>() {
955 if let Some(dt) = Utc.timestamp_opt(ts, 0).single() {
956 result.push((branch.to_string(), dt));
957 }
958 }
959 }
960 Ok(result)
961}
962
963pub fn list_remote_ticket_branches(root: &Path) -> std::collections::HashSet<String> {
969 let mut set = std::collections::HashSet::new();
970 let out = match run(root, &["ls-remote", "--heads", "origin", "ticket/*"]) {
971 Ok(o) => o,
972 Err(_) => return set,
973 };
974 for line in out.lines() {
975 if let Some(refname) = line.split('\t').nth(1) {
976 if let Some(branch) = refname.trim().strip_prefix("refs/heads/") {
977 set.insert(branch.to_string());
978 }
979 }
980 }
981 set
982}
983
984pub fn delete_remote_branch(root: &Path, branch: &str) -> Result<()> {
986 run(root, &["push", "origin", "--delete", branch])
987 .map(|_| ())
988 .context("git push origin --delete failed")
989}
990
991pub fn move_files_on_branch(
996 root: &Path,
997 branch: &str,
998 moves: &[(&str, &str, &str)],
999 message: &str,
1000) -> Result<()> {
1001 if !has_commits(root) {
1002 for (old, new, content) in moves {
1003 let new_path = root.join(new);
1004 if let Some(parent) = new_path.parent() {
1005 std::fs::create_dir_all(parent)?;
1006 }
1007 std::fs::write(&new_path, content)?;
1008 let old_path = root.join(old);
1009 let _ = std::fs::remove_file(&old_path);
1010 }
1011 return Ok(());
1012 }
1013
1014 let do_moves = |wt: &Path| -> Result<()> {
1015 for (old, new, content) in moves {
1016 let new_path = wt.join(new);
1017 if let Some(parent) = new_path.parent() {
1018 std::fs::create_dir_all(parent)?;
1019 }
1020 std::fs::write(&new_path, content)?;
1021 run(wt, &["add", new])?;
1022 run(wt, &["rm", "--force", "--quiet", old])?;
1023 }
1024 run(wt, &["commit", "-m", message])?;
1025 Ok(())
1026 };
1027
1028 if let Some(wt_path) = find_worktree_for_branch(root, branch) {
1029 let remote_ref = format!("origin/{branch}");
1030 if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
1031 let _ = run(&wt_path, &["merge", "--ff-only", &remote_ref]);
1032 }
1033 let result = do_moves(&wt_path);
1034 if result.is_ok() {
1035 crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1036 }
1037 return result;
1038 }
1039
1040 if current_branch(root).ok().as_deref() == Some(branch) {
1041 let result = do_moves(root);
1042 if result.is_ok() {
1043 crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1044 }
1045 return result;
1046 }
1047
1048 let unique = std::time::SystemTime::now()
1049 .duration_since(std::time::UNIX_EPOCH)
1050 .map(|d| d.subsec_nanos())
1051 .unwrap_or(0);
1052 let wt_path = std::env::temp_dir().join(format!(
1053 "apm-{}-{}-{}",
1054 std::process::id(),
1055 unique,
1056 branch.replace('/', "-"),
1057 ));
1058
1059 let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
1060 let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
1061
1062 if has_remote {
1063 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
1064 let _ = run(&wt_path, &["checkout", "-B", branch]);
1065 } else if has_local {
1066 let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
1067 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
1068 let _ = run(&wt_path, &["checkout", "-B", branch]);
1069 } else {
1070 run(root, &["worktree", "add", &wt_path.to_string_lossy(), branch])?;
1071 }
1072
1073 let result = do_moves(&wt_path);
1074 let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
1075 let _ = std::fs::remove_dir_all(&wt_path);
1076 if result.is_ok() {
1077 crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1078 }
1079 result
1080}
1081
1082pub fn merge_branch_into_default(root: &Path, branch: &str, default_branch: &str, warnings: &mut Vec<String>) -> Result<()> {
1083 let _ = run(root, &["fetch", "origin", default_branch]);
1084
1085 let merge_dir = if current_branch(root).ok().as_deref() == Some(default_branch) {
1086 root.to_path_buf()
1087 } else {
1088 find_worktree_for_branch(root, default_branch).unwrap_or_else(|| root.to_path_buf())
1089 };
1090
1091 if let Err(e) = run(&merge_dir, &["merge", "--no-ff", branch, "--no-edit"]) {
1092 let _ = run(&merge_dir, &["merge", "--abort"]);
1093 anyhow::bail!("merge failed: {e:#}");
1094 }
1095
1096 if has_remote(root) {
1097 if let Err(e) = run(&merge_dir, &["push", "origin", default_branch]) {
1098 warnings.push(format!("warning: push {default_branch} failed: {e:#}"));
1099 }
1100 }
1101 Ok(())
1102}
1103
1104pub 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<()> {
1105 let _ = run(root, &["fetch", "origin", default_branch]);
1106
1107 let current = std::process::Command::new("git")
1108 .args(["rev-parse", "--abbrev-ref", "HEAD"])
1109 .current_dir(root)
1110 .output()?;
1111 let current_branch = String::from_utf8_lossy(¤t.stdout).trim().to_string();
1112
1113 let merge_dir = if current_branch == default_branch {
1114 root.to_path_buf()
1115 } else {
1116 let main_root = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
1117 let worktrees_base = main_root.join(&config.worktrees.dir);
1118 ensure_worktree(root, &worktrees_base, default_branch)?
1119 };
1120
1121 let out = std::process::Command::new("git")
1122 .args(["merge", "--no-ff", branch, "--no-edit"])
1123 .current_dir(&merge_dir)
1124 .output()?;
1125
1126 if !out.status.success() {
1127 let _ = run(&merge_dir, &["merge", "--abort"]);
1128 bail!(
1129 "merge conflict — resolve manually and push: {}",
1130 String::from_utf8_lossy(&out.stderr).trim()
1131 );
1132 }
1133
1134 if skip_push {
1135 messages.push(format!("Merged {branch} into {default_branch} (local only)."));
1136 } else {
1137 push_branch(&merge_dir, default_branch)?;
1138 messages.push(format!("Merged {branch} into {default_branch} and pushed to origin."));
1139 }
1140 Ok(())
1141}
1142
1143pub fn pull_default(root: &Path, default_branch: &str, warnings: &mut Vec<String>) -> Result<()> {
1144 let fetch = std::process::Command::new("git")
1145 .args(["fetch", "origin", default_branch])
1146 .current_dir(root)
1147 .output();
1148
1149 match fetch {
1150 Err(e) => {
1151 warnings.push(format!("warning: fetch failed: {e:#}"));
1152 return Ok(());
1153 }
1154 Ok(out) if !out.status.success() => {
1155 warnings.push(format!(
1156 "warning: fetch failed: {}",
1157 String::from_utf8_lossy(&out.stderr).trim()
1158 ));
1159 return Ok(());
1160 }
1161 _ => {}
1162 }
1163
1164 let current = std::process::Command::new("git")
1165 .args(["rev-parse", "--abbrev-ref", "HEAD"])
1166 .current_dir(root)
1167 .output()?;
1168 let current_branch = String::from_utf8_lossy(¤t.stdout).trim().to_string();
1169
1170 let merge_dir = if current_branch == default_branch {
1171 root.to_path_buf()
1172 } else {
1173 find_worktree_for_branch(root, default_branch)
1174 .unwrap_or_else(|| root.to_path_buf())
1175 };
1176
1177 let remote_ref = format!("origin/{default_branch}");
1178 let out = std::process::Command::new("git")
1179 .args(["merge", "--ff-only", &remote_ref])
1180 .current_dir(&merge_dir)
1181 .output()?;
1182
1183 if !out.status.success() {
1184 warnings.push(format!("warning: could not fast-forward {default_branch} — pull manually"));
1185 }
1186
1187 Ok(())
1188}
1189
1190pub fn is_worktree_dirty(path: &Path) -> bool {
1191 let Ok(out) = Command::new("git")
1192 .args(["-C", &path.to_string_lossy(), "status", "--porcelain"])
1193 .output()
1194 else {
1195 return false;
1196 };
1197 !out.stdout.is_empty()
1198}
1199
1200pub fn is_worktree_dirty_for_sync(path: &Path) -> bool {
1203 const TEMP_FILES: &[&str] = &[".apm-worker.log", ".apm-worker.pid"];
1204 let Ok(out) = Command::new("git")
1205 .args(["-C", &path.to_string_lossy(), "status", "--porcelain"])
1206 .output()
1207 else {
1208 return false;
1209 };
1210 let stdout = String::from_utf8_lossy(&out.stdout);
1211 stdout.lines().filter(|l| !l.is_empty()).any(|l| {
1212 let fname = l.get(3..).unwrap_or("").trim();
1214 !TEMP_FILES.contains(&fname)
1215 })
1216}
1217
1218fn dirty_files_for_sync(path: &Path) -> Vec<String> {
1222 const TEMP_FILES: &[&str] = &[".apm-worker.log", ".apm-worker.pid"];
1223 let Ok(out) = Command::new("git")
1224 .args(["-C", &path.to_string_lossy(), "status", "--porcelain"])
1225 .output()
1226 else {
1227 return Vec::new();
1228 };
1229 let stdout = String::from_utf8_lossy(&out.stdout);
1230 stdout
1231 .lines()
1232 .filter(|l| !l.is_empty())
1233 .filter_map(|l| {
1234 let fname = l.get(3..)?.trim();
1235 if TEMP_FILES.contains(&fname) { None } else { Some(fname.to_string()) }
1236 })
1237 .collect()
1238}
1239
1240pub struct WorktreeSyncResult {
1242 pub fast_forwarded: Vec<(PathBuf, String)>,
1244 pub skipped_dirty: Vec<(PathBuf, String, Vec<String>)>,
1246 pub skipped_ahead: Vec<(PathBuf, String)>,
1248 pub skipped_diverged: Vec<(PathBuf, String)>,
1250}
1251
1252pub fn sync_checked_out_worktrees(root: &Path, warnings: &mut Vec<String>) -> WorktreeSyncResult {
1265 let mut result = WorktreeSyncResult {
1266 fast_forwarded: Vec::new(),
1267 skipped_dirty: Vec::new(),
1268 skipped_ahead: Vec::new(),
1269 skipped_diverged: Vec::new(),
1270 };
1271
1272 let worktrees = match crate::worktree::list_ticket_worktrees(root) {
1273 Ok(w) => w,
1274 Err(_) => return result,
1275 };
1276
1277 for (wt_path, branch) in worktrees {
1278 let local_ref = format!("refs/heads/{branch}");
1279 let remote_ref = format!("origin/{branch}");
1280 match classify_branch(root, &local_ref, &remote_ref) {
1281 BranchClass::Behind => {
1282 if is_worktree_dirty_for_sync(&wt_path) {
1283 let dirty = dirty_files_for_sync(&wt_path);
1284 result.skipped_dirty.push((wt_path, branch, dirty));
1285 } else {
1286 match run(&wt_path, &["merge", "--ff-only", &remote_ref]) {
1287 Ok(_) => result.fast_forwarded.push((wt_path, branch)),
1288 Err(e) => warnings.push(format!(
1289 "warning: fast-forward {} failed: {e:#}",
1290 wt_path.display()
1291 )),
1292 }
1293 }
1294 }
1295 BranchClass::Ahead => {
1296 result.skipped_ahead.push((wt_path, branch));
1297 }
1298 BranchClass::Diverged => {
1299 result.skipped_diverged.push((wt_path, branch));
1300 }
1301 BranchClass::Equal | BranchClass::NoRemote | BranchClass::RemoteOnly => {
1302 }
1304 }
1305 }
1306
1307 result
1308}
1309
1310pub fn local_branch_exists(root: &Path, branch: &str) -> bool {
1311 Command::new("git")
1312 .args(["-C", &root.to_string_lossy(), "rev-parse", "--verify", &format!("refs/heads/{branch}")])
1313 .output()
1314 .map(|o| o.status.success())
1315 .unwrap_or(false)
1316}
1317
1318pub fn delete_local_branch(root: &Path, branch: &str, warnings: &mut Vec<String>) {
1319 let Ok(out) = Command::new("git")
1320 .args(["-C", &root.to_string_lossy(), "branch", "-D", branch])
1321 .output()
1322 else {
1323 warnings.push(format!("warning: could not delete branch {branch}: command failed"));
1324 return;
1325 };
1326 if !out.status.success() {
1327 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1328 warnings.push(format!("warning: could not delete branch {branch}: {stderr}"));
1329 }
1330}
1331
1332pub fn prune_remote_tracking(root: &Path, branch: &str) {
1333 let _ = Command::new("git")
1334 .args(["-C", &root.to_string_lossy(), "branch", "-dr", &format!("origin/{branch}")])
1335 .output();
1336}
1337
1338pub fn stage_files(root: &Path, files: &[&str]) -> Result<()> {
1339 let mut args = vec!["add"];
1340 args.extend_from_slice(files);
1341 run(root, &args).map(|_| ())
1342}
1343
1344pub fn commit(root: &Path, message: &str) -> Result<()> {
1345 run(root, &["commit", "-m", message]).map(|_| ())
1346}
1347
1348pub fn git_config_get(root: &Path, key: &str) -> Option<String> {
1349 let out = Command::new("git")
1350 .args(["-C", &root.to_string_lossy(), "config", key])
1351 .output()
1352 .ok()?;
1353 if !out.status.success() {
1354 return None;
1355 }
1356 let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
1357 if value.is_empty() { None } else { Some(value) }
1358}
1359
1360fn clear_stale_unmerged_entries(dir: &Path, warnings: &mut Vec<String>) {
1366 let out = match Command::new("git")
1367 .args(["-C", &dir.to_string_lossy(), "ls-files", "-u"])
1368 .output()
1369 {
1370 Ok(o) if o.status.success() => o,
1371 _ => return,
1372 };
1373 let stdout = String::from_utf8_lossy(&out.stdout);
1374 let mut paths: std::collections::HashSet<String> = std::collections::HashSet::new();
1375 for line in stdout.lines() {
1376 if let Some(path) = line.split('\t').nth(1) {
1378 paths.insert(path.to_string());
1379 }
1380 }
1381 if paths.is_empty() {
1382 return;
1383 }
1384 warnings.push(format!(
1385 "warning: clearing {} stale unmerged index entr{} in {} (left by an earlier failed merge)",
1386 paths.len(),
1387 if paths.len() == 1 { "y" } else { "ies" },
1388 dir.display(),
1389 ));
1390 for path in &paths {
1391 let _ = Command::new("git")
1392 .args(["-C", &dir.to_string_lossy(), "reset", "HEAD", "--", path])
1393 .output();
1394 }
1395}
1396
1397pub fn merge_ref(dir: &Path, refname: &str, warnings: &mut Vec<String>) -> Option<String> {
1398 clear_stale_unmerged_entries(dir, warnings);
1403
1404 let out = match Command::new("git")
1411 .args([
1412 "-C", &dir.to_string_lossy(),
1413 "-c", "merge.directoryRenames=false",
1414 "merge", refname, "--no-edit",
1415 ])
1416 .output()
1417 {
1418 Ok(o) => o,
1419 Err(e) => {
1420 warnings.push(format!("warning: merge {refname} failed: {e}"));
1421 return None;
1422 }
1423 };
1424 if out.status.success() {
1425 let stdout = String::from_utf8_lossy(&out.stdout);
1426 if stdout.contains("Already up to date") {
1427 None
1428 } else {
1429 Some(format!("Merged {refname} into branch."))
1430 }
1431 } else {
1432 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1433 warnings.push(format!("warning: merge {refname} failed: {stderr}"));
1434 if detect_mid_merge_state(dir).is_some() {
1440 let abort = Command::new("git")
1441 .args(["-C", &dir.to_string_lossy(), "merge", "--abort"])
1442 .output();
1443 match abort {
1444 Ok(o) if !o.status.success() => {
1445 let aborterr = String::from_utf8_lossy(&o.stderr).trim().to_string();
1446 warnings.push(format!(
1447 "warning: could not abort merge of {refname} in {}: {aborterr}",
1448 dir.display()
1449 ));
1450 }
1451 Err(e) => {
1452 warnings.push(format!(
1453 "warning: could not abort merge of {refname} in {}: {e}",
1454 dir.display()
1455 ));
1456 }
1457 Ok(_) => {}
1458 }
1459 }
1460 None
1461 }
1462}
1463
1464pub fn is_file_tracked(root: &Path, path: &str) -> bool {
1465 Command::new("git")
1466 .args(["ls-files", "--error-unmatch", path])
1467 .current_dir(root)
1468 .stdout(std::process::Stdio::null())
1469 .stderr(std::process::Stdio::null())
1470 .status()
1471 .map(|s| s.success())
1472 .unwrap_or(false)
1473}
1474
1475pub enum MidMergeState {
1479 Merge,
1481 RebaseMerge,
1483 RebaseApply,
1485 CherryPick,
1487}
1488
1489pub fn detect_mid_merge_state(root: &Path) -> Option<MidMergeState> {
1498 let git_dir = root.join(".git");
1499 if git_dir.join("MERGE_HEAD").exists() {
1500 return Some(MidMergeState::Merge);
1501 }
1502 if git_dir.join("rebase-merge").is_dir() {
1503 return Some(MidMergeState::RebaseMerge);
1504 }
1505 if git_dir.join("rebase-apply").is_dir() {
1506 return Some(MidMergeState::RebaseApply);
1507 }
1508 if git_dir.join("CHERRY_PICK_HEAD").exists() {
1509 return Some(MidMergeState::CherryPick);
1510 }
1511 None
1512}
1513
1514pub fn merge_base(root: &Path, ref1: &str, ref2: &str) -> Result<String> {
1516 run(root, &["merge-base", ref1, ref2])
1517}
1518
1519pub fn main_worktree_root(root: &Path) -> Option<PathBuf> {
1520 let out = run(root, &["worktree", "list", "--porcelain"]).ok()?;
1521 out.lines()
1522 .next()
1523 .and_then(|line| line.strip_prefix("worktree "))
1524 .map(PathBuf::from)
1525}
1526
1527pub fn check_leaked_files(
1540 root: &Path,
1541 ticket_branch: &str,
1542 target_branch: &str,
1543) -> Result<Vec<String>> {
1544 let current = Command::new("git")
1546 .args(["rev-parse", "--abbrev-ref", "HEAD"])
1547 .current_dir(root)
1548 .output()?;
1549 let current_branch = String::from_utf8_lossy(¤t.stdout).trim().to_string();
1550
1551 let merge_dir = if current_branch == target_branch {
1552 root.to_path_buf()
1553 } else {
1554 match crate::worktree::find_worktree_for_branch(root, target_branch) {
1555 Some(p) => p,
1556 None => return Ok(vec![]), }
1558 };
1559
1560 let base = match merge_base(root, target_branch, ticket_branch) {
1562 Ok(s) => s.trim().to_string(),
1563 Err(_) => return Ok(vec![]), };
1565 if base.is_empty() {
1566 return Ok(vec![]);
1567 }
1568
1569 let diff_out = Command::new("git")
1572 .args(["diff", "--name-only", &base, ticket_branch])
1573 .current_dir(root)
1574 .output()?;
1575 let ticket_files: std::collections::HashSet<String> =
1576 String::from_utf8_lossy(&diff_out.stdout)
1577 .lines()
1578 .map(|s| s.to_string())
1579 .collect();
1580
1581 let status_out = Command::new("git")
1590 .args(["status", "--porcelain", "--untracked-files=all"])
1591 .current_dir(&merge_dir)
1592 .output()?;
1593 let dirty_files: std::collections::HashSet<String> =
1594 String::from_utf8_lossy(&status_out.stdout)
1595 .lines()
1596 .filter_map(|line| {
1597 if line.len() < 3 {
1598 return None;
1599 }
1600 let x = line.as_bytes()[0] as char;
1601 let y = line.as_bytes()[1] as char;
1602 if x == 'R' || x == 'C' || y == 'R' || y == 'C' {
1604 return None;
1605 }
1606 Some(line[3..].to_string())
1607 })
1608 .collect();
1609
1610 let mut overlap: Vec<String> = ticket_files
1612 .intersection(&dirty_files)
1613 .cloned()
1614 .collect();
1615 overlap.sort();
1616 Ok(overlap)
1617}
1618
1619#[cfg(test)]
1620mod tests {
1621 use super::*;
1622 use std::process::Command as Cmd;
1623 use tempfile::TempDir;
1624
1625 fn git_init() -> TempDir {
1626 let dir = tempfile::tempdir().unwrap();
1627 let p = dir.path();
1628 Cmd::new("git").args(["init", "-q", "-b", "main"]).current_dir(p).status().unwrap();
1629 Cmd::new("git").args(["config", "user.email", "t@t.com"]).current_dir(p).status().unwrap();
1630 Cmd::new("git").args(["config", "user.name", "test"]).current_dir(p).status().unwrap();
1631 dir
1632 }
1633
1634 fn git_cmd(dir: &Path, args: &[&str]) {
1635 Cmd::new("git")
1636 .args(args)
1637 .current_dir(dir)
1638 .env("GIT_AUTHOR_NAME", "test")
1639 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1640 .env("GIT_COMMITTER_NAME", "test")
1641 .env("GIT_COMMITTER_EMAIL", "t@t.com")
1642 .status()
1643 .unwrap();
1644 }
1645
1646 fn make_commit(dir: &Path, filename: &str, content: &str) {
1647 let full = dir.join(filename);
1648 if let Some(parent) = full.parent() {
1649 std::fs::create_dir_all(parent).unwrap();
1650 }
1651 std::fs::write(full, content).unwrap();
1652 git_cmd(dir, &["add", filename]);
1653 git_cmd(dir, &["commit", "-m", "init"]);
1654 }
1655
1656 #[test]
1657 fn is_worktree_dirty_clean() {
1658 let dir = git_init();
1659 make_commit(dir.path(), "f.txt", "hi");
1660 assert!(!is_worktree_dirty(dir.path()));
1661 }
1662
1663 #[test]
1664 fn is_worktree_dirty_dirty() {
1665 let dir = git_init();
1666 make_commit(dir.path(), "f.txt", "hi");
1667 std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1668 assert!(is_worktree_dirty(dir.path()));
1669 }
1670
1671 #[test]
1672 fn is_worktree_dirty_for_sync_clean() {
1673 let dir = git_init();
1674 make_commit(dir.path(), "f.txt", "hi");
1675 assert!(!is_worktree_dirty_for_sync(dir.path()));
1676 }
1677
1678 #[test]
1679 fn is_worktree_dirty_for_sync_temp_files_only_is_clean() {
1680 let dir = git_init();
1681 make_commit(dir.path(), "f.txt", "hi");
1682 std::fs::write(dir.path().join(".apm-worker.log"), "log").unwrap();
1684 std::fs::write(dir.path().join(".apm-worker.pid"), "123").unwrap();
1685 assert!(!is_worktree_dirty_for_sync(dir.path()));
1686 assert!(is_worktree_dirty(dir.path()));
1688 }
1689
1690 #[test]
1691 fn is_worktree_dirty_for_sync_real_change_is_dirty() {
1692 let dir = git_init();
1693 make_commit(dir.path(), "f.txt", "hi");
1694 std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1695 assert!(is_worktree_dirty_for_sync(dir.path()));
1696 }
1697
1698 #[test]
1699 fn is_worktree_dirty_for_sync_temp_plus_real_is_dirty() {
1700 let dir = git_init();
1701 make_commit(dir.path(), "f.txt", "hi");
1702 std::fs::write(dir.path().join(".apm-worker.log"), "log").unwrap();
1703 std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1704 assert!(is_worktree_dirty_for_sync(dir.path()));
1705 }
1706
1707 #[test]
1708 fn dirty_files_for_sync_excludes_temp_files() {
1709 let dir = git_init();
1710 make_commit(dir.path(), "f.txt", "hi");
1711 std::fs::write(dir.path().join(".apm-worker.log"), "log").unwrap();
1712 std::fs::write(dir.path().join(".apm-worker.pid"), "123").unwrap();
1713 std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1714 let dirty = dirty_files_for_sync(dir.path());
1715 assert!(dirty.contains(&"f.txt".to_string()), "f.txt should be in dirty; got {dirty:?}");
1716 assert!(!dirty.iter().any(|f| f.contains(".apm-worker")), "temp files should be excluded; got {dirty:?}");
1717 }
1718
1719 #[test]
1720 fn sync_checked_out_worktrees_behind_clean_fast_forwards() {
1721 let origin_tmp = git_init();
1723 let origin = origin_tmp.path();
1724 make_commit(origin, "README", "v1");
1725 git_cmd(origin, &["checkout", "-b", "ticket/test-ff"]);
1727 make_commit(origin, "impl.rs", "v1");
1728 git_cmd(origin, &["checkout", "main"]);
1729
1730 let clone_tmp = tempfile::tempdir().unwrap();
1732 let clone = clone_tmp.path();
1733 Cmd::new("git")
1734 .args(["clone", &origin.to_string_lossy(), &clone.to_string_lossy()])
1735 .env("GIT_AUTHOR_NAME", "test").env("GIT_AUTHOR_EMAIL", "t@t.com")
1736 .env("GIT_COMMITTER_NAME", "test").env("GIT_COMMITTER_EMAIL", "t@t.com")
1737 .status().unwrap();
1738 git_cmd(clone, &["config", "user.email", "t@t.com"]);
1740 git_cmd(clone, &["config", "user.name", "test"]);
1741 let wt_path = clone.join("wt-test-ff");
1743 Cmd::new("git")
1744 .args(["worktree", "add", &wt_path.to_string_lossy(), "ticket/test-ff"])
1745 .current_dir(clone).status().unwrap();
1746
1747 git_cmd(origin, &["checkout", "ticket/test-ff"]);
1749 make_commit(origin, "impl.rs", "v2");
1750 git_cmd(origin, &["checkout", "main"]);
1751 git_cmd(clone, &["fetch", "origin"]);
1753
1754 let mut warnings = Vec::new();
1755 let result = sync_checked_out_worktrees(clone, &mut warnings);
1756
1757 assert_eq!(result.fast_forwarded.len(), 1, "should have fast-forwarded 1 worktree; warnings: {warnings:?}");
1758 assert!(result.skipped_dirty.is_empty());
1759 assert!(result.skipped_ahead.is_empty());
1760 assert!(result.skipped_diverged.is_empty());
1761 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1762
1763 let content = std::fs::read_to_string(wt_path.join("impl.rs")).unwrap();
1765 assert_eq!(content.trim(), "v2", "worktree should have v2 after fast-forward");
1766 }
1767
1768 #[test]
1769 fn sync_checked_out_worktrees_dirty_skips() {
1770 let origin_tmp = git_init();
1771 let origin = origin_tmp.path();
1772 make_commit(origin, "README", "v1");
1773 git_cmd(origin, &["checkout", "-b", "ticket/test-dirty"]);
1774 make_commit(origin, "impl.rs", "v1");
1775 git_cmd(origin, &["checkout", "main"]);
1776
1777 let clone_tmp = tempfile::tempdir().unwrap();
1778 let clone = clone_tmp.path();
1779 Cmd::new("git")
1780 .args(["clone", &origin.to_string_lossy(), &clone.to_string_lossy()])
1781 .env("GIT_AUTHOR_NAME", "test").env("GIT_AUTHOR_EMAIL", "t@t.com")
1782 .env("GIT_COMMITTER_NAME", "test").env("GIT_COMMITTER_EMAIL", "t@t.com")
1783 .status().unwrap();
1784 git_cmd(clone, &["config", "user.email", "t@t.com"]);
1785 git_cmd(clone, &["config", "user.name", "test"]);
1786 let wt_path = clone.join("wt-test-dirty");
1787 Cmd::new("git")
1788 .args(["worktree", "add", &wt_path.to_string_lossy(), "ticket/test-dirty"])
1789 .current_dir(clone).status().unwrap();
1790
1791 git_cmd(origin, &["checkout", "ticket/test-dirty"]);
1793 make_commit(origin, "impl.rs", "v2");
1794 git_cmd(origin, &["checkout", "main"]);
1795 git_cmd(clone, &["fetch", "origin"]);
1796
1797 std::fs::write(wt_path.join("impl.rs"), "local change").unwrap();
1799
1800 let mut warnings = Vec::new();
1801 let result = sync_checked_out_worktrees(clone, &mut warnings);
1802
1803 assert!(result.fast_forwarded.is_empty(), "should not fast-forward dirty worktree");
1804 assert_eq!(result.skipped_dirty.len(), 1);
1805 let (_, _, ref dirty_files) = result.skipped_dirty[0];
1806 assert!(dirty_files.contains(&"impl.rs".to_string()), "impl.rs should be in dirty files; got {dirty_files:?}");
1807 }
1808
1809 #[test]
1810 fn sync_checked_out_worktrees_temp_only_is_clean() {
1811 let origin_tmp = git_init();
1812 let origin = origin_tmp.path();
1813 make_commit(origin, "README", "v1");
1814 git_cmd(origin, &["checkout", "-b", "ticket/test-temponly"]);
1815 make_commit(origin, "impl.rs", "v1");
1816 git_cmd(origin, &["checkout", "main"]);
1817
1818 let clone_tmp = tempfile::tempdir().unwrap();
1819 let clone = clone_tmp.path();
1820 Cmd::new("git")
1821 .args(["clone", &origin.to_string_lossy(), &clone.to_string_lossy()])
1822 .env("GIT_AUTHOR_NAME", "test").env("GIT_AUTHOR_EMAIL", "t@t.com")
1823 .env("GIT_COMMITTER_NAME", "test").env("GIT_COMMITTER_EMAIL", "t@t.com")
1824 .status().unwrap();
1825 git_cmd(clone, &["config", "user.email", "t@t.com"]);
1826 git_cmd(clone, &["config", "user.name", "test"]);
1827 let wt_path = clone.join("wt-test-temponly");
1828 Cmd::new("git")
1829 .args(["worktree", "add", &wt_path.to_string_lossy(), "ticket/test-temponly"])
1830 .current_dir(clone).status().unwrap();
1831
1832 git_cmd(origin, &["checkout", "ticket/test-temponly"]);
1834 make_commit(origin, "impl.rs", "v2");
1835 git_cmd(origin, &["checkout", "main"]);
1836 git_cmd(clone, &["fetch", "origin"]);
1837
1838 std::fs::write(wt_path.join(".apm-worker.log"), "log").unwrap();
1840 std::fs::write(wt_path.join(".apm-worker.pid"), "123").unwrap();
1841
1842 let mut warnings = Vec::new();
1843 let result = sync_checked_out_worktrees(clone, &mut warnings);
1844
1845 assert_eq!(result.fast_forwarded.len(), 1, "temp-only worktree should be fast-forwarded; warnings: {warnings:?}");
1846 assert!(result.skipped_dirty.is_empty());
1847 }
1848
1849 #[test]
1850 fn sync_checked_out_worktrees_no_worktrees_returns_empty() {
1851 let dir = git_init();
1852 make_commit(dir.path(), "f.txt", "hi");
1853 let mut warnings = Vec::new();
1854 let result = sync_checked_out_worktrees(dir.path(), &mut warnings);
1855 assert!(result.fast_forwarded.is_empty());
1856 assert!(result.skipped_dirty.is_empty());
1857 assert!(result.skipped_ahead.is_empty());
1858 assert!(result.skipped_diverged.is_empty());
1859 assert!(warnings.is_empty());
1860 }
1861
1862 #[test]
1863 fn local_branch_exists_present_and_absent() {
1864 let dir = git_init();
1865 make_commit(dir.path(), "f.txt", "hi");
1866 let on_main = local_branch_exists(dir.path(), "main");
1867 let on_master = local_branch_exists(dir.path(), "master");
1868 assert!(on_main || on_master);
1869 assert!(!local_branch_exists(dir.path(), "no-such-branch"));
1870 }
1871
1872 #[test]
1873 fn delete_local_branch_success() {
1874 let dir = git_init();
1875 make_commit(dir.path(), "f.txt", "hi");
1876 git_cmd(dir.path(), &["branch", "to-delete"]);
1877 let mut warnings = Vec::new();
1878 delete_local_branch(dir.path(), "to-delete", &mut warnings);
1879 assert!(warnings.is_empty());
1880 assert!(!local_branch_exists(dir.path(), "to-delete"));
1881 }
1882
1883 #[test]
1884 fn delete_local_branch_failure_adds_warning() {
1885 let dir = git_init();
1886 make_commit(dir.path(), "f.txt", "hi");
1887 let mut warnings = Vec::new();
1888 delete_local_branch(dir.path(), "nonexistent", &mut warnings);
1889 assert!(!warnings.is_empty());
1890 assert!(warnings[0].contains("warning:"));
1891 }
1892
1893 #[test]
1894 fn prune_remote_tracking_no_panic() {
1895 let dir = git_init();
1896 make_commit(dir.path(), "f.txt", "hi");
1897 prune_remote_tracking(dir.path(), "nonexistent-branch");
1899 }
1900
1901 #[test]
1902 fn stage_files_ok_and_err() {
1903 let dir = git_init();
1904 make_commit(dir.path(), "f.txt", "hi");
1905 std::fs::write(dir.path().join("new.txt"), "new").unwrap();
1906 assert!(stage_files(dir.path(), &["new.txt"]).is_ok());
1907 assert!(stage_files(dir.path(), &["missing.txt"]).is_err());
1908 }
1909
1910 #[test]
1911 fn commit_ok_and_err() {
1912 let dir = git_init();
1913 make_commit(dir.path(), "f.txt", "hi");
1914 std::fs::write(dir.path().join("new.txt"), "new").unwrap();
1915 git_cmd(dir.path(), &["add", "new.txt"]);
1916 assert!(commit(dir.path(), "test commit").is_ok());
1917 assert!(commit(dir.path(), "empty commit").is_err());
1919 }
1920
1921 #[test]
1922 fn git_config_get_some_and_none() {
1923 let dir = git_init();
1924 make_commit(dir.path(), "f.txt", "hi");
1925 let val = git_config_get(dir.path(), "user.email");
1926 assert_eq!(val, Some("t@t.com".to_string()));
1927 let missing = git_config_get(dir.path(), "no.such.key");
1928 assert!(missing.is_none());
1929 }
1930
1931 #[test]
1932 fn merge_ref_already_up_to_date() {
1933 let dir = git_init();
1934 make_commit(dir.path(), "f.txt", "hi");
1935 let branch = {
1936 let out = Cmd::new("git").args(["branch", "--show-current"]).current_dir(dir.path()).output().unwrap();
1937 String::from_utf8_lossy(&out.stdout).trim().to_string()
1938 };
1939 let mut warnings = Vec::new();
1940 let result = merge_ref(dir.path(), &branch, &mut warnings);
1942 assert!(result.is_none());
1943 assert!(warnings.is_empty());
1944 }
1945
1946 #[test]
1947 fn merge_ref_success() {
1948 let dir = git_init();
1949 make_commit(dir.path(), "f.txt", "hi");
1950 git_cmd(dir.path(), &["checkout", "-b", "feature"]);
1951 make_commit(dir.path(), "g.txt", "there");
1952 git_cmd(dir.path(), &["checkout", "main"]);
1953 let mut warnings = Vec::new();
1954 let result = merge_ref(dir.path(), "feature", &mut warnings);
1955 assert!(result.is_some());
1956 assert!(warnings.is_empty());
1957 }
1958
1959 #[test]
1960 fn merge_ref_does_not_speculate_directory_renames() {
1961 let dir = git_init();
1968 let p = dir.path();
1969 std::fs::create_dir_all(p.join("a")).unwrap();
1971 for name in &["1.md", "2.md", "3.md", "4.md"] {
1972 std::fs::write(p.join("a").join(name), "seed\n").unwrap();
1973 }
1974 git_cmd(p, &["add", "a"]);
1975 git_cmd(p, &["commit", "-m", "seed"]);
1976
1977 std::fs::create_dir_all(p.join("b")).unwrap();
1979 for name in &["1.md", "2.md", "3.md", "4.md"] {
1980 std::fs::rename(p.join("a").join(name), p.join("b").join(name)).unwrap();
1981 }
1982 git_cmd(p, &["add", "-A"]);
1983 git_cmd(p, &["commit", "-m", "archive sweep"]);
1984
1985 git_cmd(p, &["checkout", "-b", "feature", "HEAD~1"]);
1987 std::fs::write(p.join("a/new.md"), "active\n").unwrap();
1988 git_cmd(p, &["add", "a/new.md"]);
1989 git_cmd(p, &["commit", "-m", "add active ticket"]);
1990
1991 let mut warnings = Vec::new();
1992 let result = merge_ref(p, "main", &mut warnings);
1993
1994 assert!(result.is_some(), "merge should succeed without directory-rename inference; warnings: {warnings:?}");
1995 assert!(detect_mid_merge_state(p).is_none(), "should not be left mid-merge");
1996 assert!(p.join("a/new.md").exists(), "new.md should stay at a/");
1998 assert!(!p.join("b/new.md").exists(), "new.md must NOT have been speculatively renamed into b/");
1999 }
2000
2001 #[test]
2002 fn merge_ref_clears_stale_unmerged_index_entries() {
2003 let dir = git_init();
2007 let p = dir.path();
2008 make_commit(p, "f.txt", "hi");
2009
2010 git_cmd(p, &["checkout", "-b", "other"]);
2013 make_commit(p, "g.txt", "there");
2014 git_cmd(p, &["checkout", "main"]);
2015
2016 std::fs::write(p.join("conflict.md"), "main\n").unwrap();
2018 git_cmd(p, &["add", "conflict.md"]);
2019 git_cmd(p, &["commit", "-m", "main version"]);
2020
2021 git_cmd(p, &["checkout", "-b", "feature", "HEAD~1"]);
2022 std::fs::write(p.join("conflict.md"), "feature\n").unwrap();
2023 git_cmd(p, &["add", "conflict.md"]);
2024 git_cmd(p, &["commit", "-m", "feature version"]);
2025
2026 git_cmd(p, &["checkout", "main"]);
2027 let _ = Cmd::new("git")
2028 .args(["-C", &p.to_string_lossy(), "merge", "feature", "--no-edit"])
2029 .output();
2030 let _ = std::fs::remove_file(p.join(".git/MERGE_HEAD"));
2032 let _ = std::fs::remove_file(p.join(".git/MERGE_MSG"));
2033
2034 let pre = String::from_utf8_lossy(
2035 &Cmd::new("git").args(["-C", &p.to_string_lossy(), "ls-files", "-u"])
2036 .output().unwrap().stdout
2037 ).to_string();
2038 assert!(!pre.trim().is_empty(), "precondition: unmerged index entries should be present; got: {pre:?}");
2039
2040 let mut warnings = Vec::new();
2042 let result = merge_ref(p, "other", &mut warnings);
2043
2044 assert!(result.is_some(), "merge should succeed after preflight; warnings: {warnings:?}");
2045 assert!(
2046 warnings.iter().any(|w| w.contains("stale unmerged index")),
2047 "expected stale-entry warning; got: {warnings:?}"
2048 );
2049 }
2050
2051 #[test]
2052 fn merge_ref_conflict_aborts_and_warns() {
2053 let dir = git_init();
2054 let p = dir.path();
2055 make_commit(p, "f.txt", "main version\n");
2058 git_cmd(p, &["checkout", "-b", "feature"]);
2059 std::fs::write(p.join("f.txt"), "feature version\n").unwrap();
2060 git_cmd(p, &["add", "f.txt"]);
2061 git_cmd(p, &["commit", "-m", "feature change"]);
2062 git_cmd(p, &["checkout", "main"]);
2063 std::fs::write(p.join("f.txt"), "main change\n").unwrap();
2064 git_cmd(p, &["add", "f.txt"]);
2065 git_cmd(p, &["commit", "-m", "main change"]);
2066
2067 let mut warnings = Vec::new();
2068 let result = merge_ref(p, "feature", &mut warnings);
2069
2070 assert!(result.is_none(), "merge should report failure");
2071 assert!(
2072 warnings.iter().any(|w| w.contains("merge feature failed")),
2073 "expected merge-failure warning; got: {warnings:?}"
2074 );
2075 assert!(
2077 detect_mid_merge_state(p).is_none(),
2078 "merge_ref must abort on conflict so MERGE_HEAD does not persist"
2079 );
2080 }
2081
2082 #[test]
2083 fn detect_mid_merge_none_on_clean_repo() {
2084 let dir = git_init();
2085 make_commit(dir.path(), "f.txt", "hi");
2086 assert!(detect_mid_merge_state(dir.path()).is_none());
2087 }
2088
2089 #[test]
2090 fn detect_mid_merge_on_merge_head() {
2091 let dir = git_init();
2092 make_commit(dir.path(), "f.txt", "hi");
2093 std::fs::write(dir.path().join(".git/MERGE_HEAD"), "abc").unwrap();
2094 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::Merge)));
2095 }
2096
2097 #[test]
2098 fn detect_mid_merge_on_rebase_merge() {
2099 let dir = git_init();
2100 make_commit(dir.path(), "f.txt", "hi");
2101 std::fs::create_dir(dir.path().join(".git/rebase-merge")).unwrap();
2102 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseMerge)));
2103 }
2104
2105 #[test]
2106 fn detect_mid_merge_on_rebase_apply() {
2107 let dir = git_init();
2108 make_commit(dir.path(), "f.txt", "hi");
2109 std::fs::create_dir(dir.path().join(".git/rebase-apply")).unwrap();
2110 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseApply)));
2111 }
2112
2113 #[test]
2114 fn detect_mid_merge_on_cherry_pick() {
2115 let dir = git_init();
2116 make_commit(dir.path(), "f.txt", "hi");
2117 std::fs::write(dir.path().join(".git/CHERRY_PICK_HEAD"), "abc").unwrap();
2118 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::CherryPick)));
2119 }
2120
2121 #[test]
2122 fn is_file_tracked_tracked_and_untracked() {
2123 let dir = git_init();
2124 make_commit(dir.path(), "tracked.txt", "hi");
2125 assert!(is_file_tracked(dir.path(), "tracked.txt"));
2126 std::fs::write(dir.path().join("untracked.txt"), "new").unwrap();
2127 assert!(!is_file_tracked(dir.path(), "untracked.txt"));
2128 }
2129
2130 #[test]
2131 fn check_leaked_files_detects_overlap() {
2132 let dir = git_init();
2133 let p = dir.path();
2134 std::fs::create_dir_all(p.join("src")).unwrap();
2135 std::fs::write(p.join("src/foo.rs"), "original").unwrap();
2136 git_cmd(p, &["add", "src/foo.rs"]);
2137 git_cmd(p, &["commit", "-m", "add foo"]);
2138
2139 git_cmd(p, &["checkout", "-b", "ticket/overlap-test"]);
2140 std::fs::write(p.join("src/foo.rs"), "ticket-change").unwrap();
2141 git_cmd(p, &["add", "src/foo.rs"]);
2142 git_cmd(p, &["commit", "-m", "ticket: change foo"]);
2143 git_cmd(p, &["checkout", "main"]);
2144
2145 std::fs::write(p.join("src/foo.rs"), "leaked").unwrap();
2147
2148 let leaked = check_leaked_files(p, "ticket/overlap-test", "main").unwrap();
2149 assert_eq!(leaked, vec!["src/foo.rs".to_string()]);
2150 }
2151
2152 #[test]
2153 fn check_leaked_files_no_overlap() {
2154 let dir = git_init();
2155 let p = dir.path();
2156 std::fs::create_dir_all(p.join("src")).unwrap();
2157 std::fs::write(p.join("src/foo.rs"), "original").unwrap();
2158 std::fs::write(p.join("src/bar.rs"), "bar").unwrap();
2159 git_cmd(p, &["add", "src/foo.rs", "src/bar.rs"]);
2160 git_cmd(p, &["commit", "-m", "add foo and bar"]);
2161
2162 git_cmd(p, &["checkout", "-b", "ticket/no-overlap"]);
2164 std::fs::write(p.join("src/foo.rs"), "ticket-change").unwrap();
2165 git_cmd(p, &["add", "src/foo.rs"]);
2166 git_cmd(p, &["commit", "-m", "ticket: change foo"]);
2167 git_cmd(p, &["checkout", "main"]);
2168
2169 std::fs::write(p.join("src/bar.rs"), "dirty").unwrap();
2171
2172 let leaked = check_leaked_files(p, "ticket/no-overlap", "main").unwrap();
2173 assert!(leaked.is_empty(), "no overlap expected; got {leaked:?}");
2174 }
2175
2176 #[test]
2177 fn check_leaked_files_detects_untracked_overlap() {
2178 let dir = git_init();
2179 let p = dir.path();
2180 make_commit(p, "existing.rs", "base");
2181
2182 git_cmd(p, &["checkout", "-b", "ticket/untracked-overlap"]);
2184 std::fs::create_dir_all(p.join("src")).unwrap();
2185 std::fs::write(p.join("src/new.rs"), "new file").unwrap();
2186 git_cmd(p, &["add", "src/new.rs"]);
2187 git_cmd(p, &["commit", "-m", "ticket: add new file"]);
2188 git_cmd(p, &["checkout", "main"]);
2189
2190 std::fs::create_dir_all(p.join("src")).unwrap();
2192 std::fs::write(p.join("src/new.rs"), "leaked untracked").unwrap();
2193
2194 let leaked = check_leaked_files(p, "ticket/untracked-overlap", "main").unwrap();
2195 assert_eq!(leaked, vec!["src/new.rs".to_string()]);
2196 }
2197
2198 fn commit_file(dir: &Path, name: &str, content: &str) {
2202 std::fs::write(dir.join(name), content).unwrap();
2203 git_cmd(dir, &["add", name]);
2204 git_cmd(dir, &["commit", "-m", &format!("add {name}")]);
2205 }
2206
2207 #[test]
2210 fn content_merged_into_main_regular_merge_with_state_commit() {
2211 let dir = git_init();
2212 let p = dir.path();
2213
2214 commit_file(p, "README", "base");
2216
2217 git_cmd(p, &["checkout", "-b", "ticket/foo"]);
2219 std::fs::create_dir_all(p.join("src")).unwrap();
2220 commit_file(p, "src/lib.rs", "impl");
2221
2222 git_cmd(p, &["checkout", "main"]);
2224 git_cmd(p, &["merge", "--no-ff", "ticket/foo", "-m", "Merge ticket/foo"]);
2225
2226 git_cmd(p, &["checkout", "ticket/foo"]);
2228 std::fs::create_dir_all(p.join("tickets")).unwrap();
2229 commit_file(p, "tickets/foo.md", "state: implemented");
2230
2231 let result = content_merged_into_main(p, "main", "ticket/foo", "tickets").unwrap();
2233 assert!(result, "should detect that content was merged despite trailing state commit");
2234 }
2235
2236 #[test]
2239 fn content_merged_into_main_squash_merge_with_state_commit() {
2240 let dir = git_init();
2241 let p = dir.path();
2242
2243 commit_file(p, "README", "base");
2244
2245 git_cmd(p, &["checkout", "-b", "ticket/bar"]);
2247 std::fs::create_dir_all(p.join("src")).unwrap();
2248 commit_file(p, "src/lib.rs", "impl");
2249
2250 git_cmd(p, &["checkout", "main"]);
2252 git_cmd(p, &["merge", "--squash", "ticket/bar"]);
2253 git_cmd(p, &["commit", "-m", "Squash ticket/bar"]);
2254
2255 git_cmd(p, &["checkout", "ticket/bar"]);
2257 std::fs::create_dir_all(p.join("tickets")).unwrap();
2258 commit_file(p, "tickets/bar.md", "state: implemented");
2259
2260 let result = content_merged_into_main(p, "main", "ticket/bar", "tickets").unwrap();
2261 assert!(result, "should detect squash-merged content despite trailing state commit");
2262 }
2263
2264 #[test]
2267 fn content_merged_into_main_returns_false_when_ancestor() {
2268 let dir = git_init();
2269 let p = dir.path();
2270 commit_file(p, "README", "base");
2271 git_cmd(p, &["checkout", "-b", "ticket/anc"]);
2273 git_cmd(p, &["checkout", "main"]);
2275 let result = content_merged_into_main(p, "main", "ticket/anc", "tickets").unwrap();
2276 assert!(!result);
2277 }
2278
2279 #[test]
2281 fn content_merged_into_main_not_detected_when_non_ticket_file_modified_after_merge() {
2282 let dir = git_init();
2283 let p = dir.path();
2284 commit_file(p, "README", "base");
2285
2286 git_cmd(p, &["checkout", "-b", "ticket/extra"]);
2288 std::fs::create_dir_all(p.join("src")).unwrap();
2289 commit_file(p, "src/lib.rs", "impl");
2290
2291 git_cmd(p, &["checkout", "main"]);
2293 git_cmd(p, &["merge", "--squash", "ticket/extra"]);
2294 git_cmd(p, &["commit", "-m", "Squash ticket/extra"]);
2295
2296 git_cmd(p, &["checkout", "ticket/extra"]);
2298 std::fs::create_dir_all(p.join("tickets")).unwrap();
2299 commit_file(p, "tickets/extra.md", "state: implemented");
2300 commit_file(p, "src/extra.rs", "extra code");
2303
2304 let result = content_merged_into_main(p, "main", "ticket/extra", "tickets").unwrap();
2305 assert!(!result, "branch with non-ticket changes after merge must not be detected");
2306 }
2307
2308 #[test]
2311 fn content_merged_into_main_all_ticket_only_commits_returns_false() {
2312 let dir = git_init();
2313 let p = dir.path();
2314 commit_file(p, "README", "base");
2315
2316 git_cmd(p, &["checkout", "-b", "ticket/ticketonly"]);
2318 std::fs::create_dir_all(p.join("tickets")).unwrap();
2319 commit_file(p, "tickets/ticketonly.md", "state: new");
2320 git_cmd(p, &["checkout", "main"]);
2321
2322 let result = content_merged_into_main(p, "main", "ticket/ticketonly", "tickets").unwrap();
2323 assert!(!result, "all-ticket-only commits should return false");
2324 }
2325
2326 #[test]
2333 fn merged_into_main_detects_local_regular_merge_when_remote_deleted() {
2334 let dir = git_init();
2335 let p = dir.path();
2336 make_commit(p, "f.txt", "base");
2337
2338 git_cmd(p, &["checkout", "-b", "ticket/foo"]);
2340 std::fs::write(p.join("f.txt"), "ticket-change").unwrap();
2341 git_cmd(p, &["add", "f.txt"]);
2342 git_cmd(p, &["commit", "-m", "ticket: change"]);
2343
2344 git_cmd(p, &["checkout", "main"]);
2346 git_cmd(p, &["merge", "--no-ff", "ticket/foo", "-m", "Merge ticket/foo"]);
2347
2348 let main_sha = run(p, &["rev-parse", "main"]).unwrap();
2351 Cmd::new("git")
2352 .args(["update-ref", "refs/remotes/origin/main", main_sha.trim()])
2353 .current_dir(p)
2354 .status()
2355 .unwrap();
2356 let merged = merged_into_main(p, "main").unwrap();
2359 assert!(
2360 merged.iter().any(|b| b == "ticket/foo"),
2361 "expected ticket/foo in merged set; got {merged:?}"
2362 );
2363 }
2364
2365 fn git_init_with_remote() -> (TempDir, TempDir) {
2370 let bare = tempfile::tempdir().unwrap();
2372 Cmd::new("git")
2373 .args(["init", "--bare", "-q"])
2374 .current_dir(bare.path())
2375 .status()
2376 .unwrap();
2377
2378 let local = tempfile::tempdir().unwrap();
2380 let p = local.path();
2381 Cmd::new("git")
2382 .args(["clone", "-q", &bare.path().to_string_lossy(), "."])
2383 .current_dir(p)
2384 .env("GIT_AUTHOR_NAME", "test")
2385 .env("GIT_AUTHOR_EMAIL", "t@t.com")
2386 .env("GIT_COMMITTER_NAME", "test")
2387 .env("GIT_COMMITTER_EMAIL", "t@t.com")
2388 .status()
2389 .unwrap();
2390 git_cmd(p, &["config", "user.name", "test"]);
2391 git_cmd(p, &["config", "user.email", "t@t.com"]);
2392
2393 (bare, local)
2394 }
2395
2396 #[test]
2397 fn read_from_branch_with_class_behind_returns_origin_content() {
2398 let (bare, local) = git_init_with_remote();
2399 let p = local.path();
2400
2401 make_commit(p, "README", "base");
2403 git_cmd(p, &["push", "origin", "main"]);
2404
2405 git_cmd(p, &["checkout", "-b", "ticket/abc"]);
2407 make_commit(p, "tickets/abc.md", "state: ready\n");
2408 git_cmd(p, &["push", "origin", "ticket/abc"]);
2409
2410 let remote2 = tempfile::tempdir().unwrap();
2412 let r2 = remote2.path();
2413 Cmd::new("git")
2414 .args(["clone", "-q", &bare.path().to_string_lossy(), "."])
2415 .current_dir(r2)
2416 .env("GIT_AUTHOR_NAME", "test")
2417 .env("GIT_AUTHOR_EMAIL", "t@t.com")
2418 .env("GIT_COMMITTER_NAME", "test")
2419 .env("GIT_COMMITTER_EMAIL", "t@t.com")
2420 .status()
2421 .unwrap();
2422 git_cmd(r2, &["config", "user.name", "test"]);
2423 git_cmd(r2, &["config", "user.email", "t@t.com"]);
2424 git_cmd(r2, &["checkout", "ticket/abc"]);
2425 make_commit(r2, "tickets/abc.md", "state: in_progress\n");
2426 git_cmd(r2, &["push", "origin", "ticket/abc"]);
2427
2428 git_cmd(p, &["fetch", "--all", "--quiet"]);
2430
2431 let (content, class) = read_from_branch_with_class(p, "ticket/abc", "tickets/abc.md").unwrap();
2433 assert!(
2434 matches!(class, BranchClass::Behind),
2435 "expected Behind; got something else"
2436 );
2437 assert!(
2438 content.contains("in_progress"),
2439 "expected origin content 'in_progress'; got: {content:?}"
2440 );
2441 }
2442
2443 #[test]
2444 fn read_from_branch_with_class_ahead_returns_local_content() {
2445 let (_bare, local) = git_init_with_remote();
2446 let p = local.path();
2447
2448 make_commit(p, "README", "base");
2449 git_cmd(p, &["push", "origin", "main"]);
2450
2451 git_cmd(p, &["checkout", "-b", "ticket/xyz"]);
2452 make_commit(p, "tickets/xyz.md", "state: ready\n");
2453 git_cmd(p, &["push", "origin", "ticket/xyz"]);
2454
2455 make_commit(p, "tickets/xyz.md", "state: in_progress\n");
2457
2458 git_cmd(p, &["fetch", "--all", "--quiet"]);
2460
2461 let (content, class) = read_from_branch_with_class(p, "ticket/xyz", "tickets/xyz.md").unwrap();
2462 assert!(
2463 matches!(class, BranchClass::Ahead),
2464 "expected Ahead"
2465 );
2466 assert!(
2467 content.contains("in_progress"),
2468 "expected local content; got: {content:?}"
2469 );
2470 }
2471
2472 #[test]
2473 fn read_from_branch_with_class_equal_returns_content() {
2474 let (_bare, local) = git_init_with_remote();
2475 let p = local.path();
2476
2477 make_commit(p, "README", "base");
2478 git_cmd(p, &["push", "origin", "main"]);
2479
2480 git_cmd(p, &["checkout", "-b", "ticket/eq"]);
2481 make_commit(p, "tickets/eq.md", "state: ready\n");
2482 git_cmd(p, &["push", "origin", "ticket/eq"]);
2483
2484 let (content, class) = read_from_branch_with_class(p, "ticket/eq", "tickets/eq.md").unwrap();
2485 assert!(
2486 matches!(class, BranchClass::Equal),
2487 "expected Equal"
2488 );
2489 assert!(content.contains("ready"), "expected ready content; got: {content:?}");
2490 }
2491
2492 #[test]
2493 fn read_from_branch_with_class_remote_only_returns_origin_content() {
2494 let (bare, local) = git_init_with_remote();
2495 let p = local.path();
2496
2497 make_commit(p, "README", "base");
2498 git_cmd(p, &["push", "origin", "main"]);
2499
2500 let remote2 = tempfile::tempdir().unwrap();
2502 let r2 = remote2.path();
2503 Cmd::new("git")
2504 .args(["clone", "-q", &bare.path().to_string_lossy(), "."])
2505 .current_dir(r2)
2506 .env("GIT_AUTHOR_NAME", "test")
2507 .env("GIT_AUTHOR_EMAIL", "t@t.com")
2508 .env("GIT_COMMITTER_NAME", "test")
2509 .env("GIT_COMMITTER_EMAIL", "t@t.com")
2510 .status()
2511 .unwrap();
2512 git_cmd(r2, &["config", "user.name", "test"]);
2513 git_cmd(r2, &["config", "user.email", "t@t.com"]);
2514 git_cmd(r2, &["checkout", "-b", "ticket/ro"]);
2515 make_commit(r2, "tickets/ro.md", "state: ready\n");
2516 git_cmd(r2, &["push", "origin", "ticket/ro"]);
2517
2518 git_cmd(p, &["fetch", "--all", "--quiet"]);
2520
2521 let (content, class) = read_from_branch_with_class(p, "ticket/ro", "tickets/ro.md").unwrap();
2522 assert!(
2523 matches!(class, BranchClass::RemoteOnly),
2524 "expected RemoteOnly"
2525 );
2526 assert!(content.contains("ready"), "expected ready content; got: {content:?}");
2527 }
2528}