Skip to main content

solid_pod_rs_git/
init.rs

1//! Git repository auto-initialisation at pod provisioning time.
2//!
3//! Implements the [`solid_pod_rs::provision::GitInitHook`] trait
4//! (feature `git-auto-init` on `solid-pod-rs`) via
5//! `tokio::process::Command`.
6//!
7//! ## Parity
8//!
9//! Mirrors JSS `src/handlers/git.js` `tryAutoInitRepo` (issues #466,
10//! #469, #471). JSS runs:
11//!
12//! ```text
13//! git init -b main <pod_path>
14//! git -C <pod_path> config receive.denyCurrentBranch updateInstead
15//! ```
16//!
17//! We do the same. Errors are logged and swallowed so a missing `git`
18//! binary (or a CF Workers–style environment) does not fail pod
19//! provisioning.
20
21use std::path::Path;
22use std::process::Stdio;
23
24use async_trait::async_trait;
25
26use solid_pod_rs::error::PodError;
27use solid_pod_rs::provision::GitInitHook;
28
29use crate::error::GitError;
30
31/// Runs `git init -b <branch>` + `git config receive.denyCurrentBranch
32/// updateInstead` in the pod directory at provisioning time.
33///
34/// Mirrors JSS `tryAutoInitRepo` (#466/#469/#471).
35///
36/// # Usage
37///
38/// ```ignore
39/// use std::path::PathBuf;
40/// use solid_pod_rs_git::init::GitAutoInit;
41/// use solid_pod_rs::provision::{provision_pod_ext, ProvisionPlan};
42///
43/// // storage is any type implementing solid_pod_rs::storage::Storage
44/// // (e.g. FsBackend with feature "fs-backend").
45/// # async fn run(storage: impl solid_pod_rs::storage::Storage) {
46/// let plan = ProvisionPlan {
47///     pubkey: "abcd1234".into(),
48///     display_name: None,
49///     pod_base: "https://pods.example".into(),
50///     containers: vec![],
51///     root_acl: None,
52///     quota_bytes: None,
53/// };
54/// let hook = GitAutoInit::new();
55/// let fs_root = PathBuf::from("/var/lib/pods/abcd1234");
56/// provision_pod_ext(&storage, &plan, Some((&hook, fs_root.as_path()))).await.unwrap();
57/// # }
58/// ```
59#[derive(Debug, Clone)]
60pub struct GitAutoInit {
61    /// Branch name passed to `git init -b`. JSS uses `main`; configurable
62    /// here so agentbox / VisionClaw can override to `trunk` etc. if
63    /// desired.
64    pub default_branch: String,
65}
66
67impl GitAutoInit {
68    /// Create with `main` as the default branch — the JSS default.
69    pub fn new() -> Self {
70        Self {
71            default_branch: "main".into(),
72        }
73    }
74
75    /// Override the initial branch name.
76    pub fn with_branch(branch: impl Into<String>) -> Self {
77        Self {
78            default_branch: branch.into(),
79        }
80    }
81}
82
83impl Default for GitAutoInit {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89impl GitAutoInit {
90    /// Initialise a git repository at `fs_pod_root` (`git init -b <branch>` +
91    /// `receive.denyCurrentBranch updateInstead`), returning a git-crate-native
92    /// [`GitError`] on failure.
93    ///
94    /// This is the single canonical init implementation — the
95    /// [`GitInitHook::try_init_repo`] trait method (provisioning-time) and the
96    /// on-demand auto-init in [`crate::service::GitHttpService::handle`] (first
97    /// push) both delegate here, so there is exactly one place that shells
98    /// `git init`. Idempotent: re-running on an existing repo is safe (git
99    /// reinitialises in place). Mirrors JSS `tryAutoInitRepo` (#466/#469/#472).
100    pub async fn init_repo_at(&self, fs_pod_root: &Path) -> Result<(), GitError> {
101        // ── Step 1: git init -b <branch> <path> ────────────────────────────
102        let init_out = crate::hardened::hardened_git_async()
103            .arg("init")
104            .arg("-b")
105            .arg(&self.default_branch)
106            .arg(fs_pod_root)
107            .stdout(Stdio::null())
108            .stderr(Stdio::piped())
109            .output()
110            .await
111            .map_err(|e| {
112                if e.kind() == std::io::ErrorKind::NotFound {
113                    GitError::BackendNotAvailable("git binary not found in PATH".into())
114                } else {
115                    GitError::Io(e)
116                }
117            })?;
118
119        if !init_out.status.success() {
120            let stderr = String::from_utf8_lossy(&init_out.stderr).into_owned();
121            return Err(GitError::BackendFailed {
122                exit_code: init_out.status.code(),
123                stderr: format!(
124                    "git init -b {} {:?}: {stderr}",
125                    self.default_branch, fs_pod_root,
126                ),
127            });
128        }
129
130        // ── Step 2: git config receive.denyCurrentBranch updateInstead ─────
131        // Allows HTTP push to a non-bare repo (the checked-out branch gets
132        // updated in-place). Matches JSS lines 147-149. Best-effort —
133        // swallow errors so the pod is not rolled back.
134        let cfg_out = crate::hardened::hardened_git_async()
135            .arg("-C")
136            .arg(fs_pod_root)
137            .arg("config")
138            .arg("receive.denyCurrentBranch")
139            .arg("updateInstead")
140            .stdout(Stdio::null())
141            .stderr(Stdio::piped())
142            .output()
143            .await;
144
145        match cfg_out {
146            Ok(o) if o.status.success() => {}
147            Ok(o) => {
148                let stderr = String::from_utf8_lossy(&o.stderr).into_owned();
149                tracing::warn!(
150                    target: "solid_pod_rs_git::init",
151                    path = %fs_pod_root.display(),
152                    "git config receive.denyCurrentBranch failed (non-fatal): {stderr}",
153                );
154            }
155            Err(e) => {
156                tracing::warn!(
157                    target: "solid_pod_rs_git::init",
158                    "git config spawn error (non-fatal): {e}",
159                );
160            }
161        }
162
163        tracing::debug!(
164            target: "solid_pod_rs_git::init",
165            path = %fs_pod_root.display(),
166            branch = %self.default_branch,
167            "pod git repository initialised",
168        );
169
170        Ok(())
171    }
172}
173
174#[async_trait]
175impl GitInitHook for GitAutoInit {
176    async fn try_init_repo(&self, fs_pod_root: &Path) -> Result<(), PodError> {
177        // Delegate to the single canonical init implementation, mapping the
178        // git-crate error back into the provisioning `PodError` surface.
179        self.init_repo_at(fs_pod_root)
180            .await
181            .map_err(|e| PodError::Backend(e.to_string()))
182    }
183}
184
185// ---------------------------------------------------------------------------
186// Tests
187// ---------------------------------------------------------------------------
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use tempfile::TempDir;
193
194    fn git_available() -> bool {
195        std::process::Command::new("git")
196            .arg("--version")
197            .stdout(Stdio::null())
198            .stderr(Stdio::null())
199            .status()
200            .map(|s| s.success())
201            .unwrap_or(false)
202    }
203
204    #[tokio::test]
205    async fn git_auto_init_creates_dot_git() {
206        if !git_available() {
207            return;
208        }
209        let td = TempDir::new().unwrap();
210        let hook = GitAutoInit::new();
211        hook.try_init_repo(td.path()).await.unwrap();
212        assert!(td.path().join(".git").is_dir(), ".git directory must exist");
213    }
214
215    #[tokio::test]
216    async fn git_auto_init_sets_deny_current_branch() {
217        if !git_available() {
218            return;
219        }
220        let td = TempDir::new().unwrap();
221        let hook = GitAutoInit::new();
222        hook.try_init_repo(td.path()).await.unwrap();
223
224        let out = std::process::Command::new("git")
225            .arg("-C")
226            .arg(td.path())
227            .arg("config")
228            .arg("receive.denyCurrentBranch")
229            .output()
230            .unwrap();
231        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "updateInstead",);
232    }
233
234    #[tokio::test]
235    async fn git_auto_init_custom_branch() {
236        if !git_available() {
237            return;
238        }
239        let td = TempDir::new().unwrap();
240        let hook = GitAutoInit::with_branch("trunk");
241        hook.try_init_repo(td.path()).await.unwrap();
242
243        let out = std::process::Command::new("git")
244            .arg("-C")
245            .arg(td.path())
246            .arg("symbolic-ref")
247            .arg("HEAD")
248            .output()
249            .unwrap();
250        assert_eq!(
251            String::from_utf8_lossy(&out.stdout).trim(),
252            "refs/heads/trunk",
253        );
254    }
255
256    #[tokio::test]
257    async fn git_auto_init_idempotent() {
258        if !git_available() {
259            return;
260        }
261        let td = TempDir::new().unwrap();
262        let hook = GitAutoInit::new();
263        hook.try_init_repo(td.path()).await.unwrap();
264        // Second call must not fail (git init on an existing repo is safe).
265        hook.try_init_repo(td.path()).await.unwrap();
266        assert!(td.path().join(".git").is_dir());
267    }
268}