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 return Ok(merged);
127 }
128
129 let local_ref = format!("refs/heads/{default_branch}");
131 if run(root, &["rev-parse", "--verify", &local_ref]).is_err() {
132 return Ok(vec![]);
133 }
134 let regular_out = run(
135 root,
136 &["branch", "--merged", default_branch, "--list", "ticket/*"],
137 )
138 .unwrap_or_default();
139 let mut merged: Vec<String> = regular_out
140 .lines()
141 .map(|l| l.trim().trim_start_matches(['*', '+']).trim().to_string())
142 .filter(|l| !l.is_empty())
143 .collect();
144 let merged_set: std::collections::HashSet<&str> = merged.iter().map(|s| s.as_str()).collect();
145
146 let all_local = run(root, &["branch", "--list", "ticket/*"]).unwrap_or_default();
147 let candidates: Vec<String> = all_local
148 .lines()
149 .map(|l| l.trim().trim_start_matches(['*', '+']).trim().to_string())
150 .filter(|l| !l.is_empty() && !merged_set.contains(l.as_str()))
151 .collect();
152 merged.extend(squash_merged(root, default_branch, candidates)?);
153 Ok(merged)
154}
155
156fn squash_merged(root: &Path, main_ref: &str, candidates: Vec<String>) -> Result<Vec<String>> {
163 let mut result = Vec::new();
164 for branch in candidates {
165 let merge_base = match run(root, &["merge-base", main_ref, &branch]) {
166 Ok(mb) => mb,
167 Err(_) => continue,
168 };
169 let branch_tip = match run(root, &["rev-parse", &format!("{branch}^{{commit}}")]) {
170 Ok(t) => t,
171 Err(_) => continue,
172 };
173 if branch_tip == merge_base {
175 continue;
176 }
177 let squash_commit = match run(root, &[
179 "commit-tree", &format!("{branch}^{{tree}}"),
180 "-p", &merge_base,
181 "-m", "squash",
182 ]) {
183 Ok(c) => c,
184 Err(_) => continue,
185 };
186 let cherry_out = match run(root, &["cherry", main_ref, &squash_commit]) {
188 Ok(o) => o,
189 Err(_) => continue,
190 };
191 if cherry_out.trim().starts_with('-') {
192 result.push(branch);
193 }
194 }
195 Ok(result)
196}
197
198pub fn commit_to_branch(
204 root: &Path,
205 branch: &str,
206 rel_path: &str,
207 content: &str,
208 message: &str,
209) -> Result<()> {
210 if !has_commits(root) {
212 let local_path = root.join(rel_path);
213 if let Some(parent) = local_path.parent() {
214 std::fs::create_dir_all(parent)?;
215 }
216 std::fs::write(&local_path, content)?;
217 return Ok(());
218 }
219
220 if let Some(wt_path) = find_worktree_for_branch(root, branch) {
222 let remote_ref = format!("origin/{branch}");
224 if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
225 let _ = run(&wt_path, &["merge", "--ff-only", &remote_ref]);
226 }
227 let full_path = wt_path.join(rel_path);
228 if let Some(parent) = full_path.parent() {
229 std::fs::create_dir_all(parent)?;
230 }
231 std::fs::write(&full_path, content)?;
232 let _ = run(&wt_path, &["add", rel_path]);
233 let _ = run(&wt_path, &["commit", "-m", message]);
234 crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
235 return Ok(());
236 }
237
238 if current_branch(root).ok().as_deref() == Some(branch) {
240 let local_path = root.join(rel_path);
241 if let Some(parent) = local_path.parent() {
242 std::fs::create_dir_all(parent)?;
243 }
244 std::fs::write(&local_path, content)?;
245 let _ = run(root, &["add", rel_path]);
246 let _ = run(root, &["commit", "-m", message]);
247 crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
248 return Ok(());
249 }
250
251 let result = try_worktree_commit(root, branch, rel_path, content, message);
252 if result.is_ok() {
253 crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
254 }
255 result
256}
257
258fn try_worktree_commit(
259 root: &Path,
260 branch: &str,
261 rel_path: &str,
262 content: &str,
263 message: &str,
264) -> Result<()> {
265 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
266 let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
267 let wt_path = std::env::temp_dir().join(format!(
268 "apm-{}-{}-{}",
269 std::process::id(),
270 seq,
271 branch.replace('/', "-"),
272 ));
273
274 let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
275 let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
276
277 if has_remote {
278 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
279 let _ = run(&wt_path, &["checkout", "-B", branch]);
280 } else if has_local {
281 let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
283 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
284 let _ = run(&wt_path, &["checkout", "-B", branch]);
285 } else {
286 run(root, &["worktree", "add", "-b", branch, &wt_path.to_string_lossy(), "HEAD"])?;
287 }
288
289 let result = (|| -> Result<()> {
290 let full_path = wt_path.join(rel_path);
291 if let Some(parent) = full_path.parent() {
292 std::fs::create_dir_all(parent)?;
293 }
294 std::fs::write(&full_path, content)?;
295 run(&wt_path, &["add", rel_path])?;
296 run(&wt_path, &["commit", "-m", message])?;
297 Ok(())
298 })();
299
300 let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
301 let _ = std::fs::remove_dir_all(&wt_path);
302
303 result
304}
305
306
307pub fn push_ticket_branches(root: &Path, warnings: &mut Vec<String>) {
310 if run(root, &["remote", "get-url", "origin"]).is_err() {
311 return;
312 }
313 let out = match run(root, &["branch", "--list", "ticket/*"]) {
314 Ok(o) => o,
315 Err(_) => return,
316 };
317 for branch in out.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
318 let range = format!("origin/{branch}..{branch}");
319 let count = run(root, &["rev-list", "--count", &range])
320 .ok()
321 .and_then(|s| s.trim().parse::<u32>().ok())
322 .unwrap_or(0);
323 if count > 0 {
324 if let Err(e) = run(root, &["push", "origin", branch]) {
325 warnings.push(format!("warning: push {branch} failed: {e:#}"));
326 }
327 }
328 }
329}
330
331pub fn sync_non_checked_out_refs(root: &Path, warnings: &mut Vec<String>) -> Vec<String> {
359 let checked_out: std::collections::HashSet<String> = {
362 let mut set = std::collections::HashSet::new();
363 if let Ok(out) = run(root, &["worktree", "list", "--porcelain"]) {
364 for line in out.lines() {
365 if let Some(b) = line.strip_prefix("branch refs/heads/") {
366 set.insert(b.to_string());
367 }
368 }
369 }
370 set
371 };
372
373 const MANAGED_NAMESPACES: &[&str] = &["ticket", "epic"];
376
377 let mut remote_refs: Vec<String> = Vec::new();
379 for ns in MANAGED_NAMESPACES {
380 let pattern = format!("refs/remotes/origin/{ns}/");
381 if let Ok(out) = run(root, &["for-each-ref", "--format=%(refname:short)", &pattern]) {
382 for line in out.lines().filter(|l| !l.is_empty()) {
383 remote_refs.push(line.to_string());
384 }
385 }
386 }
387
388 let mut ahead_branches: Vec<String> = Vec::new();
389
390 for remote_name in remote_refs {
391 let branch = match remote_name.strip_prefix("origin/") {
394 Some(b) => b.to_string(),
395 None => continue,
396 };
397
398 if checked_out.contains(&branch) {
400 continue;
401 }
402
403 let local_ref = format!("refs/heads/{branch}");
404 let remote_ref_full = format!("refs/remotes/{remote_name}");
406
407 match classify_branch(root, &local_ref, &remote_name) {
410 BranchClass::RemoteOnly => {
411 let sha = match run(root, &["rev-parse", &remote_ref_full]) {
414 Ok(s) => s,
415 Err(_) => continue,
416 };
417 if let Err(e) = run(root, &["update-ref", &local_ref, &sha]) {
418 warnings.push(format!("warning: could not create local ref {branch}: {e:#}"));
419 }
420 }
421 BranchClass::Equal => {
422 }
424 BranchClass::Behind => {
425 let sha = match run(root, &["rev-parse", &remote_ref_full]) {
428 Ok(s) => s,
429 Err(_) => continue,
430 };
431 if let Err(e) = run(root, &["update-ref", &local_ref, &sha]) {
432 warnings.push(format!("warning: could not fast-forward {branch}: {e:#}"));
433 }
434 }
435 BranchClass::Ahead => {
436 warnings.push(crate::sync_guidance::TICKET_OR_EPIC_AHEAD.replace("<slug>", &branch));
442 ahead_branches.push(branch);
443 }
444 BranchClass::Diverged => {
445 let msg = crate::sync_guidance::TICKET_OR_EPIC_DIVERGED
448 .replace("<slug>", &branch);
449 warnings.push(msg);
450 }
451 BranchClass::NoRemote => {
452 }
455 }
456 }
457
458 ahead_branches
459}
460
461pub fn list_files_on_branch(root: &Path, branch: &str, dir: &str) -> Result<Vec<String>> {
463 let tree_ref = format!("{branch}:{dir}");
464 let out = run(root, &["ls-tree", "--name-only", &tree_ref])
465 .or_else(|_| run(root, &["ls-tree", "--name-only", &format!("origin/{branch}:{dir}")]))?;
466 Ok(out.lines()
467 .filter(|l| !l.is_empty())
468 .map(|l| format!("{dir}/{l}"))
469 .collect())
470}
471
472pub fn commit_files_to_branch(
474 root: &Path,
475 branch: &str,
476 files: &[(&str, String)],
477 message: &str,
478) -> Result<()> {
479 if !has_commits(root) {
480 for (rel_path, content) in files {
481 let local_path = root.join(rel_path);
482 if let Some(parent) = local_path.parent() {
483 std::fs::create_dir_all(parent)?;
484 }
485 std::fs::write(&local_path, content)?;
486 }
487 return Ok(());
488 }
489
490 if let Some(wt_path) = find_worktree_for_branch(root, branch) {
491 for (rel_path, content) in files {
492 let full_path = wt_path.join(rel_path);
493 if let Some(parent) = full_path.parent() {
494 std::fs::create_dir_all(parent)?;
495 }
496 std::fs::write(&full_path, content)?;
497 let _ = run(&wt_path, &["add", rel_path]);
498 }
499 run(&wt_path, &["commit", "-m", message])?;
500 crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
501 return Ok(());
502 }
503
504 if current_branch(root).ok().as_deref() == Some(branch) {
505 for (rel_path, content) in files {
506 let local_path = root.join(rel_path);
507 if let Some(parent) = local_path.parent() {
508 std::fs::create_dir_all(parent)?;
509 }
510 std::fs::write(&local_path, content)?;
511 let _ = run(root, &["add", rel_path]);
512 }
513 run(root, &["commit", "-m", message])?;
514 crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
515 return Ok(());
516 }
517
518 let unique = std::time::SystemTime::now()
519 .duration_since(std::time::UNIX_EPOCH)
520 .map(|d| d.subsec_nanos())
521 .unwrap_or(0);
522 let wt_path = std::env::temp_dir().join(format!(
523 "apm-{}-{}-{}",
524 std::process::id(),
525 unique,
526 branch.replace('/', "-"),
527 ));
528
529 let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
530 let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
531
532 if has_remote {
533 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
534 let _ = run(&wt_path, &["checkout", "-B", branch]);
535 } else if has_local {
536 let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
537 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
538 let _ = run(&wt_path, &["checkout", "-B", branch]);
539 } else {
540 run(root, &["worktree", "add", &wt_path.to_string_lossy(), branch])?;
541 }
542
543 let result = (|| -> Result<()> {
544 for (rel_path, content) in files {
545 let full_path = wt_path.join(rel_path);
546 if let Some(parent) = full_path.parent() {
547 std::fs::create_dir_all(parent)?;
548 }
549 std::fs::write(&full_path, content)?;
550 run(&wt_path, &["add", rel_path])?;
551 }
552 run(&wt_path, &["commit", "-m", message])?;
553 Ok(())
554 })();
555
556 let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
557 let _ = std::fs::remove_dir_all(&wt_path);
558
559 if result.is_ok() {
560 crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
561 }
562 result
563}
564
565pub fn branch_tip(root: &Path, branch: &str) -> Option<String> {
567 run(root, &["rev-parse", &format!("refs/heads/{branch}")]).ok()
568}
569
570pub fn resolve_branch_sha(root: &Path, branch: &str) -> Result<String> {
573 run(root, &["rev-parse", &format!("origin/{branch}")])
574 .or_else(|_| run(root, &["rev-parse", branch]))
575 .with_context(|| format!("branch '{branch}' not found locally or on origin"))
576}
577
578pub fn create_branch_at(root: &Path, branch: &str, sha: &str) -> Result<()> {
580 run(root, &["branch", branch, sha]).map(|_| ())
581}
582
583pub fn remote_branch_tip(root: &Path, branch: &str) -> Option<String> {
585 run(root, &["rev-parse", &format!("refs/remotes/origin/{branch}")]).ok()
586}
587
588pub fn is_ancestor(root: &Path, commit: &str, of_ref: &str) -> bool {
591 Command::new("git")
592 .current_dir(root)
593 .args(["merge-base", "--is-ancestor", commit, of_ref])
594 .status()
595 .map(|s| s.success())
596 .unwrap_or(false)
597}
598
599pub enum BranchClass {
609 Equal,
610 Behind,
611 Ahead,
612 Diverged,
613 RemoteOnly,
615 NoRemote,
617}
618
619pub fn classify_branch(root: &Path, local: &str, remote: &str) -> BranchClass {
627 let local_sha = match run(root, &["rev-parse", local]) {
628 Ok(s) => s,
629 Err(_) => {
630 return if run(root, &["rev-parse", remote]).is_ok() {
634 BranchClass::RemoteOnly
635 } else {
636 BranchClass::NoRemote
637 };
638 }
639 };
640 let remote_sha = match run(root, &["rev-parse", remote]) {
641 Ok(s) => s,
642 Err(_) => return BranchClass::NoRemote,
643 };
644
645 if local_sha == remote_sha {
646 return BranchClass::Equal;
647 }
648
649 let local_is_ancestor_of_remote = is_ancestor(root, local, remote);
652
653 let remote_is_ancestor_of_local = is_ancestor(root, remote, local);
656
657 match (local_is_ancestor_of_remote, remote_is_ancestor_of_local) {
658 (true, false) => BranchClass::Behind, (false, true) => BranchClass::Ahead, (false, false) => BranchClass::Diverged, (true, true) => BranchClass::Equal, }
663}
664
665pub fn sync_default_branch(root: &Path, default: &str, warnings: &mut Vec<String>) -> bool {
690 let remote = format!("origin/{default}");
691 match classify_branch(root, default, &remote) {
692 BranchClass::Equal => {
693 }
695
696 BranchClass::Behind => {
697 let wt = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
700 if run(&wt, &["merge", "--ff-only", &remote]).is_err() {
701 let msg = crate::sync_guidance::MAIN_BEHIND_DIRTY_OVERLAP
705 .replace("<default>", default);
706 warnings.push(msg);
707 }
708 }
709
710 BranchClass::Ahead => {
711 let count = run(root, &["rev-list", "--count", &format!("{remote}..{default}")])
714 .ok()
715 .and_then(|s| s.trim().parse::<u64>().ok())
716 .unwrap_or(0);
717 let msg = crate::sync_guidance::MAIN_AHEAD
718 .replace("<default>", default)
719 .replace("<remote>", &remote)
720 .replace("<count>", &count.to_string())
721 .replace("<commits>", if count == 1 { "commit" } else { "commits" });
722 warnings.push(msg);
723 return true;
724 }
725
726 BranchClass::Diverged => {
727 let wt = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
730 let guidance = if is_worktree_dirty(&wt) {
731 crate::sync_guidance::MAIN_DIVERGED_DIRTY.replace("<default>", default)
732 } else {
733 crate::sync_guidance::MAIN_DIVERGED_CLEAN.replace("<default>", default)
734 };
735 warnings.push(guidance);
736 }
737
738 BranchClass::RemoteOnly => {
739 }
743
744 BranchClass::NoRemote => {
745 }
749 }
750 false
751}
752
753pub fn fetch_branch(root: &Path, branch: &str) -> anyhow::Result<()> {
754 let status = std::process::Command::new("git")
755 .args(["fetch", "origin", branch])
756 .current_dir(root)
757 .status()?;
758 if !status.success() {
759 anyhow::bail!("git fetch failed");
760 }
761 Ok(())
762}
763
764pub fn push_branch(root: &Path, branch: &str) -> anyhow::Result<()> {
765 let status = std::process::Command::new("git")
766 .args(["push", "origin", &format!("{branch}:{branch}")])
767 .current_dir(root)
768 .status()?;
769 if !status.success() {
770 anyhow::bail!("git push failed");
771 }
772 Ok(())
773}
774
775pub fn push_branch_tracking(root: &Path, branch: &str) -> anyhow::Result<()> {
776 let out = std::process::Command::new("git")
777 .args(["push", "--set-upstream", "origin", &format!("{branch}:{branch}")])
778 .current_dir(root)
779 .output()?;
780 if !out.status.success() {
781 anyhow::bail!("git push failed: {}", String::from_utf8_lossy(&out.stderr).trim());
782 }
783 Ok(())
784}
785
786pub fn has_remote(root: &Path) -> bool {
787 run(root, &["remote", "get-url", "origin"]).is_ok()
788}
789
790pub fn remote_ticket_branches_with_dates(
795 root: &Path,
796) -> Result<Vec<(String, chrono::DateTime<chrono::Utc>)>> {
797 use chrono::{TimeZone, Utc};
798 let out = Command::new("git")
799 .current_dir(root)
800 .args([
801 "for-each-ref",
802 "refs/remotes/origin/ticket/",
803 "--format=%(refname:short) %(creatordate:unix)",
804 ])
805 .output()
806 .context("git for-each-ref failed")?;
807 let stdout = String::from_utf8_lossy(&out.stdout);
808 let mut result = Vec::new();
809 for line in stdout.lines() {
810 let mut parts = line.splitn(2, ' ');
811 let refname = parts.next().unwrap_or("").trim();
812 let ts_str = parts.next().unwrap_or("").trim();
813 let branch = refname.trim_start_matches("origin/");
814 if branch.is_empty() {
815 continue;
816 }
817 if let Ok(ts) = ts_str.parse::<i64>() {
818 if let Some(dt) = Utc.timestamp_opt(ts, 0).single() {
819 result.push((branch.to_string(), dt));
820 }
821 }
822 }
823 Ok(result)
824}
825
826pub fn delete_remote_branch(root: &Path, branch: &str) -> Result<()> {
828 let status = Command::new("git")
829 .current_dir(root)
830 .args(["push", "origin", "--delete", branch])
831 .status()
832 .context("git push origin --delete failed")?;
833 if !status.success() {
834 anyhow::bail!("git push origin --delete {branch} failed");
835 }
836 Ok(())
837}
838
839pub fn move_files_on_branch(
844 root: &Path,
845 branch: &str,
846 moves: &[(&str, &str, &str)],
847 message: &str,
848) -> Result<()> {
849 if !has_commits(root) {
850 for (old, new, content) in moves {
851 let new_path = root.join(new);
852 if let Some(parent) = new_path.parent() {
853 std::fs::create_dir_all(parent)?;
854 }
855 std::fs::write(&new_path, content)?;
856 let old_path = root.join(old);
857 let _ = std::fs::remove_file(&old_path);
858 }
859 return Ok(());
860 }
861
862 let do_moves = |wt: &Path| -> Result<()> {
863 for (old, new, content) in moves {
864 let new_path = wt.join(new);
865 if let Some(parent) = new_path.parent() {
866 std::fs::create_dir_all(parent)?;
867 }
868 std::fs::write(&new_path, content)?;
869 run(wt, &["add", new])?;
870 run(wt, &["rm", "--force", "--quiet", old])?;
871 }
872 run(wt, &["commit", "-m", message])?;
873 Ok(())
874 };
875
876 if let Some(wt_path) = find_worktree_for_branch(root, branch) {
877 let remote_ref = format!("origin/{branch}");
878 if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
879 let _ = run(&wt_path, &["merge", "--ff-only", &remote_ref]);
880 }
881 let result = do_moves(&wt_path);
882 if result.is_ok() {
883 crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
884 }
885 return result;
886 }
887
888 if current_branch(root).ok().as_deref() == Some(branch) {
889 let result = do_moves(root);
890 if result.is_ok() {
891 crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
892 }
893 return result;
894 }
895
896 let unique = std::time::SystemTime::now()
897 .duration_since(std::time::UNIX_EPOCH)
898 .map(|d| d.subsec_nanos())
899 .unwrap_or(0);
900 let wt_path = std::env::temp_dir().join(format!(
901 "apm-{}-{}-{}",
902 std::process::id(),
903 unique,
904 branch.replace('/', "-"),
905 ));
906
907 let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
908 let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
909
910 if has_remote {
911 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
912 let _ = run(&wt_path, &["checkout", "-B", branch]);
913 } else if has_local {
914 let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
915 run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
916 let _ = run(&wt_path, &["checkout", "-B", branch]);
917 } else {
918 run(root, &["worktree", "add", &wt_path.to_string_lossy(), branch])?;
919 }
920
921 let result = do_moves(&wt_path);
922 let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
923 let _ = std::fs::remove_dir_all(&wt_path);
924 if result.is_ok() {
925 crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
926 }
927 result
928}
929
930pub fn merge_branch_into_default(root: &Path, branch: &str, default_branch: &str, warnings: &mut Vec<String>) -> Result<()> {
931 let _ = run(root, &["fetch", "origin", default_branch]);
932
933 let merge_dir = if current_branch(root).ok().as_deref() == Some(default_branch) {
934 root.to_path_buf()
935 } else {
936 find_worktree_for_branch(root, default_branch).unwrap_or_else(|| root.to_path_buf())
937 };
938
939 if let Err(e) = run(&merge_dir, &["merge", "--no-ff", branch, "--no-edit"]) {
940 let _ = run(&merge_dir, &["merge", "--abort"]);
941 anyhow::bail!("merge failed: {e:#}");
942 }
943
944 if has_remote(root) {
945 if let Err(e) = run(&merge_dir, &["push", "origin", default_branch]) {
946 warnings.push(format!("warning: push {default_branch} failed: {e:#}"));
947 }
948 }
949 Ok(())
950}
951
952pub 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<()> {
953 let _ = std::process::Command::new("git")
954 .args(["fetch", "origin", default_branch])
955 .current_dir(root)
956 .status();
957
958 let current = std::process::Command::new("git")
959 .args(["rev-parse", "--abbrev-ref", "HEAD"])
960 .current_dir(root)
961 .output()?;
962 let current_branch = String::from_utf8_lossy(¤t.stdout).trim().to_string();
963
964 let merge_dir = if current_branch == default_branch {
965 root.to_path_buf()
966 } else {
967 let worktrees_base = root.join(&config.worktrees.dir);
968 ensure_worktree(root, &worktrees_base, default_branch)?
969 };
970
971 let out = std::process::Command::new("git")
972 .args(["merge", "--no-ff", branch, "--no-edit"])
973 .current_dir(&merge_dir)
974 .output()?;
975
976 if !out.status.success() {
977 let _ = std::process::Command::new("git")
978 .args(["merge", "--abort"])
979 .current_dir(&merge_dir)
980 .status();
981 bail!(
982 "merge conflict — resolve manually and push: {}",
983 String::from_utf8_lossy(&out.stderr).trim()
984 );
985 }
986
987 if skip_push {
988 messages.push(format!("Merged {branch} into {default_branch} (local only)."));
989 } else {
990 push_branch(&merge_dir, default_branch)?;
991 messages.push(format!("Merged {branch} into {default_branch} and pushed to origin."));
992 }
993 Ok(())
994}
995
996pub fn pull_default(root: &Path, default_branch: &str, warnings: &mut Vec<String>) -> Result<()> {
997 let fetch = std::process::Command::new("git")
998 .args(["fetch", "origin", default_branch])
999 .current_dir(root)
1000 .output();
1001
1002 match fetch {
1003 Err(e) => {
1004 warnings.push(format!("warning: fetch failed: {e:#}"));
1005 return Ok(());
1006 }
1007 Ok(out) if !out.status.success() => {
1008 warnings.push(format!(
1009 "warning: fetch failed: {}",
1010 String::from_utf8_lossy(&out.stderr).trim()
1011 ));
1012 return Ok(());
1013 }
1014 _ => {}
1015 }
1016
1017 let current = std::process::Command::new("git")
1018 .args(["rev-parse", "--abbrev-ref", "HEAD"])
1019 .current_dir(root)
1020 .output()?;
1021 let current_branch = String::from_utf8_lossy(¤t.stdout).trim().to_string();
1022
1023 let merge_dir = if current_branch == default_branch {
1024 root.to_path_buf()
1025 } else {
1026 find_worktree_for_branch(root, default_branch)
1027 .unwrap_or_else(|| root.to_path_buf())
1028 };
1029
1030 let remote_ref = format!("origin/{default_branch}");
1031 let out = std::process::Command::new("git")
1032 .args(["merge", "--ff-only", &remote_ref])
1033 .current_dir(&merge_dir)
1034 .output()?;
1035
1036 if !out.status.success() {
1037 warnings.push(format!("warning: could not fast-forward {default_branch} — pull manually"));
1038 }
1039
1040 Ok(())
1041}
1042
1043pub fn is_worktree_dirty(path: &Path) -> bool {
1044 let Ok(out) = Command::new("git")
1045 .args(["-C", &path.to_string_lossy(), "status", "--porcelain"])
1046 .output()
1047 else {
1048 return false;
1049 };
1050 !out.stdout.is_empty()
1051}
1052
1053pub fn local_branch_exists(root: &Path, branch: &str) -> bool {
1054 Command::new("git")
1055 .args(["-C", &root.to_string_lossy(), "rev-parse", "--verify", &format!("refs/heads/{branch}")])
1056 .output()
1057 .map(|o| o.status.success())
1058 .unwrap_or(false)
1059}
1060
1061pub fn delete_local_branch(root: &Path, branch: &str, warnings: &mut Vec<String>) {
1062 let Ok(out) = Command::new("git")
1063 .args(["-C", &root.to_string_lossy(), "branch", "-D", branch])
1064 .output()
1065 else {
1066 warnings.push(format!("warning: could not delete branch {branch}: command failed"));
1067 return;
1068 };
1069 if !out.status.success() {
1070 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1071 warnings.push(format!("warning: could not delete branch {branch}: {stderr}"));
1072 }
1073}
1074
1075pub fn prune_remote_tracking(root: &Path, branch: &str) {
1076 let _ = Command::new("git")
1077 .args(["-C", &root.to_string_lossy(), "branch", "-dr", &format!("origin/{branch}")])
1078 .output();
1079}
1080
1081pub fn stage_files(root: &Path, files: &[&str]) -> Result<()> {
1082 let mut args = vec!["add"];
1083 args.extend_from_slice(files);
1084 run(root, &args).map(|_| ())
1085}
1086
1087pub fn commit(root: &Path, message: &str) -> Result<()> {
1088 run(root, &["commit", "-m", message]).map(|_| ())
1089}
1090
1091pub fn git_config_get(root: &Path, key: &str) -> Option<String> {
1092 let out = Command::new("git")
1093 .args(["-C", &root.to_string_lossy(), "config", key])
1094 .output()
1095 .ok()?;
1096 if !out.status.success() {
1097 return None;
1098 }
1099 let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
1100 if value.is_empty() { None } else { Some(value) }
1101}
1102
1103pub fn merge_ref(dir: &Path, refname: &str, warnings: &mut Vec<String>) -> Option<String> {
1104 let out = match Command::new("git")
1105 .args(["-C", &dir.to_string_lossy(), "merge", refname, "--no-edit"])
1106 .output()
1107 {
1108 Ok(o) => o,
1109 Err(e) => {
1110 warnings.push(format!("warning: merge {refname} failed: {e}"));
1111 return None;
1112 }
1113 };
1114 if out.status.success() {
1115 let stdout = String::from_utf8_lossy(&out.stdout);
1116 if stdout.contains("Already up to date") {
1117 None
1118 } else {
1119 Some(format!("Merged {refname} into branch."))
1120 }
1121 } else {
1122 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1123 warnings.push(format!("warning: merge {refname} failed: {stderr}"));
1124 None
1125 }
1126}
1127
1128pub fn is_file_tracked(root: &Path, path: &str) -> bool {
1129 Command::new("git")
1130 .args(["ls-files", "--error-unmatch", path])
1131 .current_dir(root)
1132 .stdout(std::process::Stdio::null())
1133 .stderr(std::process::Stdio::null())
1134 .status()
1135 .map(|s| s.success())
1136 .unwrap_or(false)
1137}
1138
1139pub enum MidMergeState {
1143 Merge,
1145 RebaseMerge,
1147 RebaseApply,
1149 CherryPick,
1151}
1152
1153pub fn detect_mid_merge_state(root: &Path) -> Option<MidMergeState> {
1162 let git_dir = root.join(".git");
1163 if git_dir.join("MERGE_HEAD").exists() {
1164 return Some(MidMergeState::Merge);
1165 }
1166 if git_dir.join("rebase-merge").is_dir() {
1167 return Some(MidMergeState::RebaseMerge);
1168 }
1169 if git_dir.join("rebase-apply").is_dir() {
1170 return Some(MidMergeState::RebaseApply);
1171 }
1172 if git_dir.join("CHERRY_PICK_HEAD").exists() {
1173 return Some(MidMergeState::CherryPick);
1174 }
1175 None
1176}
1177
1178pub fn merge_base(root: &Path, ref1: &str, ref2: &str) -> Result<String> {
1180 run(root, &["merge-base", ref1, ref2])
1181}
1182
1183pub fn main_worktree_root(root: &Path) -> Option<PathBuf> {
1184 let out = run(root, &["worktree", "list", "--porcelain"]).ok()?;
1185 out.lines()
1186 .next()
1187 .and_then(|line| line.strip_prefix("worktree "))
1188 .map(PathBuf::from)
1189}
1190
1191#[cfg(test)]
1192mod tests {
1193 use super::*;
1194 use std::process::Command as Cmd;
1195 use tempfile::TempDir;
1196
1197 fn git_init() -> TempDir {
1198 let dir = tempfile::tempdir().unwrap();
1199 let p = dir.path();
1200 Cmd::new("git").args(["init", "-q", "-b", "main"]).current_dir(p).status().unwrap();
1201 Cmd::new("git").args(["config", "user.email", "t@t.com"]).current_dir(p).status().unwrap();
1202 Cmd::new("git").args(["config", "user.name", "test"]).current_dir(p).status().unwrap();
1203 dir
1204 }
1205
1206 fn git_cmd(dir: &Path, args: &[&str]) {
1207 Cmd::new("git")
1208 .args(args)
1209 .current_dir(dir)
1210 .env("GIT_AUTHOR_NAME", "test")
1211 .env("GIT_AUTHOR_EMAIL", "t@t.com")
1212 .env("GIT_COMMITTER_NAME", "test")
1213 .env("GIT_COMMITTER_EMAIL", "t@t.com")
1214 .status()
1215 .unwrap();
1216 }
1217
1218 fn make_commit(dir: &Path, filename: &str, content: &str) {
1219 std::fs::write(dir.join(filename), content).unwrap();
1220 git_cmd(dir, &["add", filename]);
1221 git_cmd(dir, &["commit", "-m", "init"]);
1222 }
1223
1224 #[test]
1225 fn is_worktree_dirty_clean() {
1226 let dir = git_init();
1227 make_commit(dir.path(), "f.txt", "hi");
1228 assert!(!is_worktree_dirty(dir.path()));
1229 }
1230
1231 #[test]
1232 fn is_worktree_dirty_dirty() {
1233 let dir = git_init();
1234 make_commit(dir.path(), "f.txt", "hi");
1235 std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1236 assert!(is_worktree_dirty(dir.path()));
1237 }
1238
1239 #[test]
1240 fn local_branch_exists_present_and_absent() {
1241 let dir = git_init();
1242 make_commit(dir.path(), "f.txt", "hi");
1243 let on_main = local_branch_exists(dir.path(), "main");
1244 let on_master = local_branch_exists(dir.path(), "master");
1245 assert!(on_main || on_master);
1246 assert!(!local_branch_exists(dir.path(), "no-such-branch"));
1247 }
1248
1249 #[test]
1250 fn delete_local_branch_success() {
1251 let dir = git_init();
1252 make_commit(dir.path(), "f.txt", "hi");
1253 git_cmd(dir.path(), &["branch", "to-delete"]);
1254 let mut warnings = Vec::new();
1255 delete_local_branch(dir.path(), "to-delete", &mut warnings);
1256 assert!(warnings.is_empty());
1257 assert!(!local_branch_exists(dir.path(), "to-delete"));
1258 }
1259
1260 #[test]
1261 fn delete_local_branch_failure_adds_warning() {
1262 let dir = git_init();
1263 make_commit(dir.path(), "f.txt", "hi");
1264 let mut warnings = Vec::new();
1265 delete_local_branch(dir.path(), "nonexistent", &mut warnings);
1266 assert!(!warnings.is_empty());
1267 assert!(warnings[0].contains("warning:"));
1268 }
1269
1270 #[test]
1271 fn prune_remote_tracking_no_panic() {
1272 let dir = git_init();
1273 make_commit(dir.path(), "f.txt", "hi");
1274 prune_remote_tracking(dir.path(), "nonexistent-branch");
1276 }
1277
1278 #[test]
1279 fn stage_files_ok_and_err() {
1280 let dir = git_init();
1281 make_commit(dir.path(), "f.txt", "hi");
1282 std::fs::write(dir.path().join("new.txt"), "new").unwrap();
1283 assert!(stage_files(dir.path(), &["new.txt"]).is_ok());
1284 assert!(stage_files(dir.path(), &["missing.txt"]).is_err());
1285 }
1286
1287 #[test]
1288 fn commit_ok_and_err() {
1289 let dir = git_init();
1290 make_commit(dir.path(), "f.txt", "hi");
1291 std::fs::write(dir.path().join("new.txt"), "new").unwrap();
1292 git_cmd(dir.path(), &["add", "new.txt"]);
1293 assert!(commit(dir.path(), "test commit").is_ok());
1294 assert!(commit(dir.path(), "empty commit").is_err());
1296 }
1297
1298 #[test]
1299 fn git_config_get_some_and_none() {
1300 let dir = git_init();
1301 make_commit(dir.path(), "f.txt", "hi");
1302 let val = git_config_get(dir.path(), "user.email");
1303 assert_eq!(val, Some("t@t.com".to_string()));
1304 let missing = git_config_get(dir.path(), "no.such.key");
1305 assert!(missing.is_none());
1306 }
1307
1308 #[test]
1309 fn merge_ref_already_up_to_date() {
1310 let dir = git_init();
1311 make_commit(dir.path(), "f.txt", "hi");
1312 let branch = {
1313 let out = Cmd::new("git").args(["branch", "--show-current"]).current_dir(dir.path()).output().unwrap();
1314 String::from_utf8_lossy(&out.stdout).trim().to_string()
1315 };
1316 let mut warnings = Vec::new();
1317 let result = merge_ref(dir.path(), &branch, &mut warnings);
1319 assert!(result.is_none());
1320 assert!(warnings.is_empty());
1321 }
1322
1323 #[test]
1324 fn merge_ref_success() {
1325 let dir = git_init();
1326 make_commit(dir.path(), "f.txt", "hi");
1327 git_cmd(dir.path(), &["checkout", "-b", "feature"]);
1328 make_commit(dir.path(), "g.txt", "there");
1329 git_cmd(dir.path(), &["checkout", "main"]);
1330 let mut warnings = Vec::new();
1331 let result = merge_ref(dir.path(), "feature", &mut warnings);
1332 assert!(result.is_some());
1333 assert!(warnings.is_empty());
1334 }
1335
1336 #[test]
1337 fn detect_mid_merge_none_on_clean_repo() {
1338 let dir = git_init();
1339 make_commit(dir.path(), "f.txt", "hi");
1340 assert!(detect_mid_merge_state(dir.path()).is_none());
1341 }
1342
1343 #[test]
1344 fn detect_mid_merge_on_merge_head() {
1345 let dir = git_init();
1346 make_commit(dir.path(), "f.txt", "hi");
1347 std::fs::write(dir.path().join(".git/MERGE_HEAD"), "abc").unwrap();
1348 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::Merge)));
1349 }
1350
1351 #[test]
1352 fn detect_mid_merge_on_rebase_merge() {
1353 let dir = git_init();
1354 make_commit(dir.path(), "f.txt", "hi");
1355 std::fs::create_dir(dir.path().join(".git/rebase-merge")).unwrap();
1356 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseMerge)));
1357 }
1358
1359 #[test]
1360 fn detect_mid_merge_on_rebase_apply() {
1361 let dir = git_init();
1362 make_commit(dir.path(), "f.txt", "hi");
1363 std::fs::create_dir(dir.path().join(".git/rebase-apply")).unwrap();
1364 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseApply)));
1365 }
1366
1367 #[test]
1368 fn detect_mid_merge_on_cherry_pick() {
1369 let dir = git_init();
1370 make_commit(dir.path(), "f.txt", "hi");
1371 std::fs::write(dir.path().join(".git/CHERRY_PICK_HEAD"), "abc").unwrap();
1372 assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::CherryPick)));
1373 }
1374
1375 #[test]
1376 fn is_file_tracked_tracked_and_untracked() {
1377 let dir = git_init();
1378 make_commit(dir.path(), "tracked.txt", "hi");
1379 assert!(is_file_tracked(dir.path(), "tracked.txt"));
1380 std::fs::write(dir.path().join("untracked.txt"), "new").unwrap();
1381 assert!(!is_file_tracked(dir.path(), "untracked.txt"));
1382 }
1383}