Skip to main content

memstead_base/
workspace.rs

1//! Workspace concept — first-class in `memstead-base` after the
2//! workspace-store rebuild.
3//!
4//! A [`Workspace`] is the operator-curated collection of mounts;
5//! each [`Mount`] attaches one mem to the workspace via a storage
6//! backend (folder / git-branch / archive). One mount = one mem:
7//! five mems living on five branches in one git-repo materialise as
8//! five mounts; the engine pools the gitdir handle internally for the
9//! shared backend rather than collapsing the conceptual mount.
10//!
11//! This module ships the data shapes only. The persistence adapter
12//! that materialises a `Workspace` from `.memstead/workspace.toml` +
13//! `.memstead/state/mounts.json` lands separately as the file-adapter
14//! sessions move forward; tests and in-memory builders construct
15//! `Workspace` directly without going through any adapter.
16//!
17//! Distinct from [`crate::mem::MemRouterSnapshot`], which is the
18//! engine's *runtime* snapshot of writable / visible mems. The
19//! engine derives a `MemRouterSnapshot` from a `Workspace` at boot;
20//! the two coexist while the rebuild is in flight.
21
22use std::collections::{BTreeMap, HashMap};
23use std::path::PathBuf;
24
25use memstead_schema::SchemaRef;
26use memstead_schema::workspace_config::CrossLinkValue;
27use serde::{Deserialize, Serialize};
28
29/// A single mem attachment in a [`Workspace`]. One mount = one
30/// mem. The schema pin is on the mount because per-mem schema
31/// resolution is fixed in code (local-storage → built-in → registry,
32/// with the storage layer owning where "local" lives — see the
33/// glossary's *Schema* entry); the mount carries which schema this
34/// mem expects, the backend resolves where the YAMLs come from.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Mount {
37    /// Operator-facing mem name within this workspace.
38    pub mem: String,
39    /// Optional *expectation assertion* about this mem's schema pin
40    /// (exact `<name>@<version>`). The authoritative pin is the mem's
41    /// own `MemConfig.schema` on its storage backend; boot/load
42    /// resolve from there. This field is a workspace-local cross-check —
43    /// useful for foreign or read-only mounts. `None` means "assert
44    /// nothing, trust the backend config". When `Some` and mismatching
45    /// the config pin, loading surfaces a `SchemaPinMismatch` finding
46    /// naming both values; neither is silently preferred. Resolution
47    /// falls back to this value only when the backend config carries no
48    /// pin.
49    pub schema: Option<SchemaRef>,
50    /// Backend-specific reference to the mem's content.
51    pub storage: MountStorage,
52    /// Read-only or writable attachment.
53    pub capability: MountCapability,
54    /// Eager (open the backend at engine start) or lazy (entity load
55    /// deferred to first read) — see [`MountLifecycle`] for the full
56    /// contract. Behavioural since flywheel W7/01; opt-in per mount.
57    pub lifecycle: MountLifecycle,
58    /// Whether other mounts in the same workspace may form
59    /// cross-mem edges into this mount. Workspace-level cross-mem
60    /// permission policy can override.
61    pub cross_linkable: bool,
62    /// In-flight schema migration target. `Some(target)` puts the
63    /// mem in dual-pin state: writes validate against `target`
64    /// (the engine's effective validation schema), reads stay
65    /// permissive, and `schema` remains the settled pin until every
66    /// entity is integral against the target — then the atomic
67    /// switch sets `schema = target` and clears this field in one
68    /// workspace-store write. Persisted so a long migration is
69    /// resumable across engine restarts.
70    pub migration_target: Option<SchemaRef>,
71}
72
73impl Mount {
74    /// Hierarchical organisational path for this mount, or `None` for
75    /// flat layout / non-hierarchical storage. Mirrors the
76    /// `MemCreateParams.path` create-side input — at delete time the
77    /// lifecycle candidate composes as `<mem_path>/<name>` (or
78    /// `<name>` alone when `None`) to match the create-side rule.
79    ///
80    /// Derivation: `MountStorage::GitBranch` carries the path in its
81    /// `branch` field. Tolerates both fully-qualified
82    /// `refs/heads/<mem_path>/<mem>` (the shape `create_mem`
83    /// produces) and bare `<mem_path>/<mem>` (the shape
84    /// `mounts.json` operator-edited entries carry) — full's
85    /// `instantiate_full_backend` already normalises both forms. Strip
86    /// the optional `refs/heads/` prefix and the trailing `<mem>`
87    /// leaf. `Folder` / `Archive` carry no hierarchical path on the
88    /// storage variant — runtime callers that know the create-time
89    /// `path` plumb it directly into the router via
90    /// `Engine::register_writable_mem`.
91    pub fn mem_path(&self) -> Option<String> {
92        match &self.storage {
93            MountStorage::GitBranch { branch, .. } => {
94                let leaf = branch
95                    .strip_prefix("refs/heads/")
96                    .unwrap_or(branch.as_str());
97                let after_leaf = leaf.strip_suffix(&self.mem)?;
98                let trimmed = after_leaf.trim_end_matches('/');
99                if trimmed.is_empty() {
100                    None
101                } else {
102                    Some(trimmed.to_string())
103                }
104            }
105            MountStorage::Folder { .. } | MountStorage::Archive { .. } | MountStorage::InMemory => {
106                None
107            }
108        }
109    }
110}
111
112/// Storage reference for a [`Mount`]. One variant per
113/// [`crate::backend::MemBackend`] implementation. New backends add
114/// a variant; the file-adapter learns to round-trip it.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub enum MountStorage {
117    /// Folder backend — mem lives as a directory tree on disk.
118    /// The mem root may be the workspace root itself (collapsed
119    /// single-mem form: `.memstead/config.json` at root, no `mems/`
120    /// subfolder) or a sibling mem subfolder.
121    Folder {
122        /// Absolute path to the mem root directory.
123        path: PathBuf,
124    },
125    /// Git-branch backend — mem lives as a branch in a mem-repo
126    /// gitdir. Multi-repo workspaces are supported by varying
127    /// `gitdir` across mounts (see the *Storage backend* glossary
128    /// entry's *per-mount git-repo* block for the trade-offs).
129    GitBranch {
130        /// Absolute path to the gitdir
131        /// (typically `<workspace>/mem-repo/.git`).
132        gitdir: PathBuf,
133        /// Branch name within the gitdir holding the mem content.
134        branch: String,
135    },
136    /// Archive backend — mem lives inside a sealed `.mem` zip archive.
137    /// Always read-only; mounts of this storage carry
138    /// [`MountCapability::ReadOnly`].
139    Archive {
140        /// Absolute path to the sealed archive file.
141        path: PathBuf,
142    },
143    /// In-memory backend — mem lives entirely in RAM, with no
144    /// filesystem path and no git. Created empty, dropped with the
145    /// engine, leaving no on-disk residue. Serves ephemeral
146    /// per-session playground mems. Carries no fields: there is
147    /// nothing to locate on disk, and the backend holds all state
148    /// itself (see [`crate::storage::InMemoryBackend`]).
149    InMemory,
150}
151
152impl MountStorage {
153    /// Stable kebab-case backend identifier surfaced in error envelopes
154    /// (e.g. `MARKDOWN_EXPORT_UNSUPPORTED_BACKEND`'s `active_backend`
155    /// detail) and in the on-disk `mounts.json` serialisation. The
156    /// kebab-case form matches the `MountStorageWire` `#[serde(tag,
157    /// rename_all = "kebab-case")]` tag.
158    pub fn backend_id(&self) -> &'static str {
159        match self {
160            MountStorage::Folder { .. } => "folder",
161            MountStorage::GitBranch { .. } => "git-branch",
162            MountStorage::Archive { .. } => "archive",
163            MountStorage::InMemory => "in-memory",
164        }
165    }
166
167    /// Whether writes (and, for read-only backends, the loaded content)
168    /// survive process restart / session-TTL eviction. `Folder`,
169    /// `GitBranch`, and `Archive` all live on disk and persist; only
170    /// `InMemory` is volatile — its state is dropped with the engine,
171    /// so a `commit_sha` it returns denotes nothing durable. This is the
172    /// fact the durability marker projects: derived from the storage
173    /// *kind*, not from `current_head()` (which is `None` for both
174    /// `Folder` and `InMemory` and so cannot tell them apart).
175    pub fn is_durable(&self) -> bool {
176        match self {
177            MountStorage::Folder { .. }
178            | MountStorage::GitBranch { .. }
179            | MountStorage::Archive { .. } => true,
180            MountStorage::InMemory => false,
181        }
182    }
183}
184
185/// What the workspace may do with a mount.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum MountCapability {
188    /// Mutations rejected — the engine surfaces a typed read-only
189    /// error before reaching the backend.
190    ReadOnly,
191    /// Full read + write.
192    Write,
193}
194
195/// When the mount's backend initialises.
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum MountLifecycle {
198    /// Open the backend at engine start.
199    Eager,
200    /// Defer the ENTITY load until the first operation that needs the
201    /// mem. The metadata half (config, provenance, schema pin) still
202    /// resolves at boot, so the mem is on the roster with its pin —
203    /// present, never silently absent — and a broken pin quarantines at
204    /// boot exactly as an eager mount's would. The first read triggers
205    /// the load through the per-operation funnel
206    /// (`Engine::ensure_mems_loaded`, called by `reload_if_stale`),
207    /// running the same validation gauntlet an eager boot runs; a load
208    /// failure quarantines at that moment with the same typed
209    /// reporting. Opt-in per mount; nothing sets it by default.
210    Lazy,
211}
212
213/// Operator-curated workspace — the in-memory shape the engine
214/// receives.
215///
216/// The two-layer file adapter produces a `Workspace` by reading
217/// `.memstead/workspace.toml` (operator-edited rules) and
218/// `.memstead/state/mounts.json` (engine-managed mount list). Tests and
219/// in-memory builders construct `Workspace` directly.
220///
221/// V1 carries the mount list and operator policy. Plugin hooks and
222/// pipeline-config handles attach as additive fields — no breaking
223/// changes expected.
224#[derive(Debug, Clone, Default)]
225pub struct Workspace {
226    pub mounts: Vec<Mount>,
227    /// Workspace-level operator policy (mem create/delete rules,
228    /// cross-mem link permissions). Defaults to empty for tests
229    /// and in-memory builders; the file adapter
230    /// populates from `.memstead/workspace.toml`'s `[mem_management]`
231    /// and `[cross_mem_links]` sections. The unified engine reads
232    /// this via [`crate::Engine::settings`] after
233    /// [`crate::Engine::from_workspace_root`] threads it through
234    /// [`crate::Engine::set_settings`].
235    pub settings: WorkspaceSettings,
236}
237
238impl Workspace {
239    /// Empty workspace — zero mounts, default settings. Useful for
240    /// tests; production workspaces always carry at least one mount
241    /// (the engine rejects an empty `Workspace` at boot).
242    pub fn empty() -> Self {
243        Self {
244            mounts: Vec::new(),
245            settings: WorkspaceSettings::default(),
246        }
247    }
248}
249
250/// Workspace-level operator policy carried alongside the mount list.
251///
252/// Data carriers only — the matcher compilation lives in
253/// `crate::mem_management::CreateRuleSet`. The engine carries the
254/// raw settings so MCP handlers can surface them under `memstead_health
255/// { include_config: true }` and `memstead_overview`'s
256/// lifecycle-namespaces section.
257///
258/// `Default::default()` is a totally-empty policy: zero create rules,
259/// zero delete rules, no cross-mem link policy. The unified engine
260/// uses this as the bootstrap value at construction time; consumers
261/// that load a real policy call [`crate::Engine::set_settings`].
262#[derive(Debug, Clone, Default)]
263pub struct WorkspaceSettings {
264    /// Raw `[[mem_management.create]]` rules in declaration order.
265    /// Each entry carries a gitignore-style `pattern` matched against
266    /// the candidate mem path, an `schemas[]` allowlist, and an
267    /// optional `default_cross_links` synthesised cross-link
268    /// permission. Empty list means "no agent-driven mem creation
269    /// allowed" — `memstead_mem_create` rejects every candidate.
270    pub mem_create_rules: Vec<CreateRuleSetting>,
271    /// Raw `[[mem_management.delete]]` rules. Same first-match
272    /// semantics as [`Self::mem_create_rules`], minus the schema
273    /// dimension. Empty list means "no agent-driven mem deletion
274    /// allowed".
275    pub mem_delete_rules: Vec<DeleteRuleSetting>,
276    /// `[cross_mem_links]` policy — workspace-level cross-mem
277    /// edge permissions keyed by source mem. Empty map means
278    /// default-deny: every cross-mem edge fails until at least one
279    /// matching entry exists or a create-rule synthesised one.
280    pub cross_mem_links: BTreeMap<String, CrossLinkValue>,
281    /// `[mcp]` section — MCP-binary tuning knobs that operators set
282    /// per-workspace. The MCP binary reads this off
283    /// `Engine::settings()` at boot to size the response chunker
284    /// (`token_budget`) and filter the advertised tool surface
285    /// (`disabled_tools`). Defaulted when the section is absent.
286    pub mcp: McpSection,
287    /// `[mutations]` section — engine-wide mutation policy. The
288    /// `require_notes` field surfaces a `WarningHint::NoteMissing` on
289    /// mutation calls that omit a `note`. Default-zeroed when absent.
290    pub mutations: MutationsSection,
291    /// `[plugin.*]` namespace — opaque pass-through map keyed by
292    /// plugin identifier (`claude_code`, …). Values are raw
293    /// TOML tables the engine never inspects; named plugins read
294    /// their own sub-table via `memstead_health { include_config: true }`.
295    pub plugin: HashMap<String, toml::Table>,
296}
297
298/// `[mcp]` section — settings the MCP binary reads at boot. Carried
299/// on `WorkspaceSettings` so the MCP server sources its tuning from
300/// `Engine::settings()` instead of a parallel TOML parse.
301#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq)]
302#[serde(deny_unknown_fields)]
303pub struct McpSection {
304    /// Per-response chunking budget in tokens. `None` → caller falls
305    /// back to the compile-time `DEFAULT_TOKEN_BUDGET`.
306    pub token_budget: Option<usize>,
307    /// Blocklist of tool names. Entries matching a compiled-in tool
308    /// are hidden from `tools/list` and rejected with `TOOL_DISABLED`
309    /// on direct invocation. Unknown entries log a warning and drop
310    /// from the effective set. Empty / absent → every compiled-in
311    /// tool is advertised.
312    pub disabled_tools: Option<Vec<String>>,
313}
314
315/// `[mutations]` section — engine-wide mutation policy. Carried on
316/// `WorkspaceSettings` so plugins can read the configured posture via
317/// `memstead_health { include_config: true }` without a round-trip.
318#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq)]
319#[serde(deny_unknown_fields)]
320pub struct MutationsSection {
321    /// When `true`, a mutation call without a `note` field emits a
322    /// `WarningHint { code: "note_missing" }`. The mutation still
323    /// succeeds — provenance is best-effort.
324    pub require_notes: Option<bool>,
325}
326
327/// One `[[mem_management.create]]` rule. Carries a glob `pattern`
328/// matched against the candidate mem path, the `schemas` allowlist
329/// (each entry an exact `name@x.y.z` pin or the literal `"*"` for
330/// any-schema), and an optional `default_cross_links` value applied
331/// to every mem the rule matches.
332#[derive(Debug, Clone, PartialEq, Eq)]
333pub struct CreateRuleSetting {
334    pub pattern: String,
335    pub schemas: Vec<String>,
336    pub default_cross_links: Option<CrossLinkValue>,
337}
338
339/// One `[[mem_management.delete]]` rule. Carries only a `pattern`;
340/// delete has no schema dimension.
341#[derive(Debug, Clone, PartialEq, Eq)]
342pub struct DeleteRuleSetting {
343    pub pattern: String,
344}
345
346/// The literal `"*"` schema-allowlist entry that admits any pinned
347/// schema. Consumed by the create-rule allowlist parser.
348pub const SCHEMA_WILDCARD: &str = "*";
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    fn pin(name: &str) -> SchemaRef {
355        SchemaRef::new(name, semver::Version::new(1, 0, 0))
356    }
357
358    #[test]
359    fn empty_workspace_has_no_mounts() {
360        let ws = Workspace::empty();
361        assert!(ws.mounts.is_empty());
362    }
363
364    #[test]
365    fn durability_follows_storage_kind() {
366        // On-disk backends persist; only in-memory is volatile. This is
367        // the fact the durability marker projects across overview / health
368        // / mutation responses.
369        let folder = MountStorage::Folder {
370            path: PathBuf::from("/work/mem"),
371        };
372        let git = MountStorage::GitBranch {
373            gitdir: PathBuf::from("/work/mem-repo/.git"),
374            branch: "specs".into(),
375        };
376        let archive = MountStorage::Archive {
377            path: PathBuf::from("/work/curated.mem"),
378        };
379        let in_memory = MountStorage::InMemory;
380
381        assert!(folder.is_durable());
382        assert!(git.is_durable());
383        assert!(archive.is_durable());
384        assert!(!in_memory.is_durable());
385
386        // The backend_id kebab string the marker rides alongside.
387        assert_eq!(folder.backend_id(), "folder");
388        assert_eq!(git.backend_id(), "git-branch");
389        assert_eq!(archive.backend_id(), "archive");
390        assert_eq!(in_memory.backend_id(), "in-memory");
391    }
392
393    #[test]
394    fn mount_can_describe_folder_storage() {
395        let m = Mount {
396            mem: "specs".into(),
397            schema: Some(pin("default")),
398            storage: MountStorage::Folder {
399                path: PathBuf::from("/work/mem"),
400            },
401            capability: MountCapability::Write,
402            lifecycle: MountLifecycle::Eager,
403            cross_linkable: true,
404            migration_target: None,
405        };
406        assert_eq!(m.mem, "specs");
407        assert!(matches!(m.storage, MountStorage::Folder { .. }));
408    }
409
410    #[test]
411    fn mount_can_describe_git_branch_storage() {
412        let m = Mount {
413            mem: "engine".into(),
414            schema: Some(pin("default")),
415            storage: MountStorage::GitBranch {
416                gitdir: PathBuf::from("/work/mem-repo/.git"),
417                branch: "engine".into(),
418            },
419            capability: MountCapability::Write,
420            lifecycle: MountLifecycle::Eager,
421            cross_linkable: true,
422            migration_target: None,
423        };
424        assert!(matches!(m.storage, MountStorage::GitBranch { .. }));
425    }
426
427    /// `Mount::mem_path()` derives the hierarchical path component
428    /// the delete-side lifecycle composer needs. Tolerates both bare
429    /// `<path>/<mem>` (operator-edited mounts.json) and
430    /// fully-qualified `refs/heads/<path>/<mem>` (runtime-created
431    /// mems). Folder / Archive variants always return `None`.
432    #[test]
433    fn mem_path_extracts_hierarchical_prefix_from_git_branch() {
434        // Bare hierarchical (mounts.json shape).
435        let m = Mount {
436            mem: "engine".into(),
437            schema: Some(pin("default")),
438            storage: MountStorage::GitBranch {
439                gitdir: PathBuf::from("/work/mem-repo/.git"),
440                branch: "memstead/engine".into(),
441            },
442            capability: MountCapability::Write,
443            lifecycle: MountLifecycle::Eager,
444            cross_linkable: true,
445            migration_target: None,
446        };
447        assert_eq!(m.mem_path(), Some("memstead".to_string()));
448
449        // Fully-qualified hierarchical (create_mem shape).
450        let m = Mount {
451            mem: "plan-foo".into(),
452            schema: Some(pin("default")),
453            storage: MountStorage::GitBranch {
454                gitdir: PathBuf::from("/work/mem-repo/.git"),
455                branch: "refs/heads/planning/plan-foo".into(),
456            },
457            capability: MountCapability::Write,
458            lifecycle: MountLifecycle::Eager,
459            cross_linkable: true,
460            migration_target: None,
461        };
462        assert_eq!(m.mem_path(), Some("planning".to_string()));
463
464        // Multi-segment hierarchical prefix.
465        let m = Mount {
466            mem: "leaf".into(),
467            schema: Some(pin("default")),
468            storage: MountStorage::GitBranch {
469                gitdir: PathBuf::from("/work/mem-repo/.git"),
470                branch: "refs/heads/a/b/c/leaf".into(),
471            },
472            capability: MountCapability::Write,
473            lifecycle: MountLifecycle::Eager,
474            cross_linkable: true,
475            migration_target: None,
476        };
477        assert_eq!(m.mem_path(), Some("a/b/c".to_string()));
478
479        // Flat layout (bare leaf, no prefix).
480        let m = Mount {
481            mem: "engine".into(),
482            schema: Some(pin("default")),
483            storage: MountStorage::GitBranch {
484                gitdir: PathBuf::from("/work/mem-repo/.git"),
485                branch: "engine".into(),
486            },
487            capability: MountCapability::Write,
488            lifecycle: MountLifecycle::Eager,
489            cross_linkable: true,
490            migration_target: None,
491        };
492        assert_eq!(m.mem_path(), None);
493
494        // Flat layout (fully-qualified, no prefix beyond refs/heads/).
495        let m = Mount {
496            mem: "engine".into(),
497            schema: Some(pin("default")),
498            storage: MountStorage::GitBranch {
499                gitdir: PathBuf::from("/work/mem-repo/.git"),
500                branch: "refs/heads/engine".into(),
501            },
502            capability: MountCapability::Write,
503            lifecycle: MountLifecycle::Eager,
504            cross_linkable: true,
505            migration_target: None,
506        };
507        assert_eq!(m.mem_path(), None);
508
509        // Folder backend has no hierarchical concept.
510        let m = Mount {
511            mem: "engine".into(),
512            schema: Some(pin("default")),
513            storage: MountStorage::Folder {
514                path: PathBuf::from("/work/mem"),
515            },
516            capability: MountCapability::Write,
517            lifecycle: MountLifecycle::Eager,
518            cross_linkable: true,
519            migration_target: None,
520        };
521        assert_eq!(m.mem_path(), None);
522    }
523
524    #[test]
525    fn mount_can_describe_archive_storage() {
526        let m = Mount {
527            mem: "external".into(),
528            schema: Some(pin("default")),
529            storage: MountStorage::Archive {
530                path: PathBuf::from("/deps/external.mem"),
531            },
532            capability: MountCapability::ReadOnly,
533            lifecycle: MountLifecycle::Lazy,
534            cross_linkable: false,
535            migration_target: None,
536        };
537        assert!(matches!(m.storage, MountStorage::Archive { .. }));
538        assert_eq!(m.capability, MountCapability::ReadOnly);
539    }
540
541    #[test]
542    fn workspace_with_heterogeneous_mounts() {
543        let ws = Workspace {
544            mounts: vec![
545                Mount {
546                    mem: "engine".into(),
547                    schema: Some(pin("default")),
548                    storage: MountStorage::GitBranch {
549                        gitdir: PathBuf::from("/work/mem-repo/.git"),
550                        branch: "engine".into(),
551                    },
552                    capability: MountCapability::Write,
553                    lifecycle: MountLifecycle::Eager,
554                    cross_linkable: true,
555                    migration_target: None,
556                },
557                Mount {
558                    mem: "macos".into(),
559                    schema: Some(pin("default")),
560                    storage: MountStorage::GitBranch {
561                        gitdir: PathBuf::from("/work/mem-repo/.git"),
562                        branch: "macos".into(),
563                    },
564                    capability: MountCapability::Write,
565                    lifecycle: MountLifecycle::Eager,
566                    cross_linkable: true,
567                    migration_target: None,
568                },
569                Mount {
570                    mem: "external".into(),
571                    schema: Some(pin("default")),
572                    storage: MountStorage::Archive {
573                        path: PathBuf::from("/deps/external.mem"),
574                    },
575                    capability: MountCapability::ReadOnly,
576                    lifecycle: MountLifecycle::Lazy,
577                    cross_linkable: false,
578                    migration_target: None,
579                },
580            ],
581            settings: WorkspaceSettings::default(),
582        };
583        assert_eq!(ws.mounts.len(), 3);
584        // Two mounts share a gitdir — the engine will pool the handle
585        // internally; the conceptual mount stays per-mem.
586        let shared_gitdir_mounts = ws
587            .mounts
588            .iter()
589            .filter(|m| matches!(&m.storage, MountStorage::GitBranch { gitdir, .. } if gitdir == std::path::Path::new("/work/mem-repo/.git")))
590            .count();
591        assert_eq!(shared_gitdir_mounts, 2);
592    }
593}