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