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