| | @@ -0,0 +1,1323 @@ |
| 1 | +//! Reading repository contents through the `git` binary. |
| 2 | +//! |
| 3 | +//! One process per question, per the Milestone 5 amendment to |
| 4 | +//! [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md): ~11–12ms of |
| 5 | +//! that is `execve`, and the upgrade to a kept-alive `cat-file --batch` is an adapter |
| 6 | +//! change behind this unchanged port. |
| 7 | +//! |
| 8 | +//! # Telling "not there" from "broken" |
| 9 | +//! |
| 10 | +//! git reports both with a non-zero exit, and at different call sites with *different* |
| 11 | +//! non-zero exits: `rev-parse --verify --quiet` says 1 for an unknown ref but 128 for a |
| 12 | +//! missing repository, while `ls-tree` pointed at a blob says 128 for what is, to a |
| 13 | +//! visitor, a 404. Keying off exit codes therefore either turns a typo'd URL into a 500 |
| 14 | +//! or buries a corrupt repository behind a "not found". |
| 15 | +//! |
| 16 | +//! So every existence question here goes through one command that does not use its exit |
| 17 | +//! status to answer: `git cat-file --batch-check` writes `<spec> missing` on stdout and |
| 18 | +//! **exits 0** for anything it cannot resolve — an unknown ref, an absent path, a path |
| 19 | +//! traversing through a blob, a submodule's commit that lives in another repository. |
| 20 | +//! That gives a single rule for this whole module: |
| 21 | +//! |
| 22 | +//! **A non-zero exit from git is always an error.** "Not found" is a value read off |
| 23 | +//! stdout, never an exit code. Everything else — git missing from `PATH`, a repository |
| 24 | +//! directory that is gone, an unreadable object store — surfaces as [`GitQueryError`] |
| 25 | +//! carrying git's own words. |
| 26 | +//! |
| 27 | +//! The listing and content commands are only ever reached *after* `--batch-check` has |
| 28 | +//! confirmed the object and its type, and are handed the resolved object id rather than |
| 29 | +//! the user's revision, so their failure modes are genuinely faults. |
| 30 | + |
| 31 | +use std::{ |
| 32 | + ffi::OsStr, |
| 33 | + path::{Path, PathBuf}, |
| 34 | + process::{Output, Stdio}, |
| 35 | + time::{Duration, SystemTime, UNIX_EPOCH}, |
| 36 | +}; |
| 37 | + |
| 38 | +use tokio::io::AsyncWriteExt; |
| 39 | + |
| 40 | +use crate::{ |
| 41 | + application::port::{Blob, GitQuery, GitQueryError}, |
| 42 | + domain::{CommitSummary, EntryKind, ObjectId, OrgName, RefName, RepoName, RepoPath, TreeEntry}, |
| 43 | + infrastructure::git::git_command, |
| 44 | +}; |
| 45 | + |
| 46 | +/// The words `cat-file --batch-check` ends a line with when it did not resolve a spec. |
| 47 | +/// |
| 48 | +/// `missing` covers the common cases; `ambiguous` is an abbreviated id matching more |
| 49 | +/// than one object, and `dangling` and `notdir` appear when following a `^{}` or a path |
| 50 | +/// through something that cannot hold one. None of them is a failure — they are the |
| 51 | +/// answer "no such thing here". |
| 52 | +const NOT_FOUND_MARKERS: [&str; 4] = ["missing", "ambiguous", "dangling", "notdir"]; |
| 53 | + |
| 54 | +/// Repository contents, read from bare repositories under a data directory. |
| 55 | +#[derive(Debug, Clone)] |
| 56 | +pub struct DiskGitQuery { |
| 57 | + data_dir: PathBuf, |
| 58 | +} |
| 59 | + |
| 60 | +impl DiskGitQuery { |
| 61 | + pub fn new(data_dir: impl Into<PathBuf>) -> Self { |
| 62 | + Self { |
| 63 | + data_dir: data_dir.into(), |
| 64 | + } |
| 65 | + } |
| 66 | + |
| 67 | + /// Where a repository lives, matching `DiskGitStorage`'s layout. |
| 68 | + pub(crate) fn repo_path(&self, handle: &OrgName, name: &RepoName) -> PathBuf { |
| 69 | + self.data_dir |
| 70 | + .join(handle.as_str()) |
| 71 | + .join(format!("{name}.git")) |
| 72 | + } |
| 73 | +} |
| 74 | + |
| 75 | +impl GitQuery for DiskGitQuery { |
| 76 | + async fn default_branch( |
| 77 | + &self, |
| 78 | + handle: &OrgName, |
| 79 | + name: &RepoName, |
| 80 | + ) -> Result<Option<RefName>, GitQueryError> { |
| 81 | + let repo = self.repo_path(handle, name); |
| 82 | + |
| 83 | + // An empty repository's HEAD names a branch that does not exist yet, so |
| 84 | + // `symbolic-ref` happily answers `main` for a repository with nothing in it. |
| 85 | + // Whether HEAD *resolves* is the actual question, and it is asked first. |
| 86 | + let Some(head) = object_info(&repo, "HEAD").await? else { |
| 87 | + return Ok(None); |
| 88 | + }; |
| 89 | + |
| 90 | + let branch = run(&repo, [OsStr::new("symbolic-ref"), OsStr::new("HEAD")]).await; |
| 91 | + |
| 92 | + match branch { |
| 93 | + Ok(output) => { |
| 94 | + let full = String::from_utf8_lossy(&output.stdout).trim().to_owned(); |
| 95 | + // `refs/heads/main` rather than `--short`, because `--short` shortens |
| 96 | + // only as far as is unambiguous and would hand back `heads/main` for a |
| 97 | + // repository that also has a tag called `main`. |
| 98 | + let short = full.strip_prefix("refs/heads/").unwrap_or(&full); |
| 99 | + |
| 100 | + Ok(Some(RefName::from_trusted(short))) |
| 101 | + } |
| 102 | + // A detached HEAD is not a state Steid creates, but a repository pushed into |
| 103 | + // from elsewhere can be in it. The commit is still browsable, so name it |
| 104 | + // rather than claiming the repository is empty — which is what `Ok(None)` |
| 105 | + // would mean to a page. |
| 106 | + Err(_) => Ok(Some(RefName::from_trusted(head.id.as_str()))), |
| 107 | + } |
| 108 | + } |
| 109 | + |
| 110 | + async fn resolve( |
| 111 | + &self, |
| 112 | + handle: &OrgName, |
| 113 | + name: &RepoName, |
| 114 | + rev: &RefName, |
| 115 | + ) -> Result<Option<ObjectId>, GitQueryError> { |
| 116 | + let repo = self.repo_path(handle, name); |
| 117 | + |
| 118 | + // `^{commit}` peels an annotated tag to what it points at, and refuses a |
| 119 | + // revision that names a tree or a blob — a browse page wants a commit, and |
| 120 | + // returning a tree id here would fail confusingly two calls later. |
| 121 | + let spec = format!("{}^{{commit}}", rev.as_str()); |
| 122 | + |
| 123 | + Ok(object_info(&repo, &spec).await?.map(|info| info.id)) |
| 124 | + } |
| 125 | + |
| 126 | + async fn list_tree( |
| 127 | + &self, |
| 128 | + handle: &OrgName, |
| 129 | + name: &RepoName, |
| 130 | + rev: &RefName, |
| 131 | + path: &RepoPath, |
| 132 | + ) -> Result<Option<Vec<TreeEntry>>, GitQueryError> { |
| 133 | + let repo = self.repo_path(handle, name); |
| 134 | + |
| 135 | + let Some(info) = object_info(&repo, &tree_spec(rev, path)).await? else { |
| 136 | + return Ok(None); |
| 137 | + }; |
| 138 | + |
| 139 | + // A file is not a directory. Asking `ls-tree` anyway is a fatal error, which is |
| 140 | + // exactly the confusion this check exists to avoid. |
| 141 | + if info.kind != ObjectKind::Tree { |
| 142 | + return Ok(None); |
| 143 | + } |
| 144 | + |
| 145 | + // `-z` because a filename may contain a newline, and `--long` for blob sizes. |
| 146 | + // The already-resolved tree id is passed rather than the user's revision, so |
| 147 | + // nothing here has to think about what git's revision parser might make of it. |
| 148 | + let output = run( |
| 149 | + &repo, |
| 150 | + [ |
| 151 | + OsStr::new("ls-tree"), |
| 152 | + OsStr::new("-z"), |
| 153 | + OsStr::new("--long"), |
| 154 | + OsStr::new(info.id.as_str()), |
| 155 | + ], |
| 156 | + ) |
| 157 | + .await?; |
| 158 | + |
| 159 | + parse_tree(&output.stdout).map(Some) |
| 160 | + } |
| 161 | + |
| 162 | + async fn read_blob( |
| 163 | + &self, |
| 164 | + handle: &OrgName, |
| 165 | + name: &RepoName, |
| 166 | + rev: &RefName, |
| 167 | + path: &RepoPath, |
| 168 | + max_bytes: u64, |
| 169 | + ) -> Result<Option<Blob>, GitQueryError> { |
| 170 | + let repo = self.repo_path(handle, name); |
| 171 | + |
| 172 | + // The root is a tree, and `{rev}:` is how git spells it — but a caller asking to |
| 173 | + // read the root is asking for a file that is not there. |
| 174 | + if path.is_root() { |
| 175 | + return Ok(None); |
| 176 | + } |
| 177 | + |
| 178 | + let Some(info) = object_info(&repo, &tree_spec(rev, path)).await? else { |
| 179 | + return Ok(None); |
| 180 | + }; |
| 181 | + |
| 182 | + // Symlinks are blobs whose content is the target path, and are read as such: |
| 183 | + // showing where a link points is more use than a blank page. Trees and |
| 184 | + // submodules are not files. |
| 185 | + if info.kind != ObjectKind::Blob { |
| 186 | + return Ok(None); |
| 187 | + } |
| 188 | + |
| 189 | + // The size comes from the object header, so an oversized file is never read. |
| 190 | + // Doing this the other way round — read, then measure — is how one URL becomes |
| 191 | + // an out-of-memory kill. |
| 192 | + let content = if info.size > max_bytes { |
| 193 | + None |
| 194 | + } else { |
| 195 | + let output = run( |
| 196 | + &repo, |
| 197 | + [ |
| 198 | + OsStr::new("cat-file"), |
| 199 | + OsStr::new("blob"), |
| 200 | + OsStr::new(info.id.as_str()), |
| 201 | + ], |
| 202 | + ) |
| 203 | + .await?; |
| 204 | + |
| 205 | + Some(output.stdout) |
| 206 | + }; |
| 207 | + |
| 208 | + Ok(Some(Blob { |
| 209 | + id: info.id, |
| 210 | + size: info.size, |
| 211 | + content, |
| 212 | + })) |
| 213 | + } |
| 214 | + |
| 215 | + async fn log( |
| 216 | + &self, |
| 217 | + handle: &OrgName, |
| 218 | + name: &RepoName, |
| 219 | + rev: &RefName, |
| 220 | + limit: usize, |
| 221 | + ) -> Result<Vec<CommitSummary>, GitQueryError> { |
| 222 | + let repo = self.repo_path(handle, name); |
| 223 | + |
| 224 | + // `git log` on a repository with no commits is a fatal error, and so is a log of |
| 225 | + // a branch that does not exist. Resolving first turns both into the empty list |
| 226 | + // the port's signature promises, without having to read meaning into a stderr |
| 227 | + // string that is localised and free to change between git versions. |
| 228 | + let Some(commit) = self.resolve(handle, name, rev).await? else { |
| 229 | + return Ok(Vec::new()); |
| 230 | + }; |
| 231 | + |
| 232 | + if limit == 0 { |
| 233 | + return Ok(Vec::new()); |
| 234 | + } |
| 235 | + |
| 236 | + // Every separator is a NUL: `-z` between commits, `%x00` between fields. A |
| 237 | + // commit message contains newlines as a matter of course, and a name can contain |
| 238 | + // almost anything, so splitting on lines or whitespace would misread real |
| 239 | + // history rather than exotic history. |
| 240 | + let format = "--format=%H%x00%ct%x00%an%x00%s"; |
| 241 | + let count = format!("--max-count={limit}"); |
| 242 | + |
| 243 | + let output = run( |
| 244 | + &repo, |
| 245 | + [ |
| 246 | + OsStr::new("log"), |
| 247 | + OsStr::new("-z"), |
| 248 | + OsStr::new(&count), |
| 249 | + OsStr::new(format), |
| 250 | + OsStr::new(commit.as_str()), |
| 251 | + ], |
| 252 | + ) |
| 253 | + .await?; |
| 254 | + |
| 255 | + parse_log(&output.stdout) |
| 256 | + } |
| 257 | +} |
| 258 | + |
| 259 | +/// What `cat-file --batch-check` said about one object. |
| 260 | +#[derive(Debug, Clone, PartialEq, Eq)] |
| 261 | +struct ObjectInfo { |
| 262 | + id: ObjectId, |
| 263 | + kind: ObjectKind, |
| 264 | + size: u64, |
| 265 | +} |
| 266 | + |
| 267 | +/// A git object's type, as its header spells it. |
| 268 | +/// |
| 269 | +/// Distinct from [`EntryKind`], which is about what a tree entry *means* — the object |
| 270 | +/// store cannot tell a symlink from a file, because both are blobs. |
| 271 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 272 | +enum ObjectKind { |
| 273 | + Blob, |
| 274 | + Tree, |
| 275 | + Commit, |
| 276 | + Tag, |
| 277 | +} |
| 278 | + |
| 279 | +impl ObjectKind { |
| 280 | + fn from_str(value: &str) -> Option<Self> { |
| 281 | + match value { |
| 282 | + "blob" => Some(Self::Blob), |
| 283 | + "tree" => Some(Self::Tree), |
| 284 | + "commit" => Some(Self::Commit), |
| 285 | + "tag" => Some(Self::Tag), |
| 286 | + _ => None, |
| 287 | + } |
| 288 | + } |
| 289 | +} |
| 290 | + |
| 291 | +/// How git addresses a path inside a revision: `{rev}:{path}`, and `{rev}:` for the root. |
| 292 | +fn tree_spec(rev: &RefName, path: &RepoPath) -> String { |
| 293 | + format!("{}:{}", rev.as_str(), path.as_str()) |
| 294 | +} |
| 295 | + |
| 296 | +/// Asks git what one revision-and-path resolves to, or `None` if it resolves to nothing. |
| 297 | +/// |
| 298 | +/// The spec goes over stdin rather than in an argument, so no revision or path can ever |
| 299 | +/// be read as a flag regardless of what validation upstream does or stops doing. |
| 300 | +async fn object_info(repo: &Path, spec: &str) -> Result<Option<ObjectInfo>, GitQueryError> { |
| 301 | + let mut command = git_command(); |
| 302 | + command |
| 303 | + .arg("-C") |
| 304 | + .arg(repo) |
| 305 | + .arg("cat-file") |
| 306 | + .arg("--batch-check") |
| 307 | + .stdin(Stdio::piped()) |
| 308 | + .stdout(Stdio::piped()) |
| 309 | + .stderr(Stdio::piped()); |
| 310 | + |
| 311 | + let mut child = command |
| 312 | + .spawn() |
| 313 | + .map_err(|error| GitQueryError::new(format!("could not run git: {error}")))?; |
| 314 | + |
| 315 | + let mut stdin = child.stdin.take().expect("stdin was piped"); |
| 316 | + |
| 317 | + // One short line, far below a pipe's buffer, so writing before waiting cannot |
| 318 | + // deadlock. Dropping stdin is what ends the batch — git would otherwise wait for |
| 319 | + // another spec forever. |
| 320 | + stdin |
| 321 | + .write_all(format!("{spec}\n").as_bytes()) |
| 322 | + .await |
| 323 | + .map_err(|error| { |
| 324 | + GitQueryError::new(format!("could not ask git about {spec:?}: {error}")) |
| 325 | + })?; |
| 326 | + drop(stdin); |
| 327 | + |
| 328 | + let output = child |
| 329 | + .wait_with_output() |
| 330 | + .await |
| 331 | + .map_err(|error| GitQueryError::new(format!("could not wait for git: {error}")))?; |
| 332 | + |
| 333 | + // Per the module note: a non-zero exit here is never "not found". |
| 334 | + if !output.status.success() { |
| 335 | + return Err(GitQueryError::new(format!( |
| 336 | + "git exited with {} looking up {spec:?}: {}", |
| 337 | + output.status, |
| 338 | + String::from_utf8_lossy(&output.stderr).trim() |
| 339 | + ))); |
| 340 | + } |
| 341 | + |
| 342 | + let line = String::from_utf8_lossy(&output.stdout); |
| 343 | + let line = line.trim_end_matches('\n'); |
| 344 | + |
| 345 | + // The marker is checked before the field count, because a not-found line echoes the |
| 346 | + // spec back — and a spec naming a file with spaces in it has no fixed field count. |
| 347 | + if line |
| 348 | + .rsplit(' ') |
| 349 | + .next() |
| 350 | + .is_some_and(|last| NOT_FOUND_MARKERS.contains(&last)) |
| 351 | + { |
| 352 | + return Ok(None); |
| 353 | + } |
| 354 | + |
| 355 | + let fields: Vec<&str> = line.split_whitespace().collect(); |
| 356 | + let [id, kind, size] = fields[..] else { |
| 357 | + return Err(GitQueryError::new(format!( |
| 358 | + "git described {spec:?} in a shape we do not understand: {line:?}" |
| 359 | + ))); |
| 360 | + }; |
| 361 | + |
| 362 | + Ok(Some(ObjectInfo { |
| 363 | + // Validated rather than trusted. git's ids are trustworthy, but this is a parse |
| 364 | + // of text whose layout we have assumed, and an id is about to appear in a URL — |
| 365 | + // a misread field should stop here rather than surface as a broken link. The |
| 366 | + // cost is a length and hex check next to a process spawn. |
| 367 | + id: ObjectId::new(id) |
| 368 | + .map_err(|error| GitQueryError::new(format!("git named a bad object id: {error}")))?, |
| 369 | + kind: ObjectKind::from_str(kind).ok_or_else(|| { |
| 370 | + GitQueryError::new(format!("git reported an unknown object type {kind:?}")) |
| 371 | + })?, |
| 372 | + size: size.parse().map_err(|_| { |
| 373 | + GitQueryError::new(format!("git reported an unreadable object size {size:?}")) |
| 374 | + })?, |
| 375 | + })) |
| 376 | +} |
| 377 | + |
| 378 | +/// Parses `ls-tree -z --long` output. |
| 379 | +/// |
| 380 | +/// Each record is `<mode> SP <type> SP <id> SP <size> TAB <name>`, NUL-terminated, where |
| 381 | +/// the size is space-padded and `-` for anything that is not a blob. The name is |
| 382 | +/// everything after the first tab and is *raw bytes* — which is why the split happens |
| 383 | +/// before any attempt to read it as text. |
| 384 | +fn parse_tree(stdout: &[u8]) -> Result<Vec<TreeEntry>, GitQueryError> { |
| 385 | + let mut entries = Vec::new(); |
| 386 | + |
| 387 | + for record in stdout.split(|byte| *byte == 0) { |
| 388 | + if record.is_empty() { |
| 389 | + continue; |
| 390 | + } |
| 391 | + |
| 392 | + let Some(tab) = record.iter().position(|byte| *byte == b'\t') else { |
| 393 | + return Err(GitQueryError::new( |
| 394 | + "git listed a tree entry with no name separator", |
| 395 | + )); |
| 396 | + }; |
| 397 | + |
| 398 | + let (meta, name) = record.split_at(tab); |
| 399 | + let name = &name[1..]; |
| 400 | + |
| 401 | + let meta = std::str::from_utf8(meta).map_err(|_| { |
| 402 | + GitQueryError::new("git listed a tree entry whose metadata is not text") |
| 403 | + })?; |
| 404 | + |
| 405 | + let fields: Vec<&str> = meta.split_whitespace().collect(); |
| 406 | + let [mode, _type, id, size] = fields[..] else { |
| 407 | + return Err(GitQueryError::new(format!( |
| 408 | + "git listed a tree entry in a shape we do not understand: {meta:?}" |
| 409 | + ))); |
| 410 | + }; |
| 411 | + |
| 412 | + entries.push(TreeEntry { |
| 413 | + // Lossy, because `TreeEntry::name` is a `String` and a filename is not |
| 414 | + // required to be UTF-8. A replacement character renders; refusing to list |
| 415 | + // the whole directory because one file has an odd name does not. |
| 416 | + name: String::from_utf8_lossy(name).into_owned(), |
| 417 | + kind: EntryKind::from_mode(mode) |
| 418 | + .map_err(|error| GitQueryError::new(format!("git listed {name:?}: {error}")))?, |
| 419 | + id: ObjectId::new(id).map_err(|error| { |
| 420 | + GitQueryError::new(format!("git named a bad object id: {error}")) |
| 421 | + })?, |
| 422 | + // `-` for a tree or a submodule, which have no size a listing can show. |
| 423 | + size: size.parse().ok(), |
| 424 | + }); |
| 425 | + } |
| 426 | + |
| 427 | + // Unsorted on purpose: ordering is `TreeEntry::ordering_key`'s decision, made once |
| 428 | + // in the application rather than differently in each adapter. |
| 429 | + Ok(entries) |
| 430 | +} |
| 431 | + |
| 432 | +/// Parses the NUL-separated `log` stream into four-field records. |
| 433 | +fn parse_log(stdout: &[u8]) -> Result<Vec<CommitSummary>, GitQueryError> { |
| 434 | + // `-z` terminates the last record too, so the split leaves a trailing empty field |
| 435 | + // that is not a commit. |
| 436 | + let fields: Vec<&[u8]> = stdout |
| 437 | + .split(|byte| *byte == 0) |
| 438 | + .filter(|field| !field.is_empty()) |
| 439 | + .collect(); |
| 440 | + |
| 441 | + let mut commits = Vec::with_capacity(fields.len() / 4); |
| 442 | + |
| 443 | + for record in fields.chunks(4) { |
| 444 | + let [id, committed_at, author_name, summary] = record[..] else { |
| 445 | + return Err(GitQueryError::new( |
| 446 | + "git logged a commit with missing fields", |
| 447 | + )); |
| 448 | + }; |
| 449 | + |
| 450 | + let id = String::from_utf8_lossy(id); |
| 451 | + let committed_at = String::from_utf8_lossy(committed_at); |
| 452 | + let committed_at: i64 = committed_at.trim().parse().map_err(|_| { |
| 453 | + GitQueryError::new(format!( |
| 454 | + "git logged an unreadable commit time {committed_at:?}" |
| 455 | + )) |
| 456 | + })?; |
| 457 | + |
| 458 | + commits.push(CommitSummary { |
| 459 | + id: ObjectId::new(id.trim()).map_err(|error| { |
| 460 | + GitQueryError::new(format!("git named a bad object id: {error}")) |
| 461 | + })?, |
| 462 | + // `%s` is git's subject: the first paragraph, joined into one line. Trimmed |
| 463 | + // to the first line anyway, because that invariant is git's rather than |
| 464 | + // something this parser should assume. |
| 465 | + summary: String::from_utf8_lossy(summary) |
| 466 | + .lines() |
| 467 | + .next() |
| 468 | + .unwrap_or_default() |
| 469 | + .to_owned(), |
| 470 | + author_name: String::from_utf8_lossy(author_name).into_owned(), |
| 471 | + committed_at: unix_time(committed_at), |
| 472 | + }); |
| 473 | + } |
| 474 | + |
| 475 | + Ok(commits) |
| 476 | +} |
| 477 | + |
| 478 | +/// A unix timestamp as a `SystemTime`, including the negative ones. |
| 479 | +/// |
| 480 | +/// A commit dated before 1970 is either a lie or an import from something older than |
| 481 | +/// git, and both exist in real repositories. `UNIX_EPOCH + Duration` would panic on the |
| 482 | +/// subtraction it cannot do. |
| 483 | +fn unix_time(seconds: i64) -> SystemTime { |
| 484 | + match u64::try_from(seconds) { |
| 485 | + Ok(seconds) => UNIX_EPOCH + Duration::from_secs(seconds), |
| 486 | + Err(_) => UNIX_EPOCH - Duration::from_secs(seconds.unsigned_abs()), |
| 487 | + } |
| 488 | +} |
| 489 | + |
| 490 | +/// Runs a git command inside a repository and fails on a non-zero exit. |
| 491 | +/// |
| 492 | +/// Only ever used for commands whose subject has already been confirmed to exist, so a |
| 493 | +/// failure really is a failure. Built from [`git_command`] so the host isolation 0006 |
| 494 | +/// insists on cannot drift out of this module. |
| 495 | +async fn run<I, S>(repo: &Path, args: I) -> Result<Output, GitQueryError> |
| 496 | +where |
| 497 | + I: IntoIterator<Item = S>, |
| 498 | + S: AsRef<OsStr>, |
| 499 | +{ |
| 500 | + let mut command = git_command(); |
| 501 | + command.arg("-C").arg(repo).args(args).stdin(Stdio::null()); |
| 502 | + |
| 503 | + let output = command |
| 504 | + .output() |
| 505 | + .await |
| 506 | + .map_err(|error| GitQueryError::new(format!("could not run git: {error}")))?; |
| 507 | + |
| 508 | + if !output.status.success() { |
| 509 | + return Err(GitQueryError::new(format!( |
| 510 | + "git exited with {}: {}", |
| 511 | + output.status, |
| 512 | + String::from_utf8_lossy(&output.stderr).trim() |
| 513 | + ))); |
| 514 | + } |
| 515 | + |
| 516 | + Ok(output) |
| 517 | +} |
| 518 | + |
| 519 | +#[cfg(test)] |
| 520 | +mod tests { |
| 521 | + use std::collections::HashMap; |
| 522 | + |
| 523 | + use tempfile::TempDir; |
| 524 | + |
| 525 | + use super::*; |
| 526 | + use crate::domain::EntryKind; |
| 527 | + |
| 528 | + /// Fixed so a timestamp assertion is exact rather than approximate. |
| 529 | + const FIRST_COMMIT: i64 = 1_700_000_000; |
| 530 | + const SECOND_COMMIT: i64 = 1_700_000_100; |
| 531 | + const THIRD_COMMIT: i64 = 1_700_000_200; |
| 532 | + |
| 533 | + /// A subject with the punctuation a naive parser splits on, followed by a body — so |
| 534 | + /// a test can prove the body does not leak into the summary. |
| 535 | + const ODD_MESSAGE: &str = |
| 536 | + "third: 'quotes', \"doubles\" | pipes\n\nbody line one\nbody line two"; |
| 537 | + |
| 538 | + const BINARY: &[u8] = &[0x00, 0x01, 0xff, 0xfe, 0x80]; |
| 539 | + |
| 540 | + fn handle() -> OrgName { |
| 541 | + OrgName::new("jamesgill").expect("valid handle") |
| 542 | + } |
| 543 | + |
| 544 | + fn repo_name() -> RepoName { |
| 545 | + RepoName::new("steid").expect("valid repository name") |
| 546 | + } |
| 547 | + |
| 548 | + fn rev(value: &str) -> RefName { |
| 549 | + RefName::new(value).expect("valid revision") |
| 550 | + } |
| 551 | + |
| 552 | + fn path(value: &str) -> RepoPath { |
| 553 | + RepoPath::new(value).expect("valid path") |
| 554 | + } |
| 555 | + |
| 556 | + /// Runs git in a fixture, isolated from the host's configuration the same way the |
| 557 | + /// adapter is — otherwise a developer's `commit.gpgsign` or `init.defaultBranch` |
| 558 | + /// decides whether the suite passes. |
| 559 | + fn git(dir: &Path, when: i64, args: &[&str]) { |
| 560 | + let date = format!("@{when} +0000"); |
| 561 | + |
| 562 | + let output = std::process::Command::new("git") |
| 563 | + .arg("-C") |
| 564 | + .arg(dir) |
| 565 | + .args(args) |
| 566 | + .env("GIT_CONFIG_GLOBAL", "/dev/null") |
| 567 | + .env("GIT_CONFIG_SYSTEM", "/dev/null") |
| 568 | + .env("GIT_AUTHOR_NAME", "Ada Lovelace") |
| 569 | + .env("GIT_AUTHOR_EMAIL", "ada@example.com") |
| 570 | + .env("GIT_COMMITTER_NAME", "Ada Lovelace") |
| 571 | + .env("GIT_COMMITTER_EMAIL", "ada@example.com") |
| 572 | + .env("GIT_AUTHOR_DATE", &date) |
| 573 | + .env("GIT_COMMITTER_DATE", &date) |
| 574 | + .output() |
| 575 | + .expect("git should be on PATH"); |
| 576 | + |
| 577 | + assert!( |
| 578 | + output.status.success(), |
| 579 | + "git {args:?} failed: {}", |
| 580 | + String::from_utf8_lossy(&output.stderr) |
| 581 | + ); |
| 582 | + } |
| 583 | + |
| 584 | + /// A data directory holding one empty bare repository, exactly as Steid creates it. |
| 585 | + /// |
| 586 | + /// The `TempDir` is returned because dropping it deletes the fixture. |
| 587 | + fn empty() -> (TempDir, DiskGitQuery) { |
| 588 | + let dir = TempDir::new().expect("temp dir"); |
| 589 | + let query = DiskGitQuery::new(dir.path()); |
| 590 | + let repo = query.repo_path(&handle(), &repo_name()); |
| 591 | + |
| 592 | + std::fs::create_dir_all(repo.parent().expect("has a parent")).expect("create handle dir"); |
| 593 | + git( |
| 594 | + dir.path(), |
| 595 | + FIRST_COMMIT, |
| 596 | + &[ |
| 597 | + "init", |
| 598 | + "--bare", |
| 599 | + "--quiet", |
| 600 | + "--template=", |
| 601 | + "--initial-branch=main", |
| 602 | + "--", |
| 603 | + repo.to_str().expect("utf-8 fixture path"), |
| 604 | + ], |
| 605 | + ); |
| 606 | + |
| 607 | + (dir, query) |
| 608 | + } |
| 609 | + |
| 610 | + /// The empty repository with three commits pushed into it, the way a real one fills |
| 611 | + /// up — a working copy and a push, rather than plumbing straight into the object |
| 612 | + /// store. |
| 613 | + fn populated() -> (TempDir, DiskGitQuery) { |
| 614 | + let (dir, query) = empty(); |
| 615 | + let repo = query.repo_path(&handle(), &repo_name()); |
| 616 | + let work = dir.path().join("work"); |
| 617 | + |
| 618 | + std::fs::create_dir_all(work.join("src/deep")).expect("create work tree"); |
| 619 | + git(&work, FIRST_COMMIT, &["init", "--quiet", "-b", "main"]); |
| 620 | + |
| 621 | + std::fs::write(work.join("README.md"), b"hello\n").expect("write"); |
| 622 | + std::fs::write(work.join("with space.txt"), b"spaced\n").expect("write"); |
| 623 | + std::fs::write(work.join("bin.dat"), BINARY).expect("write"); |
| 624 | + std::fs::write(work.join("big.txt"), vec![b'x'; 100]).expect("write"); |
| 625 | + std::fs::write(work.join("src/deep/file.rs"), b"fn main() {}\n").expect("write"); |
| 626 | + std::os::unix::fs::symlink("README.md", work.join("link")).expect("symlink"); |
| 627 | + |
| 628 | + git(&work, FIRST_COMMIT, &["add", "-A"]); |
| 629 | + git(&work, FIRST_COMMIT, &["commit", "--quiet", "-m", "first"]); |
| 630 | + |
| 631 | + std::fs::write(work.join("README.md"), b"hello again\n").expect("write"); |
| 632 | + git(&work, SECOND_COMMIT, &["add", "-A"]); |
| 633 | + git(&work, SECOND_COMMIT, &["commit", "--quiet", "-m", "second"]); |
| 634 | + |
| 635 | + git( |
| 636 | + &work, |
| 637 | + THIRD_COMMIT, |
| 638 | + &["commit", "--quiet", "--allow-empty", "-m", ODD_MESSAGE], |
| 639 | + ); |
| 640 | + |
| 641 | + git( |
| 642 | + &work, |
| 643 | + THIRD_COMMIT, |
| 644 | + &[ |
| 645 | + "push", |
| 646 | + "--quiet", |
| 647 | + repo.to_str().expect("utf-8 fixture path"), |
| 648 | + "main", |
| 649 | + ], |
| 650 | + ); |
| 651 | + |
| 652 | + (dir, query) |
| 653 | + } |
| 654 | + |
| 655 | + /// A listing keyed by name, so an assertion does not depend on an order the port |
| 656 | + /// explicitly does not promise. |
| 657 | + fn by_name(entries: Vec<TreeEntry>) -> HashMap<String, TreeEntry> { |
| 658 | + entries |
| 659 | + .into_iter() |
| 660 | + .map(|entry| (entry.name.clone(), entry)) |
| 661 | + .collect() |
| 662 | + } |
| 663 | + |
| 664 | + // --- an empty repository --------------------------------------------------- |
| 665 | + |
| 666 | + #[tokio::test] |
| 667 | + async fn an_empty_repository_has_no_default_branch() { |
| 668 | + // The distinction the port exists for: HEAD names `main`, but `main` has no |
| 669 | + // commits, so "nothing pushed yet" rather than a branch a page can browse. |
| 670 | + let (_dir, query) = empty(); |
| 671 | + |
| 672 | + assert_eq!( |
| 673 | + query |
| 674 | + .default_branch(&handle(), &repo_name()) |
| 675 | + .await |
| 676 | + .expect("should read"), |
| 677 | + None |
| 678 | + ); |
| 679 | + } |
| 680 | + |
| 681 | + #[tokio::test] |
| 682 | + async fn nothing_resolves_in_an_empty_repository() { |
| 683 | + let (_dir, query) = empty(); |
| 684 | + |
| 685 | + for revision in ["main", "HEAD", "v1.0"] { |
| 686 | + assert_eq!( |
| 687 | + query |
| 688 | + .resolve(&handle(), &repo_name(), &rev(revision)) |
| 689 | + .await |
| 690 | + .expect("should read"), |
| 691 | + None, |
| 692 | + "{revision} should not resolve" |
| 693 | + ); |
| 694 | + } |
| 695 | + } |
| 696 | + |
| 697 | + #[tokio::test] |
| 698 | + async fn an_empty_repository_lists_nothing_and_reads_nothing() { |
| 699 | + let (_dir, query) = empty(); |
| 700 | + |
| 701 | + assert_eq!( |
| 702 | + query |
| 703 | + .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root()) |
| 704 | + .await |
| 705 | + .expect("should read"), |
| 706 | + None |
| 707 | + ); |
| 708 | + assert_eq!( |
| 709 | + query |
| 710 | + .read_blob( |
| 711 | + &handle(), |
| 712 | + &repo_name(), |
| 713 | + &rev("main"), |
| 714 | + &path("README.md"), |
| 715 | + 1024 |
| 716 | + ) |
| 717 | + .await |
| 718 | + .expect("should read"), |
| 719 | + None |
| 720 | + ); |
| 721 | + } |
| 722 | + |
| 723 | + #[tokio::test] |
| 724 | + async fn an_empty_repository_has_an_empty_log() { |
| 725 | + // `git log` is a fatal error here, and an empty list is what the port promises. |
| 726 | + let (_dir, query) = empty(); |
| 727 | + |
| 728 | + assert_eq!( |
| 729 | + query |
| 730 | + .log(&handle(), &repo_name(), &rev("main"), 10) |
| 731 | + .await |
| 732 | + .expect("should read"), |
| 733 | + Vec::new() |
| 734 | + ); |
| 735 | + } |
| 736 | + |
| 737 | + // --- a missing repository is a failure, not a 404 --------------------------- |
| 738 | + |
| 739 | + #[tokio::test] |
| 740 | + async fn a_repository_that_is_not_on_disk_is_an_error() { |
| 741 | + // A record with no directory is a fault to investigate, not a "no such branch". |
| 742 | + // Answering `Ok(None)` here would hide it behind a plausible-looking 404. |
| 743 | + let (_dir, query) = empty(); |
| 744 | + let missing = RepoName::new("never-created").expect("valid repository name"); |
| 745 | + |
| 746 | + assert!(query.default_branch(&handle(), &missing).await.is_err()); |
| 747 | + assert!( |
| 748 | + query |
| 749 | + .resolve(&handle(), &missing, &rev("main")) |
| 750 | + .await |
| 751 | + .is_err() |
| 752 | + ); |
| 753 | + assert!( |
| 754 | + query |
| 755 | + .list_tree(&handle(), &missing, &rev("main"), &RepoPath::root()) |
| 756 | + .await |
| 757 | + .is_err() |
| 758 | + ); |
| 759 | + assert!( |
| 760 | + query |
| 761 | + .log(&handle(), &missing, &rev("main"), 10) |
| 762 | + .await |
| 763 | + .is_err() |
| 764 | + ); |
| 765 | + } |
| 766 | + |
| 767 | + // --- default_branch and resolve -------------------------------------------- |
| 768 | + |
| 769 | + #[tokio::test] |
| 770 | + async fn a_repository_with_commits_reports_its_default_branch() { |
| 771 | + let (_dir, query) = populated(); |
| 772 | + |
| 773 | + assert_eq!( |
| 774 | + query |
| 775 | + .default_branch(&handle(), &repo_name()) |
| 776 | + .await |
| 777 | + .expect("should read"), |
| 778 | + Some(RefName::from_trusted("main")) |
| 779 | + ); |
| 780 | + } |
| 781 | + |
| 782 | + #[tokio::test] |
| 783 | + async fn a_branch_and_head_resolve_to_the_same_commit() { |
| 784 | + let (_dir, query) = populated(); |
| 785 | + |
| 786 | + let main = query |
| 787 | + .resolve(&handle(), &repo_name(), &rev("main")) |
| 788 | + .await |
| 789 | + .expect("should read") |
| 790 | + .expect("main should resolve"); |
| 791 | + let head = query |
| 792 | + .resolve(&handle(), &repo_name(), &rev("HEAD")) |
| 793 | + .await |
| 794 | + .expect("should read"); |
| 795 | + |
| 796 | + assert_eq!(head, Some(main)); |
| 797 | + } |
| 798 | + |
| 799 | + #[tokio::test] |
| 800 | + async fn a_commit_id_resolves_to_itself() { |
| 801 | + let (_dir, query) = populated(); |
| 802 | + |
| 803 | + let main = query |
| 804 | + .resolve(&handle(), &repo_name(), &rev("main")) |
| 805 | + .await |
| 806 | + .expect("should read") |
| 807 | + .expect("main should resolve"); |
| 808 | + |
| 809 | + assert_eq!( |
| 810 | + query |
| 811 | + .resolve(&handle(), &repo_name(), &rev(main.as_str())) |
| 812 | + .await |
| 813 | + .expect("should read"), |
| 814 | + Some(main) |
| 815 | + ); |
| 816 | + } |
| 817 | + |
| 818 | + #[tokio::test] |
| 819 | + async fn an_unknown_revision_resolves_to_nothing() { |
| 820 | + let (_dir, query) = populated(); |
| 821 | + |
| 822 | + assert_eq!( |
| 823 | + query |
| 824 | + .resolve(&handle(), &repo_name(), &rev("no-such-branch")) |
| 825 | + .await |
| 826 | + .expect("looking up a missing branch is not a failure"), |
| 827 | + None |
| 828 | + ); |
| 829 | + } |
| 830 | + |
| 831 | + // --- list_tree -------------------------------------------------------------- |
| 832 | + |
| 833 | + #[tokio::test] |
| 834 | + async fn the_root_lists_every_top_level_entry() { |
| 835 | + let (_dir, query) = populated(); |
| 836 | + |
| 837 | + let entries = by_name( |
| 838 | + query |
| 839 | + .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root()) |
| 840 | + .await |
| 841 | + .expect("should read") |
| 842 | + .expect("the root is a directory"), |
| 843 | + ); |
| 844 | + |
| 845 | + let mut names: Vec<&str> = entries.keys().map(String::as_str).collect(); |
| 846 | + names.sort_unstable(); |
| 847 | + assert_eq!( |
| 848 | + names, |
| 849 | + vec![ |
| 850 | + "README.md", |
| 851 | + "big.txt", |
| 852 | + "bin.dat", |
| 853 | + "link", |
| 854 | + "src", |
| 855 | + "with space.txt" |
| 856 | + ] |
| 857 | + ); |
| 858 | + assert_eq!(entries["src"].kind, EntryKind::Tree); |
| 859 | + assert_eq!(entries["README.md"].kind, EntryKind::Blob); |
| 860 | + assert_eq!( |
| 861 | + entries["link"].kind, |
| 862 | + EntryKind::Symlink, |
| 863 | + "a symlink is its own kind, not a file" |
| 864 | + ); |
| 865 | + } |
| 866 | + |
| 867 | + #[tokio::test] |
| 868 | + async fn a_listing_carries_blob_sizes_but_not_tree_sizes() { |
| 869 | + let (_dir, query) = populated(); |
| 870 | + |
| 871 | + let entries = by_name( |
| 872 | + query |
| 873 | + .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root()) |
| 874 | + .await |
| 875 | + .expect("should read") |
| 876 | + .expect("the root is a directory"), |
| 877 | + ); |
| 878 | + |
| 879 | + assert_eq!(entries["big.txt"].size, Some(100)); |
| 880 | + assert_eq!( |
| 881 | + entries["src"].size, None, |
| 882 | + "a directory has no size a listing can show" |
| 883 | + ); |
| 884 | + } |
| 885 | + |
| 886 | + #[tokio::test] |
| 887 | + async fn a_filename_containing_a_space_survives_the_listing() { |
| 888 | + // The reason `-z` is not optional: split on whitespace and this name becomes two. |
| 889 | + let (_dir, query) = populated(); |
| 890 | + |
| 891 | + let entries = by_name( |
| 892 | + query |
| 893 | + .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root()) |
| 894 | + .await |
| 895 | + .expect("should read") |
| 896 | + .expect("the root is a directory"), |
| 897 | + ); |
| 898 | + |
| 899 | + assert_eq!(entries["with space.txt"].kind, EntryKind::Blob); |
| 900 | + assert_eq!(entries["with space.txt"].size, Some(7)); |
| 901 | + } |
| 902 | + |
| 903 | + #[tokio::test] |
| 904 | + async fn a_nested_directory_lists_only_its_own_entries() { |
| 905 | + let (_dir, query) = populated(); |
| 906 | + |
| 907 | + let entries = query |
| 908 | + .list_tree(&handle(), &repo_name(), &rev("main"), &path("src")) |
| 909 | + .await |
| 910 | + .expect("should read") |
| 911 | + .expect("src is a directory"); |
| 912 | + |
| 913 | + assert_eq!(entries.len(), 1); |
| 914 | + assert_eq!(entries[0].name, "deep", "names are entry names, not paths"); |
| 915 | + assert_eq!(entries[0].kind, EntryKind::Tree); |
| 916 | + |
| 917 | + let deeper = query |
| 918 | + .list_tree(&handle(), &repo_name(), &rev("main"), &path("src/deep")) |
| 919 | + .await |
| 920 | + .expect("should read") |
| 921 | + .expect("src/deep is a directory"); |
| 922 | + |
| 923 | + assert_eq!(deeper.len(), 1); |
| 924 | + assert_eq!(deeper[0].name, "file.rs"); |
| 925 | + } |
| 926 | + |
| 927 | + #[tokio::test] |
| 928 | + async fn listing_a_file_as_a_directory_finds_nothing() { |
| 929 | + // git calls this a fatal error; to a visitor it is a wrong URL. |
| 930 | + let (_dir, query) = populated(); |
| 931 | + |
| 932 | + assert_eq!( |
| 933 | + query |
| 934 | + .list_tree(&handle(), &repo_name(), &rev("main"), &path("README.md")) |
| 935 | + .await |
| 936 | + .expect("a file is not a failure"), |
| 937 | + None |
| 938 | + ); |
| 939 | + } |
| 940 | + |
| 941 | + #[tokio::test] |
| 942 | + async fn listing_a_path_that_is_not_there_finds_nothing() { |
| 943 | + let (_dir, query) = populated(); |
| 944 | + |
| 945 | + for missing in ["nope", "src/nope", "README.md/nope"] { |
| 946 | + assert_eq!( |
| 947 | + query |
| 948 | + .list_tree(&handle(), &repo_name(), &rev("main"), &path(missing)) |
| 949 | + .await |
| 950 | + .expect("should read"), |
| 951 | + None, |
| 952 | + "{missing} should not be found" |
| 953 | + ); |
| 954 | + } |
| 955 | + } |
| 956 | + |
| 957 | + #[tokio::test] |
| 958 | + async fn listing_at_an_unknown_revision_finds_nothing() { |
| 959 | + let (_dir, query) = populated(); |
| 960 | + |
| 961 | + assert_eq!( |
| 962 | + query |
| 963 | + .list_tree( |
| 964 | + &handle(), |
| 965 | + &repo_name(), |
| 966 | + &rev("no-such-branch"), |
| 967 | + &RepoPath::root() |
| 968 | + ) |
| 969 | + .await |
| 970 | + .expect("should read"), |
| 971 | + None |
| 972 | + ); |
| 973 | + } |
| 974 | + |
| 975 | + #[tokio::test] |
| 976 | + async fn a_listing_reflects_the_revision_it_was_asked_for() { |
| 977 | + // Proves the revision is actually used rather than HEAD being read every time. |
| 978 | + let (_dir, query) = populated(); |
| 979 | + |
| 980 | + let first = query |
| 981 | + .log(&handle(), &repo_name(), &rev("main"), 10) |
| 982 | + .await |
| 983 | + .expect("should read") |
| 984 | + .last() |
| 985 | + .expect("three commits") |
| 986 | + .id |
| 987 | + .clone(); |
| 988 | + |
| 989 | + let old = query |
| 990 | + .read_blob( |
| 991 | + &handle(), |
| 992 | + &repo_name(), |
| 993 | + &rev(first.as_str()), |
| 994 | + &path("README.md"), |
| 995 | + 1024, |
| 996 | + ) |
| 997 | + .await |
| 998 | + .expect("should read") |
| 999 | + .expect("README existed in the first commit"); |