zuka
zuka/src/git/store.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/git/store.rs
RSstore.rs25.2 KBDownload
1// Repository lifecycle on disk.
2//
3// Hooks are symlinks into `$DATA_DIR/hooks/`, installed through git's
4// `init.templateDir` at creation. Symlinks rather than copies so upgrading a hook
5// is atomic across every existing repository.
6//
7// The hooks contain no policy: each is a one-line `exec` into this binary. The ref
8// rule therefore lives in Rust rather than in shell, where it would have been a
9// second implementation that drifts. It has one caller — the `update` hook — because
10// there is no API path that moves a ref; git is the only writer.
11
12use crate::brand;
13use crate::error::{Error, Result};
14use crate::git::exec::Git;
15use crate::git::validate::{self, Name};
16use anyhow::Context;
17use std::path::{Path, PathBuf};
18
19/// Hooks installed into every repository. Each delegates to `<binary> hook <name>`
20/// so policy lives in Rust rather than in shell.
21const HOOKS: &[&str] = &["update", "post-receive"];
22
23#[derive(Debug, Clone)]
24pub struct GitStore {
25 git_root: PathBuf,
26 hooks_dir: PathBuf,
27 template_dir: PathBuf,
28 deleted_dir: PathBuf,
29 /// Mirrors the transport's cap so `receive.maxInputSize` cannot disagree with
30 /// the byte limit the HTTP layer enforces.
31 max_pack_bytes: u64,
32}
33
34impl GitStore {
35 pub fn open(data_dir: &Path, max_pack_bytes: u64) -> Result<Self> {
36 let store = GitStore {
37 git_root: data_dir.join("git"),
38 hooks_dir: data_dir.join("hooks"),
39 template_dir: data_dir.join("template"),
40 deleted_dir: data_dir.join("tmp").join("deleted"),
41 max_pack_bytes,
42 };
43 store.install_hooks()?;
44 Ok(store)
45 }
46
47 pub fn git_root(&self) -> &Path {
48 &self.git_root
49 }
50
51 /// Write hook shims and point the template's `hooks/` at them.
52 ///
53 /// Idempotent, and rerun on every boot so an upgraded binary lands its hook
54 /// changes across repositories that already exist.
55 fn install_hooks(&self) -> Result<()> {
56 for dir in [&self.git_root, &self.hooks_dir, &self.deleted_dir] {
57 std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
58 }
59 let template_hooks = self.template_dir.join("hooks");
60 std::fs::create_dir_all(&template_hooks)
61 .with_context(|| format!("create {}", template_hooks.display()))?;
62
63 let binary = std::env::current_exe().context("locate own executable")?;
64
65 for name in HOOKS {
66 let target = self.hooks_dir.join(name);
67 let body = format!(
68 "#!/bin/sh\n# Installed by {}. Policy lives in the binary.\nexec {} hook {name} \"$@\"\n",
69 brand::NAME,
70 shell_quote(&binary.to_string_lossy())
71 );
72 std::fs::write(&target, body)
73 .with_context(|| format!("write hook {}", target.display()))?;
74 set_executable(&target)?;
75
76 let link = template_hooks.join(name);
77 match std::fs::remove_file(&link) {
78 Ok(()) => {}
79 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
80 Err(e) => {
81 return Err(Error::Internal(
82 anyhow::Error::from(e).context(format!("unlink {}", link.display())),
83 ))
84 }
85 }
86 symlink(&target, &link).with_context(|| format!("link {}", link.display()))?;
87 }
88 Ok(())
89 }
90
91 pub fn repo_path(&self, account: &Name, repo: &Name) -> Result<PathBuf> {
92 validate::repo_path(&self.git_root, account, repo)
93 }
94
95 pub fn exists(&self, account: &Name, repo: &Name) -> Result<bool> {
96 Ok(self.repo_path(account, repo)?.join("HEAD").is_file())
97 }
98
99 /// Initialise a bare repository with hooks installed and HEAD pointed at the
100 /// default branch.
101 ///
102 /// HEAD must be a symref to the branch even though it does not exist yet, or
103 /// cloning the empty repo leaves the client on its own `init.defaultBranch`.
104 pub fn create(&self, account: &Name, repo: &Name, default_branch: &str) -> Result<PathBuf> {
105 validate::ref_name(default_branch)?;
106 let path = self.repo_path(account, repo)?;
107
108 let parent = path.parent().expect("repo path always has a parent");
109 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
110
111 let path_str = path
112 .to_str()
113 .ok_or_else(|| Error::invalid("invalid-name", "repository path is not valid UTF-8"))?;
114
115 Git::plain().run(&[
116 "init",
117 "--bare",
118 "--quiet",
119 &format!("--template={}", self.template_dir.display()),
120 &format!(
121 "--initial-branch={}",
122 default_branch
123 .strip_prefix("refs/heads/")
124 .unwrap_or(default_branch)
125 ),
126 path_str,
127 ])?;
128
129 self.apply_repo_config(&path)?;
130 Ok(path)
131 }
132
133 /// Config every repository must carry. Reapplied on open so an upgrade reaches
134 /// repositories created before a setting existed.
135 fn apply_repo_config(&self, path: &Path) -> Result<()> {
136 let git = Git::at(path);
137 let max_pack = self.max_pack_bytes.to_string();
138
139 for setting in [
140 // Reject malformed and unreachable objects at receive time rather than
141 // storing them and failing later on read.
142 ["receive.fsckObjects", "true"].as_slice(),
143 // Git's own bound, applied at index-pack before the pack reaches disk.
144 // Taken from configuration so it cannot disagree with the transport cap.
145 ["receive.maxInputSize", &max_pack].as_slice(),
146 ["core.logAllRefUpdates", "true"].as_slice(),
147 // Maintenance is scheduled, not opportunistic: git's own auto-gc
148 // otherwise fires inside a push, at a moment nobody chose and
149 // unsynchronised with everything else. See `jobs::gc`.
150 ["gc.auto", "0"].as_slice(),
151 ["receive.autogc", "false"].as_slice(),
152 ] {
153 git.run(&["config", setting[0], setting[1]])?;
154 }
155 Ok(())
156 }
157
158 /// Apply our config and hooks to a repository that arrived from elsewhere.
159 ///
160 /// An imported repository is untrusted input and must end up indistinguishable
161 /// from one we created: same hooks, same receive limits, same maintenance
162 /// posture. `clone --mirror` brings none of that.
163 pub fn adopt(&self, account: &Name, repo: &Name) -> Result<()> {
164 let path = self.repo_path(account, repo)?;
165 self.apply_repo_config(&path)?;
166
167 let hooks = path.join("hooks");
168 std::fs::create_dir_all(&hooks).with_context(|| format!("create {}", hooks.display()))?;
169
170 for name in HOOKS {
171 let link = hooks.join(name);
172 let _ = std::fs::remove_file(&link);
173 symlink(&self.hooks_dir.join(name), &link)
174 .with_context(|| format!("link {}", link.display()))?;
175 }
176 Ok(())
177 }
178
179 /// The branch HEAD points at, if it is a symbolic ref.
180 pub fn head_ref(&self, account: &Name, repo: &Name) -> Option<String> {
181 let path = self.repo_path(account, repo).ok()?;
182 let head = Git::at(&path)
183 .text(&["symbolic-ref", "--quiet", "HEAD"])
184 .ok()?;
185 (!head.is_empty()).then_some(head)
186 }
187
188 /// Move a repository aside. Recoverable until the sweep runs.
189 pub fn soft_delete(&self, account: &Name, repo: &Name) -> Result<bool> {
190 let path = self.repo_path(account, repo)?;
191 if !path.exists() {
192 return Ok(false);
193 }
194 let stamp = std::time::SystemTime::now()
195 .duration_since(std::time::UNIX_EPOCH)
196 .map(|d| d.as_millis())
197 .unwrap_or(0);
198 let grave = self
199 .deleted_dir
200 .join(format!("{account}__{repo}__{stamp}.git"));
201 std::fs::rename(&path, &grave)
202 .with_context(|| format!("move {} to {}", path.display(), grave.display()))?;
203 Ok(true)
204 }
205
206 /// List refs under `refs/heads/`, `refs/tags/` and `refs/notes/`.
207 pub fn list_refs(&self, account: &Name, repo: &Name) -> Result<Vec<(String, String)>> {
208 let path = self.repo_path(account, repo)?;
209 let out = Git::at(&path).run(&[
210 "for-each-ref",
211 "--format=%(refname) %(objectname)",
212 "refs/heads/",
213 "refs/tags/",
214 "refs/notes/",
215 ])?;
216
217 Ok(String::from_utf8_lossy(&out)
218 .lines()
219 .filter_map(|line| {
220 let (name, sha) = line.split_once(' ')?;
221 Some((name.to_string(), sha.to_string()))
222 })
223 .collect())
224 }
225}
226
227/// The all-zero object id git uses for "this ref does not exist".
228///
229/// Length varies with the repository's hash algorithm, so this matches on content
230/// rather than against a hardcoded 40-character SHA-1 sentinel.
231pub fn is_null_oid(oid: &str) -> bool {
232 !oid.is_empty() && oid.bytes().all(|b| b == b'0')
233}
234
235/// Git namespace for immutable direct-package releases.
236///
237/// A repository is the package, so a normal Git tag is the smallest release
238/// record we can have. Keeping package tags below their own prefix leaves ordinary
239/// project tags alone while giving `0.0.1` URLs one permanent Git anchor.
240const PACKAGE_RELEASE_PREFIX: &str = "refs/tags/pkg/";
241
242/// The exact release version encoded by a package tag.
243///
244/// Package v1 intentionally admits stable `MAJOR.MINOR.PATCH` only. It is enough
245/// to make `0.0.1` honest, avoids silently normalising multiple spellings, and
246/// leaves prerelease/channel policy for a later explicit design.
247pub fn package_release_version(ref_name: &str) -> Option<&str> {
248 let version = ref_name.strip_prefix(PACKAGE_RELEASE_PREFIX)?;
249 let mut pieces = version.split('.');
250 let (Some(major), Some(minor), Some(patch), None) =
251 (pieces.next(), pieces.next(), pieces.next(), pieces.next())
252 else {
253 return None;
254 };
255 [major, minor, patch]
256 .into_iter()
257 .all(valid_version_part)
258 .then_some(version)
259}
260
261pub fn package_release_ref(version: &str) -> Option<String> {
262 let ref_name = format!("{PACKAGE_RELEASE_PREFIX}{version}");
263 package_release_version(&ref_name)
264 .is_some()
265 .then_some(ref_name)
266}
267
268fn valid_version_part(part: &str) -> bool {
269 !part.is_empty()
270 && part.bytes().all(|byte| byte.is_ascii_digit())
271 && (part == "0" || !part.starts_with('0'))
272}
273
274/// Git config key holding the refs that may not be rewritten.
275fn protected_key() -> String {
276 brand::git_config_key("protected")
277}
278
279/// Refs protected from rewriting, read from the repository's own git config.
280///
281/// Kept in git config rather than in our metadata so the hook can read it with no
282/// data directory, no environment and no lookup — and so the setting travels with
283/// the repository if it is ever copied or restored from a backup.
284pub fn protected_refs(git_dir: &Path) -> Vec<String> {
285 let Ok(Some(out)) = Git::at(git_dir).query(&["config", "--get-all", &protected_key()]) else {
286 return Vec::new();
287 };
288 String::from_utf8_lossy(&out)
289 .lines()
290 .map(str::trim)
291 .filter(|line| !line.is_empty())
292 .map(String::from)
293 .collect()
294}
295
296/// Replace the protected-ref list.
297pub fn set_protected_refs(git_dir: &Path, refs: &[String]) -> Result<()> {
298 for name in refs {
299 validate::ref_name(name)?;
300 }
301
302 let key = protected_key();
303 let git = Git::at(git_dir);
304 // `--unset-all` exits 5 when the key is absent, which is not a failure here.
305 git.ignore(&["config", "--unset-all", &key]);
306
307 for name in refs {
308 git.run(&["config", "--add", &key, name])?;
309 }
310 Ok(())
311}
312
313/// Point HEAD at a branch.
314///
315/// A symref rather than a detached id, so a clone of the repository checks the
316/// branch out by name.
317pub fn set_head(git_dir: &Path, branch: &str) -> Result<()> {
318 validate::ref_name(branch)?;
319 Git::at(git_dir)
320 .run(&["symbolic-ref", "HEAD", branch])
321 .map(|_| ())
322}
323
324/// Whether a ref may move from `old` to `new`.
325///
326/// **Rewriting is allowed by default.** There is one agent and no reviewer here, so
327/// a blanket non-fast-forward block protects nobody while breaking `rebase`,
328/// `commit --amend` and `filter-branch` — ordinary git that this service exists to
329/// serve. Recovery is git's own reflog, enabled on every repository at creation.
330///
331/// Protection is opt-in per ref, for the branch something deploys from. Its one
332/// caller is the `update` hook, reached through `<binary> hook update`.
333pub fn check_ref_move(git_dir: &Path, ref_name: &str, old: &str, new: &str) -> Result<()> {
334 // Release tags are created by ordinary `git push` and become their own
335 // immutable source anchor. Unlike protected branches, deletion also has to be
336 // refused: a version URL is cached for a year and must never point elsewhere
337 // or become an unreachable object after someone has imported it.
338 if package_release_version(ref_name).is_some() && !is_null_oid(old) {
339 return Err(Error::conflict(
340 "immutable-release",
341 format!("{ref_name} is an immutable package release and cannot move or be deleted"),
342 ));
343 }
344 // Creating or deleting a ref is not a rewrite.
345 if is_null_oid(old) || is_null_oid(new) {
346 return Ok(());
347 }
348 if !protected_refs(git_dir).iter().any(|p| p == ref_name) {
349 return Ok(());
350 }
351
352 if Git::at(git_dir).succeeds(&["merge-base", "--is-ancestor", old, new]) {
353 return Ok(());
354 }
355
356 Err(Error::conflict(
357 "non-fast-forward",
358 format!("{ref_name} is protected; non-fast-forward update rejected"),
359 ))
360}
361
362/// Single-quote a path for embedding in a `sh` script.
363fn shell_quote(value: &str) -> String {
364 format!("'{}'", value.replace('\'', r"'\''"))
365}
366
367#[cfg(unix)]
368fn set_executable(path: &Path) -> Result<()> {
369 use std::os::unix::fs::PermissionsExt;
370 let mut perms = std::fs::metadata(path)
371 .with_context(|| format!("stat {}", path.display()))?
372 .permissions();
373 perms.set_mode(0o755);
374 std::fs::set_permissions(path, perms).with_context(|| format!("chmod {}", path.display()))?;
375 Ok(())
376}
377
378#[cfg(not(unix))]
379fn set_executable(_path: &Path) -> Result<()> {
380 Ok(())
381}
382
383#[cfg(unix)]
384fn symlink(target: &Path, link: &Path) -> anyhow::Result<()> {
385 std::os::unix::fs::symlink(target, link)?;
386 Ok(())
387}
388
389#[cfg(not(unix))]
390fn symlink(target: &Path, link: &Path) -> anyhow::Result<()> {
391 std::fs::copy(target, link)?;
392 Ok(())
393}
394
395#[cfg(all(test, unix))]
396mod tests {
397 use super::*;
398 use crate::git::validate::name;
399 // Tests build commits directly, which needs stdin; the Git helper is
400 // deliberately stdin-less because no production call site needs it.
401 use std::process::Command;
402
403 fn store() -> (tempfile::TempDir, GitStore) {
404 let dir = tempfile::tempdir().unwrap();
405 let store = GitStore::open(dir.path(), 512 * 1024 * 1024).unwrap();
406 (dir, store)
407 }
408
409 fn make(store: &GitStore, account: &str, repo: &str) -> PathBuf {
410 store
411 .create(
412 &name(account).unwrap(),
413 &name(repo).unwrap(),
414 "refs/heads/main",
415 )
416 .unwrap()
417 }
418
419 #[test]
420 fn creates_a_bare_repo_with_head_pointed_at_the_default_branch() {
421 let (_dir, store) = store();
422 let path = make(&store, "alice", "site");
423
424 assert!(path.join("HEAD").is_file());
425 assert!(!path.join(".git").exists(), "repo must be bare");
426 assert_eq!(
427 std::fs::read_to_string(path.join("HEAD")).unwrap().trim(),
428 "ref: refs/heads/main"
429 );
430 }
431
432 #[test]
433 fn every_repo_carries_the_pack_safety_config() {
434 let (_dir, store) = store();
435 let path = make(&store, "alice", "site");
436
437 for (key, expected) in [
438 ("receive.fsckObjects", "true"),
439 ("receive.maxInputSize", "536870912"),
440 ] {
441 assert_eq!(
442 Git::at(&path).text(&["config", "--get", key]).unwrap(),
443 expected,
444 "{key} must be set at create"
445 );
446 }
447 }
448
449 #[test]
450 fn hooks_are_executable_symlinks_that_delegate_to_the_binary() {
451 let (dir, store) = store();
452 let path = make(&store, "alice", "site");
453
454 for name in HOOKS {
455 let hook = path.join("hooks").join(name);
456 assert!(
457 std::fs::symlink_metadata(&hook)
458 .unwrap()
459 .file_type()
460 .is_symlink(),
461 "{name} must be a symlink"
462 );
463 assert_eq!(
464 std::fs::read_link(&hook).unwrap(),
465 dir.path().join("hooks").join(name)
466 );
467
468 let body = std::fs::read_to_string(&hook).unwrap();
469 assert!(
470 body.contains(&format!("hook {name}")),
471 "{name} must delegate to the binary, not carry policy in shell"
472 );
473 }
474 }
475
476 #[test]
477 fn a_hook_upgrade_reaches_repos_that_already_exist() {
478 let dir = tempfile::tempdir().unwrap();
479 let store = GitStore::open(dir.path(), 512 * 1024 * 1024).unwrap();
480 let path = make(&store, "alice", "site");
481
482 std::fs::write(
483 dir.path().join("hooks").join("update"),
484 "#!/bin/sh\nexit 7\n",
485 )
486 .unwrap();
487 let body = std::fs::read_to_string(path.join("hooks").join("update")).unwrap();
488 assert!(
489 body.contains("exit 7"),
490 "the symlink must carry the upgrade"
491 );
492 }
493
494 #[test]
495 fn a_case_variant_resolves_to_the_same_repository() {
496 let (_dir, store) = store();
497 let created = make(&store, "alice", "site");
498 let variant = store
499 .repo_path(&name("Alice").unwrap(), &name("SITE").unwrap())
500 .unwrap();
501 assert_eq!(created, variant);
502 }
503
504 #[test]
505 fn rejects_a_default_branch_that_is_not_a_valid_ref() {
506 let (_dir, store) = store();
507 assert!(store
508 .create(
509 &name("alice").unwrap(),
510 &name("site").unwrap(),
511 "refs/heads/../config"
512 )
513 .is_err());
514 }
515
516 #[test]
517 fn soft_delete_moves_the_repo_aside_rather_than_removing_it() {
518 let (dir, store) = store();
519 let path = make(&store, "alice", "site");
520 let (alice, site) = (name("alice").unwrap(), name("site").unwrap());
521
522 assert!(store.soft_delete(&alice, &site).unwrap());
523 assert!(!path.exists());
524 assert_eq!(
525 std::fs::read_dir(dir.path().join("tmp").join("deleted"))
526 .unwrap()
527 .count(),
528 1,
529 "repo must be recoverable after delete"
530 );
531 assert!(!store.soft_delete(&alice, &site).unwrap());
532 }
533
534 #[test]
535 fn an_empty_repo_lists_no_refs() {
536 let (_dir, store) = store();
537 make(&store, "alice", "site");
538 assert!(store
539 .list_refs(&name("alice").unwrap(), &name("site").unwrap())
540 .unwrap()
541 .is_empty());
542 }
543
544 #[test]
545 fn the_null_oid_is_recognised_at_any_hash_length() {
546 assert!(is_null_oid(&"0".repeat(40)), "sha-1 sentinel");
547 assert!(is_null_oid(&"0".repeat(64)), "sha-256 sentinel");
548 assert!(!is_null_oid(""));
549 assert!(!is_null_oid("0123"));
550 }
551
552 #[test]
553 fn ref_creation_and_deletion_are_always_allowed() {
554 let (_dir, store) = store();
555 let path = make(&store, "alice", "site");
556 let zero = "0".repeat(40);
557
558 check_ref_move(&path, "refs/heads/main", &zero, "abc").unwrap();
559 check_ref_move(&path, "refs/heads/main", "abc", &zero).unwrap();
560 }
561
562 #[test]
563 fn rewriting_an_unprotected_ref_is_allowed() {
564 let (_dir, store) = store();
565 let path = make(&store, "alice", "site");
566
567 // Two unrelated commits: neither is an ancestor of the other.
568 let first = commit(&path, "one");
569 let second = commit(&path, "two");
570
571 check_ref_move(&path, "refs/heads/main", &second, &first).unwrap_or_else(|e| {
572 panic!("rebase and amend are ordinary git and must not be blocked: {e}")
573 });
574 }
575
576 #[test]
577 fn a_package_release_tag_is_created_once_then_immutable() {
578 let (_dir, store) = store();
579 let path = make(&store, "alice", "package");
580 let zero = "0".repeat(40);
581 let first = "a".repeat(40);
582 let second = "b".repeat(40);
583 let release = "refs/tags/pkg/0.0.1";
584
585 // The first push creates the release. Every later move, including a
586 // deletion, is refused so a version URL remains one source tree forever.
587 check_ref_move(&path, release, &zero, &first).unwrap();
588 assert!(check_ref_move(&path, release, &first, &second).is_err());
589 assert!(check_ref_move(&path, release, &first, &zero).is_err());
590
591 // Project tags outside the package namespace retain ordinary Git rules.
592 check_ref_move(&path, "refs/tags/v0.0.1", &first, &second).unwrap();
593 }
594
595 #[test]
596 fn package_versions_have_one_canonical_stable_spelling() {
597 assert_eq!(
598 package_release_version("refs/tags/pkg/0.0.1"),
599 Some("0.0.1")
600 );
601 assert_eq!(
602 package_release_ref("1.2.3"),
603 Some("refs/tags/pkg/1.2.3".into())
604 );
605 for bad in [
606 "refs/tags/pkg/v0.0.1",
607 "refs/tags/pkg/01.0.1",
608 "refs/tags/pkg/0.1",
609 "refs/tags/pkg/0.0.1-beta.1",
610 "refs/tags/v0.0.1",
611 ] {
612 assert_eq!(package_release_version(bad), None, "{bad}");
613 }
614 }
615
616 #[test]
617 fn rewriting_a_protected_ref_is_refused() {
618 let (_dir, store) = store();
619 let path = make(&store, "alice", "site");
620 set_protected_refs(&path, &["refs/heads/main".to_string()]).unwrap();
621
622 let first = commit(&path, "one");
623 let second = commit(&path, "two");
624
625 let err = check_ref_move(&path, "refs/heads/main", &second, &first).unwrap_err();
626 assert_eq!(err.status(), 409);
627
628 // A different branch is untouched by main's protection.
629 check_ref_move(&path, "refs/heads/topic", &second, &first).unwrap();
630 }
631
632 #[test]
633 fn a_fast_forward_is_allowed_even_when_protected() {
634 let (_dir, store) = store();
635 let path = make(&store, "alice", "site");
636 set_protected_refs(&path, &["refs/heads/main".to_string()]).unwrap();
637
638 let first = commit(&path, "one");
639 let second = commit_onto(&path, &first, "two");
640 check_ref_move(&path, "refs/heads/main", &first, &second).unwrap();
641 }
642
643 #[test]
644 fn protected_refs_round_trip_through_the_repository_config() {
645 let (_dir, store) = store();
646 let path = make(&store, "alice", "site");
647
648 assert!(
649 protected_refs(&path).is_empty(),
650 "nothing is protected by default"
651 );
652
653 let wanted = vec!["refs/heads/main".to_string(), "refs/tags/v1".to_string()];
654 set_protected_refs(&path, &wanted).unwrap();
655 assert_eq!(protected_refs(&path), wanted);
656
657 set_protected_refs(&path, &[]).unwrap();
658 assert!(protected_refs(&path).is_empty());
659 }
660
661 #[test]
662 fn a_protected_ref_name_is_validated_before_it_reaches_git_config() {
663 let (_dir, store) = store();
664 let path = make(&store, "alice", "site");
665 assert!(set_protected_refs(&path, &["../../config".to_string()]).is_err());
666 }
667
668 /// Create a root commit directly in the bare repo, returning its oid.
669 fn commit(git_dir: &Path, message: &str) -> String {
670 let tree = git(git_dir, &["hash-object", "-t", "tree", "-w", "--stdin"]);
671 let out = Command::new("git")
672 .arg("--git-dir")
673 .arg(git_dir)
674 .args(["commit-tree", &tree, "-m", message])
675 .env("GIT_AUTHOR_NAME", "t")
676 .env("GIT_AUTHOR_EMAIL", "t@e")
677 .env("GIT_COMMITTER_NAME", "t")
678 .env("GIT_COMMITTER_EMAIL", "t@e")
679 .env("GIT_AUTHOR_DATE", format!("@{} +0000", message.len()))
680 .output()
681 .unwrap();
682 String::from_utf8_lossy(&out.stdout).trim().to_string()
683 }
684
685 fn commit_onto(git_dir: &Path, parent: &str, message: &str) -> String {
686 let tree = git(git_dir, &["hash-object", "-t", "tree", "-w", "--stdin"]);
687 let out = Command::new("git")
688 .arg("--git-dir")
689 .arg(git_dir)
690 .args(["commit-tree", &tree, "-p", parent, "-m", message])
691 .env("GIT_AUTHOR_NAME", "t")
692 .env("GIT_AUTHOR_EMAIL", "t@e")
693 .env("GIT_COMMITTER_NAME", "t")
694 .env("GIT_COMMITTER_EMAIL", "t@e")
695 .output()
696 .unwrap();
697 String::from_utf8_lossy(&out.stdout).trim().to_string()
698 }
699
700 fn git(git_dir: &Path, args: &[&str]) -> String {
701 use std::io::Write;
702 use std::process::Stdio;
703 let mut child = Command::new("git")
704 .arg("--git-dir")
705 .arg(git_dir)
706 .args(args)
707 .stdin(Stdio::piped())
708 .stdout(Stdio::piped())
709 .spawn()
710 .unwrap();
711 child.stdin.take().unwrap().write_all(b"").unwrap();
712 let out = child.wait_with_output().unwrap();
713 String::from_utf8_lossy(&out.stdout).trim().to_string()
714 }
715}