1use std::io::Write;
2use std::process::{Command, Stdio};
3use std::sync::atomic::{AtomicBool, Ordering};
4
5use anyhow::{Context, Result, anyhow, bail};
6
7static VERBOSE: AtomicBool = AtomicBool::new(false);
8
9pub fn set_verbose(verbose: bool) {
11 VERBOSE.store(verbose, Ordering::Relaxed);
12}
13
14pub fn verbose() -> bool {
17 VERBOSE.load(Ordering::Relaxed)
18}
19
20pub fn current_branch() -> Result<String> {
21 output(&["symbolic-ref", "--quiet", "--short", "HEAD"])
22 .context("failed to determine current branch")
23}
24
25pub fn is_in_repo() -> bool {
29 Command::new("git")
30 .args(["rev-parse", "--is-inside-work-tree"])
31 .stdout(Stdio::piped())
32 .stderr(Stdio::piped())
33 .output()
34 .is_ok_and(|out| out.status.success() && out.stdout.starts_with(b"true"))
35}
36
37pub fn local_branches() -> Result<Vec<String>> {
38 let output = output(&["for-each-ref", "--format=%(refname:short)", "refs/heads"])?;
39 Ok(output.lines().map(str::to_owned).collect())
40}
41
42pub fn git_path(path: &str) -> Result<String> {
43 output(&["rev-parse", "--git-path", path])
44}
45
46pub fn repo_root() -> Result<std::path::PathBuf> {
48 Ok(std::path::PathBuf::from(output(&[
49 "rev-parse",
50 "--show-toplevel",
51 ])?))
52}
53
54pub fn git_common_path(path: &str) -> Result<String> {
59 let common_dir = output(&["rev-parse", "--git-common-dir"])?;
60 Ok(std::path::Path::new(&common_dir)
61 .join(path)
62 .to_string_lossy()
63 .into_owned())
64}
65
66pub fn worktree_branches() -> Result<Vec<(String, std::path::PathBuf)>> {
70 let porcelain = output(&["worktree", "list", "--porcelain"])?;
71 Ok(parse_worktree_branches(
72 &porcelain,
73 repo_root().ok().as_deref(),
74 ))
75}
76
77pub fn worktree_add_detached(path: &std::path::Path, commit: &str) -> Result<()> {
87 let path = path.to_string_lossy().into_owned();
88 status(&[
89 "worktree", "add", "--detach", "--force", "--quiet", &path, commit,
90 ])
91 .with_context(|| format!("failed to create a worktree at {path}"))
92}
93
94pub fn worktree_add_new_branch(path: &std::path::Path, branch: &str, start: &str) -> Result<()> {
96 let path = path.to_string_lossy().into_owned();
97 status(&["worktree", "add", "--quiet", "-b", branch, &path, start])
98 .with_context(|| format!("failed to create a worktree for {branch} at {path}"))
99}
100
101pub fn worktree_has_changes(path: &std::path::Path) -> bool {
104 let dir = path.to_string_lossy().into_owned();
105 output(&["-C", &dir, "status", "--porcelain"]).map_or(true, |out| !out.is_empty())
109}
110
111pub fn worktree_remove(path: &std::path::Path) -> Result<()> {
114 let path = path.to_string_lossy().into_owned();
115 status(&["worktree", "remove", "--force", &path])
116 .with_context(|| format!("failed to remove the worktree at {path}"))
117}
118
119pub fn checkout_detached_in(worktree: &std::path::Path, commit: &str) -> Result<()> {
122 let dir = worktree.to_string_lossy().into_owned();
123 status(&["-C", &dir, "checkout", "--detach", "--quiet", commit])
124 .with_context(|| format!("failed to check out {commit} in {dir}"))
125}
126
127pub fn git_common_path_absolute(path: &str) -> Result<std::path::PathBuf> {
131 let joined = git_common_path(path)?;
132 std::path::absolute(&joined).with_context(|| format!("failed to resolve {joined}"))
133}
134
135pub fn worktree_holding(branch: &str) -> Result<Option<std::path::PathBuf>> {
137 Ok(worktree_branches()?
138 .into_iter()
139 .find(|(name, _)| name == branch)
140 .map(|(_, path)| path))
141}
142
143pub fn in_linked_worktree() -> bool {
147 repo_root().is_ok_and(|root| !is_main_worktree(&root))
148}
149
150pub fn is_main_worktree(path: &std::path::Path) -> bool {
154 main_worktree().is_some_and(|main| same_path(&main, path))
157}
158
159fn main_worktree() -> Option<std::path::PathBuf> {
161 parse_main_worktree(&output(&["worktree", "list", "--porcelain"]).ok()?)
162}
163
164pub fn main_worktree_root() -> Result<std::path::PathBuf> {
170 match main_worktree() {
171 Some(path) => Ok(path),
172 None => repo_root(),
173 }
174}
175
176fn parse_main_worktree(porcelain: &str) -> Option<std::path::PathBuf> {
177 porcelain
178 .lines()
179 .find_map(|line| line.strip_prefix("worktree "))
180 .map(std::path::PathBuf::from)
181}
182
183pub fn detach_command(path: &std::path::Path) -> String {
188 format!("git -C \"{}\" checkout --detach", display_path(path))
191}
192
193pub fn describe_worktree(path: &std::path::Path) -> String {
196 let shown = display_path(path);
197 if is_main_worktree(path) {
198 format!("{shown} (the main worktree)")
199 } else {
200 shown
201 }
202}
203
204pub fn distinct_paths<'a>(
207 paths: impl IntoIterator<Item = &'a std::path::Path>,
208) -> Vec<std::path::PathBuf> {
209 let mut distinct: Vec<std::path::PathBuf> = Vec::new();
210 for path in paths {
211 if !distinct.iter().any(|seen| same_path(seen, path)) {
212 distinct.push(path.to_path_buf());
213 }
214 }
215 distinct
216}
217
218fn parse_worktree_branches(
224 porcelain: &str,
225 current: Option<&std::path::Path>,
226) -> Vec<(String, std::path::PathBuf)> {
227 let current = current.map(canonical);
228 let mut held = Vec::new();
229 let mut path: Option<std::path::PathBuf> = None;
230
231 for line in porcelain.lines() {
232 if let Some(rest) = line.strip_prefix("worktree ") {
233 path = Some(std::path::PathBuf::from(rest));
234 } else if let Some(branch) = line.strip_prefix("branch refs/heads/") {
235 if let Some(path) = path.take()
238 && current.as_deref() != Some(canonical(&path).as_path())
239 {
240 held.push((branch.to_owned(), path));
241 }
242 }
243 }
244
245 held
246}
247
248fn canonical(path: &std::path::Path) -> std::path::PathBuf {
251 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
252}
253
254pub fn same_path(a: &std::path::Path, b: &std::path::Path) -> bool {
259 canonical(a) == canonical(b)
260}
261
262pub fn display_path(path: &std::path::Path) -> String {
267 let Ok(cwd) = std::env::current_dir() else {
268 return path.display().to_string();
269 };
270
271 if let Ok(rest) = path.strip_prefix(&cwd)
272 && rest.components().next().is_some()
273 {
274 return format!("./{}", rest.display());
275 }
276 if let Some(up) = cwd.parent()
277 && let Ok(rest) = path.strip_prefix(up)
278 && rest.components().next().is_some()
279 {
280 return format!("../{}", rest.display());
281 }
282
283 path.display().to_string()
284}
285
286pub fn remote_url(remote: &str) -> Result<Option<String>> {
287 output_codes(&["remote", "get-url", remote], &[2], "git remote get-url")
289}
290
291fn worktree_collision(branch: &str) -> Option<String> {
297 let path = worktree_holding(branch).ok().flatten()?;
298 Some(collision_message(
299 branch,
300 &display_path(&path),
301 is_main_worktree(&path),
302 ))
303}
304
305fn collision_message(branch: &str, shown: &str, is_main: bool) -> String {
310 let mut free = format!("free it with `git -C \"{shown}\" checkout --detach`");
311 if !is_main {
312 free.push_str(&format!(
313 ", or drop that worktree with `git worktree remove \"{shown}\"`"
314 ));
315 }
316 format!(
317 "{branch} is checked out in the worktree at {shown}\n\
318 work on it there with `cd \"{shown}\"`, or {free}"
319 )
320}
321
322pub fn checkout(branch: &str) -> Result<()> {
323 checkout_silently(branch)?;
324 anstream::println!("switched to {}", switched_to(branch));
325 Ok(())
326}
327
328pub fn checkout_silently(branch: &str) -> Result<()> {
331 if let Some(message) = worktree_collision(branch) {
332 bail!(message);
333 }
334
335 status(&["switch", branch]).with_context(|| format!("failed to check out {branch}"))
336}
337
338pub fn switched_to(branch: &str) -> String {
340 crate::style::paint(crate::style::BRANCH, branch)
341}
342
343pub fn create_branch(branch: &str) -> Result<()> {
344 status(&["switch", "-c", branch]).with_context(|| format!("failed to create branch {branch}"))
345}
346
347pub fn create_branch_at(branch: &str, sha: &str) -> Result<()> {
350 status(&["branch", branch, sha])
351 .with_context(|| format!("failed to create branch {branch} at {sha}"))
352}
353
354pub fn delete_branch(branch: &str) -> Result<()> {
358 status(&["branch", "-D", branch]).with_context(|| format!("failed to delete branch {branch}"))
359}
360
361pub fn rename_branch(old: &str, new: &str) -> Result<()> {
363 status(&["branch", "-m", old, new]).with_context(|| format!("failed to rename {old} to {new}"))
364}
365
366pub fn fetch_branch(remote: &str, branch: &str) -> Result<()> {
368 let refspec = format!("{branch}:{branch}");
369 status(&["fetch", remote, &refspec])
370 .with_context(|| format!("failed to fetch {branch} from {remote}"))
371}
372
373pub fn pull_ff_only() -> Result<()> {
374 status(&["pull", "--ff-only"]).context("failed to fast-forward from the remote")
375}
376
377pub fn push_force_with_lease(remote: &str, branches: &[String]) -> Result<Vec<String>> {
382 let mut args = vec!["push", "--force-with-lease", remote];
383 args.extend(branches.iter().map(String::as_str));
384
385 run_lease_push(&args, remote, branches)
386}
387
388fn run_lease_push(args: &[&str], remote: &str, branches: &[String]) -> Result<Vec<String>> {
404 if verbose() {
408 status_passthrough(args).with_context(|| format!("failed to push branches to {remote}"))?;
409 return Ok(branches.to_vec());
410 }
411
412 let output = Command::new("git")
413 .args(args)
414 .output()
415 .context("failed to run git")?;
416 if output.status.success() {
417 return Ok(branches.to_vec());
418 }
419
420 let stderr = String::from_utf8_lossy(&output.stderr);
428 if let Some(queued) = merge_queue_rejection(&stderr) {
429 anstream::eprintln!(
430 "{}",
431 crate::style::warn(&format!(
432 "{} {} in a merge queue and was not updated (dequeue its review to push it)",
433 queued.join(", "),
434 if queued.len() == 1 { "is" } else { "are" },
435 ))
436 );
437 return Ok(landed_branches(branches, &queued));
438 }
439
440 if let Some(stale) = stale_rejection(&stderr) {
441 bail!(
444 "could not push {} to {remote}: the remote has moved on \
445 (a branch in the stack was likely merged or updated upstream)\n\
446 run `git stk sync` to reconcile your local stack with the remote, then try again",
447 stale.join(", "),
448 );
449 }
450
451 let _ = std::io::stdout().write_all(&output.stdout);
452 let _ = std::io::stderr().write_all(&output.stderr);
453 bail!(
454 "failed to push branches to {remote}: git exited with status {}",
455 output.status
456 )
457}
458
459fn landed_branches(attempted: &[String], held: &[String]) -> Vec<String> {
462 attempted
463 .iter()
464 .filter(|branch| !held.iter().any(|name| name == *branch))
465 .cloned()
466 .collect()
467}
468
469fn merge_queue_rejection(stderr: &str) -> Option<Vec<String>> {
476 let lower = stderr.to_lowercase();
477 let mentions_queue = lower.contains("merge queue") || lower.contains("queued for merging");
478 if !mentions_queue {
479 return None;
480 }
481 if ["stale info", "non-fast-forward", "fetch first"]
484 .iter()
485 .any(|marker| lower.contains(marker))
486 {
487 return None;
488 }
489 let rejected = rejected_refs(stderr);
490 if rejected.is_empty() {
491 None
492 } else {
493 Some(rejected)
494 }
495}
496
497fn stale_rejection(stderr: &str) -> Option<Vec<String>> {
509 let rejected: Vec<&str> = stderr
510 .lines()
511 .filter(|line| line.contains("[remote rejected]") || line.contains("[rejected]"))
512 .collect();
513 if rejected.is_empty() || !rejected.iter().all(|line| line_is_stale(line)) {
514 return None;
515 }
516 let names: Vec<String> = rejected
517 .iter()
518 .filter_map(|line| rejected_ref_name(line))
519 .collect();
520 if names.is_empty() { None } else { Some(names) }
521}
522
523fn line_is_stale(line: &str) -> bool {
527 let lower = line.to_lowercase();
528 ["stale info", "non-fast-forward", "fetch first"]
529 .iter()
530 .any(|marker| lower.contains(marker))
531}
532
533fn rejected_ref_name(line: &str) -> Option<String> {
536 let after = line.split("-> ").nth(1)?;
537 Some(after.split_whitespace().next()?.to_owned())
538}
539
540fn rejected_refs(stderr: &str) -> Vec<String> {
543 stderr
544 .lines()
545 .filter(|line| line.contains("[remote rejected]") || line.contains("[rejected]"))
546 .filter_map(rejected_ref_name)
547 .collect()
548}
549
550pub fn push_set_upstream_force_with_lease(remote: &str, branches: &[String]) -> Result<()> {
553 let mut args = vec!["push", "--set-upstream", "--force-with-lease", remote];
554 args.extend(branches.iter().map(String::as_str));
555
556 run_lease_push(&args, remote, branches)?;
559 Ok(())
560}
561
562pub fn write_blob_ref(reference: &str, file: &str, content: &str) -> Result<()> {
566 let blob = output_with_stdin(&["hash-object", "-w", "--stdin"], content)
567 .context("failed to hash stack metadata")?;
568 let tree = output_with_stdin(&["mktree"], &format!("100644 blob {blob}\t{file}\n"))
569 .context("failed to write stack metadata tree")?;
570 let commit = output(&["commit-tree", &tree, "-m", "git-stk stack metadata"])
571 .context("failed to commit stack metadata")?;
572 status(&["update-ref", reference, &commit])
573 .with_context(|| format!("failed to update {reference}"))
574}
575
576pub fn push_ref(remote: &str, reference: &str) -> Result<()> {
579 status(&[
580 "push",
581 "--force",
582 remote,
583 &format!("{reference}:{reference}"),
584 ])
585 .with_context(|| format!("failed to push {reference} to {remote}"))
586}
587
588pub fn fetch_ref(remote: &str, reference: &str) -> Result<()> {
590 status(&["fetch", remote, &format!("+{reference}:{reference}")])
591 .with_context(|| format!("failed to fetch {reference} from {remote}"))
592}
593
594pub fn read_ref_file(reference: &str, file: &str) -> Result<Option<String>> {
597 let output = Command::new("git")
598 .args(["cat-file", "blob", &format!("{reference}:{file}")])
599 .stdout(Stdio::piped())
600 .stderr(Stdio::piped())
601 .output()
602 .context("failed to run git cat-file")?;
603 if output.status.success() {
604 Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()))
605 } else {
606 Ok(None)
607 }
608}
609
610pub fn rebase(parent: &str, branch: &str, update_refs: bool) -> Result<()> {
611 if let Some(message) = worktree_collision(branch) {
612 bail!(message);
613 }
614 let mut args = vec!["rebase"];
615 if update_refs {
616 args.push("--update-refs");
617 }
618 args.extend([parent, branch]);
619
620 status(&args).with_context(|| format!("failed to rebase {branch} onto {parent}"))
621}
622
623pub fn rebase_onto(parent: &str, base: &str, branch: &str, update_refs: bool) -> Result<()> {
627 if let Some(message) = worktree_collision(branch) {
628 bail!(message);
629 }
630 let mut args = vec!["rebase"];
631 if update_refs {
632 args.push("--update-refs");
633 }
634 args.extend(["--onto", parent, base, branch]);
635
636 status(&args).with_context(|| format!("failed to rebase {branch} onto {parent} from {base}"))
637}
638
639pub fn rev_parse(rev: &str) -> Result<String> {
640 let spec = format!("{rev}^{{commit}}");
641 output(&["rev-parse", "--verify", &spec]).with_context(|| format!("failed to resolve {rev}"))
642}
643
644pub fn branch_sha(branch: &str) -> Option<String> {
646 rev_parse(branch).ok()
647}
648
649pub fn update_ref(branch: &str, sha: &str) -> Result<()> {
652 status(&["update-ref", &format!("refs/heads/{branch}"), sha])
653 .with_context(|| format!("failed to update {branch} to {sha}"))
654}
655
656pub fn reset_hard() -> Result<()> {
659 status(&["reset", "--hard"]).context("failed to reset the worktree")
660}
661
662pub fn worktree_is_clean() -> Result<bool> {
664 Ok(output(&["status", "--porcelain"])?.is_empty())
665}
666
667pub fn remote_default_branch(remote: &str) -> Option<String> {
669 let reference = format!("refs/remotes/{remote}/HEAD");
670 let full = output(&["symbolic-ref", "--short", &reference]).ok()?;
671 full.strip_prefix(&format!("{remote}/")).map(str::to_owned)
672}
673
674pub fn commits_behind(branch: &str, parent: &str) -> Result<usize> {
677 let range = format!("{branch}..{parent}");
678 let count = output(&["rev-list", "--count", &range])
679 .with_context(|| format!("failed to count commits in {range}"))?;
680 count
681 .trim()
682 .parse()
683 .context("failed to parse rev-list count")
684}
685
686pub fn merge_base(a: &str, b: &str) -> Result<String> {
687 output(&["merge-base", a, b])
688 .with_context(|| format!("failed to find merge base of {a} and {b}"))
689}
690
691pub fn diff_against_head(cached: bool) -> Result<String> {
695 let mut args = vec!["diff", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"];
698 if cached {
699 args.push("--cached");
700 }
701 args.push("HEAD");
702 output(&args).context("failed to diff against HEAD")
703}
704
705pub fn blame_line_shas(file: &str, start: usize, len: usize) -> Result<Vec<String>> {
708 if len == 0 {
709 return Ok(Vec::new());
710 }
711 let range = format!("{start},{}", start + len - 1);
712 let out = output(&[
713 "blame",
714 "HEAD",
715 "-L",
716 &range,
717 "--line-porcelain",
718 "--",
719 file,
720 ])
721 .with_context(|| format!("failed to blame {file}"))?;
722
723 let mut shas = Vec::new();
724 for line in out.lines() {
725 let token = line.split(' ').next().unwrap_or_default();
729 if token.len() == 40
730 && token.bytes().all(|byte| byte.is_ascii_hexdigit())
731 && !shas.iter().any(|seen| seen == token)
732 {
733 shas.push(token.to_owned());
734 }
735 }
736 Ok(shas)
737}
738
739pub fn rev_list(range: &str) -> Result<Vec<String>> {
741 Ok(output(&["rev-list", range])
742 .with_context(|| format!("failed to list commits in {range}"))?
743 .lines()
744 .map(str::to_owned)
745 .collect())
746}
747
748pub fn log_oneline(range: &str) -> Result<Vec<(String, String)>> {
751 Ok(output(&["log", "--format=%h%x09%s", range])
752 .with_context(|| format!("failed to log {range}"))?
753 .lines()
754 .filter_map(|line| {
755 line.split_once('\t')
756 .map(|(sha, subject)| (sha.to_owned(), subject.to_owned()))
757 })
758 .collect())
759}
760
761pub fn commit_subject(sha: &str) -> Result<String> {
763 output(&["show", "--no-patch", "--format=%s", sha])
764 .with_context(|| format!("failed to read subject of {sha}"))
765}
766
767pub fn commit_body(sha: &str) -> Result<String> {
769 output(&["show", "--no-patch", "--format=%b", sha])
770 .with_context(|| format!("failed to read body of {sha}"))
771}
772
773pub fn apply_cached(patch: &str) -> Result<()> {
776 let mut child = Command::new("git")
777 .args(["apply", "--cached", "--unidiff-zero"])
778 .stdin(Stdio::piped())
779 .stdout(Stdio::piped())
780 .stderr(Stdio::piped())
781 .spawn()
782 .context("failed to run git apply")?;
783 {
784 let mut stdin = child.stdin.take().context("git apply has no stdin")?;
785 stdin
786 .write_all(patch.as_bytes())
787 .context("failed to write patch to git apply")?;
788 }
789 let output = child
790 .wait_with_output()
791 .context("failed to run git apply")?;
792 if output.status.success() {
793 Ok(())
794 } else {
795 Err(command_error("git apply", &output.stderr))
796 }
797}
798
799pub fn commit_fixup(sha: &str) -> Result<()> {
802 status(&["commit", "--no-verify", &format!("--fixup={sha}")])
803 .with_context(|| format!("failed to create fixup commit for {sha}"))
804}
805
806pub fn reset_index() -> Result<()> {
808 status(&["reset", "--quiet"]).context("failed to reset the index")
809}
810
811pub fn reset_soft(sha: &str) -> Result<()> {
813 status(&["reset", "--soft", sha]).with_context(|| format!("failed to reset to {sha}"))
814}
815
816pub fn stash_push() -> Result<()> {
818 status(&["stash", "push", "--quiet"]).context("failed to stash changes")
819}
820
821pub fn stash_pop() -> Result<()> {
823 status(&["stash", "pop", "--quiet"]).context("failed to restore stashed changes")
824}
825
826pub fn rebase_autosquash(base: &str, update_refs: bool) -> Result<()> {
829 let mut args = vec!["rebase", "--interactive", "--autosquash"];
830 if update_refs {
831 args.push("--update-refs");
832 }
833 args.push(base);
834
835 let output = Command::new("git")
836 .args(&args)
837 .env("GIT_SEQUENCE_EDITOR", "true")
838 .env("GIT_EDITOR", "true")
839 .output()
840 .context("failed to run git rebase")?;
841 if output.status.success() {
842 Ok(())
843 } else {
844 Err(command_error("git rebase --autosquash", &output.stderr))
845 }
846}
847
848pub fn is_ancestor(ancestor: &str, descendant: &str) -> Result<bool> {
849 Ok(output_codes(
851 &["merge-base", "--is-ancestor", ancestor, descendant],
852 &[1],
853 "git merge-base --is-ancestor",
854 )?
855 .is_some())
856}
857
858pub fn diff_numstat(base: &str, branch: &str) -> Result<(usize, usize)> {
863 let output = output(&["diff", "--numstat", &format!("{base}...{branch}")])?;
864 let mut added = 0;
865 let mut deleted = 0;
866 for line in output.lines() {
867 let mut columns = line.split('\t');
868 added += column_count(columns.next());
869 deleted += column_count(columns.next());
870 }
871 Ok((added, deleted))
872}
873
874fn column_count(column: Option<&str>) -> usize {
877 column
878 .and_then(|value| value.parse::<usize>().ok())
879 .unwrap_or(0)
880}
881
882pub fn supports_rebase_update_refs() -> Result<bool> {
883 let output = Command::new("git")
884 .args(["rebase", "-h"])
885 .stdout(Stdio::piped())
886 .stderr(Stdio::piped())
887 .output()
888 .context("failed to inspect git rebase help")?;
889
890 let help = format!(
891 "{}{}",
892 String::from_utf8_lossy(&output.stdout),
893 String::from_utf8_lossy(&output.stderr)
894 );
895 Ok(help_mentions_update_refs(&help))
896}
897
898fn help_mentions_update_refs(help: &str) -> bool {
901 help.contains("update-refs")
902}
903
904pub fn rebase_in_progress() -> bool {
909 ["rebase-merge", "rebase-apply"].iter().any(|dir| {
910 git_path(dir)
911 .map(|path| std::path::Path::new(&path).exists())
912 .unwrap_or(false)
913 })
914}
915
916pub fn rebase_continue() -> Result<()> {
917 status_passthrough(&["rebase", "--continue"]).context("failed to continue rebase")
919}
920
921pub fn rebase_abort() -> Result<()> {
922 status(&["rebase", "--abort"]).context("failed to abort rebase")
923}
924
925pub fn cherry_pick(commit: &str) -> Result<()> {
929 status(&["cherry-pick", commit]).with_context(|| format!("failed to cherry-pick {commit}"))
930}
931
932pub fn fetch_tracking(remote: &str, branches: &[String]) -> Result<()> {
937 let present = remote_branches_present(remote, branches)?;
938 if present.is_empty() {
939 return Ok(());
940 }
941 let mut args = vec!["fetch", remote];
942 args.extend(present.iter().map(String::as_str));
943 status(&args).with_context(|| format!("failed to fetch branches from {remote}"))
944}
945
946pub(crate) fn remote_has_branch(remote: &str, branch: &str) -> Result<bool> {
949 Ok(!remote_branches_present(remote, std::slice::from_ref(&branch.to_owned()))?.is_empty())
950}
951
952fn remote_branches_present(remote: &str, branches: &[String]) -> Result<Vec<String>> {
956 if branches.is_empty() {
957 return Ok(Vec::new());
958 }
959 let mut args = vec!["ls-remote", "--heads", remote];
960 args.extend(branches.iter().map(String::as_str));
961 let listing =
962 output(&args).with_context(|| format!("failed to query {remote} for branch heads"))?;
963 let present: Vec<&str> = listing
964 .lines()
965 .filter_map(|line| line.split_once('\t'))
966 .filter_map(|(_, name)| name.strip_prefix("refs/heads/"))
967 .collect();
968 Ok(branches
969 .iter()
970 .filter(|branch| present.contains(&branch.as_str()))
971 .cloned()
972 .collect())
973}
974
975pub fn remote_only_commits(branch: &str, tracking: &str) -> Result<Vec<(String, String)>> {
982 let range = format!("{branch}...{tracking}");
983 let mut commits: Vec<(String, String)> = output(&[
984 "log",
985 "--cherry-pick",
986 "--right-only",
987 "--no-merges",
988 "--format=%h%x09%s",
989 &range,
990 ])
991 .with_context(|| format!("failed to list remote-only commits in {range}"))?
992 .lines()
993 .filter_map(|line| {
994 line.split_once('\t')
995 .map(|(sha, subject)| (sha.to_owned(), subject.to_owned()))
996 })
997 .collect();
998 commits.reverse();
1000 Ok(commits)
1001}
1002
1003pub fn merge_adds_nothing(one: &str, other: &str) -> Result<bool> {
1016 let Some(merged) = output_codes(
1020 &["merge-tree", "--write-tree", one, other],
1021 &[1],
1022 "git merge-tree --write-tree",
1023 )?
1024 else {
1025 return Ok(false);
1026 };
1027 let ours = output(&["rev-parse", &format!("{one}^{{tree}}")])
1028 .with_context(|| format!("failed to read the tree of {one}"))?;
1029 Ok(merged.lines().next().unwrap_or_default().trim() == ours.trim())
1030}
1031
1032pub fn config_get(key: &str) -> Result<Option<String>> {
1033 output_codes(&["config", "--get", key], &[1], "git config --get")
1035}
1036
1037pub fn config_get_bool(key: &str) -> Result<Option<bool>> {
1038 let Some(value) = output_codes(
1039 &["config", "--type=bool", "--get", key],
1040 &[1],
1041 "git config --type=bool --get",
1042 )?
1043 else {
1044 return Ok(None);
1045 };
1046 match value.as_str() {
1047 "true" => Ok(Some(true)),
1048 "false" => Ok(Some(false)),
1049 _ => bail!("git config {key} is not a boolean: {value}"),
1050 }
1051}
1052
1053pub fn config_get_regexp(pattern: &str) -> Result<Vec<(String, String)>> {
1054 let Some(text) = output_codes(
1056 &["config", "--get-regexp", pattern],
1057 &[1],
1058 "git config --get-regexp",
1059 )?
1060 else {
1061 return Ok(Vec::new());
1062 };
1063 Ok(text
1064 .lines()
1065 .filter_map(|line| {
1066 line.split_once(' ')
1067 .map(|(key, value)| (key.to_owned(), value.to_owned()))
1068 })
1069 .collect())
1070}
1071
1072pub fn config_set(key: &str, value: &str) -> Result<()> {
1073 status(&["config", key, value]).with_context(|| format!("failed to set git config {key}"))
1074}
1075
1076pub fn config_unset(key: &str) -> Result<()> {
1077 output_codes(&["config", "--unset", key], &[5], "git config --unset").map(|_| ())
1080}
1081
1082fn output_codes(args: &[&str], ok_empty: &[i32], label: &str) -> Result<Option<String>> {
1087 let output = Command::new("git")
1088 .args(args)
1089 .stdout(Stdio::piped())
1090 .stderr(Stdio::piped())
1091 .output()
1092 .context("failed to run git")?;
1093
1094 match output.status.code() {
1095 Some(0) => Ok(Some(
1096 String::from_utf8_lossy(&output.stdout).trim().to_owned(),
1097 )),
1098 Some(code) if ok_empty.contains(&code) => Ok(None),
1099 _ => Err(command_error(label, &output.stderr)),
1100 }
1101}
1102
1103fn output(args: &[&str]) -> Result<String> {
1104 let output = Command::new("git")
1105 .args(args)
1106 .stdout(Stdio::piped())
1107 .stderr(Stdio::piped())
1108 .output()
1109 .context("failed to run git")?;
1110
1111 if output.status.success() {
1112 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
1113 } else {
1114 Err(command_error("git", &output.stderr))
1115 }
1116}
1117
1118fn output_with_stdin(args: &[&str], input: &str) -> Result<String> {
1121 let mut child = Command::new("git")
1122 .args(args)
1123 .stdin(Stdio::piped())
1124 .stdout(Stdio::piped())
1125 .stderr(Stdio::piped())
1126 .spawn()
1127 .context("failed to run git")?;
1128 {
1129 let mut stdin = child.stdin.take().context("git has no stdin")?;
1130 stdin
1131 .write_all(input.as_bytes())
1132 .context("failed to write to git")?;
1133 }
1134 let output = child.wait_with_output().context("failed to run git")?;
1135 if output.status.success() {
1136 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
1137 } else {
1138 Err(command_error("git", &output.stderr))
1139 }
1140}
1141
1142fn status(args: &[&str]) -> Result<()> {
1146 if verbose() {
1147 return status_passthrough(args);
1148 }
1149
1150 let output = Command::new("git")
1151 .args(args)
1152 .output()
1153 .context("failed to run git")?;
1154
1155 if output.status.success() {
1156 Ok(())
1157 } else {
1158 let _ = std::io::stdout().write_all(&output.stdout);
1159 let _ = std::io::stderr().write_all(&output.stderr);
1160 bail!("git exited with status {}", output.status)
1161 }
1162}
1163
1164fn status_passthrough(args: &[&str]) -> Result<()> {
1167 let status = Command::new("git")
1168 .args(args)
1169 .status()
1170 .context("failed to run git")?;
1171
1172 if status.success() {
1173 Ok(())
1174 } else {
1175 bail!("git exited with status {status}")
1176 }
1177}
1178
1179fn command_error(command: &str, stderr: &[u8]) -> anyhow::Error {
1180 let stderr = String::from_utf8_lossy(stderr).trim().to_owned();
1181 if stderr.is_empty() {
1182 anyhow!("{command} failed")
1183 } else {
1184 anyhow!("{command} failed: {stderr}")
1185 }
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190 use super::*;
1191
1192 const PORCELAIN: &str = "\
1195worktree /repo
1196HEAD f7cff917cf874d0c6ff3108260fda91ac3271baf
1197branch refs/heads/feat/b
1198
1199worktree /repo/../wt-a
1200HEAD 0700673acebfe459d480fa3bd616b2ecf6249fe1
1201branch refs/heads/feat/a
1202
1203worktree /repo/../wt-detached
1204HEAD 25fb6254b4b1cd5cbe2b0d4b1f5b1cf6e7d8a9b0
1205detached
1206";
1207
1208 #[test]
1209 fn worktree_parsing_keeps_branches_and_drops_detached_ones() {
1210 let held = parse_worktree_branches(PORCELAIN, None);
1214 assert_eq!(
1215 held,
1216 vec![
1217 ("feat/b".to_owned(), std::path::PathBuf::from("/repo")),
1218 (
1219 "feat/a".to_owned(),
1220 std::path::PathBuf::from("/repo/../wt-a")
1221 ),
1222 ]
1223 );
1224 }
1225
1226 #[test]
1227 fn worktree_parsing_excludes_the_worktree_we_are_standing_in() {
1228 let held = parse_worktree_branches(PORCELAIN, Some(std::path::Path::new("/repo")));
1231 assert_eq!(
1232 held,
1233 vec![(
1234 "feat/a".to_owned(),
1235 std::path::PathBuf::from("/repo/../wt-a")
1236 )]
1237 );
1238 }
1239
1240 #[test]
1241 fn a_bare_record_does_not_lend_its_path_to_the_next_branch() {
1242 let porcelain = "\
1245worktree /repo/.bare
1246bare
1247
1248worktree /repo/wt-a
1249HEAD 0700673acebfe459d480fa3bd616b2ecf6249fe1
1250branch refs/heads/feat/a
1251";
1252 assert_eq!(
1253 parse_worktree_branches(porcelain, None),
1254 vec![("feat/a".to_owned(), std::path::PathBuf::from("/repo/wt-a"))]
1255 );
1256 }
1257
1258 #[test]
1259 fn branch_names_containing_slashes_survive_the_refs_heads_strip() {
1260 let porcelain = "\
1263worktree /repo/wt
1264HEAD 0700673acebfe459d480fa3bd616b2ecf6249fe1
1265branch refs/heads/feat/deep/nested/name
1266";
1267 assert_eq!(
1268 parse_worktree_branches(porcelain, None)
1269 .first()
1270 .map(|(branch, _)| branch.as_str()),
1271 Some("feat/deep/nested/name")
1272 );
1273 }
1274
1275 #[test]
1276 fn empty_porcelain_holds_nothing() {
1277 assert!(parse_worktree_branches("", None).is_empty());
1278 }
1279
1280 #[test]
1281 fn a_collision_message_quotes_the_path_it_suggests_pasting() {
1282 let message = collision_message("feat/a", "../my worktree", false);
1285 assert!(
1286 message.contains(r#"`cd "../my worktree"`"#),
1287 "cd suggestion is not pasteable: {message}"
1288 );
1289 assert!(
1290 message.contains(r#"`git worktree remove "../my worktree"`"#),
1291 "remove suggestion is not pasteable: {message}"
1292 );
1293 assert!(
1294 message.contains(r#"`git -C "../my worktree" checkout --detach`"#),
1295 "detach suggestion is not pasteable: {message}"
1296 );
1297 }
1298
1299 #[test]
1300 fn a_collision_with_the_main_worktree_never_suggests_removing_it() {
1301 let message = collision_message("feat/a", "../product", true);
1304 assert!(
1305 !message.contains("git worktree remove"),
1306 "the main worktree cannot be removed: {message}"
1307 );
1308 assert!(
1309 message.contains(r#"`git -C "../product" checkout --detach`"#),
1310 "no workable way to free the branch: {message}"
1311 );
1312 }
1313
1314 #[test]
1315 fn the_main_worktree_is_the_first_record_listed() {
1316 let porcelain = "\
1317worktree /repo/product
1318HEAD 1111111111111111111111111111111111111111
1319branch refs/heads/feat/b
1320
1321worktree /repo/product-worktrees/feat/a
1322HEAD 2222222222222222222222222222222222222222
1323branch refs/heads/feat/a
1324";
1325 assert_eq!(
1326 parse_main_worktree(porcelain),
1327 Some(std::path::PathBuf::from("/repo/product"))
1328 );
1329 }
1330
1331 #[test]
1332 fn no_listing_names_no_main_worktree() {
1333 assert_eq!(parse_main_worktree(""), None);
1334 }
1335
1336 #[test]
1337 fn one_worktree_holding_three_branches_is_freed_once() {
1338 let held = [
1339 std::path::Path::new("../wt-a"),
1340 std::path::Path::new("../wt-a"),
1341 std::path::Path::new("../wt-b"),
1342 ];
1343 assert_eq!(
1344 distinct_paths(held),
1345 vec![
1346 std::path::PathBuf::from("../wt-a"),
1347 std::path::PathBuf::from("../wt-b")
1348 ]
1349 );
1350 }
1351
1352 #[test]
1353 fn a_collision_message_names_the_branch_and_where_it_lives() {
1354 let message = collision_message("feat/a", "../wt-a", false);
1355 assert!(message.starts_with("feat/a is checked out in the worktree at ../wt-a"));
1356 }
1357
1358 #[test]
1359 fn a_merge_queue_rejection_is_downgraded_to_the_queued_refs() {
1360 let stderr = "\
1363remote: error: GH006: Protected branch update failed for refs/heads/feat/tf-deploy.
1364remote: - A pull request for this branch has been added to a merge queue. Branches that
1365remote: are queued for merging cannot be updated. To modify this branch, dequeue the
1366remote: associated pull request.
1367To github.com:higharc/product
1368 + 016bb37...3a94024 feat/spa-env -> feat/spa-env (forced update)
1369 ! [remote rejected] feat/tf-deploy -> feat/tf-deploy (protected branch hook declined)
1370error: failed to push some refs to 'github.com:higharc/product'";
1371 assert_eq!(
1372 merge_queue_rejection(stderr),
1373 Some(vec!["feat/tf-deploy".to_owned()])
1374 );
1375 }
1376
1377 #[test]
1378 fn a_stale_lease_rejection_is_not_swallowed_even_with_a_queue_mention() {
1379 let stderr = "\
1382remote: GitHub found 270 vulnerabilities ... merge queue notes ...
1383 ! [rejected] feat/tf-deploy -> feat/tf-deploy (stale info)
1384error: failed to push some refs";
1385 assert_eq!(merge_queue_rejection(stderr), None);
1386 }
1387
1388 #[test]
1389 fn no_queue_mention_is_not_a_queue_rejection() {
1390 let stderr = " ! [remote rejected] feat/x -> feat/x (permission denied)";
1391 assert_eq!(merge_queue_rejection(stderr), None);
1392 }
1393
1394 #[test]
1395 fn landed_branches_drops_only_the_held_ones() {
1396 let attempted = [
1397 "feat/a".to_owned(),
1398 "feat/b".to_owned(),
1399 "feat/c".to_owned(),
1400 ];
1401 assert_eq!(
1404 landed_branches(&attempted, &["feat/b".to_owned()]),
1405 vec!["feat/a".to_owned(), "feat/c".to_owned()]
1406 );
1407 assert_eq!(landed_branches(&attempted, &[]), attempted.to_vec());
1409 assert!(landed_branches(&attempted, &attempted).is_empty());
1411 }
1412
1413 #[test]
1414 fn a_stale_lease_push_names_the_rejected_branch() {
1415 let stderr = "\
1418To github.com:higharc/product
1419 3a94024..d63a2b2 feat/spa-env -> feat/spa-env
1420 ! [rejected] feat/tf-deploy -> feat/tf-deploy (stale info)
1421error: failed to push some refs to 'github.com:higharc/product'";
1422 assert_eq!(
1423 stale_rejection(stderr),
1424 Some(vec!["feat/tf-deploy".to_owned()])
1425 );
1426 }
1427
1428 #[test]
1429 fn a_non_fast_forward_push_is_treated_as_stale() {
1430 let stderr = " ! [rejected] feat/x -> feat/x (non-fast-forward)";
1431 assert_eq!(stale_rejection(stderr), Some(vec!["feat/x".to_owned()]));
1432 }
1433
1434 #[test]
1435 fn an_unrelated_push_failure_is_not_classified_as_stale() {
1436 let stderr = " ! [remote rejected] feat/x -> feat/x (permission denied)";
1438 assert_eq!(stale_rejection(stderr), None);
1439 assert_eq!(stale_rejection("fatal: could not read from remote"), None);
1440 }
1441
1442 #[test]
1443 fn a_mixed_stale_and_non_stale_rejection_is_not_classified_as_stale() {
1444 let stderr = "\
1448 ! [rejected] feat/tf-deploy -> feat/tf-deploy (stale info)
1449 ! [remote rejected] feat/locked -> feat/locked (permission denied)
1450error: failed to push some refs";
1451 assert_eq!(stale_rejection(stderr), None);
1452 }
1453
1454 #[test]
1455 fn help_mentions_update_refs_matches_pre_2_43_spelling() {
1456 assert!(help_mentions_update_refs(
1457 " --update-refs update branches that point to commits that are being rebased"
1458 ));
1459 }
1460
1461 #[test]
1462 fn help_mentions_update_refs_matches_negatable_spelling() {
1463 assert!(help_mentions_update_refs(
1464 " --[no-]update-refs update branches that point to commits that are being rebased"
1465 ));
1466 }
1467
1468 #[test]
1469 fn help_mentions_update_refs_rejects_help_without_the_option() {
1470 assert!(!help_mentions_update_refs(
1471 " --[no-]autosquash move commits that begin with squash!/fixup!"
1472 ));
1473 }
1474
1475 #[test]
1476 fn detection_agrees_with_the_real_git_on_this_machine() {
1477 let probe = Command::new("git")
1480 .args(["rebase", "--update-refs", "-h"])
1481 .stdout(Stdio::piped())
1482 .stderr(Stdio::piped())
1483 .output()
1484 .expect("run git rebase probe");
1485 let probe_text = format!(
1486 "{}{}",
1487 String::from_utf8_lossy(&probe.stdout),
1488 String::from_utf8_lossy(&probe.stderr)
1489 );
1490 let real_support = !probe_text.contains("unknown option");
1491
1492 assert_eq!(
1493 supports_rebase_update_refs().expect("detect support"),
1494 real_support
1495 );
1496 }
1497}