Skip to main content

hypha/git/
mod.rs

1//! Git operations using system `git` command.
2//!
3//! All functions shell out to `git` via `std::process::Command`.
4//! This eliminates the heavy `gix` dependency and works with any
5//! git transport (including dumb HTTP).
6
7use std::path::Path;
8
9/// Error type for git operations.
10#[derive(Debug, thiserror::Error)]
11pub enum GitError {
12    /// Failed to spawn or execute the git process.
13    #[error("failed to run git: {0}")]
14    Exec(#[from] std::io::Error),
15    /// Git command exited with non-zero status (stderr captured).
16    #[error("{0}")]
17    Command(String),
18    /// URL rejected by security validation.
19    #[error("rejected git URL: {0}")]
20    InvalidUrl(String),
21}
22
23/// Validate that a git URL is safe for remote operations.
24///
25/// Delegates to `substrate::normalize_and_validate_url()` for SSRF protection (loopback,
26/// private IPs, link-local, CGNAT, userinfo), then additionally rejects
27/// non-HTTPS schemes that substrate allows for .onion/.i2p (git must be HTTPS-only).
28fn validate_remote_url(url: &str) -> Result<(), GitError> {
29    // substrate::normalize_and_validate_url covers: SSRF
30    // (private/reserved IPs, localhost, link-local, CGNAT), userinfo, bare
31    // hostnames, scheme validation, and trailing-slash normalization.
32    let normalized = substrate::normalize_and_validate_url(url)
33        .map_err(|e| GitError::InvalidUrl(e.to_string()))?;
34
35    // substrate allows HTTP for .onion/.i2p — git requires strict HTTPS
36    let parsed = reqwest::Url::parse(&normalized)
37        .map_err(|e| GitError::InvalidUrl(format!("invalid URL syntax ({})", e)))?;
38    if parsed.scheme() != "https" {
39        return Err(GitError::InvalidUrl(format!(
40            "only https:// URLs are allowed (got: {})",
41            url
42        )));
43    }
44    Ok(())
45}
46
47/// Run a git command and return Ok(()) on success, or the stderr message on failure.
48fn run_git(args: &[&str]) -> Result<(), GitError> {
49    let output = std::process::Command::new("git").args(args).output()?;
50    if !output.status.success() {
51        return Err(GitError::Command(
52            String::from_utf8_lossy(&output.stderr).trim().to_string(),
53        ));
54    }
55    Ok(())
56}
57
58/// Run a git command in a specific directory.
59fn run_git_in(dir: &Path, args: &[&str]) -> Result<(), GitError> {
60    let output = std::process::Command::new("git")
61        .args(args)
62        .current_dir(dir)
63        .output()?;
64    if !output.status.success() {
65        return Err(GitError::Command(
66            String::from_utf8_lossy(&output.stderr).trim().to_string(),
67        ));
68    }
69    Ok(())
70}
71
72/// Run a git command in a specific directory and return stdout.
73fn run_git_output(dir: &Path, args: &[&str]) -> Result<String, GitError> {
74    let output = std::process::Command::new("git")
75        .args(args)
76        .current_dir(dir)
77        .output()?;
78    if !output.status.success() {
79        return Err(GitError::Command(
80            String::from_utf8_lossy(&output.stderr).trim().to_string(),
81        ));
82    }
83    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
84}
85
86/// Check if system git is available.
87pub fn is_available() -> bool {
88    std::process::Command::new("git")
89        .arg("--version")
90        .stdout(std::process::Stdio::null())
91        .stderr(std::process::Stdio::null())
92        .status()
93        .map(|s| s.success())
94        .unwrap_or(false)
95}
96
97/// Clone a git repository to the specified destination.
98///
99/// - `url`: Git repository URL
100/// - `dest`: Destination directory (must not exist)
101/// - `shallow`: If true, performs a shallow clone (depth 1)
102pub fn clone_repo(url: &str, dest: &Path, shallow: bool) -> Result<(), GitError> {
103    validate_remote_url(url)?;
104    let dest_str = dest.display().to_string();
105    if shallow {
106        run_git(&["clone", "--depth", "1", url, &dest_str])
107    } else {
108        run_git(&["clone", url, &dest_str])
109    }
110}
111
112/// Clone a git repository as a bare repository.
113pub fn clone_bare_repo(url: &str, dest: &Path) -> Result<(), GitError> {
114    validate_remote_url(url)?;
115    let dest_str = dest.display().to_string();
116    run_git(&["clone", "--bare", url, &dest_str])
117}
118
119/// Clone from a local bare repository to a working directory.
120pub fn clone_from_local(local_bare_path: &Path, dest: &Path) -> Result<(), GitError> {
121    let src = format!("file://{}", local_bare_path.display());
122    let dest_str = dest.display().to_string();
123    run_git(&["clone", &src, &dest_str])
124}
125
126/// Checkout a specific ref (commit SHA, tag, or branch).
127pub fn checkout_ref(repo_path: &Path, ref_spec: &str) -> Result<(), GitError> {
128    run_git_in(repo_path, &["checkout", ref_spec])
129}
130
131/// Initialize a new git repository at the given path.
132pub fn init_repo(path: &Path) -> Result<(), GitError> {
133    run_git_in(path, &["init"])
134}
135
136/// Add all files and create a commit. Returns the commit SHA.
137pub fn add_all_and_commit(repo_path: &Path, message: &str) -> Result<String, GitError> {
138    run_git_in(repo_path, &["add", "."])?;
139    run_git_in(
140        repo_path,
141        &[
142            "-c",
143            "user.name=CMN Hypha",
144            "-c",
145            "user.email=hypha@cmn.dev",
146            "commit",
147            "-m",
148            message,
149        ],
150    )?;
151    run_git_output(repo_path, &["rev-parse", "HEAD"])
152}
153
154/// Get the current HEAD commit ID as a string.
155pub fn get_head_commit(repo_path: &Path) -> Result<String, GitError> {
156    run_git_output(repo_path, &["rev-parse", "HEAD"])
157}
158
159/// Check if a commit exists in the repository.
160pub fn commit_exists(repo_path: &Path, commit_sha: &str) -> Result<bool, GitError> {
161    let output = std::process::Command::new("git")
162        .args(["cat-file", "-t", commit_sha])
163        .current_dir(repo_path)
164        .output()?;
165    Ok(output.status.success())
166}
167
168/// Fetch from a remote URL into a bare repository.
169pub fn fetch_to_bare(bare_repo_path: &Path, remote_url: &str) -> Result<(), GitError> {
170    validate_remote_url(remote_url)?;
171    run_git_in(
172        bare_repo_path,
173        &["fetch", remote_url, "+refs/heads/*:refs/heads/*", "--force"],
174    )
175}
176
177/// Fetch from a named remote in the repository.
178pub fn fetch_from_remote(repo_path: &Path, remote_name: &str) -> Result<(), GitError> {
179    run_git_in(repo_path, &["fetch", remote_name])
180}
181
182/// Add a remote to the repository.
183pub fn add_remote(repo_path: &Path, remote_name: &str, remote_url: &str) -> Result<(), GitError> {
184    run_git_in(repo_path, &["remote", "add", remote_name, remote_url])
185}
186
187/// Set the URL for an existing remote.
188pub fn set_remote_url(repo_path: &Path, remote_name: &str, new_url: &str) -> Result<(), GitError> {
189    run_git_in(repo_path, &["remote", "set-url", remote_name, new_url])
190}
191
192/// Check if the working directory has uncommitted changes.
193///
194/// Returns true if clean (no changes), false if dirty.
195pub fn is_working_dir_clean(repo_path: &Path) -> Result<bool, GitError> {
196    let output = run_git_output(repo_path, &["status", "--porcelain"])?;
197    Ok(output.is_empty())
198}
199
200/// Get the root commit from a bare repository.
201pub fn get_root_commit_bare(bare_repo_path: &Path) -> Result<String, GitError> {
202    run_git_output(bare_repo_path, &["rev-list", "--max-parents=0", "HEAD"])
203}
204
205/// Get the root commit SHA (first commit in history) from a working directory.
206pub fn get_root_commit(repo_path: &Path) -> Result<String, GitError> {
207    run_git_output(repo_path, &["rev-list", "--max-parents=0", "HEAD"])
208}
209
210/// Get the URL of a named remote, or None if the remote doesn't exist.
211pub fn get_remote_url(repo_path: &Path, remote: &str) -> Result<Option<String>, GitError> {
212    match run_git_output(repo_path, &["remote", "get-url", remote]) {
213        Ok(url) if url.is_empty() => Ok(None),
214        Ok(url) => Ok(Some(url)),
215        Err(_) => Ok(None),
216    }
217}