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 in_linked_worktree() -> bool {
145 repo_root().is_ok_and(|root| !is_main_worktree(&root))
146}
147
148pub fn is_main_worktree(path: &std::path::Path) -> bool {
152 main_worktree().is_some_and(|main| same_path(&main, path))
155}
156
157fn main_worktree() -> Option<std::path::PathBuf> {
159 parse_main_worktree(&output(&["worktree", "list", "--porcelain"]).ok()?)
160}
161
162pub fn main_worktree_root() -> Result<std::path::PathBuf> {
168 match main_worktree() {
169 Some(path) => Ok(path),
170 None => repo_root(),
171 }
172}
173
174fn parse_main_worktree(porcelain: &str) -> Option<std::path::PathBuf> {
175 porcelain
176 .lines()
177 .find_map(|line| line.strip_prefix("worktree "))
178 .map(std::path::PathBuf::from)
179}
180
181pub fn detach_command(path: &std::path::Path) -> String {
186 format!("git -C \"{}\" checkout --detach", display_path(path))
189}
190
191pub fn describe_worktree(path: &std::path::Path) -> String {
194 let shown = display_path(path);
195 if is_main_worktree(path) {
196 format!("{shown} (the main worktree)")
197 } else {
198 shown
199 }
200}
201
202pub fn distinct_paths<'a>(
205 paths: impl IntoIterator<Item = &'a std::path::Path>,
206) -> Vec<std::path::PathBuf> {
207 let mut distinct: Vec<std::path::PathBuf> = Vec::new();
208 for path in paths {
209 if !distinct.iter().any(|seen| same_path(seen, path)) {
210 distinct.push(path.to_path_buf());
211 }
212 }
213 distinct
214}
215
216fn parse_worktree_branches(
222 porcelain: &str,
223 current: Option<&std::path::Path>,
224) -> Vec<(String, std::path::PathBuf)> {
225 let current = current.map(canonical);
226 let mut held = Vec::new();
227 let mut path: Option<std::path::PathBuf> = None;
228
229 for line in porcelain.lines() {
230 if let Some(rest) = line.strip_prefix("worktree ") {
231 path = Some(std::path::PathBuf::from(rest));
232 } else if let Some(branch) = line.strip_prefix("branch refs/heads/") {
233 if let Some(path) = path.take()
236 && current.as_deref() != Some(canonical(&path).as_path())
237 {
238 held.push((branch.to_owned(), path));
239 }
240 }
241 }
242
243 held
244}
245
246fn canonical(path: &std::path::Path) -> std::path::PathBuf {
249 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
250}
251
252pub fn same_path(a: &std::path::Path, b: &std::path::Path) -> bool {
257 canonical(a) == canonical(b)
258}
259
260pub fn display_path(path: &std::path::Path) -> String {
265 let Ok(cwd) = std::env::current_dir() else {
266 return path.display().to_string();
267 };
268
269 if let Ok(rest) = path.strip_prefix(&cwd)
270 && rest.components().next().is_some()
271 {
272 return format!("./{}", rest.display());
273 }
274 if let Some(up) = cwd.parent()
275 && let Ok(rest) = path.strip_prefix(up)
276 && rest.components().next().is_some()
277 {
278 return format!("../{}", rest.display());
279 }
280
281 path.display().to_string()
282}
283
284pub fn remote_url(remote: &str) -> Result<Option<String>> {
285 output_codes(&["remote", "get-url", remote], &[2], "git remote get-url")
287}
288
289fn worktree_collision(branch: &str) -> Option<String> {
295 let path = worktree_holding(branch).ok().flatten()?;
296 Some(collision_message(
297 branch,
298 &display_path(&path),
299 is_main_worktree(&path),
300 ))
301}
302
303fn collision_message(branch: &str, shown: &str, is_main: bool) -> String {
308 let mut free = format!("free it with `git -C \"{shown}\" checkout --detach`");
309 if !is_main {
310 free.push_str(&format!(
311 ", or drop that worktree with `git worktree remove \"{shown}\"`"
312 ));
313 }
314 format!(
315 "{branch} is checked out in the worktree at {shown}\n\
316 work on it there with `cd \"{shown}\"`, or {free}"
317 )
318}
319
320pub fn checkout(branch: &str) -> Result<()> {
321 checkout_silently(branch)?;
322 anstream::println!("switched to {}", switched_to(branch));
323 Ok(())
324}
325
326pub fn checkout_silently(branch: &str) -> Result<()> {
329 if let Some(message) = worktree_collision(branch) {
330 bail!(message);
331 }
332
333 status(&["switch", branch]).with_context(|| format!("failed to check out {branch}"))
334}
335
336pub fn switched_to(branch: &str) -> String {
338 crate::style::paint(crate::style::BRANCH, branch)
339}
340
341pub fn create_branch(branch: &str) -> Result<()> {
342 status(&["switch", "-c", branch]).with_context(|| format!("failed to create branch {branch}"))
343}
344
345pub fn create_branch_at(branch: &str, sha: &str) -> Result<()> {
348 status(&["branch", branch, sha])
349 .with_context(|| format!("failed to create branch {branch} at {sha}"))
350}
351
352pub fn delete_branch(branch: &str) -> Result<()> {
356 status(&["branch", "-D", branch]).with_context(|| format!("failed to delete branch {branch}"))
357}
358
359pub fn rename_branch(old: &str, new: &str) -> Result<()> {
361 status(&["branch", "-m", old, new]).with_context(|| format!("failed to rename {old} to {new}"))
362}
363
364pub fn fetch_branch(remote: &str, branch: &str) -> Result<()> {
366 let refspec = format!("{branch}:{branch}");
367 status(&["fetch", remote, &refspec])
368 .with_context(|| format!("failed to fetch {branch} from {remote}"))
369}
370
371pub fn pull_ff_only() -> Result<()> {
372 status(&["pull", "--ff-only"]).context("failed to fast-forward from the remote")
373}
374
375pub fn push_force_with_lease(remote: &str, branches: &[String]) -> Result<Vec<String>> {
380 let mut args = vec!["push", "--force-with-lease", remote];
381 args.extend(branches.iter().map(String::as_str));
382
383 run_lease_push(&args, remote, branches)
384}
385
386fn run_lease_push(args: &[&str], remote: &str, branches: &[String]) -> Result<Vec<String>> {
402 if verbose() {
406 status_passthrough(args).with_context(|| format!("failed to push branches to {remote}"))?;
407 return Ok(branches.to_vec());
408 }
409
410 let output = Command::new("git")
411 .args(args)
412 .output()
413 .context("failed to run git")?;
414 if output.status.success() {
415 return Ok(branches.to_vec());
416 }
417
418 let stderr = String::from_utf8_lossy(&output.stderr);
426 if let Some(queued) = merge_queue_rejection(&stderr) {
427 anstream::eprintln!(
428 "{}",
429 crate::style::warn(&format!(
430 "{} {} in a merge queue and was not updated (dequeue its review to push it)",
431 queued.join(", "),
432 if queued.len() == 1 { "is" } else { "are" },
433 ))
434 );
435 return Ok(landed_branches(branches, &queued));
436 }
437
438 if let Some(stale) = stale_rejection(&stderr) {
439 bail!(
442 "could not push {} to {remote}: the remote has moved on \
443 (a branch in the stack was likely merged or updated upstream)\n\
444 run `git stk sync` to reconcile your local stack with the remote, then try again",
445 stale.join(", "),
446 );
447 }
448
449 let _ = std::io::stdout().write_all(&output.stdout);
450 let _ = std::io::stderr().write_all(&output.stderr);
451 bail!(
452 "failed to push branches to {remote}: git exited with status {}",
453 output.status
454 )
455}
456
457fn landed_branches(attempted: &[String], held: &[String]) -> Vec<String> {
460 attempted
461 .iter()
462 .filter(|branch| !held.iter().any(|name| name == *branch))
463 .cloned()
464 .collect()
465}
466
467fn merge_queue_rejection(stderr: &str) -> Option<Vec<String>> {
474 let lower = stderr.to_lowercase();
475 let mentions_queue = lower.contains("merge queue") || lower.contains("queued for merging");
476 if !mentions_queue {
477 return None;
478 }
479 if ["stale info", "non-fast-forward", "fetch first"]
482 .iter()
483 .any(|marker| lower.contains(marker))
484 {
485 return None;
486 }
487 let rejected = rejected_refs(stderr);
488 if rejected.is_empty() {
489 None
490 } else {
491 Some(rejected)
492 }
493}
494
495fn stale_rejection(stderr: &str) -> Option<Vec<String>> {
507 let rejected: Vec<&str> = stderr
508 .lines()
509 .filter(|line| line.contains("[remote rejected]") || line.contains("[rejected]"))
510 .collect();
511 if rejected.is_empty() || !rejected.iter().all(|line| line_is_stale(line)) {
512 return None;
513 }
514 let names: Vec<String> = rejected
515 .iter()
516 .filter_map(|line| rejected_ref_name(line))
517 .collect();
518 if names.is_empty() { None } else { Some(names) }
519}
520
521fn line_is_stale(line: &str) -> bool {
525 let lower = line.to_lowercase();
526 ["stale info", "non-fast-forward", "fetch first"]
527 .iter()
528 .any(|marker| lower.contains(marker))
529}
530
531fn rejected_ref_name(line: &str) -> Option<String> {
534 let after = line.split("-> ").nth(1)?;
535 Some(after.split_whitespace().next()?.to_owned())
536}
537
538fn rejected_refs(stderr: &str) -> Vec<String> {
541 stderr
542 .lines()
543 .filter(|line| line.contains("[remote rejected]") || line.contains("[rejected]"))
544 .filter_map(rejected_ref_name)
545 .collect()
546}
547
548pub fn push_set_upstream_force_with_lease(remote: &str, branches: &[String]) -> Result<()> {
551 let mut args = vec!["push", "--set-upstream", "--force-with-lease", remote];
552 args.extend(branches.iter().map(String::as_str));
553
554 run_lease_push(&args, remote, branches)?;
557 Ok(())
558}
559
560pub fn write_blob_ref(reference: &str, file: &str, content: &str) -> Result<()> {
564 let blob = output_with_stdin(&["hash-object", "-w", "--stdin"], content)
565 .context("failed to hash stack metadata")?;
566 let tree = output_with_stdin(&["mktree"], &format!("100644 blob {blob}\t{file}\n"))
567 .context("failed to write stack metadata tree")?;
568 let commit = output(&["commit-tree", &tree, "-m", "git-stk stack metadata"])
569 .context("failed to commit stack metadata")?;
570 status(&["update-ref", reference, &commit])
571 .with_context(|| format!("failed to update {reference}"))
572}
573
574pub fn push_ref(remote: &str, reference: &str) -> Result<()> {
577 status(&[
578 "push",
579 "--force",
580 remote,
581 &format!("{reference}:{reference}"),
582 ])
583 .with_context(|| format!("failed to push {reference} to {remote}"))
584}
585
586pub fn fetch_ref(remote: &str, reference: &str) -> Result<()> {
588 status(&["fetch", remote, &format!("+{reference}:{reference}")])
589 .with_context(|| format!("failed to fetch {reference} from {remote}"))
590}
591
592pub fn read_ref_file(reference: &str, file: &str) -> Result<Option<String>> {
595 let output = Command::new("git")
596 .args(["cat-file", "blob", &format!("{reference}:{file}")])
597 .stdout(Stdio::piped())
598 .stderr(Stdio::piped())
599 .output()
600 .context("failed to run git cat-file")?;
601 if output.status.success() {
602 Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()))
603 } else {
604 Ok(None)
605 }
606}
607
608pub fn rebase(parent: &str, branch: &str, update_refs: bool) -> Result<()> {
609 if let Some(message) = worktree_collision(branch) {
610 bail!(message);
611 }
612 let mut args = vec!["rebase"];
613 if update_refs {
614 args.push("--update-refs");
615 }
616 args.extend([parent, branch]);
617
618 status(&args).with_context(|| format!("failed to rebase {branch} onto {parent}"))
619}
620
621pub fn rebase_onto(parent: &str, base: &str, branch: &str, update_refs: bool) -> Result<()> {
625 if let Some(message) = worktree_collision(branch) {
626 bail!(message);
627 }
628 let mut args = vec!["rebase"];
629 if update_refs {
630 args.push("--update-refs");
631 }
632 args.extend(["--onto", parent, base, branch]);
633
634 status(&args).with_context(|| format!("failed to rebase {branch} onto {parent} from {base}"))
635}
636
637pub fn rev_parse(rev: &str) -> Result<String> {
638 let spec = format!("{rev}^{{commit}}");
639 output(&["rev-parse", "--verify", &spec]).with_context(|| format!("failed to resolve {rev}"))
640}
641
642pub fn branch_sha(branch: &str) -> Option<String> {
644 rev_parse(branch).ok()
645}
646
647pub fn update_ref(branch: &str, sha: &str) -> Result<()> {
650 status(&["update-ref", &format!("refs/heads/{branch}"), sha])
651 .with_context(|| format!("failed to update {branch} to {sha}"))
652}
653
654pub fn reset_hard() -> Result<()> {
657 status(&["reset", "--hard"]).context("failed to reset the worktree")
658}
659
660pub fn worktree_is_clean() -> Result<bool> {
662 Ok(output(&["status", "--porcelain"])?.is_empty())
663}
664
665pub fn remote_default_branch(remote: &str) -> Option<String> {
667 let reference = format!("refs/remotes/{remote}/HEAD");
668 let full = output(&["symbolic-ref", "--short", &reference]).ok()?;
669 full.strip_prefix(&format!("{remote}/")).map(str::to_owned)
670}
671
672pub fn commits_behind(branch: &str, parent: &str) -> Result<usize> {
675 let range = format!("{branch}..{parent}");
676 let count = output(&["rev-list", "--count", &range])
677 .with_context(|| format!("failed to count commits in {range}"))?;
678 count
679 .trim()
680 .parse()
681 .context("failed to parse rev-list count")
682}
683
684pub fn merge_base(a: &str, b: &str) -> Result<String> {
685 output(&["merge-base", a, b])
686 .with_context(|| format!("failed to find merge base of {a} and {b}"))
687}
688
689pub fn diff_against_head(cached: bool) -> Result<String> {
693 let mut args = vec!["diff", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"];
696 if cached {
697 args.push("--cached");
698 }
699 args.push("HEAD");
700 output(&args).context("failed to diff against HEAD")
701}
702
703pub fn blame_line_shas(file: &str, start: usize, len: usize) -> Result<Vec<String>> {
706 if len == 0 {
707 return Ok(Vec::new());
708 }
709 let range = format!("{start},{}", start + len - 1);
710 let out = output(&[
711 "blame",
712 "HEAD",
713 "-L",
714 &range,
715 "--line-porcelain",
716 "--",
717 file,
718 ])
719 .with_context(|| format!("failed to blame {file}"))?;
720
721 let mut shas = Vec::new();
722 for line in out.lines() {
723 let token = line.split(' ').next().unwrap_or_default();
727 if token.len() == 40
728 && token.bytes().all(|byte| byte.is_ascii_hexdigit())
729 && !shas.iter().any(|seen| seen == token)
730 {
731 shas.push(token.to_owned());
732 }
733 }
734 Ok(shas)
735}
736
737pub fn rev_list(range: &str) -> Result<Vec<String>> {
739 Ok(output(&["rev-list", range])
740 .with_context(|| format!("failed to list commits in {range}"))?
741 .lines()
742 .map(str::to_owned)
743 .collect())
744}
745
746pub fn log_oneline(range: &str) -> Result<Vec<(String, String)>> {
749 Ok(output(&["log", "--format=%h%x09%s", range])
750 .with_context(|| format!("failed to log {range}"))?
751 .lines()
752 .filter_map(|line| {
753 line.split_once('\t')
754 .map(|(sha, subject)| (sha.to_owned(), subject.to_owned()))
755 })
756 .collect())
757}
758
759pub fn commit_subject(sha: &str) -> Result<String> {
761 output(&["show", "--no-patch", "--format=%s", sha])
762 .with_context(|| format!("failed to read subject of {sha}"))
763}
764
765pub fn commit_body(sha: &str) -> Result<String> {
767 output(&["show", "--no-patch", "--format=%b", sha])
768 .with_context(|| format!("failed to read body of {sha}"))
769}
770
771pub fn apply_cached(patch: &str) -> Result<()> {
774 let mut child = Command::new("git")
775 .args(["apply", "--cached", "--unidiff-zero"])
776 .stdin(Stdio::piped())
777 .stdout(Stdio::piped())
778 .stderr(Stdio::piped())
779 .spawn()
780 .context("failed to run git apply")?;
781 {
782 let mut stdin = child.stdin.take().context("git apply has no stdin")?;
783 stdin
784 .write_all(patch.as_bytes())
785 .context("failed to write patch to git apply")?;
786 }
787 let output = child
788 .wait_with_output()
789 .context("failed to run git apply")?;
790 if output.status.success() {
791 Ok(())
792 } else {
793 Err(command_error("git apply", &output.stderr))
794 }
795}
796
797pub fn commit_fixup(sha: &str) -> Result<()> {
800 status(&["commit", "--no-verify", &format!("--fixup={sha}")])
801 .with_context(|| format!("failed to create fixup commit for {sha}"))
802}
803
804pub fn reset_index() -> Result<()> {
806 status(&["reset", "--quiet"]).context("failed to reset the index")
807}
808
809pub fn reset_soft(sha: &str) -> Result<()> {
811 status(&["reset", "--soft", sha]).with_context(|| format!("failed to reset to {sha}"))
812}
813
814pub fn stash_push() -> Result<()> {
816 status(&["stash", "push", "--quiet"]).context("failed to stash changes")
817}
818
819pub fn stash_pop() -> Result<()> {
821 status(&["stash", "pop", "--quiet"]).context("failed to restore stashed changes")
822}
823
824pub fn rebase_autosquash(base: &str, update_refs: bool) -> Result<()> {
827 let mut args = vec!["rebase", "--interactive", "--autosquash"];
828 if update_refs {
829 args.push("--update-refs");
830 }
831 args.push(base);
832
833 let output = Command::new("git")
834 .args(&args)
835 .env("GIT_SEQUENCE_EDITOR", "true")
836 .env("GIT_EDITOR", "true")
837 .output()
838 .context("failed to run git rebase")?;
839 if output.status.success() {
840 Ok(())
841 } else {
842 Err(command_error("git rebase --autosquash", &output.stderr))
843 }
844}
845
846pub fn is_ancestor(ancestor: &str, descendant: &str) -> Result<bool> {
847 Ok(output_codes(
849 &["merge-base", "--is-ancestor", ancestor, descendant],
850 &[1],
851 "git merge-base --is-ancestor",
852 )?
853 .is_some())
854}
855
856pub fn diff_numstat(base: &str, branch: &str) -> Result<(usize, usize)> {
861 let output = output(&["diff", "--numstat", &format!("{base}...{branch}")])?;
862 let mut added = 0;
863 let mut deleted = 0;
864 for line in output.lines() {
865 let mut columns = line.split('\t');
866 added += column_count(columns.next());
867 deleted += column_count(columns.next());
868 }
869 Ok((added, deleted))
870}
871
872fn column_count(column: Option<&str>) -> usize {
875 column
876 .and_then(|value| value.parse::<usize>().ok())
877 .unwrap_or(0)
878}
879
880pub fn supports_rebase_update_refs() -> Result<bool> {
881 let output = Command::new("git")
882 .args(["rebase", "-h"])
883 .stdout(Stdio::piped())
884 .stderr(Stdio::piped())
885 .output()
886 .context("failed to inspect git rebase help")?;
887
888 let help = format!(
889 "{}{}",
890 String::from_utf8_lossy(&output.stdout),
891 String::from_utf8_lossy(&output.stderr)
892 );
893 Ok(help_mentions_update_refs(&help))
894}
895
896fn help_mentions_update_refs(help: &str) -> bool {
899 help.contains("update-refs")
900}
901
902pub fn rebase_in_progress() -> bool {
907 ["rebase-merge", "rebase-apply"].iter().any(|dir| {
908 git_path(dir)
909 .map(|path| std::path::Path::new(&path).exists())
910 .unwrap_or(false)
911 })
912}
913
914pub fn rebase_continue() -> Result<()> {
915 status_passthrough(&["rebase", "--continue"]).context("failed to continue rebase")
917}
918
919pub fn rebase_abort() -> Result<()> {
920 status(&["rebase", "--abort"]).context("failed to abort rebase")
921}
922
923pub fn cherry_pick(commit: &str) -> Result<()> {
927 status(&["cherry-pick", commit]).with_context(|| format!("failed to cherry-pick {commit}"))
928}
929
930pub fn fetch_tracking(remote: &str, branches: &[String]) -> Result<()> {
935 let present = remote_branches_present(remote, branches)?;
936 if present.is_empty() {
937 return Ok(());
938 }
939 let mut args = vec!["fetch", remote];
940 args.extend(present.iter().map(String::as_str));
941 status(&args).with_context(|| format!("failed to fetch branches from {remote}"))
942}
943
944fn remote_branches_present(remote: &str, branches: &[String]) -> Result<Vec<String>> {
948 if branches.is_empty() {
949 return Ok(Vec::new());
950 }
951 let mut args = vec!["ls-remote", "--heads", remote];
952 args.extend(branches.iter().map(String::as_str));
953 let listing =
954 output(&args).with_context(|| format!("failed to query {remote} for branch heads"))?;
955 let present: Vec<&str> = listing
956 .lines()
957 .filter_map(|line| line.split_once('\t'))
958 .filter_map(|(_, name)| name.strip_prefix("refs/heads/"))
959 .collect();
960 Ok(branches
961 .iter()
962 .filter(|branch| present.contains(&branch.as_str()))
963 .cloned()
964 .collect())
965}
966
967pub fn remote_only_commits(branch: &str, tracking: &str) -> Result<Vec<(String, String)>> {
974 let range = format!("{branch}...{tracking}");
975 let mut commits: Vec<(String, String)> = output(&[
976 "log",
977 "--cherry-pick",
978 "--right-only",
979 "--no-merges",
980 "--format=%h%x09%s",
981 &range,
982 ])
983 .with_context(|| format!("failed to list remote-only commits in {range}"))?
984 .lines()
985 .filter_map(|line| {
986 line.split_once('\t')
987 .map(|(sha, subject)| (sha.to_owned(), subject.to_owned()))
988 })
989 .collect();
990 commits.reverse();
992 Ok(commits)
993}
994
995pub fn config_get(key: &str) -> Result<Option<String>> {
996 output_codes(&["config", "--get", key], &[1], "git config --get")
998}
999
1000pub fn config_get_bool(key: &str) -> Result<Option<bool>> {
1001 let Some(value) = output_codes(
1002 &["config", "--type=bool", "--get", key],
1003 &[1],
1004 "git config --type=bool --get",
1005 )?
1006 else {
1007 return Ok(None);
1008 };
1009 match value.as_str() {
1010 "true" => Ok(Some(true)),
1011 "false" => Ok(Some(false)),
1012 _ => bail!("git config {key} is not a boolean: {value}"),
1013 }
1014}
1015
1016pub fn config_get_regexp(pattern: &str) -> Result<Vec<(String, String)>> {
1017 let Some(text) = output_codes(
1019 &["config", "--get-regexp", pattern],
1020 &[1],
1021 "git config --get-regexp",
1022 )?
1023 else {
1024 return Ok(Vec::new());
1025 };
1026 Ok(text
1027 .lines()
1028 .filter_map(|line| {
1029 line.split_once(' ')
1030 .map(|(key, value)| (key.to_owned(), value.to_owned()))
1031 })
1032 .collect())
1033}
1034
1035pub fn config_set(key: &str, value: &str) -> Result<()> {
1036 status(&["config", key, value]).with_context(|| format!("failed to set git config {key}"))
1037}
1038
1039pub fn config_unset(key: &str) -> Result<()> {
1040 output_codes(&["config", "--unset", key], &[5], "git config --unset").map(|_| ())
1043}
1044
1045fn output_codes(args: &[&str], ok_empty: &[i32], label: &str) -> Result<Option<String>> {
1050 let output = Command::new("git")
1051 .args(args)
1052 .stdout(Stdio::piped())
1053 .stderr(Stdio::piped())
1054 .output()
1055 .context("failed to run git")?;
1056
1057 match output.status.code() {
1058 Some(0) => Ok(Some(
1059 String::from_utf8_lossy(&output.stdout).trim().to_owned(),
1060 )),
1061 Some(code) if ok_empty.contains(&code) => Ok(None),
1062 _ => Err(command_error(label, &output.stderr)),
1063 }
1064}
1065
1066fn output(args: &[&str]) -> Result<String> {
1067 let output = Command::new("git")
1068 .args(args)
1069 .stdout(Stdio::piped())
1070 .stderr(Stdio::piped())
1071 .output()
1072 .context("failed to run git")?;
1073
1074 if output.status.success() {
1075 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
1076 } else {
1077 Err(command_error("git", &output.stderr))
1078 }
1079}
1080
1081fn output_with_stdin(args: &[&str], input: &str) -> Result<String> {
1084 let mut child = Command::new("git")
1085 .args(args)
1086 .stdin(Stdio::piped())
1087 .stdout(Stdio::piped())
1088 .stderr(Stdio::piped())
1089 .spawn()
1090 .context("failed to run git")?;
1091 {
1092 let mut stdin = child.stdin.take().context("git has no stdin")?;
1093 stdin
1094 .write_all(input.as_bytes())
1095 .context("failed to write to git")?;
1096 }
1097 let output = child.wait_with_output().context("failed to run git")?;
1098 if output.status.success() {
1099 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
1100 } else {
1101 Err(command_error("git", &output.stderr))
1102 }
1103}
1104
1105fn status(args: &[&str]) -> Result<()> {
1109 if verbose() {
1110 return status_passthrough(args);
1111 }
1112
1113 let output = Command::new("git")
1114 .args(args)
1115 .output()
1116 .context("failed to run git")?;
1117
1118 if output.status.success() {
1119 Ok(())
1120 } else {
1121 let _ = std::io::stdout().write_all(&output.stdout);
1122 let _ = std::io::stderr().write_all(&output.stderr);
1123 bail!("git exited with status {}", output.status)
1124 }
1125}
1126
1127fn status_passthrough(args: &[&str]) -> Result<()> {
1130 let status = Command::new("git")
1131 .args(args)
1132 .status()
1133 .context("failed to run git")?;
1134
1135 if status.success() {
1136 Ok(())
1137 } else {
1138 bail!("git exited with status {status}")
1139 }
1140}
1141
1142fn command_error(command: &str, stderr: &[u8]) -> anyhow::Error {
1143 let stderr = String::from_utf8_lossy(stderr).trim().to_owned();
1144 if stderr.is_empty() {
1145 anyhow!("{command} failed")
1146 } else {
1147 anyhow!("{command} failed: {stderr}")
1148 }
1149}
1150
1151#[cfg(test)]
1152mod tests {
1153 use super::*;
1154
1155 const PORCELAIN: &str = "\
1158worktree /repo
1159HEAD f7cff917cf874d0c6ff3108260fda91ac3271baf
1160branch refs/heads/feat/b
1161
1162worktree /repo/../wt-a
1163HEAD 0700673acebfe459d480fa3bd616b2ecf6249fe1
1164branch refs/heads/feat/a
1165
1166worktree /repo/../wt-detached
1167HEAD 25fb6254b4b1cd5cbe2b0d4b1f5b1cf6e7d8a9b0
1168detached
1169";
1170
1171 #[test]
1172 fn worktree_parsing_keeps_branches_and_drops_detached_ones() {
1173 let held = parse_worktree_branches(PORCELAIN, None);
1177 assert_eq!(
1178 held,
1179 vec![
1180 ("feat/b".to_owned(), std::path::PathBuf::from("/repo")),
1181 (
1182 "feat/a".to_owned(),
1183 std::path::PathBuf::from("/repo/../wt-a")
1184 ),
1185 ]
1186 );
1187 }
1188
1189 #[test]
1190 fn worktree_parsing_excludes_the_worktree_we_are_standing_in() {
1191 let held = parse_worktree_branches(PORCELAIN, Some(std::path::Path::new("/repo")));
1194 assert_eq!(
1195 held,
1196 vec![(
1197 "feat/a".to_owned(),
1198 std::path::PathBuf::from("/repo/../wt-a")
1199 )]
1200 );
1201 }
1202
1203 #[test]
1204 fn a_bare_record_does_not_lend_its_path_to_the_next_branch() {
1205 let porcelain = "\
1208worktree /repo/.bare
1209bare
1210
1211worktree /repo/wt-a
1212HEAD 0700673acebfe459d480fa3bd616b2ecf6249fe1
1213branch refs/heads/feat/a
1214";
1215 assert_eq!(
1216 parse_worktree_branches(porcelain, None),
1217 vec![("feat/a".to_owned(), std::path::PathBuf::from("/repo/wt-a"))]
1218 );
1219 }
1220
1221 #[test]
1222 fn branch_names_containing_slashes_survive_the_refs_heads_strip() {
1223 let porcelain = "\
1226worktree /repo/wt
1227HEAD 0700673acebfe459d480fa3bd616b2ecf6249fe1
1228branch refs/heads/feat/deep/nested/name
1229";
1230 assert_eq!(
1231 parse_worktree_branches(porcelain, None)
1232 .first()
1233 .map(|(branch, _)| branch.as_str()),
1234 Some("feat/deep/nested/name")
1235 );
1236 }
1237
1238 #[test]
1239 fn empty_porcelain_holds_nothing() {
1240 assert!(parse_worktree_branches("", None).is_empty());
1241 }
1242
1243 #[test]
1244 fn a_collision_message_quotes_the_path_it_suggests_pasting() {
1245 let message = collision_message("feat/a", "../my worktree", false);
1248 assert!(
1249 message.contains(r#"`cd "../my worktree"`"#),
1250 "cd suggestion is not pasteable: {message}"
1251 );
1252 assert!(
1253 message.contains(r#"`git worktree remove "../my worktree"`"#),
1254 "remove suggestion is not pasteable: {message}"
1255 );
1256 assert!(
1257 message.contains(r#"`git -C "../my worktree" checkout --detach`"#),
1258 "detach suggestion is not pasteable: {message}"
1259 );
1260 }
1261
1262 #[test]
1263 fn a_collision_with_the_main_worktree_never_suggests_removing_it() {
1264 let message = collision_message("feat/a", "../product", true);
1267 assert!(
1268 !message.contains("git worktree remove"),
1269 "the main worktree cannot be removed: {message}"
1270 );
1271 assert!(
1272 message.contains(r#"`git -C "../product" checkout --detach`"#),
1273 "no workable way to free the branch: {message}"
1274 );
1275 }
1276
1277 #[test]
1278 fn the_main_worktree_is_the_first_record_listed() {
1279 let porcelain = "\
1280worktree /repo/product
1281HEAD 1111111111111111111111111111111111111111
1282branch refs/heads/feat/b
1283
1284worktree /repo/product-worktrees/feat/a
1285HEAD 2222222222222222222222222222222222222222
1286branch refs/heads/feat/a
1287";
1288 assert_eq!(
1289 parse_main_worktree(porcelain),
1290 Some(std::path::PathBuf::from("/repo/product"))
1291 );
1292 }
1293
1294 #[test]
1295 fn no_listing_names_no_main_worktree() {
1296 assert_eq!(parse_main_worktree(""), None);
1297 }
1298
1299 #[test]
1300 fn one_worktree_holding_three_branches_is_freed_once() {
1301 let held = [
1302 std::path::Path::new("../wt-a"),
1303 std::path::Path::new("../wt-a"),
1304 std::path::Path::new("../wt-b"),
1305 ];
1306 assert_eq!(
1307 distinct_paths(held),
1308 vec![
1309 std::path::PathBuf::from("../wt-a"),
1310 std::path::PathBuf::from("../wt-b")
1311 ]
1312 );
1313 }
1314
1315 #[test]
1316 fn a_collision_message_names_the_branch_and_where_it_lives() {
1317 let message = collision_message("feat/a", "../wt-a", false);
1318 assert!(message.starts_with("feat/a is checked out in the worktree at ../wt-a"));
1319 }
1320
1321 #[test]
1322 fn a_merge_queue_rejection_is_downgraded_to_the_queued_refs() {
1323 let stderr = "\
1326remote: error: GH006: Protected branch update failed for refs/heads/feat/tf-deploy.
1327remote: - A pull request for this branch has been added to a merge queue. Branches that
1328remote: are queued for merging cannot be updated. To modify this branch, dequeue the
1329remote: associated pull request.
1330To github.com:higharc/product
1331 + 016bb37...3a94024 feat/spa-env -> feat/spa-env (forced update)
1332 ! [remote rejected] feat/tf-deploy -> feat/tf-deploy (protected branch hook declined)
1333error: failed to push some refs to 'github.com:higharc/product'";
1334 assert_eq!(
1335 merge_queue_rejection(stderr),
1336 Some(vec!["feat/tf-deploy".to_owned()])
1337 );
1338 }
1339
1340 #[test]
1341 fn a_stale_lease_rejection_is_not_swallowed_even_with_a_queue_mention() {
1342 let stderr = "\
1345remote: GitHub found 270 vulnerabilities ... merge queue notes ...
1346 ! [rejected] feat/tf-deploy -> feat/tf-deploy (stale info)
1347error: failed to push some refs";
1348 assert_eq!(merge_queue_rejection(stderr), None);
1349 }
1350
1351 #[test]
1352 fn no_queue_mention_is_not_a_queue_rejection() {
1353 let stderr = " ! [remote rejected] feat/x -> feat/x (permission denied)";
1354 assert_eq!(merge_queue_rejection(stderr), None);
1355 }
1356
1357 #[test]
1358 fn landed_branches_drops_only_the_held_ones() {
1359 let attempted = [
1360 "feat/a".to_owned(),
1361 "feat/b".to_owned(),
1362 "feat/c".to_owned(),
1363 ];
1364 assert_eq!(
1367 landed_branches(&attempted, &["feat/b".to_owned()]),
1368 vec!["feat/a".to_owned(), "feat/c".to_owned()]
1369 );
1370 assert_eq!(landed_branches(&attempted, &[]), attempted.to_vec());
1372 assert!(landed_branches(&attempted, &attempted).is_empty());
1374 }
1375
1376 #[test]
1377 fn a_stale_lease_push_names_the_rejected_branch() {
1378 let stderr = "\
1381To github.com:higharc/product
1382 3a94024..d63a2b2 feat/spa-env -> feat/spa-env
1383 ! [rejected] feat/tf-deploy -> feat/tf-deploy (stale info)
1384error: failed to push some refs to 'github.com:higharc/product'";
1385 assert_eq!(
1386 stale_rejection(stderr),
1387 Some(vec!["feat/tf-deploy".to_owned()])
1388 );
1389 }
1390
1391 #[test]
1392 fn a_non_fast_forward_push_is_treated_as_stale() {
1393 let stderr = " ! [rejected] feat/x -> feat/x (non-fast-forward)";
1394 assert_eq!(stale_rejection(stderr), Some(vec!["feat/x".to_owned()]));
1395 }
1396
1397 #[test]
1398 fn an_unrelated_push_failure_is_not_classified_as_stale() {
1399 let stderr = " ! [remote rejected] feat/x -> feat/x (permission denied)";
1401 assert_eq!(stale_rejection(stderr), None);
1402 assert_eq!(stale_rejection("fatal: could not read from remote"), None);
1403 }
1404
1405 #[test]
1406 fn a_mixed_stale_and_non_stale_rejection_is_not_classified_as_stale() {
1407 let stderr = "\
1411 ! [rejected] feat/tf-deploy -> feat/tf-deploy (stale info)
1412 ! [remote rejected] feat/locked -> feat/locked (permission denied)
1413error: failed to push some refs";
1414 assert_eq!(stale_rejection(stderr), None);
1415 }
1416
1417 #[test]
1418 fn help_mentions_update_refs_matches_pre_2_43_spelling() {
1419 assert!(help_mentions_update_refs(
1420 " --update-refs update branches that point to commits that are being rebased"
1421 ));
1422 }
1423
1424 #[test]
1425 fn help_mentions_update_refs_matches_negatable_spelling() {
1426 assert!(help_mentions_update_refs(
1427 " --[no-]update-refs update branches that point to commits that are being rebased"
1428 ));
1429 }
1430
1431 #[test]
1432 fn help_mentions_update_refs_rejects_help_without_the_option() {
1433 assert!(!help_mentions_update_refs(
1434 " --[no-]autosquash move commits that begin with squash!/fixup!"
1435 ));
1436 }
1437
1438 #[test]
1439 fn detection_agrees_with_the_real_git_on_this_machine() {
1440 let probe = Command::new("git")
1443 .args(["rebase", "--update-refs", "-h"])
1444 .stdout(Stdio::piped())
1445 .stderr(Stdio::piped())
1446 .output()
1447 .expect("run git rebase probe");
1448 let probe_text = format!(
1449 "{}{}",
1450 String::from_utf8_lossy(&probe.stdout),
1451 String::from_utf8_lossy(&probe.stderr)
1452 );
1453 let real_support = !probe_text.contains("unknown option");
1454
1455 assert_eq!(
1456 supports_rebase_update_refs().expect("detect support"),
1457 real_support
1458 );
1459 }
1460}