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<()> {
113 let mut args = vec!["push", "--force-with-lease", remote];
114 args.extend(branches.iter().map(String::as_str));
115
116 status(&args).with_context(|| format!("failed to push branches to {remote}"))
117}
118
119pub fn push_set_upstream_force_with_lease(remote: &str, branches: &[String]) -> Result<()> {
122 let mut args = vec!["push", "--set-upstream", "--force-with-lease", remote];
123 args.extend(branches.iter().map(String::as_str));
124
125 status(&args).with_context(|| format!("failed to push branches to {remote}"))
126}
127
128pub fn write_blob_ref(reference: &str, file: &str, content: &str) -> Result<()> {
132 let blob = output_with_stdin(&["hash-object", "-w", "--stdin"], content)
133 .context("failed to hash stack metadata")?;
134 let tree = output_with_stdin(&["mktree"], &format!("100644 blob {blob}\t{file}\n"))
135 .context("failed to write stack metadata tree")?;
136 let commit = output(&["commit-tree", &tree, "-m", "git-stk stack metadata"])
137 .context("failed to commit stack metadata")?;
138 status(&["update-ref", reference, &commit])
139 .with_context(|| format!("failed to update {reference}"))
140}
141
142pub fn push_ref(remote: &str, reference: &str) -> Result<()> {
145 status(&[
146 "push",
147 "--force",
148 remote,
149 &format!("{reference}:{reference}"),
150 ])
151 .with_context(|| format!("failed to push {reference} to {remote}"))
152}
153
154pub fn fetch_ref(remote: &str, reference: &str) -> Result<()> {
156 status(&["fetch", remote, &format!("+{reference}:{reference}")])
157 .with_context(|| format!("failed to fetch {reference} from {remote}"))
158}
159
160pub fn read_ref_file(reference: &str, file: &str) -> Result<Option<String>> {
163 let output = Command::new("git")
164 .args(["cat-file", "blob", &format!("{reference}:{file}")])
165 .stdout(Stdio::piped())
166 .stderr(Stdio::piped())
167 .output()
168 .context("failed to run git cat-file")?;
169 if output.status.success() {
170 Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()))
171 } else {
172 Ok(None)
173 }
174}
175
176pub fn rebase(parent: &str, branch: &str, update_refs: bool) -> Result<()> {
177 let mut args = vec!["rebase"];
178 if update_refs {
179 args.push("--update-refs");
180 }
181 args.extend([parent, branch]);
182
183 status(&args).with_context(|| format!("failed to rebase {branch} onto {parent}"))
184}
185
186pub fn rebase_onto(parent: &str, base: &str, branch: &str, update_refs: bool) -> Result<()> {
190 let mut args = vec!["rebase"];
191 if update_refs {
192 args.push("--update-refs");
193 }
194 args.extend(["--onto", parent, base, branch]);
195
196 status(&args).with_context(|| format!("failed to rebase {branch} onto {parent} from {base}"))
197}
198
199pub fn rev_parse(rev: &str) -> Result<String> {
200 let spec = format!("{rev}^{{commit}}");
201 output(&["rev-parse", "--verify", &spec]).with_context(|| format!("failed to resolve {rev}"))
202}
203
204pub fn branch_sha(branch: &str) -> Option<String> {
206 rev_parse(branch).ok()
207}
208
209pub fn update_ref(branch: &str, sha: &str) -> Result<()> {
212 status(&["update-ref", &format!("refs/heads/{branch}"), sha])
213 .with_context(|| format!("failed to update {branch} to {sha}"))
214}
215
216pub fn reset_hard() -> Result<()> {
219 status(&["reset", "--hard"]).context("failed to reset the worktree")
220}
221
222pub fn worktree_is_clean() -> Result<bool> {
224 Ok(output(&["status", "--porcelain"])?.is_empty())
225}
226
227pub fn remote_default_branch(remote: &str) -> Option<String> {
229 let reference = format!("refs/remotes/{remote}/HEAD");
230 let full = output(&["symbolic-ref", "--short", &reference]).ok()?;
231 full.strip_prefix(&format!("{remote}/")).map(str::to_owned)
232}
233
234pub fn commits_behind(branch: &str, parent: &str) -> Result<usize> {
237 let range = format!("{branch}..{parent}");
238 let count = output(&["rev-list", "--count", &range])
239 .with_context(|| format!("failed to count commits in {range}"))?;
240 count
241 .trim()
242 .parse()
243 .context("failed to parse rev-list count")
244}
245
246pub fn merge_base(a: &str, b: &str) -> Result<String> {
247 output(&["merge-base", a, b])
248 .with_context(|| format!("failed to find merge base of {a} and {b}"))
249}
250
251pub fn diff_against_head(cached: bool) -> Result<String> {
255 let mut args = vec!["diff", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"];
258 if cached {
259 args.push("--cached");
260 }
261 args.push("HEAD");
262 output(&args).context("failed to diff against HEAD")
263}
264
265pub fn blame_line_shas(file: &str, start: usize, len: usize) -> Result<Vec<String>> {
268 if len == 0 {
269 return Ok(Vec::new());
270 }
271 let range = format!("{start},{}", start + len - 1);
272 let out = output(&[
273 "blame",
274 "HEAD",
275 "-L",
276 &range,
277 "--line-porcelain",
278 "--",
279 file,
280 ])
281 .with_context(|| format!("failed to blame {file}"))?;
282
283 let mut shas = Vec::new();
284 for line in out.lines() {
285 let token = line.split(' ').next().unwrap_or_default();
289 if token.len() == 40
290 && token.bytes().all(|byte| byte.is_ascii_hexdigit())
291 && !shas.iter().any(|seen| seen == token)
292 {
293 shas.push(token.to_owned());
294 }
295 }
296 Ok(shas)
297}
298
299pub fn rev_list(range: &str) -> Result<Vec<String>> {
301 Ok(output(&["rev-list", range])
302 .with_context(|| format!("failed to list commits in {range}"))?
303 .lines()
304 .map(str::to_owned)
305 .collect())
306}
307
308pub fn log_oneline(range: &str) -> Result<Vec<(String, String)>> {
311 Ok(output(&["log", "--format=%h%x09%s", range])
312 .with_context(|| format!("failed to log {range}"))?
313 .lines()
314 .filter_map(|line| {
315 line.split_once('\t')
316 .map(|(sha, subject)| (sha.to_owned(), subject.to_owned()))
317 })
318 .collect())
319}
320
321pub fn commit_subject(sha: &str) -> Result<String> {
323 output(&["show", "--no-patch", "--format=%s", sha])
324 .with_context(|| format!("failed to read subject of {sha}"))
325}
326
327pub fn commit_body(sha: &str) -> Result<String> {
329 output(&["show", "--no-patch", "--format=%b", sha])
330 .with_context(|| format!("failed to read body of {sha}"))
331}
332
333pub fn apply_cached(patch: &str) -> Result<()> {
336 let mut child = Command::new("git")
337 .args(["apply", "--cached", "--unidiff-zero"])
338 .stdin(Stdio::piped())
339 .stdout(Stdio::piped())
340 .stderr(Stdio::piped())
341 .spawn()
342 .context("failed to run git apply")?;
343 {
344 let mut stdin = child.stdin.take().context("git apply has no stdin")?;
345 stdin
346 .write_all(patch.as_bytes())
347 .context("failed to write patch to git apply")?;
348 }
349 let output = child
350 .wait_with_output()
351 .context("failed to run git apply")?;
352 if output.status.success() {
353 Ok(())
354 } else {
355 Err(command_error("git apply", &output.stderr))
356 }
357}
358
359pub fn commit_fixup(sha: &str) -> Result<()> {
362 status(&["commit", "--no-verify", &format!("--fixup={sha}")])
363 .with_context(|| format!("failed to create fixup commit for {sha}"))
364}
365
366pub fn reset_index() -> Result<()> {
368 status(&["reset", "--quiet"]).context("failed to reset the index")
369}
370
371pub fn reset_soft(sha: &str) -> Result<()> {
373 status(&["reset", "--soft", sha]).with_context(|| format!("failed to reset to {sha}"))
374}
375
376pub fn stash_push() -> Result<()> {
378 status(&["stash", "push", "--quiet"]).context("failed to stash changes")
379}
380
381pub fn stash_pop() -> Result<()> {
383 status(&["stash", "pop", "--quiet"]).context("failed to restore stashed changes")
384}
385
386pub fn rebase_autosquash(base: &str, update_refs: bool) -> Result<()> {
389 let mut args = vec!["rebase", "--interactive", "--autosquash"];
390 if update_refs {
391 args.push("--update-refs");
392 }
393 args.push(base);
394
395 let output = Command::new("git")
396 .args(&args)
397 .env("GIT_SEQUENCE_EDITOR", "true")
398 .env("GIT_EDITOR", "true")
399 .output()
400 .context("failed to run git rebase")?;
401 if output.status.success() {
402 Ok(())
403 } else {
404 Err(command_error("git rebase --autosquash", &output.stderr))
405 }
406}
407
408pub fn is_ancestor(ancestor: &str, descendant: &str) -> Result<bool> {
409 Ok(output_codes(
411 &["merge-base", "--is-ancestor", ancestor, descendant],
412 &[1],
413 "git merge-base --is-ancestor",
414 )?
415 .is_some())
416}
417
418pub fn diff_numstat(base: &str, branch: &str) -> Result<(usize, usize)> {
423 let output = output(&["diff", "--numstat", &format!("{base}...{branch}")])?;
424 let mut added = 0;
425 let mut deleted = 0;
426 for line in output.lines() {
427 let mut columns = line.split('\t');
428 added += column_count(columns.next());
429 deleted += column_count(columns.next());
430 }
431 Ok((added, deleted))
432}
433
434fn column_count(column: Option<&str>) -> usize {
437 column
438 .and_then(|value| value.parse::<usize>().ok())
439 .unwrap_or(0)
440}
441
442pub fn supports_rebase_update_refs() -> Result<bool> {
443 let output = Command::new("git")
444 .args(["rebase", "-h"])
445 .stdout(Stdio::piped())
446 .stderr(Stdio::piped())
447 .output()
448 .context("failed to inspect git rebase help")?;
449
450 let help = format!(
451 "{}{}",
452 String::from_utf8_lossy(&output.stdout),
453 String::from_utf8_lossy(&output.stderr)
454 );
455 Ok(help_mentions_update_refs(&help))
456}
457
458fn help_mentions_update_refs(help: &str) -> bool {
461 help.contains("update-refs")
462}
463
464pub fn rebase_continue() -> Result<()> {
465 status_passthrough(&["rebase", "--continue"]).context("failed to continue rebase")
467}
468
469pub fn rebase_abort() -> Result<()> {
470 status(&["rebase", "--abort"]).context("failed to abort rebase")
471}
472
473pub fn config_get(key: &str) -> Result<Option<String>> {
474 output_codes(&["config", "--get", key], &[1], "git config --get")
476}
477
478pub fn config_get_bool(key: &str) -> Result<Option<bool>> {
479 let Some(value) = output_codes(
480 &["config", "--type=bool", "--get", key],
481 &[1],
482 "git config --type=bool --get",
483 )?
484 else {
485 return Ok(None);
486 };
487 match value.as_str() {
488 "true" => Ok(Some(true)),
489 "false" => Ok(Some(false)),
490 _ => bail!("git config {key} is not a boolean: {value}"),
491 }
492}
493
494pub fn config_get_regexp(pattern: &str) -> Result<Vec<(String, String)>> {
495 let Some(text) = output_codes(
497 &["config", "--get-regexp", pattern],
498 &[1],
499 "git config --get-regexp",
500 )?
501 else {
502 return Ok(Vec::new());
503 };
504 Ok(text
505 .lines()
506 .filter_map(|line| {
507 line.split_once(' ')
508 .map(|(key, value)| (key.to_owned(), value.to_owned()))
509 })
510 .collect())
511}
512
513pub fn config_set(key: &str, value: &str) -> Result<()> {
514 status(&["config", key, value]).with_context(|| format!("failed to set git config {key}"))
515}
516
517pub fn config_unset(key: &str) -> Result<()> {
518 output_codes(&["config", "--unset", key], &[5], "git config --unset").map(|_| ())
521}
522
523fn output_codes(args: &[&str], ok_empty: &[i32], label: &str) -> Result<Option<String>> {
528 let output = Command::new("git")
529 .args(args)
530 .stdout(Stdio::piped())
531 .stderr(Stdio::piped())
532 .output()
533 .context("failed to run git")?;
534
535 match output.status.code() {
536 Some(0) => Ok(Some(
537 String::from_utf8_lossy(&output.stdout).trim().to_owned(),
538 )),
539 Some(code) if ok_empty.contains(&code) => Ok(None),
540 _ => Err(command_error(label, &output.stderr)),
541 }
542}
543
544fn output(args: &[&str]) -> Result<String> {
545 let output = Command::new("git")
546 .args(args)
547 .stdout(Stdio::piped())
548 .stderr(Stdio::piped())
549 .output()
550 .context("failed to run git")?;
551
552 if output.status.success() {
553 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
554 } else {
555 Err(command_error("git", &output.stderr))
556 }
557}
558
559fn output_with_stdin(args: &[&str], input: &str) -> Result<String> {
562 let mut child = Command::new("git")
563 .args(args)
564 .stdin(Stdio::piped())
565 .stdout(Stdio::piped())
566 .stderr(Stdio::piped())
567 .spawn()
568 .context("failed to run git")?;
569 {
570 let mut stdin = child.stdin.take().context("git has no stdin")?;
571 stdin
572 .write_all(input.as_bytes())
573 .context("failed to write to git")?;
574 }
575 let output = child.wait_with_output().context("failed to run git")?;
576 if output.status.success() {
577 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
578 } else {
579 Err(command_error("git", &output.stderr))
580 }
581}
582
583fn status(args: &[&str]) -> Result<()> {
587 if verbose() {
588 return status_passthrough(args);
589 }
590
591 let output = Command::new("git")
592 .args(args)
593 .output()
594 .context("failed to run git")?;
595
596 if output.status.success() {
597 Ok(())
598 } else {
599 let _ = std::io::stdout().write_all(&output.stdout);
600 let _ = std::io::stderr().write_all(&output.stderr);
601 bail!("git exited with status {}", output.status)
602 }
603}
604
605fn status_passthrough(args: &[&str]) -> Result<()> {
608 let status = Command::new("git")
609 .args(args)
610 .status()
611 .context("failed to run git")?;
612
613 if status.success() {
614 Ok(())
615 } else {
616 bail!("git exited with status {status}")
617 }
618}
619
620fn command_error(command: &str, stderr: &[u8]) -> anyhow::Error {
621 let stderr = String::from_utf8_lossy(stderr).trim().to_owned();
622 if stderr.is_empty() {
623 anyhow!("{command} failed")
624 } else {
625 anyhow!("{command} failed: {stderr}")
626 }
627}
628
629#[cfg(test)]
630mod tests {
631 use super::*;
632
633 #[test]
634 fn help_mentions_update_refs_matches_pre_2_43_spelling() {
635 assert!(help_mentions_update_refs(
636 " --update-refs update branches that point to commits that are being rebased"
637 ));
638 }
639
640 #[test]
641 fn help_mentions_update_refs_matches_negatable_spelling() {
642 assert!(help_mentions_update_refs(
643 " --[no-]update-refs update branches that point to commits that are being rebased"
644 ));
645 }
646
647 #[test]
648 fn help_mentions_update_refs_rejects_help_without_the_option() {
649 assert!(!help_mentions_update_refs(
650 " --[no-]autosquash move commits that begin with squash!/fixup!"
651 ));
652 }
653
654 #[test]
655 fn detection_agrees_with_the_real_git_on_this_machine() {
656 let probe = Command::new("git")
659 .args(["rebase", "--update-refs", "-h"])
660 .stdout(Stdio::piped())
661 .stderr(Stdio::piped())
662 .output()
663 .expect("run git rebase probe");
664 let probe_text = format!(
665 "{}{}",
666 String::from_utf8_lossy(&probe.stdout),
667 String::from_utf8_lossy(&probe.stderr)
668 );
669 let real_support = !probe_text.contains("unknown option");
670
671 assert_eq!(
672 supports_rebase_update_refs().expect("detect support"),
673 real_support
674 );
675 }
676}