1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use crate::protocol::RefUpdate;
7use crate::util::validate_repo_name;
8use crate::{Error, Result};
9
10pub fn check_git_available() -> Result<()> {
11 run(Command::new("git").arg("--version")).map(|_| ())
12}
13
14pub fn repository_path(root: &Path, repository: &str) -> Result<PathBuf> {
15 validate_repo_name(repository)?;
16 Ok(root.join(repository))
17}
18
19pub fn ensure_bare_repository(path: &Path) -> Result<()> {
20 if path.join("HEAD").exists() && path.join("objects").is_dir() {
21 return Ok(());
22 }
23 if let Some(parent) = path.parent() {
24 fs::create_dir_all(parent)?;
25 }
26 run(Command::new("git").arg("init").arg("--bare").arg(path)).map(|_| ())
27}
28
29pub fn list_refs(path: &Path) -> Result<Vec<(String, String)>> {
30 require_repository(path)?;
31 let output = run(Command::new("git")
32 .arg("--git-dir")
33 .arg(path)
34 .arg("for-each-ref")
35 .arg("--format=%(objectname) %(refname)"))?;
36 Ok(output
37 .lines()
38 .filter_map(|line| {
39 let (sha, name) = line.split_once(' ')?;
40 Some((sha.to_string(), name.to_string()))
41 })
42 .collect())
43}
44
45pub fn list_refs_text(path: &Path) -> Result<Vec<u8>> {
46 let mut out = Vec::new();
47 for (sha, name) in list_refs(path)? {
48 out.extend_from_slice(sha.as_bytes());
49 out.push(b' ');
50 out.extend_from_slice(name.as_bytes());
51 out.push(b'\n');
52 }
53 Ok(out)
54}
55
56pub fn create_bundle(path: &Path, _have: &[String]) -> Result<Vec<u8>> {
57 require_repository(path)?;
58 if list_refs(path)?.is_empty() {
59 return Ok(Vec::new());
60 }
61 let bundle_path = temp_path("rngit-fetch", "bundle");
62 let result = run(Command::new("git")
63 .arg("--git-dir")
64 .arg(path)
65 .arg("bundle")
66 .arg("create")
67 .arg(&bundle_path)
68 .arg("--all"));
69 let bytes = match result {
70 Ok(_) => fs::read(&bundle_path)?,
71 Err(err) => {
72 let _ = fs::remove_file(&bundle_path);
73 return Err(err);
74 }
75 };
76 let _ = fs::remove_file(&bundle_path);
77 Ok(bytes)
78}
79
80pub fn apply_push(path: &Path, bundle: &[u8], updates: &[RefUpdate]) -> Result<()> {
81 ensure_bare_repository(path)?;
82 if !bundle.is_empty() {
83 let bundle_path = temp_path("rngit-push", "bundle");
84 fs::write(&bundle_path, bundle)?;
85 let result = run(Command::new("git")
86 .arg("--git-dir")
87 .arg(path)
88 .arg("fetch")
89 .arg(&bundle_path)
90 .arg("+refs/heads/*:refs/heads/*")
91 .arg("+refs/tags/*:refs/tags/*"));
92 let _ = fs::remove_file(&bundle_path);
93 result?;
94 }
95
96 for update in updates {
97 if let Some(new) = update.new.as_deref() {
98 update_ref(
99 path,
100 &update.refname,
101 new,
102 update.old.as_deref(),
103 update.force,
104 )?;
105 } else {
106 delete_ref(path, &update.refname, update.old.as_deref(), update.force)?;
107 }
108 }
109 Ok(())
110}
111
112pub fn local_ref_sha(refname: &str) -> Result<Option<String>> {
113 let output = Command::new("git")
114 .arg("rev-parse")
115 .arg("--verify")
116 .arg(refname)
117 .output()?;
118 if !output.status.success() {
119 return Ok(None);
120 }
121 Ok(Some(
122 String::from_utf8_lossy(&output.stdout).trim().to_string(),
123 ))
124}
125
126pub fn create_local_bundle(refs: &[String]) -> Result<Vec<u8>> {
127 if refs.is_empty() {
128 return Ok(Vec::new());
129 }
130 let bundle_path = temp_path("rngit-local-push", "bundle");
131 let result = run(Command::new("git")
132 .arg("bundle")
133 .arg("create")
134 .arg(&bundle_path)
135 .args(refs));
136 let bytes = match result {
137 Ok(_) => fs::read(&bundle_path)?,
138 Err(err) => {
139 let _ = fs::remove_file(&bundle_path);
140 return Err(err);
141 }
142 };
143 let _ = fs::remove_file(&bundle_path);
144 Ok(bytes)
145}
146
147pub fn fetch_bundle_into_local(bundle: &[u8], wanted: &[String]) -> Result<()> {
148 if bundle.is_empty() {
149 return Ok(());
150 }
151 let bundle_path = temp_path("rngit-local-fetch", "bundle");
152 fs::write(&bundle_path, bundle)?;
153 let mut cmd = Command::new("git");
154 cmd.arg("fetch").arg(&bundle_path);
155 if wanted.is_empty() {
156 cmd.arg("refs/*:refs/*");
157 } else {
158 cmd.args(wanted);
159 }
160 let result = run(&mut cmd);
161 let _ = fs::remove_file(&bundle_path);
162 result.map(|_| ())
163}
164
165fn update_ref(path: &Path, refname: &str, new: &str, old: Option<&str>, force: bool) -> Result<()> {
166 let mut cmd = Command::new("git");
167 cmd.arg("--git-dir")
168 .arg(path)
169 .arg("update-ref")
170 .arg(refname)
171 .arg(new);
172 if !force {
173 if let Some(old) = old {
174 cmd.arg(old);
175 }
176 }
177 run(&mut cmd).map(|_| ())
178}
179
180fn delete_ref(path: &Path, refname: &str, old: Option<&str>, force: bool) -> Result<()> {
181 let mut cmd = Command::new("git");
182 cmd.arg("--git-dir")
183 .arg(path)
184 .arg("update-ref")
185 .arg("-d")
186 .arg(refname);
187 if !force {
188 if let Some(old) = old {
189 cmd.arg(old);
190 }
191 }
192 run(&mut cmd).map(|_| ())
193}
194
195fn require_repository(path: &Path) -> Result<()> {
196 if path.join("HEAD").exists() && path.join("objects").is_dir() {
197 Ok(())
198 } else {
199 Err(Error::msg("repository not found"))
200 }
201}
202
203fn temp_path(prefix: &str, extension: &str) -> PathBuf {
204 let now = SystemTime::now()
205 .duration_since(UNIX_EPOCH)
206 .unwrap_or_default()
207 .as_nanos();
208 std::env::temp_dir().join(format!("{prefix}-{}-{now}.{extension}", std::process::id()))
209}
210
211fn run(cmd: &mut Command) -> Result<String> {
212 let output = cmd.output()?;
213 if output.status.success() {
214 return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
215 }
216 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
217 Err(Error::msg(if stderr.is_empty() {
218 "git command failed".to_string()
219 } else {
220 stderr
221 }))
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn bare_repo_can_list_refs() {
230 let tmp = tempfile::tempdir().unwrap();
231 let repo = tmp.path().join("repo.git");
232 ensure_bare_repository(&repo).unwrap();
233 assert!(list_refs(&repo).unwrap().is_empty());
234 }
235
236 #[test]
237 fn repository_paths_reject_traversal() {
238 assert!(repository_path(Path::new("/tmp/repos"), "../x").is_err());
239 assert!(repository_path(Path::new("/tmp/repos"), "group/repo").is_ok());
240 }
241
242 #[test]
243 fn bundle_push_roundtrip_updates_bare_repository() {
244 let tmp = tempfile::tempdir().unwrap();
245 let work = tmp.path().join("work");
246 let target = tmp.path().join("target.git");
247 fs::create_dir_all(&work).unwrap();
248
249 test_git(Command::new("git").arg("init").arg(&work));
250 fs::write(work.join("README.md"), "hello\n").unwrap();
251 test_git(
252 Command::new("git")
253 .arg("-C")
254 .arg(&work)
255 .arg("add")
256 .arg("README.md"),
257 );
258 test_git(
259 Command::new("git")
260 .arg("-C")
261 .arg(&work)
262 .arg("-c")
263 .arg("user.name=RNS Test")
264 .arg("-c")
265 .arg("user.email=rns@example.invalid")
266 .arg("commit")
267 .arg("-m")
268 .arg("init"),
269 );
270 test_git(
271 Command::new("git")
272 .arg("-C")
273 .arg(&work)
274 .arg("branch")
275 .arg("-M")
276 .arg("main"),
277 );
278 let sha = test_git(
279 Command::new("git")
280 .arg("-C")
281 .arg(&work)
282 .arg("rev-parse")
283 .arg("refs/heads/main"),
284 );
285 let sha = sha.trim().to_string();
286 let bundle_path = tmp.path().join("push.bundle");
287 test_git(
288 Command::new("git")
289 .arg("-C")
290 .arg(&work)
291 .arg("bundle")
292 .arg("create")
293 .arg(&bundle_path)
294 .arg("refs/heads/main"),
295 );
296
297 apply_push(
298 &target,
299 &fs::read(&bundle_path).unwrap(),
300 &[RefUpdate {
301 refname: "refs/heads/main".into(),
302 old: None,
303 new: Some(sha.clone()),
304 force: true,
305 }],
306 )
307 .unwrap();
308
309 assert_eq!(
310 list_refs(&target).unwrap(),
311 vec![(sha, "refs/heads/main".into())]
312 );
313 assert!(!create_bundle(&target, &[]).unwrap().is_empty());
314 }
315
316 fn test_git(cmd: &mut Command) -> String {
317 let output = cmd.output().unwrap();
318 assert!(
319 output.status.success(),
320 "git command failed: {}",
321 String::from_utf8_lossy(&output.stderr)
322 );
323 String::from_utf8_lossy(&output.stdout).into_owned()
324 }
325}