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 remote_url(remote: &str) -> Result<Option<String>> {
65 output_codes(&["remote", "get-url", remote], &[2], "git remote get-url")
67}
68
69pub fn checkout(branch: &str) -> Result<()> {
70 status(&["switch", branch]).with_context(|| format!("failed to check out {branch}"))?;
71 anstream::println!(
72 "switched to {}",
73 crate::style::paint(crate::style::BRANCH, branch)
74 );
75 Ok(())
76}
77
78pub fn create_branch(branch: &str) -> Result<()> {
79 status(&["switch", "-c", branch]).with_context(|| format!("failed to create branch {branch}"))
80}
81
82pub fn create_branch_at(branch: &str, sha: &str) -> Result<()> {
85 status(&["branch", branch, sha])
86 .with_context(|| format!("failed to create branch {branch} at {sha}"))
87}
88
89pub fn delete_branch(branch: &str) -> Result<()> {
93 status(&["branch", "-D", branch]).with_context(|| format!("failed to delete branch {branch}"))
94}
95
96pub fn rename_branch(old: &str, new: &str) -> Result<()> {
98 status(&["branch", "-m", old, new]).with_context(|| format!("failed to rename {old} to {new}"))
99}
100
101pub fn fetch_branch(remote: &str, branch: &str) -> Result<()> {
103 let refspec = format!("{branch}:{branch}");
104 status(&["fetch", remote, &refspec])
105 .with_context(|| format!("failed to fetch {branch} from {remote}"))
106}
107
108pub fn pull_ff_only() -> Result<()> {
109 status(&["pull", "--ff-only"]).context("failed to fast-forward from the remote")
110}
111
112pub fn push_force_with_lease(remote: &str, branches: &[String]) -> Result<Vec<String>> {
117 let mut args = vec!["push", "--force-with-lease", remote];
118 args.extend(branches.iter().map(String::as_str));
119
120 run_lease_push(&args, remote, branches)
121}
122
123fn run_lease_push(args: &[&str], remote: &str, branches: &[String]) -> Result<Vec<String>> {
139 if verbose() {
143 status_passthrough(args).with_context(|| format!("failed to push branches to {remote}"))?;
144 return Ok(branches.to_vec());
145 }
146
147 let output = Command::new("git")
148 .args(args)
149 .output()
150 .context("failed to run git")?;
151 if output.status.success() {
152 return Ok(branches.to_vec());
153 }
154
155 let stderr = String::from_utf8_lossy(&output.stderr);
163 if let Some(queued) = merge_queue_rejection(&stderr) {
164 anstream::eprintln!(
165 "{}",
166 crate::style::warn(&format!(
167 "{} {} in a merge queue and was not updated (dequeue its review to push it)",
168 queued.join(", "),
169 if queued.len() == 1 { "is" } else { "are" },
170 ))
171 );
172 return Ok(landed_branches(branches, &queued));
173 }
174
175 if let Some(stale) = stale_rejection(&stderr) {
176 bail!(
179 "could not push {} to {remote}: the remote has moved on \
180 (a branch in the stack was likely merged or updated upstream)\n\
181 run `git stk sync` to reconcile your local stack with the remote, then try again",
182 stale.join(", "),
183 );
184 }
185
186 let _ = std::io::stdout().write_all(&output.stdout);
187 let _ = std::io::stderr().write_all(&output.stderr);
188 bail!(
189 "failed to push branches to {remote}: git exited with status {}",
190 output.status
191 )
192}
193
194fn landed_branches(attempted: &[String], held: &[String]) -> Vec<String> {
197 attempted
198 .iter()
199 .filter(|branch| !held.iter().any(|name| name == *branch))
200 .cloned()
201 .collect()
202}
203
204fn merge_queue_rejection(stderr: &str) -> Option<Vec<String>> {
211 let lower = stderr.to_lowercase();
212 let mentions_queue = lower.contains("merge queue") || lower.contains("queued for merging");
213 if !mentions_queue {
214 return None;
215 }
216 if ["stale info", "non-fast-forward", "fetch first"]
219 .iter()
220 .any(|marker| lower.contains(marker))
221 {
222 return None;
223 }
224 let rejected = rejected_refs(stderr);
225 if rejected.is_empty() {
226 None
227 } else {
228 Some(rejected)
229 }
230}
231
232fn stale_rejection(stderr: &str) -> Option<Vec<String>> {
244 let rejected: Vec<&str> = stderr
245 .lines()
246 .filter(|line| line.contains("[remote rejected]") || line.contains("[rejected]"))
247 .collect();
248 if rejected.is_empty() || !rejected.iter().all(|line| line_is_stale(line)) {
249 return None;
250 }
251 let names: Vec<String> = rejected
252 .iter()
253 .filter_map(|line| rejected_ref_name(line))
254 .collect();
255 if names.is_empty() { None } else { Some(names) }
256}
257
258fn line_is_stale(line: &str) -> bool {
262 let lower = line.to_lowercase();
263 ["stale info", "non-fast-forward", "fetch first"]
264 .iter()
265 .any(|marker| lower.contains(marker))
266}
267
268fn rejected_ref_name(line: &str) -> Option<String> {
271 let after = line.split("-> ").nth(1)?;
272 Some(after.split_whitespace().next()?.to_owned())
273}
274
275fn rejected_refs(stderr: &str) -> Vec<String> {
278 stderr
279 .lines()
280 .filter(|line| line.contains("[remote rejected]") || line.contains("[rejected]"))
281 .filter_map(rejected_ref_name)
282 .collect()
283}
284
285pub fn push_set_upstream_force_with_lease(remote: &str, branches: &[String]) -> Result<()> {
288 let mut args = vec!["push", "--set-upstream", "--force-with-lease", remote];
289 args.extend(branches.iter().map(String::as_str));
290
291 run_lease_push(&args, remote, branches)?;
294 Ok(())
295}
296
297pub fn write_blob_ref(reference: &str, file: &str, content: &str) -> Result<()> {
301 let blob = output_with_stdin(&["hash-object", "-w", "--stdin"], content)
302 .context("failed to hash stack metadata")?;
303 let tree = output_with_stdin(&["mktree"], &format!("100644 blob {blob}\t{file}\n"))
304 .context("failed to write stack metadata tree")?;
305 let commit = output(&["commit-tree", &tree, "-m", "git-stk stack metadata"])
306 .context("failed to commit stack metadata")?;
307 status(&["update-ref", reference, &commit])
308 .with_context(|| format!("failed to update {reference}"))
309}
310
311pub fn push_ref(remote: &str, reference: &str) -> Result<()> {
314 status(&[
315 "push",
316 "--force",
317 remote,
318 &format!("{reference}:{reference}"),
319 ])
320 .with_context(|| format!("failed to push {reference} to {remote}"))
321}
322
323pub fn fetch_ref(remote: &str, reference: &str) -> Result<()> {
325 status(&["fetch", remote, &format!("+{reference}:{reference}")])
326 .with_context(|| format!("failed to fetch {reference} from {remote}"))
327}
328
329pub fn read_ref_file(reference: &str, file: &str) -> Result<Option<String>> {
332 let output = Command::new("git")
333 .args(["cat-file", "blob", &format!("{reference}:{file}")])
334 .stdout(Stdio::piped())
335 .stderr(Stdio::piped())
336 .output()
337 .context("failed to run git cat-file")?;
338 if output.status.success() {
339 Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()))
340 } else {
341 Ok(None)
342 }
343}
344
345pub fn rebase(parent: &str, branch: &str, update_refs: bool) -> Result<()> {
346 let mut args = vec!["rebase"];
347 if update_refs {
348 args.push("--update-refs");
349 }
350 args.extend([parent, branch]);
351
352 status(&args).with_context(|| format!("failed to rebase {branch} onto {parent}"))
353}
354
355pub fn rebase_onto(parent: &str, base: &str, branch: &str, update_refs: bool) -> Result<()> {
359 let mut args = vec!["rebase"];
360 if update_refs {
361 args.push("--update-refs");
362 }
363 args.extend(["--onto", parent, base, branch]);
364
365 status(&args).with_context(|| format!("failed to rebase {branch} onto {parent} from {base}"))
366}
367
368pub fn rev_parse(rev: &str) -> Result<String> {
369 let spec = format!("{rev}^{{commit}}");
370 output(&["rev-parse", "--verify", &spec]).with_context(|| format!("failed to resolve {rev}"))
371}
372
373pub fn branch_sha(branch: &str) -> Option<String> {
375 rev_parse(branch).ok()
376}
377
378pub fn update_ref(branch: &str, sha: &str) -> Result<()> {
381 status(&["update-ref", &format!("refs/heads/{branch}"), sha])
382 .with_context(|| format!("failed to update {branch} to {sha}"))
383}
384
385pub fn reset_hard() -> Result<()> {
388 status(&["reset", "--hard"]).context("failed to reset the worktree")
389}
390
391pub fn worktree_is_clean() -> Result<bool> {
393 Ok(output(&["status", "--porcelain"])?.is_empty())
394}
395
396pub fn remote_default_branch(remote: &str) -> Option<String> {
398 let reference = format!("refs/remotes/{remote}/HEAD");
399 let full = output(&["symbolic-ref", "--short", &reference]).ok()?;
400 full.strip_prefix(&format!("{remote}/")).map(str::to_owned)
401}
402
403pub fn commits_behind(branch: &str, parent: &str) -> Result<usize> {
406 let range = format!("{branch}..{parent}");
407 let count = output(&["rev-list", "--count", &range])
408 .with_context(|| format!("failed to count commits in {range}"))?;
409 count
410 .trim()
411 .parse()
412 .context("failed to parse rev-list count")
413}
414
415pub fn merge_base(a: &str, b: &str) -> Result<String> {
416 output(&["merge-base", a, b])
417 .with_context(|| format!("failed to find merge base of {a} and {b}"))
418}
419
420pub fn diff_against_head(cached: bool) -> Result<String> {
424 let mut args = vec!["diff", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"];
427 if cached {
428 args.push("--cached");
429 }
430 args.push("HEAD");
431 output(&args).context("failed to diff against HEAD")
432}
433
434pub fn blame_line_shas(file: &str, start: usize, len: usize) -> Result<Vec<String>> {
437 if len == 0 {
438 return Ok(Vec::new());
439 }
440 let range = format!("{start},{}", start + len - 1);
441 let out = output(&[
442 "blame",
443 "HEAD",
444 "-L",
445 &range,
446 "--line-porcelain",
447 "--",
448 file,
449 ])
450 .with_context(|| format!("failed to blame {file}"))?;
451
452 let mut shas = Vec::new();
453 for line in out.lines() {
454 let token = line.split(' ').next().unwrap_or_default();
458 if token.len() == 40
459 && token.bytes().all(|byte| byte.is_ascii_hexdigit())
460 && !shas.iter().any(|seen| seen == token)
461 {
462 shas.push(token.to_owned());
463 }
464 }
465 Ok(shas)
466}
467
468pub fn rev_list(range: &str) -> Result<Vec<String>> {
470 Ok(output(&["rev-list", range])
471 .with_context(|| format!("failed to list commits in {range}"))?
472 .lines()
473 .map(str::to_owned)
474 .collect())
475}
476
477pub fn log_oneline(range: &str) -> Result<Vec<(String, String)>> {
480 Ok(output(&["log", "--format=%h%x09%s", range])
481 .with_context(|| format!("failed to log {range}"))?
482 .lines()
483 .filter_map(|line| {
484 line.split_once('\t')
485 .map(|(sha, subject)| (sha.to_owned(), subject.to_owned()))
486 })
487 .collect())
488}
489
490pub fn commit_subject(sha: &str) -> Result<String> {
492 output(&["show", "--no-patch", "--format=%s", sha])
493 .with_context(|| format!("failed to read subject of {sha}"))
494}
495
496pub fn commit_body(sha: &str) -> Result<String> {
498 output(&["show", "--no-patch", "--format=%b", sha])
499 .with_context(|| format!("failed to read body of {sha}"))
500}
501
502pub fn apply_cached(patch: &str) -> Result<()> {
505 let mut child = Command::new("git")
506 .args(["apply", "--cached", "--unidiff-zero"])
507 .stdin(Stdio::piped())
508 .stdout(Stdio::piped())
509 .stderr(Stdio::piped())
510 .spawn()
511 .context("failed to run git apply")?;
512 {
513 let mut stdin = child.stdin.take().context("git apply has no stdin")?;
514 stdin
515 .write_all(patch.as_bytes())
516 .context("failed to write patch to git apply")?;
517 }
518 let output = child
519 .wait_with_output()
520 .context("failed to run git apply")?;
521 if output.status.success() {
522 Ok(())
523 } else {
524 Err(command_error("git apply", &output.stderr))
525 }
526}
527
528pub fn commit_fixup(sha: &str) -> Result<()> {
531 status(&["commit", "--no-verify", &format!("--fixup={sha}")])
532 .with_context(|| format!("failed to create fixup commit for {sha}"))
533}
534
535pub fn reset_index() -> Result<()> {
537 status(&["reset", "--quiet"]).context("failed to reset the index")
538}
539
540pub fn reset_soft(sha: &str) -> Result<()> {
542 status(&["reset", "--soft", sha]).with_context(|| format!("failed to reset to {sha}"))
543}
544
545pub fn stash_push() -> Result<()> {
547 status(&["stash", "push", "--quiet"]).context("failed to stash changes")
548}
549
550pub fn stash_pop() -> Result<()> {
552 status(&["stash", "pop", "--quiet"]).context("failed to restore stashed changes")
553}
554
555pub fn rebase_autosquash(base: &str, update_refs: bool) -> Result<()> {
558 let mut args = vec!["rebase", "--interactive", "--autosquash"];
559 if update_refs {
560 args.push("--update-refs");
561 }
562 args.push(base);
563
564 let output = Command::new("git")
565 .args(&args)
566 .env("GIT_SEQUENCE_EDITOR", "true")
567 .env("GIT_EDITOR", "true")
568 .output()
569 .context("failed to run git rebase")?;
570 if output.status.success() {
571 Ok(())
572 } else {
573 Err(command_error("git rebase --autosquash", &output.stderr))
574 }
575}
576
577pub fn is_ancestor(ancestor: &str, descendant: &str) -> Result<bool> {
578 Ok(output_codes(
580 &["merge-base", "--is-ancestor", ancestor, descendant],
581 &[1],
582 "git merge-base --is-ancestor",
583 )?
584 .is_some())
585}
586
587pub fn diff_numstat(base: &str, branch: &str) -> Result<(usize, usize)> {
592 let output = output(&["diff", "--numstat", &format!("{base}...{branch}")])?;
593 let mut added = 0;
594 let mut deleted = 0;
595 for line in output.lines() {
596 let mut columns = line.split('\t');
597 added += column_count(columns.next());
598 deleted += column_count(columns.next());
599 }
600 Ok((added, deleted))
601}
602
603fn column_count(column: Option<&str>) -> usize {
606 column
607 .and_then(|value| value.parse::<usize>().ok())
608 .unwrap_or(0)
609}
610
611pub fn supports_rebase_update_refs() -> Result<bool> {
612 let output = Command::new("git")
613 .args(["rebase", "-h"])
614 .stdout(Stdio::piped())
615 .stderr(Stdio::piped())
616 .output()
617 .context("failed to inspect git rebase help")?;
618
619 let help = format!(
620 "{}{}",
621 String::from_utf8_lossy(&output.stdout),
622 String::from_utf8_lossy(&output.stderr)
623 );
624 Ok(help_mentions_update_refs(&help))
625}
626
627fn help_mentions_update_refs(help: &str) -> bool {
630 help.contains("update-refs")
631}
632
633pub fn rebase_continue() -> Result<()> {
634 status_passthrough(&["rebase", "--continue"]).context("failed to continue rebase")
636}
637
638pub fn rebase_abort() -> Result<()> {
639 status(&["rebase", "--abort"]).context("failed to abort rebase")
640}
641
642pub fn cherry_pick(commit: &str) -> Result<()> {
646 status(&["cherry-pick", commit]).with_context(|| format!("failed to cherry-pick {commit}"))
647}
648
649pub fn fetch_tracking(remote: &str, branches: &[String]) -> Result<()> {
654 let present = remote_branches_present(remote, branches)?;
655 if present.is_empty() {
656 return Ok(());
657 }
658 let mut args = vec!["fetch", remote];
659 args.extend(present.iter().map(String::as_str));
660 status(&args).with_context(|| format!("failed to fetch branches from {remote}"))
661}
662
663fn remote_branches_present(remote: &str, branches: &[String]) -> Result<Vec<String>> {
667 if branches.is_empty() {
668 return Ok(Vec::new());
669 }
670 let mut args = vec!["ls-remote", "--heads", remote];
671 args.extend(branches.iter().map(String::as_str));
672 let listing =
673 output(&args).with_context(|| format!("failed to query {remote} for branch heads"))?;
674 let present: Vec<&str> = listing
675 .lines()
676 .filter_map(|line| line.split_once('\t'))
677 .filter_map(|(_, name)| name.strip_prefix("refs/heads/"))
678 .collect();
679 Ok(branches
680 .iter()
681 .filter(|branch| present.contains(&branch.as_str()))
682 .cloned()
683 .collect())
684}
685
686pub fn remote_only_commits(branch: &str, tracking: &str) -> Result<Vec<(String, String)>> {
693 let range = format!("{branch}...{tracking}");
694 let mut commits: Vec<(String, String)> = output(&[
695 "log",
696 "--cherry-pick",
697 "--right-only",
698 "--no-merges",
699 "--format=%h%x09%s",
700 &range,
701 ])
702 .with_context(|| format!("failed to list remote-only commits in {range}"))?
703 .lines()
704 .filter_map(|line| {
705 line.split_once('\t')
706 .map(|(sha, subject)| (sha.to_owned(), subject.to_owned()))
707 })
708 .collect();
709 commits.reverse();
711 Ok(commits)
712}
713
714pub fn config_get(key: &str) -> Result<Option<String>> {
715 output_codes(&["config", "--get", key], &[1], "git config --get")
717}
718
719pub fn config_get_bool(key: &str) -> Result<Option<bool>> {
720 let Some(value) = output_codes(
721 &["config", "--type=bool", "--get", key],
722 &[1],
723 "git config --type=bool --get",
724 )?
725 else {
726 return Ok(None);
727 };
728 match value.as_str() {
729 "true" => Ok(Some(true)),
730 "false" => Ok(Some(false)),
731 _ => bail!("git config {key} is not a boolean: {value}"),
732 }
733}
734
735pub fn config_get_regexp(pattern: &str) -> Result<Vec<(String, String)>> {
736 let Some(text) = output_codes(
738 &["config", "--get-regexp", pattern],
739 &[1],
740 "git config --get-regexp",
741 )?
742 else {
743 return Ok(Vec::new());
744 };
745 Ok(text
746 .lines()
747 .filter_map(|line| {
748 line.split_once(' ')
749 .map(|(key, value)| (key.to_owned(), value.to_owned()))
750 })
751 .collect())
752}
753
754pub fn config_set(key: &str, value: &str) -> Result<()> {
755 status(&["config", key, value]).with_context(|| format!("failed to set git config {key}"))
756}
757
758pub fn config_unset(key: &str) -> Result<()> {
759 output_codes(&["config", "--unset", key], &[5], "git config --unset").map(|_| ())
762}
763
764fn output_codes(args: &[&str], ok_empty: &[i32], label: &str) -> Result<Option<String>> {
769 let output = Command::new("git")
770 .args(args)
771 .stdout(Stdio::piped())
772 .stderr(Stdio::piped())
773 .output()
774 .context("failed to run git")?;
775
776 match output.status.code() {
777 Some(0) => Ok(Some(
778 String::from_utf8_lossy(&output.stdout).trim().to_owned(),
779 )),
780 Some(code) if ok_empty.contains(&code) => Ok(None),
781 _ => Err(command_error(label, &output.stderr)),
782 }
783}
784
785fn output(args: &[&str]) -> Result<String> {
786 let output = Command::new("git")
787 .args(args)
788 .stdout(Stdio::piped())
789 .stderr(Stdio::piped())
790 .output()
791 .context("failed to run git")?;
792
793 if output.status.success() {
794 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
795 } else {
796 Err(command_error("git", &output.stderr))
797 }
798}
799
800fn output_with_stdin(args: &[&str], input: &str) -> Result<String> {
803 let mut child = Command::new("git")
804 .args(args)
805 .stdin(Stdio::piped())
806 .stdout(Stdio::piped())
807 .stderr(Stdio::piped())
808 .spawn()
809 .context("failed to run git")?;
810 {
811 let mut stdin = child.stdin.take().context("git has no stdin")?;
812 stdin
813 .write_all(input.as_bytes())
814 .context("failed to write to git")?;
815 }
816 let output = child.wait_with_output().context("failed to run git")?;
817 if output.status.success() {
818 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
819 } else {
820 Err(command_error("git", &output.stderr))
821 }
822}
823
824fn status(args: &[&str]) -> Result<()> {
828 if verbose() {
829 return status_passthrough(args);
830 }
831
832 let output = Command::new("git")
833 .args(args)
834 .output()
835 .context("failed to run git")?;
836
837 if output.status.success() {
838 Ok(())
839 } else {
840 let _ = std::io::stdout().write_all(&output.stdout);
841 let _ = std::io::stderr().write_all(&output.stderr);
842 bail!("git exited with status {}", output.status)
843 }
844}
845
846fn status_passthrough(args: &[&str]) -> Result<()> {
849 let status = Command::new("git")
850 .args(args)
851 .status()
852 .context("failed to run git")?;
853
854 if status.success() {
855 Ok(())
856 } else {
857 bail!("git exited with status {status}")
858 }
859}
860
861fn command_error(command: &str, stderr: &[u8]) -> anyhow::Error {
862 let stderr = String::from_utf8_lossy(stderr).trim().to_owned();
863 if stderr.is_empty() {
864 anyhow!("{command} failed")
865 } else {
866 anyhow!("{command} failed: {stderr}")
867 }
868}
869
870#[cfg(test)]
871mod tests {
872 use super::*;
873
874 #[test]
875 fn a_merge_queue_rejection_is_downgraded_to_the_queued_refs() {
876 let stderr = "\
879remote: error: GH006: Protected branch update failed for refs/heads/feat/tf-deploy.
880remote: - A pull request for this branch has been added to a merge queue. Branches that
881remote: are queued for merging cannot be updated. To modify this branch, dequeue the
882remote: associated pull request.
883To github.com:higharc/product
884 + 016bb37...3a94024 feat/spa-env -> feat/spa-env (forced update)
885 ! [remote rejected] feat/tf-deploy -> feat/tf-deploy (protected branch hook declined)
886error: failed to push some refs to 'github.com:higharc/product'";
887 assert_eq!(
888 merge_queue_rejection(stderr),
889 Some(vec!["feat/tf-deploy".to_owned()])
890 );
891 }
892
893 #[test]
894 fn a_stale_lease_rejection_is_not_swallowed_even_with_a_queue_mention() {
895 let stderr = "\
898remote: GitHub found 270 vulnerabilities ... merge queue notes ...
899 ! [rejected] feat/tf-deploy -> feat/tf-deploy (stale info)
900error: failed to push some refs";
901 assert_eq!(merge_queue_rejection(stderr), None);
902 }
903
904 #[test]
905 fn no_queue_mention_is_not_a_queue_rejection() {
906 let stderr = " ! [remote rejected] feat/x -> feat/x (permission denied)";
907 assert_eq!(merge_queue_rejection(stderr), None);
908 }
909
910 #[test]
911 fn landed_branches_drops_only_the_held_ones() {
912 let attempted = [
913 "feat/a".to_owned(),
914 "feat/b".to_owned(),
915 "feat/c".to_owned(),
916 ];
917 assert_eq!(
920 landed_branches(&attempted, &["feat/b".to_owned()]),
921 vec!["feat/a".to_owned(), "feat/c".to_owned()]
922 );
923 assert_eq!(landed_branches(&attempted, &[]), attempted.to_vec());
925 assert!(landed_branches(&attempted, &attempted).is_empty());
927 }
928
929 #[test]
930 fn a_stale_lease_push_names_the_rejected_branch() {
931 let stderr = "\
934To github.com:higharc/product
935 3a94024..d63a2b2 feat/spa-env -> feat/spa-env
936 ! [rejected] feat/tf-deploy -> feat/tf-deploy (stale info)
937error: failed to push some refs to 'github.com:higharc/product'";
938 assert_eq!(
939 stale_rejection(stderr),
940 Some(vec!["feat/tf-deploy".to_owned()])
941 );
942 }
943
944 #[test]
945 fn a_non_fast_forward_push_is_treated_as_stale() {
946 let stderr = " ! [rejected] feat/x -> feat/x (non-fast-forward)";
947 assert_eq!(stale_rejection(stderr), Some(vec!["feat/x".to_owned()]));
948 }
949
950 #[test]
951 fn an_unrelated_push_failure_is_not_classified_as_stale() {
952 let stderr = " ! [remote rejected] feat/x -> feat/x (permission denied)";
954 assert_eq!(stale_rejection(stderr), None);
955 assert_eq!(stale_rejection("fatal: could not read from remote"), None);
956 }
957
958 #[test]
959 fn a_mixed_stale_and_non_stale_rejection_is_not_classified_as_stale() {
960 let stderr = "\
964 ! [rejected] feat/tf-deploy -> feat/tf-deploy (stale info)
965 ! [remote rejected] feat/locked -> feat/locked (permission denied)
966error: failed to push some refs";
967 assert_eq!(stale_rejection(stderr), None);
968 }
969
970 #[test]
971 fn help_mentions_update_refs_matches_pre_2_43_spelling() {
972 assert!(help_mentions_update_refs(
973 " --update-refs update branches that point to commits that are being rebased"
974 ));
975 }
976
977 #[test]
978 fn help_mentions_update_refs_matches_negatable_spelling() {
979 assert!(help_mentions_update_refs(
980 " --[no-]update-refs update branches that point to commits that are being rebased"
981 ));
982 }
983
984 #[test]
985 fn help_mentions_update_refs_rejects_help_without_the_option() {
986 assert!(!help_mentions_update_refs(
987 " --[no-]autosquash move commits that begin with squash!/fixup!"
988 ));
989 }
990
991 #[test]
992 fn detection_agrees_with_the_real_git_on_this_machine() {
993 let probe = Command::new("git")
996 .args(["rebase", "--update-refs", "-h"])
997 .stdout(Stdio::piped())
998 .stderr(Stdio::piped())
999 .output()
1000 .expect("run git rebase probe");
1001 let probe_text = format!(
1002 "{}{}",
1003 String::from_utf8_lossy(&probe.stdout),
1004 String::from_utf8_lossy(&probe.stderr)
1005 );
1006 let real_support = !probe_text.contains("unknown option");
1007
1008 assert_eq!(
1009 supports_rebase_update_refs().expect("detect support"),
1010 real_support
1011 );
1012 }
1013}