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 commit_to_branch(
228 root: &Path,
229 branch: &str,
230 rel_path: &str,
231 content: &str,
232 message: &str,
233) -> Result<()> {
234 if !has_commits(root) {
236 let local_path = root.join(rel_path);
237 if let Some(parent) = local_path.parent() {
238 std::fs::create_dir_all(parent)?;
239 }
240 std::fs::write(&local_path, content)?;
241 return Ok(());
242 }
243
244 if let Some(wt_path) = find_worktree_for_branch(root, branch) {
246 let remote_ref = format!("origin/{branch}");
248 if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
249 let _ = run(&wt_path, &["merge", "--ff-only", &remote_ref]);
250 }
251 let full_path = wt_path.join(rel_path);
252 if let Some(parent) = full_path.parent() {
253 std::fs::create_dir_all(parent)?;
254 }
255 std::fs::write(&full_path, content)?;
256 let _ = run(&wt_path, &["add", rel_path]);
257 let _ = run(&wt_path, &["commit", "-m", message]);
258 crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
259 return Ok(());
260 }
261
262 if current_branch(root).ok().as_deref() == Some(branch) {
264 let local_path = root.join(rel_path);
265 if let Some(parent) = local_path.parent() {
266 std::fs::create_dir_all(parent)?;
267 }
268 std::fs::write(&local_path, content)?;
269 let _ = run(root, &["add", rel_path]);
270 let _ = run(root, &["commit", "-m", message]);
271 crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
272 return Ok(());
273 }
274
275 let result = try_worktree_commit(root, branch, rel_path, content, message);
276 if result.is_ok() {
277 crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
278 }
279 result
280}
281
282fn try_worktree_commit(
283 root: &Path,
284 branch: &str,
285 rel_path: &str,
286 content: &str,
287 message: &str,
288) -> Result<()> {
289 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
290 let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
291 let wt_path = std::env::temp_dir().join(format!(
292 "apm-{}-{}-{}",
293 std::process::id(),
294 seq,
295 branch.replace('/', "-"),
296 ));
297
298 let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
299 let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
300
301 if has_remote {
302 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
303 let _ = run(&wt_path, &["checkout", "-B", branch]);
304 } else if has_local {
305 let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
307 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
308 let _ = run(&wt_path, &["checkout", "-B", branch]);
309 } else {
310 run(root, &["worktree", "add", "-b", branch, &wt_path.to_string_lossy(), "HEAD"])?;
311 }
312
313 let result = (|| -> Result<()> {
314 let full_path = wt_path.join(rel_path);
315 if let Some(parent) = full_path.parent() {
316 std::fs::create_dir_all(parent)?;
317 }
318 std::fs::write(&full_path, content)?;
319 run(&wt_path, &["add", rel_path])?;
320 run(&wt_path, &["commit", "-m", message])?;
321 Ok(())
322 })();
323
324 let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
325 let _ = std::fs::remove_dir_all(&wt_path);
326
327 result
328}
329
330
331pub fn push_ticket_branches(root: &Path, warnings: &mut Vec<String>) {
334 if run(root, &["remote", "get-url", "origin"]).is_err() {
335 return;
336 }
337 let out = match run(root, &["branch", "--list", "ticket/*"]) {
338 Ok(o) => o,
339 Err(_) => return,
340 };
341 for branch in out.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
342 let range = format!("origin/{branch}..{branch}");
343 let count = run(root, &["rev-list", "--count", &range])
344 .ok()
345 .and_then(|s| s.trim().parse::<u32>().ok())
346 .unwrap_or(0);
347 if count > 0 {
348 if let Err(e) = run(root, &["push", "origin", branch]) {
349 warnings.push(format!("warning: push {branch} failed: {e:#}"));
350 }
351 }
352 }
353}
354
355pub fn sync_non_checked_out_refs(root: &Path, warnings: &mut Vec<String>) -> Vec<String> {
383 let checked_out: std::collections::HashSet<String> = {
386 let mut set = std::collections::HashSet::new();
387 if let Ok(out) = run(root, &["worktree", "list", "--porcelain"]) {
388 for line in out.lines() {
389 if let Some(b) = line.strip_prefix("branch refs/heads/") {
390 set.insert(b.to_string());
391 }
392 }
393 }
394 set
395 };
396
397 const MANAGED_NAMESPACES: &[&str] = &["ticket", "epic"];
400
401 let mut remote_refs: Vec<String> = Vec::new();
403 for ns in MANAGED_NAMESPACES {
404 let pattern = format!("refs/remotes/origin/{ns}/");
405 if let Ok(out) = run(root, &["for-each-ref", "--format=%(refname:short)", &pattern]) {
406 for line in out.lines().filter(|l| !l.is_empty()) {
407 remote_refs.push(line.to_string());
408 }
409 }
410 }
411
412 let mut ahead_branches: Vec<String> = Vec::new();
413
414 for remote_name in remote_refs {
415 let branch = match remote_name.strip_prefix("origin/") {
418 Some(b) => b.to_string(),
419 None => continue,
420 };
421
422 if checked_out.contains(&branch) {
424 continue;
425 }
426
427 let local_ref = format!("refs/heads/{branch}");
428 let remote_ref_full = format!("refs/remotes/{remote_name}");
430
431 match classify_branch(root, &local_ref, &remote_name) {
434 BranchClass::RemoteOnly => {
435 let sha = match run(root, &["rev-parse", &remote_ref_full]) {
438 Ok(s) => s,
439 Err(_) => continue,
440 };
441 if let Err(e) = run(root, &["update-ref", &local_ref, &sha]) {
442 warnings.push(format!("warning: could not create local ref {branch}: {e:#}"));
443 }
444 }
445 BranchClass::Equal => {
446 }
448 BranchClass::Behind => {
449 let sha = match run(root, &["rev-parse", &remote_ref_full]) {
452 Ok(s) => s,
453 Err(_) => continue,
454 };
455 if let Err(e) = run(root, &["update-ref", &local_ref, &sha]) {
456 warnings.push(format!("warning: could not fast-forward {branch}: {e:#}"));
457 }
458 }
459 BranchClass::Ahead => {
460 warnings.push(crate::sync_guidance::TICKET_OR_EPIC_AHEAD.replace("<slug>", &branch));
466 ahead_branches.push(branch);
467 }
468 BranchClass::Diverged => {
469 let msg = crate::sync_guidance::TICKET_OR_EPIC_DIVERGED
472 .replace("<slug>", &branch);
473 warnings.push(msg);
474 }
475 BranchClass::NoRemote => {
476 }
479 }
480 }
481
482 ahead_branches
483}
484
485pub fn list_files_on_branch(root: &Path, branch: &str, dir: &str) -> Result<Vec<String>> {
487 let tree_ref = format!("{branch}:{dir}");
488 let out = run(root, &["ls-tree", "--name-only", &tree_ref])
489 .or_else(|_| run(root, &["ls-tree", "--name-only", &format!("origin/{branch}:{dir}")]))?;
490 Ok(out.lines()
491 .filter(|l| !l.is_empty())
492 .map(|l| format!("{dir}/{l}"))
493 .collect())
494}
495
496pub fn commit_files_to_branch(
498 root: &Path,
499 branch: &str,
500 files: &[(&str, String)],
501 message: &str,
502) -> Result<()> {
503 if !has_commits(root) {
504 for (rel_path, content) in files {
505 let local_path = root.join(rel_path);
506 if let Some(parent) = local_path.parent() {
507 std::fs::create_dir_all(parent)?;
508 }
509 std::fs::write(&local_path, content)?;
510 }
511 return Ok(());
512 }
513
514 if let Some(wt_path) = find_worktree_for_branch(root, branch) {
515 for (rel_path, content) in files {
516 let full_path = wt_path.join(rel_path);
517 if let Some(parent) = full_path.parent() {
518 std::fs::create_dir_all(parent)?;
519 }
520 std::fs::write(&full_path, content)?;
521 let _ = run(&wt_path, &["add", rel_path]);
522 }
523 run(&wt_path, &["commit", "-m", message])?;
524 crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
525 return Ok(());
526 }
527
528 if current_branch(root).ok().as_deref() == Some(branch) {
529 for (rel_path, content) in files {
530 let local_path = root.join(rel_path);
531 if let Some(parent) = local_path.parent() {
532 std::fs::create_dir_all(parent)?;
533 }
534 std::fs::write(&local_path, content)?;
535 let _ = run(root, &["add", rel_path]);
536 }
537 run(root, &["commit", "-m", message])?;
538 crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
539 return Ok(());
540 }
541
542 let unique = std::time::SystemTime::now()
543 .duration_since(std::time::UNIX_EPOCH)
544 .map(|d| d.subsec_nanos())
545 .unwrap_or(0);
546 let wt_path = std::env::temp_dir().join(format!(
547 "apm-{}-{}-{}",
548 std::process::id(),
549 unique,
550 branch.replace('/', "-"),
551 ));
552
553 let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
554 let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
555
556 if has_remote {
557 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
558 let _ = run(&wt_path, &["checkout", "-B", branch]);
559 } else if has_local {
560 let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
561 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
562 let _ = run(&wt_path, &["checkout", "-B", branch]);
563 } else {
564 run(root, &["worktree", "add", &wt_path.to_string_lossy(), branch])?;
565 }
566
567 let result = (|| -> Result<()> {
568 for (rel_path, content) in files {
569 let full_path = wt_path.join(rel_path);
570 if let Some(parent) = full_path.parent() {
571 std::fs::create_dir_all(parent)?;
572 }
573 std::fs::write(&full_path, content)?;
574 run(&wt_path, &["add", rel_path])?;
575 }
576 run(&wt_path, &["commit", "-m", message])?;
577 Ok(())
578 })();
579
580 let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
581 let _ = std::fs::remove_dir_all(&wt_path);
582
583 if result.is_ok() {
584 crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
585 }
586 result
587}
588
589pub fn branch_tip(root: &Path, branch: &str) -> Option<String> {
591 run(root, &["rev-parse", &format!("refs/heads/{branch}")]).ok()
592}
593
594pub fn resolve_branch_sha(root: &Path, branch: &str) -> Result<String> {
597 run(root, &["rev-parse", &format!("origin/{branch}")])
598 .or_else(|_| run(root, &["rev-parse", branch]))
599 .with_context(|| format!("branch '{branch}' not found locally or on origin"))
600}
601
602pub fn create_branch_at(root: &Path, branch: &str, sha: &str) -> Result<()> {
604 run(root, &["branch", branch, sha]).map(|_| ())
605}
606
607pub fn remote_branch_tip(root: &Path, branch: &str) -> Option<String> {
609 run(root, &["rev-parse", &format!("refs/remotes/origin/{branch}")]).ok()
610}
611
612pub fn is_ancestor(root: &Path, commit: &str, of_ref: &str) -> bool {
615 Command::new("git")
616 .current_dir(root)
617 .args(["merge-base", "--is-ancestor", commit, of_ref])
618 .status()
619 .map(|s| s.success())
620 .unwrap_or(false)
621}
622
623pub enum BranchClass {
633 Equal,
634 Behind,
635 Ahead,
636 Diverged,
637 RemoteOnly,
639 NoRemote,
641}
642
643pub fn classify_branch(root: &Path, local: &str, remote: &str) -> BranchClass {
651 let local_sha = match run(root, &["rev-parse", local]) {
652 Ok(s) => s,
653 Err(_) => {
654 return if run(root, &["rev-parse", remote]).is_ok() {
658 BranchClass::RemoteOnly
659 } else {
660 BranchClass::NoRemote
661 };
662 }
663 };
664 let remote_sha = match run(root, &["rev-parse", remote]) {
665 Ok(s) => s,
666 Err(_) => return BranchClass::NoRemote,
667 };
668
669 if local_sha == remote_sha {
670 return BranchClass::Equal;
671 }
672
673 let local_is_ancestor_of_remote = is_ancestor(root, local, remote);
676
677 let remote_is_ancestor_of_local = is_ancestor(root, remote, local);
680
681 match (local_is_ancestor_of_remote, remote_is_ancestor_of_local) {
682 (true, false) => BranchClass::Behind, (false, true) => BranchClass::Ahead, (false, false) => BranchClass::Diverged, (true, true) => BranchClass::Equal, }
687}
688
689pub fn sync_default_branch(root: &Path, default: &str, warnings: &mut Vec<String>) -> bool {
714 let remote = format!("origin/{default}");
715 match classify_branch(root, default, &remote) {
716 BranchClass::Equal => {
717 }
719
720 BranchClass::Behind => {
721 let wt = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
724 if run(&wt, &["merge", "--ff-only", &remote]).is_err() {
725 let msg = crate::sync_guidance::MAIN_BEHIND_DIRTY_OVERLAP
729 .replace("<default>", default);
730 warnings.push(msg);
731 }
732 }
733
734 BranchClass::Ahead => {
735 let count = run(root, &["rev-list", "--count", &format!("{remote}..{default}")])
738 .ok()
739 .and_then(|s| s.trim().parse::<u64>().ok())
740 .unwrap_or(0);
741 let msg = crate::sync_guidance::MAIN_AHEAD
742 .replace("<default>", default)
743 .replace("<remote>", &remote)
744 .replace("<count>", &count.to_string())
745 .replace("<commits>", if count == 1 { "commit" } else { "commits" });
746 warnings.push(msg);
747 return true;
748 }
749
750 BranchClass::Diverged => {
751 let wt = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
754 let guidance = if is_worktree_dirty(&wt) {
755 crate::sync_guidance::MAIN_DIVERGED_DIRTY.replace("<default>", default)
756 } else {
757 crate::sync_guidance::MAIN_DIVERGED_CLEAN.replace("<default>", default)
758 };
759 warnings.push(guidance);
760 }
761
762 BranchClass::RemoteOnly => {
763 }
767
768 BranchClass::NoRemote => {
769 }
773 }
774 false
775}
776
777pub fn fetch_branch(root: &Path, branch: &str) -> anyhow::Result<()> {
778 let status = std::process::Command::new("git")
779 .args(["fetch", "origin", branch])
780 .current_dir(root)
781 .status()?;
782 if !status.success() {
783 anyhow::bail!("git fetch failed");
784 }
785 Ok(())
786}
787
788pub fn push_branch(root: &Path, branch: &str) -> anyhow::Result<()> {
789 let status = std::process::Command::new("git")
790 .args(["push", "origin", &format!("{branch}:{branch}")])
791 .current_dir(root)
792 .status()?;
793 if !status.success() {
794 anyhow::bail!("git push failed");
795 }
796 Ok(())
797}
798
799pub fn push_branch_tracking(root: &Path, branch: &str) -> anyhow::Result<()> {
800 let out = std::process::Command::new("git")
801 .args(["push", "--set-upstream", "origin", &format!("{branch}:{branch}")])
802 .current_dir(root)
803 .output()?;
804 if !out.status.success() {
805 anyhow::bail!("git push failed: {}", String::from_utf8_lossy(&out.stderr).trim());
806 }
807 Ok(())
808}
809
810pub fn has_remote(root: &Path) -> bool {
811 run(root, &["remote", "get-url", "origin"]).is_ok()
812}
813
814pub fn remote_ticket_branches_with_dates(
819 root: &Path,
820) -> Result<Vec<(String, chrono::DateTime<chrono::Utc>)>> {
821 use chrono::{TimeZone, Utc};
822 let out = Command::new("git")
823 .current_dir(root)
824 .args([
825 "for-each-ref",
826 "refs/remotes/origin/ticket/",
827 "--format=%(refname:short) %(creatordate:unix)",
828 ])
829 .output()
830 .context("git for-each-ref failed")?;
831 let stdout = String::from_utf8_lossy(&out.stdout);
832 let mut result = Vec::new();
833 for line in stdout.lines() {
834 let mut parts = line.splitn(2, ' ');
835 let refname = parts.next().unwrap_or("").trim();
836 let ts_str = parts.next().unwrap_or("").trim();
837 let branch = refname.trim_start_matches("origin/");
838 if branch.is_empty() {
839 continue;
840 }
841 if let Ok(ts) = ts_str.parse::<i64>() {
842 if let Some(dt) = Utc.timestamp_opt(ts, 0).single() {
843 result.push((branch.to_string(), dt));
844 }
845 }
846 }
847 Ok(result)
848}
849
850pub fn list_remote_ticket_branches(root: &Path) -> std::collections::HashSet<String> {
856 let mut set = std::collections::HashSet::new();
857 let out = match run(root, &["ls-remote", "--heads", "origin", "ticket/*"]) {
858 Ok(o) => o,
859 Err(_) => return set,
860 };
861 for line in out.lines() {
862 if let Some(refname) = line.split('\t').nth(1) {
863 if let Some(branch) = refname.trim().strip_prefix("refs/heads/") {
864 set.insert(branch.to_string());
865 }
866 }
867 }
868 set
869}
870
871pub fn delete_remote_branch(root: &Path, branch: &str) -> Result<()> {
873 let status = Command::new("git")
874 .current_dir(root)
875 .args(["push", "origin", "--delete", branch])
876 .status()
877 .context("git push origin --delete failed")?;
878 if !status.success() {
879 anyhow::bail!("git push origin --delete {branch} failed");
880 }
881 Ok(())
882}
883
884pub fn move_files_on_branch(
889 root: &Path,
890 branch: &str,
891 moves: &[(&str, &str, &str)],
892 message: &str,
893) -> Result<()> {
894 if !has_commits(root) {
895 for (old, new, content) in moves {
896 let new_path = root.join(new);
897 if let Some(parent) = new_path.parent() {
898 std::fs::create_dir_all(parent)?;
899 }
900 std::fs::write(&new_path, content)?;
901 let old_path = root.join(old);
902 let _ = std::fs::remove_file(&old_path);
903 }
904 return Ok(());
905 }
906
907 let do_moves = |wt: &Path| -> Result<()> {
908 for (old, new, content) in moves {
909 let new_path = wt.join(new);
910 if let Some(parent) = new_path.parent() {
911 std::fs::create_dir_all(parent)?;
912 }
913 std::fs::write(&new_path, content)?;
914 run(wt, &["add", new])?;
915 run(wt, &["rm", "--force", "--quiet", old])?;
916 }
917 run(wt, &["commit", "-m", message])?;
918 Ok(())
919 };
920
921 if let Some(wt_path) = find_worktree_for_branch(root, branch) {
922 let remote_ref = format!("origin/{branch}");
923 if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
924 let _ = run(&wt_path, &["merge", "--ff-only", &remote_ref]);
925 }
926 let result = do_moves(&wt_path);
927 if result.is_ok() {
928 crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
929 }
930 return result;
931 }
932
933 if current_branch(root).ok().as_deref() == Some(branch) {
934 let result = do_moves(root);
935 if result.is_ok() {
936 crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
937 }
938 return result;
939 }
940
941 let unique = std::time::SystemTime::now()
942 .duration_since(std::time::UNIX_EPOCH)
943 .map(|d| d.subsec_nanos())
944 .unwrap_or(0);
945 let wt_path = std::env::temp_dir().join(format!(
946 "apm-{}-{}-{}",
947 std::process::id(),
948 unique,
949 branch.replace('/', "-"),
950 ));
951
952 let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
953 let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
954
955 if has_remote {
956 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
957 let _ = run(&wt_path, &["checkout", "-B", branch]);
958 } else if has_local {
959 let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
960 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
961 let _ = run(&wt_path, &["checkout", "-B", branch]);
962 } else {
963 run(root, &["worktree", "add", &wt_path.to_string_lossy(), branch])?;
964 }
965
966 let result = do_moves(&wt_path);
967 let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
968 let _ = std::fs::remove_dir_all(&wt_path);
969 if result.is_ok() {
970 crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
971 }
972 result
973}
974
975pub fn merge_branch_into_default(root: &Path, branch: &str, default_branch: &str, warnings: &mut Vec<String>) -> Result<()> {
976 let _ = run(root, &["fetch", "origin", default_branch]);
977
978 let merge_dir = if current_branch(root).ok().as_deref() == Some(default_branch) {
979 root.to_path_buf()
980 } else {
981 find_worktree_for_branch(root, default_branch).unwrap_or_else(|| root.to_path_buf())
982 };
983
984 if let Err(e) = run(&merge_dir, &["merge", "--no-ff", branch, "--no-edit"]) {
985 let _ = run(&merge_dir, &["merge", "--abort"]);
986 anyhow::bail!("merge failed: {e:#}");
987 }
988
989 if has_remote(root) {
990 if let Err(e) = run(&merge_dir, &["push", "origin", default_branch]) {
991 warnings.push(format!("warning: push {default_branch} failed: {e:#}"));
992 }
993 }
994 Ok(())
995}
996
997pub 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<()> {
998 let _ = std::process::Command::new("git")
999 .args(["fetch", "origin", default_branch])
1000 .current_dir(root)
1001 .status();
1002
1003 let current = std::process::Command::new("git")
1004 .args(["rev-parse", "--abbrev-ref", "HEAD"])
1005 .current_dir(root)
1006 .output()?;
1007 let current_branch = String::from_utf8_lossy(¤t.stdout).trim().to_string();
1008
1009 let merge_dir = if current_branch == default_branch {
1010 root.to_path_buf()
1011 } else {
1012 let main_root = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
1013 let worktrees_base = main_root.join(&config.worktrees.dir);
1014 ensure_worktree(root, &worktrees_base, default_branch)?
1015 };
1016
1017 let out = std::process::Command::new("git")
1018 .args(["merge", "--no-ff", branch, "--no-edit"])
1019 .current_dir(&merge_dir)
1020 .output()?;
1021
1022 if !out.status.success() {
1023 let _ = std::process::Command::new("git")
1024 .args(["merge", "--abort"])
1025 .current_dir(&merge_dir)
1026 .status();
1027 bail!(
1028 "merge conflict — resolve manually and push: {}",
1029 String::from_utf8_lossy(&out.stderr).trim()
1030 );
1031 }
1032
1033 if skip_push {
1034 messages.push(format!("Merged {branch} into {default_branch} (local only)."));
1035 } else {
1036 push_branch(&merge_dir, default_branch)?;
1037 messages.push(format!("Merged {branch} into {default_branch} and pushed to origin."));
1038 }
1039 Ok(())
1040}
1041
1042pub fn pull_default(root: &Path, default_branch: &str, warnings: &mut Vec<String>) -> Result<()> {
1043 let fetch = std::process::Command::new("git")
1044 .args(["fetch", "origin", default_branch])
1045 .current_dir(root)
1046 .output();
1047
1048 match fetch {
1049 Err(e) => {
1050 warnings.push(format!("warning: fetch failed: {e:#}"));
1051 return Ok(());
1052 }
1053 Ok(out) if !out.status.success() => {
1054 warnings.push(format!(
1055 "warning: fetch failed: {}",
1056 String::from_utf8_lossy(&out.stderr).trim()
1057 ));
1058 return Ok(());
1059 }
1060 _ => {}
1061 }
1062
1063 let current = std::process::Command::new("git")
1064 .args(["rev-parse", "--abbrev-ref", "HEAD"])
1065 .current_dir(root)
1066 .output()?;
1067 let current_branch = String::from_utf8_lossy(¤t.stdout).trim().to_string();
1068
1069 let merge_dir = if current_branch == default_branch {
1070 root.to_path_buf()
1071 } else {
1072 find_worktree_for_branch(root, default_branch)
1073 .unwrap_or_else(|| root.to_path_buf())
1074 };
1075
1076 let remote_ref = format!("origin/{default_branch}");
1077 let out = std::process::Command::new("git")
1078 .args(["merge", "--ff-only", &remote_ref])
1079 .current_dir(&merge_dir)
1080 .output()?;
1081
1082 if !out.status.success() {
1083 warnings.push(format!("warning: could not fast-forward {default_branch} — pull manually"));
1084 }
1085
1086 Ok(())
1087}
1088
1089pub fn is_worktree_dirty(path: &Path) -> bool {
1090 let Ok(out) = Command::new("git")
1091 .args(["-C", &path.to_string_lossy(), "status", "--porcelain"])
1092 .output()
1093 else {
1094 return false;
1095 };
1096 !out.stdout.is_empty()
1097}
1098
1099pub fn local_branch_exists(root: &Path, branch: &str) -> bool {
1100 Command::new("git")
1101 .args(["-C", &root.to_string_lossy(), "rev-parse", "--verify", &format!("refs/heads/{branch}")])
1102 .output()
1103 .map(|o| o.status.success())
1104 .unwrap_or(false)
1105}
1106
1107pub fn delete_local_branch(root: &Path, branch: &str, warnings: &mut Vec<String>) {
1108 let Ok(out) = Command::new("git")
1109 .args(["-C", &root.to_string_lossy(), "branch", "-D", branch])
1110 .output()
1111 else {
1112 warnings.push(format!("warning: could not delete branch {branch}: command failed"));
1113 return;
1114 };
1115 if !out.status.success() {
1116 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1117 warnings.push(format!("warning: could not delete branch {branch}: {stderr}"));
1118 }
1119}
1120
1121pub fn prune_remote_tracking(root: &Path, branch: &str) {
1122 let _ = Command::new("git")
1123 .args(["-C", &root.to_string_lossy(), "branch", "-dr", &format!("origin/{branch}")])
1124 .output();
1125}
1126
1127pub fn stage_files(root: &Path, files: &[&str]) -> Result<()> {
1128 let mut args = vec!["add"];
1129 args.extend_from_slice(files);
1130 run(root, &args).map(|_| ())
1131}
1132
1133pub fn commit(root: &Path, message: &str) -> Result<()> {
1134 run(root, &["commit", "-m", message]).map(|_| ())
1135}
1136
1137pub fn git_config_get(root: &Path, key: &str) -> Option<String> {
1138 let out = Command::new("git")
1139 .args(["-C", &root.to_string_lossy(), "config", key])
1140 .output()
1141 .ok()?;
1142 if !out.status.success() {
1143 return None;
1144 }
1145 let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
1146 if value.is_empty() { None } else { Some(value) }
1147}
1148
1149pub fn merge_ref(dir: &Path, refname: &str, warnings: &mut Vec<String>) -> Option<String> {
1150 let out = match Command::new("git")
1151 .args(["-C", &dir.to_string_lossy(), "merge", refname, "--no-edit"])
1152 .output()
1153 {
1154 Ok(o) => o,
1155 Err(e) => {
1156 warnings.push(format!("warning: merge {refname} failed: {e}"));
1157 return None;
1158 }
1159 };
1160 if out.status.success() {
1161 let stdout = String::from_utf8_lossy(&out.stdout);
1162 if stdout.contains("Already up to date") {
1163 None
1164 } else {
1165 Some(format!("Merged {refname} into branch."))
1166 }
1167 } else {
1168 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1169 warnings.push(format!("warning: merge {refname} failed: {stderr}"));
1170 None
1171 }
1172}
1173
1174pub fn is_file_tracked(root: &Path, path: &str) -> bool {
1175 Command::new("git")
1176 .args(["ls-files", "--error-unmatch", path])
1177 .current_dir(root)
1178 .stdout(std::process::Stdio::null())
1179 .stderr(std::process::Stdio::null())
1180 .status()
1181 .map(|s| s.success())
1182 .unwrap_or(false)
1183}
1184
1185pub enum MidMergeState {
1189 Merge,
1191 RebaseMerge,
1193 RebaseApply,
1195 CherryPick,
1197}
1198
1199pub fn detect_mid_merge_state(root: &Path) -> Option<MidMergeState> {
1208 let git_dir = root.join(".git");
1209 if git_dir.join("MERGE_HEAD").exists() {
1210 return Some(MidMergeState::Merge);
1211 }
1212 if git_dir.join("rebase-merge").is_dir() {
1213 return Some(MidMergeState::RebaseMerge);
1214 }
1215 if git_dir.join("rebase-apply").is_dir() {
1216 return Some(MidMergeState::RebaseApply);
1217 }
1218 if git_dir.join("CHERRY_PICK_HEAD").exists() {
1219 return Some(MidMergeState::CherryPick);
1220 }
1221 None
1222}
1223
1224pub fn merge_base(root: &Path, ref1: &str, ref2: &str) -> Result<String> {
1226 run(root, &["merge-base", ref1, ref2])
1227}
1228
1229pub fn main_worktree_root(root: &Path) -> Option<PathBuf> {
1230 let out = run(root, &["worktree", "list", "--porcelain"]).ok()?;
1231 out.lines()
1232 .next()
1233 .and_then(|line| line.strip_prefix("worktree "))
1234 .map(PathBuf::from)
1235}
1236
1237pub fn check_leaked_files(
1250 root: &Path,
1251 ticket_branch: &str,
1252 target_branch: &str,
1253) -> Result<Vec<String>> {
1254 let current = Command::new("git")
1256 .args(["rev-parse", "--abbrev-ref", "HEAD"])
1257 .current_dir(root)
1258 .output()?;
1259 let current_branch = String::from_utf8_lossy(¤t.stdout).trim().to_string();
1260
1261 let merge_dir = if current_branch == target_branch {
1262 root.to_path_buf()
1263 } else {
1264 match crate::worktree::find_worktree_for_branch(root, target_branch) {
1265 Some(p) => p,
1266 None => return Ok(vec![]), }
1268 };
1269
1270 let base = match merge_base(root, target_branch, ticket_branch) {
1272 Ok(s) => s.trim().to_string(),
1273 Err(_) => return Ok(vec![]), };
1275 if base.is_empty() {
1276 return Ok(vec![]);
1277 }
1278
1279 let diff_out = Command::new("git")
1282 .args(["diff", "--name-only", &base, ticket_branch])
1283 .current_dir(root)
1284 .output()?;
1285 let ticket_files: std::collections::HashSet<String> =
1286 String::from_utf8_lossy(&diff_out.stdout)
1287 .lines()
1288 .map(|s| s.to_string())
1289 .collect();
1290
1291 let status_out = Command::new("git")
1300 .args(["status", "--porcelain", "--untracked-files=all"])
1301 .current_dir(&merge_dir)
1302 .output()?;
1303 let dirty_files: std::collections::HashSet<String> =
1304 String::from_utf8_lossy(&status_out.stdout)
1305 .lines()
1306 .filter_map(|line| {
1307 if line.len() < 3 {
1308 return None;
1309 }
1310 let x = line.as_bytes()[0] as char;
1311 let y = line.as_bytes()[1] as char;
1312 if x == 'R' || x == 'C' || y == 'R' || y == 'C' {
1314 return None;
1315 }
1316 Some(line[3..].to_string())
1317 })
1318 .collect();
1319
1320 let mut overlap: Vec<String> = ticket_files
1322 .intersection(&dirty_files)
1323 .cloned()
1324 .collect();
1325 overlap.sort();
1326 Ok(overlap)
1327}
1328
1329#[cfg(test)]
1330mod tests {
1331 use super::*;
1332 use std::process::Command as Cmd;
1333 use tempfile::TempDir;
1334
1335 fn git_init() -> TempDir {
1336 let dir = tempfile::tempdir().unwrap();
1337 let p = dir.path();
1338 Cmd::new("git").args(["init", "-q", "-b", "main"]).current_dir(p).status().unwrap();
1339 Cmd::new("git").args(["config", "user.email", "t@t.com"]).current_dir(p).status().unwrap();
1340 Cmd::new("git").args(["config", "user.name", "test"]).current_dir(p).status().unwrap();
1341 dir
1342 }
1343
1344 fn git_cmd(dir: &Path, args: &[&str]) {
1345 Cmd::new("git")
1346 .args(args)
1347 .current_dir(dir)
1348 .env("GIT_AUTHOR_NAME", "test")
1349 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1350 .env("GIT_COMMITTER_NAME", "test")
1351 .env("GIT_COMMITTER_EMAIL", "t@t.com")
1352 .status()
1353 .unwrap();
1354 }
1355
1356 fn make_commit(dir: &Path, filename: &str, content: &str) {
1357 std::fs::write(dir.join(filename), content).unwrap();
1358 git_cmd(dir, &["add", filename]);
1359 git_cmd(dir, &["commit", "-m", "init"]);
1360 }
1361
1362 #[test]
1363 fn is_worktree_dirty_clean() {
1364 let dir = git_init();
1365 make_commit(dir.path(), "f.txt", "hi");
1366 assert!(!is_worktree_dirty(dir.path()));
1367 }
1368
1369 #[test]
1370 fn is_worktree_dirty_dirty() {
1371 let dir = git_init();
1372 make_commit(dir.path(), "f.txt", "hi");
1373 std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1374 assert!(is_worktree_dirty(dir.path()));
1375 }
1376
1377 #[test]
1378 fn local_branch_exists_present_and_absent() {
1379 let dir = git_init();
1380 make_commit(dir.path(), "f.txt", "hi");
1381 let on_main = local_branch_exists(dir.path(), "main");
1382 let on_master = local_branch_exists(dir.path(), "master");
1383 assert!(on_main || on_master);
1384 assert!(!local_branch_exists(dir.path(), "no-such-branch"));
1385 }
1386
1387 #[test]
1388 fn delete_local_branch_success() {
1389 let dir = git_init();
1390 make_commit(dir.path(), "f.txt", "hi");
1391 git_cmd(dir.path(), &["branch", "to-delete"]);
1392 let mut warnings = Vec::new();
1393 delete_local_branch(dir.path(), "to-delete", &mut warnings);
1394 assert!(warnings.is_empty());
1395 assert!(!local_branch_exists(dir.path(), "to-delete"));
1396 }
1397
1398 #[test]
1399 fn delete_local_branch_failure_adds_warning() {
1400 let dir = git_init();
1401 make_commit(dir.path(), "f.txt", "hi");
1402 let mut warnings = Vec::new();
1403 delete_local_branch(dir.path(), "nonexistent", &mut warnings);
1404 assert!(!warnings.is_empty());
1405 assert!(warnings[0].contains("warning:"));
1406 }
1407
1408 #[test]
1409 fn prune_remote_tracking_no_panic() {
1410 let dir = git_init();
1411 make_commit(dir.path(), "f.txt", "hi");
1412 prune_remote_tracking(dir.path(), "nonexistent-branch");
1414 }
1415
1416 #[test]
1417 fn stage_files_ok_and_err() {
1418 let dir = git_init();
1419 make_commit(dir.path(), "f.txt", "hi");
1420 std::fs::write(dir.path().join("new.txt"), "new").unwrap();
1421 assert!(stage_files(dir.path(), &["new.txt"]).is_ok());
1422 assert!(stage_files(dir.path(), &["missing.txt"]).is_err());
1423 }
1424
1425 #[test]
1426 fn commit_ok_and_err() {
1427 let dir = git_init();
1428 make_commit(dir.path(), "f.txt", "hi");
1429 std::fs::write(dir.path().join("new.txt"), "new").unwrap();
1430 git_cmd(dir.path(), &["add", "new.txt"]);
1431 assert!(commit(dir.path(), "test commit").is_ok());
1432 assert!(commit(dir.path(), "empty commit").is_err());
1434 }
1435
1436 #[test]
1437 fn git_config_get_some_and_none() {
1438 let dir = git_init();
1439 make_commit(dir.path(), "f.txt", "hi");
1440 let val = git_config_get(dir.path(), "user.email");
1441 assert_eq!(val, Some("t@t.com".to_string()));
1442 let missing = git_config_get(dir.path(), "no.such.key");
1443 assert!(missing.is_none());
1444 }
1445
1446 #[test]
1447 fn merge_ref_already_up_to_date() {
1448 let dir = git_init();
1449 make_commit(dir.path(), "f.txt", "hi");
1450 let branch = {
1451 let out = Cmd::new("git").args(["branch", "--show-current"]).current_dir(dir.path()).output().unwrap();
1452 String::from_utf8_lossy(&out.stdout).trim().to_string()
1453 };
1454 let mut warnings = Vec::new();
1455 let result = merge_ref(dir.path(), &branch, &mut warnings);
1457 assert!(result.is_none());
1458 assert!(warnings.is_empty());
1459 }
1460
1461 #[test]
1462 fn merge_ref_success() {
1463 let dir = git_init();
1464 make_commit(dir.path(), "f.txt", "hi");
1465 git_cmd(dir.path(), &["checkout", "-b", "feature"]);
1466 make_commit(dir.path(), "g.txt", "there");
1467 git_cmd(dir.path(), &["checkout", "main"]);
1468 let mut warnings = Vec::new();
1469 let result = merge_ref(dir.path(), "feature", &mut warnings);
1470 assert!(result.is_some());
1471 assert!(warnings.is_empty());
1472 }
1473
1474 #[test]
1475 fn detect_mid_merge_none_on_clean_repo() {
1476 let dir = git_init();
1477 make_commit(dir.path(), "f.txt", "hi");
1478 assert!(detect_mid_merge_state(dir.path()).is_none());
1479 }
1480
1481 #[test]
1482 fn detect_mid_merge_on_merge_head() {
1483 let dir = git_init();
1484 make_commit(dir.path(), "f.txt", "hi");
1485 std::fs::write(dir.path().join(".git/MERGE_HEAD"), "abc").unwrap();
1486 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::Merge)));
1487 }
1488
1489 #[test]
1490 fn detect_mid_merge_on_rebase_merge() {
1491 let dir = git_init();
1492 make_commit(dir.path(), "f.txt", "hi");
1493 std::fs::create_dir(dir.path().join(".git/rebase-merge")).unwrap();
1494 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseMerge)));
1495 }
1496
1497 #[test]
1498 fn detect_mid_merge_on_rebase_apply() {
1499 let dir = git_init();
1500 make_commit(dir.path(), "f.txt", "hi");
1501 std::fs::create_dir(dir.path().join(".git/rebase-apply")).unwrap();
1502 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseApply)));
1503 }
1504
1505 #[test]
1506 fn detect_mid_merge_on_cherry_pick() {
1507 let dir = git_init();
1508 make_commit(dir.path(), "f.txt", "hi");
1509 std::fs::write(dir.path().join(".git/CHERRY_PICK_HEAD"), "abc").unwrap();
1510 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::CherryPick)));
1511 }
1512
1513 #[test]
1514 fn is_file_tracked_tracked_and_untracked() {
1515 let dir = git_init();
1516 make_commit(dir.path(), "tracked.txt", "hi");
1517 assert!(is_file_tracked(dir.path(), "tracked.txt"));
1518 std::fs::write(dir.path().join("untracked.txt"), "new").unwrap();
1519 assert!(!is_file_tracked(dir.path(), "untracked.txt"));
1520 }
1521
1522 #[test]
1523 fn check_leaked_files_detects_overlap() {
1524 let dir = git_init();
1525 let p = dir.path();
1526 std::fs::create_dir_all(p.join("src")).unwrap();
1527 std::fs::write(p.join("src/foo.rs"), "original").unwrap();
1528 git_cmd(p, &["add", "src/foo.rs"]);
1529 git_cmd(p, &["commit", "-m", "add foo"]);
1530
1531 git_cmd(p, &["checkout", "-b", "ticket/overlap-test"]);
1532 std::fs::write(p.join("src/foo.rs"), "ticket-change").unwrap();
1533 git_cmd(p, &["add", "src/foo.rs"]);
1534 git_cmd(p, &["commit", "-m", "ticket: change foo"]);
1535 git_cmd(p, &["checkout", "main"]);
1536
1537 std::fs::write(p.join("src/foo.rs"), "leaked").unwrap();
1539
1540 let leaked = check_leaked_files(p, "ticket/overlap-test", "main").unwrap();
1541 assert_eq!(leaked, vec!["src/foo.rs".to_string()]);
1542 }
1543
1544 #[test]
1545 fn check_leaked_files_no_overlap() {
1546 let dir = git_init();
1547 let p = dir.path();
1548 std::fs::create_dir_all(p.join("src")).unwrap();
1549 std::fs::write(p.join("src/foo.rs"), "original").unwrap();
1550 std::fs::write(p.join("src/bar.rs"), "bar").unwrap();
1551 git_cmd(p, &["add", "src/foo.rs", "src/bar.rs"]);
1552 git_cmd(p, &["commit", "-m", "add foo and bar"]);
1553
1554 git_cmd(p, &["checkout", "-b", "ticket/no-overlap"]);
1556 std::fs::write(p.join("src/foo.rs"), "ticket-change").unwrap();
1557 git_cmd(p, &["add", "src/foo.rs"]);
1558 git_cmd(p, &["commit", "-m", "ticket: change foo"]);
1559 git_cmd(p, &["checkout", "main"]);
1560
1561 std::fs::write(p.join("src/bar.rs"), "dirty").unwrap();
1563
1564 let leaked = check_leaked_files(p, "ticket/no-overlap", "main").unwrap();
1565 assert!(leaked.is_empty(), "no overlap expected; got {leaked:?}");
1566 }
1567
1568 #[test]
1569 fn check_leaked_files_detects_untracked_overlap() {
1570 let dir = git_init();
1571 let p = dir.path();
1572 make_commit(p, "existing.rs", "base");
1573
1574 git_cmd(p, &["checkout", "-b", "ticket/untracked-overlap"]);
1576 std::fs::create_dir_all(p.join("src")).unwrap();
1577 std::fs::write(p.join("src/new.rs"), "new file").unwrap();
1578 git_cmd(p, &["add", "src/new.rs"]);
1579 git_cmd(p, &["commit", "-m", "ticket: add new file"]);
1580 git_cmd(p, &["checkout", "main"]);
1581
1582 std::fs::create_dir_all(p.join("src")).unwrap();
1584 std::fs::write(p.join("src/new.rs"), "leaked untracked").unwrap();
1585
1586 let leaked = check_leaked_files(p, "ticket/untracked-overlap", "main").unwrap();
1587 assert_eq!(leaked, vec!["src/new.rs".to_string()]);
1588 }
1589
1590 #[test]
1597 fn merged_into_main_detects_local_regular_merge_when_remote_deleted() {
1598 let dir = git_init();
1599 let p = dir.path();
1600 make_commit(p, "f.txt", "base");
1601
1602 git_cmd(p, &["checkout", "-b", "ticket/foo"]);
1604 std::fs::write(p.join("f.txt"), "ticket-change").unwrap();
1605 git_cmd(p, &["add", "f.txt"]);
1606 git_cmd(p, &["commit", "-m", "ticket: change"]);
1607
1608 git_cmd(p, &["checkout", "main"]);
1610 git_cmd(p, &["merge", "--no-ff", "ticket/foo", "-m", "Merge ticket/foo"]);
1611
1612 let main_sha = run(p, &["rev-parse", "main"]).unwrap();
1615 Cmd::new("git")
1616 .args(["update-ref", "refs/remotes/origin/main", main_sha.trim()])
1617 .current_dir(p)
1618 .status()
1619 .unwrap();
1620 let merged = merged_into_main(p, "main").unwrap();
1623 assert!(
1624 merged.iter().any(|b| b == "ticket/foo"),
1625 "expected ticket/foo in merged set; got {merged:?}"
1626 );
1627 }
1628}