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
141fn parse_worktree_branches(
147 porcelain: &str,
148 current: Option<&std::path::Path>,
149) -> Vec<(String, std::path::PathBuf)> {
150 let current = current.map(canonical);
151 let mut held = Vec::new();
152 let mut path: Option<std::path::PathBuf> = None;
153
154 for line in porcelain.lines() {
155 if let Some(rest) = line.strip_prefix("worktree ") {
156 path = Some(std::path::PathBuf::from(rest));
157 } else if let Some(branch) = line.strip_prefix("branch refs/heads/") {
158 if let Some(path) = path.take()
161 && current.as_deref() != Some(canonical(&path).as_path())
162 {
163 held.push((branch.to_owned(), path));
164 }
165 }
166 }
167
168 held
169}
170
171fn canonical(path: &std::path::Path) -> std::path::PathBuf {
174 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
175}
176
177pub fn same_path(a: &std::path::Path, b: &std::path::Path) -> bool {
182 canonical(a) == canonical(b)
183}
184
185pub fn display_path(path: &std::path::Path) -> String {
190 let Ok(cwd) = std::env::current_dir() else {
191 return path.display().to_string();
192 };
193
194 if let Ok(rest) = path.strip_prefix(&cwd)
195 && rest.components().next().is_some()
196 {
197 return format!("./{}", rest.display());
198 }
199 if let Some(up) = cwd.parent()
200 && let Ok(rest) = path.strip_prefix(up)
201 && rest.components().next().is_some()
202 {
203 return format!("../{}", rest.display());
204 }
205
206 path.display().to_string()
207}
208
209pub fn remote_url(remote: &str) -> Result<Option<String>> {
210 output_codes(&["remote", "get-url", remote], &[2], "git remote get-url")
212}
213
214fn worktree_collision(branch: &str) -> Option<String> {
220 let path = worktree_holding(branch).ok().flatten()?;
221 Some(collision_message(branch, &display_path(&path)))
222}
223
224fn collision_message(branch: &str, shown: &str) -> String {
228 format!(
229 "{branch} is checked out in the worktree at {shown}\n\
230 work on it there with `cd \"{shown}\"`, or free it with \
231 `git worktree remove \"{shown}\"`"
232 )
233}
234
235pub fn checkout(branch: &str) -> Result<()> {
236 checkout_silently(branch)?;
237 anstream::println!("switched to {}", switched_to(branch));
238 Ok(())
239}
240
241pub fn checkout_silently(branch: &str) -> Result<()> {
244 if let Some(message) = worktree_collision(branch) {
245 bail!(message);
246 }
247
248 status(&["switch", branch]).with_context(|| format!("failed to check out {branch}"))
249}
250
251pub fn switched_to(branch: &str) -> String {
253 crate::style::paint(crate::style::BRANCH, branch)
254}
255
256pub fn create_branch(branch: &str) -> Result<()> {
257 status(&["switch", "-c", branch]).with_context(|| format!("failed to create branch {branch}"))
258}
259
260pub fn create_branch_at(branch: &str, sha: &str) -> Result<()> {
263 status(&["branch", branch, sha])
264 .with_context(|| format!("failed to create branch {branch} at {sha}"))
265}
266
267pub fn delete_branch(branch: &str) -> Result<()> {
271 status(&["branch", "-D", branch]).with_context(|| format!("failed to delete branch {branch}"))
272}
273
274pub fn rename_branch(old: &str, new: &str) -> Result<()> {
276 status(&["branch", "-m", old, new]).with_context(|| format!("failed to rename {old} to {new}"))
277}
278
279pub fn fetch_branch(remote: &str, branch: &str) -> Result<()> {
281 let refspec = format!("{branch}:{branch}");
282 status(&["fetch", remote, &refspec])
283 .with_context(|| format!("failed to fetch {branch} from {remote}"))
284}
285
286pub fn pull_ff_only() -> Result<()> {
287 status(&["pull", "--ff-only"]).context("failed to fast-forward from the remote")
288}
289
290pub fn push_force_with_lease(remote: &str, branches: &[String]) -> Result<Vec<String>> {
295 let mut args = vec!["push", "--force-with-lease", remote];
296 args.extend(branches.iter().map(String::as_str));
297
298 run_lease_push(&args, remote, branches)
299}
300
301fn run_lease_push(args: &[&str], remote: &str, branches: &[String]) -> Result<Vec<String>> {
317 if verbose() {
321 status_passthrough(args).with_context(|| format!("failed to push branches to {remote}"))?;
322 return Ok(branches.to_vec());
323 }
324
325 let output = Command::new("git")
326 .args(args)
327 .output()
328 .context("failed to run git")?;
329 if output.status.success() {
330 return Ok(branches.to_vec());
331 }
332
333 let stderr = String::from_utf8_lossy(&output.stderr);
341 if let Some(queued) = merge_queue_rejection(&stderr) {
342 anstream::eprintln!(
343 "{}",
344 crate::style::warn(&format!(
345 "{} {} in a merge queue and was not updated (dequeue its review to push it)",
346 queued.join(", "),
347 if queued.len() == 1 { "is" } else { "are" },
348 ))
349 );
350 return Ok(landed_branches(branches, &queued));
351 }
352
353 if let Some(stale) = stale_rejection(&stderr) {
354 bail!(
357 "could not push {} to {remote}: the remote has moved on \
358 (a branch in the stack was likely merged or updated upstream)\n\
359 run `git stk sync` to reconcile your local stack with the remote, then try again",
360 stale.join(", "),
361 );
362 }
363
364 let _ = std::io::stdout().write_all(&output.stdout);
365 let _ = std::io::stderr().write_all(&output.stderr);
366 bail!(
367 "failed to push branches to {remote}: git exited with status {}",
368 output.status
369 )
370}
371
372fn landed_branches(attempted: &[String], held: &[String]) -> Vec<String> {
375 attempted
376 .iter()
377 .filter(|branch| !held.iter().any(|name| name == *branch))
378 .cloned()
379 .collect()
380}
381
382fn merge_queue_rejection(stderr: &str) -> Option<Vec<String>> {
389 let lower = stderr.to_lowercase();
390 let mentions_queue = lower.contains("merge queue") || lower.contains("queued for merging");
391 if !mentions_queue {
392 return None;
393 }
394 if ["stale info", "non-fast-forward", "fetch first"]
397 .iter()
398 .any(|marker| lower.contains(marker))
399 {
400 return None;
401 }
402 let rejected = rejected_refs(stderr);
403 if rejected.is_empty() {
404 None
405 } else {
406 Some(rejected)
407 }
408}
409
410fn stale_rejection(stderr: &str) -> Option<Vec<String>> {
422 let rejected: Vec<&str> = stderr
423 .lines()
424 .filter(|line| line.contains("[remote rejected]") || line.contains("[rejected]"))
425 .collect();
426 if rejected.is_empty() || !rejected.iter().all(|line| line_is_stale(line)) {
427 return None;
428 }
429 let names: Vec<String> = rejected
430 .iter()
431 .filter_map(|line| rejected_ref_name(line))
432 .collect();
433 if names.is_empty() { None } else { Some(names) }
434}
435
436fn line_is_stale(line: &str) -> bool {
440 let lower = line.to_lowercase();
441 ["stale info", "non-fast-forward", "fetch first"]
442 .iter()
443 .any(|marker| lower.contains(marker))
444}
445
446fn rejected_ref_name(line: &str) -> Option<String> {
449 let after = line.split("-> ").nth(1)?;
450 Some(after.split_whitespace().next()?.to_owned())
451}
452
453fn rejected_refs(stderr: &str) -> Vec<String> {
456 stderr
457 .lines()
458 .filter(|line| line.contains("[remote rejected]") || line.contains("[rejected]"))
459 .filter_map(rejected_ref_name)
460 .collect()
461}
462
463pub fn push_set_upstream_force_with_lease(remote: &str, branches: &[String]) -> Result<()> {
466 let mut args = vec!["push", "--set-upstream", "--force-with-lease", remote];
467 args.extend(branches.iter().map(String::as_str));
468
469 run_lease_push(&args, remote, branches)?;
472 Ok(())
473}
474
475pub fn write_blob_ref(reference: &str, file: &str, content: &str) -> Result<()> {
479 let blob = output_with_stdin(&["hash-object", "-w", "--stdin"], content)
480 .context("failed to hash stack metadata")?;
481 let tree = output_with_stdin(&["mktree"], &format!("100644 blob {blob}\t{file}\n"))
482 .context("failed to write stack metadata tree")?;
483 let commit = output(&["commit-tree", &tree, "-m", "git-stk stack metadata"])
484 .context("failed to commit stack metadata")?;
485 status(&["update-ref", reference, &commit])
486 .with_context(|| format!("failed to update {reference}"))
487}
488
489pub fn push_ref(remote: &str, reference: &str) -> Result<()> {
492 status(&[
493 "push",
494 "--force",
495 remote,
496 &format!("{reference}:{reference}"),
497 ])
498 .with_context(|| format!("failed to push {reference} to {remote}"))
499}
500
501pub fn fetch_ref(remote: &str, reference: &str) -> Result<()> {
503 status(&["fetch", remote, &format!("+{reference}:{reference}")])
504 .with_context(|| format!("failed to fetch {reference} from {remote}"))
505}
506
507pub fn read_ref_file(reference: &str, file: &str) -> Result<Option<String>> {
510 let output = Command::new("git")
511 .args(["cat-file", "blob", &format!("{reference}:{file}")])
512 .stdout(Stdio::piped())
513 .stderr(Stdio::piped())
514 .output()
515 .context("failed to run git cat-file")?;
516 if output.status.success() {
517 Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()))
518 } else {
519 Ok(None)
520 }
521}
522
523pub fn rebase(parent: &str, branch: &str, update_refs: bool) -> Result<()> {
524 if let Some(message) = worktree_collision(branch) {
525 bail!(message);
526 }
527 let mut args = vec!["rebase"];
528 if update_refs {
529 args.push("--update-refs");
530 }
531 args.extend([parent, branch]);
532
533 status(&args).with_context(|| format!("failed to rebase {branch} onto {parent}"))
534}
535
536pub fn rebase_onto(parent: &str, base: &str, branch: &str, update_refs: bool) -> Result<()> {
540 if let Some(message) = worktree_collision(branch) {
541 bail!(message);
542 }
543 let mut args = vec!["rebase"];
544 if update_refs {
545 args.push("--update-refs");
546 }
547 args.extend(["--onto", parent, base, branch]);
548
549 status(&args).with_context(|| format!("failed to rebase {branch} onto {parent} from {base}"))
550}
551
552pub fn rev_parse(rev: &str) -> Result<String> {
553 let spec = format!("{rev}^{{commit}}");
554 output(&["rev-parse", "--verify", &spec]).with_context(|| format!("failed to resolve {rev}"))
555}
556
557pub fn branch_sha(branch: &str) -> Option<String> {
559 rev_parse(branch).ok()
560}
561
562pub fn update_ref(branch: &str, sha: &str) -> Result<()> {
565 status(&["update-ref", &format!("refs/heads/{branch}"), sha])
566 .with_context(|| format!("failed to update {branch} to {sha}"))
567}
568
569pub fn reset_hard() -> Result<()> {
572 status(&["reset", "--hard"]).context("failed to reset the worktree")
573}
574
575pub fn worktree_is_clean() -> Result<bool> {
577 Ok(output(&["status", "--porcelain"])?.is_empty())
578}
579
580pub fn remote_default_branch(remote: &str) -> Option<String> {
582 let reference = format!("refs/remotes/{remote}/HEAD");
583 let full = output(&["symbolic-ref", "--short", &reference]).ok()?;
584 full.strip_prefix(&format!("{remote}/")).map(str::to_owned)
585}
586
587pub fn commits_behind(branch: &str, parent: &str) -> Result<usize> {
590 let range = format!("{branch}..{parent}");
591 let count = output(&["rev-list", "--count", &range])
592 .with_context(|| format!("failed to count commits in {range}"))?;
593 count
594 .trim()
595 .parse()
596 .context("failed to parse rev-list count")
597}
598
599pub fn merge_base(a: &str, b: &str) -> Result<String> {
600 output(&["merge-base", a, b])
601 .with_context(|| format!("failed to find merge base of {a} and {b}"))
602}
603
604pub fn diff_against_head(cached: bool) -> Result<String> {
608 let mut args = vec!["diff", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"];
611 if cached {
612 args.push("--cached");
613 }
614 args.push("HEAD");
615 output(&args).context("failed to diff against HEAD")
616}
617
618pub fn blame_line_shas(file: &str, start: usize, len: usize) -> Result<Vec<String>> {
621 if len == 0 {
622 return Ok(Vec::new());
623 }
624 let range = format!("{start},{}", start + len - 1);
625 let out = output(&[
626 "blame",
627 "HEAD",
628 "-L",
629 &range,
630 "--line-porcelain",
631 "--",
632 file,
633 ])
634 .with_context(|| format!("failed to blame {file}"))?;
635
636 let mut shas = Vec::new();
637 for line in out.lines() {
638 let token = line.split(' ').next().unwrap_or_default();
642 if token.len() == 40
643 && token.bytes().all(|byte| byte.is_ascii_hexdigit())
644 && !shas.iter().any(|seen| seen == token)
645 {
646 shas.push(token.to_owned());
647 }
648 }
649 Ok(shas)
650}
651
652pub fn rev_list(range: &str) -> Result<Vec<String>> {
654 Ok(output(&["rev-list", range])
655 .with_context(|| format!("failed to list commits in {range}"))?
656 .lines()
657 .map(str::to_owned)
658 .collect())
659}
660
661pub fn log_oneline(range: &str) -> Result<Vec<(String, String)>> {
664 Ok(output(&["log", "--format=%h%x09%s", range])
665 .with_context(|| format!("failed to log {range}"))?
666 .lines()
667 .filter_map(|line| {
668 line.split_once('\t')
669 .map(|(sha, subject)| (sha.to_owned(), subject.to_owned()))
670 })
671 .collect())
672}
673
674pub fn commit_subject(sha: &str) -> Result<String> {
676 output(&["show", "--no-patch", "--format=%s", sha])
677 .with_context(|| format!("failed to read subject of {sha}"))
678}
679
680pub fn commit_body(sha: &str) -> Result<String> {
682 output(&["show", "--no-patch", "--format=%b", sha])
683 .with_context(|| format!("failed to read body of {sha}"))
684}
685
686pub fn apply_cached(patch: &str) -> Result<()> {
689 let mut child = Command::new("git")
690 .args(["apply", "--cached", "--unidiff-zero"])
691 .stdin(Stdio::piped())
692 .stdout(Stdio::piped())
693 .stderr(Stdio::piped())
694 .spawn()
695 .context("failed to run git apply")?;
696 {
697 let mut stdin = child.stdin.take().context("git apply has no stdin")?;
698 stdin
699 .write_all(patch.as_bytes())
700 .context("failed to write patch to git apply")?;
701 }
702 let output = child
703 .wait_with_output()
704 .context("failed to run git apply")?;
705 if output.status.success() {
706 Ok(())
707 } else {
708 Err(command_error("git apply", &output.stderr))
709 }
710}
711
712pub fn commit_fixup(sha: &str) -> Result<()> {
715 status(&["commit", "--no-verify", &format!("--fixup={sha}")])
716 .with_context(|| format!("failed to create fixup commit for {sha}"))
717}
718
719pub fn reset_index() -> Result<()> {
721 status(&["reset", "--quiet"]).context("failed to reset the index")
722}
723
724pub fn reset_soft(sha: &str) -> Result<()> {
726 status(&["reset", "--soft", sha]).with_context(|| format!("failed to reset to {sha}"))
727}
728
729pub fn stash_push() -> Result<()> {
731 status(&["stash", "push", "--quiet"]).context("failed to stash changes")
732}
733
734pub fn stash_pop() -> Result<()> {
736 status(&["stash", "pop", "--quiet"]).context("failed to restore stashed changes")
737}
738
739pub fn rebase_autosquash(base: &str, update_refs: bool) -> Result<()> {
742 let mut args = vec!["rebase", "--interactive", "--autosquash"];
743 if update_refs {
744 args.push("--update-refs");
745 }
746 args.push(base);
747
748 let output = Command::new("git")
749 .args(&args)
750 .env("GIT_SEQUENCE_EDITOR", "true")
751 .env("GIT_EDITOR", "true")
752 .output()
753 .context("failed to run git rebase")?;
754 if output.status.success() {
755 Ok(())
756 } else {
757 Err(command_error("git rebase --autosquash", &output.stderr))
758 }
759}
760
761pub fn is_ancestor(ancestor: &str, descendant: &str) -> Result<bool> {
762 Ok(output_codes(
764 &["merge-base", "--is-ancestor", ancestor, descendant],
765 &[1],
766 "git merge-base --is-ancestor",
767 )?
768 .is_some())
769}
770
771pub fn diff_numstat(base: &str, branch: &str) -> Result<(usize, usize)> {
776 let output = output(&["diff", "--numstat", &format!("{base}...{branch}")])?;
777 let mut added = 0;
778 let mut deleted = 0;
779 for line in output.lines() {
780 let mut columns = line.split('\t');
781 added += column_count(columns.next());
782 deleted += column_count(columns.next());
783 }
784 Ok((added, deleted))
785}
786
787fn column_count(column: Option<&str>) -> usize {
790 column
791 .and_then(|value| value.parse::<usize>().ok())
792 .unwrap_or(0)
793}
794
795pub fn supports_rebase_update_refs() -> Result<bool> {
796 let output = Command::new("git")
797 .args(["rebase", "-h"])
798 .stdout(Stdio::piped())
799 .stderr(Stdio::piped())
800 .output()
801 .context("failed to inspect git rebase help")?;
802
803 let help = format!(
804 "{}{}",
805 String::from_utf8_lossy(&output.stdout),
806 String::from_utf8_lossy(&output.stderr)
807 );
808 Ok(help_mentions_update_refs(&help))
809}
810
811fn help_mentions_update_refs(help: &str) -> bool {
814 help.contains("update-refs")
815}
816
817pub fn rebase_in_progress() -> bool {
822 ["rebase-merge", "rebase-apply"].iter().any(|dir| {
823 git_path(dir)
824 .map(|path| std::path::Path::new(&path).exists())
825 .unwrap_or(false)
826 })
827}
828
829pub fn rebase_continue() -> Result<()> {
830 status_passthrough(&["rebase", "--continue"]).context("failed to continue rebase")
832}
833
834pub fn rebase_abort() -> Result<()> {
835 status(&["rebase", "--abort"]).context("failed to abort rebase")
836}
837
838pub fn cherry_pick(commit: &str) -> Result<()> {
842 status(&["cherry-pick", commit]).with_context(|| format!("failed to cherry-pick {commit}"))
843}
844
845pub fn fetch_tracking(remote: &str, branches: &[String]) -> Result<()> {
850 let present = remote_branches_present(remote, branches)?;
851 if present.is_empty() {
852 return Ok(());
853 }
854 let mut args = vec!["fetch", remote];
855 args.extend(present.iter().map(String::as_str));
856 status(&args).with_context(|| format!("failed to fetch branches from {remote}"))
857}
858
859fn remote_branches_present(remote: &str, branches: &[String]) -> Result<Vec<String>> {
863 if branches.is_empty() {
864 return Ok(Vec::new());
865 }
866 let mut args = vec!["ls-remote", "--heads", remote];
867 args.extend(branches.iter().map(String::as_str));
868 let listing =
869 output(&args).with_context(|| format!("failed to query {remote} for branch heads"))?;
870 let present: Vec<&str> = listing
871 .lines()
872 .filter_map(|line| line.split_once('\t'))
873 .filter_map(|(_, name)| name.strip_prefix("refs/heads/"))
874 .collect();
875 Ok(branches
876 .iter()
877 .filter(|branch| present.contains(&branch.as_str()))
878 .cloned()
879 .collect())
880}
881
882pub fn remote_only_commits(branch: &str, tracking: &str) -> Result<Vec<(String, String)>> {
889 let range = format!("{branch}...{tracking}");
890 let mut commits: Vec<(String, String)> = output(&[
891 "log",
892 "--cherry-pick",
893 "--right-only",
894 "--no-merges",
895 "--format=%h%x09%s",
896 &range,
897 ])
898 .with_context(|| format!("failed to list remote-only commits in {range}"))?
899 .lines()
900 .filter_map(|line| {
901 line.split_once('\t')
902 .map(|(sha, subject)| (sha.to_owned(), subject.to_owned()))
903 })
904 .collect();
905 commits.reverse();
907 Ok(commits)
908}
909
910pub fn config_get(key: &str) -> Result<Option<String>> {
911 output_codes(&["config", "--get", key], &[1], "git config --get")
913}
914
915pub fn config_get_bool(key: &str) -> Result<Option<bool>> {
916 let Some(value) = output_codes(
917 &["config", "--type=bool", "--get", key],
918 &[1],
919 "git config --type=bool --get",
920 )?
921 else {
922 return Ok(None);
923 };
924 match value.as_str() {
925 "true" => Ok(Some(true)),
926 "false" => Ok(Some(false)),
927 _ => bail!("git config {key} is not a boolean: {value}"),
928 }
929}
930
931pub fn config_get_regexp(pattern: &str) -> Result<Vec<(String, String)>> {
932 let Some(text) = output_codes(
934 &["config", "--get-regexp", pattern],
935 &[1],
936 "git config --get-regexp",
937 )?
938 else {
939 return Ok(Vec::new());
940 };
941 Ok(text
942 .lines()
943 .filter_map(|line| {
944 line.split_once(' ')
945 .map(|(key, value)| (key.to_owned(), value.to_owned()))
946 })
947 .collect())
948}
949
950pub fn config_set(key: &str, value: &str) -> Result<()> {
951 status(&["config", key, value]).with_context(|| format!("failed to set git config {key}"))
952}
953
954pub fn config_unset(key: &str) -> Result<()> {
955 output_codes(&["config", "--unset", key], &[5], "git config --unset").map(|_| ())
958}
959
960fn output_codes(args: &[&str], ok_empty: &[i32], label: &str) -> Result<Option<String>> {
965 let output = Command::new("git")
966 .args(args)
967 .stdout(Stdio::piped())
968 .stderr(Stdio::piped())
969 .output()
970 .context("failed to run git")?;
971
972 match output.status.code() {
973 Some(0) => Ok(Some(
974 String::from_utf8_lossy(&output.stdout).trim().to_owned(),
975 )),
976 Some(code) if ok_empty.contains(&code) => Ok(None),
977 _ => Err(command_error(label, &output.stderr)),
978 }
979}
980
981fn output(args: &[&str]) -> Result<String> {
982 let output = Command::new("git")
983 .args(args)
984 .stdout(Stdio::piped())
985 .stderr(Stdio::piped())
986 .output()
987 .context("failed to run git")?;
988
989 if output.status.success() {
990 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
991 } else {
992 Err(command_error("git", &output.stderr))
993 }
994}
995
996fn output_with_stdin(args: &[&str], input: &str) -> Result<String> {
999 let mut child = Command::new("git")
1000 .args(args)
1001 .stdin(Stdio::piped())
1002 .stdout(Stdio::piped())
1003 .stderr(Stdio::piped())
1004 .spawn()
1005 .context("failed to run git")?;
1006 {
1007 let mut stdin = child.stdin.take().context("git has no stdin")?;
1008 stdin
1009 .write_all(input.as_bytes())
1010 .context("failed to write to git")?;
1011 }
1012 let output = child.wait_with_output().context("failed to run git")?;
1013 if output.status.success() {
1014 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
1015 } else {
1016 Err(command_error("git", &output.stderr))
1017 }
1018}
1019
1020fn status(args: &[&str]) -> Result<()> {
1024 if verbose() {
1025 return status_passthrough(args);
1026 }
1027
1028 let output = Command::new("git")
1029 .args(args)
1030 .output()
1031 .context("failed to run git")?;
1032
1033 if output.status.success() {
1034 Ok(())
1035 } else {
1036 let _ = std::io::stdout().write_all(&output.stdout);
1037 let _ = std::io::stderr().write_all(&output.stderr);
1038 bail!("git exited with status {}", output.status)
1039 }
1040}
1041
1042fn status_passthrough(args: &[&str]) -> Result<()> {
1045 let status = Command::new("git")
1046 .args(args)
1047 .status()
1048 .context("failed to run git")?;
1049
1050 if status.success() {
1051 Ok(())
1052 } else {
1053 bail!("git exited with status {status}")
1054 }
1055}
1056
1057fn command_error(command: &str, stderr: &[u8]) -> anyhow::Error {
1058 let stderr = String::from_utf8_lossy(stderr).trim().to_owned();
1059 if stderr.is_empty() {
1060 anyhow!("{command} failed")
1061 } else {
1062 anyhow!("{command} failed: {stderr}")
1063 }
1064}
1065
1066#[cfg(test)]
1067mod tests {
1068 use super::*;
1069
1070 const PORCELAIN: &str = "\
1073worktree /repo
1074HEAD f7cff917cf874d0c6ff3108260fda91ac3271baf
1075branch refs/heads/feat/b
1076
1077worktree /repo/../wt-a
1078HEAD 0700673acebfe459d480fa3bd616b2ecf6249fe1
1079branch refs/heads/feat/a
1080
1081worktree /repo/../wt-detached
1082HEAD 25fb6254b4b1cd5cbe2b0d4b1f5b1cf6e7d8a9b0
1083detached
1084";
1085
1086 #[test]
1087 fn worktree_parsing_keeps_branches_and_drops_detached_ones() {
1088 let held = parse_worktree_branches(PORCELAIN, None);
1092 assert_eq!(
1093 held,
1094 vec![
1095 ("feat/b".to_owned(), std::path::PathBuf::from("/repo")),
1096 (
1097 "feat/a".to_owned(),
1098 std::path::PathBuf::from("/repo/../wt-a")
1099 ),
1100 ]
1101 );
1102 }
1103
1104 #[test]
1105 fn worktree_parsing_excludes_the_worktree_we_are_standing_in() {
1106 let held = parse_worktree_branches(PORCELAIN, Some(std::path::Path::new("/repo")));
1109 assert_eq!(
1110 held,
1111 vec![(
1112 "feat/a".to_owned(),
1113 std::path::PathBuf::from("/repo/../wt-a")
1114 )]
1115 );
1116 }
1117
1118 #[test]
1119 fn a_bare_record_does_not_lend_its_path_to_the_next_branch() {
1120 let porcelain = "\
1123worktree /repo/.bare
1124bare
1125
1126worktree /repo/wt-a
1127HEAD 0700673acebfe459d480fa3bd616b2ecf6249fe1
1128branch refs/heads/feat/a
1129";
1130 assert_eq!(
1131 parse_worktree_branches(porcelain, None),
1132 vec![("feat/a".to_owned(), std::path::PathBuf::from("/repo/wt-a"))]
1133 );
1134 }
1135
1136 #[test]
1137 fn branch_names_containing_slashes_survive_the_refs_heads_strip() {
1138 let porcelain = "\
1141worktree /repo/wt
1142HEAD 0700673acebfe459d480fa3bd616b2ecf6249fe1
1143branch refs/heads/feat/deep/nested/name
1144";
1145 assert_eq!(
1146 parse_worktree_branches(porcelain, None)
1147 .first()
1148 .map(|(branch, _)| branch.as_str()),
1149 Some("feat/deep/nested/name")
1150 );
1151 }
1152
1153 #[test]
1154 fn empty_porcelain_holds_nothing() {
1155 assert!(parse_worktree_branches("", None).is_empty());
1156 }
1157
1158 #[test]
1159 fn a_collision_message_quotes_the_path_it_suggests_pasting() {
1160 let message = collision_message("feat/a", "../my worktree");
1163 assert!(
1164 message.contains(r#"`cd "../my worktree"`"#),
1165 "cd suggestion is not pasteable: {message}"
1166 );
1167 assert!(
1168 message.contains(r#"`git worktree remove "../my worktree"`"#),
1169 "remove suggestion is not pasteable: {message}"
1170 );
1171 }
1172
1173 #[test]
1174 fn a_collision_message_names_the_branch_and_where_it_lives() {
1175 let message = collision_message("feat/a", "../wt-a");
1176 assert!(message.starts_with("feat/a is checked out in the worktree at ../wt-a"));
1177 }
1178
1179 #[test]
1180 fn a_merge_queue_rejection_is_downgraded_to_the_queued_refs() {
1181 let stderr = "\
1184remote: error: GH006: Protected branch update failed for refs/heads/feat/tf-deploy.
1185remote: - A pull request for this branch has been added to a merge queue. Branches that
1186remote: are queued for merging cannot be updated. To modify this branch, dequeue the
1187remote: associated pull request.
1188To github.com:higharc/product
1189 + 016bb37...3a94024 feat/spa-env -> feat/spa-env (forced update)
1190 ! [remote rejected] feat/tf-deploy -> feat/tf-deploy (protected branch hook declined)
1191error: failed to push some refs to 'github.com:higharc/product'";
1192 assert_eq!(
1193 merge_queue_rejection(stderr),
1194 Some(vec!["feat/tf-deploy".to_owned()])
1195 );
1196 }
1197
1198 #[test]
1199 fn a_stale_lease_rejection_is_not_swallowed_even_with_a_queue_mention() {
1200 let stderr = "\
1203remote: GitHub found 270 vulnerabilities ... merge queue notes ...
1204 ! [rejected] feat/tf-deploy -> feat/tf-deploy (stale info)
1205error: failed to push some refs";
1206 assert_eq!(merge_queue_rejection(stderr), None);
1207 }
1208
1209 #[test]
1210 fn no_queue_mention_is_not_a_queue_rejection() {
1211 let stderr = " ! [remote rejected] feat/x -> feat/x (permission denied)";
1212 assert_eq!(merge_queue_rejection(stderr), None);
1213 }
1214
1215 #[test]
1216 fn landed_branches_drops_only_the_held_ones() {
1217 let attempted = [
1218 "feat/a".to_owned(),
1219 "feat/b".to_owned(),
1220 "feat/c".to_owned(),
1221 ];
1222 assert_eq!(
1225 landed_branches(&attempted, &["feat/b".to_owned()]),
1226 vec!["feat/a".to_owned(), "feat/c".to_owned()]
1227 );
1228 assert_eq!(landed_branches(&attempted, &[]), attempted.to_vec());
1230 assert!(landed_branches(&attempted, &attempted).is_empty());
1232 }
1233
1234 #[test]
1235 fn a_stale_lease_push_names_the_rejected_branch() {
1236 let stderr = "\
1239To github.com:higharc/product
1240 3a94024..d63a2b2 feat/spa-env -> feat/spa-env
1241 ! [rejected] feat/tf-deploy -> feat/tf-deploy (stale info)
1242error: failed to push some refs to 'github.com:higharc/product'";
1243 assert_eq!(
1244 stale_rejection(stderr),
1245 Some(vec!["feat/tf-deploy".to_owned()])
1246 );
1247 }
1248
1249 #[test]
1250 fn a_non_fast_forward_push_is_treated_as_stale() {
1251 let stderr = " ! [rejected] feat/x -> feat/x (non-fast-forward)";
1252 assert_eq!(stale_rejection(stderr), Some(vec!["feat/x".to_owned()]));
1253 }
1254
1255 #[test]
1256 fn an_unrelated_push_failure_is_not_classified_as_stale() {
1257 let stderr = " ! [remote rejected] feat/x -> feat/x (permission denied)";
1259 assert_eq!(stale_rejection(stderr), None);
1260 assert_eq!(stale_rejection("fatal: could not read from remote"), None);
1261 }
1262
1263 #[test]
1264 fn a_mixed_stale_and_non_stale_rejection_is_not_classified_as_stale() {
1265 let stderr = "\
1269 ! [rejected] feat/tf-deploy -> feat/tf-deploy (stale info)
1270 ! [remote rejected] feat/locked -> feat/locked (permission denied)
1271error: failed to push some refs";
1272 assert_eq!(stale_rejection(stderr), None);
1273 }
1274
1275 #[test]
1276 fn help_mentions_update_refs_matches_pre_2_43_spelling() {
1277 assert!(help_mentions_update_refs(
1278 " --update-refs update branches that point to commits that are being rebased"
1279 ));
1280 }
1281
1282 #[test]
1283 fn help_mentions_update_refs_matches_negatable_spelling() {
1284 assert!(help_mentions_update_refs(
1285 " --[no-]update-refs update branches that point to commits that are being rebased"
1286 ));
1287 }
1288
1289 #[test]
1290 fn help_mentions_update_refs_rejects_help_without_the_option() {
1291 assert!(!help_mentions_update_refs(
1292 " --[no-]autosquash move commits that begin with squash!/fixup!"
1293 ));
1294 }
1295
1296 #[test]
1297 fn detection_agrees_with_the_real_git_on_this_machine() {
1298 let probe = Command::new("git")
1301 .args(["rebase", "--update-refs", "-h"])
1302 .stdout(Stdio::piped())
1303 .stderr(Stdio::piped())
1304 .output()
1305 .expect("run git rebase probe");
1306 let probe_text = format!(
1307 "{}{}",
1308 String::from_utf8_lossy(&probe.stdout),
1309 String::from_utf8_lossy(&probe.stderr)
1310 );
1311 let real_support = !probe_text.contains("unknown option");
1312
1313 assert_eq!(
1314 supports_rebase_update_refs().expect("detect support"),
1315 real_support
1316 );
1317 }
1318}