//! Building the git command lines the engine runs, and reading their output. //! //! Command builders rather than command runners, which is what makes them //! testable without a repository. use std::path::{Path, PathBuf}; /// Refresh every remote's refs and tags, so the tag a release names is present /// locally however it was pushed. No branch/upstream assumptions — a bare /// `git pull --ff-only` needs a tracking branch the release path shouldn't /// depend on. /// /// Best-effort on purpose. `fetch --all` exits non-zero if ANY remote fails, and /// the library repos carry three (`astra`, `mnw`, `srht`), so chaining this into /// the checkout with `&&` meant one unreachable mirror aborted the release and /// reported it as a missing tag. The checkout below is the step allowed to fail; /// this one only has to try. See [`git_worktree_pin_cmd`]. /// /// `repo` is interpolated UNQUOTED so a leading `~` is expanded by the remote /// host's shell (the checkout path is trusted topology config, not user input), /// matching how the recipes `cd` into it. pub fn git_fetch_cmd(repo: &str) -> String { format!("git -C {repo} fetch --all --tags --prune") } /// Probe for the one failure a tracked `Cargo.lock` hits inside Bento's /// worktree. Exits 0 when the crate tracks a lock AND the checkout sits under a /// `.cargo/config.toml` declaring `[patch]`; 1 otherwise. /// /// Both halves are needed and cargo reports neither. Under a `[patch]` block /// cargo re-resolves and rewrites the lock's `[[patch.unused]]` entries, so /// `cargo publish --dry-run` refuses the tree with "1 files in the working /// directory contain changes that were not yet committed into git: Cargo.lock" /// -- naming the lock and nothing about why it moved. pter 0.2.1 lost half an /// hour to that message on build 325. /// /// `~/Code/.bento` is under `~/Code` deliberately, so that the patch block /// reaches the build (see [`crate::topology::Host::worktree_root`]). The patch /// block is therefore not the half to remove, which is why this is worth /// saying rather than leaving cargo to be cryptic about it. /// /// `repo` is interpolated unquoted for the `~`, matching [`git_fetch_cmd`]; /// `pwd -P` then hands the loop an absolute path to walk up from. pub(super) fn tracked_lock_under_patch_cmd(repo: &str) -> String { format!( "git -C {repo} ls-files --error-unmatch Cargo.lock >/dev/null 2>&1 || exit 1; \ d=$(cd {repo} && pwd -P) || exit 1; \ while [ -n \"$d\" ] && [ \"$d\" != / ]; do \ for c in \"$d/.cargo/config.toml\" \"$d/.cargo/config\"; do \ [ -f \"$c\" ] && grep -q '^\\[patch' \"$c\" && exit 0; \ done; d=$(dirname \"$d\"); done; exit 1" ) } /// What to say when [`tracked_lock_under_patch_cmd`] answers yes. Names both /// facts, because the error cargo would otherwise print names neither, and /// closes off the two wrong fixes that are both one flag away. pub(super) fn tracked_lock_under_patch_problem(repo: &str) -> String { format!( "`Cargo.lock` is tracked and {repo} sits under a `.cargo/config.toml` \ declaring `[patch]`. Cargo re-resolves there and rewrites the lock, so \ `cargo publish --dry-run` will refuse the tree as dirty and name only \ the lock. Untrack it: `git rm --cached Cargo.lock`. Every other library \ in this tree already does. Not `--allow-dirty`, which publishes a lock \ nobody reviewed, and not committing the rewritten lock, which resolves \ differently on the next machine and fails there instead. The build \ worktree is under `~/Code` on purpose so the patch block applies to it; \ that is not the half to change." ) } /// Does `tag` resolve to a commit in this checkout? Run only when the checkout /// has already failed, to say WHY: an absent tag is an untagged or unpushed /// release, while a tag that resolves fine means the checkout was refused for a /// local reason (a dirty tree, most often) and the operator needs to hear that /// instead. pub fn git_tag_exists_cmd(repo: &str, tag: &str) -> String { format!("git -C {repo} rev-parse -q --verify \"refs/tags/{tag}^{{commit}}\"") } /// Which repository `repo` belongs to, and where `repo` sits inside it, in one /// call: `--show-toplevel` then `--show-prefix`, one per line. /// /// Both halves are needed to build in a worktree of a repo holding several /// products. The worktree is made of the repository (`~/Code/MNW`), and the /// recipe has to be pointed at the app inside it (`/pom`). pub fn git_toplevel_and_prefix_cmd(repo: &str) -> String { format!("git -C {repo} rev-parse --show-toplevel --show-prefix") } /// Read [`git_toplevel_and_prefix_cmd`]'s two lines. /// /// The prefix is empty for a repo holding one product, where `repo` IS the /// repository root — and git prints an empty second line for it, so a missing /// line is a malformed answer rather than that case. pub fn parse_toplevel_and_prefix(out: &str) -> Option<(String, String)> { let mut lines = out.split('\n'); let toplevel = lines.next()?.trim().to_string(); let prefix = lines.next()?.trim().to_string(); (!toplevel.is_empty()).then_some((toplevel, prefix)) } /// The repository's own directory name, which is what names its worktrees: /// `MNW` for `/home/max/Code/MNW`. /// /// Splits on `/` only. Git reports `--show-toplevel` with forward slashes on /// every platform, Windows included, so this is the separator to read. pub fn repo_dir_name(toplevel: &str) -> &str { toplevel .trim_end_matches('/') .rsplit('/') .next() .unwrap_or(toplevel) } /// Where the app being released sits inside its worktree: the worktree root for /// a repo holding one product, `/pom` for one holding several. pub fn app_dir_in_worktree(worktree: &str, prefix: &str) -> String { let prefix = prefix.trim_matches('/'); if prefix.is_empty() { worktree.to_string() } else { format!("{}/{prefix}", worktree.trim_end_matches('/')) } } /// Does this worktree already exist? Run before deciding whether to create one. /// /// `rev-parse --git-dir` rather than a shell test, because the one non-unix /// build host has no `test`: every command Bento renders for a host is a git /// command or something a recipe wrote. pub fn git_worktree_probe_cmd(worktree: &str) -> String { format!("git -C \"{worktree}\" rev-parse --git-dir") } /// Forget worktrees whose directories are gone. Run before creating one: a /// directory somebody deleted by hand is still registered in the repository, and /// `worktree add` refuses the path as in use rather than rebuilding it. pub fn git_worktree_prune_cmd(toplevel: &str) -> String { format!("git -C \"{toplevel}\" worktree prune") } /// Create this app's build worktree, detached at the release tag. Git creates /// the leading directories, so the worktree root needs no preparation. pub fn git_worktree_add_cmd(toplevel: &str, worktree: &str, tag: &str) -> String { format!("git -C \"{toplevel}\" worktree add --detach --force \"{worktree}\" \"{tag}\"") } /// Put an existing build worktree at the release tag. /// /// `--force` discards whatever the last release left in it — a rewritten /// `Cargo.lock`, most often — and that is safe here in a way it never was in the /// ordinary checkout: nothing but Bento writes in this tree, so there is no edit /// of anybody's to lose. Owning the tree is what buys the forcing. pub fn git_worktree_pin_cmd(worktree: &str, tag: &str) -> String { format!("git -C \"{worktree}\" checkout --detach --force \"{tag}\"") } /// The operator-facing explanation for a worktree that could not be put at the /// tag. /// /// An absent tag is an untagged or unpushed release and is the common case, so /// it is answered plainly. Anything else is git's own stderr, which says more /// about a path that is not a worktree, or a worktree another release holds, /// than a guess would. pub fn worktree_failure_reason(tag: &str, tag_exists: bool, stderr: &str) -> String { if !tag_exists { return format!("tag {tag} does not exist there (is it created and pushed?)"); } let stderr = stderr.trim(); if stderr.is_empty() { format!("tag {tag} exists, and git said nothing about why") } else { stderr.to_string() } } /// The command a host runs to report the commit it has checked out, for the /// release preflight barrier. pub fn git_rev_parse_cmd(repo: &str) -> String { format!("git -C {repo} rev-parse HEAD") } /// Expand a leading `~/` to `$HOME`. Paths in the topology are written with `~`. pub fn expand_tilde(p: &str) -> PathBuf { if let Some(rest) = p.strip_prefix("~/") && let Ok(home) = std::env::var("HOME") { return Path::new(&home).join(rest); } PathBuf::from(p) } #[cfg(test)] mod tests { use super::*; /// Reads the ambient `HOME` rather than setting one. `set_var` is /// process-global and unsynchronized, so a test that overwrote HOME changed /// it for every other test in the binary — which is what silently disabled /// `topology::live_config_smoke` (it skips when `$HOME/.config/bento` is /// absent, and `/home/test` always is). #[test] fn expand_tilde_handles_home() { let home = PathBuf::from(std::env::var("HOME").expect("HOME is set")); assert_eq!(expand_tilde("~/Code/x"), home.join("Code/x")); assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path")); } /// Both shapes of repo: one holding several products, and one holding a /// single crate, where git prints an empty prefix line. #[test] fn toplevel_and_prefix_read_both_shapes_of_repo() { let (top, prefix) = parse_toplevel_and_prefix("/home/max/Code/MNW\npom/\n").expect("two lines"); assert_eq!(top, "/home/max/Code/MNW"); assert_eq!(prefix, "pom/"); // A repo holding one product: git prints an empty second line. let (top, prefix) = parse_toplevel_and_prefix("/home/max/Code/Libraries/pter\n\n").expect("two lines"); assert_eq!(top, "/home/max/Code/Libraries/pter"); assert_eq!(prefix, ""); assert!( parse_toplevel_and_prefix("").is_none(), "no answer is not an answer" ); } #[test] fn repo_dir_name_is_the_last_segment_on_every_platform() { assert_eq!(repo_dir_name("/home/max/Code/MNW"), "MNW"); assert_eq!(repo_dir_name("/home/max/Code/MNW/"), "MNW"); // Git reports forward slashes on Windows too. assert_eq!(repo_dir_name("C:/Users/me/Code/Apps/goingson"), "goingson"); } /// The app's directory inside its worktree, for both repo shapes. #[test] fn app_dir_in_worktree_follows_the_prefix() { assert_eq!( app_dir_in_worktree("/home/max/Code/.bento/MNW/pom", "pom/"), "/home/max/Code/.bento/MNW/pom/pom" ); assert_eq!( app_dir_in_worktree("/home/max/Code/.bento/pter/pter", ""), "/home/max/Code/.bento/pter/pter" ); } /// A missing tag is the common failure and gets a plain answer; anything /// else is git's own stderr, which says more than a guess. #[test] fn worktree_failure_reason_names_the_tag_or_repeats_git() { let missing = worktree_failure_reason("pom-v0.4.5", false, "irrelevant"); assert!(missing.contains("does not exist"), "{missing}"); let held = worktree_failure_reason( "pom-v0.4.5", true, "fatal: '/home/max/Code/.bento/MNW/pom' already exists", ); assert!(held.contains("already exists"), "{held}"); let silent = worktree_failure_reason("pom-v0.4.5", true, " "); assert!(silent.contains("said nothing"), "{silent}"); } /// Run the probe for real rather than asserting on its text: it is shell, /// and the thing worth knowing is whether `sh` agrees, not whether the /// string looks right. fn probe(repo: &std::path::Path) -> bool { std::process::Command::new("sh") .arg("-c") .arg(tracked_lock_under_patch_cmd(&repo.display().to_string())) .status() .unwrap() .success() } /// A crate under a `[patch]` root, with and without its lock tracked. /// /// pter 0.2.1 is the case: the tracked half was true, the patch half was /// true, and cargo reported only "Cargo.lock" (build 325). Untracking the /// lock in `a9969a9` is what fixed it, and this asserts the probe agrees /// with that fix in both directions. #[test] fn a_tracked_lock_is_only_a_problem_under_a_patch_block() { let root = tempfile::tempdir().unwrap(); let repo = root.path().join("crate"); std::fs::create_dir_all(&repo).unwrap(); let git = |args: &[&str]| { std::process::Command::new("git") .args(args) .current_dir(&repo) .env("GIT_AUTHOR_NAME", "t") .env("GIT_AUTHOR_EMAIL", "t@t") .env("GIT_COMMITTER_NAME", "t") .env("GIT_COMMITTER_EMAIL", "t@t") .output() .unwrap() }; git(&["init", "-q", "."]); std::fs::write(repo.join("Cargo.lock"), "# lock\n").unwrap(); // Lock present but untracked, no patch anywhere: nothing to say. assert!(!probe(&repo)); // Tracked, still no patch root. Every library that commits a lock and // builds outside `~/Code` lives here, and it publishes fine. git(&["add", "Cargo.lock"]); git(&["commit", "-qm", "lock"]); assert!(!probe(&repo)); // The ancestor declares `[patch]`. Both halves now hold. std::fs::create_dir_all(root.path().join(".cargo")).unwrap(); std::fs::write( root.path().join(".cargo/config.toml"), "[patch.\"https://makenot.work/git/max/docengine.git\"]\ndocengine = { path = \"x\" }\n", ) .unwrap(); assert!(probe(&repo)); // Untracking the lock is the fix, and the probe has to agree that it is. git(&["rm", "-q", "--cached", "Cargo.lock"]); assert!(!probe(&repo)); } /// The message exists because cargo's names neither fact and both wrong /// fixes are one flag away. Assert it still says all four things. #[test] fn the_tracked_lock_message_names_both_facts_and_refuses_both_wrong_fixes() { let msg = tracked_lock_under_patch_problem("~/Code/.bento/pter/pter"); assert!(msg.contains("Cargo.lock"), "{msg}"); assert!(msg.contains("[patch]"), "{msg}"); assert!(msg.contains("git rm --cached"), "{msg}"); assert!(msg.contains("--allow-dirty"), "{msg}"); assert!(msg.contains("~/Code/.bento/pter/pter"), "{msg}"); } }