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