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 remote_url(remote: &str) -> Result<Option<String>> {
33 let output = Command::new("git")
34 .args(["remote", "get-url", remote])
35 .stdout(Stdio::piped())
36 .stderr(Stdio::piped())
37 .output()
38 .with_context(|| format!("failed to read git remote {remote}"))?;
39
40 match output.status.code() {
41 Some(0) => Ok(Some(
42 String::from_utf8_lossy(&output.stdout).trim().to_owned(),
43 )),
44 Some(2) => Ok(None),
45 _ => Err(command_error("git remote get-url", &output.stderr)),
46 }
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 rebase(parent: &str, branch: &str, update_refs: bool) -> Result<()> {
102 let mut args = vec!["rebase"];
103 if update_refs {
104 args.push("--update-refs");
105 }
106 args.extend([parent, branch]);
107
108 status(&args).with_context(|| format!("failed to rebase {branch} onto {parent}"))
109}
110
111pub fn rebase_onto(parent: &str, base: &str, branch: &str, update_refs: bool) -> Result<()> {
115 let mut args = vec!["rebase"];
116 if update_refs {
117 args.push("--update-refs");
118 }
119 args.extend(["--onto", parent, base, branch]);
120
121 status(&args).with_context(|| format!("failed to rebase {branch} onto {parent} from {base}"))
122}
123
124pub fn rev_parse(rev: &str) -> Result<String> {
125 let spec = format!("{rev}^{{commit}}");
126 output(&["rev-parse", "--verify", &spec]).with_context(|| format!("failed to resolve {rev}"))
127}
128
129pub fn branch_sha(branch: &str) -> Option<String> {
131 rev_parse(branch).ok()
132}
133
134pub fn update_ref(branch: &str, sha: &str) -> Result<()> {
137 status(&["update-ref", &format!("refs/heads/{branch}"), sha])
138 .with_context(|| format!("failed to update {branch} to {sha}"))
139}
140
141pub fn reset_hard() -> Result<()> {
144 status(&["reset", "--hard"]).context("failed to reset the worktree")
145}
146
147pub fn worktree_is_clean() -> Result<bool> {
149 Ok(output(&["status", "--porcelain"])?.is_empty())
150}
151
152pub fn remote_default_branch(remote: &str) -> Option<String> {
154 let reference = format!("refs/remotes/{remote}/HEAD");
155 let full = output(&["symbolic-ref", "--short", &reference]).ok()?;
156 full.strip_prefix(&format!("{remote}/")).map(str::to_owned)
157}
158
159pub fn commits_behind(branch: &str, parent: &str) -> Result<usize> {
162 let range = format!("{branch}..{parent}");
163 let count = output(&["rev-list", "--count", &range])
164 .with_context(|| format!("failed to count commits in {range}"))?;
165 count
166 .trim()
167 .parse()
168 .context("failed to parse rev-list count")
169}
170
171pub fn merge_base(a: &str, b: &str) -> Result<String> {
172 output(&["merge-base", a, b])
173 .with_context(|| format!("failed to find merge base of {a} and {b}"))
174}
175
176pub fn diff_against_head(cached: bool) -> Result<String> {
180 let mut args = vec!["diff", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"];
183 if cached {
184 args.push("--cached");
185 }
186 args.push("HEAD");
187 output(&args).context("failed to diff against HEAD")
188}
189
190pub fn blame_line_shas(file: &str, start: usize, len: usize) -> Result<Vec<String>> {
193 if len == 0 {
194 return Ok(Vec::new());
195 }
196 let range = format!("{start},{}", start + len - 1);
197 let out = output(&[
198 "blame",
199 "HEAD",
200 "-L",
201 &range,
202 "--line-porcelain",
203 "--",
204 file,
205 ])
206 .with_context(|| format!("failed to blame {file}"))?;
207
208 let mut shas = Vec::new();
209 for line in out.lines() {
210 let token = line.split(' ').next().unwrap_or_default();
214 if token.len() == 40
215 && token.bytes().all(|byte| byte.is_ascii_hexdigit())
216 && !shas.iter().any(|seen| seen == token)
217 {
218 shas.push(token.to_owned());
219 }
220 }
221 Ok(shas)
222}
223
224pub fn rev_list(range: &str) -> Result<Vec<String>> {
226 Ok(output(&["rev-list", range])
227 .with_context(|| format!("failed to list commits in {range}"))?
228 .lines()
229 .map(str::to_owned)
230 .collect())
231}
232
233pub fn commit_subject(sha: &str) -> Result<String> {
235 output(&["show", "--no-patch", "--format=%s", sha])
236 .with_context(|| format!("failed to read subject of {sha}"))
237}
238
239pub fn apply_cached(patch: &str) -> Result<()> {
242 let mut child = Command::new("git")
243 .args(["apply", "--cached", "--unidiff-zero"])
244 .stdin(Stdio::piped())
245 .stdout(Stdio::piped())
246 .stderr(Stdio::piped())
247 .spawn()
248 .context("failed to run git apply")?;
249 {
250 let mut stdin = child.stdin.take().context("git apply has no stdin")?;
251 stdin
252 .write_all(patch.as_bytes())
253 .context("failed to write patch to git apply")?;
254 }
255 let output = child
256 .wait_with_output()
257 .context("failed to run git apply")?;
258 if output.status.success() {
259 Ok(())
260 } else {
261 Err(command_error("git apply", &output.stderr))
262 }
263}
264
265pub fn commit_fixup(sha: &str) -> Result<()> {
268 status(&["commit", "--no-verify", &format!("--fixup={sha}")])
269 .with_context(|| format!("failed to create fixup commit for {sha}"))
270}
271
272pub fn reset_index() -> Result<()> {
274 status(&["reset", "--quiet"]).context("failed to reset the index")
275}
276
277pub fn reset_soft(sha: &str) -> Result<()> {
279 status(&["reset", "--soft", sha]).with_context(|| format!("failed to reset to {sha}"))
280}
281
282pub fn stash_push() -> Result<()> {
284 status(&["stash", "push", "--quiet"]).context("failed to stash changes")
285}
286
287pub fn stash_pop() -> Result<()> {
289 status(&["stash", "pop", "--quiet"]).context("failed to restore stashed changes")
290}
291
292pub fn rebase_autosquash(base: &str, update_refs: bool) -> Result<()> {
295 let mut args = vec!["rebase", "--interactive", "--autosquash"];
296 if update_refs {
297 args.push("--update-refs");
298 }
299 args.push(base);
300
301 let output = Command::new("git")
302 .args(&args)
303 .env("GIT_SEQUENCE_EDITOR", "true")
304 .env("GIT_EDITOR", "true")
305 .output()
306 .context("failed to run git rebase")?;
307 if output.status.success() {
308 Ok(())
309 } else {
310 Err(command_error("git rebase --autosquash", &output.stderr))
311 }
312}
313
314pub fn is_ancestor(ancestor: &str, descendant: &str) -> Result<bool> {
315 let output = Command::new("git")
316 .args(["merge-base", "--is-ancestor", ancestor, descendant])
317 .stdout(Stdio::piped())
318 .stderr(Stdio::piped())
319 .output()
320 .context("failed to run git merge-base --is-ancestor")?;
321
322 match output.status.code() {
323 Some(0) => Ok(true),
324 Some(1) => Ok(false),
325 _ => Err(command_error(
326 "git merge-base --is-ancestor",
327 &output.stderr,
328 )),
329 }
330}
331
332pub fn supports_rebase_update_refs() -> Result<bool> {
333 let output = Command::new("git")
334 .args(["rebase", "-h"])
335 .stdout(Stdio::piped())
336 .stderr(Stdio::piped())
337 .output()
338 .context("failed to inspect git rebase help")?;
339
340 let help = format!(
341 "{}{}",
342 String::from_utf8_lossy(&output.stdout),
343 String::from_utf8_lossy(&output.stderr)
344 );
345 Ok(help_mentions_update_refs(&help))
346}
347
348fn help_mentions_update_refs(help: &str) -> bool {
351 help.contains("update-refs")
352}
353
354pub fn rebase_continue() -> Result<()> {
355 status_passthrough(&["rebase", "--continue"]).context("failed to continue rebase")
357}
358
359pub fn rebase_abort() -> Result<()> {
360 status(&["rebase", "--abort"]).context("failed to abort rebase")
361}
362
363pub fn config_get(key: &str) -> Result<Option<String>> {
364 let output = Command::new("git")
365 .args(["config", "--get", key])
366 .stdout(Stdio::piped())
367 .stderr(Stdio::piped())
368 .output()
369 .with_context(|| format!("failed to read git config {key}"))?;
370
371 match output.status.code() {
372 Some(0) => Ok(Some(
373 String::from_utf8_lossy(&output.stdout).trim().to_owned(),
374 )),
375 Some(1) => Ok(None),
376 _ => Err(command_error("git config --get", &output.stderr)),
377 }
378}
379
380pub fn config_get_bool(key: &str) -> Result<Option<bool>> {
381 let output = Command::new("git")
382 .args(["config", "--type=bool", "--get", key])
383 .stdout(Stdio::piped())
384 .stderr(Stdio::piped())
385 .output()
386 .with_context(|| format!("failed to read git config {key}"))?;
387
388 match output.status.code() {
389 Some(0) => {
390 let value = String::from_utf8_lossy(&output.stdout).trim().to_owned();
391 match value.as_str() {
392 "true" => Ok(Some(true)),
393 "false" => Ok(Some(false)),
394 _ => bail!("git config {key} is not a boolean: {value}"),
395 }
396 }
397 Some(1) => Ok(None),
398 _ => Err(command_error(
399 "git config --type=bool --get",
400 &output.stderr,
401 )),
402 }
403}
404
405pub fn config_get_regexp(pattern: &str) -> Result<Vec<(String, String)>> {
406 let output = Command::new("git")
407 .args(["config", "--get-regexp", pattern])
408 .stdout(Stdio::piped())
409 .stderr(Stdio::piped())
410 .output()
411 .with_context(|| format!("failed to read git config matching {pattern}"))?;
412
413 match output.status.code() {
414 Some(0) => Ok(String::from_utf8_lossy(&output.stdout)
415 .lines()
416 .filter_map(|line| {
417 line.split_once(' ')
418 .map(|(key, value)| (key.to_owned(), value.to_owned()))
419 })
420 .collect()),
421 Some(1) => Ok(Vec::new()),
422 _ => Err(command_error("git config --get-regexp", &output.stderr)),
423 }
424}
425
426pub fn config_set(key: &str, value: &str) -> Result<()> {
427 status(&["config", key, value]).with_context(|| format!("failed to set git config {key}"))
428}
429
430pub fn config_unset(key: &str) -> Result<()> {
431 let output = Command::new("git")
432 .args(["config", "--unset", key])
433 .stdout(Stdio::piped())
434 .stderr(Stdio::piped())
435 .output()
436 .with_context(|| format!("failed to unset git config {key}"))?;
437
438 match output.status.code() {
439 Some(0) | Some(5) => Ok(()),
440 _ => Err(command_error("git config --unset", &output.stderr)),
441 }
442}
443
444fn output(args: &[&str]) -> Result<String> {
445 let output = Command::new("git")
446 .args(args)
447 .stdout(Stdio::piped())
448 .stderr(Stdio::piped())
449 .output()
450 .context("failed to run git")?;
451
452 if output.status.success() {
453 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
454 } else {
455 Err(command_error("git", &output.stderr))
456 }
457}
458
459fn status(args: &[&str]) -> Result<()> {
463 if verbose() {
464 return status_passthrough(args);
465 }
466
467 let output = Command::new("git")
468 .args(args)
469 .output()
470 .context("failed to run git")?;
471
472 if output.status.success() {
473 Ok(())
474 } else {
475 let _ = std::io::stdout().write_all(&output.stdout);
476 let _ = std::io::stderr().write_all(&output.stderr);
477 bail!("git exited with status {}", output.status)
478 }
479}
480
481fn status_passthrough(args: &[&str]) -> Result<()> {
484 let status = Command::new("git")
485 .args(args)
486 .status()
487 .context("failed to run git")?;
488
489 if status.success() {
490 Ok(())
491 } else {
492 bail!("git exited with status {status}")
493 }
494}
495
496fn command_error(command: &str, stderr: &[u8]) -> anyhow::Error {
497 let stderr = String::from_utf8_lossy(stderr).trim().to_owned();
498 if stderr.is_empty() {
499 anyhow!("{command} failed")
500 } else {
501 anyhow!("{command} failed: {stderr}")
502 }
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508
509 #[test]
510 fn help_mentions_update_refs_matches_pre_2_43_spelling() {
511 assert!(help_mentions_update_refs(
512 " --update-refs update branches that point to commits that are being rebased"
513 ));
514 }
515
516 #[test]
517 fn help_mentions_update_refs_matches_negatable_spelling() {
518 assert!(help_mentions_update_refs(
519 " --[no-]update-refs update branches that point to commits that are being rebased"
520 ));
521 }
522
523 #[test]
524 fn help_mentions_update_refs_rejects_help_without_the_option() {
525 assert!(!help_mentions_update_refs(
526 " --[no-]autosquash move commits that begin with squash!/fixup!"
527 ));
528 }
529
530 #[test]
531 fn detection_agrees_with_the_real_git_on_this_machine() {
532 let probe = Command::new("git")
535 .args(["rebase", "--update-refs", "-h"])
536 .stdout(Stdio::piped())
537 .stderr(Stdio::piped())
538 .output()
539 .expect("run git rebase probe");
540 let probe_text = format!(
541 "{}{}",
542 String::from_utf8_lossy(&probe.stdout),
543 String::from_utf8_lossy(&probe.stderr)
544 );
545 let real_support = !probe_text.contains("unknown option");
546
547 assert_eq!(
548 supports_rebase_update_refs().expect("detect support"),
549 real_support
550 );
551 }
552}