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}");
343 if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
344 let _ = run(&wt_path, &["merge", "--ff-only", &remote_ref]);
345 }
346 let full_path = wt_path.join(rel_path);
347 if let Some(parent) = full_path.parent() {
348 std::fs::create_dir_all(parent)?;
349 }
350 std::fs::write(&full_path, content)?;
351 let _ = run(&wt_path, &["add", rel_path]);
352 let _ = run(&wt_path, &["commit", "-m", message]);
353 crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
354 return Ok(());
355 }
356
357 if current_branch(root).ok().as_deref() == Some(branch) {
359 let local_path = root.join(rel_path);
360 if let Some(parent) = local_path.parent() {
361 std::fs::create_dir_all(parent)?;
362 }
363 std::fs::write(&local_path, content)?;
364 let _ = run(root, &["add", rel_path]);
365 let _ = run(root, &["commit", "-m", message]);
366 crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
367 return Ok(());
368 }
369
370 let result = try_worktree_commit(root, branch, rel_path, content, message);
371 if result.is_ok() {
372 crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
373 }
374 result
375}
376
377fn try_worktree_commit(
378 root: &Path,
379 branch: &str,
380 rel_path: &str,
381 content: &str,
382 message: &str,
383) -> Result<()> {
384 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
385 let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
386 let wt_path = std::env::temp_dir().join(format!(
387 "apm-{}-{}-{}",
388 std::process::id(),
389 seq,
390 branch.replace('/', "-"),
391 ));
392
393 let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
394 let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
395
396 if has_remote {
397 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
398 let _ = run(&wt_path, &["checkout", "-B", branch]);
399 } else if has_local {
400 let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
402 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
403 let _ = run(&wt_path, &["checkout", "-B", branch]);
404 } else {
405 run(root, &["worktree", "add", "-b", branch, &wt_path.to_string_lossy(), "HEAD"])?;
406 }
407
408 let result = (|| -> Result<()> {
409 let full_path = wt_path.join(rel_path);
410 if let Some(parent) = full_path.parent() {
411 std::fs::create_dir_all(parent)?;
412 }
413 std::fs::write(&full_path, content)?;
414 run(&wt_path, &["add", rel_path])?;
415 run(&wt_path, &["commit", "-m", message])?;
416 Ok(())
417 })();
418
419 let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
420 let _ = std::fs::remove_dir_all(&wt_path);
421
422 result
423}
424
425
426pub fn push_ticket_branches(root: &Path, warnings: &mut Vec<String>) {
429 if run(root, &["remote", "get-url", "origin"]).is_err() {
430 return;
431 }
432 let out = match run(root, &["branch", "--list", "ticket/*"]) {
433 Ok(o) => o,
434 Err(_) => return,
435 };
436 for branch in out.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
437 let range = format!("origin/{branch}..{branch}");
438 let count = run(root, &["rev-list", "--count", &range])
439 .ok()
440 .and_then(|s| s.trim().parse::<u32>().ok())
441 .unwrap_or(0);
442 if count > 0 {
443 if let Err(e) = run(root, &["push", "origin", branch]) {
444 warnings.push(format!("warning: push {branch} failed: {e:#}"));
445 }
446 }
447 }
448}
449
450pub fn sync_non_checked_out_refs(root: &Path, warnings: &mut Vec<String>) -> Vec<String> {
478 let checked_out: std::collections::HashSet<String> = {
481 let mut set = std::collections::HashSet::new();
482 if let Ok(out) = run(root, &["worktree", "list", "--porcelain"]) {
483 for line in out.lines() {
484 if let Some(b) = line.strip_prefix("branch refs/heads/") {
485 set.insert(b.to_string());
486 }
487 }
488 }
489 set
490 };
491
492 const MANAGED_NAMESPACES: &[&str] = &["ticket", "epic"];
495
496 let mut remote_refs: Vec<String> = Vec::new();
498 for ns in MANAGED_NAMESPACES {
499 let pattern = format!("refs/remotes/origin/{ns}/");
500 if let Ok(out) = run(root, &["for-each-ref", "--format=%(refname:short)", &pattern]) {
501 for line in out.lines().filter(|l| !l.is_empty()) {
502 remote_refs.push(line.to_string());
503 }
504 }
505 }
506
507 let mut ahead_branches: Vec<String> = Vec::new();
508
509 for remote_name in remote_refs {
510 let branch = match remote_name.strip_prefix("origin/") {
513 Some(b) => b.to_string(),
514 None => continue,
515 };
516
517 if checked_out.contains(&branch) {
519 continue;
520 }
521
522 let local_ref = format!("refs/heads/{branch}");
523 let remote_ref_full = format!("refs/remotes/{remote_name}");
525
526 match classify_branch(root, &local_ref, &remote_name) {
529 BranchClass::RemoteOnly => {
530 let sha = match run(root, &["rev-parse", &remote_ref_full]) {
533 Ok(s) => s,
534 Err(_) => continue,
535 };
536 if let Err(e) = run(root, &["update-ref", &local_ref, &sha]) {
537 warnings.push(format!("warning: could not create local ref {branch}: {e:#}"));
538 }
539 }
540 BranchClass::Equal => {
541 }
543 BranchClass::Behind => {
544 let sha = match run(root, &["rev-parse", &remote_ref_full]) {
547 Ok(s) => s,
548 Err(_) => continue,
549 };
550 if let Err(e) = run(root, &["update-ref", &local_ref, &sha]) {
551 warnings.push(format!("warning: could not fast-forward {branch}: {e:#}"));
552 }
553 }
554 BranchClass::Ahead => {
555 warnings.push(crate::sync_guidance::TICKET_OR_EPIC_AHEAD.replace("<slug>", &branch));
561 ahead_branches.push(branch);
562 }
563 BranchClass::Diverged => {
564 let msg = crate::sync_guidance::TICKET_OR_EPIC_DIVERGED
567 .replace("<slug>", &branch);
568 warnings.push(msg);
569 }
570 BranchClass::NoRemote => {
571 }
574 }
575 }
576
577 ahead_branches
578}
579
580pub fn list_files_on_branch(root: &Path, branch: &str, dir: &str) -> Result<Vec<String>> {
582 let tree_ref = format!("{branch}:{dir}");
583 let out = run(root, &["ls-tree", "--name-only", &tree_ref])
584 .or_else(|_| run(root, &["ls-tree", "--name-only", &format!("origin/{branch}:{dir}")]))?;
585 Ok(out.lines()
586 .filter(|l| !l.is_empty())
587 .map(|l| format!("{dir}/{l}"))
588 .collect())
589}
590
591pub fn commit_files_to_branch(
593 root: &Path,
594 branch: &str,
595 files: &[(&str, String)],
596 message: &str,
597) -> Result<()> {
598 if !has_commits(root) {
599 for (rel_path, content) in files {
600 let local_path = root.join(rel_path);
601 if let Some(parent) = local_path.parent() {
602 std::fs::create_dir_all(parent)?;
603 }
604 std::fs::write(&local_path, content)?;
605 }
606 return Ok(());
607 }
608
609 if let Some(wt_path) = find_worktree_for_branch(root, branch) {
610 for (rel_path, content) in files {
611 let full_path = wt_path.join(rel_path);
612 if let Some(parent) = full_path.parent() {
613 std::fs::create_dir_all(parent)?;
614 }
615 std::fs::write(&full_path, content)?;
616 let _ = run(&wt_path, &["add", rel_path]);
617 }
618 run(&wt_path, &["commit", "-m", message])?;
619 crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
620 return Ok(());
621 }
622
623 if current_branch(root).ok().as_deref() == Some(branch) {
624 for (rel_path, content) in files {
625 let local_path = root.join(rel_path);
626 if let Some(parent) = local_path.parent() {
627 std::fs::create_dir_all(parent)?;
628 }
629 std::fs::write(&local_path, content)?;
630 let _ = run(root, &["add", rel_path]);
631 }
632 run(root, &["commit", "-m", message])?;
633 crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
634 return Ok(());
635 }
636
637 let unique = std::time::SystemTime::now()
638 .duration_since(std::time::UNIX_EPOCH)
639 .map(|d| d.subsec_nanos())
640 .unwrap_or(0);
641 let wt_path = std::env::temp_dir().join(format!(
642 "apm-{}-{}-{}",
643 std::process::id(),
644 unique,
645 branch.replace('/', "-"),
646 ));
647
648 let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
649 let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
650
651 if has_remote {
652 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
653 let _ = run(&wt_path, &["checkout", "-B", branch]);
654 } else if has_local {
655 let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
656 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
657 let _ = run(&wt_path, &["checkout", "-B", branch]);
658 } else {
659 run(root, &["worktree", "add", &wt_path.to_string_lossy(), branch])?;
660 }
661
662 let result = (|| -> Result<()> {
663 for (rel_path, content) in files {
664 let full_path = wt_path.join(rel_path);
665 if let Some(parent) = full_path.parent() {
666 std::fs::create_dir_all(parent)?;
667 }
668 std::fs::write(&full_path, content)?;
669 run(&wt_path, &["add", rel_path])?;
670 }
671 run(&wt_path, &["commit", "-m", message])?;
672 Ok(())
673 })();
674
675 let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
676 let _ = std::fs::remove_dir_all(&wt_path);
677
678 if result.is_ok() {
679 crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
680 }
681 result
682}
683
684pub fn branch_tip(root: &Path, branch: &str) -> Option<String> {
686 run(root, &["rev-parse", &format!("refs/heads/{branch}")]).ok()
687}
688
689pub fn resolve_branch_sha(root: &Path, branch: &str) -> Result<String> {
692 run(root, &["rev-parse", &format!("origin/{branch}")])
693 .or_else(|_| run(root, &["rev-parse", branch]))
694 .with_context(|| format!("branch '{branch}' not found locally or on origin"))
695}
696
697pub fn create_branch_at(root: &Path, branch: &str, sha: &str) -> Result<()> {
699 run(root, &["branch", branch, sha]).map(|_| ())
700}
701
702pub fn remote_branch_tip(root: &Path, branch: &str) -> Option<String> {
704 run(root, &["rev-parse", &format!("refs/remotes/origin/{branch}")]).ok()
705}
706
707pub fn is_ancestor(root: &Path, commit: &str, of_ref: &str) -> bool {
710 Command::new("git")
711 .current_dir(root)
712 .args(["merge-base", "--is-ancestor", commit, of_ref])
713 .status()
714 .map(|s| s.success())
715 .unwrap_or(false)
716}
717
718pub enum BranchClass {
728 Equal,
729 Behind,
730 Ahead,
731 Diverged,
732 RemoteOnly,
734 NoRemote,
736}
737
738pub fn classify_branch(root: &Path, local: &str, remote: &str) -> BranchClass {
746 let local_sha = match run(root, &["rev-parse", local]) {
747 Ok(s) => s,
748 Err(_) => {
749 return if run(root, &["rev-parse", remote]).is_ok() {
753 BranchClass::RemoteOnly
754 } else {
755 BranchClass::NoRemote
756 };
757 }
758 };
759 let remote_sha = match run(root, &["rev-parse", remote]) {
760 Ok(s) => s,
761 Err(_) => return BranchClass::NoRemote,
762 };
763
764 if local_sha == remote_sha {
765 return BranchClass::Equal;
766 }
767
768 let local_is_ancestor_of_remote = is_ancestor(root, local, remote);
771
772 let remote_is_ancestor_of_local = is_ancestor(root, remote, local);
775
776 match (local_is_ancestor_of_remote, remote_is_ancestor_of_local) {
777 (true, false) => BranchClass::Behind, (false, true) => BranchClass::Ahead, (false, false) => BranchClass::Diverged, (true, true) => BranchClass::Equal, }
782}
783
784pub fn sync_default_branch(root: &Path, default: &str, warnings: &mut Vec<String>) -> bool {
809 let remote = format!("origin/{default}");
810 match classify_branch(root, default, &remote) {
811 BranchClass::Equal => {
812 }
814
815 BranchClass::Behind => {
816 let wt = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
819 if run(&wt, &["merge", "--ff-only", &remote]).is_err() {
820 let msg = crate::sync_guidance::MAIN_BEHIND_DIRTY_OVERLAP
824 .replace("<default>", default);
825 warnings.push(msg);
826 }
827 }
828
829 BranchClass::Ahead => {
830 let count = run(root, &["rev-list", "--count", &format!("{remote}..{default}")])
833 .ok()
834 .and_then(|s| s.trim().parse::<u64>().ok())
835 .unwrap_or(0);
836 let msg = crate::sync_guidance::MAIN_AHEAD
837 .replace("<default>", default)
838 .replace("<remote>", &remote)
839 .replace("<count>", &count.to_string())
840 .replace("<commits>", if count == 1 { "commit" } else { "commits" });
841 warnings.push(msg);
842 return true;
843 }
844
845 BranchClass::Diverged => {
846 let wt = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
849 let guidance = if is_worktree_dirty(&wt) {
850 crate::sync_guidance::MAIN_DIVERGED_DIRTY.replace("<default>", default)
851 } else {
852 crate::sync_guidance::MAIN_DIVERGED_CLEAN.replace("<default>", default)
853 };
854 warnings.push(guidance);
855 }
856
857 BranchClass::RemoteOnly => {
858 }
862
863 BranchClass::NoRemote => {
864 }
868 }
869 false
870}
871
872pub fn fetch_branch(root: &Path, branch: &str) -> anyhow::Result<()> {
873 let status = std::process::Command::new("git")
874 .args(["fetch", "origin", branch])
875 .current_dir(root)
876 .status()?;
877 if !status.success() {
878 anyhow::bail!("git fetch failed");
879 }
880 Ok(())
881}
882
883pub fn push_branch(root: &Path, branch: &str) -> anyhow::Result<()> {
884 let status = std::process::Command::new("git")
885 .args(["push", "origin", &format!("{branch}:{branch}")])
886 .current_dir(root)
887 .status()?;
888 if !status.success() {
889 anyhow::bail!("git push failed");
890 }
891 Ok(())
892}
893
894pub fn push_branch_tracking(root: &Path, branch: &str) -> anyhow::Result<()> {
895 let out = std::process::Command::new("git")
896 .args(["push", "--set-upstream", "origin", &format!("{branch}:{branch}")])
897 .current_dir(root)
898 .output()?;
899 if !out.status.success() {
900 anyhow::bail!("git push failed: {}", String::from_utf8_lossy(&out.stderr).trim());
901 }
902 Ok(())
903}
904
905pub fn has_remote(root: &Path) -> bool {
906 run(root, &["remote", "get-url", "origin"]).is_ok()
907}
908
909pub fn remote_ticket_branches_with_dates(
914 root: &Path,
915) -> Result<Vec<(String, chrono::DateTime<chrono::Utc>)>> {
916 use chrono::{TimeZone, Utc};
917 let out = Command::new("git")
918 .current_dir(root)
919 .args([
920 "for-each-ref",
921 "refs/remotes/origin/ticket/",
922 "--format=%(refname:short) %(creatordate:unix)",
923 ])
924 .output()
925 .context("git for-each-ref failed")?;
926 let stdout = String::from_utf8_lossy(&out.stdout);
927 let mut result = Vec::new();
928 for line in stdout.lines() {
929 let mut parts = line.splitn(2, ' ');
930 let refname = parts.next().unwrap_or("").trim();
931 let ts_str = parts.next().unwrap_or("").trim();
932 let branch = refname.trim_start_matches("origin/");
933 if branch.is_empty() {
934 continue;
935 }
936 if let Ok(ts) = ts_str.parse::<i64>() {
937 if let Some(dt) = Utc.timestamp_opt(ts, 0).single() {
938 result.push((branch.to_string(), dt));
939 }
940 }
941 }
942 Ok(result)
943}
944
945pub fn list_remote_ticket_branches(root: &Path) -> std::collections::HashSet<String> {
951 let mut set = std::collections::HashSet::new();
952 let out = match run(root, &["ls-remote", "--heads", "origin", "ticket/*"]) {
953 Ok(o) => o,
954 Err(_) => return set,
955 };
956 for line in out.lines() {
957 if let Some(refname) = line.split('\t').nth(1) {
958 if let Some(branch) = refname.trim().strip_prefix("refs/heads/") {
959 set.insert(branch.to_string());
960 }
961 }
962 }
963 set
964}
965
966pub fn delete_remote_branch(root: &Path, branch: &str) -> Result<()> {
968 let status = Command::new("git")
969 .current_dir(root)
970 .args(["push", "origin", "--delete", branch])
971 .status()
972 .context("git push origin --delete failed")?;
973 if !status.success() {
974 anyhow::bail!("git push origin --delete {branch} failed");
975 }
976 Ok(())
977}
978
979pub fn move_files_on_branch(
984 root: &Path,
985 branch: &str,
986 moves: &[(&str, &str, &str)],
987 message: &str,
988) -> Result<()> {
989 if !has_commits(root) {
990 for (old, new, content) in moves {
991 let new_path = root.join(new);
992 if let Some(parent) = new_path.parent() {
993 std::fs::create_dir_all(parent)?;
994 }
995 std::fs::write(&new_path, content)?;
996 let old_path = root.join(old);
997 let _ = std::fs::remove_file(&old_path);
998 }
999 return Ok(());
1000 }
1001
1002 let do_moves = |wt: &Path| -> Result<()> {
1003 for (old, new, content) in moves {
1004 let new_path = wt.join(new);
1005 if let Some(parent) = new_path.parent() {
1006 std::fs::create_dir_all(parent)?;
1007 }
1008 std::fs::write(&new_path, content)?;
1009 run(wt, &["add", new])?;
1010 run(wt, &["rm", "--force", "--quiet", old])?;
1011 }
1012 run(wt, &["commit", "-m", message])?;
1013 Ok(())
1014 };
1015
1016 if let Some(wt_path) = find_worktree_for_branch(root, branch) {
1017 let remote_ref = format!("origin/{branch}");
1018 if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
1019 let _ = run(&wt_path, &["merge", "--ff-only", &remote_ref]);
1020 }
1021 let result = do_moves(&wt_path);
1022 if result.is_ok() {
1023 crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1024 }
1025 return result;
1026 }
1027
1028 if current_branch(root).ok().as_deref() == Some(branch) {
1029 let result = do_moves(root);
1030 if result.is_ok() {
1031 crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1032 }
1033 return result;
1034 }
1035
1036 let unique = std::time::SystemTime::now()
1037 .duration_since(std::time::UNIX_EPOCH)
1038 .map(|d| d.subsec_nanos())
1039 .unwrap_or(0);
1040 let wt_path = std::env::temp_dir().join(format!(
1041 "apm-{}-{}-{}",
1042 std::process::id(),
1043 unique,
1044 branch.replace('/', "-"),
1045 ));
1046
1047 let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
1048 let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
1049
1050 if has_remote {
1051 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
1052 let _ = run(&wt_path, &["checkout", "-B", branch]);
1053 } else if has_local {
1054 let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
1055 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
1056 let _ = run(&wt_path, &["checkout", "-B", branch]);
1057 } else {
1058 run(root, &["worktree", "add", &wt_path.to_string_lossy(), branch])?;
1059 }
1060
1061 let result = do_moves(&wt_path);
1062 let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
1063 let _ = std::fs::remove_dir_all(&wt_path);
1064 if result.is_ok() {
1065 crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1066 }
1067 result
1068}
1069
1070pub fn merge_branch_into_default(root: &Path, branch: &str, default_branch: &str, warnings: &mut Vec<String>) -> Result<()> {
1071 let _ = run(root, &["fetch", "origin", default_branch]);
1072
1073 let merge_dir = if current_branch(root).ok().as_deref() == Some(default_branch) {
1074 root.to_path_buf()
1075 } else {
1076 find_worktree_for_branch(root, default_branch).unwrap_or_else(|| root.to_path_buf())
1077 };
1078
1079 if let Err(e) = run(&merge_dir, &["merge", "--no-ff", branch, "--no-edit"]) {
1080 let _ = run(&merge_dir, &["merge", "--abort"]);
1081 anyhow::bail!("merge failed: {e:#}");
1082 }
1083
1084 if has_remote(root) {
1085 if let Err(e) = run(&merge_dir, &["push", "origin", default_branch]) {
1086 warnings.push(format!("warning: push {default_branch} failed: {e:#}"));
1087 }
1088 }
1089 Ok(())
1090}
1091
1092pub 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<()> {
1093 let _ = std::process::Command::new("git")
1094 .args(["fetch", "origin", default_branch])
1095 .current_dir(root)
1096 .status();
1097
1098 let current = std::process::Command::new("git")
1099 .args(["rev-parse", "--abbrev-ref", "HEAD"])
1100 .current_dir(root)
1101 .output()?;
1102 let current_branch = String::from_utf8_lossy(¤t.stdout).trim().to_string();
1103
1104 let merge_dir = if current_branch == default_branch {
1105 root.to_path_buf()
1106 } else {
1107 let main_root = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
1108 let worktrees_base = main_root.join(&config.worktrees.dir);
1109 ensure_worktree(root, &worktrees_base, default_branch)?
1110 };
1111
1112 let out = std::process::Command::new("git")
1113 .args(["merge", "--no-ff", branch, "--no-edit"])
1114 .current_dir(&merge_dir)
1115 .output()?;
1116
1117 if !out.status.success() {
1118 let _ = std::process::Command::new("git")
1119 .args(["merge", "--abort"])
1120 .current_dir(&merge_dir)
1121 .status();
1122 bail!(
1123 "merge conflict — resolve manually and push: {}",
1124 String::from_utf8_lossy(&out.stderr).trim()
1125 );
1126 }
1127
1128 if skip_push {
1129 messages.push(format!("Merged {branch} into {default_branch} (local only)."));
1130 } else {
1131 push_branch(&merge_dir, default_branch)?;
1132 messages.push(format!("Merged {branch} into {default_branch} and pushed to origin."));
1133 }
1134 Ok(())
1135}
1136
1137pub fn pull_default(root: &Path, default_branch: &str, warnings: &mut Vec<String>) -> Result<()> {
1138 let fetch = std::process::Command::new("git")
1139 .args(["fetch", "origin", default_branch])
1140 .current_dir(root)
1141 .output();
1142
1143 match fetch {
1144 Err(e) => {
1145 warnings.push(format!("warning: fetch failed: {e:#}"));
1146 return Ok(());
1147 }
1148 Ok(out) if !out.status.success() => {
1149 warnings.push(format!(
1150 "warning: fetch failed: {}",
1151 String::from_utf8_lossy(&out.stderr).trim()
1152 ));
1153 return Ok(());
1154 }
1155 _ => {}
1156 }
1157
1158 let current = std::process::Command::new("git")
1159 .args(["rev-parse", "--abbrev-ref", "HEAD"])
1160 .current_dir(root)
1161 .output()?;
1162 let current_branch = String::from_utf8_lossy(¤t.stdout).trim().to_string();
1163
1164 let merge_dir = if current_branch == default_branch {
1165 root.to_path_buf()
1166 } else {
1167 find_worktree_for_branch(root, default_branch)
1168 .unwrap_or_else(|| root.to_path_buf())
1169 };
1170
1171 let remote_ref = format!("origin/{default_branch}");
1172 let out = std::process::Command::new("git")
1173 .args(["merge", "--ff-only", &remote_ref])
1174 .current_dir(&merge_dir)
1175 .output()?;
1176
1177 if !out.status.success() {
1178 warnings.push(format!("warning: could not fast-forward {default_branch} — pull manually"));
1179 }
1180
1181 Ok(())
1182}
1183
1184pub fn is_worktree_dirty(path: &Path) -> bool {
1185 let Ok(out) = Command::new("git")
1186 .args(["-C", &path.to_string_lossy(), "status", "--porcelain"])
1187 .output()
1188 else {
1189 return false;
1190 };
1191 !out.stdout.is_empty()
1192}
1193
1194pub fn local_branch_exists(root: &Path, branch: &str) -> bool {
1195 Command::new("git")
1196 .args(["-C", &root.to_string_lossy(), "rev-parse", "--verify", &format!("refs/heads/{branch}")])
1197 .output()
1198 .map(|o| o.status.success())
1199 .unwrap_or(false)
1200}
1201
1202pub fn delete_local_branch(root: &Path, branch: &str, warnings: &mut Vec<String>) {
1203 let Ok(out) = Command::new("git")
1204 .args(["-C", &root.to_string_lossy(), "branch", "-D", branch])
1205 .output()
1206 else {
1207 warnings.push(format!("warning: could not delete branch {branch}: command failed"));
1208 return;
1209 };
1210 if !out.status.success() {
1211 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1212 warnings.push(format!("warning: could not delete branch {branch}: {stderr}"));
1213 }
1214}
1215
1216pub fn prune_remote_tracking(root: &Path, branch: &str) {
1217 let _ = Command::new("git")
1218 .args(["-C", &root.to_string_lossy(), "branch", "-dr", &format!("origin/{branch}")])
1219 .output();
1220}
1221
1222pub fn stage_files(root: &Path, files: &[&str]) -> Result<()> {
1223 let mut args = vec!["add"];
1224 args.extend_from_slice(files);
1225 run(root, &args).map(|_| ())
1226}
1227
1228pub fn commit(root: &Path, message: &str) -> Result<()> {
1229 run(root, &["commit", "-m", message]).map(|_| ())
1230}
1231
1232pub fn git_config_get(root: &Path, key: &str) -> Option<String> {
1233 let out = Command::new("git")
1234 .args(["-C", &root.to_string_lossy(), "config", key])
1235 .output()
1236 .ok()?;
1237 if !out.status.success() {
1238 return None;
1239 }
1240 let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
1241 if value.is_empty() { None } else { Some(value) }
1242}
1243
1244pub fn merge_ref(dir: &Path, refname: &str, warnings: &mut Vec<String>) -> Option<String> {
1245 let out = match Command::new("git")
1246 .args(["-C", &dir.to_string_lossy(), "merge", refname, "--no-edit"])
1247 .output()
1248 {
1249 Ok(o) => o,
1250 Err(e) => {
1251 warnings.push(format!("warning: merge {refname} failed: {e}"));
1252 return None;
1253 }
1254 };
1255 if out.status.success() {
1256 let stdout = String::from_utf8_lossy(&out.stdout);
1257 if stdout.contains("Already up to date") {
1258 None
1259 } else {
1260 Some(format!("Merged {refname} into branch."))
1261 }
1262 } else {
1263 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1264 warnings.push(format!("warning: merge {refname} failed: {stderr}"));
1265 None
1266 }
1267}
1268
1269pub fn is_file_tracked(root: &Path, path: &str) -> bool {
1270 Command::new("git")
1271 .args(["ls-files", "--error-unmatch", path])
1272 .current_dir(root)
1273 .stdout(std::process::Stdio::null())
1274 .stderr(std::process::Stdio::null())
1275 .status()
1276 .map(|s| s.success())
1277 .unwrap_or(false)
1278}
1279
1280pub enum MidMergeState {
1284 Merge,
1286 RebaseMerge,
1288 RebaseApply,
1290 CherryPick,
1292}
1293
1294pub fn detect_mid_merge_state(root: &Path) -> Option<MidMergeState> {
1303 let git_dir = root.join(".git");
1304 if git_dir.join("MERGE_HEAD").exists() {
1305 return Some(MidMergeState::Merge);
1306 }
1307 if git_dir.join("rebase-merge").is_dir() {
1308 return Some(MidMergeState::RebaseMerge);
1309 }
1310 if git_dir.join("rebase-apply").is_dir() {
1311 return Some(MidMergeState::RebaseApply);
1312 }
1313 if git_dir.join("CHERRY_PICK_HEAD").exists() {
1314 return Some(MidMergeState::CherryPick);
1315 }
1316 None
1317}
1318
1319pub fn merge_base(root: &Path, ref1: &str, ref2: &str) -> Result<String> {
1321 run(root, &["merge-base", ref1, ref2])
1322}
1323
1324pub fn main_worktree_root(root: &Path) -> Option<PathBuf> {
1325 let out = run(root, &["worktree", "list", "--porcelain"]).ok()?;
1326 out.lines()
1327 .next()
1328 .and_then(|line| line.strip_prefix("worktree "))
1329 .map(PathBuf::from)
1330}
1331
1332pub fn check_leaked_files(
1345 root: &Path,
1346 ticket_branch: &str,
1347 target_branch: &str,
1348) -> Result<Vec<String>> {
1349 let current = Command::new("git")
1351 .args(["rev-parse", "--abbrev-ref", "HEAD"])
1352 .current_dir(root)
1353 .output()?;
1354 let current_branch = String::from_utf8_lossy(¤t.stdout).trim().to_string();
1355
1356 let merge_dir = if current_branch == target_branch {
1357 root.to_path_buf()
1358 } else {
1359 match crate::worktree::find_worktree_for_branch(root, target_branch) {
1360 Some(p) => p,
1361 None => return Ok(vec![]), }
1363 };
1364
1365 let base = match merge_base(root, target_branch, ticket_branch) {
1367 Ok(s) => s.trim().to_string(),
1368 Err(_) => return Ok(vec![]), };
1370 if base.is_empty() {
1371 return Ok(vec![]);
1372 }
1373
1374 let diff_out = Command::new("git")
1377 .args(["diff", "--name-only", &base, ticket_branch])
1378 .current_dir(root)
1379 .output()?;
1380 let ticket_files: std::collections::HashSet<String> =
1381 String::from_utf8_lossy(&diff_out.stdout)
1382 .lines()
1383 .map(|s| s.to_string())
1384 .collect();
1385
1386 let status_out = Command::new("git")
1395 .args(["status", "--porcelain", "--untracked-files=all"])
1396 .current_dir(&merge_dir)
1397 .output()?;
1398 let dirty_files: std::collections::HashSet<String> =
1399 String::from_utf8_lossy(&status_out.stdout)
1400 .lines()
1401 .filter_map(|line| {
1402 if line.len() < 3 {
1403 return None;
1404 }
1405 let x = line.as_bytes()[0] as char;
1406 let y = line.as_bytes()[1] as char;
1407 if x == 'R' || x == 'C' || y == 'R' || y == 'C' {
1409 return None;
1410 }
1411 Some(line[3..].to_string())
1412 })
1413 .collect();
1414
1415 let mut overlap: Vec<String> = ticket_files
1417 .intersection(&dirty_files)
1418 .cloned()
1419 .collect();
1420 overlap.sort();
1421 Ok(overlap)
1422}
1423
1424#[cfg(test)]
1425mod tests {
1426 use super::*;
1427 use std::process::Command as Cmd;
1428 use tempfile::TempDir;
1429
1430 fn git_init() -> TempDir {
1431 let dir = tempfile::tempdir().unwrap();
1432 let p = dir.path();
1433 Cmd::new("git").args(["init", "-q", "-b", "main"]).current_dir(p).status().unwrap();
1434 Cmd::new("git").args(["config", "user.email", "t@t.com"]).current_dir(p).status().unwrap();
1435 Cmd::new("git").args(["config", "user.name", "test"]).current_dir(p).status().unwrap();
1436 dir
1437 }
1438
1439 fn git_cmd(dir: &Path, args: &[&str]) {
1440 Cmd::new("git")
1441 .args(args)
1442 .current_dir(dir)
1443 .env("GIT_AUTHOR_NAME", "test")
1444 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1445 .env("GIT_COMMITTER_NAME", "test")
1446 .env("GIT_COMMITTER_EMAIL", "t@t.com")
1447 .status()
1448 .unwrap();
1449 }
1450
1451 fn make_commit(dir: &Path, filename: &str, content: &str) {
1452 std::fs::write(dir.join(filename), content).unwrap();
1453 git_cmd(dir, &["add", filename]);
1454 git_cmd(dir, &["commit", "-m", "init"]);
1455 }
1456
1457 #[test]
1458 fn is_worktree_dirty_clean() {
1459 let dir = git_init();
1460 make_commit(dir.path(), "f.txt", "hi");
1461 assert!(!is_worktree_dirty(dir.path()));
1462 }
1463
1464 #[test]
1465 fn is_worktree_dirty_dirty() {
1466 let dir = git_init();
1467 make_commit(dir.path(), "f.txt", "hi");
1468 std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1469 assert!(is_worktree_dirty(dir.path()));
1470 }
1471
1472 #[test]
1473 fn local_branch_exists_present_and_absent() {
1474 let dir = git_init();
1475 make_commit(dir.path(), "f.txt", "hi");
1476 let on_main = local_branch_exists(dir.path(), "main");
1477 let on_master = local_branch_exists(dir.path(), "master");
1478 assert!(on_main || on_master);
1479 assert!(!local_branch_exists(dir.path(), "no-such-branch"));
1480 }
1481
1482 #[test]
1483 fn delete_local_branch_success() {
1484 let dir = git_init();
1485 make_commit(dir.path(), "f.txt", "hi");
1486 git_cmd(dir.path(), &["branch", "to-delete"]);
1487 let mut warnings = Vec::new();
1488 delete_local_branch(dir.path(), "to-delete", &mut warnings);
1489 assert!(warnings.is_empty());
1490 assert!(!local_branch_exists(dir.path(), "to-delete"));
1491 }
1492
1493 #[test]
1494 fn delete_local_branch_failure_adds_warning() {
1495 let dir = git_init();
1496 make_commit(dir.path(), "f.txt", "hi");
1497 let mut warnings = Vec::new();
1498 delete_local_branch(dir.path(), "nonexistent", &mut warnings);
1499 assert!(!warnings.is_empty());
1500 assert!(warnings[0].contains("warning:"));
1501 }
1502
1503 #[test]
1504 fn prune_remote_tracking_no_panic() {
1505 let dir = git_init();
1506 make_commit(dir.path(), "f.txt", "hi");
1507 prune_remote_tracking(dir.path(), "nonexistent-branch");
1509 }
1510
1511 #[test]
1512 fn stage_files_ok_and_err() {
1513 let dir = git_init();
1514 make_commit(dir.path(), "f.txt", "hi");
1515 std::fs::write(dir.path().join("new.txt"), "new").unwrap();
1516 assert!(stage_files(dir.path(), &["new.txt"]).is_ok());
1517 assert!(stage_files(dir.path(), &["missing.txt"]).is_err());
1518 }
1519
1520 #[test]
1521 fn commit_ok_and_err() {
1522 let dir = git_init();
1523 make_commit(dir.path(), "f.txt", "hi");
1524 std::fs::write(dir.path().join("new.txt"), "new").unwrap();
1525 git_cmd(dir.path(), &["add", "new.txt"]);
1526 assert!(commit(dir.path(), "test commit").is_ok());
1527 assert!(commit(dir.path(), "empty commit").is_err());
1529 }
1530
1531 #[test]
1532 fn git_config_get_some_and_none() {
1533 let dir = git_init();
1534 make_commit(dir.path(), "f.txt", "hi");
1535 let val = git_config_get(dir.path(), "user.email");
1536 assert_eq!(val, Some("t@t.com".to_string()));
1537 let missing = git_config_get(dir.path(), "no.such.key");
1538 assert!(missing.is_none());
1539 }
1540
1541 #[test]
1542 fn merge_ref_already_up_to_date() {
1543 let dir = git_init();
1544 make_commit(dir.path(), "f.txt", "hi");
1545 let branch = {
1546 let out = Cmd::new("git").args(["branch", "--show-current"]).current_dir(dir.path()).output().unwrap();
1547 String::from_utf8_lossy(&out.stdout).trim().to_string()
1548 };
1549 let mut warnings = Vec::new();
1550 let result = merge_ref(dir.path(), &branch, &mut warnings);
1552 assert!(result.is_none());
1553 assert!(warnings.is_empty());
1554 }
1555
1556 #[test]
1557 fn merge_ref_success() {
1558 let dir = git_init();
1559 make_commit(dir.path(), "f.txt", "hi");
1560 git_cmd(dir.path(), &["checkout", "-b", "feature"]);
1561 make_commit(dir.path(), "g.txt", "there");
1562 git_cmd(dir.path(), &["checkout", "main"]);
1563 let mut warnings = Vec::new();
1564 let result = merge_ref(dir.path(), "feature", &mut warnings);
1565 assert!(result.is_some());
1566 assert!(warnings.is_empty());
1567 }
1568
1569 #[test]
1570 fn detect_mid_merge_none_on_clean_repo() {
1571 let dir = git_init();
1572 make_commit(dir.path(), "f.txt", "hi");
1573 assert!(detect_mid_merge_state(dir.path()).is_none());
1574 }
1575
1576 #[test]
1577 fn detect_mid_merge_on_merge_head() {
1578 let dir = git_init();
1579 make_commit(dir.path(), "f.txt", "hi");
1580 std::fs::write(dir.path().join(".git/MERGE_HEAD"), "abc").unwrap();
1581 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::Merge)));
1582 }
1583
1584 #[test]
1585 fn detect_mid_merge_on_rebase_merge() {
1586 let dir = git_init();
1587 make_commit(dir.path(), "f.txt", "hi");
1588 std::fs::create_dir(dir.path().join(".git/rebase-merge")).unwrap();
1589 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseMerge)));
1590 }
1591
1592 #[test]
1593 fn detect_mid_merge_on_rebase_apply() {
1594 let dir = git_init();
1595 make_commit(dir.path(), "f.txt", "hi");
1596 std::fs::create_dir(dir.path().join(".git/rebase-apply")).unwrap();
1597 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseApply)));
1598 }
1599
1600 #[test]
1601 fn detect_mid_merge_on_cherry_pick() {
1602 let dir = git_init();
1603 make_commit(dir.path(), "f.txt", "hi");
1604 std::fs::write(dir.path().join(".git/CHERRY_PICK_HEAD"), "abc").unwrap();
1605 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::CherryPick)));
1606 }
1607
1608 #[test]
1609 fn is_file_tracked_tracked_and_untracked() {
1610 let dir = git_init();
1611 make_commit(dir.path(), "tracked.txt", "hi");
1612 assert!(is_file_tracked(dir.path(), "tracked.txt"));
1613 std::fs::write(dir.path().join("untracked.txt"), "new").unwrap();
1614 assert!(!is_file_tracked(dir.path(), "untracked.txt"));
1615 }
1616
1617 #[test]
1618 fn check_leaked_files_detects_overlap() {
1619 let dir = git_init();
1620 let p = dir.path();
1621 std::fs::create_dir_all(p.join("src")).unwrap();
1622 std::fs::write(p.join("src/foo.rs"), "original").unwrap();
1623 git_cmd(p, &["add", "src/foo.rs"]);
1624 git_cmd(p, &["commit", "-m", "add foo"]);
1625
1626 git_cmd(p, &["checkout", "-b", "ticket/overlap-test"]);
1627 std::fs::write(p.join("src/foo.rs"), "ticket-change").unwrap();
1628 git_cmd(p, &["add", "src/foo.rs"]);
1629 git_cmd(p, &["commit", "-m", "ticket: change foo"]);
1630 git_cmd(p, &["checkout", "main"]);
1631
1632 std::fs::write(p.join("src/foo.rs"), "leaked").unwrap();
1634
1635 let leaked = check_leaked_files(p, "ticket/overlap-test", "main").unwrap();
1636 assert_eq!(leaked, vec!["src/foo.rs".to_string()]);
1637 }
1638
1639 #[test]
1640 fn check_leaked_files_no_overlap() {
1641 let dir = git_init();
1642 let p = dir.path();
1643 std::fs::create_dir_all(p.join("src")).unwrap();
1644 std::fs::write(p.join("src/foo.rs"), "original").unwrap();
1645 std::fs::write(p.join("src/bar.rs"), "bar").unwrap();
1646 git_cmd(p, &["add", "src/foo.rs", "src/bar.rs"]);
1647 git_cmd(p, &["commit", "-m", "add foo and bar"]);
1648
1649 git_cmd(p, &["checkout", "-b", "ticket/no-overlap"]);
1651 std::fs::write(p.join("src/foo.rs"), "ticket-change").unwrap();
1652 git_cmd(p, &["add", "src/foo.rs"]);
1653 git_cmd(p, &["commit", "-m", "ticket: change foo"]);
1654 git_cmd(p, &["checkout", "main"]);
1655
1656 std::fs::write(p.join("src/bar.rs"), "dirty").unwrap();
1658
1659 let leaked = check_leaked_files(p, "ticket/no-overlap", "main").unwrap();
1660 assert!(leaked.is_empty(), "no overlap expected; got {leaked:?}");
1661 }
1662
1663 #[test]
1664 fn check_leaked_files_detects_untracked_overlap() {
1665 let dir = git_init();
1666 let p = dir.path();
1667 make_commit(p, "existing.rs", "base");
1668
1669 git_cmd(p, &["checkout", "-b", "ticket/untracked-overlap"]);
1671 std::fs::create_dir_all(p.join("src")).unwrap();
1672 std::fs::write(p.join("src/new.rs"), "new file").unwrap();
1673 git_cmd(p, &["add", "src/new.rs"]);
1674 git_cmd(p, &["commit", "-m", "ticket: add new file"]);
1675 git_cmd(p, &["checkout", "main"]);
1676
1677 std::fs::create_dir_all(p.join("src")).unwrap();
1679 std::fs::write(p.join("src/new.rs"), "leaked untracked").unwrap();
1680
1681 let leaked = check_leaked_files(p, "ticket/untracked-overlap", "main").unwrap();
1682 assert_eq!(leaked, vec!["src/new.rs".to_string()]);
1683 }
1684
1685 fn commit_file(dir: &Path, name: &str, content: &str) {
1689 std::fs::write(dir.join(name), content).unwrap();
1690 git_cmd(dir, &["add", name]);
1691 git_cmd(dir, &["commit", "-m", &format!("add {name}")]);
1692 }
1693
1694 #[test]
1697 fn content_merged_into_main_regular_merge_with_state_commit() {
1698 let dir = git_init();
1699 let p = dir.path();
1700
1701 commit_file(p, "README", "base");
1703
1704 git_cmd(p, &["checkout", "-b", "ticket/foo"]);
1706 std::fs::create_dir_all(p.join("src")).unwrap();
1707 commit_file(p, "src/lib.rs", "impl");
1708
1709 git_cmd(p, &["checkout", "main"]);
1711 git_cmd(p, &["merge", "--no-ff", "ticket/foo", "-m", "Merge ticket/foo"]);
1712
1713 git_cmd(p, &["checkout", "ticket/foo"]);
1715 std::fs::create_dir_all(p.join("tickets")).unwrap();
1716 commit_file(p, "tickets/foo.md", "state: implemented");
1717
1718 let result = content_merged_into_main(p, "main", "ticket/foo", "tickets").unwrap();
1720 assert!(result, "should detect that content was merged despite trailing state commit");
1721 }
1722
1723 #[test]
1726 fn content_merged_into_main_squash_merge_with_state_commit() {
1727 let dir = git_init();
1728 let p = dir.path();
1729
1730 commit_file(p, "README", "base");
1731
1732 git_cmd(p, &["checkout", "-b", "ticket/bar"]);
1734 std::fs::create_dir_all(p.join("src")).unwrap();
1735 commit_file(p, "src/lib.rs", "impl");
1736
1737 git_cmd(p, &["checkout", "main"]);
1739 git_cmd(p, &["merge", "--squash", "ticket/bar"]);
1740 git_cmd(p, &["commit", "-m", "Squash ticket/bar"]);
1741
1742 git_cmd(p, &["checkout", "ticket/bar"]);
1744 std::fs::create_dir_all(p.join("tickets")).unwrap();
1745 commit_file(p, "tickets/bar.md", "state: implemented");
1746
1747 let result = content_merged_into_main(p, "main", "ticket/bar", "tickets").unwrap();
1748 assert!(result, "should detect squash-merged content despite trailing state commit");
1749 }
1750
1751 #[test]
1754 fn content_merged_into_main_returns_false_when_ancestor() {
1755 let dir = git_init();
1756 let p = dir.path();
1757 commit_file(p, "README", "base");
1758 git_cmd(p, &["checkout", "-b", "ticket/anc"]);
1760 git_cmd(p, &["checkout", "main"]);
1762 let result = content_merged_into_main(p, "main", "ticket/anc", "tickets").unwrap();
1763 assert!(!result);
1764 }
1765
1766 #[test]
1768 fn content_merged_into_main_not_detected_when_non_ticket_file_modified_after_merge() {
1769 let dir = git_init();
1770 let p = dir.path();
1771 commit_file(p, "README", "base");
1772
1773 git_cmd(p, &["checkout", "-b", "ticket/extra"]);
1775 std::fs::create_dir_all(p.join("src")).unwrap();
1776 commit_file(p, "src/lib.rs", "impl");
1777
1778 git_cmd(p, &["checkout", "main"]);
1780 git_cmd(p, &["merge", "--squash", "ticket/extra"]);
1781 git_cmd(p, &["commit", "-m", "Squash ticket/extra"]);
1782
1783 git_cmd(p, &["checkout", "ticket/extra"]);
1785 std::fs::create_dir_all(p.join("tickets")).unwrap();
1786 commit_file(p, "tickets/extra.md", "state: implemented");
1787 commit_file(p, "src/extra.rs", "extra code");
1790
1791 let result = content_merged_into_main(p, "main", "ticket/extra", "tickets").unwrap();
1792 assert!(!result, "branch with non-ticket changes after merge must not be detected");
1793 }
1794
1795 #[test]
1798 fn content_merged_into_main_all_ticket_only_commits_returns_false() {
1799 let dir = git_init();
1800 let p = dir.path();
1801 commit_file(p, "README", "base");
1802
1803 git_cmd(p, &["checkout", "-b", "ticket/ticketonly"]);
1805 std::fs::create_dir_all(p.join("tickets")).unwrap();
1806 commit_file(p, "tickets/ticketonly.md", "state: new");
1807 git_cmd(p, &["checkout", "main"]);
1808
1809 let result = content_merged_into_main(p, "main", "ticket/ticketonly", "tickets").unwrap();
1810 assert!(!result, "all-ticket-only commits should return false");
1811 }
1812
1813 #[test]
1820 fn merged_into_main_detects_local_regular_merge_when_remote_deleted() {
1821 let dir = git_init();
1822 let p = dir.path();
1823 make_commit(p, "f.txt", "base");
1824
1825 git_cmd(p, &["checkout", "-b", "ticket/foo"]);
1827 std::fs::write(p.join("f.txt"), "ticket-change").unwrap();
1828 git_cmd(p, &["add", "f.txt"]);
1829 git_cmd(p, &["commit", "-m", "ticket: change"]);
1830
1831 git_cmd(p, &["checkout", "main"]);
1833 git_cmd(p, &["merge", "--no-ff", "ticket/foo", "-m", "Merge ticket/foo"]);
1834
1835 let main_sha = run(p, &["rev-parse", "main"]).unwrap();
1838 Cmd::new("git")
1839 .args(["update-ref", "refs/remotes/origin/main", main_sha.trim()])
1840 .current_dir(p)
1841 .status()
1842 .unwrap();
1843 let merged = merged_into_main(p, "main").unwrap();
1846 assert!(
1847 merged.iter().any(|b| b == "ticket/foo"),
1848 "expected ticket/foo in merged set; got {merged:?}"
1849 );
1850 }
1851}