From 78b0451ed1120306bdcb962d9dba62f8be4c6784 Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 7 Nov 2026 15:12:51 +0100 Subject: [PATCH] hive-priv: overlay the knowledge .git only when the clone has one, so agents start before the first clone --- docs/2-handbook/swarm/knowledge.md | 6 +++++ hive-priv/src/container_config.rs | 37 +++++++++++++++++++++++------- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/docs/2-handbook/swarm/knowledge.md b/docs/2-handbook/swarm/knowledge.md index 263a645a..5dfb9d73 100644 --- a/docs/2-handbook/swarm/knowledge.md +++ b/docs/2-handbook/swarm/knowledge.md @@ -112,6 +112,12 @@ tmpfs at `/knowledge/.git`, so agents see the documents, not the branches or history. An older clone also kept a token in `.git/config`; `knowledge::pull()` rewrites that remote to the plain URL. +hive-priv adds the overlay only when the host clone has a `.git`. Before the +first clone succeeds, the agent starts with an empty `/knowledge` and no +overlay, because nspawn can't create the mount point inside the read-only +bind. A container that started in that window sees the `.git` once the clone +lands, until its next start. + ## Contributing Agents have read access to `internal/knowledge` (it's public) but no diff --git a/hive-priv/src/container_config.rs b/hive-priv/src/container_config.rs index a8c81849..8c47a992 100644 --- a/hive-priv/src/container_config.rs +++ b/hive-priv/src/container_config.rs @@ -414,9 +414,15 @@ fn validate_cpu_quota(value: &str) -> Result<()> { /// /// An overlay rather than an exported copy: there is no second tree to keep in /// sync, so nothing can go stale, and no code path has to remember to refresh -/// it. It is applied unconditionally: the directory is created empty and cloned -/// into later, so a container started before the clone must already hide the -/// `.git` that arrives. +/// it. +/// +/// Applied only when the bind's host source has a `.git` entry. nspawn creates +/// the mount point inside the read-only bind, and a read-only mount refuses +/// that (`EROFS`) where nothing exists to mount over, which fails the whole +/// container start while the first clone is missing. A container started +/// before that clone has no overlay, so it sees the `.git` that arrives until +/// its next start rewrites these flags. The entry is looked up without +/// following a symlink, and a symlink named `.git` counts as present. /// /// ⚠️ Ordering matters: these must be appended **after** the `--bind` flags so /// nspawn mounts them on top of the already-mounted trees. @@ -424,6 +430,7 @@ fn git_overlay_flags(binds: &[BindMount]) -> Vec { binds .iter() .filter(|b| b.container_path == "/knowledge") + .filter(|b| std::fs::symlink_metadata(Path::new(&b.host_path).join(".git")).is_ok()) .map(|b| format!("--tmpfs={}/.git", b.container_path)) .collect() } @@ -814,19 +821,33 @@ mod tests { } } - /// The knowledge tree gets its `.git` overlaid, whatever its source holds: - /// it is cloned into after the container may have started, so the overlay - /// does not wait for a `.git` to exist. + /// The knowledge tree gets its `.git` overlaid when its source holds one. + /// Without one the overlay is left out, since nspawn cannot create the + /// mount point inside the read-only bind and the container would not start. #[test] - fn the_knowledge_tree_gets_its_dot_git_hidden() { + fn the_knowledge_dot_git_is_hidden_only_where_there_is_one() { let flags = git_overlay_flags(&[bind_from(&repo_source(), "/knowledge")]); assert_eq!(flags, ["--tmpfs=/knowledge/.git"]); let empty = scratch().to_string_lossy().into_owned(); let flags = git_overlay_flags(&[bind_from(&empty, "/knowledge")]); + assert!( + flags.is_empty(), + "overlaid a source with no .git: {flags:?}" + ); + } + + /// A symlink named `.git` is not followed: it counts as present, even + /// dangling, so the path it leads to is hidden rather than skipped. + #[test] + fn a_dangling_dot_git_symlink_still_gets_the_overlay() { + let dir = scratch(); + std::os::unix::fs::symlink("/nonexistent", dir.join(".git")).unwrap(); + let src = dir.to_string_lossy().into_owned(); + let flags = git_overlay_flags(&[bind_from(&src, "/knowledge")]); assert_eq!(flags, ["--tmpfs=/knowledge/.git"]); } - /// ...and nothing else does. A blanket "overlay .git on every bind" + /// No other mount is overlaid. A blanket "overlay .git on every bind" /// would mask a real `.git` under `state/`, where an agent legitimately /// keeps working clones of its own. #[test]