Skip to main content

max / makenotwork

14.6 KB · 338 lines History Blame Raw
1 //! Building the git command lines the engine runs, and reading their output.
2 //!
3 //! Command builders rather than command runners, which is what makes them
4 //! testable without a repository.
5
6 use std::path::{Path, PathBuf};
7
8 /// Refresh every remote's refs and tags, so the tag a release names is present
9 /// locally however it was pushed. No branch/upstream assumptions — a bare
10 /// `git pull --ff-only` needs a tracking branch the release path shouldn't
11 /// depend on.
12 ///
13 /// Best-effort on purpose. `fetch --all` exits non-zero if ANY remote fails, and
14 /// the library repos carry three (`astra`, `mnw`, `srht`), so chaining this into
15 /// the checkout with `&&` meant one unreachable mirror aborted the release and
16 /// reported it as a missing tag. The checkout below is the step allowed to fail;
17 /// this one only has to try. See [`git_worktree_pin_cmd`].
18 ///
19 /// `repo` is interpolated UNQUOTED so a leading `~` is expanded by the remote
20 /// host's shell (the checkout path is trusted topology config, not user input),
21 /// matching how the recipes `cd` into it.
22 pub fn git_fetch_cmd(repo: &str) -> String {
23 format!("git -C {repo} fetch --all --tags --prune")
24 }
25
26 /// Probe for the one failure a tracked `Cargo.lock` hits inside Bento's
27 /// worktree. Exits 0 when the crate tracks a lock AND the checkout sits under a
28 /// `.cargo/config.toml` declaring `[patch]`; 1 otherwise.
29 ///
30 /// Both halves are needed and cargo reports neither. Under a `[patch]` block
31 /// cargo re-resolves and rewrites the lock's `[[patch.unused]]` entries, so
32 /// `cargo publish --dry-run` refuses the tree with "1 files in the working
33 /// directory contain changes that were not yet committed into git: Cargo.lock"
34 /// -- naming the lock and nothing about why it moved. pter 0.2.1 lost half an
35 /// hour to that message on build 325.
36 ///
37 /// `~/Code/.bento` is under `~/Code` deliberately, so that the patch block
38 /// reaches the build (see [`crate::topology::Host::worktree_root`]). The patch
39 /// block is therefore not the half to remove, which is why this is worth
40 /// saying rather than leaving cargo to be cryptic about it.
41 ///
42 /// `repo` is interpolated unquoted for the `~`, matching [`git_fetch_cmd`];
43 /// `pwd -P` then hands the loop an absolute path to walk up from.
44 pub(super) fn tracked_lock_under_patch_cmd(repo: &str) -> String {
45 format!(
46 "git -C {repo} ls-files --error-unmatch Cargo.lock >/dev/null 2>&1 || exit 1; \
47 d=$(cd {repo} && pwd -P) || exit 1; \
48 while [ -n \"$d\" ] && [ \"$d\" != / ]; do \
49 for c in \"$d/.cargo/config.toml\" \"$d/.cargo/config\"; do \
50 [ -f \"$c\" ] && grep -q '^\\[patch' \"$c\" && exit 0; \
51 done; d=$(dirname \"$d\"); done; exit 1"
52 )
53 }
54
55 /// What to say when [`tracked_lock_under_patch_cmd`] answers yes. Names both
56 /// facts, because the error cargo would otherwise print names neither, and
57 /// closes off the two wrong fixes that are both one flag away.
58 pub(super) fn tracked_lock_under_patch_problem(repo: &str) -> String {
59 format!(
60 "`Cargo.lock` is tracked and {repo} sits under a `.cargo/config.toml` \
61 declaring `[patch]`. Cargo re-resolves there and rewrites the lock, so \
62 `cargo publish --dry-run` will refuse the tree as dirty and name only \
63 the lock. Untrack it: `git rm --cached Cargo.lock`. Every other library \
64 in this tree already does. Not `--allow-dirty`, which publishes a lock \
65 nobody reviewed, and not committing the rewritten lock, which resolves \
66 differently on the next machine and fails there instead. The build \
67 worktree is under `~/Code` on purpose so the patch block applies to it; \
68 that is not the half to change."
69 )
70 }
71
72 /// Does `tag` resolve to a commit in this checkout? Run only when the checkout
73 /// has already failed, to say WHY: an absent tag is an untagged or unpushed
74 /// release, while a tag that resolves fine means the checkout was refused for a
75 /// local reason (a dirty tree, most often) and the operator needs to hear that
76 /// instead.
77 pub fn git_tag_exists_cmd(repo: &str, tag: &str) -> String {
78 format!("git -C {repo} rev-parse -q --verify \"refs/tags/{tag}^{{commit}}\"")
79 }
80
81 /// Which repository `repo` belongs to, and where `repo` sits inside it, in one
82 /// call: `--show-toplevel` then `--show-prefix`, one per line.
83 ///
84 /// Both halves are needed to build in a worktree of a repo holding several
85 /// products. The worktree is made of the repository (`~/Code/MNW`), and the
86 /// recipe has to be pointed at the app inside it (`<worktree>/pom`).
87 pub fn git_toplevel_and_prefix_cmd(repo: &str) -> String {
88 format!("git -C {repo} rev-parse --show-toplevel --show-prefix")
89 }
90
91 /// Read [`git_toplevel_and_prefix_cmd`]'s two lines.
92 ///
93 /// The prefix is empty for a repo holding one product, where `repo` IS the
94 /// repository root — and git prints an empty second line for it, so a missing
95 /// line is a malformed answer rather than that case.
96 pub fn parse_toplevel_and_prefix(out: &str) -> Option<(String, String)> {
97 let mut lines = out.split('\n');
98 let toplevel = lines.next()?.trim().to_string();
99 let prefix = lines.next()?.trim().to_string();
100 (!toplevel.is_empty()).then_some((toplevel, prefix))
101 }
102
103 /// The repository's own directory name, which is what names its worktrees:
104 /// `MNW` for `/home/max/Code/MNW`.
105 ///
106 /// Splits on `/` only. Git reports `--show-toplevel` with forward slashes on
107 /// every platform, Windows included, so this is the separator to read.
108 pub fn repo_dir_name(toplevel: &str) -> &str {
109 toplevel
110 .trim_end_matches('/')
111 .rsplit('/')
112 .next()
113 .unwrap_or(toplevel)
114 }
115
116 /// Where the app being released sits inside its worktree: the worktree root for
117 /// a repo holding one product, `<worktree>/pom` for one holding several.
118 pub fn app_dir_in_worktree(worktree: &str, prefix: &str) -> String {
119 let prefix = prefix.trim_matches('/');
120 if prefix.is_empty() {
121 worktree.to_string()
122 } else {
123 format!("{}/{prefix}", worktree.trim_end_matches('/'))
124 }
125 }
126
127 /// Does this worktree already exist? Run before deciding whether to create one.
128 ///
129 /// `rev-parse --git-dir` rather than a shell test, because the one non-unix
130 /// build host has no `test`: every command Bento renders for a host is a git
131 /// command or something a recipe wrote.
132 pub fn git_worktree_probe_cmd(worktree: &str) -> String {
133 format!("git -C \"{worktree}\" rev-parse --git-dir")
134 }
135
136 /// Forget worktrees whose directories are gone. Run before creating one: a
137 /// directory somebody deleted by hand is still registered in the repository, and
138 /// `worktree add` refuses the path as in use rather than rebuilding it.
139 pub fn git_worktree_prune_cmd(toplevel: &str) -> String {
140 format!("git -C \"{toplevel}\" worktree prune")
141 }
142
143 /// Create this app's build worktree, detached at the release tag. Git creates
144 /// the leading directories, so the worktree root needs no preparation.
145 pub fn git_worktree_add_cmd(toplevel: &str, worktree: &str, tag: &str) -> String {
146 format!("git -C \"{toplevel}\" worktree add --detach --force \"{worktree}\" \"{tag}\"")
147 }
148
149 /// Put an existing build worktree at the release tag.
150 ///
151 /// `--force` discards whatever the last release left in it — a rewritten
152 /// `Cargo.lock`, most often — and that is safe here in a way it never was in the
153 /// ordinary checkout: nothing but Bento writes in this tree, so there is no edit
154 /// of anybody's to lose. Owning the tree is what buys the forcing.
155 pub fn git_worktree_pin_cmd(worktree: &str, tag: &str) -> String {
156 format!("git -C \"{worktree}\" checkout --detach --force \"{tag}\"")
157 }
158
159 /// The operator-facing explanation for a worktree that could not be put at the
160 /// tag.
161 ///
162 /// An absent tag is an untagged or unpushed release and is the common case, so
163 /// it is answered plainly. Anything else is git's own stderr, which says more
164 /// about a path that is not a worktree, or a worktree another release holds,
165 /// than a guess would.
166 pub fn worktree_failure_reason(tag: &str, tag_exists: bool, stderr: &str) -> String {
167 if !tag_exists {
168 return format!("tag {tag} does not exist there (is it created and pushed?)");
169 }
170 let stderr = stderr.trim();
171 if stderr.is_empty() {
172 format!("tag {tag} exists, and git said nothing about why")
173 } else {
174 stderr.to_string()
175 }
176 }
177
178 /// The command a host runs to report the commit it has checked out, for the
179 /// release preflight barrier.
180 pub fn git_rev_parse_cmd(repo: &str) -> String {
181 format!("git -C {repo} rev-parse HEAD")
182 }
183
184 /// Expand a leading `~/` to `$HOME`. Paths in the topology are written with `~`.
185 pub fn expand_tilde(p: &str) -> PathBuf {
186 if let Some(rest) = p.strip_prefix("~/")
187 && let Ok(home) = std::env::var("HOME")
188 {
189 return Path::new(&home).join(rest);
190 }
191 PathBuf::from(p)
192 }
193
194 #[cfg(test)]
195 mod tests {
196 use super::*;
197
198 /// Reads the ambient `HOME` rather than setting one. `set_var` is
199 /// process-global and unsynchronized, so a test that overwrote HOME changed
200 /// it for every other test in the binary — which is what silently disabled
201 /// `topology::live_config_smoke` (it skips when `$HOME/.config/bento` is
202 /// absent, and `/home/test` always is).
203 #[test]
204 fn expand_tilde_handles_home() {
205 let home = PathBuf::from(std::env::var("HOME").expect("HOME is set"));
206 assert_eq!(expand_tilde("~/Code/x"), home.join("Code/x"));
207 assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path"));
208 }
209
210 /// Both shapes of repo: one holding several products, and one holding a
211 /// single crate, where git prints an empty prefix line.
212 #[test]
213 fn toplevel_and_prefix_read_both_shapes_of_repo() {
214 let (top, prefix) =
215 parse_toplevel_and_prefix("/home/max/Code/MNW\npom/\n").expect("two lines");
216 assert_eq!(top, "/home/max/Code/MNW");
217 assert_eq!(prefix, "pom/");
218 // A repo holding one product: git prints an empty second line.
219 let (top, prefix) =
220 parse_toplevel_and_prefix("/home/max/Code/Libraries/pter\n\n").expect("two lines");
221 assert_eq!(top, "/home/max/Code/Libraries/pter");
222 assert_eq!(prefix, "");
223 assert!(
224 parse_toplevel_and_prefix("").is_none(),
225 "no answer is not an answer"
226 );
227 }
228
229 #[test]
230 fn repo_dir_name_is_the_last_segment_on_every_platform() {
231 assert_eq!(repo_dir_name("/home/max/Code/MNW"), "MNW");
232 assert_eq!(repo_dir_name("/home/max/Code/MNW/"), "MNW");
233 // Git reports forward slashes on Windows too.
234 assert_eq!(repo_dir_name("C:/Users/me/Code/Apps/goingson"), "goingson");
235 }
236
237 /// The app's directory inside its worktree, for both repo shapes.
238 #[test]
239 fn app_dir_in_worktree_follows_the_prefix() {
240 assert_eq!(
241 app_dir_in_worktree("/home/max/Code/.bento/MNW/pom", "pom/"),
242 "/home/max/Code/.bento/MNW/pom/pom"
243 );
244 assert_eq!(
245 app_dir_in_worktree("/home/max/Code/.bento/pter/pter", ""),
246 "/home/max/Code/.bento/pter/pter"
247 );
248 }
249
250 /// A missing tag is the common failure and gets a plain answer; anything
251 /// else is git's own stderr, which says more than a guess.
252 #[test]
253 fn worktree_failure_reason_names_the_tag_or_repeats_git() {
254 let missing = worktree_failure_reason("pom-v0.4.5", false, "irrelevant");
255 assert!(missing.contains("does not exist"), "{missing}");
256 let held = worktree_failure_reason(
257 "pom-v0.4.5",
258 true,
259 "fatal: '/home/max/Code/.bento/MNW/pom' already exists",
260 );
261 assert!(held.contains("already exists"), "{held}");
262 let silent = worktree_failure_reason("pom-v0.4.5", true, " ");
263 assert!(silent.contains("said nothing"), "{silent}");
264 }
265
266 /// Run the probe for real rather than asserting on its text: it is shell,
267 /// and the thing worth knowing is whether `sh` agrees, not whether the
268 /// string looks right.
269 fn probe(repo: &std::path::Path) -> bool {
270 std::process::Command::new("sh")
271 .arg("-c")
272 .arg(tracked_lock_under_patch_cmd(&repo.display().to_string()))
273 .status()
274 .unwrap()
275 .success()
276 }
277
278 /// A crate under a `[patch]` root, with and without its lock tracked.
279 ///
280 /// pter 0.2.1 is the case: the tracked half was true, the patch half was
281 /// true, and cargo reported only "Cargo.lock" (build 325). Untracking the
282 /// lock in `a9969a9` is what fixed it, and this asserts the probe agrees
283 /// with that fix in both directions.
284 #[test]
285 fn a_tracked_lock_is_only_a_problem_under_a_patch_block() {
286 let root = tempfile::tempdir().unwrap();
287 let repo = root.path().join("crate");
288 std::fs::create_dir_all(&repo).unwrap();
289 let git = |args: &[&str]| {
290 std::process::Command::new("git")
291 .args(args)
292 .current_dir(&repo)
293 .env("GIT_AUTHOR_NAME", "t")
294 .env("GIT_AUTHOR_EMAIL", "t@t")
295 .env("GIT_COMMITTER_NAME", "t")
296 .env("GIT_COMMITTER_EMAIL", "t@t")
297 .output()
298 .unwrap()
299 };
300 git(&["init", "-q", "."]);
301 std::fs::write(repo.join("Cargo.lock"), "# lock\n").unwrap();
302
303 // Lock present but untracked, no patch anywhere: nothing to say.
304 assert!(!probe(&repo));
305
306 // Tracked, still no patch root. Every library that commits a lock and
307 // builds outside `~/Code` lives here, and it publishes fine.
308 git(&["add", "Cargo.lock"]);
309 git(&["commit", "-qm", "lock"]);
310 assert!(!probe(&repo));
311
312 // The ancestor declares `[patch]`. Both halves now hold.
313 std::fs::create_dir_all(root.path().join(".cargo")).unwrap();
314 std::fs::write(
315 root.path().join(".cargo/config.toml"),
316 "[patch.\"https://makenot.work/git/max/docengine.git\"]\ndocengine = { path = \"x\" }\n",
317 )
318 .unwrap();
319 assert!(probe(&repo));
320
321 // Untracking the lock is the fix, and the probe has to agree that it is.
322 git(&["rm", "-q", "--cached", "Cargo.lock"]);
323 assert!(!probe(&repo));
324 }
325
326 /// The message exists because cargo's names neither fact and both wrong
327 /// fixes are one flag away. Assert it still says all four things.
328 #[test]
329 fn the_tracked_lock_message_names_both_facts_and_refuses_both_wrong_fixes() {
330 let msg = tracked_lock_under_patch_problem("~/Code/.bento/pter/pter");
331 assert!(msg.contains("Cargo.lock"), "{msg}");
332 assert!(msg.contains("[patch]"), "{msg}");
333 assert!(msg.contains("git rm --cached"), "{msg}");
334 assert!(msg.contains("--allow-dirty"), "{msg}");
335 assert!(msg.contains("~/Code/.bento/pter/pter"), "{msg}");
336 }
337 }
338