steid

@jamesgill /

1//! What the merge button does, and what this machine's git can do.
2
3use std::{
4 path::PathBuf,
5 process::Stdio,
6 sync::{Arc, Mutex},
7 time::SystemTime,
8};
9
10use crate::{
11 application::port::{CommitTreeRequest, GitMerge, GitMergeError},
12 domain::{ObjectId, OrgName, RefName, RepoName},
13};
14
15use super::{count_spawn, git_command};
16
17// --- Merging ------------------------------------------------------------------------
18
19/// The oldest git that can merge inside a bare repository.
20///
21/// `git merge-tree --write-tree` arrived in 2.38. Before that, `merge-tree` meant
22/// something else entirely — a three-way *diff* that wrote nothing — so an older git
23/// does not fail with a clear message, it succeeds at the wrong thing. That is why the
24/// version is read at boot and the merge box is absent rather than broken where it is
25/// too old: a check that only fired at merge time would be a check nobody sees until
26/// they have already written the pull request.
27const MERGE_TREE_SINCE: (u32, u32) = (2, 38);
28
29/// What this machine's `git` can do, read once at boot.
30///
31/// One struct rather than a bare version number, because a version is a fact about git
32/// and this is a fact about what Steid may offer — and the pages only ever ask the
33/// second question. Adding a capability later is a field here, not a comparison
34/// scattered across use cases.
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
36pub struct GitFeatures {
37 /// Whether `git merge-tree --write-tree` is available, and therefore whether a pull
38 /// request can be merged from the web at all.
39 pub merge_tree: bool,
40
41 /// The major and minor version git reported, when it could be read at all.
42 ///
43 /// Kept beside the capability rather than replacing it, because the two answer
44 /// different questions: a page asks what Steid may offer, and an operator asks what
45 /// is installed on the machine — a number a control plane compares across a fleet
46 /// and puts in a "your git is too old" alert. Two components only, because
47 /// [`parse_git_version`] reads two and a feature floor has never needed a third.
48 ///
49 /// `None` for a git that could not be run or whose output could not be parsed, which
50 /// is the same case that leaves every capability off.
51 pub version: Option<(u32, u32)>,
52}
53
54impl GitFeatures {
55 /// The version as `2.47`, for a response body. `None` where the version is.
56 #[must_use]
57 pub fn version_string(&self) -> Option<String> {
58 self.version
59 .map(|(major, minor)| format!("{major}.{minor}"))
60 }
61}
62
63impl GitFeatures {
64 /// Asks git what version it is and decides what that allows.
65 ///
66 /// **Never fails.** A git that cannot be run or whose output cannot be parsed
67 /// reports no capabilities, which degrades the merge box into its "this server
68 /// cannot merge" state rather than refusing to boot. Steid needs git for everything
69 /// else too, so a truly missing binary announces itself within one request anyway.
70 pub async fn detect() -> Self {
71 let mut command = git_command();
72 command.arg("--version").stdin(Stdio::null());
73 count_spawn(&command);
74
75 let Ok(output) = command.output().await else {
76 return Self::default();
77 };
78
79 let version = parse_git_version(&String::from_utf8_lossy(&output.stdout));
80
81 Self {
82 merge_tree: version.is_some_and(|version| version >= MERGE_TREE_SINCE),
83 version,
84 }
85 }
86}
87
88/// Reads `git version 2.47.1` — and `git version 2.39.5 (Apple Git-154)` — as `(2, 47)`.
89///
90/// Only the first two components, because that is the whole of what a feature floor is
91/// ever expressed in. A patch release has never added a subcommand.
92fn parse_git_version(output: &str) -> Option<(u32, u32)> {
93 let version = output.split_whitespace().nth(2)?;
94 let mut parts = version.split('.');
95
96 Some((parts.next()?.parse().ok()?, parts.next()?.parse().ok()?))
97}
98
99/// Merges written into bare repositories under a data directory.
100///
101/// The write half of a merge, whose read half — "would this even merge?" — is
102/// [`GitQuery::merge_preview`]. They are separate ports for the reason
103/// [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md) separates
104/// everything else: asking is safe and idempotent, and moving a branch is neither.
105#[derive(Debug, Clone)]
106pub struct DiskGitMerge {
107 data_dir: PathBuf,
108}
109
110impl DiskGitMerge {
111 pub fn new(data_dir: impl Into<PathBuf>) -> Self {
112 Self {
113 data_dir: data_dir.into(),
114 }
115 }
116
117 /// Where a repository lives, matching `DiskGitStorage`'s layout.
118 fn repo_path(&self, handle: &OrgName, name: &RepoName) -> PathBuf {
119 self.data_dir
120 .join(handle.as_str())
121 .join(format!("{name}.git"))
122 }
123
124 /// Moves or deletes a ref, guarded on where it is now.
125 ///
126 /// One function for both because they differ only by a `-d` and by whether a new
127 /// value is passed, and the interesting half — telling "somebody pushed" apart from
128 /// "git broke" — is identical and worth having in one place.
129 async fn guarded_update(
130 &self,
131 handle: &OrgName,
132 name: &RepoName,
133 branch: &RefName,
134 new: Option<&ObjectId>,
135 old: &ObjectId,
136 ) -> Result<(), GitMergeError> {
137 let path = self.repo_path(handle, name);
138 let full = format!("refs/heads/{branch}");
139
140 let mut command = git_command();
141 command
142 .arg("--git-dir")
143 .arg(&path)
144 .arg("update-ref")
145 .stdin(Stdio::null());
146
147 count_spawn(&command);
148
149 match new {
150 Some(new) => {
151 command.arg(&full).arg(new.as_str()).arg(old.as_str());
152 }
153 None => {
154 command.arg("-d").arg(&full).arg(old.as_str());
155 }
156 }
157
158 let output = command
159 .output()
160 .await
161 .map_err(|error| GitMergeError::backend(format!("could not run git: {error}")))?;
162
163 if output.status.success() {
164 return Ok(());
165 }
166
167 let stderr = String::from_utf8_lossy(&output.stderr);
168
169 // git has no distinct exit status for "the old value did not match" — it is 1,
170 // and so is everything else — so the sentence is what tells them apart. It is
171 // matched loosely on purpose: the two spellings git has used are "is at … but
172 // expected …" for a move and "is at … but expected …" for a delete, and a
173 // future third would still contain "expected".
174 if stderr.contains("but expected") {
175 return Err(GitMergeError::Stale);
176 }
177
178 Err(GitMergeError::backend(format!(
179 "git update-ref exited with {}: {}",
180 output.status,
181 stderr.trim()
182 )))
183 }
184}
185
186impl GitMerge for DiskGitMerge {
187 async fn commit_tree(
188 &self,
189 handle: &OrgName,
190 name: &RepoName,
191 request: &CommitTreeRequest,
192 ) -> Result<ObjectId, GitMergeError> {
193 let path = self.repo_path(handle, name);
194
195 let mut command = git_command();
196 command
197 .arg("--git-dir")
198 .arg(&path)
199 .arg("commit-tree")
200 .arg(request.tree.as_str());
201
202 for parent in &request.parents {
203 command.arg("-p").arg(parent.as_str());
204 }
205
206 // `-m` rather than the message on stdin: a commit message is bounded by the
207 // pull request's own body cap, and passing it as an argument keeps this one
208 // process with no plumbing to get wrong.
209 command.arg("-m").arg(&request.message);
210
211 let stamp = git_timestamp(request.now);
212
213 // Author *and* committer, both the person who pressed Merge. Nobody else is
214 // more the author of a merge commit than whoever decided to make it, and a
215 // commit whose author git had to invent from the host's hostname is a commit
216 // nobody can attribute later.
217 //
218 // The date is passed rather than left to git's own clock so that a use case
219 // taking a `now` actually controls the commit it makes — the same rule every
220 // other timestamp in Steid follows.
221 command
222 .env("GIT_AUTHOR_NAME", &request.author_name)
223 .env("GIT_AUTHOR_EMAIL", &request.author_email)
224 .env("GIT_AUTHOR_DATE", &stamp)
225 .env("GIT_COMMITTER_NAME", &request.author_name)
226 .env("GIT_COMMITTER_EMAIL", &request.author_email)
227 .env("GIT_COMMITTER_DATE", &stamp)
228 .stdin(Stdio::null());
229 count_spawn(&command);
230
231 let output = command
232 .output()
233 .await
234 .map_err(|error| GitMergeError::backend(format!("could not run git: {error}")))?;
235
236 if !output.status.success() {
237 return Err(GitMergeError::backend(format!(
238 "git commit-tree exited with {}: {}",
239 output.status,
240 String::from_utf8_lossy(&output.stderr).trim()
241 )));
242 }
243
244 let id = String::from_utf8_lossy(&output.stdout);
245
246 ObjectId::new(id.trim())
247 .map_err(|error| GitMergeError::backend(format!("git named a bad commit: {error}")))
248 }
249
250 async fn update_ref(
251 &self,
252 handle: &OrgName,
253 name: &RepoName,
254 branch: &RefName,
255 new: &ObjectId,
256 old: &ObjectId,
257 ) -> Result<(), GitMergeError> {
258 self.guarded_update(handle, name, branch, Some(new), old)
259 .await
260 }
261
262 async fn delete_ref(
263 &self,
264 handle: &OrgName,
265 name: &RepoName,
266 branch: &RefName,
267 old: &ObjectId,
268 ) -> Result<(), GitMergeError> {
269 self.guarded_update(handle, name, branch, None, old).await
270 }
271}
272
273/// A time in the one date format git will not try to be clever about.
274///
275/// `@<seconds> <zone>` is git's raw form, and the `@` is load-bearing: without it git
276/// runs the value through its human-date parser, which reads a bare `1700000000` as an
277/// epoch by luck and refuses `0` outright — so a commit made at the epoch failed while
278/// every other one worked.
279///
280/// `+0000` always. Steid stores every time as an instant and has nowhere to get a
281/// person's zone from, and a made-up offset would be a claim about where somebody was.
282fn git_timestamp(now: SystemTime) -> String {
283 let seconds = now
284 .duration_since(SystemTime::UNIX_EPOCH)
285 .map(|elapsed| elapsed.as_secs())
286 .unwrap_or(0);
287
288 format!("@{seconds} +0000")
289}
290
291/// Merges recorded in memory, for testing use cases without a repository.
292///
293/// Records what it was asked to do and answers with ids a test seeded, so a use case
294/// test can assert the *sequence* — commit, then move the target, then delete the
295/// source — which is the part that has to be right and the part a disk fixture makes
296/// slow to check.
297#[derive(Debug, Default, Clone)]
298pub struct InMemoryGitMerge {
299 commits: Arc<Mutex<Vec<CommitTreeRequest>>>,
300 updates: Arc<Mutex<Vec<RecordedRefUpdate>>>,
301 /// The id every `commit_tree` answers with.
302 written: Option<ObjectId>,
303 /// Makes every ref update report that somebody else pushed first.
304 stale: bool,
305}
306
307/// One ref update a fake merge was asked to make. `new` is `None` for a delete.
308#[derive(Debug, Clone, PartialEq, Eq)]
309pub struct RecordedRefUpdate {
310 pub branch: RefName,
311 pub new: Option<ObjectId>,
312 pub old: ObjectId,
313}
314
315impl InMemoryGitMerge {
316 pub fn new() -> Self {
317 Self::default()
318 }
319
320 /// What `commit_tree` will answer with.
321 pub fn writing(id: &str) -> Self {
322 Self {
323 written: Some(ObjectId::from_trusted(format!("{id:0>40}"))),
324 ..Self::default()
325 }
326 }
327
328 /// A repository where every ref moved under the merge's feet.
329 pub fn stale() -> Self {
330 Self {
331 stale: true,
332 ..Self::default()
333 }
334 }
335
336 /// The commits it was asked to write, in order.
337 pub fn commits(&self) -> Vec<CommitTreeRequest> {
338 self.commits.lock().expect("lock poisoned").clone()
339 }
340
341 /// The ref updates it was asked to make, in order.
342 pub fn updates(&self) -> Vec<RecordedRefUpdate> {
343 self.updates.lock().expect("lock poisoned").clone()
344 }
345}
346
347impl GitMerge for InMemoryGitMerge {
348 async fn commit_tree(
349 &self,
350 _handle: &OrgName,
351 _name: &RepoName,
352 request: &CommitTreeRequest,
353 ) -> Result<ObjectId, GitMergeError> {
354 self.commits
355 .lock()
356 .expect("lock poisoned")
357 .push(request.clone());
358
359 Ok(self
360 .written
361 .clone()
362 .unwrap_or_else(|| ObjectId::from_trusted("c".repeat(40))))
363 }
364
365 async fn update_ref(
366 &self,
367 _handle: &OrgName,
368 _name: &RepoName,
369 branch: &RefName,
370 new: &ObjectId,
371 old: &ObjectId,
372 ) -> Result<(), GitMergeError> {
373 if self.stale {
374 return Err(GitMergeError::Stale);
375 }
376
377 self.updates
378 .lock()
379 .expect("lock poisoned")
380 .push(RecordedRefUpdate {
381 branch: branch.clone(),
382 new: Some(new.clone()),
383 old: old.clone(),
384 });
385
386 Ok(())
387 }
388
389 async fn delete_ref(
390 &self,
391 _handle: &OrgName,
392 _name: &RepoName,
393 branch: &RefName,
394 old: &ObjectId,
395 ) -> Result<(), GitMergeError> {
396 if self.stale {
397 return Err(GitMergeError::Stale);
398 }
399
400 self.updates
401 .lock()
402 .expect("lock poisoned")
403 .push(RecordedRefUpdate {
404 branch: branch.clone(),
405 new: None,
406 old: old.clone(),
407 });
408
409 Ok(())
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use std::path::Path;
416
417 use tempfile::TempDir;
418
419 use super::*;
420 use crate::infrastructure::git::fixture::{git_says, handle, repo_name};
421
422 // --- Merging ----------------------------------------------------------------------
423
424 /// A bare repository with `main` and a `tidy` branch off it, and the merge adapter
425 /// pointed at the same data directory.
426 ///
427 /// Returns the repository path too, because every assertion here is "what does git
428 /// believe now" rather than "what did the adapter say".
429 fn mergeable() -> (TempDir, DiskGitMerge, PathBuf) {
430 let dir = TempDir::new().expect("temp dir");
431 let repo = dir.path().join("jamesgill").join("steid.git");
432 let work = dir.path().join("work");
433
434 let git = |path: &Path, args: &[&str]| -> String {
435 let output = std::process::Command::new("git")
436 .arg("-C")
437 .arg(path)
438 .args(args)
439 .env("GIT_CONFIG_GLOBAL", "/dev/null")
440 .env("GIT_CONFIG_SYSTEM", "/dev/null")
441 .env("GIT_AUTHOR_NAME", "Ada Lovelace")
442 .env("GIT_AUTHOR_EMAIL", "ada@example.com")
443 .env("GIT_COMMITTER_NAME", "Ada Lovelace")
444 .env("GIT_COMMITTER_EMAIL", "ada@example.com")
445 .output()
446 .expect("git should be on PATH");
447
448 assert!(
449 output.status.success(),
450 "git {args:?} failed: {}",
451 String::from_utf8_lossy(&output.stderr)
452 );
453
454 String::from_utf8_lossy(&output.stdout).trim().to_owned()
455 };
456
457 std::fs::create_dir_all(&work).expect("create work tree");
458 git(
459 dir.path(),
460 &[
461 "init",
462 "--bare",
463 "--quiet",
464 "--template=",
465 "--initial-branch=main",
466 "--",
467 repo.to_str().expect("utf-8 fixture path"),
468 ],
469 );
470
471 git(&work, &["init", "--quiet", "-b", "main"]);
472 std::fs::write(work.join("README.md"), b"hello\n").expect("write");
473 git(&work, &["add", "-A"]);
474 git(&work, &["commit", "--quiet", "-m", "first"]);
475
476 git(&work, &["checkout", "--quiet", "-b", "tidy"]);
477 std::fs::write(work.join("other.txt"), b"tidy\n").expect("write");
478 git(&work, &["add", "-A"]);
479 git(&work, &["commit", "--quiet", "-m", "tidy"]);
480
481 git(
482 &work,
483 &[
484 "push",
485 "--quiet",
486 repo.to_str().expect("utf-8"),
487 "main",
488 "tidy",
489 ],
490 );
491
492 let merges = DiskGitMerge::new(dir.path());
493
494 (dir, merges, repo)
495 }
496
497 /// What a revision resolves to in the fixture.
498 fn head(repo: &Path, revision: &str) -> ObjectId {
499 ObjectId::new(git_says(repo, &["rev-parse", revision])).expect("a real object id")
500 }
501
502 #[tokio::test]
503 async fn a_merge_commit_carries_both_parents_and_the_person_who_pressed_merge() {
504 let (_dir, merges, repo) = mergeable();
505 let main = head(&repo, "main");
506 let tidy = head(&repo, "tidy");
507 let tree = ObjectId::new(git_says(&repo, &["rev-parse", "tidy^{tree}"])).expect("a tree");
508
509 let commit = merges
510 .commit_tree(
511 &handle(),
512 &repo_name("steid"),
513 &CommitTreeRequest {
514 tree,
515 parents: vec![main.clone(), tidy.clone()],
516 message: "Merge pull request #7 from tidy\n\nTidy things up.".to_owned(),
517 author_name: "Ada Lovelace".to_owned(),
518 author_email: "ada@example.com".to_owned(),
519 now: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000),
520 },
521 )
522 .await
523 .expect("should write a commit");
524
525 // The target first, so the first-parent line stays the target's history.
526 assert_eq!(
527 git_says(
528 &repo,
529 &["rev-list", "--parents", "-n", "1", commit.as_str()]
530 ),
531 format!("{commit} {main} {tidy}")
532 );
533 assert_eq!(
534 git_says(
535 &repo,
536 &["log", "-1", "--format=%an <%ae> %at", commit.as_str()]
537 ),
538 "Ada Lovelace <ada@example.com> 1700000000",
539 "the caller's `now` decides the commit's date, not git's clock"
540 );
541 // Written, and *not* referenced: the ref update is a separate, later step.
542 assert_eq!(git_says(&repo, &["rev-parse", "main"]), main.as_str());
543 }
544
545 #[tokio::test]
546 async fn a_squash_commit_has_one_parent() {
547 let (_dir, merges, repo) = mergeable();
548 let main = head(&repo, "main");
549 let tree = ObjectId::new(git_says(&repo, &["rev-parse", "tidy^{tree}"])).expect("a tree");
550
551 let commit = merges
552 .commit_tree(
553 &handle(),
554 &repo_name("steid"),
555 &CommitTreeRequest {
556 tree,
557 parents: vec![main.clone()],
558 message: "Tidy things up (#7)".to_owned(),
559 author_name: "Ada Lovelace".to_owned(),
560 author_email: "ada@example.com".to_owned(),
561 now: SystemTime::UNIX_EPOCH,
562 },
563 )
564 .await
565 .expect("should write a commit");
566
567 assert_eq!(
568 git_says(
569 &repo,
570 &["rev-list", "--parents", "-n", "1", commit.as_str()]
571 ),
572 format!("{commit} {main}")
573 );
574 }
575
576 #[tokio::test]
577 async fn moving_a_branch_from_where_it_is_moves_it() {
578 let (_dir, merges, repo) = mergeable();
579 let main = head(&repo, "main");
580 let tidy = head(&repo, "tidy");
581
582 merges
583 .update_ref(
584 &handle(),
585 &repo_name("steid"),
586 &RefName::from_trusted("main"),
587 &tidy,
588 &main,
589 )
590 .await
591 .expect("should move the branch");
592
593 assert_eq!(git_says(&repo, &["rev-parse", "main"]), tidy.as_str());
594 }
595
596 #[tokio::test]
597 async fn moving_a_branch_from_where_it_is_not_changes_nothing() {
598 // The compare-and-swap that makes a concurrent push lose cleanly rather than
599 // being silently discarded.
600 let (_dir, merges, repo) = mergeable();
601 let main = head(&repo, "main");
602 let tidy = head(&repo, "tidy");
603
604 let error = merges
605 .update_ref(
606 &handle(),
607 &repo_name("steid"),
608 &RefName::from_trusted("main"),
609 &main,
610 &tidy,
611 )
612 .await
613 .expect_err("expected the guard to refuse");
614
615 assert!(error.is_stale(), "expected staleness, got {error:?}");
616 assert_eq!(git_says(&repo, &["rev-parse", "main"]), main.as_str());
617 }
618
619 #[tokio::test]
620 async fn deleting_a_branch_is_guarded_the_same_way() {
621 let (_dir, merges, repo) = mergeable();
622 let main = head(&repo, "main");
623 let tidy = head(&repo, "tidy");
624
625 let refused = merges
626 .delete_ref(
627 &handle(),
628 &repo_name("steid"),
629 &RefName::from_trusted("tidy"),
630 &main,
631 )
632 .await
633 .expect_err("expected the guard to refuse");
634 assert!(refused.is_stale(), "expected staleness, got {refused:?}");
635
636 merges
637 .delete_ref(
638 &handle(),
639 &repo_name("steid"),
640 &RefName::from_trusted("tidy"),
641 &tidy,
642 )
643 .await
644 .expect("should delete the branch");
645
646 assert_eq!(
647 git_says(
648 &repo,
649 &["for-each-ref", "--format=%(refname)", "refs/heads/"]
650 ),
651 "refs/heads/main"
652 );
653 }
654
655 // --- What this git can do ----------------------------------------------------------
656
657 #[test]
658 fn a_version_reads_as_major_and_minor() {
659 assert_eq!(parse_git_version("git version 2.47.1\n"), Some((2, 47)));
660 // Apple's git appends a build, which must not stop the parse.
661 assert_eq!(
662 parse_git_version("git version 2.39.5 (Apple Git-154)\n"),
663 Some((2, 39))
664 );
665 assert_eq!(parse_git_version(""), None);
666 assert_eq!(parse_git_version("git version banana"), None);
667 }
668
669 #[tokio::test]
670 async fn this_machine_reports_what_its_git_can_do() {
671 // Not an assertion about the answer — a developer may be on an old git — but
672 // about detection producing one rather than panicking or hanging.
673 let features = GitFeatures::detect().await;
674
675 assert_eq!(
676 features.merge_tree,
677 parse_git_version(&git_says(Path::new("."), &["--version"]))
678 .is_some_and(|version| version >= MERGE_TREE_SINCE)
679 );
680 }
681
682 #[tokio::test]
683 async fn detection_reports_the_version_it_decided_on() {
684 let features = GitFeatures::detect().await;
685 let parsed = parse_git_version(&git_says(Path::new("."), &["--version"]));
686
687 assert_eq!(features.version, parsed);
688 assert_eq!(
689 features.version_string(),
690 parsed.map(|(major, minor)| format!("{major}.{minor}")),
691 );
692 }
693}