steid

@jamesgill /

Rust9.5 KBCode·Blame·History·Raw
1//! `git archive`, streamed, and the recording double the use-case tests drive.
2
3use std::{
4 path::PathBuf,
5 process::Stdio,
6 sync::{Arc, Mutex},
7};
8
9use crate::{
10 application::port::{ArchiveRequest, ByteStream, GitArchive, GitArchiveError},
11 domain::{ObjectId, OrgName, RepoName},
12};
13
14use tokio::io::AsyncReadExt;
15
16use super::{count_spawn, git_command};
17
18/// `git archive`, streamed.
19///
20/// Beside the protocol server rather than beside the read queries because it has the
21/// protocol's shape, not theirs: the output is as large as the repository and is handed
22/// on as it arrives. `DiskGitQuery`'s contract — bounded bytes, collected — is exactly
23/// what an archive must not do.
24#[derive(Debug, Clone)]
25pub struct DiskGitArchive {
26 data_dir: PathBuf,
27}
28
29impl DiskGitArchive {
30 pub fn new(data_dir: impl Into<PathBuf>) -> Self {
31 Self {
32 data_dir: data_dir.into(),
33 }
34 }
35
36 /// Where a repository lives, matching `DiskGitStorage`'s layout.
37 fn repo_path(&self, handle: &OrgName, name: &RepoName) -> PathBuf {
38 self.data_dir
39 .join(handle.as_str())
40 .join(format!("{name}.git"))
41 }
42}
43
44impl GitArchive for DiskGitArchive {
45 async fn archive(&self, request: ArchiveRequest) -> Result<ByteStream, GitArchiveError> {
46 let repo = self.repo_path(&request.handle, &request.name);
47
48 // Both values are the use case's own construction — a resolved object id and a
49 // prefix built from the repository's name — so nothing from a URL reaches git's
50 // revision parser here. They are still written `--flag=value` rather than as
51 // separate arguments, so neither can be read as a flag of its own.
52 let mut command = git_command();
53 command
54 .arg("-C")
55 .arg(&repo)
56 .arg("archive")
57 .arg(format!("--format={}", request.format.as_str()))
58 .arg(format!("--prefix={}", request.prefix))
59 .arg(request.commit.as_str())
60 .stdin(Stdio::null())
61 .stdout(Stdio::piped())
62 .stderr(Stdio::piped())
63 // A client that gives up mid-download must take the packing with it;
64 // without this, git finishes compressing a repository for nobody.
65 .kill_on_drop(true);
66 count_spawn(&command);
67
68 let mut child = command
69 .spawn()
70 .map_err(|error| GitArchiveError::new(format!("could not run git: {error}")))?;
71
72 let stdout = child.stdout.take().expect("stdout was piped");
73 let mut stderr = child.stderr.take().expect("stderr was piped");
74
75 // The child is reaped in its own task, exactly as the protocol server's is, and
76 // for the same two reasons: dropping a `Child` with `kill_on_drop` would kill
77 // the process the response body is still reading from, and an unread stderr pipe
78 // eventually fills and stalls the transfer. The exit status cannot gate the
79 // response — by the time it is known, bytes have been sent — so a late failure
80 // is a truncated download and a line in the log.
81 tokio::spawn(async move {
82 let mut complaint = String::new();
83 let _ = stderr.read_to_string(&mut complaint).await;
84
85 match child.wait().await {
86 Ok(status) if status.success() => {}
87 Ok(status) => {
88 eprintln!(
89 "steid: git archive exited with {status}: {}",
90 complaint.trim()
91 )
92 }
93 Err(error) => eprintln!("steid: could not wait for git archive: {error}"),
94 }
95 });
96
97 Ok(Box::pin(stdout))
98 }
99}
100
101/// What an [`InMemoryGitArchive`] was asked for.
102///
103/// The request minus nothing: unlike a protocol request there is no body, so every
104/// field is worth asserting on.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct RecordedArchive {
107 pub handle: OrgName,
108 pub name: RepoName,
109 pub format: crate::application::port::ArchiveFormat,
110 pub commit: ObjectId,
111 pub prefix: String,
112}
113
114/// An archive port that records what it was asked and never runs git.
115///
116/// Like [`InMemoryGitProtocol`], what it is really for is proving a negative: that a
117/// use case refused before a single byte was packed.
118#[derive(Debug, Default, Clone)]
119pub struct InMemoryGitArchive {
120 requests: Arc<Mutex<Vec<RecordedArchive>>>,
121}
122
123impl InMemoryGitArchive {
124 pub fn new() -> Self {
125 Self::default()
126 }
127
128 pub fn requests(&self) -> Vec<RecordedArchive> {
129 self.requests.lock().expect("lock poisoned").clone()
130 }
131
132 /// Whether anything was ever packed.
133 pub fn was_called(&self) -> bool {
134 !self.requests.lock().expect("lock poisoned").is_empty()
135 }
136}
137
138impl GitArchive for InMemoryGitArchive {
139 async fn archive(&self, request: ArchiveRequest) -> Result<ByteStream, GitArchiveError> {
140 self.requests
141 .lock()
142 .expect("lock poisoned")
143 .push(RecordedArchive {
144 handle: request.handle,
145 name: request.name,
146 format: request.format,
147 commit: request.commit,
148 prefix: request.prefix,
149 });
150
151 Ok(Box::pin(std::io::Cursor::new(b"archive".to_vec())))
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use std::path::Path;
158
159 use tempfile::TempDir;
160
161 use super::*;
162 use crate::application::port::{ArchiveFormat, GitStorage};
163 use crate::infrastructure::git::{
164 fixture::{handle, repo_name},
165 storage::DiskGitStorage,
166 };
167
168 // --- git archive ---------------------------------------------------------------
169
170 /// A bare repository with one commit in it, packed the way a real one is.
171 ///
172 /// Built by pushing from a working copy rather than by writing objects directly,
173 /// for the same reason `git_query`'s fixture is: it is what actually happens.
174 fn archivable() -> (TempDir, DiskGitArchive, ObjectId) {
175 let dir = TempDir::new().expect("temp dir");
176 let storage = DiskGitStorage::new(dir.path());
177 let repo = storage.repo_path(&handle(), &repo_name("steid"));
178 let work = dir.path().join("work");
179
180 let git = |at: &Path, args: &[&str]| {
181 let output = std::process::Command::new("git")
182 .arg("-C")
183 .arg(at)
184 .args(args)
185 .env("GIT_CONFIG_GLOBAL", "/dev/null")
186 .env("GIT_CONFIG_SYSTEM", "/dev/null")
187 .env("GIT_AUTHOR_NAME", "Ada Lovelace")
188 .env("GIT_AUTHOR_EMAIL", "ada@example.com")
189 .env("GIT_COMMITTER_NAME", "Ada Lovelace")
190 .env("GIT_COMMITTER_EMAIL", "ada@example.com")
191 .output()
192 .expect("git should be on PATH");
193
194 assert!(
195 output.status.success(),
196 "git {args:?} failed: {}",
197 String::from_utf8_lossy(&output.stderr)
198 );
199
200 String::from_utf8_lossy(&output.stdout).trim().to_owned()
201 };
202
203 std::fs::create_dir_all(&work).expect("create work tree");
204 git(
205 dir.path(),
206 &[
207 "init",
208 "--bare",
209 "--quiet",
210 "--template=",
211 "--initial-branch=main",
212 "--",
213 repo.to_str().expect("utf-8 fixture path"),
214 ],
215 );
216 git(&work, &["init", "--quiet", "-b", "main"]);
217 std::fs::write(work.join("README.md"), b"hello\n").expect("write");
218 git(&work, &["add", "-A"]);
219 git(&work, &["commit", "--quiet", "-m", "first"]);
220 git(
221 &work,
222 &["push", "--quiet", repo.to_str().expect("utf-8"), "main"],
223 );
224
225 let head = git(&repo, &["rev-parse", "main"]);
226 let archives = DiskGitArchive::new(dir.path());
227
228 (
229 dir,
230 archives,
231 ObjectId::new(head).expect("a real object id"),
232 )
233 }
234
235 async fn packed(format: ArchiveFormat) -> Vec<u8> {
236 let (dir, archives, commit) = archivable();
237
238 let mut stream = archives
239 .archive(ArchiveRequest {
240 handle: handle(),
241 name: repo_name("steid"),
242 format,
243 commit,
244 prefix: "steid-main/".to_owned(),
245 })
246 .await
247 .expect("should pack");
248
249 let mut bytes = Vec::new();
250 stream.read_to_end(&mut bytes).await.expect("should stream");
251
252 // Held until the bytes are read: dropping the fixture deletes the repository
253 // git is still reading from.
254 drop(dir);
255
256 bytes
257 }
258
259 #[tokio::test]
260 async fn a_tarball_carries_the_prefix_directory() {
261 let bytes = packed(ArchiveFormat::TarGz).await;
262
263 // The gzip magic number, so the format flag is doing something rather than
264 // silently producing an uncompressed tar.
265 assert_eq!(&bytes[..2], &[0x1f, 0x8b], "expected gzip");
266 assert!(bytes.len() > 100, "expected a real archive");
267 }
268
269 #[tokio::test]
270 async fn a_zip_is_a_zip() {
271 let bytes = packed(ArchiveFormat::Zip).await;
272
273 assert_eq!(&bytes[..2], b"PK", "expected a zip");
274 // The prefix is stored as part of every entry name, uncompressed in the
275 // central directory, so it is readable in the raw bytes.
276 assert!(
277 bytes.windows(11).any(|window| window == b"steid-main/"),
278 "expected the prefix directory in the archive"
279 );
280 }
281}