Skip to main content

kata/
git.rs

1//! Thin shell-out wrappers around the `git` CLI. Phase 2 chose
2//! shell-out over libgit2 / gix because Windows linking pain
3//! outweighed the benefits (yui experience). The only mandatory
4//! dependency is a working `git` on `PATH`; `kata doctor` checks.
5
6use camino::Utf8Path;
7use tokio::process::Command;
8
9use crate::error::{Error, Result};
10
11/// Full-history clone of `url` into `dest`. Phase 2-c1 keeps the
12/// whole history so any rev (branch / tag / SHA) can be checked
13/// out later without a re-fetch. Shallow clones can be added
14/// behind a flag if first-clone latency becomes a real complaint.
15///
16/// `--` separates options from positional args so a hostile preset
17/// can't sneak `url = "--upload-pack=evil"` through and turn the
18/// shell-out into arbitrary code execution. Same trick we use for
19/// any subsequent `git` calls that take user-supplied refs.
20pub async fn clone_at(url: &str, dest: &Utf8Path) -> Result<()> {
21    let output = Command::new("git")
22        .arg("clone")
23        .arg("--")
24        .arg(url)
25        .arg(dest.as_str())
26        .output()
27        .await
28        .map_err(|e| Error::Git(format!("spawn `git clone {url}`: {e}")))?;
29    if !output.status.success() {
30        return Err(Error::Git(format!(
31            "git clone {url}: {}",
32            String::from_utf8_lossy(&output.stderr).trim()
33        )));
34    }
35    Ok(())
36}
37
38/// `git fetch --prune` inside `dir` to pull new commits + delete
39/// stale remote-tracking refs. Used by `kata update` to refresh
40/// the cache slot before re-checking out.
41pub async fn fetch(dir: &Utf8Path) -> Result<()> {
42    let output = Command::new("git")
43        .current_dir(dir.as_std_path())
44        .arg("fetch")
45        .arg("--prune")
46        .output()
47        .await
48        .map_err(|e| Error::Git(format!("spawn `git fetch`: {e}")))?;
49    if !output.status.success() {
50        return Err(Error::Git(format!(
51            "git fetch in {dir}: {}",
52            String::from_utf8_lossy(&output.stderr).trim()
53        )));
54    }
55    Ok(())
56}
57
58/// `git checkout <rev>` inside `dir`. Suppresses git's
59/// detached-HEAD chatter so kata's own log stays clean.
60///
61/// Note: we do **not** wrap the rev in `--`. For `git checkout`
62/// specifically, `--` separates revs (left of it) from paths
63/// (right), so `git checkout -- <rev>` would try to interpret
64/// `<rev>` as a file path and fail. Defence in depth instead:
65/// refuse revs that look like CLI options up front.
66///
67/// Branch-name resolution: kata's template cache slots are cloned
68/// in detached-HEAD state and **never create local branches**, so
69/// a literal `git checkout main` fails with "no such ref" the
70/// moment the user asks `kata update --rev main`. To handle that
71/// while still supporting branches whose names contain `/`
72/// (`feature/foo`, `release/v1`), the strategy is:
73///
74/// 1. Try the literal `git checkout <rev>` first. SHAs, tags,
75///    `HEAD`, already-qualified refs, and locally-tracked
76///    branches all succeed here.
77/// 2. If that fails AND the rev isn't already fully qualified
78///    (i.e. doesn't start with `origin/` or `refs/`, isn't
79///    `HEAD`), retry against `git checkout origin/<rev>`. This
80///    rescues plain branch names whether or not they contain `/`.
81pub async fn checkout(dir: &Utf8Path, rev: &str) -> Result<()> {
82    if rev.starts_with('-') {
83        return Err(Error::Git(format!(
84            "rev `{rev}` starts with '-' (looks like a CLI option); refusing to pass to git checkout"
85        )));
86    }
87
88    let literal_err = match try_checkout(dir, rev).await {
89        Ok(()) => return Ok(()),
90        Err(e) => e,
91    };
92
93    // Already fully qualified? Surface the original error rather
94    // than constructing an `origin/origin/...` chain.
95    if rev == "HEAD" || rev.starts_with("origin/") || rev.starts_with("refs/") {
96        return Err(literal_err);
97    }
98
99    let upstream = format!("origin/{rev}");
100    match try_checkout(dir, &upstream).await {
101        Ok(()) => Ok(()),
102        // The upstream retry failed too. The literal error is the
103        // more informative one to surface — it's what the user
104        // actually asked for.
105        Err(_) => Err(literal_err),
106    }
107}
108
109async fn try_checkout(dir: &Utf8Path, rev: &str) -> Result<()> {
110    let output = Command::new("git")
111        .current_dir(dir.as_std_path())
112        .arg("-c")
113        .arg("advice.detachedHead=false")
114        .arg("checkout")
115        .arg(rev)
116        .output()
117        .await
118        .map_err(|e| Error::Git(format!("spawn `git checkout {rev}`: {e}")))?;
119    if !output.status.success() {
120        return Err(Error::Git(format!(
121            "git checkout {rev} in {dir}: {}",
122            String::from_utf8_lossy(&output.stderr).trim()
123        )));
124    }
125    Ok(())
126}
127
128/// Resolve a rev (branch / tag / SHA / `HEAD`) to a full commit SHA
129/// inside `dir`.
130pub async fn rev_parse(dir: &Utf8Path, rev: &str) -> Result<String> {
131    let output = Command::new("git")
132        .current_dir(dir.as_std_path())
133        .arg("rev-parse")
134        .arg(rev)
135        .output()
136        .await
137        .map_err(|e| Error::Git(format!("spawn `git rev-parse {rev}`: {e}")))?;
138    if !output.status.success() {
139        return Err(Error::Git(format!(
140            "git rev-parse {rev} in {dir}: {}",
141            String::from_utf8_lossy(&output.stderr).trim()
142        )));
143    }
144    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
145}
146
147/// Current `HEAD` SHA in `dir`. Convenience over `rev_parse(dir, "HEAD")`.
148pub async fn current_head(dir: &Utf8Path) -> Result<String> {
149    rev_parse(dir, "HEAD").await
150}
151
152/// Derive the upstream repo basename from `git config --get
153/// remote.origin.url` for the project at `dir`. Used by the cmd
154/// layer to set `project.name` so it's stable across worktrees
155/// instead of being the worktree directory's leaf — running
156/// `kata apply` from `~/wt/<repo>/<branch>/` should still report
157/// `project.name = <repo>`, not `<branch>`.
158///
159/// Returns `None` when the directory isn't a git repo, has no
160/// `remote.origin`, or the URL doesn't end with a parseable
161/// segment. Callers fall back to the directory leaf in that case.
162pub async fn repo_name_from_remote(dir: &Utf8Path) -> Option<String> {
163    let output = Command::new("git")
164        .current_dir(dir.as_std_path())
165        .args(["config", "--get", "remote.origin.url"])
166        .output()
167        .await
168        .ok()?;
169    if !output.status.success() {
170        return None;
171    }
172    let url = String::from_utf8(output.stdout).ok()?;
173    parse_repo_basename(url.trim())
174}
175
176/// Pull the trailing repo segment out of a git remote URL. Handles
177/// the four common shapes:
178///
179/// - `https://github.com/owner/repo.git`
180/// - `https://github.com/owner/repo`
181/// - `git@github.com:owner/repo.git`
182/// - `git@github.com:owner/repo`
183///
184/// Returns `None` for empty / `/` / `:` only inputs.
185fn parse_repo_basename(url: &str) -> Option<String> {
186    let url = url.trim();
187    if url.is_empty() {
188        return None;
189    }
190    // SSH URLs put the path after `:`; HTTPS / file URLs use `/`.
191    // Splitting on either is enough because the trailing segment
192    // has no `:` or `/` either way.
193    let last = url.rsplit(['/', ':']).next()?;
194    let trimmed = last.trim_end_matches(".git").trim();
195    if trimmed.is_empty() {
196        None
197    } else {
198        Some(trimmed.to_string())
199    }
200}
201
202/// True if `git` is on PATH and runnable. Used by `kata doctor`.
203pub async fn is_available() -> bool {
204    Command::new("git")
205        .arg("--version")
206        .stdout(std::process::Stdio::null())
207        .stderr(std::process::Stdio::null())
208        .status()
209        .await
210        .map(|s| s.success())
211        .unwrap_or(false)
212}
213
214#[cfg(test)]
215mod tests {
216    use super::parse_repo_basename;
217
218    #[test]
219    fn parse_repo_basename_handles_https_with_dot_git() {
220        assert_eq!(
221            parse_repo_basename("https://github.com/yukimemi/kata.git").as_deref(),
222            Some("kata"),
223        );
224    }
225
226    #[test]
227    fn parse_repo_basename_handles_https_without_dot_git() {
228        assert_eq!(
229            parse_repo_basename("https://github.com/yukimemi/kata").as_deref(),
230            Some("kata"),
231        );
232    }
233
234    #[test]
235    fn parse_repo_basename_handles_ssh_with_dot_git() {
236        assert_eq!(
237            parse_repo_basename("git@github.com:yukimemi/kata.git").as_deref(),
238            Some("kata"),
239        );
240    }
241
242    #[test]
243    fn parse_repo_basename_handles_ssh_without_dot_git() {
244        assert_eq!(
245            parse_repo_basename("git@github.com:yukimemi/kata").as_deref(),
246            Some("kata"),
247        );
248    }
249
250    #[test]
251    fn parse_repo_basename_returns_none_on_garbage_input() {
252        assert!(parse_repo_basename("").is_none());
253        assert!(parse_repo_basename("/").is_none());
254        assert!(parse_repo_basename(":").is_none());
255        assert!(parse_repo_basename(".git").is_none());
256    }
257}