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 remote_default_branch(remote: &str) -> Option<String> {
131 let reference = format!("refs/remotes/{remote}/HEAD");
132 let full = output(&["symbolic-ref", "--short", &reference]).ok()?;
133 full.strip_prefix(&format!("{remote}/")).map(str::to_owned)
134}
135
136pub fn commits_behind(branch: &str, parent: &str) -> Result<usize> {
139 let range = format!("{branch}..{parent}");
140 let count = output(&["rev-list", "--count", &range])
141 .with_context(|| format!("failed to count commits in {range}"))?;
142 count
143 .trim()
144 .parse()
145 .context("failed to parse rev-list count")
146}
147
148pub fn merge_base(a: &str, b: &str) -> Result<String> {
149 output(&["merge-base", a, b])
150 .with_context(|| format!("failed to find merge base of {a} and {b}"))
151}
152
153pub fn is_ancestor(ancestor: &str, descendant: &str) -> Result<bool> {
154 let output = Command::new("git")
155 .args(["merge-base", "--is-ancestor", ancestor, descendant])
156 .stdout(Stdio::piped())
157 .stderr(Stdio::piped())
158 .output()
159 .context("failed to run git merge-base --is-ancestor")?;
160
161 match output.status.code() {
162 Some(0) => Ok(true),
163 Some(1) => Ok(false),
164 _ => Err(command_error(
165 "git merge-base --is-ancestor",
166 &output.stderr,
167 )),
168 }
169}
170
171pub fn supports_rebase_update_refs() -> Result<bool> {
172 let output = Command::new("git")
173 .args(["rebase", "-h"])
174 .stdout(Stdio::piped())
175 .stderr(Stdio::piped())
176 .output()
177 .context("failed to inspect git rebase help")?;
178
179 let help = format!(
180 "{}{}",
181 String::from_utf8_lossy(&output.stdout),
182 String::from_utf8_lossy(&output.stderr)
183 );
184 Ok(help_mentions_update_refs(&help))
185}
186
187fn help_mentions_update_refs(help: &str) -> bool {
190 help.contains("update-refs")
191}
192
193pub fn rebase_continue() -> Result<()> {
194 status_passthrough(&["rebase", "--continue"]).context("failed to continue rebase")
196}
197
198pub fn rebase_abort() -> Result<()> {
199 status(&["rebase", "--abort"]).context("failed to abort rebase")
200}
201
202pub fn config_get(key: &str) -> Result<Option<String>> {
203 let output = Command::new("git")
204 .args(["config", "--get", key])
205 .stdout(Stdio::piped())
206 .stderr(Stdio::piped())
207 .output()
208 .with_context(|| format!("failed to read git config {key}"))?;
209
210 match output.status.code() {
211 Some(0) => Ok(Some(
212 String::from_utf8_lossy(&output.stdout).trim().to_owned(),
213 )),
214 Some(1) => Ok(None),
215 _ => Err(command_error("git config --get", &output.stderr)),
216 }
217}
218
219pub fn config_get_bool(key: &str) -> Result<Option<bool>> {
220 let output = Command::new("git")
221 .args(["config", "--type=bool", "--get", key])
222 .stdout(Stdio::piped())
223 .stderr(Stdio::piped())
224 .output()
225 .with_context(|| format!("failed to read git config {key}"))?;
226
227 match output.status.code() {
228 Some(0) => {
229 let value = String::from_utf8_lossy(&output.stdout).trim().to_owned();
230 match value.as_str() {
231 "true" => Ok(Some(true)),
232 "false" => Ok(Some(false)),
233 _ => bail!("git config {key} is not a boolean: {value}"),
234 }
235 }
236 Some(1) => Ok(None),
237 _ => Err(command_error(
238 "git config --type=bool --get",
239 &output.stderr,
240 )),
241 }
242}
243
244pub fn config_get_regexp(pattern: &str) -> Result<Vec<(String, String)>> {
245 let output = Command::new("git")
246 .args(["config", "--get-regexp", pattern])
247 .stdout(Stdio::piped())
248 .stderr(Stdio::piped())
249 .output()
250 .with_context(|| format!("failed to read git config matching {pattern}"))?;
251
252 match output.status.code() {
253 Some(0) => Ok(String::from_utf8_lossy(&output.stdout)
254 .lines()
255 .filter_map(|line| {
256 line.split_once(' ')
257 .map(|(key, value)| (key.to_owned(), value.to_owned()))
258 })
259 .collect()),
260 Some(1) => Ok(Vec::new()),
261 _ => Err(command_error("git config --get-regexp", &output.stderr)),
262 }
263}
264
265pub fn config_set(key: &str, value: &str) -> Result<()> {
266 status(&["config", key, value]).with_context(|| format!("failed to set git config {key}"))
267}
268
269pub fn config_unset(key: &str) -> Result<()> {
270 let output = Command::new("git")
271 .args(["config", "--unset", key])
272 .stdout(Stdio::piped())
273 .stderr(Stdio::piped())
274 .output()
275 .with_context(|| format!("failed to unset git config {key}"))?;
276
277 match output.status.code() {
278 Some(0) | Some(5) => Ok(()),
279 _ => Err(command_error("git config --unset", &output.stderr)),
280 }
281}
282
283fn output(args: &[&str]) -> Result<String> {
284 let output = Command::new("git")
285 .args(args)
286 .stdout(Stdio::piped())
287 .stderr(Stdio::piped())
288 .output()
289 .context("failed to run git")?;
290
291 if output.status.success() {
292 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
293 } else {
294 Err(command_error("git", &output.stderr))
295 }
296}
297
298fn status(args: &[&str]) -> Result<()> {
302 if verbose() {
303 return status_passthrough(args);
304 }
305
306 let output = Command::new("git")
307 .args(args)
308 .output()
309 .context("failed to run git")?;
310
311 if output.status.success() {
312 Ok(())
313 } else {
314 let _ = std::io::stdout().write_all(&output.stdout);
315 let _ = std::io::stderr().write_all(&output.stderr);
316 bail!("git exited with status {}", output.status)
317 }
318}
319
320fn status_passthrough(args: &[&str]) -> Result<()> {
323 let status = Command::new("git")
324 .args(args)
325 .status()
326 .context("failed to run git")?;
327
328 if status.success() {
329 Ok(())
330 } else {
331 bail!("git exited with status {status}")
332 }
333}
334
335fn command_error(command: &str, stderr: &[u8]) -> anyhow::Error {
336 let stderr = String::from_utf8_lossy(stderr).trim().to_owned();
337 if stderr.is_empty() {
338 anyhow!("{command} failed")
339 } else {
340 anyhow!("{command} failed: {stderr}")
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 #[test]
349 fn help_mentions_update_refs_matches_pre_2_43_spelling() {
350 assert!(help_mentions_update_refs(
351 " --update-refs update branches that point to commits that are being rebased"
352 ));
353 }
354
355 #[test]
356 fn help_mentions_update_refs_matches_negatable_spelling() {
357 assert!(help_mentions_update_refs(
358 " --[no-]update-refs update branches that point to commits that are being rebased"
359 ));
360 }
361
362 #[test]
363 fn help_mentions_update_refs_rejects_help_without_the_option() {
364 assert!(!help_mentions_update_refs(
365 " --[no-]autosquash move commits that begin with squash!/fixup!"
366 ));
367 }
368
369 #[test]
370 fn detection_agrees_with_the_real_git_on_this_machine() {
371 let probe = Command::new("git")
374 .args(["rebase", "--update-refs", "-h"])
375 .stdout(Stdio::piped())
376 .stderr(Stdio::piped())
377 .output()
378 .expect("run git rebase probe");
379 let probe_text = format!(
380 "{}{}",
381 String::from_utf8_lossy(&probe.stdout),
382 String::from_utf8_lossy(&probe.stderr)
383 );
384 let real_support = !probe_text.contains("unknown option");
385
386 assert_eq!(
387 supports_rebase_update_refs().expect("detect support"),
388 real_support
389 );
390 }
391}