1use crate::error::BridgeError;
11use crate::gitobj::{GitType, Sha1Id, sha1_from_hex, sha1_hex};
12use std::io::{BufRead, BufReader, Read, Write};
13use std::path::{Path, PathBuf};
14use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
15
16pub const MAX_OBJECT_BYTES: u64 = 1024 * 1024 * 1024;
20
21#[must_use]
32pub fn git_command(repo: &Path) -> Command {
33 let mut c = Command::new("git");
34 c.arg("-C").arg(repo);
35 apply_hygiene(&mut c);
36 c
37}
38
39pub fn apply_hygiene(c: &mut Command) {
43 c.env("GIT_TERMINAL_PROMPT", "0");
44 let null = if cfg!(windows) { "NUL" } else { "/dev/null" };
45 c.arg("-c").arg(format!("core.hooksPath={null}"));
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum GitObjKind {
51 Blob,
52 Tree,
53 Commit,
54 Tag,
55}
56
57impl GitObjKind {
58 fn from_name(name: &str) -> Option<Self> {
59 Some(match name {
60 "blob" => Self::Blob,
61 "tree" => Self::Tree,
62 "commit" => Self::Commit,
63 "tag" => Self::Tag,
64 _ => return None,
65 })
66 }
67}
68
69impl From<GitObjKind> for GitType {
70 fn from(kind: GitObjKind) -> Self {
71 match kind {
72 GitObjKind::Blob => Self::Blob,
73 GitObjKind::Tree => Self::Tree,
74 GitObjKind::Commit => Self::Commit,
75 GitObjKind::Tag => Self::Tag,
76 }
77 }
78}
79
80#[derive(Debug)]
88pub struct CatFileBatch {
89 child: Child,
90 stdin: ChildStdin,
91 stdout: BufReader<ChildStdout>,
92 repo: PathBuf,
93}
94
95impl CatFileBatch {
96 pub fn open(repo: &Path) -> Result<Self, BridgeError> {
98 let mut child = git_command(repo)
99 .args(["cat-file", "--batch"])
100 .stdin(Stdio::piped())
101 .stdout(Stdio::piped())
102 .stderr(Stdio::null())
103 .spawn()
104 .map_err(|e| BridgeError::Source(format!("spawn git cat-file: {e}")))?;
105 let stdin = child
106 .stdin
107 .take()
108 .ok_or_else(|| BridgeError::Source("cat-file stdin unavailable".into()))?;
109 let stdout = child
110 .stdout
111 .take()
112 .map(BufReader::new)
113 .ok_or_else(|| BridgeError::Source("cat-file stdout unavailable".into()))?;
114 Ok(Self {
115 child,
116 stdin,
117 stdout,
118 repo: repo.to_path_buf(),
119 })
120 }
121
122 pub fn read(&mut self, id: &Sha1Id) -> Result<(GitObjKind, Vec<u8>), BridgeError> {
124 let hex = sha1_hex(id);
125 self.stdin
126 .write_all(format!("{hex}\n").as_bytes())
127 .and_then(|()| self.stdin.flush())
128 .map_err(|e| BridgeError::Source(format!("cat-file write: {e}")))?;
129
130 let mut header = String::new();
131 self.stdout
132 .read_line(&mut header)
133 .map_err(|e| BridgeError::Source(format!("cat-file read: {e}")))?;
134 let header = header.trim_end();
135 let mut parts = header.split(' ');
136 let (Some(echo), Some(kind_or_missing)) = (parts.next(), parts.next()) else {
137 return Err(BridgeError::Source(format!(
138 "cat-file: malformed header {header:?} (repo {})",
139 self.repo.display()
140 )));
141 };
142 if kind_or_missing == "missing" {
143 return Err(BridgeError::Source(format!("object {echo} missing")));
144 }
145 let kind = GitObjKind::from_name(kind_or_missing)
146 .ok_or_else(|| BridgeError::Source(format!("cat-file: unknown type {header:?}")))?;
147 let size: u64 = parts
148 .next()
149 .and_then(|s| s.parse().ok())
150 .ok_or_else(|| BridgeError::Source(format!("cat-file: bad size {header:?}")))?;
151 if size > MAX_OBJECT_BYTES {
152 let Some(mut remaining) = size.checked_add(1) else {
160 return Err(BridgeError::Source(format!(
161 "object {echo} reports an absurd size ({size}); cat-file \
162 stream untrustworthy"
163 )));
164 };
165 let mut sink_buf = vec![0u8; 64 * 1024];
166 while remaining > 0 {
167 let take = remaining.min(sink_buf.len() as u64);
168 #[allow(clippy::cast_possible_truncation)] let take = take as usize;
170 self.stdout
171 .read_exact(&mut sink_buf[..take])
172 .map_err(|e| BridgeError::Source(format!("cat-file drain: {e}")))?;
173 remaining -= take as u64;
174 }
175 let mut obj = crate::gitobj::Sha1Id::default();
176 if let Some(parsed) = sha1_from_hex(echo) {
177 obj = parsed;
178 }
179 return Err(crate::error::Refusal::BlobTooLarge {
180 object: {
181 let mut h = [0u8; 32];
182 h[..20].copy_from_slice(&obj);
183 h
184 },
185 size,
186 }
187 .into());
188 }
189 #[allow(clippy::cast_possible_truncation)] let mut body = vec![0u8; size as usize];
191 self.stdout
192 .read_exact(&mut body)
193 .map_err(|e| BridgeError::Source(format!("cat-file body: {e}")))?;
194 let mut nl = [0u8; 1];
195 self.stdout
196 .read_exact(&mut nl)
197 .map_err(|e| BridgeError::Source(format!("cat-file trailer: {e}")))?;
198 Ok((kind, body))
199 }
200}
201
202impl Drop for CatFileBatch {
203 fn drop(&mut self) {
204 let _ = self.child.kill();
207 let _ = self.child.wait();
208 }
209}
210
211fn git_stdout(repo: &Path, args: &[&str]) -> Result<String, BridgeError> {
212 let out = git_command(repo)
213 .args(args)
214 .output()
215 .map_err(|e| BridgeError::Source(format!("spawn git: {e}")))?;
216 if !out.status.success() {
217 return Err(BridgeError::Source(format!(
218 "git {} failed: {}",
219 args.first().copied().unwrap_or(""),
220 String::from_utf8_lossy(&out.stderr).trim()
221 )));
222 }
223 String::from_utf8(out.stdout).map_err(|_| BridgeError::Source("git output not UTF-8".into()))
224}
225
226pub fn rev_list(
229 repo: &Path,
230 tips: &[Sha1Id],
231 exclude: &[Sha1Id],
232) -> Result<Vec<Sha1Id>, BridgeError> {
233 let mut args: Vec<String> = vec!["rev-list".into(), "--reverse".into(), "--topo-order".into()];
234 for t in tips {
235 args.push(sha1_hex(t));
236 }
237 for e in exclude {
238 args.push(format!("^{}", sha1_hex(e)));
239 }
240 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
241 let out = git_stdout(repo, &arg_refs)?;
242 out.lines()
243 .map(|l| {
244 sha1_from_hex(l.trim())
245 .ok_or_else(|| BridgeError::Source(format!("rev-list: bad id {l:?}")))
246 })
247 .collect()
248}
249
250#[derive(Debug, Clone, PartialEq, Eq)]
252pub struct UpstreamRef {
253 pub name: String,
255 pub id: Sha1Id,
257 pub peeled: Option<Sha1Id>,
259}
260
261pub fn list_refs(repo: &Path) -> Result<Vec<UpstreamRef>, BridgeError> {
263 let out = git_stdout(
264 repo,
265 &[
266 "for-each-ref",
267 "--format=%(refname) %(objectname) %(*objectname)",
268 "refs/heads",
269 "refs/tags",
270 ],
271 )?;
272 let mut refs = Vec::new();
273 for line in out.lines() {
274 let mut parts = line.split(' ');
275 let (Some(name), Some(id_hex)) = (parts.next(), parts.next()) else {
276 continue;
277 };
278 let Some(id) = sha1_from_hex(id_hex) else {
279 continue;
280 };
281 let peeled = parts
282 .next()
283 .filter(|s| !s.is_empty())
284 .and_then(sha1_from_hex);
285 refs.push(UpstreamRef {
286 name: name.to_owned(),
287 id,
288 peeled,
289 });
290 }
291 Ok(refs)
292}
293
294pub fn default_branch(repo: &Path) -> Result<Option<String>, BridgeError> {
296 let out = git_command(repo)
297 .args(["symbolic-ref", "--quiet", "HEAD"])
298 .output()
299 .map_err(|e| BridgeError::Source(format!("spawn git: {e}")))?;
300 if !out.status.success() {
301 return Ok(None); }
303 Ok(Some(String::from_utf8_lossy(&out.stdout).trim().to_owned()))
304}
305
306pub fn is_ancestor(repo: &Path, old: &Sha1Id, new: &Sha1Id) -> Result<bool, BridgeError> {
309 let st = git_command(repo)
310 .args([
311 "merge-base",
312 "--is-ancestor",
313 &sha1_hex(old),
314 &sha1_hex(new),
315 ])
316 .stdout(Stdio::null())
317 .stderr(Stdio::null())
318 .status()
319 .map_err(|e| BridgeError::Source(format!("spawn git: {e}")))?;
320 match st.code() {
321 Some(0) => Ok(true),
322 Some(1) => Ok(false),
323 _ => Err(BridgeError::Source(
324 "merge-base --is-ancestor failed".into(),
325 )),
326 }
327}
328
329pub fn object_exists(repo: &Path, id: &Sha1Id) -> Result<bool, BridgeError> {
332 let st = git_command(repo)
333 .args(["cat-file", "-e", &sha1_hex(id)])
334 .stdout(Stdio::null())
335 .stderr(Stdio::null())
336 .status()
337 .map_err(|e| BridgeError::Source(format!("spawn git: {e}")))?;
338 Ok(st.code() == Some(0))
339}
340
341pub fn is_sha256_repo(repo: &Path) -> Result<bool, BridgeError> {
343 let out = git_command(repo)
344 .args(["config", "extensions.objectformat"])
345 .output()
346 .map_err(|e| BridgeError::Source(format!("spawn git: {e}")))?;
347 Ok(out.status.success()
349 && String::from_utf8_lossy(&out.stdout)
350 .trim()
351 .eq_ignore_ascii_case("sha256"))
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use mkit_test_util::require_tool;
358
359 fn fixture() -> Option<(tempfile::TempDir, Sha1Id)> {
361 if !require_tool("git") {
362 return None;
363 }
364 let td = tempfile::tempdir().unwrap();
365 let run = |args: &[&str]| {
366 let out = Command::new("git")
367 .arg("-C")
368 .arg(td.path())
369 .args(args)
370 .env("GIT_AUTHOR_NAME", "A")
371 .env("GIT_AUTHOR_EMAIL", "a@x")
372 .env("GIT_COMMITTER_NAME", "C")
373 .env("GIT_COMMITTER_EMAIL", "c@x")
374 .env("GIT_AUTHOR_DATE", "1700000000 +0000")
375 .env("GIT_COMMITTER_DATE", "1700000000 +0000")
376 .output()
377 .unwrap();
378 assert!(out.status.success(), "git {args:?}: {out:?}");
379 String::from_utf8_lossy(&out.stdout).trim().to_owned()
380 };
381 run(&["init", "--quiet", "--initial-branch=main", "."]);
382 std::fs::write(td.path().join("a.txt"), "hello\n").unwrap();
383 run(&["add", "a.txt"]);
384 run(&["commit", "--quiet", "-m", "first"]);
385 std::fs::write(td.path().join("b.txt"), "world\n").unwrap();
386 run(&["add", "b.txt"]);
387 run(&["commit", "--quiet", "-m", "second"]);
388 run(&["tag", "-a", "v1", "-m", "tag msg"]);
389 let head = sha1_from_hex(&run(&["rev-parse", "HEAD"])).unwrap();
390 Some((td, head))
391 }
392
393 #[test]
394 fn batch_reads_kinds_and_missing() {
395 let Some((td, head)) = fixture() else { return };
396 let git_dir = td.path().join(".git");
397 let mut batch = CatFileBatch::open(&git_dir).unwrap();
398 let (kind, body) = batch.read(&head).unwrap();
399 assert_eq!(kind, GitObjKind::Commit);
400 let c = crate::gitparse::parse_commit(&body).unwrap();
401 assert_eq!(c.message, b"second\n");
402 assert_eq!(c.committer.timestamp, 1_700_000_000);
403 let (kind, tree_body) = batch.read(&c.tree).unwrap();
405 assert_eq!(kind, GitObjKind::Tree);
406 let entries = crate::gitparse::parse_tree(&tree_body).unwrap();
407 assert_eq!(entries.len(), 2);
408 let (kind, blob) = batch.read(&entries[0].id).unwrap();
409 assert_eq!(kind, GitObjKind::Blob);
410 assert_eq!(blob, b"hello\n");
411 assert!(batch.read(&[0xEEu8; 20]).is_err());
413 assert!(batch.read(&head).is_ok(), "stream survives a miss");
414 }
415
416 #[test]
417 fn rev_list_orders_parents_first_and_excludes() {
418 let Some((td, head)) = fixture() else { return };
419 let git_dir = td.path().join(".git");
420 let all = rev_list(&git_dir, &[head], &[]).unwrap();
421 assert_eq!(all.len(), 2);
422 assert_eq!(*all.last().unwrap(), head, "tip last (parents first)");
423 let inc = rev_list(&git_dir, &[head], &[all[0]]).unwrap();
424 assert_eq!(inc, vec![head], "exclusion yields the delta only");
425 }
426
427 #[test]
428 fn list_refs_peels_tags_and_default_branch() {
429 let Some((td, head)) = fixture() else { return };
430 let git_dir = td.path().join(".git");
431 let refs = list_refs(&git_dir).unwrap();
432 let tag = refs.iter().find(|r| r.name == "refs/tags/v1").unwrap();
433 assert_ne!(tag.id, head, "annotated tag has its own object id");
434 assert_eq!(tag.peeled, Some(head));
435 let main = refs.iter().find(|r| r.name == "refs/heads/main").unwrap();
436 assert_eq!(main.id, head);
437 assert_eq!(main.peeled, None);
438 assert_eq!(
439 default_branch(&git_dir).unwrap().as_deref(),
440 Some("refs/heads/main")
441 );
442 assert!(!is_sha256_repo(&git_dir).unwrap());
443 }
444
445 #[test]
449 fn objkind_into_gittype_frames_canonically() {
450 for (kind, name) in [
451 (GitObjKind::Blob, "blob"),
452 (GitObjKind::Tree, "tree"),
453 (GitObjKind::Commit, "commit"),
454 (GitObjKind::Tag, "tag"),
455 ] {
456 let gtype: GitType = kind.into();
457 assert_eq!(gtype.name(), name);
458 let body = b"hi".to_vec();
459 let raw = crate::gitobj::GitObject {
460 gtype,
461 body: body.clone(),
462 }
463 .raw();
464 let mut expect = Vec::new();
465 expect.extend_from_slice(name.as_bytes());
466 expect.push(b' ');
467 expect.extend_from_slice(body.len().to_string().as_bytes());
468 expect.push(0);
469 expect.extend_from_slice(&body);
470 assert_eq!(raw, expect, "{name} framing");
471 }
472 }
473}