Skip to main content

rns_git/
git.rs

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}