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 ticket_branches(root: &Path) -> Result<Vec<String>> {
43 let mut seen = std::collections::HashSet::new();
44 let mut branches = Vec::new();
45
46 let local = run(root, &["branch", "--list", "ticket/*"]).unwrap_or_default();
47 for b in local.lines()
48 .map(|l| l.trim().trim_start_matches(['*', '+']).trim())
49 .filter(|l| !l.is_empty())
50 {
51 if seen.insert(b.to_string()) {
52 branches.push(b.to_string());
53 }
54 }
55
56 let remote = run(root, &["branch", "-r", "--list", "origin/ticket/*"]).unwrap_or_default();
57 for b in remote.lines()
58 .map(|l| l.trim().trim_start_matches("origin/").to_string())
59 .filter(|l| !l.is_empty())
60 {
61 if seen.insert(b.clone()) {
62 branches.push(b);
63 }
64 }
65
66 Ok(branches)
67}
68
69pub fn merged_into_main(root: &Path, default_branch: &str) -> Result<Vec<String>> {
72 let remote_ref = format!("refs/remotes/origin/{default_branch}");
73 let remote_merged = format!("origin/{default_branch}");
74
75 if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
76 let regular_out = run(
78 root,
79 &["branch", "-r", "--merged", &remote_merged, "--list", "origin/ticket/*"],
80 )
81 .unwrap_or_default();
82 let mut merged: Vec<String> = regular_out
83 .lines()
84 .map(|l| l.trim().trim_start_matches("origin/").to_string())
85 .filter(|l| !l.is_empty())
86 .collect();
87 let merged_set: std::collections::HashSet<String> = merged.iter().cloned().collect();
88
89 let all_remote = run(root, &["branch", "-r", "--list", "origin/ticket/*"])
92 .unwrap_or_default();
93 let remote_candidates: Vec<String> = all_remote
94 .lines()
95 .map(|l| l.trim().to_string())
96 .filter(|l| {
97 let stripped = l.strip_prefix("origin/").unwrap_or(l.as_str());
98 !l.is_empty() && !merged_set.contains(stripped)
99 })
100 .collect();
101 let remote_squashed = squash_merged(root, &remote_merged, remote_candidates)?;
102 merged.extend(remote_squashed.into_iter().map(|b| {
104 b.strip_prefix("origin/").unwrap_or(&b).to_string()
105 }));
106
107 let remote_stripped: std::collections::HashSet<String> = all_remote
110 .lines()
111 .map(|l| l.trim().trim_start_matches("origin/").to_string())
112 .filter(|l| !l.is_empty())
113 .collect();
114 let merged_now: std::collections::HashSet<String> = merged.iter().cloned().collect();
115 let all_local = run(root, &["branch", "--list", "ticket/*"]).unwrap_or_default();
116 let local_only: Vec<String> = all_local
117 .lines()
118 .map(|l| l.trim().trim_start_matches(['*', '+']).trim().to_string())
119 .filter(|l| {
120 !l.is_empty()
121 && !remote_stripped.contains(l)
122 && !merged_now.contains(l)
123 })
124 .collect();
125 merged.extend(squash_merged(root, &remote_merged, local_only)?);
126
127 let local_default_ref = format!("refs/heads/{default_branch}");
135 if run(root, &["rev-parse", "--verify", &local_default_ref]).is_ok() {
136 let already: std::collections::HashSet<String> = merged.iter().cloned().collect();
137 let local_regular = run(
138 root,
139 &["branch", "--merged", default_branch, "--list", "ticket/*"],
140 )
141 .unwrap_or_default();
142 for line in local_regular.lines() {
143 let b = line.trim().trim_start_matches(['*', '+']).trim().to_string();
144 if !b.is_empty() && !already.contains(&b) {
145 merged.push(b);
146 }
147 }
148 }
149
150 return Ok(merged);
151 }
152
153 let local_ref = format!("refs/heads/{default_branch}");
155 if run(root, &["rev-parse", "--verify", &local_ref]).is_err() {
156 return Ok(vec![]);
157 }
158 let regular_out = run(
159 root,
160 &["branch", "--merged", default_branch, "--list", "ticket/*"],
161 )
162 .unwrap_or_default();
163 let mut merged: Vec<String> = regular_out
164 .lines()
165 .map(|l| l.trim().trim_start_matches(['*', '+']).trim().to_string())
166 .filter(|l| !l.is_empty())
167 .collect();
168 let merged_set: std::collections::HashSet<&str> = merged.iter().map(|s| s.as_str()).collect();
169
170 let all_local = run(root, &["branch", "--list", "ticket/*"]).unwrap_or_default();
171 let candidates: Vec<String> = all_local
172 .lines()
173 .map(|l| l.trim().trim_start_matches(['*', '+']).trim().to_string())
174 .filter(|l| !l.is_empty() && !merged_set.contains(l.as_str()))
175 .collect();
176 merged.extend(squash_merged(root, default_branch, candidates)?);
177 Ok(merged)
178}
179
180fn squash_merged(root: &Path, main_ref: &str, candidates: Vec<String>) -> Result<Vec<String>> {
187 let mut result = Vec::new();
188 for branch in candidates {
189 let merge_base = match run(root, &["merge-base", main_ref, &branch]) {
190 Ok(mb) => mb,
191 Err(_) => continue,
192 };
193 let branch_tip = match run(root, &["rev-parse", &format!("{branch}^{{commit}}")]) {
194 Ok(t) => t,
195 Err(_) => continue,
196 };
197 if branch_tip == merge_base {
199 continue;
200 }
201 let squash_commit = match run(root, &[
203 "commit-tree", &format!("{branch}^{{tree}}"),
204 "-p", &merge_base,
205 "-m", "squash",
206 ]) {
207 Ok(c) => c,
208 Err(_) => continue,
209 };
210 let cherry_out = match run(root, &["cherry", main_ref, &squash_commit]) {
212 Ok(o) => o,
213 Err(_) => continue,
214 };
215 if cherry_out.trim().starts_with('-') {
216 result.push(branch);
217 }
218 }
219 Ok(result)
220}
221
222pub fn content_merged_into_main(
229 root: &Path,
230 main_ref: &str,
231 branch: &str,
232 tickets_dir: &str,
233) -> Result<bool> {
234 let merge_base = match run(root, &["merge-base", main_ref, branch]) {
236 Ok(mb) => mb,
237 Err(_) => return Ok(false),
238 };
239 let branch_tip = match run(root, &["rev-parse", &format!("{branch}^{{commit}}")]) {
241 Ok(t) => t,
242 Err(_) => return Ok(false),
243 };
244 if branch_tip == merge_base {
246 return Ok(false);
247 }
248 let log_out = match run(root, &["log", "--pretty=%H", branch, &format!("^{merge_base}")]) {
250 Ok(o) => o,
251 Err(_) => return Ok(false),
252 };
253 let tickets_prefix = format!("{tickets_dir}/");
255 let mut content_tip: Option<String> = None;
256 for sha in log_out.lines() {
257 let diff_out = match run(root, &["diff-tree", "--no-commit-id", "-r", "--name-only", sha]) {
258 Ok(o) => o,
259 Err(_) => continue,
260 };
261 let has_non_ticket = diff_out.lines().any(|path| !path.starts_with(&tickets_prefix));
262 if has_non_ticket {
263 content_tip = Some(sha.to_string());
264 break;
265 }
266 }
267 if content_tip.is_none() {
269 let parent_spec = format!("{merge_base}^1");
283 if let Ok(fp_log) = run(root, &[
284 "rev-list", "--first-parent", main_ref, &format!("^{parent_spec}"),
285 ]) {
286 let oldest = fp_log.lines().last().unwrap_or("").trim();
287 if !oldest.is_empty() && oldest != merge_base {
288 return Ok(true);
290 }
291 }
292 return Ok(false);
293 }
294 let content_tip = content_tip.unwrap();
295 if content_tip == branch_tip {
298 return Ok(false);
299 }
300 let squash_commit = match run(root, &[
302 "commit-tree", &format!("{content_tip}^{{tree}}"),
303 "-p", &merge_base,
304 "-m", "squash",
305 ]) {
306 Ok(c) => c,
307 Err(_) => return Ok(false),
308 };
309 let cherry_out = match run(root, &["cherry", main_ref, &squash_commit]) {
311 Ok(o) => o,
312 Err(_) => return Ok(false),
313 };
314 Ok(cherry_out.trim().starts_with('-'))
315}
316
317pub fn commit_to_branch(
323 root: &Path,
324 branch: &str,
325 rel_path: &str,
326 content: &str,
327 message: &str,
328) -> Result<()> {
329 if !has_commits(root) {
331 let local_path = root.join(rel_path);
332 if let Some(parent) = local_path.parent() {
333 std::fs::create_dir_all(parent)?;
334 }
335 std::fs::write(&local_path, content)?;
336 return Ok(());
337 }
338
339 if let Some(wt_path) = find_worktree_for_branch(root, branch) {
341 let remote_ref = format!("origin/{branch}");
345 if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
346 let _ = run(&wt_path, &["merge", "--ff-only", &remote_ref]);
347 }
348 let full_path = wt_path.join(rel_path);
349 if let Some(parent) = full_path.parent() {
350 std::fs::create_dir_all(parent)?;
351 }
352 std::fs::write(&full_path, content)?;
353 run(&wt_path, &["add", rel_path])
354 .with_context(|| format!("git add {rel_path} in worktree {} failed", wt_path.display()))?;
355 run(&wt_path, &["commit", "-m", message])
356 .with_context(|| format!("git commit on {branch} in worktree {} failed", wt_path.display()))?;
357 crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
358 return Ok(());
359 }
360
361 if current_branch(root).ok().as_deref() == Some(branch) {
363 let local_path = root.join(rel_path);
364 if let Some(parent) = local_path.parent() {
365 std::fs::create_dir_all(parent)?;
366 }
367 std::fs::write(&local_path, content)?;
368 run(root, &["add", rel_path])
369 .with_context(|| format!("git add {rel_path} failed"))?;
370 run(root, &["commit", "-m", message])
371 .with_context(|| format!("git commit on {branch} failed"))?;
372 crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
373 return Ok(());
374 }
375
376 let result = try_worktree_commit(root, branch, rel_path, content, message);
377 if result.is_ok() {
378 crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
379 }
380 result
381}
382
383fn try_worktree_commit(
384 root: &Path,
385 branch: &str,
386 rel_path: &str,
387 content: &str,
388 message: &str,
389) -> Result<()> {
390 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
391 let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
392 let wt_path = std::env::temp_dir().join(format!(
393 "apm-{}-{}-{}",
394 std::process::id(),
395 seq,
396 branch.replace('/', "-"),
397 ));
398
399 let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
400 let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
401
402 if has_remote {
403 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
404 let _ = run(&wt_path, &["checkout", "-B", branch]);
405 } else if has_local {
406 let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
408 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
409 let _ = run(&wt_path, &["checkout", "-B", branch]);
410 } else {
411 run(root, &["worktree", "add", "-b", branch, &wt_path.to_string_lossy(), "HEAD"])?;
412 }
413
414 let result = (|| -> Result<()> {
415 let full_path = wt_path.join(rel_path);
416 if let Some(parent) = full_path.parent() {
417 std::fs::create_dir_all(parent)?;
418 }
419 std::fs::write(&full_path, content)?;
420 run(&wt_path, &["add", rel_path])?;
421 run(&wt_path, &["commit", "-m", message])?;
422 Ok(())
423 })();
424
425 let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
426 let _ = std::fs::remove_dir_all(&wt_path);
427
428 result
429}
430
431
432pub fn push_ticket_branches(root: &Path, warnings: &mut Vec<String>) {
435 if run(root, &["remote", "get-url", "origin"]).is_err() {
436 return;
437 }
438 let out = match run(root, &["branch", "--list", "ticket/*"]) {
439 Ok(o) => o,
440 Err(_) => return,
441 };
442 for branch in out.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
443 let range = format!("origin/{branch}..{branch}");
444 let count = run(root, &["rev-list", "--count", &range])
445 .ok()
446 .and_then(|s| s.trim().parse::<u32>().ok())
447 .unwrap_or(0);
448 if count > 0 {
449 if let Err(e) = run(root, &["push", "origin", branch]) {
450 warnings.push(format!("warning: push {branch} failed: {e:#}"));
451 }
452 }
453 }
454}
455
456pub fn sync_non_checked_out_refs(root: &Path, warnings: &mut Vec<String>) -> Vec<String> {
484 let checked_out: std::collections::HashSet<String> = {
487 let mut set = std::collections::HashSet::new();
488 if let Ok(out) = run(root, &["worktree", "list", "--porcelain"]) {
489 for line in out.lines() {
490 if let Some(b) = line.strip_prefix("branch refs/heads/") {
491 set.insert(b.to_string());
492 }
493 }
494 }
495 set
496 };
497
498 const MANAGED_NAMESPACES: &[&str] = &["ticket", "epic"];
501
502 let mut remote_refs: Vec<String> = Vec::new();
504 for ns in MANAGED_NAMESPACES {
505 let pattern = format!("refs/remotes/origin/{ns}/");
506 if let Ok(out) = run(root, &["for-each-ref", "--format=%(refname:short)", &pattern]) {
507 for line in out.lines().filter(|l| !l.is_empty()) {
508 remote_refs.push(line.to_string());
509 }
510 }
511 }
512
513 let mut ahead_branches: Vec<String> = Vec::new();
514
515 for remote_name in remote_refs {
516 let branch = match remote_name.strip_prefix("origin/") {
519 Some(b) => b.to_string(),
520 None => continue,
521 };
522
523 if checked_out.contains(&branch) {
525 continue;
526 }
527
528 let local_ref = format!("refs/heads/{branch}");
529 let remote_ref_full = format!("refs/remotes/{remote_name}");
531
532 match classify_branch(root, &local_ref, &remote_name) {
535 BranchClass::RemoteOnly => {
536 let sha = match run(root, &["rev-parse", &remote_ref_full]) {
539 Ok(s) => s,
540 Err(_) => continue,
541 };
542 if let Err(e) = run(root, &["update-ref", &local_ref, &sha]) {
543 warnings.push(format!("warning: could not create local ref {branch}: {e:#}"));
544 }
545 }
546 BranchClass::Equal => {
547 }
549 BranchClass::Behind => {
550 let sha = match run(root, &["rev-parse", &remote_ref_full]) {
553 Ok(s) => s,
554 Err(_) => continue,
555 };
556 if let Err(e) = run(root, &["update-ref", &local_ref, &sha]) {
557 warnings.push(format!("warning: could not fast-forward {branch}: {e:#}"));
558 }
559 }
560 BranchClass::Ahead => {
561 warnings.push(crate::sync_guidance::TICKET_OR_EPIC_AHEAD.replace("<slug>", &branch));
567 ahead_branches.push(branch);
568 }
569 BranchClass::Diverged => {
570 let msg = crate::sync_guidance::TICKET_OR_EPIC_DIVERGED
573 .replace("<slug>", &branch);
574 warnings.push(msg);
575 }
576 BranchClass::NoRemote => {
577 }
580 }
581 }
582
583 ahead_branches
584}
585
586pub fn list_files_on_branch(root: &Path, branch: &str, dir: &str) -> Result<Vec<String>> {
588 let tree_ref = format!("{branch}:{dir}");
589 let out = run(root, &["ls-tree", "--name-only", &tree_ref])
590 .or_else(|_| run(root, &["ls-tree", "--name-only", &format!("origin/{branch}:{dir}")]))?;
591 Ok(out.lines()
592 .filter(|l| !l.is_empty())
593 .map(|l| format!("{dir}/{l}"))
594 .collect())
595}
596
597pub fn commit_files_to_branch(
599 root: &Path,
600 branch: &str,
601 files: &[(&str, String)],
602 message: &str,
603) -> Result<()> {
604 if !has_commits(root) {
605 for (rel_path, content) in files {
606 let local_path = root.join(rel_path);
607 if let Some(parent) = local_path.parent() {
608 std::fs::create_dir_all(parent)?;
609 }
610 std::fs::write(&local_path, content)?;
611 }
612 return Ok(());
613 }
614
615 if let Some(wt_path) = find_worktree_for_branch(root, branch) {
616 for (rel_path, content) in files {
617 let full_path = wt_path.join(rel_path);
618 if let Some(parent) = full_path.parent() {
619 std::fs::create_dir_all(parent)?;
620 }
621 std::fs::write(&full_path, content)?;
622 let _ = run(&wt_path, &["add", rel_path]);
623 }
624 run(&wt_path, &["commit", "-m", message])?;
625 crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
626 return Ok(());
627 }
628
629 if current_branch(root).ok().as_deref() == Some(branch) {
630 for (rel_path, content) in files {
631 let local_path = root.join(rel_path);
632 if let Some(parent) = local_path.parent() {
633 std::fs::create_dir_all(parent)?;
634 }
635 std::fs::write(&local_path, content)?;
636 let _ = run(root, &["add", rel_path]);
637 }
638 run(root, &["commit", "-m", message])?;
639 crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
640 return Ok(());
641 }
642
643 let unique = std::time::SystemTime::now()
644 .duration_since(std::time::UNIX_EPOCH)
645 .map(|d| d.subsec_nanos())
646 .unwrap_or(0);
647 let wt_path = std::env::temp_dir().join(format!(
648 "apm-{}-{}-{}",
649 std::process::id(),
650 unique,
651 branch.replace('/', "-"),
652 ));
653
654 let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
655 let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
656
657 if has_remote {
658 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
659 let _ = run(&wt_path, &["checkout", "-B", branch]);
660 } else if has_local {
661 let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
662 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
663 let _ = run(&wt_path, &["checkout", "-B", branch]);
664 } else {
665 run(root, &["worktree", "add", &wt_path.to_string_lossy(), branch])?;
666 }
667
668 let result = (|| -> Result<()> {
669 for (rel_path, content) in files {
670 let full_path = wt_path.join(rel_path);
671 if let Some(parent) = full_path.parent() {
672 std::fs::create_dir_all(parent)?;
673 }
674 std::fs::write(&full_path, content)?;
675 run(&wt_path, &["add", rel_path])?;
676 }
677 run(&wt_path, &["commit", "-m", message])?;
678 Ok(())
679 })();
680
681 let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
682 let _ = std::fs::remove_dir_all(&wt_path);
683
684 if result.is_ok() {
685 crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
686 }
687 result
688}
689
690pub fn branch_tip(root: &Path, branch: &str) -> Option<String> {
692 run(root, &["rev-parse", &format!("refs/heads/{branch}")]).ok()
693}
694
695pub fn resolve_branch_sha(root: &Path, branch: &str) -> Result<String> {
698 run(root, &["rev-parse", &format!("origin/{branch}")])
699 .or_else(|_| run(root, &["rev-parse", branch]))
700 .with_context(|| format!("branch '{branch}' not found locally or on origin"))
701}
702
703pub fn create_branch_at(root: &Path, branch: &str, sha: &str) -> Result<()> {
705 run(root, &["branch", branch, sha]).map(|_| ())
706}
707
708pub fn remote_branch_tip(root: &Path, branch: &str) -> Option<String> {
710 run(root, &["rev-parse", &format!("refs/remotes/origin/{branch}")]).ok()
711}
712
713pub fn is_ancestor(root: &Path, commit: &str, of_ref: &str) -> bool {
716 Command::new("git")
717 .current_dir(root)
718 .args(["merge-base", "--is-ancestor", commit, of_ref])
719 .status()
720 .map(|s| s.success())
721 .unwrap_or(false)
722}
723
724pub enum BranchClass {
734 Equal,
735 Behind,
736 Ahead,
737 Diverged,
738 RemoteOnly,
740 NoRemote,
742}
743
744pub fn classify_branch(root: &Path, local: &str, remote: &str) -> BranchClass {
752 let local_sha = match run(root, &["rev-parse", local]) {
753 Ok(s) => s,
754 Err(_) => {
755 return if run(root, &["rev-parse", remote]).is_ok() {
759 BranchClass::RemoteOnly
760 } else {
761 BranchClass::NoRemote
762 };
763 }
764 };
765 let remote_sha = match run(root, &["rev-parse", remote]) {
766 Ok(s) => s,
767 Err(_) => return BranchClass::NoRemote,
768 };
769
770 if local_sha == remote_sha {
771 return BranchClass::Equal;
772 }
773
774 let local_is_ancestor_of_remote = is_ancestor(root, local, remote);
777
778 let remote_is_ancestor_of_local = is_ancestor(root, remote, local);
781
782 match (local_is_ancestor_of_remote, remote_is_ancestor_of_local) {
783 (true, false) => BranchClass::Behind, (false, true) => BranchClass::Ahead, (false, false) => BranchClass::Diverged, (true, true) => BranchClass::Equal, }
788}
789
790pub fn sync_default_branch(root: &Path, default: &str, warnings: &mut Vec<String>) -> bool {
815 let remote = format!("origin/{default}");
816 match classify_branch(root, default, &remote) {
817 BranchClass::Equal => {
818 }
820
821 BranchClass::Behind => {
822 let wt = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
825 if run(&wt, &["merge", "--ff-only", &remote]).is_err() {
826 let msg = crate::sync_guidance::MAIN_BEHIND_DIRTY_OVERLAP
830 .replace("<default>", default);
831 warnings.push(msg);
832 }
833 }
834
835 BranchClass::Ahead => {
836 let count = run(root, &["rev-list", "--count", &format!("{remote}..{default}")])
839 .ok()
840 .and_then(|s| s.trim().parse::<u64>().ok())
841 .unwrap_or(0);
842 let msg = crate::sync_guidance::MAIN_AHEAD
843 .replace("<default>", default)
844 .replace("<remote>", &remote)
845 .replace("<count>", &count.to_string())
846 .replace("<commits>", if count == 1 { "commit" } else { "commits" });
847 warnings.push(msg);
848 return true;
849 }
850
851 BranchClass::Diverged => {
852 let wt = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
855 let guidance = if is_worktree_dirty(&wt) {
856 crate::sync_guidance::MAIN_DIVERGED_DIRTY.replace("<default>", default)
857 } else {
858 crate::sync_guidance::MAIN_DIVERGED_CLEAN.replace("<default>", default)
859 };
860 warnings.push(guidance);
861 }
862
863 BranchClass::RemoteOnly => {
864 }
868
869 BranchClass::NoRemote => {
870 }
874 }
875 false
876}
877
878pub fn fetch_branch(root: &Path, branch: &str) -> anyhow::Result<()> {
879 let status = std::process::Command::new("git")
880 .args(["fetch", "origin", branch])
881 .current_dir(root)
882 .status()?;
883 if !status.success() {
884 anyhow::bail!("git fetch failed");
885 }
886 Ok(())
887}
888
889pub fn push_branch(root: &Path, branch: &str) -> anyhow::Result<()> {
890 let status = std::process::Command::new("git")
891 .args(["push", "origin", &format!("{branch}:{branch}")])
892 .current_dir(root)
893 .status()?;
894 if !status.success() {
895 anyhow::bail!("git push failed");
896 }
897 Ok(())
898}
899
900pub fn push_branch_tracking(root: &Path, branch: &str) -> anyhow::Result<()> {
901 let out = std::process::Command::new("git")
902 .args(["push", "--set-upstream", "origin", &format!("{branch}:{branch}")])
903 .current_dir(root)
904 .output()?;
905 if !out.status.success() {
906 anyhow::bail!("git push failed: {}", String::from_utf8_lossy(&out.stderr).trim());
907 }
908 Ok(())
909}
910
911pub fn has_remote(root: &Path) -> bool {
912 run(root, &["remote", "get-url", "origin"]).is_ok()
913}
914
915pub fn remote_ticket_branches_with_dates(
920 root: &Path,
921) -> Result<Vec<(String, chrono::DateTime<chrono::Utc>)>> {
922 use chrono::{TimeZone, Utc};
923 let out = Command::new("git")
924 .current_dir(root)
925 .args([
926 "for-each-ref",
927 "refs/remotes/origin/ticket/",
928 "--format=%(refname:short) %(creatordate:unix)",
929 ])
930 .output()
931 .context("git for-each-ref failed")?;
932 let stdout = String::from_utf8_lossy(&out.stdout);
933 let mut result = Vec::new();
934 for line in stdout.lines() {
935 let mut parts = line.splitn(2, ' ');
936 let refname = parts.next().unwrap_or("").trim();
937 let ts_str = parts.next().unwrap_or("").trim();
938 let branch = refname.trim_start_matches("origin/");
939 if branch.is_empty() {
940 continue;
941 }
942 if let Ok(ts) = ts_str.parse::<i64>() {
943 if let Some(dt) = Utc.timestamp_opt(ts, 0).single() {
944 result.push((branch.to_string(), dt));
945 }
946 }
947 }
948 Ok(result)
949}
950
951pub fn list_remote_ticket_branches(root: &Path) -> std::collections::HashSet<String> {
957 let mut set = std::collections::HashSet::new();
958 let out = match run(root, &["ls-remote", "--heads", "origin", "ticket/*"]) {
959 Ok(o) => o,
960 Err(_) => return set,
961 };
962 for line in out.lines() {
963 if let Some(refname) = line.split('\t').nth(1) {
964 if let Some(branch) = refname.trim().strip_prefix("refs/heads/") {
965 set.insert(branch.to_string());
966 }
967 }
968 }
969 set
970}
971
972pub fn delete_remote_branch(root: &Path, branch: &str) -> Result<()> {
974 let status = Command::new("git")
975 .current_dir(root)
976 .args(["push", "origin", "--delete", branch])
977 .status()
978 .context("git push origin --delete failed")?;
979 if !status.success() {
980 anyhow::bail!("git push origin --delete {branch} failed");
981 }
982 Ok(())
983}
984
985pub fn move_files_on_branch(
990 root: &Path,
991 branch: &str,
992 moves: &[(&str, &str, &str)],
993 message: &str,
994) -> Result<()> {
995 if !has_commits(root) {
996 for (old, new, content) in moves {
997 let new_path = root.join(new);
998 if let Some(parent) = new_path.parent() {
999 std::fs::create_dir_all(parent)?;
1000 }
1001 std::fs::write(&new_path, content)?;
1002 let old_path = root.join(old);
1003 let _ = std::fs::remove_file(&old_path);
1004 }
1005 return Ok(());
1006 }
1007
1008 let do_moves = |wt: &Path| -> Result<()> {
1009 for (old, new, content) in moves {
1010 let new_path = wt.join(new);
1011 if let Some(parent) = new_path.parent() {
1012 std::fs::create_dir_all(parent)?;
1013 }
1014 std::fs::write(&new_path, content)?;
1015 run(wt, &["add", new])?;
1016 run(wt, &["rm", "--force", "--quiet", old])?;
1017 }
1018 run(wt, &["commit", "-m", message])?;
1019 Ok(())
1020 };
1021
1022 if let Some(wt_path) = find_worktree_for_branch(root, branch) {
1023 let remote_ref = format!("origin/{branch}");
1024 if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
1025 let _ = run(&wt_path, &["merge", "--ff-only", &remote_ref]);
1026 }
1027 let result = do_moves(&wt_path);
1028 if result.is_ok() {
1029 crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1030 }
1031 return result;
1032 }
1033
1034 if current_branch(root).ok().as_deref() == Some(branch) {
1035 let result = do_moves(root);
1036 if result.is_ok() {
1037 crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1038 }
1039 return result;
1040 }
1041
1042 let unique = std::time::SystemTime::now()
1043 .duration_since(std::time::UNIX_EPOCH)
1044 .map(|d| d.subsec_nanos())
1045 .unwrap_or(0);
1046 let wt_path = std::env::temp_dir().join(format!(
1047 "apm-{}-{}-{}",
1048 std::process::id(),
1049 unique,
1050 branch.replace('/', "-"),
1051 ));
1052
1053 let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
1054 let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
1055
1056 if has_remote {
1057 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
1058 let _ = run(&wt_path, &["checkout", "-B", branch]);
1059 } else if has_local {
1060 let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
1061 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
1062 let _ = run(&wt_path, &["checkout", "-B", branch]);
1063 } else {
1064 run(root, &["worktree", "add", &wt_path.to_string_lossy(), branch])?;
1065 }
1066
1067 let result = do_moves(&wt_path);
1068 let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
1069 let _ = std::fs::remove_dir_all(&wt_path);
1070 if result.is_ok() {
1071 crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1072 }
1073 result
1074}
1075
1076pub fn merge_branch_into_default(root: &Path, branch: &str, default_branch: &str, warnings: &mut Vec<String>) -> Result<()> {
1077 let _ = run(root, &["fetch", "origin", default_branch]);
1078
1079 let merge_dir = if current_branch(root).ok().as_deref() == Some(default_branch) {
1080 root.to_path_buf()
1081 } else {
1082 find_worktree_for_branch(root, default_branch).unwrap_or_else(|| root.to_path_buf())
1083 };
1084
1085 if let Err(e) = run(&merge_dir, &["merge", "--no-ff", branch, "--no-edit"]) {
1086 let _ = run(&merge_dir, &["merge", "--abort"]);
1087 anyhow::bail!("merge failed: {e:#}");
1088 }
1089
1090 if has_remote(root) {
1091 if let Err(e) = run(&merge_dir, &["push", "origin", default_branch]) {
1092 warnings.push(format!("warning: push {default_branch} failed: {e:#}"));
1093 }
1094 }
1095 Ok(())
1096}
1097
1098pub 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<()> {
1099 let _ = std::process::Command::new("git")
1100 .args(["fetch", "origin", default_branch])
1101 .current_dir(root)
1102 .status();
1103
1104 let current = std::process::Command::new("git")
1105 .args(["rev-parse", "--abbrev-ref", "HEAD"])
1106 .current_dir(root)
1107 .output()?;
1108 let current_branch = String::from_utf8_lossy(¤t.stdout).trim().to_string();
1109
1110 let merge_dir = if current_branch == default_branch {
1111 root.to_path_buf()
1112 } else {
1113 let main_root = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
1114 let worktrees_base = main_root.join(&config.worktrees.dir);
1115 ensure_worktree(root, &worktrees_base, default_branch)?
1116 };
1117
1118 let out = std::process::Command::new("git")
1119 .args(["merge", "--no-ff", branch, "--no-edit"])
1120 .current_dir(&merge_dir)
1121 .output()?;
1122
1123 if !out.status.success() {
1124 let _ = std::process::Command::new("git")
1125 .args(["merge", "--abort"])
1126 .current_dir(&merge_dir)
1127 .status();
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 local_branch_exists(root: &Path, branch: &str) -> bool {
1201 Command::new("git")
1202 .args(["-C", &root.to_string_lossy(), "rev-parse", "--verify", &format!("refs/heads/{branch}")])
1203 .output()
1204 .map(|o| o.status.success())
1205 .unwrap_or(false)
1206}
1207
1208pub fn delete_local_branch(root: &Path, branch: &str, warnings: &mut Vec<String>) {
1209 let Ok(out) = Command::new("git")
1210 .args(["-C", &root.to_string_lossy(), "branch", "-D", branch])
1211 .output()
1212 else {
1213 warnings.push(format!("warning: could not delete branch {branch}: command failed"));
1214 return;
1215 };
1216 if !out.status.success() {
1217 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1218 warnings.push(format!("warning: could not delete branch {branch}: {stderr}"));
1219 }
1220}
1221
1222pub fn prune_remote_tracking(root: &Path, branch: &str) {
1223 let _ = Command::new("git")
1224 .args(["-C", &root.to_string_lossy(), "branch", "-dr", &format!("origin/{branch}")])
1225 .output();
1226}
1227
1228pub fn stage_files(root: &Path, files: &[&str]) -> Result<()> {
1229 let mut args = vec!["add"];
1230 args.extend_from_slice(files);
1231 run(root, &args).map(|_| ())
1232}
1233
1234pub fn commit(root: &Path, message: &str) -> Result<()> {
1235 run(root, &["commit", "-m", message]).map(|_| ())
1236}
1237
1238pub fn git_config_get(root: &Path, key: &str) -> Option<String> {
1239 let out = Command::new("git")
1240 .args(["-C", &root.to_string_lossy(), "config", key])
1241 .output()
1242 .ok()?;
1243 if !out.status.success() {
1244 return None;
1245 }
1246 let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
1247 if value.is_empty() { None } else { Some(value) }
1248}
1249
1250fn clear_stale_unmerged_entries(dir: &Path, warnings: &mut Vec<String>) {
1256 let out = match Command::new("git")
1257 .args(["-C", &dir.to_string_lossy(), "ls-files", "-u"])
1258 .output()
1259 {
1260 Ok(o) if o.status.success() => o,
1261 _ => return,
1262 };
1263 let stdout = String::from_utf8_lossy(&out.stdout);
1264 let mut paths: std::collections::HashSet<String> = std::collections::HashSet::new();
1265 for line in stdout.lines() {
1266 if let Some(path) = line.split('\t').nth(1) {
1268 paths.insert(path.to_string());
1269 }
1270 }
1271 if paths.is_empty() {
1272 return;
1273 }
1274 warnings.push(format!(
1275 "warning: clearing {} stale unmerged index entr{} in {} (left by an earlier failed merge)",
1276 paths.len(),
1277 if paths.len() == 1 { "y" } else { "ies" },
1278 dir.display(),
1279 ));
1280 for path in &paths {
1281 let _ = Command::new("git")
1282 .args(["-C", &dir.to_string_lossy(), "reset", "HEAD", "--", path])
1283 .output();
1284 }
1285}
1286
1287pub fn merge_ref(dir: &Path, refname: &str, warnings: &mut Vec<String>) -> Option<String> {
1288 clear_stale_unmerged_entries(dir, warnings);
1293
1294 let out = match Command::new("git")
1301 .args([
1302 "-C", &dir.to_string_lossy(),
1303 "-c", "merge.directoryRenames=false",
1304 "merge", refname, "--no-edit",
1305 ])
1306 .output()
1307 {
1308 Ok(o) => o,
1309 Err(e) => {
1310 warnings.push(format!("warning: merge {refname} failed: {e}"));
1311 return None;
1312 }
1313 };
1314 if out.status.success() {
1315 let stdout = String::from_utf8_lossy(&out.stdout);
1316 if stdout.contains("Already up to date") {
1317 None
1318 } else {
1319 Some(format!("Merged {refname} into branch."))
1320 }
1321 } else {
1322 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1323 warnings.push(format!("warning: merge {refname} failed: {stderr}"));
1324 if detect_mid_merge_state(dir).is_some() {
1330 let abort = Command::new("git")
1331 .args(["-C", &dir.to_string_lossy(), "merge", "--abort"])
1332 .output();
1333 match abort {
1334 Ok(o) if !o.status.success() => {
1335 let aborterr = String::from_utf8_lossy(&o.stderr).trim().to_string();
1336 warnings.push(format!(
1337 "warning: could not abort merge of {refname} in {}: {aborterr}",
1338 dir.display()
1339 ));
1340 }
1341 Err(e) => {
1342 warnings.push(format!(
1343 "warning: could not abort merge of {refname} in {}: {e}",
1344 dir.display()
1345 ));
1346 }
1347 Ok(_) => {}
1348 }
1349 }
1350 None
1351 }
1352}
1353
1354pub fn is_file_tracked(root: &Path, path: &str) -> bool {
1355 Command::new("git")
1356 .args(["ls-files", "--error-unmatch", path])
1357 .current_dir(root)
1358 .stdout(std::process::Stdio::null())
1359 .stderr(std::process::Stdio::null())
1360 .status()
1361 .map(|s| s.success())
1362 .unwrap_or(false)
1363}
1364
1365pub enum MidMergeState {
1369 Merge,
1371 RebaseMerge,
1373 RebaseApply,
1375 CherryPick,
1377}
1378
1379pub fn detect_mid_merge_state(root: &Path) -> Option<MidMergeState> {
1388 let git_dir = root.join(".git");
1389 if git_dir.join("MERGE_HEAD").exists() {
1390 return Some(MidMergeState::Merge);
1391 }
1392 if git_dir.join("rebase-merge").is_dir() {
1393 return Some(MidMergeState::RebaseMerge);
1394 }
1395 if git_dir.join("rebase-apply").is_dir() {
1396 return Some(MidMergeState::RebaseApply);
1397 }
1398 if git_dir.join("CHERRY_PICK_HEAD").exists() {
1399 return Some(MidMergeState::CherryPick);
1400 }
1401 None
1402}
1403
1404pub fn merge_base(root: &Path, ref1: &str, ref2: &str) -> Result<String> {
1406 run(root, &["merge-base", ref1, ref2])
1407}
1408
1409pub fn main_worktree_root(root: &Path) -> Option<PathBuf> {
1410 let out = run(root, &["worktree", "list", "--porcelain"]).ok()?;
1411 out.lines()
1412 .next()
1413 .and_then(|line| line.strip_prefix("worktree "))
1414 .map(PathBuf::from)
1415}
1416
1417pub fn check_leaked_files(
1430 root: &Path,
1431 ticket_branch: &str,
1432 target_branch: &str,
1433) -> Result<Vec<String>> {
1434 let current = Command::new("git")
1436 .args(["rev-parse", "--abbrev-ref", "HEAD"])
1437 .current_dir(root)
1438 .output()?;
1439 let current_branch = String::from_utf8_lossy(¤t.stdout).trim().to_string();
1440
1441 let merge_dir = if current_branch == target_branch {
1442 root.to_path_buf()
1443 } else {
1444 match crate::worktree::find_worktree_for_branch(root, target_branch) {
1445 Some(p) => p,
1446 None => return Ok(vec![]), }
1448 };
1449
1450 let base = match merge_base(root, target_branch, ticket_branch) {
1452 Ok(s) => s.trim().to_string(),
1453 Err(_) => return Ok(vec![]), };
1455 if base.is_empty() {
1456 return Ok(vec![]);
1457 }
1458
1459 let diff_out = Command::new("git")
1462 .args(["diff", "--name-only", &base, ticket_branch])
1463 .current_dir(root)
1464 .output()?;
1465 let ticket_files: std::collections::HashSet<String> =
1466 String::from_utf8_lossy(&diff_out.stdout)
1467 .lines()
1468 .map(|s| s.to_string())
1469 .collect();
1470
1471 let status_out = Command::new("git")
1480 .args(["status", "--porcelain", "--untracked-files=all"])
1481 .current_dir(&merge_dir)
1482 .output()?;
1483 let dirty_files: std::collections::HashSet<String> =
1484 String::from_utf8_lossy(&status_out.stdout)
1485 .lines()
1486 .filter_map(|line| {
1487 if line.len() < 3 {
1488 return None;
1489 }
1490 let x = line.as_bytes()[0] as char;
1491 let y = line.as_bytes()[1] as char;
1492 if x == 'R' || x == 'C' || y == 'R' || y == 'C' {
1494 return None;
1495 }
1496 Some(line[3..].to_string())
1497 })
1498 .collect();
1499
1500 let mut overlap: Vec<String> = ticket_files
1502 .intersection(&dirty_files)
1503 .cloned()
1504 .collect();
1505 overlap.sort();
1506 Ok(overlap)
1507}
1508
1509#[cfg(test)]
1510mod tests {
1511 use super::*;
1512 use std::process::Command as Cmd;
1513 use tempfile::TempDir;
1514
1515 fn git_init() -> TempDir {
1516 let dir = tempfile::tempdir().unwrap();
1517 let p = dir.path();
1518 Cmd::new("git").args(["init", "-q", "-b", "main"]).current_dir(p).status().unwrap();
1519 Cmd::new("git").args(["config", "user.email", "t@t.com"]).current_dir(p).status().unwrap();
1520 Cmd::new("git").args(["config", "user.name", "test"]).current_dir(p).status().unwrap();
1521 dir
1522 }
1523
1524 fn git_cmd(dir: &Path, args: &[&str]) {
1525 Cmd::new("git")
1526 .args(args)
1527 .current_dir(dir)
1528 .env("GIT_AUTHOR_NAME", "test")
1529 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1530 .env("GIT_COMMITTER_NAME", "test")
1531 .env("GIT_COMMITTER_EMAIL", "t@t.com")
1532 .status()
1533 .unwrap();
1534 }
1535
1536 fn make_commit(dir: &Path, filename: &str, content: &str) {
1537 std::fs::write(dir.join(filename), content).unwrap();
1538 git_cmd(dir, &["add", filename]);
1539 git_cmd(dir, &["commit", "-m", "init"]);
1540 }
1541
1542 #[test]
1543 fn is_worktree_dirty_clean() {
1544 let dir = git_init();
1545 make_commit(dir.path(), "f.txt", "hi");
1546 assert!(!is_worktree_dirty(dir.path()));
1547 }
1548
1549 #[test]
1550 fn is_worktree_dirty_dirty() {
1551 let dir = git_init();
1552 make_commit(dir.path(), "f.txt", "hi");
1553 std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1554 assert!(is_worktree_dirty(dir.path()));
1555 }
1556
1557 #[test]
1558 fn local_branch_exists_present_and_absent() {
1559 let dir = git_init();
1560 make_commit(dir.path(), "f.txt", "hi");
1561 let on_main = local_branch_exists(dir.path(), "main");
1562 let on_master = local_branch_exists(dir.path(), "master");
1563 assert!(on_main || on_master);
1564 assert!(!local_branch_exists(dir.path(), "no-such-branch"));
1565 }
1566
1567 #[test]
1568 fn delete_local_branch_success() {
1569 let dir = git_init();
1570 make_commit(dir.path(), "f.txt", "hi");
1571 git_cmd(dir.path(), &["branch", "to-delete"]);
1572 let mut warnings = Vec::new();
1573 delete_local_branch(dir.path(), "to-delete", &mut warnings);
1574 assert!(warnings.is_empty());
1575 assert!(!local_branch_exists(dir.path(), "to-delete"));
1576 }
1577
1578 #[test]
1579 fn delete_local_branch_failure_adds_warning() {
1580 let dir = git_init();
1581 make_commit(dir.path(), "f.txt", "hi");
1582 let mut warnings = Vec::new();
1583 delete_local_branch(dir.path(), "nonexistent", &mut warnings);
1584 assert!(!warnings.is_empty());
1585 assert!(warnings[0].contains("warning:"));
1586 }
1587
1588 #[test]
1589 fn prune_remote_tracking_no_panic() {
1590 let dir = git_init();
1591 make_commit(dir.path(), "f.txt", "hi");
1592 prune_remote_tracking(dir.path(), "nonexistent-branch");
1594 }
1595
1596 #[test]
1597 fn stage_files_ok_and_err() {
1598 let dir = git_init();
1599 make_commit(dir.path(), "f.txt", "hi");
1600 std::fs::write(dir.path().join("new.txt"), "new").unwrap();
1601 assert!(stage_files(dir.path(), &["new.txt"]).is_ok());
1602 assert!(stage_files(dir.path(), &["missing.txt"]).is_err());
1603 }
1604
1605 #[test]
1606 fn commit_ok_and_err() {
1607 let dir = git_init();
1608 make_commit(dir.path(), "f.txt", "hi");
1609 std::fs::write(dir.path().join("new.txt"), "new").unwrap();
1610 git_cmd(dir.path(), &["add", "new.txt"]);
1611 assert!(commit(dir.path(), "test commit").is_ok());
1612 assert!(commit(dir.path(), "empty commit").is_err());
1614 }
1615
1616 #[test]
1617 fn git_config_get_some_and_none() {
1618 let dir = git_init();
1619 make_commit(dir.path(), "f.txt", "hi");
1620 let val = git_config_get(dir.path(), "user.email");
1621 assert_eq!(val, Some("t@t.com".to_string()));
1622 let missing = git_config_get(dir.path(), "no.such.key");
1623 assert!(missing.is_none());
1624 }
1625
1626 #[test]
1627 fn merge_ref_already_up_to_date() {
1628 let dir = git_init();
1629 make_commit(dir.path(), "f.txt", "hi");
1630 let branch = {
1631 let out = Cmd::new("git").args(["branch", "--show-current"]).current_dir(dir.path()).output().unwrap();
1632 String::from_utf8_lossy(&out.stdout).trim().to_string()
1633 };
1634 let mut warnings = Vec::new();
1635 let result = merge_ref(dir.path(), &branch, &mut warnings);
1637 assert!(result.is_none());
1638 assert!(warnings.is_empty());
1639 }
1640
1641 #[test]
1642 fn merge_ref_success() {
1643 let dir = git_init();
1644 make_commit(dir.path(), "f.txt", "hi");
1645 git_cmd(dir.path(), &["checkout", "-b", "feature"]);
1646 make_commit(dir.path(), "g.txt", "there");
1647 git_cmd(dir.path(), &["checkout", "main"]);
1648 let mut warnings = Vec::new();
1649 let result = merge_ref(dir.path(), "feature", &mut warnings);
1650 assert!(result.is_some());
1651 assert!(warnings.is_empty());
1652 }
1653
1654 #[test]
1655 fn merge_ref_does_not_speculate_directory_renames() {
1656 let dir = git_init();
1663 let p = dir.path();
1664 std::fs::create_dir_all(p.join("a")).unwrap();
1666 for name in &["1.md", "2.md", "3.md", "4.md"] {
1667 std::fs::write(p.join("a").join(name), "seed\n").unwrap();
1668 }
1669 git_cmd(p, &["add", "a"]);
1670 git_cmd(p, &["commit", "-m", "seed"]);
1671
1672 std::fs::create_dir_all(p.join("b")).unwrap();
1674 for name in &["1.md", "2.md", "3.md", "4.md"] {
1675 std::fs::rename(p.join("a").join(name), p.join("b").join(name)).unwrap();
1676 }
1677 git_cmd(p, &["add", "-A"]);
1678 git_cmd(p, &["commit", "-m", "archive sweep"]);
1679
1680 git_cmd(p, &["checkout", "-b", "feature", "HEAD~1"]);
1682 std::fs::write(p.join("a/new.md"), "active\n").unwrap();
1683 git_cmd(p, &["add", "a/new.md"]);
1684 git_cmd(p, &["commit", "-m", "add active ticket"]);
1685
1686 let mut warnings = Vec::new();
1687 let result = merge_ref(p, "main", &mut warnings);
1688
1689 assert!(result.is_some(), "merge should succeed without directory-rename inference; warnings: {warnings:?}");
1690 assert!(detect_mid_merge_state(p).is_none(), "should not be left mid-merge");
1691 assert!(p.join("a/new.md").exists(), "new.md should stay at a/");
1693 assert!(!p.join("b/new.md").exists(), "new.md must NOT have been speculatively renamed into b/");
1694 }
1695
1696 #[test]
1697 fn merge_ref_clears_stale_unmerged_index_entries() {
1698 let dir = git_init();
1702 let p = dir.path();
1703 make_commit(p, "f.txt", "hi");
1704
1705 git_cmd(p, &["checkout", "-b", "other"]);
1708 make_commit(p, "g.txt", "there");
1709 git_cmd(p, &["checkout", "main"]);
1710
1711 std::fs::write(p.join("conflict.md"), "main\n").unwrap();
1713 git_cmd(p, &["add", "conflict.md"]);
1714 git_cmd(p, &["commit", "-m", "main version"]);
1715
1716 git_cmd(p, &["checkout", "-b", "feature", "HEAD~1"]);
1717 std::fs::write(p.join("conflict.md"), "feature\n").unwrap();
1718 git_cmd(p, &["add", "conflict.md"]);
1719 git_cmd(p, &["commit", "-m", "feature version"]);
1720
1721 git_cmd(p, &["checkout", "main"]);
1722 let _ = Cmd::new("git")
1723 .args(["-C", &p.to_string_lossy(), "merge", "feature", "--no-edit"])
1724 .output();
1725 let _ = std::fs::remove_file(p.join(".git/MERGE_HEAD"));
1727 let _ = std::fs::remove_file(p.join(".git/MERGE_MSG"));
1728
1729 let pre = String::from_utf8_lossy(
1730 &Cmd::new("git").args(["-C", &p.to_string_lossy(), "ls-files", "-u"])
1731 .output().unwrap().stdout
1732 ).to_string();
1733 assert!(!pre.trim().is_empty(), "precondition: unmerged index entries should be present; got: {pre:?}");
1734
1735 let mut warnings = Vec::new();
1737 let result = merge_ref(p, "other", &mut warnings);
1738
1739 assert!(result.is_some(), "merge should succeed after preflight; warnings: {warnings:?}");
1740 assert!(
1741 warnings.iter().any(|w| w.contains("stale unmerged index")),
1742 "expected stale-entry warning; got: {warnings:?}"
1743 );
1744 }
1745
1746 #[test]
1747 fn merge_ref_conflict_aborts_and_warns() {
1748 let dir = git_init();
1749 let p = dir.path();
1750 make_commit(p, "f.txt", "main version\n");
1753 git_cmd(p, &["checkout", "-b", "feature"]);
1754 std::fs::write(p.join("f.txt"), "feature version\n").unwrap();
1755 git_cmd(p, &["add", "f.txt"]);
1756 git_cmd(p, &["commit", "-m", "feature change"]);
1757 git_cmd(p, &["checkout", "main"]);
1758 std::fs::write(p.join("f.txt"), "main change\n").unwrap();
1759 git_cmd(p, &["add", "f.txt"]);
1760 git_cmd(p, &["commit", "-m", "main change"]);
1761
1762 let mut warnings = Vec::new();
1763 let result = merge_ref(p, "feature", &mut warnings);
1764
1765 assert!(result.is_none(), "merge should report failure");
1766 assert!(
1767 warnings.iter().any(|w| w.contains("merge feature failed")),
1768 "expected merge-failure warning; got: {warnings:?}"
1769 );
1770 assert!(
1772 detect_mid_merge_state(p).is_none(),
1773 "merge_ref must abort on conflict so MERGE_HEAD does not persist"
1774 );
1775 }
1776
1777 #[test]
1778 fn detect_mid_merge_none_on_clean_repo() {
1779 let dir = git_init();
1780 make_commit(dir.path(), "f.txt", "hi");
1781 assert!(detect_mid_merge_state(dir.path()).is_none());
1782 }
1783
1784 #[test]
1785 fn detect_mid_merge_on_merge_head() {
1786 let dir = git_init();
1787 make_commit(dir.path(), "f.txt", "hi");
1788 std::fs::write(dir.path().join(".git/MERGE_HEAD"), "abc").unwrap();
1789 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::Merge)));
1790 }
1791
1792 #[test]
1793 fn detect_mid_merge_on_rebase_merge() {
1794 let dir = git_init();
1795 make_commit(dir.path(), "f.txt", "hi");
1796 std::fs::create_dir(dir.path().join(".git/rebase-merge")).unwrap();
1797 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseMerge)));
1798 }
1799
1800 #[test]
1801 fn detect_mid_merge_on_rebase_apply() {
1802 let dir = git_init();
1803 make_commit(dir.path(), "f.txt", "hi");
1804 std::fs::create_dir(dir.path().join(".git/rebase-apply")).unwrap();
1805 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseApply)));
1806 }
1807
1808 #[test]
1809 fn detect_mid_merge_on_cherry_pick() {
1810 let dir = git_init();
1811 make_commit(dir.path(), "f.txt", "hi");
1812 std::fs::write(dir.path().join(".git/CHERRY_PICK_HEAD"), "abc").unwrap();
1813 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::CherryPick)));
1814 }
1815
1816 #[test]
1817 fn is_file_tracked_tracked_and_untracked() {
1818 let dir = git_init();
1819 make_commit(dir.path(), "tracked.txt", "hi");
1820 assert!(is_file_tracked(dir.path(), "tracked.txt"));
1821 std::fs::write(dir.path().join("untracked.txt"), "new").unwrap();
1822 assert!(!is_file_tracked(dir.path(), "untracked.txt"));
1823 }
1824
1825 #[test]
1826 fn check_leaked_files_detects_overlap() {
1827 let dir = git_init();
1828 let p = dir.path();
1829 std::fs::create_dir_all(p.join("src")).unwrap();
1830 std::fs::write(p.join("src/foo.rs"), "original").unwrap();
1831 git_cmd(p, &["add", "src/foo.rs"]);
1832 git_cmd(p, &["commit", "-m", "add foo"]);
1833
1834 git_cmd(p, &["checkout", "-b", "ticket/overlap-test"]);
1835 std::fs::write(p.join("src/foo.rs"), "ticket-change").unwrap();
1836 git_cmd(p, &["add", "src/foo.rs"]);
1837 git_cmd(p, &["commit", "-m", "ticket: change foo"]);
1838 git_cmd(p, &["checkout", "main"]);
1839
1840 std::fs::write(p.join("src/foo.rs"), "leaked").unwrap();
1842
1843 let leaked = check_leaked_files(p, "ticket/overlap-test", "main").unwrap();
1844 assert_eq!(leaked, vec!["src/foo.rs".to_string()]);
1845 }
1846
1847 #[test]
1848 fn check_leaked_files_no_overlap() {
1849 let dir = git_init();
1850 let p = dir.path();
1851 std::fs::create_dir_all(p.join("src")).unwrap();
1852 std::fs::write(p.join("src/foo.rs"), "original").unwrap();
1853 std::fs::write(p.join("src/bar.rs"), "bar").unwrap();
1854 git_cmd(p, &["add", "src/foo.rs", "src/bar.rs"]);
1855 git_cmd(p, &["commit", "-m", "add foo and bar"]);
1856
1857 git_cmd(p, &["checkout", "-b", "ticket/no-overlap"]);
1859 std::fs::write(p.join("src/foo.rs"), "ticket-change").unwrap();
1860 git_cmd(p, &["add", "src/foo.rs"]);
1861 git_cmd(p, &["commit", "-m", "ticket: change foo"]);
1862 git_cmd(p, &["checkout", "main"]);
1863
1864 std::fs::write(p.join("src/bar.rs"), "dirty").unwrap();
1866
1867 let leaked = check_leaked_files(p, "ticket/no-overlap", "main").unwrap();
1868 assert!(leaked.is_empty(), "no overlap expected; got {leaked:?}");
1869 }
1870
1871 #[test]
1872 fn check_leaked_files_detects_untracked_overlap() {
1873 let dir = git_init();
1874 let p = dir.path();
1875 make_commit(p, "existing.rs", "base");
1876
1877 git_cmd(p, &["checkout", "-b", "ticket/untracked-overlap"]);
1879 std::fs::create_dir_all(p.join("src")).unwrap();
1880 std::fs::write(p.join("src/new.rs"), "new file").unwrap();
1881 git_cmd(p, &["add", "src/new.rs"]);
1882 git_cmd(p, &["commit", "-m", "ticket: add new file"]);
1883 git_cmd(p, &["checkout", "main"]);
1884
1885 std::fs::create_dir_all(p.join("src")).unwrap();
1887 std::fs::write(p.join("src/new.rs"), "leaked untracked").unwrap();
1888
1889 let leaked = check_leaked_files(p, "ticket/untracked-overlap", "main").unwrap();
1890 assert_eq!(leaked, vec!["src/new.rs".to_string()]);
1891 }
1892
1893 fn commit_file(dir: &Path, name: &str, content: &str) {
1897 std::fs::write(dir.join(name), content).unwrap();
1898 git_cmd(dir, &["add", name]);
1899 git_cmd(dir, &["commit", "-m", &format!("add {name}")]);
1900 }
1901
1902 #[test]
1905 fn content_merged_into_main_regular_merge_with_state_commit() {
1906 let dir = git_init();
1907 let p = dir.path();
1908
1909 commit_file(p, "README", "base");
1911
1912 git_cmd(p, &["checkout", "-b", "ticket/foo"]);
1914 std::fs::create_dir_all(p.join("src")).unwrap();
1915 commit_file(p, "src/lib.rs", "impl");
1916
1917 git_cmd(p, &["checkout", "main"]);
1919 git_cmd(p, &["merge", "--no-ff", "ticket/foo", "-m", "Merge ticket/foo"]);
1920
1921 git_cmd(p, &["checkout", "ticket/foo"]);
1923 std::fs::create_dir_all(p.join("tickets")).unwrap();
1924 commit_file(p, "tickets/foo.md", "state: implemented");
1925
1926 let result = content_merged_into_main(p, "main", "ticket/foo", "tickets").unwrap();
1928 assert!(result, "should detect that content was merged despite trailing state commit");
1929 }
1930
1931 #[test]
1934 fn content_merged_into_main_squash_merge_with_state_commit() {
1935 let dir = git_init();
1936 let p = dir.path();
1937
1938 commit_file(p, "README", "base");
1939
1940 git_cmd(p, &["checkout", "-b", "ticket/bar"]);
1942 std::fs::create_dir_all(p.join("src")).unwrap();
1943 commit_file(p, "src/lib.rs", "impl");
1944
1945 git_cmd(p, &["checkout", "main"]);
1947 git_cmd(p, &["merge", "--squash", "ticket/bar"]);
1948 git_cmd(p, &["commit", "-m", "Squash ticket/bar"]);
1949
1950 git_cmd(p, &["checkout", "ticket/bar"]);
1952 std::fs::create_dir_all(p.join("tickets")).unwrap();
1953 commit_file(p, "tickets/bar.md", "state: implemented");
1954
1955 let result = content_merged_into_main(p, "main", "ticket/bar", "tickets").unwrap();
1956 assert!(result, "should detect squash-merged content despite trailing state commit");
1957 }
1958
1959 #[test]
1962 fn content_merged_into_main_returns_false_when_ancestor() {
1963 let dir = git_init();
1964 let p = dir.path();
1965 commit_file(p, "README", "base");
1966 git_cmd(p, &["checkout", "-b", "ticket/anc"]);
1968 git_cmd(p, &["checkout", "main"]);
1970 let result = content_merged_into_main(p, "main", "ticket/anc", "tickets").unwrap();
1971 assert!(!result);
1972 }
1973
1974 #[test]
1976 fn content_merged_into_main_not_detected_when_non_ticket_file_modified_after_merge() {
1977 let dir = git_init();
1978 let p = dir.path();
1979 commit_file(p, "README", "base");
1980
1981 git_cmd(p, &["checkout", "-b", "ticket/extra"]);
1983 std::fs::create_dir_all(p.join("src")).unwrap();
1984 commit_file(p, "src/lib.rs", "impl");
1985
1986 git_cmd(p, &["checkout", "main"]);
1988 git_cmd(p, &["merge", "--squash", "ticket/extra"]);
1989 git_cmd(p, &["commit", "-m", "Squash ticket/extra"]);
1990
1991 git_cmd(p, &["checkout", "ticket/extra"]);
1993 std::fs::create_dir_all(p.join("tickets")).unwrap();
1994 commit_file(p, "tickets/extra.md", "state: implemented");
1995 commit_file(p, "src/extra.rs", "extra code");
1998
1999 let result = content_merged_into_main(p, "main", "ticket/extra", "tickets").unwrap();
2000 assert!(!result, "branch with non-ticket changes after merge must not be detected");
2001 }
2002
2003 #[test]
2006 fn content_merged_into_main_all_ticket_only_commits_returns_false() {
2007 let dir = git_init();
2008 let p = dir.path();
2009 commit_file(p, "README", "base");
2010
2011 git_cmd(p, &["checkout", "-b", "ticket/ticketonly"]);
2013 std::fs::create_dir_all(p.join("tickets")).unwrap();
2014 commit_file(p, "tickets/ticketonly.md", "state: new");
2015 git_cmd(p, &["checkout", "main"]);
2016
2017 let result = content_merged_into_main(p, "main", "ticket/ticketonly", "tickets").unwrap();
2018 assert!(!result, "all-ticket-only commits should return false");
2019 }
2020
2021 #[test]
2028 fn merged_into_main_detects_local_regular_merge_when_remote_deleted() {
2029 let dir = git_init();
2030 let p = dir.path();
2031 make_commit(p, "f.txt", "base");
2032
2033 git_cmd(p, &["checkout", "-b", "ticket/foo"]);
2035 std::fs::write(p.join("f.txt"), "ticket-change").unwrap();
2036 git_cmd(p, &["add", "f.txt"]);
2037 git_cmd(p, &["commit", "-m", "ticket: change"]);
2038
2039 git_cmd(p, &["checkout", "main"]);
2041 git_cmd(p, &["merge", "--no-ff", "ticket/foo", "-m", "Merge ticket/foo"]);
2042
2043 let main_sha = run(p, &["rev-parse", "main"]).unwrap();
2046 Cmd::new("git")
2047 .args(["update-ref", "refs/remotes/origin/main", main_sha.trim()])
2048 .current_dir(p)
2049 .status()
2050 .unwrap();
2051 let merged = merged_into_main(p, "main").unwrap();
2054 assert!(
2055 merged.iter().any(|b| b == "ticket/foo"),
2056 "expected ticket/foo in merged set; got {merged:?}"
2057 );
2058 }
2059}