rtb_vcs/git/checkout.rs
1//! `Repo::checkout` — switch the working tree to a revspec.
2//!
3//! v0.5 commit 5. Dirty-tree guard via `Repo::status` runs before
4//! the shell-out; pass `CheckoutOptions { force: true }` to skip
5//! the guard. Implementation shells out to `git checkout <revspec>`
6//! (with `--force` when overriding), matching the rest of the
7//! v0.5 write-path backend choices.
8
9use std::path::Path;
10use std::process::Command;
11
12use super::{Repo, RepoError};
13
14/// Options for [`Repo::checkout`].
15///
16/// `#[non_exhaustive]` to keep future field additions non-breaking;
17/// use [`CheckoutOptions::default()`] or the
18/// [`CheckoutOptions::force`] builder for the common cases.
19#[derive(Debug, Default, Clone)]
20#[non_exhaustive]
21pub struct CheckoutOptions {
22 /// Skip the dirty-working-tree guard. Lost work is the caller's
23 /// problem when this is `true` — same semantics as
24 /// `git checkout --force`.
25 pub force: bool,
26}
27
28impl CheckoutOptions {
29 /// Convenience constructor: `force = true`. Mirrors
30 /// `git checkout --force`.
31 #[must_use]
32 pub const fn forced() -> Self {
33 Self { force: true }
34 }
35}
36
37impl Repo {
38 /// Switch the working tree to `revspec`.
39 ///
40 /// Refuses by default if the working tree has unstaged
41 /// modifications to tracked files — surfaced as
42 /// [`RepoError::DirtyWorkingTree`] with the offending paths.
43 /// Pass `CheckoutOptions::force(true)` to override.
44 ///
45 /// # Errors
46 ///
47 /// - [`RepoError::DirtyWorkingTree`] — dirty tree, `force = false`.
48 /// - [`RepoError::RevspecNotFound`] — `revspec` did not resolve.
49 /// - [`RepoError::CheckoutFailed`] — `git checkout` returned
50 /// non-zero for some other reason.
51 pub async fn checkout(&self, revspec: &str, opts: CheckoutOptions) -> Result<(), RepoError> {
52 if !opts.force {
53 let status = self.status().await?;
54 if !status.unstaged.is_empty() || !status.staged.is_empty() {
55 let mut paths = Vec::new();
56 paths.extend(status.staged.iter().cloned());
57 paths.extend(status.unstaged.iter().cloned());
58 return Err(RepoError::DirtyWorkingTree { paths });
59 }
60 }
61 let workdir = self.path().to_path_buf();
62 let revspec = revspec.to_string();
63 tokio::task::spawn_blocking(move || run_checkout(&workdir, &revspec, opts.force))
64 .await
65 .map_err(|join| RepoError::CheckoutFailed {
66 revspec: String::new(),
67 cause: format!("spawn_blocking join: {join}"),
68 })?
69 }
70}
71
72fn run_checkout(workdir: &Path, revspec: &str, force: bool) -> Result<(), RepoError> {
73 let mut cmd = Command::new("git");
74 cmd.arg("checkout");
75 if force {
76 cmd.arg("--force");
77 }
78 cmd.arg(revspec).current_dir(workdir);
79 let out = cmd.output().map_err(|e| RepoError::CheckoutFailed {
80 revspec: revspec.to_string(),
81 cause: format!("spawn `git checkout`: {e}"),
82 })?;
83 if !out.status.success() {
84 let stderr = String::from_utf8_lossy(&out.stderr);
85 // git's exit message includes specific phrases for the
86 // "did not match any" / "unknown revision" cases; map them
87 // to RevspecNotFound so callers can pattern-match cleanly.
88 if stderr.contains("did not match any")
89 || stderr.contains("unknown revision")
90 || stderr.contains("not a valid object name")
91 || stderr.contains("pathspec")
92 {
93 return Err(RepoError::RevspecNotFound { revspec: revspec.to_string() });
94 }
95 return Err(RepoError::CheckoutFailed {
96 revspec: revspec.to_string(),
97 cause: format!("git checkout: {}", stderr.trim()),
98 });
99 }
100 Ok(())
101}