Skip to main content

solid_pod_rs_git/
config.rs

1//! Repo-level `git config` mutators — mirrors JSS
2//! `src/handlers/git.js` lines 133-150, which runs two `git config`
3//! invocations on every write request:
4//!
5//! 1. `git config http.receivepack true` (always — so HTTP push is
6//!    accepted even on repos that didn't set this at `init` time).
7//! 2. `git config receive.denyCurrentBranch updateInstead` (only on
8//!    non-bare repos — so a push to the currently checked-out branch
9//!    updates the working tree instead of being rejected).
10//!
11//! Both invocations are idempotent; running them on every write is
12//! the JSS-chosen strategy, and we replicate it here.
13
14use std::path::Path;
15use std::process::Stdio;
16
17use tokio::process::Command;
18
19use crate::error::GitError;
20
21/// Information about the git directory layout for a given repo path.
22#[derive(Debug, Clone)]
23pub struct GitDir {
24    /// The absolute path to the actual git directory (`.git` for
25    /// regular repos, the repo path itself for bare repos).
26    pub git_dir: std::path::PathBuf,
27
28    /// `true` if this is a regular (non-bare) repository.
29    pub is_regular: bool,
30}
31
32/// Inspect `repo_path` and determine whether it is a git repository,
33/// and if so whether it is bare.
34///
35/// Returns `Ok(None)` if the directory exists but is not a repo; that
36/// maps to a 404 at the HTTP layer.
37pub fn find_git_dir(repo_path: &Path) -> std::io::Result<Option<GitDir>> {
38    if !repo_path.exists() || !repo_path.is_dir() {
39        return Ok(None);
40    }
41
42    let dot_git = repo_path.join(".git");
43    if dot_git.exists() && dot_git.is_dir() {
44        return Ok(Some(GitDir {
45            git_dir: dot_git,
46            is_regular: true,
47        }));
48    }
49
50    // Bare-repo heuristic matches JSS: `objects/` + `refs/` present.
51    let objects = repo_path.join("objects");
52    let refs = repo_path.join("refs");
53    if objects.exists() && refs.exists() {
54        return Ok(Some(GitDir {
55            git_dir: repo_path.to_path_buf(),
56            is_regular: false,
57        }));
58    }
59
60    Ok(None)
61}
62
63/// Apply the JSS-parity config to a repo on every write request.
64///
65/// Errors from the underlying `git config` invocations are **logged
66/// and swallowed** — this matches JSS's `try { execSync … } catch (e)
67/// { }` behaviour at lines 147-149. Rationale: config mutators
68/// tripping (e.g. permissions) must not block the main CGI from
69/// attempting the push; the CGI will itself surface a proper error
70/// if the push truly can't proceed.
71pub async fn apply_write_config(git_dir: &GitDir, cwd: &Path) -> Result<(), GitError> {
72    // 1. http.receivepack = true  (always).
73    let _ = run_git_config(cwd, &git_dir.git_dir, "http.receivepack", "true").await;
74
75    // 2. receive.denyCurrentBranch = updateInstead  (non-bare only).
76    if git_dir.is_regular {
77        let _ = run_git_config(
78            cwd,
79            &git_dir.git_dir,
80            "receive.denyCurrentBranch",
81            "updateInstead",
82        )
83        .await;
84    }
85
86    Ok(())
87}
88
89/// Run `git config --local <key> <value>` with `GIT_DIR` set.
90///
91/// Returns `Err(GitError::BackendNotAvailable)` if the `git` binary
92/// can't be spawned (test gate); otherwise returns `Err(Io)` on
93/// other OS-level failures. Callers that are OK with a best-effort
94/// apply swallow the error.
95pub async fn run_git_config(
96    cwd: &Path,
97    git_dir: &Path,
98    key: &str,
99    value: &str,
100) -> Result<(), GitError> {
101    let mut cmd = Command::new("git");
102    cmd.arg("config")
103        .arg("--local")
104        .arg(key)
105        .arg(value)
106        .current_dir(cwd)
107        .env("GIT_DIR", git_dir)
108        .stdout(Stdio::null())
109        .stderr(Stdio::piped());
110
111    let output = match cmd.output().await {
112        Ok(o) => o,
113        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
114            return Err(GitError::BackendNotAvailable(format!(
115                "git binary not found: {e}"
116            )));
117        }
118        Err(e) => return Err(GitError::Io(e)),
119    };
120
121    if !output.status.success() {
122        let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
123        tracing::debug!(
124            target: "solid_pod_rs_git::config",
125            "git config {key}={value} failed: {stderr}"
126        );
127        return Err(GitError::BackendFailed {
128            exit_code: output.status.code(),
129            stderr,
130        });
131    }
132    Ok(())
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use tempfile::TempDir;
139
140    #[tokio::test]
141    async fn find_git_dir_empty_returns_none() {
142        let td = TempDir::new().unwrap();
143        let res = find_git_dir(td.path()).unwrap();
144        assert!(res.is_none());
145    }
146
147    #[tokio::test]
148    async fn find_git_dir_regular_detected() {
149        let td = TempDir::new().unwrap();
150        std::fs::create_dir(td.path().join(".git")).unwrap();
151        let res = find_git_dir(td.path()).unwrap().unwrap();
152        assert!(res.is_regular);
153        assert_eq!(res.git_dir, td.path().join(".git"));
154    }
155
156    #[tokio::test]
157    async fn find_git_dir_bare_detected() {
158        let td = TempDir::new().unwrap();
159        std::fs::create_dir(td.path().join("objects")).unwrap();
160        std::fs::create_dir(td.path().join("refs")).unwrap();
161        let res = find_git_dir(td.path()).unwrap().unwrap();
162        assert!(!res.is_regular);
163        assert_eq!(res.git_dir, td.path());
164    }
165
166    /// Only runs when the git binary is available.
167    #[tokio::test]
168    async fn apply_write_config_roundtrip() {
169        let td = TempDir::new().unwrap();
170        let repo = td.path();
171        // Init a regular repo via the system git if present.
172        let status = Command::new("git")
173            .arg("init")
174            .arg(repo)
175            .stdout(Stdio::null())
176            .stderr(Stdio::null())
177            .status()
178            .await;
179        let status = match status {
180            Ok(s) => s,
181            Err(_) => return, // no git binary — skip.
182        };
183        assert!(status.success());
184
185        let gd = find_git_dir(repo).unwrap().unwrap();
186        apply_write_config(&gd, repo).await.unwrap();
187
188        // Verify the config was applied by reading it back.
189        let out = Command::new("git")
190            .arg("config")
191            .arg("--local")
192            .arg("receive.denyCurrentBranch")
193            .current_dir(repo)
194            .env("GIT_DIR", &gd.git_dir)
195            .output()
196            .await
197            .unwrap();
198        assert!(out.status.success());
199        assert_eq!(
200            String::from_utf8_lossy(&out.stdout).trim(),
201            "updateInstead"
202        );
203
204        let out2 = Command::new("git")
205            .arg("config")
206            .arg("--local")
207            .arg("http.receivepack")
208            .current_dir(repo)
209            .env("GIT_DIR", &gd.git_dir)
210            .output()
211            .await
212            .unwrap();
213        assert_eq!(String::from_utf8_lossy(&out2.stdout).trim(), "true");
214    }
215}