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
14fn verbose() -> bool {
15 VERBOSE.load(Ordering::Relaxed)
16}
17
18pub fn current_branch() -> Result<String> {
19 output(&["symbolic-ref", "--quiet", "--short", "HEAD"])
20 .context("failed to determine current branch")
21}
22
23pub fn is_in_repo() -> bool {
27 Command::new("git")
28 .args(["rev-parse", "--is-inside-work-tree"])
29 .stdout(Stdio::piped())
30 .stderr(Stdio::piped())
31 .output()
32 .is_ok_and(|out| out.status.success() && out.stdout.starts_with(b"true"))
33}
34
35pub fn local_branches() -> Result<Vec<String>> {
36 let output = output(&["for-each-ref", "--format=%(refname:short)", "refs/heads"])?;
37 Ok(output.lines().map(str::to_owned).collect())
38}
39
40pub fn git_path(path: &str) -> Result<String> {
41 output(&["rev-parse", "--git-path", path])
42}
43
44pub fn repo_root() -> Result<std::path::PathBuf> {
46 Ok(std::path::PathBuf::from(output(&[
47 "rev-parse",
48 "--show-toplevel",
49 ])?))
50}
51
52pub fn git_common_path(path: &str) -> Result<String> {
57 let common_dir = output(&["rev-parse", "--git-common-dir"])?;
58 Ok(std::path::Path::new(&common_dir)
59 .join(path)
60 .to_string_lossy()
61 .into_owned())
62}
63
64pub fn worktree_branches() -> Result<Vec<(String, std::path::PathBuf)>> {
68 let porcelain = output(&["worktree", "list", "--porcelain"])?;
69 Ok(parse_worktree_branches(
70 &porcelain,
71 repo_root().ok().as_deref(),
72 ))
73}
74
75pub fn worktree_add_detached(path: &std::path::Path, commit: &str) -> Result<()> {
85 let path = path.to_string_lossy().into_owned();
86 status(&[
87 "worktree", "add", "--detach", "--force", "--quiet", &path, commit,
88 ])
89 .with_context(|| format!("failed to create a worktree at {path}"))
90}
91
92pub fn worktree_add_new_branch(path: &std::path::Path, branch: &str, start: &str) -> Result<()> {
94 let path = path.to_string_lossy().into_owned();
95 status(&["worktree", "add", "--quiet", "-b", branch, &path, start])
96 .with_context(|| format!("failed to create a worktree for {branch} at {path}"))
97}
98
99pub fn worktree_has_changes(path: &std::path::Path) -> bool {
102 let dir = path.to_string_lossy().into_owned();
103 output(&["-C", &dir, "status", "--porcelain"]).map_or(true, |out| !out.is_empty())
107}
108
109pub fn worktree_remove(path: &std::path::Path) -> Result<()> {
112 let path = path.to_string_lossy().into_owned();
113 status(&["worktree", "remove", "--force", &path])
114 .with_context(|| format!("failed to remove the worktree at {path}"))
115}
116
117pub fn checkout_detached_in(worktree: &std::path::Path, commit: &str) -> Result<()> {
120 let dir = worktree.to_string_lossy().into_owned();
121 status(&["-C", &dir, "checkout", "--detach", "--quiet", commit])
122 .with_context(|| format!("failed to check out {commit} in {dir}"))
123}
124
125pub fn git_common_path_absolute(path: &str) -> Result<std::path::PathBuf> {
129 let joined = git_common_path(path)?;
130 std::path::absolute(&joined).with_context(|| format!("failed to resolve {joined}"))
131}
132
133pub fn worktree_holding(branch: &str) -> Result<Option<std::path::PathBuf>> {
135 Ok(worktree_branches()?
136 .into_iter()
137 .find(|(name, _)| name == branch)
138 .map(|(_, path)| path))
139}
140
141pub fn is_main_worktree(path: &std::path::Path) -> bool {
145 main_worktree().is_some_and(|main| same_path(&main, path))
148}
149
150fn main_worktree() -> Option<std::path::PathBuf> {
152 parse_main_worktree(&output(&["worktree", "list", "--porcelain"]).ok()?)
153}
154
155pub fn main_worktree_root() -> Result<std::path::PathBuf> {
161 match main_worktree() {
162 Some(path) => Ok(path),
163 None => repo_root(),
164 }
165}
166
167fn parse_main_worktree(porcelain: &str) -> Option<std::path::PathBuf> {
168 porcelain
169 .lines()
170 .find_map(|line| line.strip_prefix("worktree "))
171 .map(std::path::PathBuf::from)
172}
173
174pub fn detach_command(path: &std::path::Path) -> String {
179 format!("git -C \"{}\" checkout --detach", display_path(path))
182}
183
184pub fn describe_worktree(path: &std::path::Path) -> String {
187 let shown = display_path(path);
188 if is_main_worktree(path) {
189 format!("{shown} (the main worktree)")
190 } else {
191 shown
192 }
193}
194
195pub fn distinct_paths<'a>(
198 paths: impl IntoIterator<Item = &'a std::path::Path>,
199) -> Vec<std::path::PathBuf> {
200 let mut distinct: Vec<std::path::PathBuf> = Vec::new();
201 for path in paths {
202 if !distinct.iter().any(|seen| same_path(seen, path)) {
203 distinct.push(path.to_path_buf());
204 }
205 }
206 distinct
207}
208
209fn parse_worktree_branches(
215 porcelain: &str,
216 current: Option<&std::path::Path>,
217) -> Vec<(String, std::path::PathBuf)> {
218 let current = current.map(canonical);
219 let mut held = Vec::new();
220 let mut path: Option<std::path::PathBuf> = None;
221
222 for line in porcelain.lines() {
223 if let Some(rest) = line.strip_prefix("worktree ") {
224 path = Some(std::path::PathBuf::from(rest));
225 } else if let Some(branch) = line.strip_prefix("branch refs/heads/") {
226 if let Some(path) = path.take()
229 && current.as_deref() != Some(canonical(&path).as_path())
230 {
231 held.push((branch.to_owned(), path));
232 }
233 }
234 }
235
236 held
237}
238
239fn canonical(path: &std::path::Path) -> std::path::PathBuf {
242 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
243}
244
245pub fn same_path(a: &std::path::Path, b: &std::path::Path) -> bool {
250 canonical(a) == canonical(b)
251}
252
253pub fn display_path(path: &std::path::Path) -> String {
258 let Ok(cwd) = std::env::current_dir() else {
259 return path.display().to_string();
260 };
261
262 if let Ok(rest) = path.strip_prefix(&cwd)
263 && rest.components().next().is_some()
264 {
265 return format!("./{}", rest.display());
266 }
267 if let Some(up) = cwd.parent()
268 && let Ok(rest) = path.strip_prefix(up)
269 && rest.components().next().is_some()
270 {
271 return format!("../{}", rest.display());
272 }
273
274 path.display().to_string()
275}
276
277pub fn remote_url(remote: &str) -> Result<Option<String>> {
278 output_codes(&["remote", "get-url", remote], &[2], "git remote get-url")
280}
281
282fn worktree_collision(branch: &str) -> Option<String> {
288 let path = worktree_holding(branch).ok().flatten()?;
289 Some(collision_message(
290 branch,
291 &display_path(&path),
292 is_main_worktree(&path),
293 ))
294}
295
296fn collision_message(branch: &str, shown: &str, is_main: bool) -> String {
301 let mut free = format!("free it with `git -C \"{shown}\" checkout --detach`");
302 if !is_main {
303 free.push_str(&format!(
304 ", or drop that worktree with `git worktree remove \"{shown}\"`"
305 ));
306 }
307 format!(
308 "{branch} is checked out in the worktree at {shown}\n\
309 work on it there with `cd \"{shown}\"`, or {free}"
310 )
311}
312
313pub fn checkout(branch: &str) -> Result<()> {
314 checkout_silently(branch)?;
315 anstream::println!("switched to {}", switched_to(branch));
316 Ok(())
317}
318
319pub fn checkout_silently(branch: &str) -> Result<()> {
322 if let Some(message) = worktree_collision(branch) {
323 bail!(message);
324 }
325
326 status(&["switch", branch]).with_context(|| format!("failed to check out {branch}"))
327}
328
329pub fn switched_to(branch: &str) -> String {
331 crate::style::paint(crate::style::BRANCH, branch)
332}
333
334pub fn create_branch(branch: &str) -> Result<()> {
335 status(&["switch", "-c", branch]).with_context(|| format!("failed to create branch {branch}"))
336}
337
338pub fn create_branch_at(branch: &str, sha: &str) -> Result<()> {
341 status(&["branch", branch, sha])
342 .with_context(|| format!("failed to create branch {branch} at {sha}"))
343}
344
345pub fn delete_branch(branch: &str) -> Result<()> {
349 status(&["branch", "-D", branch]).with_context(|| format!("failed to delete branch {branch}"))
350}
351
352pub fn rename_branch(old: &str, new: &str) -> Result<()> {
354 status(&["branch", "-m", old, new]).with_context(|| format!("failed to rename {old} to {new}"))
355}
356
357pub fn fetch_branch(remote: &str, branch: &str) -> Result<()> {
359 let refspec = format!("{branch}:{branch}");
360 status(&["fetch", remote, &refspec])
361 .with_context(|| format!("failed to fetch {branch} from {remote}"))
362}
363
364pub fn pull_ff_only() -> Result<()> {
365 status(&["pull", "--ff-only"]).context("failed to fast-forward from the remote")
366}
367
368pub fn push_force_with_lease(remote: &str, branches: &[String]) -> Result<Vec<String>> {
373 let mut args = vec!["push", "--force-with-lease", remote];
374 args.extend(branches.iter().map(String::as_str));
375
376 run_lease_push(&args, remote, branches)
377}
378
379fn run_lease_push(args: &[&str], remote: &str, branches: &[String]) -> Result<Vec<String>> {
395 if verbose() {
399 status_passthrough(args).with_context(|| format!("failed to push branches to {remote}"))?;
400 return Ok(branches.to_vec());
401 }
402
403 let output = Command::new("git")
404 .args(args)
405 .output()
406 .context("failed to run git")?;
407 if output.status.success() {
408 return Ok(branches.to_vec());
409 }
410
411 let stderr = String::from_utf8_lossy(&output.stderr);
419 if let Some(queued) = merge_queue_rejection(&stderr) {
420 anstream::eprintln!(
421 "{}",
422 crate::style::warn(&format!(
423 "{} {} in a merge queue and was not updated (dequeue its review to push it)",
424 queued.join(", "),
425 if queued.len() == 1 { "is" } else { "are" },
426 ))
427 );
428 return Ok(landed_branches(branches, &queued));
429 }
430
431 if let Some(stale) = stale_rejection(&stderr) {
432 bail!(
435 "could not push {} to {remote}: the remote has moved on \
436 (a branch in the stack was likely merged or updated upstream)\n\
437 run `git stk sync` to reconcile your local stack with the remote, then try again",
438 stale.join(", "),
439 );
440 }
441
442 let _ = std::io::stdout().write_all(&output.stdout);
443 let _ = std::io::stderr().write_all(&output.stderr);
444 bail!(
445 "failed to push branches to {remote}: git exited with status {}",
446 output.status
447 )
448}
449
450fn landed_branches(attempted: &[String], held: &[String]) -> Vec<String> {
453 attempted
454 .iter()
455 .filter(|branch| !held.iter().any(|name| name == *branch))
456 .cloned()
457 .collect()
458}
459
460fn merge_queue_rejection(stderr: &str) -> Option<Vec<String>> {
467 let lower = stderr.to_lowercase();
468 let mentions_queue = lower.contains("merge queue") || lower.contains("queued for merging");
469 if !mentions_queue {
470 return None;
471 }
472 if ["stale info", "non-fast-forward", "fetch first"]
475 .iter()
476 .any(|marker| lower.contains(marker))
477 {
478 return None;
479 }
480 let rejected = rejected_refs(stderr);
481 if rejected.is_empty() {
482 None
483 } else {
484 Some(rejected)
485 }
486}
487
488fn stale_rejection(stderr: &str) -> Option<Vec<String>> {
500 let rejected: Vec<&str> = stderr
501 .lines()
502 .filter(|line| line.contains("[remote rejected]") || line.contains("[rejected]"))
503 .collect();
504 if rejected.is_empty() || !rejected.iter().all(|line| line_is_stale(line)) {
505 return None;
506 }
507 let names: Vec<String> = rejected
508 .iter()
509 .filter_map(|line| rejected_ref_name(line))
510 .collect();
511 if names.is_empty() { None } else { Some(names) }
512}
513
514fn line_is_stale(line: &str) -> bool {
518 let lower = line.to_lowercase();
519 ["stale info", "non-fast-forward", "fetch first"]
520 .iter()
521 .any(|marker| lower.contains(marker))
522}
523
524fn rejected_ref_name(line: &str) -> Option<String> {
527 let after = line.split("-> ").nth(1)?;
528 Some(after.split_whitespace().next()?.to_owned())
529}
530
531fn rejected_refs(stderr: &str) -> Vec<String> {
534 stderr
535 .lines()
536 .filter(|line| line.contains("[remote rejected]") || line.contains("[rejected]"))
537 .filter_map(rejected_ref_name)
538 .collect()
539}
540
541pub fn push_set_upstream_force_with_lease(remote: &str, branches: &[String]) -> Result<()> {
544 let mut args = vec!["push", "--set-upstream", "--force-with-lease", remote];
545 args.extend(branches.iter().map(String::as_str));
546
547 run_lease_push(&args, remote, branches)?;
550 Ok(())
551}
552
553pub fn write_blob_ref(reference: &str, file: &str, content: &str) -> Result<()> {
557 let blob = output_with_stdin(&["hash-object", "-w", "--stdin"], content)
558 .context("failed to hash stack metadata")?;
559 let tree = output_with_stdin(&["mktree"], &format!("100644 blob {blob}\t{file}\n"))
560 .context("failed to write stack metadata tree")?;
561 let commit = output(&["commit-tree", &tree, "-m", "git-stk stack metadata"])
562 .context("failed to commit stack metadata")?;
563 status(&["update-ref", reference, &commit])
564 .with_context(|| format!("failed to update {reference}"))
565}
566
567pub fn push_ref(remote: &str, reference: &str) -> Result<()> {
570 status(&[
571 "push",
572 "--force",
573 remote,
574 &format!("{reference}:{reference}"),
575 ])
576 .with_context(|| format!("failed to push {reference} to {remote}"))
577}
578
579pub fn fetch_ref(remote: &str, reference: &str) -> Result<()> {
581 status(&["fetch", remote, &format!("+{reference}:{reference}")])
582 .with_context(|| format!("failed to fetch {reference} from {remote}"))
583}
584
585pub fn read_ref_file(reference: &str, file: &str) -> Result<Option<String>> {
588 let output = Command::new("git")
589 .args(["cat-file", "blob", &format!("{reference}:{file}")])
590 .stdout(Stdio::piped())
591 .stderr(Stdio::piped())
592 .output()
593 .context("failed to run git cat-file")?;
594 if output.status.success() {
595 Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()))
596 } else {
597 Ok(None)
598 }
599}
600
601pub fn rebase(parent: &str, branch: &str, update_refs: bool) -> Result<()> {
602 if let Some(message) = worktree_collision(branch) {
603 bail!(message);
604 }
605 let mut args = vec!["rebase"];
606 if update_refs {
607 args.push("--update-refs");
608 }
609 args.extend([parent, branch]);
610
611 status(&args).with_context(|| format!("failed to rebase {branch} onto {parent}"))
612}
613
614pub fn rebase_onto(parent: &str, base: &str, branch: &str, update_refs: bool) -> Result<()> {
618 if let Some(message) = worktree_collision(branch) {
619 bail!(message);
620 }
621 let mut args = vec!["rebase"];
622 if update_refs {
623 args.push("--update-refs");
624 }
625 args.extend(["--onto", parent, base, branch]);
626
627 status(&args).with_context(|| format!("failed to rebase {branch} onto {parent} from {base}"))
628}
629
630pub fn rev_parse(rev: &str) -> Result<String> {
631 let spec = format!("{rev}^{{commit}}");
632 output(&["rev-parse", "--verify", &spec]).with_context(|| format!("failed to resolve {rev}"))
633}
634
635pub fn branch_sha(branch: &str) -> Option<String> {
637 rev_parse(branch).ok()
638}
639
640pub fn update_ref(branch: &str, sha: &str) -> Result<()> {
643 status(&["update-ref", &format!("refs/heads/{branch}"), sha])
644 .with_context(|| format!("failed to update {branch} to {sha}"))
645}
646
647pub fn reset_hard() -> Result<()> {
650 status(&["reset", "--hard"]).context("failed to reset the worktree")
651}
652
653pub fn worktree_is_clean() -> Result<bool> {
655 Ok(output(&["status", "--porcelain"])?.is_empty())
656}
657
658pub fn remote_default_branch(remote: &str) -> Option<String> {
660 let reference = format!("refs/remotes/{remote}/HEAD");
661 let full = output(&["symbolic-ref", "--short", &reference]).ok()?;
662 full.strip_prefix(&format!("{remote}/")).map(str::to_owned)
663}
664
665pub fn commits_behind(branch: &str, parent: &str) -> Result<usize> {
668 let range = format!("{branch}..{parent}");
669 let count = output(&["rev-list", "--count", &range])
670 .with_context(|| format!("failed to count commits in {range}"))?;
671 count
672 .trim()
673 .parse()
674 .context("failed to parse rev-list count")
675}
676
677pub fn merge_base(a: &str, b: &str) -> Result<String> {
678 output(&["merge-base", a, b])
679 .with_context(|| format!("failed to find merge base of {a} and {b}"))
680}
681
682pub fn diff_against_head(cached: bool) -> Result<String> {
686 let mut args = vec!["diff", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"];
689 if cached {
690 args.push("--cached");
691 }
692 args.push("HEAD");
693 output(&args).context("failed to diff against HEAD")
694}
695
696pub fn blame_line_shas(file: &str, start: usize, len: usize) -> Result<Vec<String>> {
699 if len == 0 {
700 return Ok(Vec::new());
701 }
702 let range = format!("{start},{}", start + len - 1);
703 let out = output(&[
704 "blame",
705 "HEAD",
706 "-L",
707 &range,
708 "--line-porcelain",
709 "--",
710 file,
711 ])
712 .with_context(|| format!("failed to blame {file}"))?;
713
714 let mut shas = Vec::new();
715 for line in out.lines() {
716 let token = line.split(' ').next().unwrap_or_default();
720 if token.len() == 40
721 && token.bytes().all(|byte| byte.is_ascii_hexdigit())
722 && !shas.iter().any(|seen| seen == token)
723 {
724 shas.push(token.to_owned());
725 }
726 }
727 Ok(shas)
728}
729
730pub fn rev_list(range: &str) -> Result<Vec<String>> {
732 Ok(output(&["rev-list", range])
733 .with_context(|| format!("failed to list commits in {range}"))?
734 .lines()
735 .map(str::to_owned)
736 .collect())
737}
738
739pub fn log_oneline(range: &str) -> Result<Vec<(String, String)>> {
742 Ok(output(&["log", "--format=%h%x09%s", range])
743 .with_context(|| format!("failed to log {range}"))?
744 .lines()
745 .filter_map(|line| {
746 line.split_once('\t')
747 .map(|(sha, subject)| (sha.to_owned(), subject.to_owned()))
748 })
749 .collect())
750}
751
752pub fn commit_subject(sha: &str) -> Result<String> {
754 output(&["show", "--no-patch", "--format=%s", sha])
755 .with_context(|| format!("failed to read subject of {sha}"))
756}
757
758pub fn commit_body(sha: &str) -> Result<String> {
760 output(&["show", "--no-patch", "--format=%b", sha])
761 .with_context(|| format!("failed to read body of {sha}"))
762}
763
764pub fn apply_cached(patch: &str) -> Result<()> {
767 let mut child = Command::new("git")
768 .args(["apply", "--cached", "--unidiff-zero"])
769 .stdin(Stdio::piped())
770 .stdout(Stdio::piped())
771 .stderr(Stdio::piped())
772 .spawn()
773 .context("failed to run git apply")?;
774 {
775 let mut stdin = child.stdin.take().context("git apply has no stdin")?;
776 stdin
777 .write_all(patch.as_bytes())
778 .context("failed to write patch to git apply")?;
779 }
780 let output = child
781 .wait_with_output()
782 .context("failed to run git apply")?;
783 if output.status.success() {
784 Ok(())
785 } else {
786 Err(command_error("git apply", &output.stderr))
787 }
788}
789
790pub fn commit_fixup(sha: &str) -> Result<()> {
793 status(&["commit", "--no-verify", &format!("--fixup={sha}")])
794 .with_context(|| format!("failed to create fixup commit for {sha}"))
795}
796
797pub fn reset_index() -> Result<()> {
799 status(&["reset", "--quiet"]).context("failed to reset the index")
800}
801
802pub fn reset_soft(sha: &str) -> Result<()> {
804 status(&["reset", "--soft", sha]).with_context(|| format!("failed to reset to {sha}"))
805}
806
807pub fn stash_push() -> Result<()> {
809 status(&["stash", "push", "--quiet"]).context("failed to stash changes")
810}
811
812pub fn stash_pop() -> Result<()> {
814 status(&["stash", "pop", "--quiet"]).context("failed to restore stashed changes")
815}
816
817pub fn rebase_autosquash(base: &str, update_refs: bool) -> Result<()> {
820 let mut args = vec!["rebase", "--interactive", "--autosquash"];
821 if update_refs {
822 args.push("--update-refs");
823 }
824 args.push(base);
825
826 let output = Command::new("git")
827 .args(&args)
828 .env("GIT_SEQUENCE_EDITOR", "true")
829 .env("GIT_EDITOR", "true")
830 .output()
831 .context("failed to run git rebase")?;
832 if output.status.success() {
833 Ok(())
834 } else {
835 Err(command_error("git rebase --autosquash", &output.stderr))
836 }
837}
838
839pub fn is_ancestor(ancestor: &str, descendant: &str) -> Result<bool> {
840 Ok(output_codes(
842 &["merge-base", "--is-ancestor", ancestor, descendant],
843 &[1],
844 "git merge-base --is-ancestor",
845 )?
846 .is_some())
847}
848
849pub fn diff_numstat(base: &str, branch: &str) -> Result<(usize, usize)> {
854 let output = output(&["diff", "--numstat", &format!("{base}...{branch}")])?;
855 let mut added = 0;
856 let mut deleted = 0;
857 for line in output.lines() {
858 let mut columns = line.split('\t');
859 added += column_count(columns.next());
860 deleted += column_count(columns.next());
861 }
862 Ok((added, deleted))
863}
864
865fn column_count(column: Option<&str>) -> usize {
868 column
869 .and_then(|value| value.parse::<usize>().ok())
870 .unwrap_or(0)
871}
872
873pub fn supports_rebase_update_refs() -> Result<bool> {
874 let output = Command::new("git")
875 .args(["rebase", "-h"])
876 .stdout(Stdio::piped())
877 .stderr(Stdio::piped())
878 .output()
879 .context("failed to inspect git rebase help")?;
880
881 let help = format!(
882 "{}{}",
883 String::from_utf8_lossy(&output.stdout),
884 String::from_utf8_lossy(&output.stderr)
885 );
886 Ok(help_mentions_update_refs(&help))
887}
888
889fn help_mentions_update_refs(help: &str) -> bool {
892 help.contains("update-refs")
893}
894
895pub fn rebase_in_progress() -> bool {
900 ["rebase-merge", "rebase-apply"].iter().any(|dir| {
901 git_path(dir)
902 .map(|path| std::path::Path::new(&path).exists())
903 .unwrap_or(false)
904 })
905}
906
907pub fn rebase_continue() -> Result<()> {
908 status_passthrough(&["rebase", "--continue"]).context("failed to continue rebase")
910}
911
912pub fn rebase_abort() -> Result<()> {
913 status(&["rebase", "--abort"]).context("failed to abort rebase")
914}
915
916pub fn cherry_pick(commit: &str) -> Result<()> {
920 status(&["cherry-pick", commit]).with_context(|| format!("failed to cherry-pick {commit}"))
921}
922
923pub fn fetch_tracking(remote: &str, branches: &[String]) -> Result<()> {
928 let present = remote_branches_present(remote, branches)?;
929 if present.is_empty() {
930 return Ok(());
931 }
932 let mut args = vec!["fetch", remote];
933 args.extend(present.iter().map(String::as_str));
934 status(&args).with_context(|| format!("failed to fetch branches from {remote}"))
935}
936
937fn remote_branches_present(remote: &str, branches: &[String]) -> Result<Vec<String>> {
941 if branches.is_empty() {
942 return Ok(Vec::new());
943 }
944 let mut args = vec!["ls-remote", "--heads", remote];
945 args.extend(branches.iter().map(String::as_str));
946 let listing =
947 output(&args).with_context(|| format!("failed to query {remote} for branch heads"))?;
948 let present: Vec<&str> = listing
949 .lines()
950 .filter_map(|line| line.split_once('\t'))
951 .filter_map(|(_, name)| name.strip_prefix("refs/heads/"))
952 .collect();
953 Ok(branches
954 .iter()
955 .filter(|branch| present.contains(&branch.as_str()))
956 .cloned()
957 .collect())
958}
959
960pub fn remote_only_commits(branch: &str, tracking: &str) -> Result<Vec<(String, String)>> {
967 let range = format!("{branch}...{tracking}");
968 let mut commits: Vec<(String, String)> = output(&[
969 "log",
970 "--cherry-pick",
971 "--right-only",
972 "--no-merges",
973 "--format=%h%x09%s",
974 &range,
975 ])
976 .with_context(|| format!("failed to list remote-only commits in {range}"))?
977 .lines()
978 .filter_map(|line| {
979 line.split_once('\t')
980 .map(|(sha, subject)| (sha.to_owned(), subject.to_owned()))
981 })
982 .collect();
983 commits.reverse();
985 Ok(commits)
986}
987
988pub fn config_get(key: &str) -> Result<Option<String>> {
989 output_codes(&["config", "--get", key], &[1], "git config --get")
991}
992
993pub fn config_get_bool(key: &str) -> Result<Option<bool>> {
994 let Some(value) = output_codes(
995 &["config", "--type=bool", "--get", key],
996 &[1],
997 "git config --type=bool --get",
998 )?
999 else {
1000 return Ok(None);
1001 };
1002 match value.as_str() {
1003 "true" => Ok(Some(true)),
1004 "false" => Ok(Some(false)),
1005 _ => bail!("git config {key} is not a boolean: {value}"),
1006 }
1007}
1008
1009pub fn config_get_regexp(pattern: &str) -> Result<Vec<(String, String)>> {
1010 let Some(text) = output_codes(
1012 &["config", "--get-regexp", pattern],
1013 &[1],
1014 "git config --get-regexp",
1015 )?
1016 else {
1017 return Ok(Vec::new());
1018 };
1019 Ok(text
1020 .lines()
1021 .filter_map(|line| {
1022 line.split_once(' ')
1023 .map(|(key, value)| (key.to_owned(), value.to_owned()))
1024 })
1025 .collect())
1026}
1027
1028pub fn config_set(key: &str, value: &str) -> Result<()> {
1029 status(&["config", key, value]).with_context(|| format!("failed to set git config {key}"))
1030}
1031
1032pub fn config_unset(key: &str) -> Result<()> {
1033 output_codes(&["config", "--unset", key], &[5], "git config --unset").map(|_| ())
1036}
1037
1038fn output_codes(args: &[&str], ok_empty: &[i32], label: &str) -> Result<Option<String>> {
1043 let output = Command::new("git")
1044 .args(args)
1045 .stdout(Stdio::piped())
1046 .stderr(Stdio::piped())
1047 .output()
1048 .context("failed to run git")?;
1049
1050 match output.status.code() {
1051 Some(0) => Ok(Some(
1052 String::from_utf8_lossy(&output.stdout).trim().to_owned(),
1053 )),
1054 Some(code) if ok_empty.contains(&code) => Ok(None),
1055 _ => Err(command_error(label, &output.stderr)),
1056 }
1057}
1058
1059fn output(args: &[&str]) -> Result<String> {
1060 let output = Command::new("git")
1061 .args(args)
1062 .stdout(Stdio::piped())
1063 .stderr(Stdio::piped())
1064 .output()
1065 .context("failed to run git")?;
1066
1067 if output.status.success() {
1068 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
1069 } else {
1070 Err(command_error("git", &output.stderr))
1071 }
1072}
1073
1074fn output_with_stdin(args: &[&str], input: &str) -> Result<String> {
1077 let mut child = Command::new("git")
1078 .args(args)
1079 .stdin(Stdio::piped())
1080 .stdout(Stdio::piped())
1081 .stderr(Stdio::piped())
1082 .spawn()
1083 .context("failed to run git")?;
1084 {
1085 let mut stdin = child.stdin.take().context("git has no stdin")?;
1086 stdin
1087 .write_all(input.as_bytes())
1088 .context("failed to write to git")?;
1089 }
1090 let output = child.wait_with_output().context("failed to run git")?;
1091 if output.status.success() {
1092 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
1093 } else {
1094 Err(command_error("git", &output.stderr))
1095 }
1096}
1097
1098fn status(args: &[&str]) -> Result<()> {
1102 if verbose() {
1103 return status_passthrough(args);
1104 }
1105
1106 let output = Command::new("git")
1107 .args(args)
1108 .output()
1109 .context("failed to run git")?;
1110
1111 if output.status.success() {
1112 Ok(())
1113 } else {
1114 let _ = std::io::stdout().write_all(&output.stdout);
1115 let _ = std::io::stderr().write_all(&output.stderr);
1116 bail!("git exited with status {}", output.status)
1117 }
1118}
1119
1120fn status_passthrough(args: &[&str]) -> Result<()> {
1123 let status = Command::new("git")
1124 .args(args)
1125 .status()
1126 .context("failed to run git")?;
1127
1128 if status.success() {
1129 Ok(())
1130 } else {
1131 bail!("git exited with status {status}")
1132 }
1133}
1134
1135fn command_error(command: &str, stderr: &[u8]) -> anyhow::Error {
1136 let stderr = String::from_utf8_lossy(stderr).trim().to_owned();
1137 if stderr.is_empty() {
1138 anyhow!("{command} failed")
1139 } else {
1140 anyhow!("{command} failed: {stderr}")
1141 }
1142}
1143
1144#[cfg(test)]
1145mod tests {
1146 use super::*;
1147
1148 const PORCELAIN: &str = "\
1151worktree /repo
1152HEAD f7cff917cf874d0c6ff3108260fda91ac3271baf
1153branch refs/heads/feat/b
1154
1155worktree /repo/../wt-a
1156HEAD 0700673acebfe459d480fa3bd616b2ecf6249fe1
1157branch refs/heads/feat/a
1158
1159worktree /repo/../wt-detached
1160HEAD 25fb6254b4b1cd5cbe2b0d4b1f5b1cf6e7d8a9b0
1161detached
1162";
1163
1164 #[test]
1165 fn worktree_parsing_keeps_branches_and_drops_detached_ones() {
1166 let held = parse_worktree_branches(PORCELAIN, None);
1170 assert_eq!(
1171 held,
1172 vec![
1173 ("feat/b".to_owned(), std::path::PathBuf::from("/repo")),
1174 (
1175 "feat/a".to_owned(),
1176 std::path::PathBuf::from("/repo/../wt-a")
1177 ),
1178 ]
1179 );
1180 }
1181
1182 #[test]
1183 fn worktree_parsing_excludes_the_worktree_we_are_standing_in() {
1184 let held = parse_worktree_branches(PORCELAIN, Some(std::path::Path::new("/repo")));
1187 assert_eq!(
1188 held,
1189 vec![(
1190 "feat/a".to_owned(),
1191 std::path::PathBuf::from("/repo/../wt-a")
1192 )]
1193 );
1194 }
1195
1196 #[test]
1197 fn a_bare_record_does_not_lend_its_path_to_the_next_branch() {
1198 let porcelain = "\
1201worktree /repo/.bare
1202bare
1203
1204worktree /repo/wt-a
1205HEAD 0700673acebfe459d480fa3bd616b2ecf6249fe1
1206branch refs/heads/feat/a
1207";
1208 assert_eq!(
1209 parse_worktree_branches(porcelain, None),
1210 vec![("feat/a".to_owned(), std::path::PathBuf::from("/repo/wt-a"))]
1211 );
1212 }
1213
1214 #[test]
1215 fn branch_names_containing_slashes_survive_the_refs_heads_strip() {
1216 let porcelain = "\
1219worktree /repo/wt
1220HEAD 0700673acebfe459d480fa3bd616b2ecf6249fe1
1221branch refs/heads/feat/deep/nested/name
1222";
1223 assert_eq!(
1224 parse_worktree_branches(porcelain, None)
1225 .first()
1226 .map(|(branch, _)| branch.as_str()),
1227 Some("feat/deep/nested/name")
1228 );
1229 }
1230
1231 #[test]
1232 fn empty_porcelain_holds_nothing() {
1233 assert!(parse_worktree_branches("", None).is_empty());
1234 }
1235
1236 #[test]
1237 fn a_collision_message_quotes_the_path_it_suggests_pasting() {
1238 let message = collision_message("feat/a", "../my worktree", false);
1241 assert!(
1242 message.contains(r#"`cd "../my worktree"`"#),
1243 "cd suggestion is not pasteable: {message}"
1244 );
1245 assert!(
1246 message.contains(r#"`git worktree remove "../my worktree"`"#),
1247 "remove suggestion is not pasteable: {message}"
1248 );
1249 assert!(
1250 message.contains(r#"`git -C "../my worktree" checkout --detach`"#),
1251 "detach suggestion is not pasteable: {message}"
1252 );
1253 }
1254
1255 #[test]
1256 fn a_collision_with_the_main_worktree_never_suggests_removing_it() {
1257 let message = collision_message("feat/a", "../product", true);
1260 assert!(
1261 !message.contains("git worktree remove"),
1262 "the main worktree cannot be removed: {message}"
1263 );
1264 assert!(
1265 message.contains(r#"`git -C "../product" checkout --detach`"#),
1266 "no workable way to free the branch: {message}"
1267 );
1268 }
1269
1270 #[test]
1271 fn the_main_worktree_is_the_first_record_listed() {
1272 let porcelain = "\
1273worktree /repo/product
1274HEAD 1111111111111111111111111111111111111111
1275branch refs/heads/feat/b
1276
1277worktree /repo/product-worktrees/feat/a
1278HEAD 2222222222222222222222222222222222222222
1279branch refs/heads/feat/a
1280";
1281 assert_eq!(
1282 parse_main_worktree(porcelain),
1283 Some(std::path::PathBuf::from("/repo/product"))
1284 );
1285 }
1286
1287 #[test]
1288 fn no_listing_names_no_main_worktree() {
1289 assert_eq!(parse_main_worktree(""), None);
1290 }
1291
1292 #[test]
1293 fn one_worktree_holding_three_branches_is_freed_once() {
1294 let held = [
1295 std::path::Path::new("../wt-a"),
1296 std::path::Path::new("../wt-a"),
1297 std::path::Path::new("../wt-b"),
1298 ];
1299 assert_eq!(
1300 distinct_paths(held),
1301 vec![
1302 std::path::PathBuf::from("../wt-a"),
1303 std::path::PathBuf::from("../wt-b")
1304 ]
1305 );
1306 }
1307
1308 #[test]
1309 fn a_collision_message_names_the_branch_and_where_it_lives() {
1310 let message = collision_message("feat/a", "../wt-a", false);
1311 assert!(message.starts_with("feat/a is checked out in the worktree at ../wt-a"));
1312 }
1313
1314 #[test]
1315 fn a_merge_queue_rejection_is_downgraded_to_the_queued_refs() {
1316 let stderr = "\
1319remote: error: GH006: Protected branch update failed for refs/heads/feat/tf-deploy.
1320remote: - A pull request for this branch has been added to a merge queue. Branches that
1321remote: are queued for merging cannot be updated. To modify this branch, dequeue the
1322remote: associated pull request.
1323To github.com:higharc/product
1324 + 016bb37...3a94024 feat/spa-env -> feat/spa-env (forced update)
1325 ! [remote rejected] feat/tf-deploy -> feat/tf-deploy (protected branch hook declined)
1326error: failed to push some refs to 'github.com:higharc/product'";
1327 assert_eq!(
1328 merge_queue_rejection(stderr),
1329 Some(vec!["feat/tf-deploy".to_owned()])
1330 );
1331 }
1332
1333 #[test]
1334 fn a_stale_lease_rejection_is_not_swallowed_even_with_a_queue_mention() {
1335 let stderr = "\
1338remote: GitHub found 270 vulnerabilities ... merge queue notes ...
1339 ! [rejected] feat/tf-deploy -> feat/tf-deploy (stale info)
1340error: failed to push some refs";
1341 assert_eq!(merge_queue_rejection(stderr), None);
1342 }
1343
1344 #[test]
1345 fn no_queue_mention_is_not_a_queue_rejection() {
1346 let stderr = " ! [remote rejected] feat/x -> feat/x (permission denied)";
1347 assert_eq!(merge_queue_rejection(stderr), None);
1348 }
1349
1350 #[test]
1351 fn landed_branches_drops_only_the_held_ones() {
1352 let attempted = [
1353 "feat/a".to_owned(),
1354 "feat/b".to_owned(),
1355 "feat/c".to_owned(),
1356 ];
1357 assert_eq!(
1360 landed_branches(&attempted, &["feat/b".to_owned()]),
1361 vec!["feat/a".to_owned(), "feat/c".to_owned()]
1362 );
1363 assert_eq!(landed_branches(&attempted, &[]), attempted.to_vec());
1365 assert!(landed_branches(&attempted, &attempted).is_empty());
1367 }
1368
1369 #[test]
1370 fn a_stale_lease_push_names_the_rejected_branch() {
1371 let stderr = "\
1374To github.com:higharc/product
1375 3a94024..d63a2b2 feat/spa-env -> feat/spa-env
1376 ! [rejected] feat/tf-deploy -> feat/tf-deploy (stale info)
1377error: failed to push some refs to 'github.com:higharc/product'";
1378 assert_eq!(
1379 stale_rejection(stderr),
1380 Some(vec!["feat/tf-deploy".to_owned()])
1381 );
1382 }
1383
1384 #[test]
1385 fn a_non_fast_forward_push_is_treated_as_stale() {
1386 let stderr = " ! [rejected] feat/x -> feat/x (non-fast-forward)";
1387 assert_eq!(stale_rejection(stderr), Some(vec!["feat/x".to_owned()]));
1388 }
1389
1390 #[test]
1391 fn an_unrelated_push_failure_is_not_classified_as_stale() {
1392 let stderr = " ! [remote rejected] feat/x -> feat/x (permission denied)";
1394 assert_eq!(stale_rejection(stderr), None);
1395 assert_eq!(stale_rejection("fatal: could not read from remote"), None);
1396 }
1397
1398 #[test]
1399 fn a_mixed_stale_and_non_stale_rejection_is_not_classified_as_stale() {
1400 let stderr = "\
1404 ! [rejected] feat/tf-deploy -> feat/tf-deploy (stale info)
1405 ! [remote rejected] feat/locked -> feat/locked (permission denied)
1406error: failed to push some refs";
1407 assert_eq!(stale_rejection(stderr), None);
1408 }
1409
1410 #[test]
1411 fn help_mentions_update_refs_matches_pre_2_43_spelling() {
1412 assert!(help_mentions_update_refs(
1413 " --update-refs update branches that point to commits that are being rebased"
1414 ));
1415 }
1416
1417 #[test]
1418 fn help_mentions_update_refs_matches_negatable_spelling() {
1419 assert!(help_mentions_update_refs(
1420 " --[no-]update-refs update branches that point to commits that are being rebased"
1421 ));
1422 }
1423
1424 #[test]
1425 fn help_mentions_update_refs_rejects_help_without_the_option() {
1426 assert!(!help_mentions_update_refs(
1427 " --[no-]autosquash move commits that begin with squash!/fixup!"
1428 ));
1429 }
1430
1431 #[test]
1432 fn detection_agrees_with_the_real_git_on_this_machine() {
1433 let probe = Command::new("git")
1436 .args(["rebase", "--update-refs", "-h"])
1437 .stdout(Stdio::piped())
1438 .stderr(Stdio::piped())
1439 .output()
1440 .expect("run git rebase probe");
1441 let probe_text = format!(
1442 "{}{}",
1443 String::from_utf8_lossy(&probe.stdout),
1444 String::from_utf8_lossy(&probe.stderr)
1445 );
1446 let real_support = !probe_text.contains("unknown option");
1447
1448 assert_eq!(
1449 supports_rebase_update_refs().expect("detect support"),
1450 real_support
1451 );
1452 }
1453}