Skip to main content

max / makenotwork

sando: auxiliary-repo checkout so cross-repo path deps resolve The mnw-cli companion is built from the MNW worktree at <workdir>/<sha>/mnw-cli and now carries `synckit-client = { path = "../../synckit/synckit-client" }` after synckit moved to its own repo. That resolves to <workdir>/synckit, a sibling of the per-sha worktree Sando never created, so the companion build failed with "No such file or directory" and blocked the v0.10.19 deploy (task 18cdb32f). Add [[aux_repo]] to the topology: extra repos Sando fetches and checks out beside the worktree before building. The checkout is a fixed shared path (<workdir>/<checkout_dir>) refreshed to branch HEAD each build, not per-sha, because the dependent's relative path resolves there regardless of the main sha and builds serialize. A path dep has no lockfile pin, so branch HEAD is the honest resolution, matching the dev working copy. - topology: AuxRepo + aux_repos (default empty); validate checkout_dir is a single safe component and dirs are unique. - git: hookless ensure_bare_repo_no_hook (an aux bare is fetch-only; the build-trigger post-receive hook would be dead weight, or worse, fire). - build: checkout_aux_repos before the server/companion build; fetch failure warns, an unresolvable branch or failed worktree fails the build loudly. - sando.toml: wire the synckit aux_repo. Unblocks the MNW deploy once sandod self-updates to a build carrying this and /srv/sando/synckit.git is seeded on first build. Version left at 0.2.2 for the deliberate self-update bump.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-25 12:16 UTC
Signed with PGP, not checked
Commit: 93db2191a85d1a705f64658293b549fabf727fd3
Parent: 73c460f
7 files changed, +413 insertions, -3 deletions
@@ -21,6 +21,21 @@
21 21 # Leave unset for push-based hosts (commits arrive via the bare-repo hook).
22 22 upstream = "git@ssh.makenot.work:max/makenotwork.git"
23 23
24 + # ---- auxiliary repos ----
25 + # Extra repos fetched and checked out beside the per-sha worktree so a cross-repo
26 + # path dependency resolves at build time. The mnw-cli companion (built from the
27 + # MNW worktree) carries `synckit-client = { path = "../../synckit/synckit-client" }`
28 + # after synckit moved to its own repo; from <workdir>/<sha>/mnw-cli that resolves
29 + # to <workdir>/synckit, so synckit must be checked out there. checkout_dir is a
30 + # single component under the workdir; the checkout is shared across shas and
31 + # refreshed to `branch` HEAD each build. See the maintainer wiki, sando-overview.
32 + [[aux_repo]]
33 + name = "synckit"
34 + bare_path = "/srv/sando/synckit.git"
35 + upstream = "git@ssh.makenot.work:max/synckit.git"
36 + branch = "main"
37 + checkout_dir = "synckit"
38 +
24 39 [backup]
25 40 # Source of the prod-backup clone used by migration_dry_run on the Sando host.
26 41 # For localhost dev this can be a file:// path to a fixture dump. In prod we
@@ -288,6 +288,7 @@
288 288 },
289 289 backup: BackupConfig { source, local_path },
290 290 tiers: vec![],
291 + aux_repos: Vec::new(),
291 292 }
292 293 }
293 294
@@ -98,6 +98,12 @@
98 98
99 99 git::checkout_worktree(&bare, sha.as_str(), &worktree).await?;
100 100
101 + // Check out any auxiliary repos (e.g. synckit) beside the worktree so a
102 + // cross-repo path dependency in the server or a companion resolves. Fails the
103 + // build if an aux repo can't be assembled — a companion that silently fails to
104 + // find its source would fail the compile downstream with a worse message.
105 + checkout_aux_repos(&cfg, &topo).await?;
106 +
101 107 let server_dir = worktree.join("server");
102 108 let version = read_pkg_version(&server_dir.join("Cargo.toml"))
103 109 .await
@@ -224,6 +230,54 @@
224 230 })
225 231 }
226 232
233 + /// Fetch and check out every configured auxiliary repo at `cfg.workdir/<checkout_dir>`,
234 + /// so a cross-repo path dependency built from the main worktree resolves (wiki
235 + /// [[sando-overview]]; the synckit split, task sando-18cdb32f).
236 + ///
237 + /// Each aux repo is a fixed, shared checkout refreshed to `branch` HEAD — not
238 + /// per-sha — because the dependent's relative path resolves to that fixed spot
239 + /// regardless of the main sha, and builds serialize. A fetch failure is a warning
240 + /// (the branch may already be present from a prior build); an unresolvable branch
241 + /// after that is fatal, as is a failed worktree — a half-assembled source tree
242 + /// must fail the build here, loudly, not as a downstream compile error.
243 + pub async fn checkout_aux_repos(cfg: &Config, topo: &Topology) -> Result<()> {
244 + for aux in &topo.aux_repos {
245 + let bare = PathBuf::from(&aux.bare_path);
246 + git::ensure_bare_repo_no_hook(&bare)
247 + .await
248 + .with_context(|| format!("aux repo {}: init bare {}", aux.name, aux.bare_path))?;
249 + if let Err(e) = git::fetch_upstream(&bare, &aux.upstream, &aux.branch).await {
250 + tracing::warn!(
251 + aux = %aux.name, error = %e,
252 + "aux repo fetch failed; proceeding with current bare-repo state",
253 + );
254 + }
255 + let sha = git::resolve_ref(&bare, &aux.branch).await.with_context(|| {
256 + format!(
257 + "aux repo {}: branch {} not resolvable after fetch — is {} reachable with that branch?",
258 + aux.name, aux.branch, aux.upstream,
259 + )
260 + })?;
261 + let dest = cfg.workdir.join(&aux.checkout_dir);
262 + git::checkout_worktree(&bare, &sha, &dest)
263 + .await
264 + .with_context(|| {
265 + format!(
266 + "aux repo {}: checking out {} ({}) at {}",
267 + aux.name,
268 + aux.branch,
269 + sha,
270 + dest.display()
271 + )
272 + })?;
273 + tracing::info!(
274 + aux = %aux.name, branch = %aux.branch, sha = %sha, dest = %dest.display(),
275 + "aux repo checked out beside worktree",
276 + );
277 + }
278 + Ok(())
279 + }
280 +
227 281 /// Build one companion crate from the worktree, returning its release binary
228 282 /// path. Mirrors the server build's target-dir handling (shared
229 283 /// `cargo_target_dir` when set, for incremental reuse; else the crate's own
@@ -544,10 +598,12 @@
544 598
545 599 #[cfg(test)]
546 600 mod tests {
547 - use super::{BuildArtifact, check_build_host, runtime_hostname, stage_and_gate, tail};
601 + use super::{
602 + BuildArtifact, check_build_host, checkout_aux_repos, runtime_hostname, stage_and_gate, tail,
603 + };
548 604 use crate::config::{Config, TestTarget};
549 605 use crate::domain::{GitSha, RunId, Version};
550 - use crate::topology::{BackupConfig, CanaryPolicy, Gate, RepoConfig, Tier, Topology};
606 + use crate::topology::{AuxRepo, BackupConfig, CanaryPolicy, Gate, RepoConfig, Tier, Topology};
551 607 use sqlx::SqlitePool;
552 608 use sqlx::sqlite::SqlitePoolOptions;
553 609 use std::path::PathBuf;
@@ -658,6 +714,7 @@
658 714 canary: CanaryPolicy::Sequential,
659 715 nodes: Vec::new(),
660 716 }],
717 + aux_repos: Vec::new(),
661 718 };
662 719
663 720 let art = BuildArtifact {
@@ -679,6 +736,150 @@
679 736 )
680 737 }
681 738
739 + // ---- checkout_aux_repos ----
740 +
741 + async fn git_in(dir: &std::path::Path, args: &[&str]) {
742 + let out = tokio::process::Command::new("git")
743 + .args(["-c", "user.email=t@t", "-c", "user.name=t"])
744 + .current_dir(dir)
745 + .args(args)
746 + .output()
747 + .await
748 + .unwrap();
749 + assert!(
750 + out.status.success(),
751 + "git {args:?}: {}",
752 + String::from_utf8_lossy(&out.stderr)
753 + );
754 + }
755 +
756 + /// A minimal `Config` whose only field this test path reads is `workdir`.
757 + fn cfg_with_workdir(workdir: PathBuf) -> Config {
758 + Config {
759 + listen: "127.0.0.1:0".into(),
760 + db_path: PathBuf::from(":memory:"),
761 + topology_path: PathBuf::from("/tmp/test-sando.toml"),
762 + build_host: "test-host".into(),
763 + workdir,
764 + release_root: PathBuf::from("/tmp/rr"),
765 + scratch_db_url: None,
766 + scratch_owner_role: "makenotwork".into(),
767 + boot_smoke_port: 18181,
768 + code_smoke_port: 18182,
769 + bin_names: vec!["makenotwork".into()],
770 + logs_root: PathBuf::from("/tmp/logs"),
771 + release_contents: vec![],
772 + cargo_target_dir: None,
773 + gate_timeout_secs: 2400,
774 + companions: Vec::new(),
775 + test_targets: vec![],
776 + }
777 + }
778 +
779 + fn topo_with_aux(aux_repos: Vec<AuxRepo>) -> Topology {
780 + Topology {
781 + repo: RepoConfig {
782 + bare_path: "/tmp/x.git".into(),
783 + branch: "main".into(),
784 + upstream: None,
785 + },
786 + backup: BackupConfig {
787 + source: "s".into(),
788 + local_path: "/tmp/d".into(),
789 + },
790 + tiers: vec![],
791 + aux_repos,
792 + }
793 + }
794 +
795 + #[tokio::test]
796 + async fn checkout_aux_repos_places_repo_beside_worktree_and_refreshes_to_branch_head() {
797 + let tmp = tempfile::tempdir().unwrap();
798 +
799 + // An "upstream" source repo with a marker file on main.
800 + let src = tmp.path().join("synckit-src");
801 + tokio::fs::create_dir_all(&src).await.unwrap();
802 + git_in(&src, &["init", "-q", "-b", "main"]).await;
803 + tokio::fs::write(src.join("VERSION"), b"v1").await.unwrap();
804 + git_in(&src, &["add", "."]).await;
805 + git_in(&src, &["commit", "-q", "-m", "one"]).await;
806 +
807 + let workdir = tmp.path().join("work");
808 + tokio::fs::create_dir_all(&workdir).await.unwrap();
809 + let cfg = cfg_with_workdir(workdir.clone());
810 + let topo = topo_with_aux(vec![AuxRepo {
811 + name: "synckit".into(),
812 + bare_path: tmp
813 + .path()
814 + .join("synckit.git")
815 + .to_string_lossy()
816 + .into_owned(),
817 + upstream: src.to_string_lossy().into_owned(),
818 + branch: "main".into(),
819 + checkout_dir: "synckit".into(),
820 + }]);
821 +
822 + // First build: the aux repo lands at workdir/synckit at v1.
823 + checkout_aux_repos(&cfg, &topo).await.unwrap();
824 + let dest = workdir.join("synckit");
825 + assert_eq!(
826 + tokio::fs::read(dest.join("VERSION")).await.unwrap(),
827 + b"v1",
828 + "aux repo checked out beside the worktree",
829 + );
830 +
831 + // Upstream advances; a later build refreshes the shared checkout to HEAD.
832 + tokio::fs::write(src.join("VERSION"), b"v2").await.unwrap();
833 + git_in(&src, &["add", "."]).await;
834 + git_in(&src, &["commit", "-q", "-m", "two"]).await;
835 + checkout_aux_repos(&cfg, &topo).await.unwrap();
836 + assert_eq!(
837 + tokio::fs::read(dest.join("VERSION")).await.unwrap(),
838 + b"v2",
839 + "aux checkout refreshed to the new branch HEAD",
840 + );
841 +
842 + // The aux bare carries no build-trigger hook.
843 + assert!(
844 + !tmp.path().join("synckit.git/hooks/post-receive").exists(),
845 + "aux bare must be hookless",
846 + );
847 + }
848 +
849 + #[tokio::test]
850 + async fn checkout_aux_repos_is_a_noop_without_aux_repos() {
851 + let tmp = tempfile::tempdir().unwrap();
852 + let cfg = cfg_with_workdir(tmp.path().to_path_buf());
853 + checkout_aux_repos(&cfg, &topo_with_aux(vec![]))
854 + .await
855 + .unwrap();
856 + }
857 +
858 + #[tokio::test]
859 + async fn checkout_aux_repos_fails_on_an_unresolvable_branch() {
860 + let tmp = tempfile::tempdir().unwrap();
861 + let src = tmp.path().join("src");
862 + tokio::fs::create_dir_all(&src).await.unwrap();
863 + git_in(&src, &["init", "-q", "-b", "main"]).await;
864 + tokio::fs::write(src.join("f"), b"x").await.unwrap();
865 + git_in(&src, &["add", "."]).await;
866 + git_in(&src, &["commit", "-q", "-m", "c"]).await;
867 +
868 + let cfg = cfg_with_workdir(tmp.path().join("work"));
869 + let topo = topo_with_aux(vec![AuxRepo {
870 + name: "synckit".into(),
871 + bare_path: tmp.path().join("s.git").to_string_lossy().into_owned(),
872 + upstream: src.to_string_lossy().into_owned(),
873 + branch: "nonexistent".into(),
874 + checkout_dir: "synckit".into(),
875 + }]);
876 + let err = checkout_aux_repos(&cfg, &topo).await.unwrap_err();
877 + assert!(
878 + format!("{err:#}").contains("synckit"),
879 + "error names the aux repo: {err:#}",
880 + );
881 + }
882 +
682 883 async fn tier_versions(pool: &SqlitePool, tier: &str) -> (Option<String>, Option<String>) {
683 884 sqlx::query_as("SELECT current_version, previous_version FROM tier_state WHERE tier = ?")
684 885 .bind(tier)
@@ -9,6 +9,21 @@
9 9 const POST_RECEIVE: &str = include_str!("../../hooks/post-receive");
10 10
11 11 pub async fn ensure_bare_repo(path: &Path) -> Result<()> {
12 + init_bare_repo(path).await?;
13 + install_hook(path).await?;
14 + Ok(())
15 + }
16 +
17 + /// Like [`ensure_bare_repo`] but installs no `post-receive` hook. For an
18 + /// auxiliary repo (e.g. synckit) Sando only ever *fetches* — nobody pushes to
19 + /// its bare — so the build-triggering hook would be dead weight, and worse, if
20 + /// it ever did fire it would kick an MNW build. Keep the aux bare inert.
21 + pub async fn ensure_bare_repo_no_hook(path: &Path) -> Result<()> {
22 + init_bare_repo(path).await
23 + }
24 +
25 + /// `git init --bare` at `path` if it isn't already a repo. Idempotent.
26 + async fn init_bare_repo(path: &Path) -> Result<()> {
12 27 if !path.join("HEAD").exists() {
13 28 tokio::fs::create_dir_all(path).await?;
14 29 let out = Command::new("git")
@@ -23,7 +38,6 @@
23 38 String::from_utf8_lossy(&out.stderr),
24 39 );
25 40 }
26 - install_hook(path).await?;
27 41 Ok(())
28 42 }
29 43
@@ -267,6 +281,27 @@
267 281 (tmp, gitdir, sha1, sha2)
268 282 }
269 283
284 + #[tokio::test]
285 + async fn ensure_bare_repo_installs_hook_but_no_hook_variant_does_not() {
286 + let tmp = tempfile::tempdir().unwrap();
287 + let with_hook = tmp.path().join("hooked.git");
288 + let without = tmp.path().join("bare.git");
289 +
290 + ensure_bare_repo(&with_hook).await.unwrap();
291 + ensure_bare_repo_no_hook(&without).await.unwrap();
292 +
293 + // Both are real bare repos.
294 + assert!(with_hook.join("HEAD").exists());
295 + assert!(without.join("HEAD").exists());
296 + // Only the main-repo variant carries the build-trigger hook.
297 + assert!(with_hook.join("hooks/post-receive").exists());
298 + assert!(!without.join("hooks/post-receive").exists());
299 +
300 + // Both are idempotent.
301 + ensure_bare_repo_no_hook(&without).await.unwrap();
302 + assert!(!without.join("hooks/post-receive").exists());
303 + }
304 +
270 305 #[tokio::test]
271 306 async fn checkout_worktree_creates_at_sha() {
272 307 let (tmp, gitdir, sha1, _sha2) = two_commit_repo().await;
@@ -140,6 +140,7 @@
140 140 local_path: "/tmp/b".into(),
141 141 },
142 142 tiers,
143 + aux_repos: Vec::new(),
143 144 }
144 145 }
145 146
@@ -9,6 +9,45 @@
9 9 pub backup: BackupConfig,
10 10 #[serde(rename = "tier")]
11 11 pub tiers: Vec<Tier>,
12 + /// Extra repos to fetch and check out beside the main worktree before a
13 + /// build, so a path dependency that reaches across the repo split resolves.
14 + /// Empty (default) keeps an existing `sando.toml` working unedited. See
15 + /// [`AuxRepo`] and [`crate::build::checkout_aux_repos`].
16 + #[serde(default, rename = "aux_repo")]
17 + pub aux_repos: Vec<AuxRepo>,
18 + }
19 +
20 + /// An auxiliary repo checked out beside the per-sha worktree so cross-repo path
21 + /// dependencies resolve at build time.
22 + ///
23 + /// The concrete case (2026-07-24): `mnw-cli` — a companion built from the MNW
24 + /// worktree — carries `synckit-client = { path = "../../synckit/synckit-client" }`
25 + /// after synckit moved to its own repo. From the companion crate at
26 + /// `<workdir>/<sha>/mnw-cli`, that path resolves to `<workdir>/synckit`, a sibling
27 + /// of the per-sha worktree that Sando otherwise never creates, so the companion
28 + /// build failed with "No such file or directory". An `aux_repo` named to land at
29 + /// `checkout_dir = "synckit"` puts the synckit source exactly there.
30 + ///
31 + /// The checkout is at the FIXED `<workdir>/<checkout_dir>`, not per-sha: the path
32 + /// dependency resolves to that spot regardless of the MNW sha, and builds
33 + /// serialize, so a single shared checkout refreshed to `branch` HEAD each build
34 + /// is correct. Because a path dep has no lockfile pin, "branch HEAD" is the honest
35 + /// resolution — the same contract as the dev working copy.
36 + #[derive(Debug, Clone, Serialize, Deserialize)]
37 + pub struct AuxRepo {
38 + /// Human label for logs and errors (e.g. `synckit`).
39 + pub name: String,
40 + /// Bare repo Sando fetches into and worktrees from, e.g.
41 + /// `/srv/sando/synckit.git`. Auto-created (hookless) on first build.
42 + pub bare_path: String,
43 + /// Canonical git remote fetched before checkout. Like [`RepoConfig::upstream`]
44 + /// but required here: an aux repo is pull-based (nobody pushes to its bare).
45 + pub upstream: String,
46 + /// Branch whose HEAD is checked out.
47 + pub branch: String,
48 + /// Where the worktree lands, relative to `cfg.workdir`. Must be a single safe
49 + /// path component (no `..`, not absolute) so it stays under the workdir.
50 + pub checkout_dir: String,
12 51 }
13 52
14 53 #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -263,6 +302,31 @@
263 302 );
264 303 }
265 304 }
305 + let mut seen_dirs = std::collections::HashSet::new();
306 + for aux in &self.aux_repos {
307 + anyhow::ensure!(
308 + !aux.name.is_empty() && !aux.bare_path.is_empty() && !aux.branch.is_empty(),
309 + "aux_repo entry has an empty name/bare_path/branch"
310 + );
311 + // `checkout_dir` becomes a `workdir.join(..)`; keep it a single safe
312 + // component so an aux repo can never write outside the workdir or
313 + // collide with a per-sha worktree dir.
314 + let dir = &aux.checkout_dir;
315 + anyhow::ensure!(
316 + !dir.is_empty()
317 + && !dir.contains('/')
318 + && !dir.contains('\\')
319 + && dir != "."
320 + && dir != "..",
321 + "aux_repo {} has an unsafe checkout_dir {dir:?} (must be a single path component, \
322 + no separators or dot-dot)",
323 + aux.name,
324 + );
325 + anyhow::ensure!(
326 + seen_dirs.insert(dir.as_str()),
327 + "two aux_repo entries share checkout_dir {dir:?}; they would clobber each other"
328 + );
329 + }
266 330 Ok(())
267 331 }
268 332 }
@@ -392,6 +456,98 @@
392 456 assert_eq!(c[0].service_name, "mnw-cli.service");
393 457 }
394 458
459 + fn topo_with_aux(aux_block: &str) -> Result<Topology> {
460 + let raw = format!(
461 + r#"
462 + [repo]
463 + bare_path = "/tmp/repo.git"
464 + branch = "main"
465 + [backup]
466 + source = "s"
467 + local_path = "/tmp/d"
468 + [[tier]]
469 + name = "b"
470 + provisioned = true
471 + gates = [{{ kind = "node_health" }}]
472 + [[tier.node]]
473 + name = "prod-1"
474 + ssh_target = "prod-1"
475 + release_root = "/srv/mnw"
476 + {aux_block}
477 + "#
478 + );
479 + let topo: Topology = toml::from_str(&raw)?;
480 + topo.validate_for_test()?;
481 + Ok(topo)
482 + }
483 +
484 + #[test]
485 + fn aux_repos_default_empty() {
486 + let topo = topo_with_aux("").expect("no aux_repo block is fine");
487 + assert!(topo.aux_repos.is_empty());
488 + }
489 +
490 + #[test]
491 + fn aux_repo_parses_all_fields() {
492 + let topo = topo_with_aux(
493 + r#"
494 + [[aux_repo]]
495 + name = "synckit"
496 + bare_path = "/srv/sando/synckit.git"
497 + upstream = "git@ssh.makenot.work:max/synckit.git"
498 + branch = "main"
499 + checkout_dir = "synckit""#,
500 + )
501 + .expect("valid aux_repo parses");
502 + assert_eq!(topo.aux_repos.len(), 1);
503 + let a = &topo.aux_repos[0];
504 + assert_eq!(a.name, "synckit");
505 + assert_eq!(a.bare_path, "/srv/sando/synckit.git");
506 + assert_eq!(a.upstream, "git@ssh.makenot.work:max/synckit.git");
507 + assert_eq!(a.branch, "main");
508 + assert_eq!(a.checkout_dir, "synckit");
509 + }
510 +
511 + #[test]
512 + fn aux_repo_with_traversing_checkout_dir_is_rejected() {
513 + for bad in ["../escape", "a/b", "..", "."] {
514 + let err = topo_with_aux(&format!(
515 + r#"
516 + [[aux_repo]]
517 + name = "x"
518 + bare_path = "/srv/sando/x.git"
519 + upstream = "u"
520 + branch = "main"
521 + checkout_dir = "{bad}""#,
522 + ))
523 + .unwrap_err()
524 + .to_string();
525 + assert!(err.contains("unsafe checkout_dir"), "for {bad:?}: {err}");
526 + }
527 + }
528 +
529 + #[test]
530 + fn aux_repos_sharing_a_checkout_dir_are_rejected() {
531 + let err = topo_with_aux(
532 + r#"
533 + [[aux_repo]]
534 + name = "one"
535 + bare_path = "/srv/sando/one.git"
536 + upstream = "u"
537 + branch = "main"
538 + checkout_dir = "shared"
539 + [[aux_repo]]
540 + name = "two"
541 + bare_path = "/srv/sando/two.git"
542 + upstream = "u"
543 + branch = "main"
544 + checkout_dir = "shared""#,
545 + )
546 + .unwrap_err()
547 + .to_string();
548 + assert!(err.contains("share checkout_dir"), "{err}");
549 + }
550 +
395 551 #[test]
396 552 fn real_sando_toml_loads_clean() {
397 553 // The shipped topology must satisfy the invariant — guards against a
@@ -905,6 +905,7 @@
905 905 }],
906 906 },
907 907 ],
908 + aux_repos: Vec::new(),
908 909 }
909 910 }
910 911