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