| | @@ -21,8 +21,9 @@ use tokio::{ |
| 21 | 21 | |
| 22 | 22 | use crate::{ |
| 23 | 23 | application::port::{ |
| 24 | | − Blob, GitMethod, GitProtocolError, GitProtocolServer, GitQuery, GitQueryError, GitRequest, |
| 25 | | − GitResponse, GitStorage, GitStorageError, |
| 24 | + ArchiveRequest, Blob, ByteStream, GitArchive, GitArchiveError, GitMethod, GitProtocolError, |
| 25 | + GitProtocolServer, GitQuery, GitQueryError, GitRequest, GitResponse, GitStorage, |
| 26 | + GitStorageError, |
| 26 | 27 | }, |
| 27 | 28 | domain::{ |
| 28 | 29 | BranchRow, CommitSummary, GitRef, ObjectId, OrgName, RefKind, RefName, RepoName, RepoPath, |
| | @@ -472,6 +473,142 @@ impl GitProtocolServer for InMemoryGitProtocol { |
| 472 | 473 | } |
| 473 | 474 | } |
| 474 | 475 | |
| 476 | +/// `git archive`, streamed. |
| 477 | +/// |
| 478 | +/// Beside the protocol server rather than beside the read queries because it has the |
| 479 | +/// protocol's shape, not theirs: the output is as large as the repository and is handed |
| 480 | +/// on as it arrives. `DiskGitQuery`'s contract — bounded bytes, collected — is exactly |
| 481 | +/// what an archive must not do. |
| 482 | +#[derive(Debug, Clone)] |
| 483 | +pub struct DiskGitArchive { |
| 484 | + data_dir: PathBuf, |
| 485 | +} |
| 486 | + |
| 487 | +impl DiskGitArchive { |
| 488 | + pub fn new(data_dir: impl Into<PathBuf>) -> Self { |
| 489 | + Self { |
| 490 | + data_dir: data_dir.into(), |
| 491 | + } |
| 492 | + } |
| 493 | + |
| 494 | + /// Where a repository lives, matching `DiskGitStorage`'s layout. |
| 495 | + fn repo_path(&self, handle: &OrgName, name: &RepoName) -> PathBuf { |
| 496 | + self.data_dir |
| 497 | + .join(handle.as_str()) |
| 498 | + .join(format!("{name}.git")) |
| 499 | + } |
| 500 | +} |
| 501 | + |
| 502 | +impl GitArchive for DiskGitArchive { |
| 503 | + async fn archive(&self, request: ArchiveRequest) -> Result<ByteStream, GitArchiveError> { |
| 504 | + let repo = self.repo_path(&request.handle, &request.name); |
| 505 | + |
| 506 | + // Both values are the use case's own construction — a resolved object id and a |
| 507 | + // prefix built from the repository's name — so nothing from a URL reaches git's |
| 508 | + // revision parser here. They are still written `--flag=value` rather than as |
| 509 | + // separate arguments, so neither can be read as a flag of its own. |
| 510 | + let mut command = git_command(); |
| 511 | + command |
| 512 | + .arg("-C") |
| 513 | + .arg(&repo) |
| 514 | + .arg("archive") |
| 515 | + .arg(format!("--format={}", request.format.as_str())) |
| 516 | + .arg(format!("--prefix={}", request.prefix)) |
| 517 | + .arg(request.commit.as_str()) |
| 518 | + .stdin(Stdio::null()) |
| 519 | + .stdout(Stdio::piped()) |
| 520 | + .stderr(Stdio::piped()) |
| 521 | + // A client that gives up mid-download must take the packing with it; |
| 522 | + // without this, git finishes compressing a repository for nobody. |
| 523 | + .kill_on_drop(true); |
| 524 | + |
| 525 | + let mut child = command |
| 526 | + .spawn() |
| 527 | + .map_err(|error| GitArchiveError::new(format!("could not run git: {error}")))?; |
| 528 | + |
| 529 | + let stdout = child.stdout.take().expect("stdout was piped"); |
| 530 | + let mut stderr = child.stderr.take().expect("stderr was piped"); |
| 531 | + |
| 532 | + // The child is reaped in its own task, exactly as the protocol server's is, and |
| 533 | + // for the same two reasons: dropping a `Child` with `kill_on_drop` would kill |
| 534 | + // the process the response body is still reading from, and an unread stderr pipe |
| 535 | + // eventually fills and stalls the transfer. The exit status cannot gate the |
| 536 | + // response — by the time it is known, bytes have been sent — so a late failure |
| 537 | + // is a truncated download and a line in the log. |
| 538 | + tokio::spawn(async move { |
| 539 | + let mut complaint = String::new(); |
| 540 | + let _ = stderr.read_to_string(&mut complaint).await; |
| 541 | + |
| 542 | + match child.wait().await { |
| 543 | + Ok(status) if status.success() => {} |
| 544 | + Ok(status) => { |
| 545 | + eprintln!( |
| 546 | + "steid: git archive exited with {status}: {}", |
| 547 | + complaint.trim() |
| 548 | + ) |
| 549 | + } |
| 550 | + Err(error) => eprintln!("steid: could not wait for git archive: {error}"), |
| 551 | + } |
| 552 | + }); |
| 553 | + |
| 554 | + Ok(Box::pin(stdout)) |
| 555 | + } |
| 556 | +} |
| 557 | + |
| 558 | +/// What an [`InMemoryGitArchive`] was asked for. |
| 559 | +/// |
| 560 | +/// The request minus nothing: unlike a protocol request there is no body, so every |
| 561 | +/// field is worth asserting on. |
| 562 | +#[derive(Debug, Clone, PartialEq, Eq)] |
| 563 | +pub struct RecordedArchive { |
| 564 | + pub handle: OrgName, |
| 565 | + pub name: RepoName, |
| 566 | + pub format: crate::application::port::ArchiveFormat, |
| 567 | + pub commit: ObjectId, |
| 568 | + pub prefix: String, |
| 569 | +} |
| 570 | + |
| 571 | +/// An archive port that records what it was asked and never runs git. |
| 572 | +/// |
| 573 | +/// Like [`InMemoryGitProtocol`], what it is really for is proving a negative: that a |
| 574 | +/// use case refused before a single byte was packed. |
| 575 | +#[derive(Debug, Default, Clone)] |
| 576 | +pub struct InMemoryGitArchive { |
| 577 | + requests: Arc<Mutex<Vec<RecordedArchive>>>, |
| 578 | +} |
| 579 | + |
| 580 | +impl InMemoryGitArchive { |
| 581 | + pub fn new() -> Self { |
| 582 | + Self::default() |
| 583 | + } |
| 584 | + |
| 585 | + pub fn requests(&self) -> Vec<RecordedArchive> { |
| 586 | + self.requests.lock().expect("lock poisoned").clone() |
| 587 | + } |
| 588 | + |
| 589 | + /// Whether anything was ever packed. |
| 590 | + pub fn was_called(&self) -> bool { |
| 591 | + !self.requests.lock().expect("lock poisoned").is_empty() |
| 592 | + } |
| 593 | +} |
| 594 | + |
| 595 | +impl GitArchive for InMemoryGitArchive { |
| 596 | + async fn archive(&self, request: ArchiveRequest) -> Result<ByteStream, GitArchiveError> { |
| 597 | + self.requests |
| 598 | + .lock() |
| 599 | + .expect("lock poisoned") |
| 600 | + .push(RecordedArchive { |
| 601 | + handle: request.handle, |
| 602 | + name: request.name, |
| 603 | + format: request.format, |
| 604 | + commit: request.commit, |
| 605 | + prefix: request.prefix, |
| 606 | + }); |
| 607 | + |
| 608 | + Ok(Box::pin(std::io::Cursor::new(b"archive".to_vec()))) |
| 609 | + } |
| 610 | +} |
| 611 | + |
| 475 | 612 | /// A repository's contents, held in memory, for testing use cases and pages. |
| 476 | 613 | /// |
| 477 | 614 | /// The counterpart to `DiskGitQuery`. Seeded with exactly what a test needs rather than |
| | @@ -706,7 +843,10 @@ mod tests { |
| 706 | 843 | |
| 707 | 844 | use tempfile::TempDir; |
| 708 | 845 | |
| 846 | + use tokio::io::AsyncReadExt; |
| 847 | + |
| 709 | 848 | use super::*; |
| 849 | + use crate::application::port::ArchiveFormat; |
| 710 | 850 | |
| 711 | 851 | /// The `TempDir` is returned alongside the storage because dropping it deletes the |
| 712 | 852 | /// data directory — binding it to `_` would remove the fixture mid-test. |
| | @@ -1124,4 +1264,118 @@ mod tests { |
| 1124 | 1264 | ); |
| 1125 | 1265 | assert!(protocol.was_called()); |
| 1126 | 1266 | } |
| 1267 | + |
| 1268 | + // --- git archive --------------------------------------------------------------- |
| 1269 | + |
| 1270 | + /// A bare repository with one commit in it, packed the way a real one is. |
| 1271 | + /// |
| 1272 | + /// Built by pushing from a working copy rather than by writing objects directly, |
| 1273 | + /// for the same reason `git_query`'s fixture is: it is what actually happens. |
| 1274 | + fn archivable() -> (TempDir, DiskGitArchive, ObjectId) { |
| 1275 | + let dir = TempDir::new().expect("temp dir"); |
| 1276 | + let storage = DiskGitStorage::new(dir.path()); |
| 1277 | + let repo = storage.repo_path(&handle(), &repo_name("steid")); |
| 1278 | + let work = dir.path().join("work"); |
| 1279 | + |
| 1280 | + let git = |at: &Path, args: &[&str]| { |
| 1281 | + let output = std::process::Command::new("git") |
| 1282 | + .arg("-C") |
| 1283 | + .arg(at) |
| 1284 | + .args(args) |
| 1285 | + .env("GIT_CONFIG_GLOBAL", "/dev/null") |
| 1286 | + .env("GIT_CONFIG_SYSTEM", "/dev/null") |
| 1287 | + .env("GIT_AUTHOR_NAME", "Ada Lovelace") |
| 1288 | + .env("GIT_AUTHOR_EMAIL", "ada@example.com") |
| 1289 | + .env("GIT_COMMITTER_NAME", "Ada Lovelace") |
| 1290 | + .env("GIT_COMMITTER_EMAIL", "ada@example.com") |
| 1291 | + .output() |
| 1292 | + .expect("git should be on PATH"); |
| 1293 | + |
| 1294 | + assert!( |
| 1295 | + output.status.success(), |
| 1296 | + "git {args:?} failed: {}", |
| 1297 | + String::from_utf8_lossy(&output.stderr) |
| 1298 | + ); |
| 1299 | + |
| 1300 | + String::from_utf8_lossy(&output.stdout).trim().to_owned() |
| 1301 | + }; |
| 1302 | + |
| 1303 | + std::fs::create_dir_all(&work).expect("create work tree"); |
| 1304 | + git( |
| 1305 | + dir.path(), |
| 1306 | + &[ |
| 1307 | + "init", |
| 1308 | + "--bare", |
| 1309 | + "--quiet", |
| 1310 | + "--template=", |
| 1311 | + "--initial-branch=main", |
| 1312 | + "--", |
| 1313 | + repo.to_str().expect("utf-8 fixture path"), |
| 1314 | + ], |
| 1315 | + ); |
| 1316 | + git(&work, &["init", "--quiet", "-b", "main"]); |
| 1317 | + std::fs::write(work.join("README.md"), b"hello\n").expect("write"); |
| 1318 | + git(&work, &["add", "-A"]); |
| 1319 | + git(&work, &["commit", "--quiet", "-m", "first"]); |
| 1320 | + git( |
| 1321 | + &work, |
| 1322 | + &["push", "--quiet", repo.to_str().expect("utf-8"), "main"], |
| 1323 | + ); |
| 1324 | + |
| 1325 | + let head = git(&repo, &["rev-parse", "main"]); |
| 1326 | + let archives = DiskGitArchive::new(dir.path()); |
| 1327 | + |
| 1328 | + ( |
| 1329 | + dir, |
| 1330 | + archives, |
| 1331 | + ObjectId::new(head).expect("a real object id"), |
| 1332 | + ) |
| 1333 | + } |
| 1334 | + |
| 1335 | + async fn packed(format: ArchiveFormat) -> Vec<u8> { |
| 1336 | + let (dir, archives, commit) = archivable(); |
| 1337 | + |
| 1338 | + let mut stream = archives |
| 1339 | + .archive(ArchiveRequest { |
| 1340 | + handle: handle(), |
| 1341 | + name: repo_name("steid"), |
| 1342 | + format, |
| 1343 | + commit, |
| 1344 | + prefix: "steid-main/".to_owned(), |
| 1345 | + }) |
| 1346 | + .await |
| 1347 | + .expect("should pack"); |
| 1348 | + |
| 1349 | + let mut bytes = Vec::new(); |
| 1350 | + stream.read_to_end(&mut bytes).await.expect("should stream"); |
| 1351 | + |
| 1352 | + // Held until the bytes are read: dropping the fixture deletes the repository |
| 1353 | + // git is still reading from. |
| 1354 | + drop(dir); |
| 1355 | + |
| 1356 | + bytes |
| 1357 | + } |
| 1358 | + |
| 1359 | + #[tokio::test] |
| 1360 | + async fn a_tarball_carries_the_prefix_directory() { |
| 1361 | + let bytes = packed(ArchiveFormat::TarGz).await; |
| 1362 | + |
| 1363 | + // The gzip magic number, so the format flag is doing something rather than |
| 1364 | + // silently producing an uncompressed tar. |
| 1365 | + assert_eq!(&bytes[..2], &[0x1f, 0x8b], "expected gzip"); |
| 1366 | + assert!(bytes.len() > 100, "expected a real archive"); |
| 1367 | + } |
| 1368 | + |
| 1369 | + #[tokio::test] |
| 1370 | + async fn a_zip_is_a_zip() { |
| 1371 | + let bytes = packed(ArchiveFormat::Zip).await; |
| 1372 | + |
| 1373 | + assert_eq!(&bytes[..2], b"PK", "expected a zip"); |
| 1374 | + // The prefix is stored as part of every entry name, uncompressed in the |
| 1375 | + // central directory, so it is readable in the raw bytes. |
| 1376 | + assert!( |
| 1377 | + bytes.windows(11).any(|window| window == b"steid-main/"), |
| 1378 | + "expected the prefix directory in the archive" |
| 1379 | + ); |
| 1380 | + } |
| 1127 | 1381 | } |