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::ffi::OsStr;
8use std::io::{Read, Seek, SeekFrom};
9use std::path::Path;
10use std::process::{Command, Output, Stdio};
11use std::time::{Duration, Instant};
12
13/// Default wall-clock limit for every system git invocation.
14pub const GIT_COMMAND_TIMEOUT: Duration = Duration::from_secs(300);
15
16const GIT_POLL_INTERVAL: Duration = Duration::from_millis(50);
17const BLOBLESS_FILTER: &str = "blob:none";
18pub const CMN_PROMISOR_REMOTE: &str = "cmn-promisor";
19
20/// Error type for git operations.
21#[derive(Debug, thiserror::Error)]
22pub enum GitError {
23    /// Failed to spawn or execute the git process.
24    #[error("failed to run git: {0}")]
25    Exec(#[from] std::io::Error),
26    /// Git command exited with non-zero status (stderr captured).
27    #[error("{0}")]
28    Command(String),
29    /// Git command exceeded the wall-clock timeout.
30    #[error("git command timed out after {timeout_secs}s: {command}")]
31    Timeout { command: String, timeout_secs: u64 },
32    /// Git content exceeded the configured local size budget.
33    #[error("git size budget exceeded: {0}")]
34    SizeLimit(String),
35    /// URL rejected by security validation.
36    #[error("rejected git URL: {0}")]
37    InvalidUrl(String),
38    /// Argument rejected by the option-injection guard.
39    #[error("rejected git argument: {0}")]
40    InvalidArg(String),
41}
42
43/// Disk budget for cloned/fetched git repositories.
44#[derive(Debug, Clone, Copy)]
45pub struct GitSizeLimits {
46    pub max_bytes: u64,
47    pub max_files: u64,
48}
49
50impl GitSizeLimits {
51    pub fn new(max_bytes: u64, max_files: u64) -> Self {
52        Self {
53            max_bytes,
54            max_files,
55        }
56    }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub struct GitSizeStats {
61    pub bytes: u64,
62    pub files: u64,
63}
64
65/// Reject a value that could be misinterpreted by git as an option flag.
66///
67/// Refs, remote names, and URLs are passed positionally; a leading `-` would
68/// otherwise be parsed as a flag (e.g. a ref named `--upload-pack=...`).
69fn reject_option_like(value: &str, what: &str) -> Result<(), GitError> {
70    if value.starts_with('-') {
71        return Err(GitError::InvalidArg(format!(
72            "{} must not start with '-': {}",
73            what, value
74        )));
75    }
76    Ok(())
77}
78
79/// Validate that a git URL is safe for remote operations.
80///
81/// Delegates to `substrate::normalize_and_validate_url()` for SSRF protection (loopback,
82/// private IPs, link-local, CGNAT, userinfo), then additionally rejects
83/// non-HTTPS schemes that substrate allows for .onion/.i2p (git must be HTTPS-only).
84fn validate_remote_url(url: &str) -> Result<(), GitError> {
85    // substrate::normalize_and_validate_url covers: SSRF
86    // (private/reserved IPs, localhost, link-local, CGNAT), userinfo, bare
87    // hostnames, scheme validation, and trailing-slash normalization.
88    let normalized = substrate::normalize_and_validate_url(url).map_err(|e| {
89        let safe_url = agent_first_data::redact_url_secrets(url);
90        GitError::InvalidUrl(e.to_string().replace(url, &safe_url))
91    })?;
92
93    // substrate allows HTTP for .onion/.i2p — git requires strict HTTPS
94    let parsed = reqwest::Url::parse(&normalized)
95        .map_err(|e| GitError::InvalidUrl(format!("invalid URL syntax ({})", e)))?;
96    if parsed.scheme() != "https" {
97        let safe_url = agent_first_data::redact_url_secrets(url);
98        return Err(GitError::InvalidUrl(format!(
99            "only https:// URLs are allowed (got: {})",
100            safe_url
101        )));
102    }
103    Ok(())
104}
105
106/// Run a git command (optionally in `dir`), erroring with captured stderr on
107/// non-zero exit. Returns the raw `Output` so callers can read stdout.
108fn display_command(program: &str, args: &[impl AsRef<OsStr>]) -> String {
109    let mut parts = vec![program.to_string()];
110    parts.extend(
111        args.iter()
112            .map(|arg| arg.as_ref().to_string_lossy().into_owned()),
113    );
114    parts.join(" ")
115}
116
117fn run_program_raw<S: AsRef<OsStr>>(
118    program: &str,
119    dir: Option<&Path>,
120    args: &[S],
121    timeout: Duration,
122) -> Result<Output, GitError> {
123    let command_display = display_command(program, args);
124    let mut stdout_file = tempfile::tempfile()?;
125    let mut stderr_file = tempfile::tempfile()?;
126
127    let mut cmd = Command::new(program);
128    cmd.args(args.iter().map(|arg| arg.as_ref()));
129    if let Some(d) = dir {
130        cmd.current_dir(d);
131    }
132    cmd.stdout(Stdio::from(stdout_file.try_clone()?));
133    cmd.stderr(Stdio::from(stderr_file.try_clone()?));
134
135    let mut child = cmd.spawn()?;
136    let started = Instant::now();
137    let status = loop {
138        if let Some(status) = child.try_wait()? {
139            break status;
140        }
141        if started.elapsed() >= timeout {
142            let _ = child.kill();
143            let _ = child.wait();
144            return Err(GitError::Timeout {
145                command: command_display,
146                timeout_secs: timeout.as_secs(),
147            });
148        }
149        std::thread::sleep(GIT_POLL_INTERVAL.min(timeout.saturating_sub(started.elapsed())));
150    };
151
152    let mut stdout = Vec::new();
153    stdout_file.seek(SeekFrom::Start(0))?;
154    stdout_file.read_to_end(&mut stdout)?;
155    let mut stderr = Vec::new();
156    stderr_file.seek(SeekFrom::Start(0))?;
157    stderr_file.read_to_end(&mut stderr)?;
158
159    Ok(Output {
160        status,
161        stdout,
162        stderr,
163    })
164}
165
166/// Run a git command (optionally in `dir`) and return the raw output without
167/// treating non-zero exit status as an error.
168fn run_git_raw_unchecked<S: AsRef<OsStr>>(
169    dir: Option<&Path>,
170    args: &[S],
171) -> Result<Output, GitError> {
172    run_program_raw("git", dir, args, GIT_COMMAND_TIMEOUT)
173}
174
175/// Run a git command (optionally in `dir`), erroring with captured stderr on
176/// non-zero exit. Returns the raw `Output` so callers can read stdout.
177fn run_git_raw<S: AsRef<OsStr>>(dir: Option<&Path>, args: &[S]) -> Result<Output, GitError> {
178    let output = run_git_raw_unchecked(dir, args)?;
179    if !output.status.success() {
180        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
181        let message = if stderr.is_empty() {
182            format!(
183                "{} exited with {}",
184                display_command("git", args),
185                output.status
186            )
187        } else {
188            stderr
189        };
190        return Err(GitError::Command(message));
191    }
192    Ok(output)
193}
194
195/// Run a git command and return Ok(()) on success, or the stderr message on failure.
196fn run_git<S: AsRef<OsStr>>(args: &[S]) -> Result<(), GitError> {
197    run_git_raw(None, args).map(|_| ())
198}
199
200/// Run a git command in a specific directory.
201fn run_git_in<S: AsRef<OsStr>>(dir: &Path, args: &[S]) -> Result<(), GitError> {
202    run_git_raw(Some(dir), args).map(|_| ())
203}
204
205/// Run a git command in a specific directory and return stdout.
206fn run_git_output<S: AsRef<OsStr>>(dir: &Path, args: &[S]) -> Result<String, GitError> {
207    let output = run_git_raw(Some(dir), args)?;
208    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
209}
210
211/// Check if system git is available.
212pub fn is_available() -> bool {
213    run_git_raw_unchecked(None, &["--version"])
214        .map(|output| output.status.success())
215        .unwrap_or(false)
216}
217
218fn clone_repo_args(url: &str, dest: &str, shallow: bool) -> Vec<String> {
219    let mut args = vec![
220        "clone".to_string(),
221        "--filter".to_string(),
222        BLOBLESS_FILTER.to_string(),
223    ];
224    if shallow {
225        args.extend(["--depth".to_string(), "1".to_string()]);
226    }
227    args.extend(["--".to_string(), url.to_string(), dest.to_string()]);
228    args
229}
230
231fn clone_bare_repo_args(url: &str, dest: &str) -> Vec<String> {
232    vec![
233        "clone".to_string(),
234        "--bare".to_string(),
235        "--filter".to_string(),
236        BLOBLESS_FILTER.to_string(),
237        "--".to_string(),
238        url.to_string(),
239        dest.to_string(),
240    ]
241}
242
243fn clone_from_local_args(local_bare_path: &Path, dest: &Path, no_checkout: bool) -> Vec<String> {
244    let src = format!("file://{}", local_bare_path.display());
245    let dest_str = dest.display().to_string();
246    let mut args = vec![
247        "clone".to_string(),
248        "--filter".to_string(),
249        BLOBLESS_FILTER.to_string(),
250    ];
251    if no_checkout {
252        args.push("--no-checkout".to_string());
253    }
254    args.extend(["--".to_string(), src, dest_str]);
255    args
256}
257
258fn fetch_to_bare_args(remote_url: &str) -> Vec<String> {
259    vec![
260        "fetch".to_string(),
261        "--filter".to_string(),
262        BLOBLESS_FILTER.to_string(),
263        "--force".to_string(),
264        remote_url.to_string(),
265        "+refs/heads/*:refs/heads/*".to_string(),
266    ]
267}
268
269/// Clone a git repository to the specified destination.
270///
271/// - `url`: Git repository URL
272/// - `dest`: Destination directory (must not exist)
273/// - `shallow`: If true, performs a shallow clone (depth 1)
274pub fn clone_repo(url: &str, dest: &Path, shallow: bool) -> Result<(), GitError> {
275    validate_remote_url(url)?;
276    let dest_str = dest.display().to_string();
277    run_git(&clone_repo_args(url, &dest_str, shallow))
278}
279
280/// Clone a git repository as a bare repository.
281pub fn clone_bare_repo(url: &str, dest: &Path) -> Result<(), GitError> {
282    validate_remote_url(url)?;
283    let dest_str = dest.display().to_string();
284    run_git(&clone_bare_repo_args(url, &dest_str))
285}
286
287/// Clone from a local bare repository to a working directory.
288pub fn clone_from_local(local_bare_path: &Path, dest: &Path) -> Result<(), GitError> {
289    run_git(&clone_from_local_args(local_bare_path, dest, false))
290}
291
292/// Clone from a local bare repository without checking out files.
293pub fn clone_from_local_no_checkout(local_bare_path: &Path, dest: &Path) -> Result<(), GitError> {
294    run_git(&clone_from_local_args(local_bare_path, dest, true))
295}
296
297/// Checkout a specific ref (commit SHA, tag, or branch).
298pub fn checkout_ref(repo_path: &Path, ref_spec: &str) -> Result<(), GitError> {
299    // `ref_spec` is untrusted (from spore manifests). Reject option-like values
300    // and pin it as a tree-ish with a trailing `--` so it can never be parsed
301    // as a flag or a pathspec.
302    reject_option_like(ref_spec, "git ref")?;
303    run_git_in(repo_path, &["checkout", ref_spec, "--"])
304}
305
306/// Initialize a new git repository at the given path.
307pub fn init_repo(path: &Path) -> Result<(), GitError> {
308    run_git_in(path, &["init"])
309}
310
311/// Configure a blobless promisor remote for lazy blob fetching.
312pub fn configure_blobless_promisor_remote(
313    repo_path: &Path,
314    remote_name: &str,
315    remote_url: &str,
316) -> Result<(), GitError> {
317    reject_option_like(remote_name, "remote name")?;
318    validate_remote_url(remote_url)?;
319    if get_remote_url(repo_path, remote_name)?.is_some() {
320        run_git_in(repo_path, &["remote", "set-url", remote_name, remote_url])?;
321    } else {
322        run_git_in(repo_path, &["remote", "add", remote_name, remote_url])?;
323    }
324    let promisor_key = format!("remote.{remote_name}.promisor");
325    run_git_in(repo_path, &["config", promisor_key.as_str(), "true"])?;
326    let filter_key = format!("remote.{remote_name}.partialclonefilter");
327    run_git_in(repo_path, &["config", filter_key.as_str(), BLOBLESS_FILTER])?;
328    run_git_in(
329        repo_path,
330        &["config", "extensions.partialClone", remote_name],
331    )
332}
333
334/// Configure origin as a blobless promisor remote for lazy blob fetching.
335pub fn configure_blobless_origin(repo_path: &Path, remote_url: &str) -> Result<(), GitError> {
336    configure_blobless_promisor_remote(repo_path, "origin", remote_url)
337}
338
339/// Add all files and create a commit. Returns the commit SHA.
340pub fn add_all_and_commit(repo_path: &Path, message: &str) -> Result<String, GitError> {
341    run_git_in(repo_path, &["add", "."])?;
342    run_git_in(
343        repo_path,
344        &[
345            "-c",
346            "user.name=CMN Hypha",
347            "-c",
348            "user.email=hypha@cmn.dev",
349            "commit",
350            "-m",
351            message,
352        ],
353    )?;
354    run_git_output(repo_path, &["rev-parse", "HEAD"])
355}
356
357/// Get the current HEAD commit ID as a string.
358pub fn get_head_commit(repo_path: &Path) -> Result<String, GitError> {
359    run_git_output(repo_path, &["rev-parse", "HEAD"])
360}
361
362/// Check if a commit exists in the repository.
363pub fn commit_exists(repo_path: &Path, commit_sha: &str) -> Result<bool, GitError> {
364    reject_option_like(commit_sha, "commit sha")?;
365    let output = run_git_raw_unchecked(Some(repo_path), &["cat-file", "-t", commit_sha])?;
366    Ok(output.status.success())
367}
368
369/// Fetch from a remote URL into a bare repository.
370pub fn fetch_to_bare(bare_repo_path: &Path, remote_url: &str) -> Result<(), GitError> {
371    validate_remote_url(remote_url)?;
372    run_git_in(bare_repo_path, &fetch_to_bare_args(remote_url))
373}
374
375/// Fetch from a named remote in the repository.
376pub fn fetch_from_remote(repo_path: &Path, remote_name: &str) -> Result<(), GitError> {
377    reject_option_like(remote_name, "remote name")?;
378    run_git_in(
379        repo_path,
380        &["fetch", "--filter", BLOBLESS_FILTER, remote_name],
381    )
382}
383
384/// Add a remote to the repository.
385pub fn add_remote(repo_path: &Path, remote_name: &str, remote_url: &str) -> Result<(), GitError> {
386    reject_option_like(remote_name, "remote name")?;
387    reject_option_like(remote_url, "remote url")?;
388    run_git_in(repo_path, &["remote", "add", remote_name, remote_url])
389}
390
391/// Set the URL for an existing remote.
392pub fn set_remote_url(repo_path: &Path, remote_name: &str, new_url: &str) -> Result<(), GitError> {
393    reject_option_like(remote_name, "remote name")?;
394    reject_option_like(new_url, "remote url")?;
395    run_git_in(repo_path, &["remote", "set-url", remote_name, new_url])
396}
397
398/// Check if the working directory has uncommitted changes.
399///
400/// Returns true if clean (no changes), false if dirty.
401pub fn is_working_dir_clean(repo_path: &Path) -> Result<bool, GitError> {
402    let output = run_git_output(repo_path, &["status", "--porcelain"])?;
403    Ok(output.is_empty())
404}
405
406/// Get the root commit from a bare repository.
407pub fn get_root_commit_bare(bare_repo_path: &Path) -> Result<String, GitError> {
408    run_git_output(bare_repo_path, &["rev-list", "--max-parents=0", "HEAD"])
409}
410
411/// Get the root commit SHA (first commit in history) from a working directory.
412pub fn get_root_commit(repo_path: &Path) -> Result<String, GitError> {
413    run_git_output(repo_path, &["rev-list", "--max-parents=0", "HEAD"])
414}
415
416/// Get the URL of a named remote, or None if the remote doesn't exist.
417pub fn get_remote_url(repo_path: &Path, remote: &str) -> Result<Option<String>, GitError> {
418    match run_git_output(repo_path, &["remote", "get-url", remote]) {
419        Ok(url) if url.is_empty() => Ok(None),
420        Ok(url) => Ok(Some(url)),
421        Err(_) => Ok(None),
422    }
423}
424
425/// Get the latest git commit timestamp for a path, if the path is in a repo.
426pub fn last_commit_epoch_ms(repo_path: &Path) -> Option<u64> {
427    let output = run_git_output(repo_path, &["log", "-1", "--format=%ct", "--", "."]).ok()?;
428    let epoch_s: u64 = output.parse().ok()?;
429    Some(epoch_s * 1000)
430}
431
432/// Walk a git checkout/cache path without following symlinks and enforce disk limits.
433pub fn enforce_size_budget(path: &Path, limits: GitSizeLimits) -> Result<GitSizeStats, GitError> {
434    let mut stats = GitSizeStats { bytes: 0, files: 0 };
435    let mut stack = vec![path.to_path_buf()];
436
437    while let Some(dir) = stack.pop() {
438        for entry in std::fs::read_dir(&dir)? {
439            let entry = entry?;
440            let path = entry.path();
441            let meta = std::fs::symlink_metadata(&path)?;
442
443            stats.files = stats.files.saturating_add(1);
444            stats.bytes = stats.bytes.saturating_add(meta.len());
445            if stats.files > limits.max_files {
446                return Err(GitError::SizeLimit(format!(
447                    "{} contains more than {} entries",
448                    path.display(),
449                    limits.max_files
450                )));
451            }
452            if stats.bytes > limits.max_bytes {
453                return Err(GitError::SizeLimit(format!(
454                    "{} exceeds {} bytes",
455                    path.display(),
456                    limits.max_bytes
457                )));
458            }
459
460            if meta.is_dir() {
461                stack.push(path);
462            }
463        }
464    }
465
466    Ok(stats)
467}
468
469#[cfg(test)]
470#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
471mod tests {
472    use super::*;
473    use std::fs;
474
475    #[test]
476    fn clone_args_use_blobless_filter_for_shallow_and_full() {
477        assert_eq!(
478            clone_repo_args("https://example.com/repo.git", "/tmp/repo", true),
479            [
480                "clone",
481                "--filter",
482                "blob:none",
483                "--depth",
484                "1",
485                "--",
486                "https://example.com/repo.git",
487                "/tmp/repo",
488            ]
489            .map(String::from)
490            .to_vec()
491        );
492        assert_eq!(
493            clone_repo_args("https://example.com/repo.git", "/tmp/repo", false),
494            [
495                "clone",
496                "--filter",
497                "blob:none",
498                "--",
499                "https://example.com/repo.git",
500                "/tmp/repo",
501            ]
502            .map(String::from)
503            .to_vec()
504        );
505    }
506
507    #[test]
508    fn bare_clone_and_fetch_args_use_blobless_filter() {
509        assert_eq!(
510            clone_bare_repo_args("https://example.com/repo.git", "/tmp/repo.git"),
511            [
512                "clone",
513                "--bare",
514                "--filter",
515                "blob:none",
516                "--",
517                "https://example.com/repo.git",
518                "/tmp/repo.git",
519            ]
520            .map(String::from)
521            .to_vec()
522        );
523        assert_eq!(
524            fetch_to_bare_args("https://example.com/repo.git"),
525            [
526                "fetch",
527                "--filter",
528                "blob:none",
529                "--force",
530                "https://example.com/repo.git",
531                "+refs/heads/*:refs/heads/*",
532            ]
533            .map(String::from)
534            .to_vec()
535        );
536    }
537
538    #[test]
539    fn enforce_size_budget_rejects_too_many_bytes() {
540        let dir = tempfile::tempdir().expect("tempdir");
541        fs::write(dir.path().join("large.bin"), [0u8; 16]).expect("write");
542
543        let err = enforce_size_budget(dir.path(), GitSizeLimits::new(8, 10)).unwrap_err();
544        assert!(matches!(err, GitError::SizeLimit(_)));
545    }
546
547    #[test]
548    fn enforce_size_budget_rejects_too_many_entries() {
549        let dir = tempfile::tempdir().expect("tempdir");
550        fs::write(dir.path().join("one.txt"), b"1").expect("write one");
551        fs::write(dir.path().join("two.txt"), b"2").expect("write two");
552
553        let err = enforce_size_budget(dir.path(), GitSizeLimits::new(1024, 1)).unwrap_err();
554        assert!(matches!(err, GitError::SizeLimit(_)));
555    }
556
557    #[test]
558    fn remote_url_validation_redacts_userinfo_password() {
559        let password = "git-password-canary";
560        let err = validate_remote_url(&format!("https://agent:{password}@example.com/repo.git"))
561            .unwrap_err()
562            .to_string();
563        assert!(!err.contains(password));
564        assert!(err.contains("***"));
565    }
566
567    #[cfg(unix)]
568    #[test]
569    fn run_program_raw_times_out() {
570        let err =
571            run_program_raw("sh", None, &["-c", "sleep 2"], Duration::from_millis(20)).unwrap_err();
572        assert!(matches!(err, GitError::Timeout { .. }));
573    }
574}