Skip to main content

max / makenotwork

Give Bento a per-host repo path `[app.x] repo` was one path for every host, which is a unix assumption. The Windows checkout is at C:/Users/me/Code/Apps/<app>, so the three windows.rhai recipes hard-coded it and never called repo(), which also locked them out of checkout_sha(h), since that builds its git commands from the app's one path. pin_release had the same bug and ran `git -C ~/Code/Apps/<app>` on windows-x86, so a release including windows failed on the first git command of the preflight. Adds `repo_by_host` beside `repo`, resolved through AppConfig::repo_for(host) and RecipeCtx::repo_for(host). Everything that runs on a build host goes through it: checkout_sha, repo(), pin_release, restore_branches. The daemon-local reads (version, recipe dir, crate_preflight's cargo metadata) keep the bare repo and say why. repo() answers for ctx.build_host rather than taking an argument, so no recipe changed shape. A key naming an undeclared host is refused at load, since the silent failure is the lookup missing and every host getting the unix path. The live-recipe smoke test now compiles every .rhai in a recipe dir rather than only the ones the app's current targets name, so a recipe for a temporarily unshipped target still has to parse. Restoring windows/x86_64 to the app manifests stays out: it is gated on the windows-x86 host being real, and collect is rsync against a host with no rsync.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-07 18:01 UTC
Signed with PGP, not checked
Commit: 6efdb0a814ba03af391256cf1f1b9f476299d8c0
Parent: d347305
6 files changed, +285 insertions, -75 deletions
@@ -3508,6 +3508,18 @@
3508 3508 source = "registry+https://github.com/rust-lang/crates.io-index"
3509 3509 checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
3510 3510
3511 + [[patch.unused]]
3512 + name = "synckit-client"
3513 + version = "0.7.0"
3514 +
3515 + [[patch.unused]]
3516 + name = "synckit-config"
3517 + version = "0.1.2"
3518 +
3519 + [[patch.unused]]
3520 + name = "docengine"
3521 + version = "0.4.0"
3522 +
3511 3523 [[patch.unused]]
3512 3524 name = "kberg"
3513 3525 version = "0.1.0"
@@ -3519,15 +3531,3 @@
3519 3531 [[patch.unused]]
3520 3532 name = "tagtree"
3521 3533 version = "0.4.0"
3522 -
3523 - [[patch.unused]]
3524 - name = "docengine"
3525 - version = "0.4.0"
3526 -
3527 - [[patch.unused]]
3528 - name = "synckit-client"
3529 - version = "0.7.0"
3530 -
3531 - [[patch.unused]]
3532 - name = "synckit-config"
3533 - version = "0.1.2"
@@ -15,7 +15,7 @@
15 15 |------|---------------|---------|
16 16 | `bentod.service` | `~/.config/systemd/user/` | The user service unit. |
17 17 | `bento-daemon.toml.example` | `~/.config/bento/bento-daemon.toml` | Daemon-local config (paths + listen). Absolute paths only — no shell expansion. |
18 - | `bento.toml.example` | `~/.config/bento/bento.toml` | Build topology (hosts + apps). `repo` paths are tilde-expanded. |
18 + | `bento.toml.example` | `~/.config/bento/bento.toml` | Build topology (hosts + apps). `repo` paths are tilde-expanded; `repo_by_host` overrides the path for one host. |
19 19 | (none, hand-written 0600) | `~/.config/bento/bento.env` | `BENTO_API_TOKEN=...` for a tailnet bind. Read by the unit's `EnvironmentFile=-`, so a loopback install can leave it absent. No example file ships: it holds a secret. |
20 20
21 21 ## Stand-up (no root except enable-linger)
@@ -189,7 +189,7 @@
189 189 |----------|---------|
190 190 | `version()` | the version being built (e.g. `"0.4.2"`) |
191 191 | `build_host()` | the host this target builds on (e.g. `"fw13"`) |
192 - | `repo()` | the app's checkout path (`~`-prefixed; `cd` into it — commands don't auto-cd) |
192 + | `repo()` | the app's checkout path on this target's build host (`~`-prefixed on unix; `cd` into it, commands don't auto-cd) |
193 193 | `target()` / `platform()` / `arch()` | `"linux/x86_64"` / `"linux"` / `"x86_64"` |
194 194
195 195 Non-Tauri apps (audiofiles) set `version_path` in the topology to the crate
@@ -32,12 +32,20 @@
32 32 targets = ["windows/x86_64"]
33 33 # transport defaults to "ssh"; actuate defaults to ["build", "package"]
34 34
35 + # `repo` is where the checkout lives on every host that does not say otherwise.
36 + # `repo_by_host` overrides it for one host, which is how a Windows checkout at a
37 + # Windows path stays addressable: without it a recipe has to hard-code the path,
38 + # and a hard-coded path cannot use repo() or checkout_sha(). Keys must name a
39 + # declared [[host]].
35 40 [app.goingson]
36 41 repo = "~/Code/Apps/goingson"
42 + repo_by_host = { windows-x86 = "C:/Users/me/Code/Apps/goingson" }
37 43 [app.balanced_breakfast]
38 44 repo = "~/Code/Apps/balanced_breakfast"
45 + repo_by_host = { windows-x86 = "C:/Users/me/Code/Apps/balanced_breakfast" }
39 46 [app.audiofiles]
40 47 repo = "~/Code/Apps/audiofiles"
48 + repo_by_host = { windows-x86 = "C:/Users/me/Code/Apps/audiofiles" }
41 49
42 50 # Library crates published to crates.io. Same two-file split as the apps: the
43 51 # pointer lives here, `kind = "library"` and the rest in each repo's bento.toml.
@@ -159,10 +159,14 @@
159 159 /// products spells it per product (`pom-v0.4.1`), and the recipe, the
160 160 /// preflight barrier and the failure message all have to agree on it.
161 161 pub tag: String,
162 - /// The app's checkout path (topology `repo`, `~`-prefixed). Recipes read it
163 - /// via `repo()` to `cd` into the checkout — commands don't auto-cd, and each
164 - /// `sh` is a fresh shell.
162 + /// The app's default checkout path (topology `repo`, `~`-prefixed). Read it
163 + /// through [`RecipeCtx::repo_for`] for anything that runs on a build host;
164 + /// this field alone is the daemon-local answer.
165 165 pub repo: String,
166 + /// Per-host checkout overrides (topology `repo_by_host`). Empty for every app
167 + /// that has not declared one, which is all of them but the Windows-shipping
168 + /// ones.
169 + pub repo_by_host: HashMap<String, String>,
166 170 /// Cargo features this app's release builds enable (topology `features`).
167 171 /// Recipes read it via `feature_flags()`.
168 172 pub features: Vec<String>,
@@ -282,6 +286,7 @@
282 286 build_host_ssh,
283 287 tag,
284 288 repo,
289 + repo_by_host: HashMap::new(),
285 290 features,
286 291 kind,
287 292 target_run_id,
@@ -302,6 +307,27 @@
302 307 }
303 308 }
304 309
310 + /// Declare the app's per-host checkout overrides (topology `repo_by_host`).
311 + ///
312 + /// Separate from `new` because it is empty for every app that has not opted
313 + /// in, and `new` already carries twenty arguments no test wants a
314 + /// twenty-first of.
315 + #[must_use]
316 + pub fn with_repo_by_host(mut self, repo_by_host: HashMap<String, String>) -> Self {
317 + self.repo_by_host = repo_by_host;
318 + self
319 + }
320 +
321 + /// Where this app is checked out on `host` (topology `AppConfig::repo_for`).
322 + ///
323 + /// Every git command a recipe or the engine runs on a build host goes
324 + /// through this. The bare `repo` field is the daemon-local path.
325 + pub fn repo_for(&self, host: &str) -> &str {
326 + self.repo_by_host
327 + .get(host)
328 + .map_or(self.repo.as_str(), String::as_str)
329 + }
330 +
305 331 /// Whether a newer build has superseded this run.
306 332 fn is_cancelled(&self) -> bool {
307 333 self.cancel.load(Ordering::SeqCst)
@@ -637,20 +663,23 @@
637 663 /// has checked out. Fetch + checkout stream into the current step's log;
638 664 /// the sha comes from a separate `rev-parse` so its stdout is only the sha.
639 665 fn checkout_sha(self: &Arc<Self>, host: &str) -> Result<String> {
666 + // Every command below runs ON `host`, so the path is that host's, not the
667 + // daemon's. Windows is why: its checkout is at `C:/Users/me/Code/...`.
668 + let repo = self.repo_for(host).to_string();
640 669 // A failing mirror is not a failing release: fetch is advisory, and only
641 670 // the checkout decides. Its output still streams into the step log, so an
642 671 // unreachable remote stays visible without being fatal.
643 - let _ = self.run(host, &git_fetch_cmd(&self.repo))?;
644 - let (code, _) = self.run(host, &git_checkout_tag_cmd(&self.repo, &self.tag))?;
672 + let _ = self.run(host, &git_fetch_cmd(&repo))?;
673 + let (code, _) = self.run(host, &git_checkout_tag_cmd(&repo, &self.tag))?;
645 674 if code != 0 {
646 - let (probe, _) = self.run(host, &git_tag_exists_cmd(&self.repo, &self.tag))?;
675 + let (probe, _) = self.run(host, &git_tag_exists_cmd(&repo, &self.tag))?;
647 676 anyhow::bail!(
648 677 "checkout of {} failed on `{host}`: {}",
649 678 self.tag,
650 679 checkout_failure_reason(&self.tag, probe == 0)
651 680 );
652 681 }
653 - let (code, tail) = self.run(host, &git_rev_parse_cmd(&self.repo))?;
682 + let (code, tail) = self.run(host, &git_rev_parse_cmd(&repo))?;
654 683 anyhow::ensure!(code == 0, "rev-parse failed on `{host}`");
655 684 Ok(tail.trim().to_string())
656 685 }
@@ -1316,10 +1345,16 @@
1316 1345 engine.register_fn("build_host", move || -> String { ctx.build_host.clone() });
1317 1346 }
1318 1347
1319 - // --- repo() -> string: the app's checkout path (`~`-prefixed) ---
1348 + // --- repo() -> string: the app's checkout path on this target's build host
1349 + // (`~`-prefixed on a unix host). Host-correct rather than one path per
1350 + // app, so a recipe for a host whose checkout is elsewhere still calls
1351 + // this instead of hard-coding the path — which is what kept the Windows
1352 + // recipes off `checkout_sha`. ---
1320 1353 {
1321 1354 let ctx = ctx.clone();
1322 - engine.register_fn("repo", move || -> String { ctx.repo.clone() });
1355 + engine.register_fn("repo", move || -> String {
1356 + ctx.repo_for(&ctx.build_host).to_string()
1357 + });
1323 1358 }
1324 1359
1325 1360 // --- checkout_sha(host) -> sha: pin this host to the release tag and report
@@ -1345,6 +1380,9 @@
1345 1380 engine.register_fn(
1346 1381 "crate_preflight",
1347 1382 move || -> Result<String, Box<EvalAltResult>> {
1383 + // `repo`, not `repo_for(...)`: `cargo metadata` runs on the
1384 + // daemon's own box, so this is the one checkout that is always
1385 + // the local one. It is not a missed call site.
1348 1386 let repo = expand_tilde(&ctx.repo);
1349 1387
1350 1388 let out = std::process::Command::new("cargo")
@@ -2510,6 +2548,53 @@
2510 2548 );
2511 2549 }
2512 2550
2551 + /// `repo()` answers for the host this target builds on, not for the daemon.
2552 + ///
2553 + /// This is what lets a Windows recipe call `repo()` and `checkout_sha(h)`
2554 + /// instead of hard-coding `C:/Users/me/...` — and hard-coding it is what
2555 + /// kept those recipes off the release-tag pin, since `checkout_sha` builds
2556 + /// its git commands from the app's path and takes no override.
2557 + #[tokio::test]
2558 + async fn repo_resolves_per_build_host() {
2559 + async fn repo_on(build_host: &str) -> String {
2560 + let dir = tempfile::tempdir().unwrap();
2561 + let cfg = Arc::new(Config::for_tests(dir.path()));
2562 + let pool = crate::db::open(&cfg.db_path).await.unwrap();
2563 + let ctx = Arc::new(
2564 + RecipeCtx::new(
2565 + AppId::new("demo"),
2566 + Version::parse("0.1.0").unwrap(),
2567 + "linux/x86_64".parse().unwrap(),
2568 + build_host.into(),
2569 + "local".into(),
2570 + "v0.1.0".into(),
2571 + "~/Code/Apps/demo".into(),
2572 + vec![],
2573 + Kind::App,
2574 + 1,
2575 + Arc::new(std::collections::HashMap::new()),
2576 + Arc::new(std::collections::HashMap::new()),
2577 + None,
2578 + pool,
2579 + crate::events::channel(),
2580 + cfg,
2581 + Arc::new(OtaRegistry::standard("https://makenot.work")),
2582 + tokio::runtime::Handle::current(),
2583 + Arc::new(AtomicBool::new(false)),
2584 + None,
2585 + )
2586 + .with_repo_by_host(HashMap::from([(
2587 + "windows-x86".to_string(),
2588 + "C:/Users/me/Code/Apps/demo".to_string(),
2589 + )])),
2590 + );
2591 + build_engine(&ctx).eval::<String>("repo()").unwrap()
2592 + }
2593 +
2594 + assert_eq!(repo_on("windows-x86").await, "C:/Users/me/Code/Apps/demo");
2595 + assert_eq!(repo_on("fw13").await, "~/Code/Apps/demo");
2596 + }
2597 +
2513 2598 /// `secret(key)` reads a file under `secrets_root`, trims its trailing
2514 2599 /// newline (the shape of a here-doc'd token file), and refuses any key that
2515 2600 /// could escape the root. Covers the host-fn registered in `build_engine`.
@@ -56,7 +56,6 @@
56 56 .topo
57 57 .app(app)
58 58 .ok_or_else(|| anyhow::anyhow!("unknown app `{app}`"))?;
59 - let repo = cfg.repo.clone();
60 59 // Spelled per app: a repo holding one product tags `v0.4.1`, a repo holding
61 60 // several tags `pom-v0.4.1`. See `AppConfig::tag_format`.
62 61 let tag = cfg.tag_for(version);
@@ -93,9 +92,13 @@
93 92 .get(host)
94 93 .ok_or_else(|| anyhow::anyhow!("no executor for host `{host}`"))?;
95 94 let mut sink = DiscardSink;
95 + // Resolved per host, not once for the set: the checkouts need not be at
96 + // the same path on every machine, and pinning windows-x86 with the unix
97 + // path failed on the first git command of the release.
98 + let repo = cfg.repo_for(host);
96 99
97 100 // Read the branch BEFORE pinning, while there is still one to read.
98 - let branch_cmd = OpStep::shell(Action::Build, engine::git_current_branch_cmd(&repo));
101 + let branch_cmd = OpStep::shell(Action::Build, engine::git_current_branch_cmd(repo));
99 102 let out = exec
100 103 .run_streaming(&branch_cmd, &mut sink)
101 104 .await
@@ -128,7 +131,7 @@
128 131 // builds something else. The rev-parse barrier below cannot see it:
129 132 // both hosts genuinely are on the same commit. Two architectures, two
130 133 // different binaries, one tag containing neither.
131 - let dirty_cmd = OpStep::shell(Action::Build, engine::git_dirty_cmd(&repo));
134 + let dirty_cmd = OpStep::shell(Action::Build, engine::git_dirty_cmd(repo));
132 135 let out = exec
133 136 .run_streaming(&dirty_cmd, &mut sink)
134 137 .await
@@ -150,10 +153,10 @@
150 153 // Advisory: `fetch --all` is non-zero if any one of a repo's remotes is
151 154 // unreachable, and blaming the tag for a dead mirror is what made this
152 155 // preflight misreport. Only the checkout below is allowed to fail.
153 - let fetch = OpStep::shell(Action::Build, engine::git_fetch_cmd(&repo));
156 + let fetch = OpStep::shell(Action::Build, engine::git_fetch_cmd(repo));
154 157 let _ = exec.run_streaming(&fetch, &mut sink).await;
155 158
156 - let checkout = OpStep::shell(Action::Build, engine::git_checkout_tag_cmd(&repo, &tag));
159 + let checkout = OpStep::shell(Action::Build, engine::git_checkout_tag_cmd(repo, &tag));
157 160 let out = exec
158 161 .run_streaming(&checkout, &mut sink)
159 162 .await
@@ -161,7 +164,7 @@
161 164 if !out.status.success() {
162 165 // Ask git why before telling the operator. The two causes need
163 166 // opposite responses: tag-and-push, versus clean the working tree.
164 - let probe = OpStep::shell(Action::Build, engine::git_tag_exists_cmd(&repo, &tag));
167 + let probe = OpStep::shell(Action::Build, engine::git_tag_exists_cmd(repo, &tag));
165 168 let tag_exists = exec
166 169 .run_streaming(&probe, &mut sink)
167 170 .await
@@ -172,7 +175,7 @@
172 175 );
173 176 }
174 177
175 - let rev = OpStep::shell(Action::Build, engine::git_rev_parse_cmd(&repo));
178 + let rev = OpStep::shell(Action::Build, engine::git_rev_parse_cmd(repo));
176 179 let out = exec
177 180 .run_streaming(&rev, &mut sink)
178 181 .await
@@ -262,21 +265,22 @@
262 265 None => None,
263 266 };
264 267 let mut sink = DiscardSink;
265 - let step = OpStep::shell(
266 - Action::Build,
267 - engine::git_restore_branch_cmd(&cfg.repo, branch),
268 - );
268 + // The same per-host path `pin_release` detached; restoring the daemon's
269 + // path on a host that keeps its checkout elsewhere would leave the real
270 + // one detached and report success.
271 + let repo = cfg.repo_for(host);
272 + let step = OpStep::shell(Action::Build, engine::git_restore_branch_cmd(repo, branch));
269 273 match exec.run_streaming(&step, &mut sink).await {
270 274 Ok(out) if out.status.success() => {
271 275 tracing::debug!(%host, %branch, "restored checkout to its branch");
272 276 }
273 277 Ok(_) => tracing::error!(
274 - %host, %branch, repo = %cfg.repo,
278 + %host, %branch, %repo,
275 279 "could not restore the checkout to its branch; it is left DETACHED at the \
276 280 release tag, and commits made there will belong to no branch"
277 281 ),
278 282 Err(e) => tracing::error!(
279 - %host, %branch, repo = %cfg.repo, error = %e,
283 + %host, %branch, %repo, error = %e,
280 284 "could not restore the checkout to its branch; it is left DETACHED at the \
281 285 release tag, and commits made there will belong to no branch"
282 286 ),
@@ -553,6 +557,11 @@
553 557 .app(&app)
554 558 .map(|a| a.repo.clone())
555 559 .unwrap_or_default();
560 + let repo_by_host = state
561 + .topo
562 + .app(&app)
563 + .map(|a| a.repo_by_host.clone())
564 + .unwrap_or_default();
556 565 let features = state
557 566 .topo
558 567 .app(&app)
@@ -591,30 +600,33 @@
591 600 None => state.executors.clone(),
592 601 };
593 602
594 - let ctx = Arc::new(RecipeCtx::new(
595 - app.clone(),
596 - version.clone(),
597 - target,
598 - build_host,
599 - build_host_ssh,
600 - tag.clone(),
601 - repo,
602 - features,
603 - // Gates the `verify` step's capability: a library's crate preflight is
604 - // not an app's Gatekeeper check. See engine::action_for.
605 - state.topo.app(&app).map(|a| a.kind).unwrap_or_default(),
606 - target_run_id,
607 - execs,
608 - state.syncs.clone(),
609 - deploy,
610 - state.pool.clone(),
611 - state.events.clone(),
612 - state.cfg.clone(),
613 - state.ota.clone(),
614 - tokio::runtime::Handle::current(),
615 - cancel,
616 - all_green_required,
617 - ));
603 + let ctx = Arc::new(
604 + RecipeCtx::new(
605 + app.clone(),
606 + version.clone(),
607 + target,
608 + build_host,
609 + build_host_ssh,
610 + tag.clone(),
611 + repo,
612 + features,
613 + // Gates the `verify` step's capability: a library's crate preflight is
614 + // not an app's Gatekeeper check. See engine::action_for.
615 + state.topo.app(&app).map(|a| a.kind).unwrap_or_default(),
616 + target_run_id,
617 + execs,
618 + state.syncs.clone(),
619 + deploy,
620 + state.pool.clone(),
621 + state.events.clone(),
622 + state.cfg.clone(),
623 + state.ota.clone(),
624 + tokio::runtime::Handle::current(),
625 + cancel,
626 + all_green_required,
627 + )
628 + .with_repo_by_host(repo_by_host),
629 + );
618 630
619 631 // Serialize per host: hold this host's lock for the whole recipe run so a
620 632 // second target on the same box (goingson macos + ios both on mbp) can't
@@ -3596,8 +3608,8 @@
3596 3608 /// like [`crate::topology`]'s live-config smoke test.
3597 3609 #[cfg(test)]
3598 3610 mod live_recipe_smoke {
3599 - use crate::topology::{Kind, Topology};
3600 - use std::path::Path;
3611 + use crate::topology::Topology;
3612 + use std::path::{Path, PathBuf};
3601 3613
3602 3614 #[test]
3603 3615 fn live_recipes_compile_if_present() {
@@ -3615,18 +3627,22 @@
3615 3627 let mut checked = 0;
3616 3628 for (name, cfg) in &topo.app {
3617 3629 let dir = crate::engine::expand_tilde(&cfg.repo).join(&cfg.recipe_dir);
3618 - let files: Vec<String> = match cfg.kind {
3619 - Kind::Library => vec!["publish.rhai".into()],
3620 - Kind::App | Kind::Service => cfg
3621 - .targets
3622 - .iter()
3623 - .map(|t| format!("{}.rhai", t.platform.as_str()))
3624 - .collect(),
3630 + // Every recipe in the directory, not just the ones the app's current
3631 + // targets name. A recipe for a target that is temporarily not
3632 + // shipped (windows, dropped from the manifests until its host is
3633 + // real) still has to parse, and it is the one nothing else is
3634 + // watching.
3635 + let Ok(entries) = std::fs::read_dir(&dir) else {
3636 + continue;
3625 3637 };
3626 - for file in files {
3627 - let p = dir.join(&file);
3628 - // An app can ship a target whose recipe is still pending (BB's
3629 - // macOS/iOS); a missing file is that, not a parse failure.
3638 + let mut files: Vec<PathBuf> = entries
3639 + .filter_map(Result::ok)
3640 + .map(|e| e.path())
3641 + .filter(|p| p.extension().is_some_and(|x| x == "rhai"))
3642 + .collect();
3643 + files.sort();
3644 + for p in files {
3645 + let file = p.file_name().unwrap_or_default().to_string_lossy();
3630 3646 let Ok(src) = std::fs::read_to_string(&p) else {
3631 3647 continue;
3632 3648 };
@@ -54,8 +54,11 @@
54 54 /// be able to rename the thing the daemon has history for by editing a file.
55 55 #[derive(Debug, Clone, Deserialize)]
56 56 struct AppPointer {
57 - /// Checkout path on each build host (apps are cloned on every host).
57 + /// Default checkout path, used by every host that does not override it.
58 58 repo: String,
59 + /// Per-host overrides of `repo`, keyed by host name.
60 + #[serde(default)]
61 + repo_by_host: HashMap<String, String>,
59 62 }
60 63
61 64 /// What a repo produces, which decides how Bento releases it.
@@ -227,8 +230,25 @@
227 230 /// An app as the runner sees it: its pointer merged with its in-repo manifest.
228 231 #[derive(Debug, Clone)]
229 232 pub struct AppConfig {
230 - /// Checkout path on each build host (apps are cloned on every host).
233 + /// Default checkout path: where this app is cloned on a host that does not
234 + /// override it. Read through [`AppConfig::repo_for`], never directly, on any
235 + /// path that names a build host.
231 236 pub repo: String,
237 + /// Per-host overrides of [`AppConfig::repo`], keyed by host name.
238 + ///
239 + /// One path for every host is a unix assumption. The Windows checkout is at
240 + /// `C:/Users/me/Code/Apps/goingson`, not at `~/Code/Apps/goingson`, and until
241 + /// this existed the Windows recipes worked around it by hard-coding the path
242 + /// and never calling `repo()` — which meant they could not use
243 + /// `checkout_sha(h)` either, since that builds its git commands from the
244 + /// app's one path. `pin_release` had the same bug and ran
245 + /// `git -C ~/Code/Apps/goingson` on windows-x86.
246 + ///
247 + /// Declared rather than derived from the host's `pull_root`: deriving would
248 + /// need the checkout to sit under the pull root, which is false for the
249 + /// library crates (`~/Code/Libraries/...` against a `pull_root` of
250 + /// `~/Code/Apps`).
251 + pub repo_by_host: HashMap<String, String>,
232 252 /// What this repo produces (see [`Kind`]).
233 253 pub kind: Kind,
234 254 pub branch: String,
@@ -279,6 +299,18 @@
279 299 }
280 300
281 301 impl AppConfig {
302 + /// Where this app is checked out on `host`.
303 + ///
304 + /// The single reader of the path for anything that runs on a build host, so
305 + /// a new call site cannot quietly reintroduce the one-path-per-app
306 + /// assumption. Daemon-local reads (the version, the recipe directory, the
307 + /// crate preflight) are a different question and keep using `repo`.
308 + pub fn repo_for(&self, host: &str) -> &str {
309 + self.repo_by_host
310 + .get(host)
311 + .map_or(self.repo.as_str(), String::as_str)
312 + }
313 +
282 314 /// The install destination for `target`, if this app declares one.
283 315 pub fn deploy_for(&self, target: Target) -> Option<&DeployTarget> {
284 316 self.deploy.iter().find(|d| d.target == target)
@@ -409,6 +441,7 @@
409 441 name,
410 442 AppConfig {
411 443 repo: ptr.repo,
444 + repo_by_host: ptr.repo_by_host,
412 445 kind: m.kind,
413 446 branch: m.branch,
414 447 recipe_dir: m.recipe_dir,
@@ -509,6 +542,15 @@
509 542 "app `{name}`: tag_format `{}` contains shell metacharacters",
510 543 app.tag_format
511 544 );
545 + // A typo'd host name here is invisible: the lookup misses and every
546 + // host silently gets the default path, which is the exact bug the
547 + // override exists to fix.
548 + for host in app.repo_by_host.keys() {
549 + anyhow::ensure!(
550 + self.hosts.iter().any(|h| &h.name == host),
551 + "app `{name}`: repo_by_host names host `{host}`, which no [[host]] declares"
552 + );
553 + }
512 554 validate_deploy(name, app)?;
513 555 }
514 556 // Capability/transport coherence: a host that declares buildable targets
@@ -572,6 +614,24 @@
572 614 Topology::from_str_for_tests(&daemon).map(|t| (t, dir))
573 615 }
574 616
617 + /// As [`load_with`], but the app pointer carries extra lines (a
618 + /// `repo_by_host` table) beneath its `repo`.
619 + fn load_with_pointer(
620 + hosts: &str,
621 + manifest: &str,
622 + pointer_extra: &str,
623 + ) -> Result<(Topology, tempfile::TempDir)> {
624 + let dir = tempfile::tempdir().unwrap();
625 + let repo = dir.path().join("goingson");
626 + std::fs::create_dir_all(&repo).unwrap();
627 + std::fs::write(repo.join(APP_MANIFEST), manifest).unwrap();
628 + let daemon = format!(
629 + "{hosts}\n[app.goingson]\nrepo = \"{}\"\n{pointer_extra}",
630 + repo.display()
631 + );
632 + Topology::from_str_for_tests(&daemon).map(|t| (t, dir))
633 + }
634 +
575 635 fn load(hosts: &str) -> Result<Topology> {
576 636 load_with(hosts, MANIFEST).map(|(t, dir)| {
577 637 std::mem::forget(dir);
@@ -607,6 +667,47 @@
607 667 );
608 668 }
609 669
670 + /// No `repo_by_host` is the ordinary case and every host resolves to the one
671 + /// declared path — including a host that does not exist, since the resolver
672 + /// is a lookup with a default and not a validation.
673 + #[test]
674 + fn repo_for_defaults_to_the_single_path_for_every_host() {
675 + let t = load(HOSTS).unwrap();
676 + let app = t.app(&"goingson".into()).unwrap();
677 + assert!(app.repo_by_host.is_empty());
678 + for host in ["fw13", "mbp", "nobody"] {
679 + assert_eq!(app.repo_for(host), app.repo);
680 + }
681 + }
682 +
683 + /// The Windows shape: one host's checkout is somewhere else entirely, and
684 + /// the override wins for that host and only that host.
685 + #[test]
686 + fn repo_by_host_overrides_one_host_only() {
687 + let (t, _dir) = load_with_pointer(
688 + HOSTS,
689 + MANIFEST,
690 + "[app.goingson.repo_by_host]\nmbp = \"/Users/max/Code/Apps/goingson\"\n",
691 + )
692 + .unwrap();
693 + let app = t.app(&"goingson".into()).unwrap();
694 + assert_eq!(app.repo_for("mbp"), "/Users/max/Code/Apps/goingson");
695 + assert_eq!(app.repo_for("fw13"), app.repo);
696 + }
697 +
698 + /// A misspelled host name would resolve to nothing and hand every host the
699 + /// default path, which looks exactly like a working config.
700 + #[test]
701 + fn repo_by_host_naming_an_unknown_host_is_rejected() {
702 + let err = load_with_pointer(
703 + HOSTS,
704 + MANIFEST,
705 + "[app.goingson.repo_by_host]\nwindows-x86 = \"C:/Users/me/Code/Apps/goingson\"\n",
706 + )
707 + .unwrap_err();
708 + assert!(format!("{err:#}").contains("windows-x86"), "{err:#}");
709 + }
710 +
610 711 #[test]
611 712 fn rejects_target_without_a_host() {
612 713 let only_linux = r#"