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}