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 git_common_path(path: &str) -> Result<String> {
49 let common_dir = output(&["rev-parse", "--git-common-dir"])?;
50 Ok(std::path::Path::new(&common_dir)
51 .join(path)
52 .to_string_lossy()
53 .into_owned())
54}
55
56pub fn remote_url(remote: &str) -> Result<Option<String>> {
57 output_codes(&["remote", "get-url", remote], &[2], "git remote get-url")
59}
60
61pub fn checkout(branch: &str) -> Result<()> {
62 status(&["switch", branch]).with_context(|| format!("failed to check out {branch}"))?;
63 anstream::println!(
64 "switched to {}",
65 crate::style::paint(crate::style::BRANCH, branch)
66 );
67 Ok(())
68}
69
70pub fn create_branch(branch: &str) -> Result<()> {
71 status(&["switch", "-c", branch]).with_context(|| format!("failed to create branch {branch}"))
72}
73
74pub fn create_branch_at(branch: &str, sha: &str) -> Result<()> {
77 status(&["branch", branch, sha])
78 .with_context(|| format!("failed to create branch {branch} at {sha}"))
79}
80
81pub fn delete_branch(branch: &str) -> Result<()> {
85 status(&["branch", "-D", branch]).with_context(|| format!("failed to delete branch {branch}"))
86}
87
88pub fn rename_branch(old: &str, new: &str) -> Result<()> {
90 status(&["branch", "-m", old, new]).with_context(|| format!("failed to rename {old} to {new}"))
91}
92
93pub fn fetch_branch(remote: &str, branch: &str) -> Result<()> {
95 let refspec = format!("{branch}:{branch}");
96 status(&["fetch", remote, &refspec])
97 .with_context(|| format!("failed to fetch {branch} from {remote}"))
98}
99
100pub fn pull_ff_only() -> Result<()> {
101 status(&["pull", "--ff-only"]).context("failed to fast-forward from the remote")
102}
103
104pub fn push_force_with_lease(remote: &str, branches: &[String]) -> Result<()> {
105 let mut args = vec!["push", "--force-with-lease", remote];
106 args.extend(branches.iter().map(String::as_str));
107
108 status(&args).with_context(|| format!("failed to push branches to {remote}"))
109}
110
111pub fn push_set_upstream_force_with_lease(remote: &str, branches: &[String]) -> Result<()> {
114 let mut args = vec!["push", "--set-upstream", "--force-with-lease", remote];
115 args.extend(branches.iter().map(String::as_str));
116
117 status(&args).with_context(|| format!("failed to push branches to {remote}"))
118}
119
120pub fn write_blob_ref(reference: &str, file: &str, content: &str) -> Result<()> {
124 let blob = output_with_stdin(&["hash-object", "-w", "--stdin"], content)
125 .context("failed to hash stack metadata")?;
126 let tree = output_with_stdin(&["mktree"], &format!("100644 blob {blob}\t{file}\n"))
127 .context("failed to write stack metadata tree")?;
128 let commit = output(&["commit-tree", &tree, "-m", "git-stk stack metadata"])
129 .context("failed to commit stack metadata")?;
130 status(&["update-ref", reference, &commit])
131 .with_context(|| format!("failed to update {reference}"))
132}
133
134pub fn push_ref(remote: &str, reference: &str) -> Result<()> {
137 status(&[
138 "push",
139 "--force",
140 remote,
141 &format!("{reference}:{reference}"),
142 ])
143 .with_context(|| format!("failed to push {reference} to {remote}"))
144}
145
146pub fn fetch_ref(remote: &str, reference: &str) -> Result<()> {
148 status(&["fetch", remote, &format!("+{reference}:{reference}")])
149 .with_context(|| format!("failed to fetch {reference} from {remote}"))
150}
151
152pub fn read_ref_file(reference: &str, file: &str) -> Result<Option<String>> {
155 let output = Command::new("git")
156 .args(["cat-file", "blob", &format!("{reference}:{file}")])
157 .stdout(Stdio::piped())
158 .stderr(Stdio::piped())
159 .output()
160 .context("failed to run git cat-file")?;
161 if output.status.success() {
162 Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()))
163 } else {
164 Ok(None)
165 }
166}
167
168pub fn rebase(parent: &str, branch: &str, update_refs: bool) -> Result<()> {
169 let mut args = vec!["rebase"];
170 if update_refs {
171 args.push("--update-refs");
172 }
173 args.extend([parent, branch]);
174
175 status(&args).with_context(|| format!("failed to rebase {branch} onto {parent}"))
176}
177
178pub fn rebase_onto(parent: &str, base: &str, branch: &str, update_refs: bool) -> Result<()> {
182 let mut args = vec!["rebase"];
183 if update_refs {
184 args.push("--update-refs");
185 }
186 args.extend(["--onto", parent, base, branch]);
187
188 status(&args).with_context(|| format!("failed to rebase {branch} onto {parent} from {base}"))
189}
190
191pub fn rev_parse(rev: &str) -> Result<String> {
192 let spec = format!("{rev}^{{commit}}");
193 output(&["rev-parse", "--verify", &spec]).with_context(|| format!("failed to resolve {rev}"))
194}
195
196pub fn branch_sha(branch: &str) -> Option<String> {
198 rev_parse(branch).ok()
199}
200
201pub fn update_ref(branch: &str, sha: &str) -> Result<()> {
204 status(&["update-ref", &format!("refs/heads/{branch}"), sha])
205 .with_context(|| format!("failed to update {branch} to {sha}"))
206}
207
208pub fn reset_hard() -> Result<()> {
211 status(&["reset", "--hard"]).context("failed to reset the worktree")
212}
213
214pub fn worktree_is_clean() -> Result<bool> {
216 Ok(output(&["status", "--porcelain"])?.is_empty())
217}
218
219pub fn remote_default_branch(remote: &str) -> Option<String> {
221 let reference = format!("refs/remotes/{remote}/HEAD");
222 let full = output(&["symbolic-ref", "--short", &reference]).ok()?;
223 full.strip_prefix(&format!("{remote}/")).map(str::to_owned)
224}
225
226pub fn commits_behind(branch: &str, parent: &str) -> Result<usize> {
229 let range = format!("{branch}..{parent}");
230 let count = output(&["rev-list", "--count", &range])
231 .with_context(|| format!("failed to count commits in {range}"))?;
232 count
233 .trim()
234 .parse()
235 .context("failed to parse rev-list count")
236}
237
238pub fn merge_base(a: &str, b: &str) -> Result<String> {
239 output(&["merge-base", a, b])
240 .with_context(|| format!("failed to find merge base of {a} and {b}"))
241}
242
243pub fn diff_against_head(cached: bool) -> Result<String> {
247 let mut args = vec!["diff", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"];
250 if cached {
251 args.push("--cached");
252 }
253 args.push("HEAD");
254 output(&args).context("failed to diff against HEAD")
255}
256
257pub fn blame_line_shas(file: &str, start: usize, len: usize) -> Result<Vec<String>> {
260 if len == 0 {
261 return Ok(Vec::new());
262 }
263 let range = format!("{start},{}", start + len - 1);
264 let out = output(&[
265 "blame",
266 "HEAD",
267 "-L",
268 &range,
269 "--line-porcelain",
270 "--",
271 file,
272 ])
273 .with_context(|| format!("failed to blame {file}"))?;
274
275 let mut shas = Vec::new();
276 for line in out.lines() {
277 let token = line.split(' ').next().unwrap_or_default();
281 if token.len() == 40
282 && token.bytes().all(|byte| byte.is_ascii_hexdigit())
283 && !shas.iter().any(|seen| seen == token)
284 {
285 shas.push(token.to_owned());
286 }
287 }
288 Ok(shas)
289}
290
291pub fn rev_list(range: &str) -> Result<Vec<String>> {
293 Ok(output(&["rev-list", range])
294 .with_context(|| format!("failed to list commits in {range}"))?
295 .lines()
296 .map(str::to_owned)
297 .collect())
298}
299
300pub fn log_oneline(range: &str) -> Result<Vec<(String, String)>> {
303 Ok(output(&["log", "--format=%h%x09%s", range])
304 .with_context(|| format!("failed to log {range}"))?
305 .lines()
306 .filter_map(|line| {
307 line.split_once('\t')
308 .map(|(sha, subject)| (sha.to_owned(), subject.to_owned()))
309 })
310 .collect())
311}
312
313pub fn commit_subject(sha: &str) -> Result<String> {
315 output(&["show", "--no-patch", "--format=%s", sha])
316 .with_context(|| format!("failed to read subject of {sha}"))
317}
318
319pub fn commit_body(sha: &str) -> Result<String> {
321 output(&["show", "--no-patch", "--format=%b", sha])
322 .with_context(|| format!("failed to read body of {sha}"))
323}
324
325pub fn apply_cached(patch: &str) -> Result<()> {
328 let mut child = Command::new("git")
329 .args(["apply", "--cached", "--unidiff-zero"])
330 .stdin(Stdio::piped())
331 .stdout(Stdio::piped())
332 .stderr(Stdio::piped())
333 .spawn()
334 .context("failed to run git apply")?;
335 {
336 let mut stdin = child.stdin.take().context("git apply has no stdin")?;
337 stdin
338 .write_all(patch.as_bytes())
339 .context("failed to write patch to git apply")?;
340 }
341 let output = child
342 .wait_with_output()
343 .context("failed to run git apply")?;
344 if output.status.success() {
345 Ok(())
346 } else {
347 Err(command_error("git apply", &output.stderr))
348 }
349}
350
351pub fn commit_fixup(sha: &str) -> Result<()> {
354 status(&["commit", "--no-verify", &format!("--fixup={sha}")])
355 .with_context(|| format!("failed to create fixup commit for {sha}"))
356}
357
358pub fn reset_index() -> Result<()> {
360 status(&["reset", "--quiet"]).context("failed to reset the index")
361}
362
363pub fn reset_soft(sha: &str) -> Result<()> {
365 status(&["reset", "--soft", sha]).with_context(|| format!("failed to reset to {sha}"))
366}
367
368pub fn stash_push() -> Result<()> {
370 status(&["stash", "push", "--quiet"]).context("failed to stash changes")
371}
372
373pub fn stash_pop() -> Result<()> {
375 status(&["stash", "pop", "--quiet"]).context("failed to restore stashed changes")
376}
377
378pub fn rebase_autosquash(base: &str, update_refs: bool) -> Result<()> {
381 let mut args = vec!["rebase", "--interactive", "--autosquash"];
382 if update_refs {
383 args.push("--update-refs");
384 }
385 args.push(base);
386
387 let output = Command::new("git")
388 .args(&args)
389 .env("GIT_SEQUENCE_EDITOR", "true")
390 .env("GIT_EDITOR", "true")
391 .output()
392 .context("failed to run git rebase")?;
393 if output.status.success() {
394 Ok(())
395 } else {
396 Err(command_error("git rebase --autosquash", &output.stderr))
397 }
398}
399
400pub fn is_ancestor(ancestor: &str, descendant: &str) -> Result<bool> {
401 Ok(output_codes(
403 &["merge-base", "--is-ancestor", ancestor, descendant],
404 &[1],
405 "git merge-base --is-ancestor",
406 )?
407 .is_some())
408}
409
410pub fn diff_numstat(base: &str, branch: &str) -> Result<(usize, usize)> {
415 let output = output(&["diff", "--numstat", &format!("{base}...{branch}")])?;
416 let mut added = 0;
417 let mut deleted = 0;
418 for line in output.lines() {
419 let mut columns = line.split('\t');
420 added += column_count(columns.next());
421 deleted += column_count(columns.next());
422 }
423 Ok((added, deleted))
424}
425
426fn column_count(column: Option<&str>) -> usize {
429 column
430 .and_then(|value| value.parse::<usize>().ok())
431 .unwrap_or(0)
432}
433
434pub fn supports_rebase_update_refs() -> Result<bool> {
435 let output = Command::new("git")
436 .args(["rebase", "-h"])
437 .stdout(Stdio::piped())
438 .stderr(Stdio::piped())
439 .output()
440 .context("failed to inspect git rebase help")?;
441
442 let help = format!(
443 "{}{}",
444 String::from_utf8_lossy(&output.stdout),
445 String::from_utf8_lossy(&output.stderr)
446 );
447 Ok(help_mentions_update_refs(&help))
448}
449
450fn help_mentions_update_refs(help: &str) -> bool {
453 help.contains("update-refs")
454}
455
456pub fn rebase_continue() -> Result<()> {
457 status_passthrough(&["rebase", "--continue"]).context("failed to continue rebase")
459}
460
461pub fn rebase_abort() -> Result<()> {
462 status(&["rebase", "--abort"]).context("failed to abort rebase")
463}
464
465pub fn config_get(key: &str) -> Result<Option<String>> {
466 output_codes(&["config", "--get", key], &[1], "git config --get")
468}
469
470pub fn config_get_bool(key: &str) -> Result<Option<bool>> {
471 let Some(value) = output_codes(
472 &["config", "--type=bool", "--get", key],
473 &[1],
474 "git config --type=bool --get",
475 )?
476 else {
477 return Ok(None);
478 };
479 match value.as_str() {
480 "true" => Ok(Some(true)),
481 "false" => Ok(Some(false)),
482 _ => bail!("git config {key} is not a boolean: {value}"),
483 }
484}
485
486pub fn config_get_regexp(pattern: &str) -> Result<Vec<(String, String)>> {
487 let Some(text) = output_codes(
489 &["config", "--get-regexp", pattern],
490 &[1],
491 "git config --get-regexp",
492 )?
493 else {
494 return Ok(Vec::new());
495 };
496 Ok(text
497 .lines()
498 .filter_map(|line| {
499 line.split_once(' ')
500 .map(|(key, value)| (key.to_owned(), value.to_owned()))
501 })
502 .collect())
503}
504
505pub fn config_set(key: &str, value: &str) -> Result<()> {
506 status(&["config", key, value]).with_context(|| format!("failed to set git config {key}"))
507}
508
509pub fn config_unset(key: &str) -> Result<()> {
510 output_codes(&["config", "--unset", key], &[5], "git config --unset").map(|_| ())
513}
514
515fn output_codes(args: &[&str], ok_empty: &[i32], label: &str) -> Result<Option<String>> {
520 let output = Command::new("git")
521 .args(args)
522 .stdout(Stdio::piped())
523 .stderr(Stdio::piped())
524 .output()
525 .context("failed to run git")?;
526
527 match output.status.code() {
528 Some(0) => Ok(Some(
529 String::from_utf8_lossy(&output.stdout).trim().to_owned(),
530 )),
531 Some(code) if ok_empty.contains(&code) => Ok(None),
532 _ => Err(command_error(label, &output.stderr)),
533 }
534}
535
536fn output(args: &[&str]) -> Result<String> {
537 let output = Command::new("git")
538 .args(args)
539 .stdout(Stdio::piped())
540 .stderr(Stdio::piped())
541 .output()
542 .context("failed to run git")?;
543
544 if output.status.success() {
545 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
546 } else {
547 Err(command_error("git", &output.stderr))
548 }
549}
550
551fn output_with_stdin(args: &[&str], input: &str) -> Result<String> {
554 let mut child = Command::new("git")
555 .args(args)
556 .stdin(Stdio::piped())
557 .stdout(Stdio::piped())
558 .stderr(Stdio::piped())
559 .spawn()
560 .context("failed to run git")?;
561 {
562 let mut stdin = child.stdin.take().context("git has no stdin")?;
563 stdin
564 .write_all(input.as_bytes())
565 .context("failed to write to git")?;
566 }
567 let output = child.wait_with_output().context("failed to run git")?;
568 if output.status.success() {
569 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
570 } else {
571 Err(command_error("git", &output.stderr))
572 }
573}
574
575fn status(args: &[&str]) -> Result<()> {
579 if verbose() {
580 return status_passthrough(args);
581 }
582
583 let output = Command::new("git")
584 .args(args)
585 .output()
586 .context("failed to run git")?;
587
588 if output.status.success() {
589 Ok(())
590 } else {
591 let _ = std::io::stdout().write_all(&output.stdout);
592 let _ = std::io::stderr().write_all(&output.stderr);
593 bail!("git exited with status {}", output.status)
594 }
595}
596
597fn status_passthrough(args: &[&str]) -> Result<()> {
600 let status = Command::new("git")
601 .args(args)
602 .status()
603 .context("failed to run git")?;
604
605 if status.success() {
606 Ok(())
607 } else {
608 bail!("git exited with status {status}")
609 }
610}
611
612fn command_error(command: &str, stderr: &[u8]) -> anyhow::Error {
613 let stderr = String::from_utf8_lossy(stderr).trim().to_owned();
614 if stderr.is_empty() {
615 anyhow!("{command} failed")
616 } else {
617 anyhow!("{command} failed: {stderr}")
618 }
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624
625 #[test]
626 fn help_mentions_update_refs_matches_pre_2_43_spelling() {
627 assert!(help_mentions_update_refs(
628 " --update-refs update branches that point to commits that are being rebased"
629 ));
630 }
631
632 #[test]
633 fn help_mentions_update_refs_matches_negatable_spelling() {
634 assert!(help_mentions_update_refs(
635 " --[no-]update-refs update branches that point to commits that are being rebased"
636 ));
637 }
638
639 #[test]
640 fn help_mentions_update_refs_rejects_help_without_the_option() {
641 assert!(!help_mentions_update_refs(
642 " --[no-]autosquash move commits that begin with squash!/fixup!"
643 ));
644 }
645
646 #[test]
647 fn detection_agrees_with_the_real_git_on_this_machine() {
648 let probe = Command::new("git")
651 .args(["rebase", "--update-refs", "-h"])
652 .stdout(Stdio::piped())
653 .stderr(Stdio::piped())
654 .output()
655 .expect("run git rebase probe");
656 let probe_text = format!(
657 "{}{}",
658 String::from_utf8_lossy(&probe.stdout),
659 String::from_utf8_lossy(&probe.stderr)
660 );
661 let real_support = !probe_text.contains("unknown option");
662
663 assert_eq!(
664 supports_rebase_update_refs().expect("detect support"),
665 real_support
666 );
667 }
668}