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