//! The checkout a command runs in. //! //! Every stack and PR write reads the branch it is standing on — the commits, //! their messages, their change-ids — so a scenario needs a real repo with //! real commits, not a stub. This builds one in a temp directory, with an //! `origin` whose URL names the repo's DID directly. //! //! **That URL is doing real work.** `clients/tangled/resolve.rs` answers //! "which repo is this remote?" from the path when the path holds a DID that //! stands alone, and only falls back to following clone redirects when it //! does not. So `/did:plc:…` resolves with no request at all, which is //! both the shape a `repo clone` leaves behind and the reason a scenario //! needs no git transport to *read* the remote. Pushing to it is a separate //! arrangement, and [`Checkout::new`] describes it. //! //! It still points at the mock knot rather than at an invented hostname, //! because a few commands do reach for the remote itself: `pr resubmit` asks //! `git ls-remote` whether its target branch is still there before building //! a round. Against a name that does not resolve that is a DNS lookup //! leaving the machine, which a hermetic suite may not do; against the mock //! it is a loopback request that 404s, which `remote_branch_exists` reports //! as "could not ask" — the same answer, arrived at without a packet //! leaving the host. //! //! The remote-tracking ref is created with `update-ref` rather than fetched. //! A `stack create` needs `origin/` to exist and will run `git fetch` //! if it does not; pointing the ref at a local commit gives the commands the //! base they ask for without a network transport being involved at all. //! //! Nothing here moves the process. `testutil::TempRepo` does, and has to, //! because the code it tests shells out to git in the current directory — //! but these tests spawn `atgc` as a child, so the child's directory is set //! per command and the parent never leaves where it started. use std::path::{Path, PathBuf}; use std::process::Command; pub struct Checkout { pub path: PathBuf, /// The bare repo a push to `origin` actually lands in. See /// [`Checkout::new`]. pub bare: PathBuf, } impl Checkout { /// A repo on `main` with one commit, an `origin` at `/`, /// and `origin/main` pointing at that commit. /// /// **Pushes land in a bare repo beside it.** `pr create` publishes the /// branch before it records `source: {branch}`, and a push is a real git /// transport that the mock knot — an HTTP server that answers XRPC — has /// no way to serve. Rather than teach it `git-receive-pack`, the checkout /// carries `url..pushInsteadOf = /`, so git /// rewrites the push target to a local bare repo and the branch really is /// published, on the loopback-free side of the machine. /// /// `pushInsteadOf` and not `insteadOf`: the fetch URL must stay the knot /// URL, because `git remote get-url` *does* apply `insteadOf` and /// `resolve::repo_ref` reads the repo's DID straight out of that path. /// Rewriting both would leave the command with no repo to resolve. pub fn new(path: PathBuf, git_base: &str, repo_did: &str) -> Self { let _ = std::fs::remove_dir_all(&path); std::fs::create_dir_all(&path).expect("checkout dir"); let bare = path.with_file_name("origin.git"); let _ = std::fs::remove_dir_all(&bare); run_git( Path::new("."), &["init", "-q", "--bare", &bare.to_string_lossy()], ); let checkout = Checkout { path, bare }; checkout.git(&["init", "-q", "-b", "main"]); checkout.commit( "README.md", "the base commit\n", "chore: initial commit", None, ); let remote = format!("{git_base}/{repo_did}"); checkout.git(&["remote", "add", "origin", &remote]); checkout.git(&[ "config", &format!("url.{}.pushInsteadOf", checkout.bare.to_string_lossy()), &remote, ]); checkout.sync_remote("main"); checkout } /// Make the knot refuse every push, the way a knot with no access for /// this account does. /// /// A `pre-receive` hook rather than an unwritable directory: a knot /// refuses at the protocol layer and git reports that differently from a /// filesystem it cannot open, and the difference is what the command /// under test reads. This is also the only knot failure that happens /// before any record exists, so what it leaves behind is a branch and /// nothing else. pub fn refuse_pushes(&self) { let hooks = self.bare.join("hooks"); std::fs::create_dir_all(&hooks).expect("hooks dir"); let hook = hooks.join("pre-receive"); std::fs::write( &hook, "#!/bin/sh\necho 'knot: this account may not push here' >&2\nexit 1\n", ) .expect("write the hook"); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)) .expect("make the hook executable"); } } /// The commit `branch` stands at in the bare repo pushes land in, or /// `None` when nothing has published that branch. /// /// This is the whole evidence that a push happened: the pull record says /// `source: {branch}`, and the only thing that makes that a true /// statement rather than a claim is a ref on the other side. pub fn pushed_head(&self, branch: &str) -> Option { let out = Command::new("git") .arg("--git-dir") .arg(&self.bare) .args(["rev-parse", "--verify", "--quiet"]) .arg(format!("refs/heads/{branch}")) .output() .expect("git should be on PATH"); let sha = String::from_utf8_lossy(&out.stdout).trim().to_string(); (!sha.is_empty()).then_some(sha) } /// Move `branch` in the bare repo behind somebody else's back. /// /// What a collaborator's push, or a resubmit from tangled.org, looks /// like from this checkout: the branch on the other side is somewhere /// this checkout never put it, and the lease a round pushes with is the /// only thing standing between that commit and being overwritten. pub fn publish_elsewhere(&self, branch: &str, sha: &str) { // Straight at the bare repo's path, and forced: this is standing in // for a push atgc did not make, so it neither goes through the // remote's rewrite rules nor cares what the branch held before. self.git(&[ "push", "-q", "--force", &self.bare.to_string_lossy(), &format!("{sha}:refs/heads/{branch}"), ]); } /// Put an uncommitted file in the working tree. /// /// For the image tests: `cmd/images.rs` resolves a body's relative paths /// against the working directory and then the repo root, and explicitly /// does *not* require the file to be committed anywhere — "this file, as /// it is on my disk right now" is the whole meaning of a local path in a /// body. Leaving it uncommitted is therefore the case worth driving. pub fn write(&self, name: &str, bytes: &[u8]) { let path = self.path.join(name); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).expect("parent dir"); } std::fs::write(&path, bytes).expect("write file"); } /// Point `origin/` at whatever `` is now — what a push /// followed by a fetch would leave behind, with no transport involved. pub fn sync_remote(&self, branch: &str) { let sha = self.git(&["rev-parse", branch]); self.git(&[ "update-ref", &format!("refs/remotes/origin/{branch}"), sha.trim(), ]); } /// Commit a file. `change_id` adds the trailer `stack create` matches /// commits to pull records by; `None` leaves the commit without one, so /// a test can exercise the refusal and `--add-change-ids`. pub fn commit(&self, file: &str, body: &str, subject: &str, change_id: Option<&str>) -> String { std::fs::write(self.path.join(file), body).expect("write a file"); self.git(&["add", file]); let message = match change_id { Some(id) => format!("{subject}\n\nChange-Id: {id}\n"), None => subject.to_string(), }; self.git(&["commit", "-q", "-m", &message]); self.head() } pub fn head(&self) -> String { self.git(&["rev-parse", "HEAD"]).trim().to_string() } pub fn branch(&self, name: &str) { self.git(&["checkout", "-q", "-b", name]); } /// Rewrite the message of the commit at `HEAD~` and everything /// above it, the way an interactive rebase would. Used to make a /// stack's middle member "change" without changing its change-id. pub fn amend_file(&self, file: &str, body: &str) { std::fs::write(self.path.join(file), body).expect("write a file"); self.git(&["add", file]); self.git(&["commit", "-q", "--amend", "--no-edit"]); } /// Rewrite `HEAD`'s whole commit message, as `git commit --amend` with /// an editor would. The message a stacked pull's description comes /// from, so this is how a test changes one without touching a file. pub fn amend_message(&self, message: &str) { self.git(&["commit", "-q", "--amend", "-m", message]); } /// Swap the top two commits, keeping both change-ids. pub fn swap_top_two(&self) { // A rebase would need an editor; cherry-picking onto HEAD~2 in the // other order is the same result with no interactive step. let top = self.git(&["rev-parse", "HEAD"]).trim().to_string(); let below = self.git(&["rev-parse", "HEAD~1"]).trim().to_string(); self.git(&["reset", "-q", "--hard", "HEAD~2"]); self.git(&["cherry-pick", &top]); self.git(&["cherry-pick", &below]); } /// Amend the commit `back` places below `HEAD`, keeping every commit /// above it — the effect of an interactive rebase's `edit`, without an /// editor. /// /// `rebase --onto` rather than a reset-and-replay, because it preserves /// the author dates of the commits above. That matters: a patch carries /// its author date, so replaying them by hand would change their bytes /// for a reason a real rebase never does, and a test built on it would /// be asserting the harness rather than the tool. pub fn amend_below(&self, back: usize, file: &str, body: &str) { let branch = self.git(&["rev-parse", "--abbrev-ref", "HEAD"]); let branch = branch.trim().to_string(); let old = self.git(&["rev-parse", &format!("HEAD~{back}")]); let old = old.trim().to_string(); self.git(&["checkout", "-q", &old]); std::fs::write(self.path.join(file), body).expect("write a file"); self.git(&["add", file]); self.git(&["commit", "-q", "--amend", "--no-edit"]); let new = self.git(&["rev-parse", "HEAD"]); let new = new.trim().to_string(); self.git(&["rebase", "-q", "--onto", &new, &old, &branch]); } /// Add a new commit directly above `HEAD~`, keeping every commit /// above it — an interactive rebase's `edit` followed by a fresh commit. /// A member of several commits is only really tested by writing into the /// middle of one. pub fn insert_below( &self, back: usize, file: &str, body: &str, subject: &str, change_id: Option<&str>, ) { let branch = self.git(&["rev-parse", "--abbrev-ref", "HEAD"]); let branch = branch.trim().to_string(); let below = self.git(&["rev-parse", &format!("HEAD~{back}")]); let below = below.trim().to_string(); self.git(&["checkout", "-q", &below]); let new = self.commit(file, body, subject, change_id); self.git(&["rebase", "-q", "--onto", &new, &below, &branch]); } /// Point a local branch at a commit, leaving HEAD where it is: the mark /// a stack is cut at. pub fn mark(&self, name: &str, rev: &str) { self.git(&["branch", "-f", name, rev]); } pub fn drop_top(&self) { self.git(&["reset", "-q", "--hard", "HEAD~1"]); } /// Drop the commit `back` places below `HEAD`, keeping every commit /// above it — an interactive rebase's `drop`, without an editor. /// /// The middle of a stack is where dropping a commit is interesting: the /// member above it has to be relinked onto the member below, which /// dropping the top never exercises. `rebase --onto` for the same reason /// [`Checkout::amend_below`] uses it — the commits above keep their /// author dates, and so their patch bytes. pub fn drop_below(&self, back: usize) { let branch = self.git(&["rev-parse", "--abbrev-ref", "HEAD"]); let branch = branch.trim().to_string(); let onto = self.git(&["rev-parse", &format!("HEAD~{}", back + 1)]); let onto = onto.trim().to_string(); let dropped = self.git(&["rev-parse", &format!("HEAD~{back}")]); let dropped = dropped.trim().to_string(); self.git(&["rebase", "-q", "--onto", &onto, &dropped, &branch]); } pub fn git(&self, args: &[&str]) -> String { run_git(&self.path, args) } } impl Drop for Checkout { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.path); let _ = std::fs::remove_dir_all(&self.bare); } } /// Git against an explicit directory, with identity and signing pinned on /// the command line so the machine running the tests cannot decide whether a /// commit succeeds. The same reasoning, and the same flags, as /// `src/testutil.rs`. fn run_git(dir: &Path, args: &[&str]) -> String { let out = Command::new("git") .arg("-C") .arg(dir) .args(["-c", "user.name=atgc tests"]) .args(["-c", "user.email=tests@example.invalid"]) .args(["-c", "commit.gpgsign=false"]) .args(["-c", "tag.gpgsign=false"]) .args(args) .output() .expect("git should be on PATH"); assert!( out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr) ); String::from_utf8(out.stdout).expect("utf-8") }