steid

@jamesgill /

1//! The `git` binary, behind the ports the application declares.
2//!
3//! One module owns *how* Steid invokes git — see [`git_command`] and [`run_git`] — so
4//! that isolation and error handling cannot drift between call sites. Everything below
5//! it is one port's adapter pair per file: [`storage`] for the repository lifecycle,
6//! [`protocol`] for smart-HTTP, [`archive`] for downloads, [`import`] for mirroring
7//! from elsewhere, [`merge`] for what a merge button does.
8//!
9//! The read port's two implementations live together in
10//! [`git_query`](super::git_query) rather than being split across this module and that
11//! one; `InMemoryGitQuery` is re-exported here so call sites did not have to move with
12//! it.
13
14pub mod archive;
15pub mod import;
16pub mod merge;
17pub mod protocol;
18pub mod storage;
19
20#[cfg(test)]
21pub(crate) mod fixture;
22
23pub use archive::{DiskGitArchive, InMemoryGitArchive, RecordedArchive};
24pub use import::{DiskGitImport, InMemoryGitImport};
25pub use merge::{DiskGitMerge, GitFeatures, InMemoryGitMerge, RecordedRefUpdate};
26pub use protocol::{GitHttpBackend, InMemoryGitProtocol, RecordedGitRequest};
27pub use storage::{DiskGitStorage, InMemoryGitStorage};
28
29/// The in-memory read adapter, which lives beside `DiskGitQuery`.
30///
31/// Re-exported so that `use crate::infrastructure::git::InMemoryGitQuery` — which
32/// roughly a dozen use-case test modules write — keeps working after the move.
33pub use super::git_query::InMemoryGitQuery;
34
35use std::{
36 ffi::OsStr,
37 process::{Output, Stdio},
38};
39
40use tokio::process::Command;
41
42use crate::{application::port::GitStorageError, infrastructure::metrics::metrics};
43
44/// Environment variables that redirect where git reads and writes data.
45///
46/// Steid's own environment must not reach into a repository's layout. These are set
47/// whenever a process is spawned from inside a git hook, which is exactly the shape
48/// Milestone 5 will have, and the failure is silent — objects land somewhere else and
49/// the repository looks empty.
50const REDIRECTING_VARS: &[&str] = &[
51 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
52 "GIT_DIR",
53 "GIT_INDEX_FILE",
54 "GIT_OBJECT_DIRECTORY",
55 "GIT_WORK_TREE",
56];
57
58/// A `git` command isolated from the host.
59///
60/// The one place that decides what git inherits: no ambient configuration, no
61/// redirected object storage. Both the lifecycle commands and the protocol backend
62/// build on this, which is the point — 0006 exists because these flags are exactly what
63/// drifts silently between call sites.
64pub(crate) fn git_command() -> Command {
65 let mut command = Command::new("git");
66
67 // Host configuration must not leak into repositories Steid creates, for the same
68 // reason `--initial-branch` is passed explicitly.
69 command
70 .env("GIT_CONFIG_GLOBAL", "/dev/null")
71 .env("GIT_CONFIG_SYSTEM", "/dev/null");
72
73 for variable in REDIRECTING_VARS {
74 command.env_remove(variable);
75 }
76
77 command
78}
79
80/// Counts one `git` process against `steid_git_processes_total`.
81///
82/// Takes the built [`Command`] rather than a label, so a call site adds one line and has
83/// no name to get wrong or to keep in step when an argument list is rearranged. The
84/// subcommand is read back off the arguments git itself will read.
85///
86/// One relaxed atomic add, after the arguments are set and before the process starts. It
87/// counts an attempt rather than a success — a `git` that could not be executed at all is
88/// still a fork this process asked for, and a spike of failures is exactly what somebody
89/// scraping this wants to see.
90pub(crate) fn count_spawn(command: &Command) {
91 metrics().record_git(subcommand_of(command.as_std().get_args()));
92}
93
94/// The subcommand out of a built argument list.
95///
96/// Skips the three global flags Steid passes before a subcommand — `-C`, `--git-dir` and
97/// `-c`, each of which takes its value as a separate argument — and reads the first
98/// argument after them. `git --version` has no subcommand at all, so a leading `--` is
99/// stripped and it counts as `version`.
100fn subcommand_of<'a>(args: impl Iterator<Item = &'a OsStr>) -> &'a str {
101 let mut args = args.peekable();
102
103 while let Some(arg) = args.peek().and_then(|arg| arg.to_str()) {
104 if !matches!(arg, "-C" | "--git-dir" | "-c") {
105 break;
106 }
107
108 // The flag and the value it takes.
109 args.next();
110 args.next();
111 }
112
113 args.next()
114 .and_then(OsStr::to_str)
115 .map(|arg| arg.trim_start_matches('-'))
116 .unwrap_or("other")
117}
118
119/// Runs `git` and fails on a non-zero exit.
120///
121/// The single place that decides how Steid invokes git, so every call site gets the
122/// same isolation from the host: no ambient configuration, no redirected object
123/// storage, no inherited stdin. Never depends on the working directory — callers pass
124/// absolute paths.
125async fn run_git<I, S>(args: I) -> Result<Output, GitStorageError>
126where
127 I: IntoIterator<Item = S>,
128 S: AsRef<OsStr>,
129{
130 let mut command = git_command();
131 command.args(args).stdin(Stdio::null());
132 count_spawn(&command);
133
134 // `output()` pipes stdout and stderr and waits without blocking the runtime.
135 let output = command
136 .output()
137 .await
138 .map_err(|error| GitStorageError::backend(format!("could not run git: {error}")))?;
139
140 if !output.status.success() {
141 // Carry git's own words. "command failed" sends the next person to read this
142 // code instead of reading the error.
143 return Err(GitStorageError::backend(format!(
144 "git exited with {}: {}",
145 output.status,
146 String::from_utf8_lossy(&output.stderr).trim()
147 )));
148 }
149
150 Ok(output)
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use crate::application::port::GitStorage;
157 use crate::infrastructure::git::fixture::{handle, repo_name, storage};
158
159 // --- Counting the processes -------------------------------------------------------
160
161 #[test]
162 fn the_subcommand_is_read_past_the_global_flags_steid_passes() {
163 let of = |args: &[&str]| {
164 let mut command = git_command();
165 command.args(args);
166 subcommand_of(command.as_std().get_args()).to_owned()
167 };
168
169 assert_eq!(of(&["-C", "/srv/x.git", "log", "-n", "1"]), "log");
170 assert_eq!(
171 of(&["--git-dir", "/srv/x.git", "config", "--get", "k"]),
172 "config"
173 );
174 assert_eq!(
175 of(&["-c", "http.receivepack=true", "http-backend"]),
176 "http-backend"
177 );
178 assert_eq!(of(&["-C", "/srv/x.git", "cat-file", "--batch"]), "cat-file");
179 // No subcommand at all: `git --version` is counted under `version`.
180 assert_eq!(of(&["--version"]), "version");
181 // Nothing to read is `other` rather than a panic — a metric must never fail a
182 // request it is only watching.
183 assert_eq!(of(&[]), "other");
184 }
185
186 #[tokio::test]
187 async fn running_git_counts_one_process_under_its_own_subcommand() {
188 // The counters are process-wide and tests run concurrently, so this asserts the
189 // series rose rather than what it reads.
190 let metrics = crate::infrastructure::metrics::metrics();
191 let series = "steid_git_processes_total{command=\"init\"}";
192 let before = metrics.value_of(series);
193
194 let (_dir, storage) = storage();
195 storage
196 .init_bare(&handle(), &repo_name("counted"))
197 .await
198 .expect("the repository should be created");
199
200 assert!(
201 metrics.value_of(series) > before,
202 "creating a repository runs git init, which must be counted"
203 );
204 }
205}