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}