Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333//! 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 `<knot>/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/<target>` 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 `<git_base>/<repo_did>`, /// 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.<bare>.pushInsteadOf = <git_base>/<repo_did>`, 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<String> { 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/<branch>` at whatever `<branch>` 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~<back>` 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~<back>`, 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")}