1use std::collections::BTreeMap;
70use std::path::{Component, Path, PathBuf};
71use std::process::Command;
72
73use globset::{Glob, GlobSet, GlobSetBuilder};
74
75use crate::Engine;
76use crate::pipeline::{MediumType, PatternMode};
77
78use super::brief::{NoSignalNote, SourceCursor, SyncCommand};
79use super::change_detection::{
80 StatMap, compute_stat_map, digest_stat_map, parse_digest_token, serialize_digest_token,
81};
82use super::resolve::{
83 ChangeStrategy, ResolvedIngest, ResolvedSource, find_git_root, resolve_change_strategy,
84};
85use super::slice::{
86 NoSignalReason, Slice, SliceOutcome, graph_slice_outcome, is_git_token, mtime_slice_outcome,
87};
88use crate::pipeline::Source;
89
90fn normalize_lexical(path: &Path) -> PathBuf {
94 let mut out: Vec<Component> = Vec::new();
95 for comp in path.components() {
96 match comp {
97 Component::CurDir => {}
98 Component::ParentDir => match out.last() {
99 Some(Component::Normal(_)) => {
100 out.pop();
101 }
102 Some(Component::RootDir | Component::Prefix(_)) => {}
103 _ => out.push(comp),
104 },
105 other => out.push(other),
106 }
107 }
108 out.iter().collect()
109}
110
111fn relative_path(from: &Path, to: &Path) -> PathBuf {
114 let from = normalize_lexical(from);
115 let to = normalize_lexical(to);
116 let from_comps: Vec<Component> = from.components().collect();
117 let to_comps: Vec<Component> = to.components().collect();
118 let mut common = 0;
119 while common < from_comps.len()
120 && common < to_comps.len()
121 && from_comps[common] == to_comps[common]
122 {
123 common += 1;
124 }
125 let mut result = PathBuf::new();
126 for _ in common..from_comps.len() {
127 result.push("..");
128 }
129 for comp in &to_comps[common..] {
130 result.push(comp.as_os_str());
131 }
132 result
133}
134
135pub fn medium_base(pointer: &str, workspace_root: &Path) -> PathBuf {
140 if pointer.is_empty() {
141 workspace_root.to_path_buf()
142 } else {
143 normalize_lexical(&workspace_root.join(pointer))
144 }
145}
146
147fn engine_state_denies(workspace_root: &Path) -> Vec<String> {
158 use crate::workspace_store::{FileWorkspaceStore, WorkspaceStoreAdapter};
159
160 let mut denies: Vec<String> = vec![
161 ".memstead/**".to_string(),
162 ".memstead.cache/**".to_string(),
163 "**/.memstead/**".to_string(),
164 "**/.memstead.cache/**".to_string(),
165 ];
166 if let Ok(ws) = FileWorkspaceStore.load(workspace_root) {
167 for mount in &ws.mounts {
168 let dir: Option<PathBuf> = match &mount.storage {
169 crate::workspace::MountStorage::GitBranch { gitdir, .. } => {
170 gitdir.parent().map(Path::to_path_buf)
171 }
172 crate::workspace::MountStorage::Folder { path } => Some(path.clone()),
173 crate::workspace::MountStorage::Archive { path, .. } => {
174 let rel = relative_path(workspace_root, &normalize_lexical(path));
176 denies.push(rel.to_string_lossy().to_string());
177 None
178 }
179 crate::workspace::MountStorage::InMemory => None,
181 };
182 if let Some(dir) = dir {
183 let rel = relative_path(workspace_root, &normalize_lexical(&dir));
184 if !rel.as_os_str().is_empty() {
188 denies.push(format!("{}/**", rel.to_string_lossy()));
189 }
190 }
191 }
192 }
193 denies
194}
195
196fn git_head(git_root: &Path) -> Option<String> {
198 let out = Command::new("git")
199 .args(["rev-parse", "HEAD"])
200 .current_dir(git_root)
201 .output()
202 .ok()?;
203 if !out.status.success() {
204 return None;
205 }
206 let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
207 (!sha.is_empty()).then_some(sha)
208}
209
210fn to_git_pathspec(pattern: &str, git_root: &Path, workspace_root: &Path, exclude: bool) -> String {
219 let magic = if exclude {
220 ":(glob,exclude)"
221 } else {
222 ":(glob)"
223 };
224 if pattern.starts_with("**") {
225 return format!("{magic}{pattern}");
226 }
227 let resolved = normalize_lexical(&workspace_root.join(pattern));
228 let git_rel = relative_path(git_root, &resolved);
229 format!("{magic}{}", git_rel.to_string_lossy())
230}
231
232fn in_repo_pathspec(
237 pattern: &str,
238 git_root: &Path,
239 workspace_root: &Path,
240 exclude: bool,
241) -> Option<String> {
242 if pattern.starts_with("**") {
244 return Some(to_git_pathspec(pattern, git_root, workspace_root, exclude));
245 }
246 let resolved = normalize_lexical(&workspace_root.join(pattern));
247 let git_rel = relative_path(git_root, &resolved);
248 if git_rel
249 .components()
250 .next()
251 .is_some_and(|c| c == Component::ParentDir)
252 {
253 return None;
254 }
255 let magic = if exclude {
256 ":(glob,exclude)"
257 } else {
258 ":(glob)"
259 };
260 Some(format!("{magic}{}", git_rel.to_string_lossy()))
261}
262
263fn build_glob_set(patterns: &[&str]) -> Option<GlobSet> {
266 let mut builder = GlobSetBuilder::new();
267 for pattern in patterns {
268 builder.add(Glob::new(pattern).ok()?);
269 }
270 builder.build().ok()
271}
272
273fn facet_unscoped(source: &Source) -> bool {
280 !source.scope.iter().any(|r| r.mode == PatternMode::Allow)
281}
282
283pub fn enumerate_facet_files(
300 source: &Source,
301 deny_paths: &[String],
302 workspace_root: &Path,
303) -> Vec<String> {
304 if !matches!(
305 source.medium_type,
306 MediumType::Codebase | MediumType::Filesystem
307 ) {
308 return Vec::new();
309 }
310 let mut allows: Vec<&str> = Vec::new();
311 let mut denies: Vec<&str> = Vec::new();
312 for rule in &source.scope {
313 match rule.mode {
314 PatternMode::Allow => allows.push(&rule.path),
315 PatternMode::Deny => denies.push(&rule.path),
316 }
317 }
318 for dp in deny_paths {
323 denies.push(dp);
324 }
325 let forced = engine_state_denies(workspace_root);
329 for f in &forced {
330 denies.push(f);
331 }
332 if allows.is_empty() {
333 return Vec::new();
334 }
335 let Some(allow_set) = build_glob_set(&allows) else {
336 return Vec::new();
337 };
338 let deny_set = if denies.is_empty() {
339 None
340 } else {
341 build_glob_set(&denies)
342 };
343
344 let base = medium_base(&source.pointer, workspace_root);
350 let mut out: Vec<String> = Vec::new();
351 let mut stack = vec![base];
352 while let Some(dir) = stack.pop() {
353 let Ok(entries) = std::fs::read_dir(&dir) else {
354 continue;
355 };
356 for entry in entries.flatten() {
357 let Ok(file_type) = entry.file_type() else {
358 continue;
359 };
360 let path = entry.path();
361 if file_type.is_dir() {
362 let skip = path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
363 VCS_INTERNAL_DIRS.contains(&n) || n == ".memstead" || n == ".memstead.cache"
368 });
369 if !skip {
370 stack.push(path);
371 }
372 } else if file_type.is_file() {
373 let rel = relative_path(workspace_root, &normalize_lexical(&path))
374 .to_string_lossy()
375 .to_string();
376 let denied = deny_set.as_ref().is_some_and(|d| d.is_match(&rel));
377 if allow_set.is_match(&rel) && !denied {
378 out.push(rel);
379 }
380 }
381 }
382 }
383 out.sort();
384 out.dedup();
385 out
386}
387
388fn compute_git_slice(
391 source: &Source,
392 deny_paths: &[String],
393 workspace_root: &Path,
394 baseline: Option<&str>,
395) -> SliceOutcome {
396 let base = medium_base(&source.pointer, workspace_root);
397 let Some(git_root) = find_git_root(&base) else {
398 return SliceOutcome::NoSignal {
399 reason: NoSignalReason::GitUnavailable,
400 };
401 };
402 let Some(head) = git_head(&git_root) else {
403 return SliceOutcome::NoSignal {
404 reason: NoSignalReason::GitUnavailable,
405 };
406 };
407
408 let baseline = match baseline {
409 Some(b) if is_git_token(b) => b,
410 _ => return SliceOutcome::Reseed { token: head },
412 };
413 if baseline == head {
414 return SliceOutcome::Unchanged { token: head };
415 }
416
417 let mut allows: Vec<&str> = Vec::new();
419 let mut denies: Vec<&str> = Vec::new();
420 for rule in &source.scope {
421 match rule.mode {
422 PatternMode::Allow => allows.push(&rule.path),
423 PatternMode::Deny => denies.push(&rule.path),
424 }
425 }
426 if allows.is_empty() {
427 return SliceOutcome::NoSignal {
430 reason: NoSignalReason::Unscoped,
431 };
432 }
433 for dp in deny_paths {
434 denies.push(dp);
435 }
436 let forced = engine_state_denies(workspace_root);
440 for f in &forced {
441 denies.push(f);
442 }
443 let mut specs: Vec<String> = Vec::with_capacity(allows.len() + denies.len());
444 for a in &allows {
445 specs.push(to_git_pathspec(a, &git_root, workspace_root, false));
446 }
447 for d in &denies {
448 if let Some(spec) = in_repo_pathspec(d, &git_root, workspace_root, true) {
457 specs.push(spec);
458 }
459 }
460
461 let mut cmd = Command::new("git");
462 cmd.args([
463 "diff",
464 "--no-renames",
465 "--name-status",
466 baseline,
467 &head,
468 "--",
469 ]);
470 cmd.args(&specs);
471 cmd.current_dir(&git_root);
472 let out = match cmd.output() {
473 Ok(o) if o.status.success() => o,
474 _ => {
477 return SliceOutcome::NoSignal {
478 reason: NoSignalReason::GitUnavailable,
479 };
480 }
481 };
482 let text = String::from_utf8_lossy(&out.stdout);
483
484 let mut slice = Slice::default();
485 for line in text.lines() {
486 if line.trim().is_empty() {
487 continue;
488 }
489 let Some(tab) = line.find('\t') else { continue };
490 let status = line[..tab].trim();
491 let git_path = line[tab + 1..].trim();
492 let ws_path = relative_path(workspace_root, &normalize_lexical(&git_root.join(git_path)))
493 .to_string_lossy()
494 .to_string();
495 match status.chars().next() {
496 Some('A') => slice.added.push(ws_path),
497 Some('D') => slice.deleted.push(ws_path),
498 _ => slice.modified.push(ws_path),
500 }
501 }
502 slice.added.sort();
503 slice.modified.sort();
504 slice.deleted.sort();
505 SliceOutcome::Changed {
506 token: head,
507 slice,
508 degraded: false,
509 }
510}
511
512fn compute_graph_slice(engine: &Engine, source_mem: &str, baseline: Option<&str>) -> SliceOutcome {
516 let current = match engine.mem_head_sha(source_mem) {
517 Ok(Some(sha)) => sha,
518 _ => {
520 return SliceOutcome::NoSignal {
521 reason: NoSignalReason::GraphSnapshotMissing,
522 };
523 }
524 };
525 let changed = matches!(baseline, Some(b) if is_git_token(b) && b != current);
527 if changed {
528 let baseline = baseline.expect("changed implies a baseline");
529 match engine.changes_since(source_mem, baseline, None) {
530 Ok(report) => graph_slice_outcome(Some(baseline), ¤t, &report.changes),
531 Err(_) => SliceOutcome::NoSignal {
533 reason: NoSignalReason::GraphSnapshotMissing,
534 },
535 }
536 } else {
537 graph_slice_outcome(baseline, ¤t, &[])
538 }
539}
540
541fn cursor_memo_path(cache_root: &Path, ingest_name: &str, facet_ref: &str) -> PathBuf {
557 let safe: String = facet_ref
558 .chars()
559 .map(|c| {
560 if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') {
561 c
562 } else {
563 '_'
564 }
565 })
566 .collect();
567 cache_root
568 .join("source-cursor")
569 .join(ingest_name)
570 .join(format!("{safe}.json"))
571}
572
573fn read_cursor_memo(
575 cache_root: &Path,
576 ingest: &str,
577 facet: &str,
578 aggregate: &str,
579) -> Option<StatMap> {
580 let bytes = std::fs::read(cursor_memo_path(cache_root, ingest, facet)).ok()?;
581 let memo: BTreeMap<String, StatMap> = serde_json::from_slice(&bytes).ok()?;
582 memo.get(aggregate).cloned()
583}
584
585fn write_cursor_memo(cache_root: &Path, ingest: &str, facet: &str, aggregate: &str, map: &StatMap) {
588 let path = cursor_memo_path(cache_root, ingest, facet);
589 let mut memo: BTreeMap<String, StatMap> = std::fs::read(&path)
590 .ok()
591 .and_then(|b| serde_json::from_slice(&b).ok())
592 .unwrap_or_default();
593 memo.insert(aggregate.to_string(), map.clone());
594 if memo.len() > 3 {
595 let drop: Vec<String> = memo
597 .keys()
598 .filter(|k| k.as_str() != aggregate)
599 .skip(2)
600 .cloned()
601 .collect();
602 for key in drop {
603 memo.remove(&key);
604 }
605 }
606 if let Some(parent) = path.parent() {
607 let _ = std::fs::create_dir_all(parent);
608 }
609 if let Ok(bytes) = serde_json::to_vec(&memo) {
610 let _ = std::fs::write(&path, bytes);
611 }
612}
613
614fn active_deny_path(workspace_root: &Path) -> PathBuf {
626 workspace_root
627 .join(".memstead.cache")
628 .join("projection")
629 .join("active-deny-paths.json")
630}
631
632pub fn write_active_deny_file(workspace_root: &Path, ingest_name: &str, deny_paths: &[String]) {
645 let path = active_deny_path(workspace_root);
646 let _ = std::fs::remove_file(&path);
649 if let Some(parent) = path.parent() {
650 let _ = std::fs::create_dir_all(parent);
651 }
652 let payload = serde_json::json!({
653 "ingest": ingest_name,
654 "deny_paths": deny_paths,
655 });
656 if let Ok(bytes) = serde_json::to_vec(&payload) {
657 let _ = std::fs::write(&path, bytes);
658 }
659}
660
661const VCS_INTERNAL_DIRS: &[&str] = &[".git", ".svn", ".hg"];
664
665const DEAD_DENY_SKIP_DIRS: &[&str] = &[
669 ".git",
670 "node_modules",
671 "target",
672 "dist",
673 ".memstead.cache",
674 ".sqlx",
675 ".svn",
676 ".hg",
677];
678
679fn walk_tree_bounded(base: &Path, workspace_root: &Path, cap: usize) -> Option<Vec<String>> {
685 let mut out: Vec<String> = Vec::new();
686 let mut stack = vec![base.to_path_buf()];
687 while let Some(dir) = stack.pop() {
688 let Ok(entries) = std::fs::read_dir(&dir) else {
689 continue;
690 };
691 for entry in entries.flatten() {
692 let Ok(file_type) = entry.file_type() else {
693 continue;
694 };
695 let path = entry.path();
696 if file_type.is_dir() {
697 let skip = path
698 .file_name()
699 .and_then(|n| n.to_str())
700 .is_some_and(|n| DEAD_DENY_SKIP_DIRS.contains(&n));
701 if !skip {
702 stack.push(path);
703 }
704 } else if file_type.is_file() {
705 if out.len() >= cap {
706 return None;
707 }
708 out.push(
709 relative_path(workspace_root, &normalize_lexical(&path))
710 .to_string_lossy()
711 .to_string(),
712 );
713 }
714 }
715 }
716 Some(out)
717}
718
719fn dead_deny_entries(resolved: &ResolvedIngest, workspace_root: &Path) -> Vec<String> {
730 if resolved.deny_paths.is_empty() {
731 return Vec::new();
732 }
733 let base = find_git_root(workspace_root).unwrap_or_else(|| workspace_root.to_path_buf());
734 let Some(files) = walk_tree_bounded(&base, workspace_root, 100_000) else {
735 return Vec::new();
736 };
737 let mut dead: Vec<String> = Vec::new();
738 for entry in &resolved.deny_paths {
739 let Some(set) = build_glob_set(&[entry.as_str()]) else {
740 continue;
743 };
744 if !files.iter().any(|f| set.is_match(f)) {
745 dead.push(entry.clone());
746 }
747 }
748 dead
749}
750
751fn compute_mtime_slice(
756 source: &Source,
757 ingest_name: &str,
758 deny_paths: &[String],
759 workspace_root: &Path,
760 cache_root: &Path,
761 baseline: Option<&str>,
762) -> SliceOutcome {
763 if facet_unscoped(source) {
764 return SliceOutcome::NoSignal {
767 reason: NoSignalReason::Unscoped,
768 };
769 }
770 let files = enumerate_facet_files(source, deny_paths, workspace_root);
771 let now_map = compute_stat_map(&files, workspace_root);
772 let now_digest = digest_stat_map(&now_map);
773 write_cursor_memo(
774 cache_root,
775 ingest_name,
776 &source.name,
777 &now_digest.aggregate,
778 &now_map,
779 );
780 let prev_map = baseline
781 .and_then(parse_digest_token)
782 .and_then(|base| read_cursor_memo(cache_root, ingest_name, &source.name, &base.aggregate));
783 mtime_slice_outcome(baseline, prev_map.as_ref(), &now_map)
784}
785
786fn current_primary_token(
790 engine: &Engine,
791 source: &Source,
792 deny_paths: &[String],
793 workspace_root: &Path,
794) -> Option<String> {
795 match resolve_change_strategy(source, workspace_root) {
796 ChangeStrategy::Git => git_head(&find_git_root(&medium_base(
797 &source.pointer,
798 workspace_root,
799 ))?),
800 ChangeStrategy::Graph => engine.mem_head_sha(&source.pointer).ok().flatten(),
801 ChangeStrategy::Mtime => {
802 if facet_unscoped(source) {
803 None
806 } else {
807 let files = enumerate_facet_files(source, deny_paths, workspace_root);
808 Some(serialize_digest_token(&digest_stat_map(&compute_stat_map(
809 &files,
810 workspace_root,
811 ))))
812 }
813 }
814 ChangeStrategy::None => None,
815 }
816}
817
818pub fn source_moved(engine: &Engine, resolved: &ResolvedIngest, workspace_root: &Path) -> bool {
825 source_moved_since(engine, resolved, workspace_root, "synced", false)
826}
827
828pub fn source_moved_since(
840 engine: &Engine,
841 resolved: &ResolvedIngest,
842 workspace_root: &Path,
843 state: &str,
844 missing_baseline_is_moved: bool,
845) -> bool {
846 let dest = &resolved.destination_mem;
847 let baseline_map = engine
848 .mem_config_for(dest)
849 .map(|c| c.sync_state.clone())
850 .unwrap_or_default();
851
852 for source in &resolved.sources {
853 let (facet_ref, current) = match source {
854 ResolvedSource::Primary(p) => (
855 p.name.clone(),
856 current_primary_token(engine, p, &resolved.deny_paths, workspace_root),
857 ),
858 ResolvedSource::Reference { mem } => {
859 (mem.clone(), engine.mem_head_sha(mem).ok().flatten())
860 }
861 };
862 let key = format!("{}/{}#{state}", resolved.name, facet_ref);
863 let Some(baseline) = baseline_map.get(&key) else {
864 if missing_baseline_is_moved && current.as_deref().is_some_and(|c| !c.is_empty()) {
866 return true;
867 }
868 continue;
869 };
870 if let Some(current) = current
871 && !current.is_empty()
872 && current != *baseline
873 {
874 return true;
875 }
876 }
877 false
878}
879
880pub fn compute_source_cursor(
883 engine: &Engine,
884 resolved: &ResolvedIngest,
885 workspace_root: &Path,
886) -> SourceCursor {
887 let dest = &resolved.destination_mem;
888 let baseline_map = engine
889 .mem_config_for(dest)
890 .map(|c| c.sync_state.clone())
891 .unwrap_or_default();
892
893 let cache_root = workspace_root.join(".memstead.cache").join("ingest");
894 let mut union = Slice::default();
895 let mut write_commands: Vec<SyncCommand> = Vec::new();
896 let mut reseed: Vec<SyncCommand> = Vec::new();
897 let mut no_signal: Vec<NoSignalNote> = Vec::new();
898 let mut degraded = false;
899
900 for source in &resolved.sources {
901 let (facet_ref, outcome) = match source {
904 ResolvedSource::Primary(p) => {
905 let key = format!("{}/{}#synced", resolved.name, p.name);
906 let baseline = baseline_map.get(&key).map(String::as_str);
907 let outcome = match resolve_change_strategy(p, workspace_root) {
908 ChangeStrategy::Git => {
909 compute_git_slice(p, &resolved.deny_paths, workspace_root, baseline)
910 }
911 ChangeStrategy::Graph => compute_graph_slice(engine, &p.pointer, baseline),
913 ChangeStrategy::Mtime => compute_mtime_slice(
914 p,
915 &resolved.name,
916 &resolved.deny_paths,
917 workspace_root,
918 &cache_root,
919 baseline,
920 ),
921 ChangeStrategy::None => SliceOutcome::NoSignal {
923 reason: NoSignalReason::DetectionNone,
924 },
925 };
926 (p.name.clone(), outcome)
927 }
928 ResolvedSource::Reference { mem } => {
929 let key = format!("{}/{}#synced", resolved.name, mem);
930 let baseline = baseline_map.get(&key).map(String::as_str);
931 (mem.clone(), compute_graph_slice(engine, mem, baseline))
932 }
933 };
934
935 let key = format!("{}/{}#synced", resolved.name, facet_ref);
936 match outcome {
937 SliceOutcome::Unchanged { .. } => {}
941 SliceOutcome::NoSignal { reason } => no_signal.push(NoSignalNote {
943 source: facet_ref.clone(),
944 reason,
945 }),
946 SliceOutcome::Reseed { token } => reseed.push(SyncCommand { key, token }),
947 SliceOutcome::Changed {
948 token,
949 slice,
950 degraded: d,
951 } => {
952 union.added.extend(slice.added);
953 union.modified.extend(slice.modified);
954 union.deleted.extend(slice.deleted);
955 degraded |= d;
956 write_commands.push(SyncCommand { key, token });
957 }
958 }
959 }
960
961 dedupe_sort(&mut union.added);
962 dedupe_sort(&mut union.modified);
963 dedupe_sort(&mut union.deleted);
964 let any_changes =
965 !union.added.is_empty() || !union.modified.is_empty() || !union.deleted.is_empty();
966
967 SourceCursor {
968 union,
969 write_commands,
970 reseed,
971 no_signal,
972 any_changes,
973 degraded,
974 dead_denies: dead_deny_entries(resolved, workspace_root),
975 dest_mem: dest.clone(),
976 binding_id: resolved.name.clone(),
980 }
981}
982
983fn dedupe_sort(v: &mut Vec<String>) {
984 v.sort();
985 v.dedup();
986}
987
988#[cfg(test)]
989mod tests {
990 use super::*;
991
992 #[test]
993 fn normalize_resolves_dot_and_dotdot() {
994 assert_eq!(
995 normalize_lexical(Path::new("/a/b/../c/./d")),
996 PathBuf::from("/a/c/d")
997 );
998 assert_eq!(
999 normalize_lexical(Path::new("/a/../../b")),
1000 PathBuf::from("/b"),
1001 "dotdot past root is clamped"
1002 );
1003 }
1004
1005 #[test]
1006 fn relative_computes_updowns() {
1007 assert_eq!(
1008 relative_path(Path::new("/a/b"), Path::new("/a/b/c/d")),
1009 PathBuf::from("c/d")
1010 );
1011 assert_eq!(
1012 relative_path(Path::new("/a/b/c"), Path::new("/a/x")),
1013 PathBuf::from("../../x")
1014 );
1015 assert_eq!(
1017 relative_path(Path::new("/m/public"), Path::new("/m/public/crates/x.rs")),
1018 PathBuf::from("crates/x.rs")
1019 );
1020 assert_eq!(
1021 relative_path(Path::new("/m/graph"), Path::new("/m/public/crates/x.rs")),
1022 PathBuf::from("../public/crates/x.rs")
1023 );
1024 }
1025
1026 #[test]
1027 fn pathspec_builds_glob_magic_relative_to_git_root() {
1028 let ws = Path::new("/m/graph");
1029 let git_root = Path::new("/m/public");
1030 assert_eq!(
1031 to_git_pathspec("../public/**/*.rs", git_root, ws, false),
1032 ":(glob)**/*.rs"
1033 );
1034 assert_eq!(
1035 to_git_pathspec("../public/target/**", git_root, ws, true),
1036 ":(glob,exclude)target/**"
1037 );
1038 }
1039
1040 #[test]
1046 fn wildcard_prefixed_pathspec_reanchors_verbatim() {
1047 let ws = Path::new("/m/ws");
1048 let git_root = Path::new("/m/ws/src");
1049 assert_eq!(to_git_pathspec("**/*", git_root, ws, false), ":(glob)**/*");
1050 assert_eq!(
1051 in_repo_pathspec("**/__pycache__/**", git_root, ws, true).as_deref(),
1052 Some(":(glob,exclude)**/__pycache__/**")
1053 );
1054 }
1055
1056 use crate::ingest::resolve::Source;
1057 use crate::pipeline::{MediumType, PatternEntry};
1058
1059 fn git(repo: &Path, args: &[&str]) {
1060 let status = std::process::Command::new("git")
1061 .args(args)
1062 .current_dir(repo)
1063 .env("GIT_AUTHOR_NAME", "t")
1064 .env("GIT_AUTHOR_EMAIL", "t@t")
1065 .env("GIT_COMMITTER_NAME", "t")
1066 .env("GIT_COMMITTER_EMAIL", "t@t")
1067 .output()
1068 .unwrap();
1069 assert!(
1070 status.status.success(),
1071 "git {args:?}: {}",
1072 String::from_utf8_lossy(&status.stderr)
1073 );
1074 }
1075
1076 fn primary(scope: Vec<PatternEntry>) -> Source {
1077 Source {
1078 name: "src".to_string(),
1079 medium_type: MediumType::Codebase,
1080 pointer: String::new(),
1081 change_detection: Some("git".to_string()),
1082 scope,
1083 engagement: None,
1084 preparation: None,
1085 }
1086 }
1087
1088 #[test]
1096 fn deny_dialect_fixture_matches_engine_slice() {
1097 let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR"))
1098 .join("../../plugins/claude-code/hooks/deny-dialect-fixture.json");
1099 let raw = std::fs::read(&fixture_path)
1100 .unwrap_or_else(|e| panic!("read fixture {}: {e}", fixture_path.display()));
1101 let fixture: serde_json::Value = serde_json::from_slice(&raw).unwrap();
1102 let strs = |key: &str| -> Vec<String> {
1103 fixture[key]
1104 .as_array()
1105 .unwrap()
1106 .iter()
1107 .map(|v| v.as_str().unwrap().to_string())
1108 .collect()
1109 };
1110 let entries = strs("entries");
1111 let blocked = strs("blocked");
1112 let allowed = strs("allowed");
1113
1114 let ws = tempfile::tempdir().unwrap();
1115 for rel in blocked.iter().chain(allowed.iter()) {
1116 let path = ws.path().join(rel);
1117 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1118 std::fs::write(&path, "x").unwrap();
1119 }
1120
1121 let source = primary(vec![PatternEntry {
1123 path: "**".to_string(),
1124 mode: PatternMode::Allow,
1125 }]);
1126 let mut got = enumerate_facet_files(&source, &entries, ws.path());
1127 got.sort();
1128 let mut want = allowed.clone();
1129 want.sort();
1130 assert_eq!(
1131 got, want,
1132 "engine slice must equal the fixture `allowed` set"
1133 );
1134
1135 for b in &blocked {
1136 assert!(
1137 !got.contains(b),
1138 "denied `{b}` leaked into the engine slice"
1139 );
1140 }
1141 for a in &allowed {
1142 assert!(
1143 got.contains(a),
1144 "allowed `{a}` missing from the engine slice"
1145 );
1146 }
1147 }
1148
1149 #[test]
1153 fn active_deny_file_overwrites_and_writes_empty() {
1154 let ws = tempfile::tempdir().unwrap();
1155 let path = active_deny_path(ws.path());
1156
1157 write_active_deny_file(ws.path(), "x-graph", &["dev/**".to_string()]);
1158 let v: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
1159 assert_eq!(v["ingest"], "x-graph");
1160 assert_eq!(v["deny_paths"], serde_json::json!(["dev/**"]));
1161
1162 write_active_deny_file(ws.path(), "y-graph", &["**/VISION.md".to_string()]);
1164 let v: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
1165 assert_eq!(v["ingest"], "y-graph");
1166 assert_eq!(v["deny_paths"], serde_json::json!(["**/VISION.md"]));
1167
1168 write_active_deny_file(ws.path(), "z-graph", &[]);
1170 let v: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
1171 assert_eq!(v["ingest"], "z-graph");
1172 assert_eq!(v["deny_paths"], serde_json::json!([]));
1173 }
1174
1175 #[test]
1179 fn out_of_repo_deny_pathspec_is_dropped() {
1180 let ws = Path::new("/m/graph");
1181 let git_root = Path::new("/m/public");
1182 assert_eq!(in_repo_pathspec("../dev/**", git_root, ws, true), None);
1184 assert_eq!(in_repo_pathspec("../CLAUDE.md", git_root, ws, true), None);
1185 assert_eq!(
1187 in_repo_pathspec("../public/target/**", git_root, ws, true),
1188 Some(":(glob,exclude)target/**".to_string())
1189 );
1190 }
1191
1192 #[test]
1197 fn git_slice_survives_cross_repo_deny() {
1198 let repo = tempfile::tempdir().unwrap();
1199 let root = repo.path();
1200 std::fs::write(root.join("keep.rs"), "one").unwrap();
1201 git(root, &["init", "-q"]);
1202 git(root, &["add", "-A"]);
1203 git(root, &["commit", "-qm", "seed"]);
1204 let baseline = String::from_utf8(
1205 std::process::Command::new("git")
1206 .args(["rev-parse", "HEAD"])
1207 .current_dir(root)
1208 .output()
1209 .unwrap()
1210 .stdout,
1211 )
1212 .unwrap()
1213 .trim()
1214 .to_string();
1215 std::fs::write(root.join("keep.rs"), "two").unwrap();
1216 git(root, &["add", "-A"]);
1217 git(root, &["commit", "-qm", "move"]);
1218
1219 let source = primary(vec![PatternEntry {
1220 path: "**/*.rs".to_string(),
1221 mode: PatternMode::Allow,
1222 }]);
1223 let outcome = compute_git_slice(&source, &["../dev/**".to_string()], root, Some(&baseline));
1225 match outcome {
1226 SliceOutcome::Changed { slice, .. } => {
1227 assert_eq!(slice.modified, vec!["keep.rs"]);
1228 }
1229 other => panic!("expected Changed (deny dropped), got {other:?}"),
1230 }
1231 }
1232
1233 #[test]
1236 fn git_slice_diffs_baseline_to_head() {
1237 let repo = tempfile::tempdir().unwrap();
1238 let root = repo.path();
1239 git(root, &["init", "-q"]);
1240 std::fs::write(root.join("keep.rs"), "one").unwrap();
1241 std::fs::write(root.join("gone.rs"), "bye").unwrap();
1242 std::fs::write(root.join("note.md"), "ignored-by-scope").unwrap();
1243 git(root, &["add", "-A"]);
1244 git(root, &["commit", "-qm", "base"]);
1245 let baseline = String::from_utf8(
1246 std::process::Command::new("git")
1247 .args(["rev-parse", "HEAD"])
1248 .current_dir(root)
1249 .output()
1250 .unwrap()
1251 .stdout,
1252 )
1253 .unwrap()
1254 .trim()
1255 .to_string();
1256
1257 std::fs::write(root.join("keep.rs"), "two").unwrap();
1259 std::fs::remove_file(root.join("gone.rs")).unwrap();
1260 std::fs::write(root.join("new.rs"), "hi").unwrap();
1261 std::fs::write(root.join("note.md"), "still ignored").unwrap();
1262 git(root, &["add", "-A"]);
1263 git(root, &["commit", "-qm", "move"]);
1264
1265 let source = primary(vec![PatternEntry {
1267 path: "**/*.rs".to_string(),
1268 mode: PatternMode::Allow,
1269 }]);
1270 let outcome = compute_git_slice(&source, &[], root, Some(&baseline));
1271 match outcome {
1272 SliceOutcome::Changed {
1273 slice, degraded, ..
1274 } => {
1275 assert!(!degraded);
1276 assert_eq!(slice.added, vec!["new.rs"]);
1277 assert_eq!(slice.modified, vec!["keep.rs"]);
1278 assert_eq!(slice.deleted, vec!["gone.rs"]);
1279 }
1280 other => panic!("expected Changed, got {other:?}"),
1281 }
1282
1283 let head = String::from_utf8(
1285 std::process::Command::new("git")
1286 .args(["rev-parse", "HEAD"])
1287 .current_dir(root)
1288 .output()
1289 .unwrap()
1290 .stdout,
1291 )
1292 .unwrap()
1293 .trim()
1294 .to_string();
1295 assert!(matches!(
1296 compute_git_slice(&source, &[], root, Some(&head)),
1297 SliceOutcome::Unchanged { .. }
1298 ));
1299
1300 assert!(matches!(
1302 compute_git_slice(&source, &[], root, None),
1303 SliceOutcome::Reseed { .. }
1304 ));
1305 }
1306
1307 #[test]
1310 fn enumerate_honours_allow_and_deny() {
1311 let ws = tempfile::tempdir().unwrap();
1312 let root = ws.path();
1313 std::fs::create_dir_all(root.join("sub")).unwrap();
1314 std::fs::write(root.join("a.rs"), "").unwrap();
1315 std::fs::write(root.join("sub/b.rs"), "").unwrap();
1316 std::fs::write(root.join("c.md"), "").unwrap();
1317
1318 let source = primary(vec![
1321 PatternEntry {
1322 path: "**/*.rs".to_string(),
1323 mode: PatternMode::Allow,
1324 },
1325 PatternEntry {
1326 path: "sub/**".to_string(),
1327 mode: PatternMode::Deny,
1328 },
1329 ]);
1330 assert_eq!(enumerate_facet_files(&source, &[], root), vec!["a.rs"]);
1331
1332 let mut graph_source = source.clone();
1334 graph_source.medium_type = MediumType::Graph;
1335 assert!(enumerate_facet_files(&graph_source, &[], root).is_empty());
1336 }
1337
1338 #[test]
1341 fn mtime_driver_reseeds_then_diffs_precisely() {
1342 let ws = tempfile::tempdir().unwrap();
1343 let root = ws.path();
1344 let cache = root.join(".memstead.cache").join("ingest");
1345 std::fs::write(root.join("a.rs"), "one").unwrap();
1346 std::fs::write(root.join("gone.rs"), "bye").unwrap();
1347 let source = primary(vec![PatternEntry {
1348 path: "**/*.rs".to_string(),
1349 mode: PatternMode::Allow,
1350 }]);
1351
1352 let token = match compute_mtime_slice(&source, "ing", &[], root, &cache, None) {
1354 SliceOutcome::Reseed { token } => token,
1355 other => panic!("expected Reseed, got {other:?}"),
1356 };
1357
1358 std::fs::write(root.join("a.rs"), "one-longer").unwrap();
1360 std::fs::remove_file(root.join("gone.rs")).unwrap();
1361 std::fs::write(root.join("new.rs"), "x").unwrap();
1362
1363 match compute_mtime_slice(&source, "ing", &[], root, &cache, Some(&token)) {
1365 SliceOutcome::Changed {
1366 slice, degraded, ..
1367 } => {
1368 assert!(
1369 !degraded,
1370 "memo present → precise, not a degraded full scan"
1371 );
1372 assert_eq!(slice.added, vec!["new.rs"]);
1373 assert_eq!(slice.modified, vec!["a.rs"]);
1374 assert_eq!(
1375 slice.deleted,
1376 vec!["gone.rs"],
1377 "deletions come from the memo"
1378 );
1379 }
1380 other => panic!("expected Changed, got {other:?}"),
1381 }
1382
1383 let stale = super::super::change_detection::serialize_digest_token(
1386 &super::super::change_detection::digest_stat_map(&stat_map_for(&["absent.rs"])),
1387 );
1388 match compute_mtime_slice(&source, "ing", &[], root, &cache, Some(&stale)) {
1389 SliceOutcome::Changed { degraded, .. } => assert!(degraded, "memo miss → degraded"),
1390 other => panic!("expected degraded Changed, got {other:?}"),
1391 }
1392 }
1393
1394 fn head_sha(repo: &Path) -> String {
1395 String::from_utf8(
1396 std::process::Command::new("git")
1397 .args(["rev-parse", "HEAD"])
1398 .current_dir(repo)
1399 .output()
1400 .unwrap()
1401 .stdout,
1402 )
1403 .unwrap()
1404 .trim()
1405 .to_string()
1406 }
1407
1408 fn slice_contains(slice: &Slice, path: &str) -> bool {
1409 let p = path.to_string();
1410 slice.added.contains(&p) || slice.modified.contains(&p) || slice.deleted.contains(&p)
1411 }
1412
1413 fn mtime_token(source: &Source, deny: &[String], root: &Path) -> String {
1417 let files = enumerate_facet_files(source, deny, root);
1418 serialize_digest_token(&digest_stat_map(&compute_stat_map(&files, root)))
1419 }
1420
1421 #[test]
1427 fn deny_paths_excluded_from_every_strategy_and_token() {
1428 use crate::binding::BuildMode;
1429 use crate::ingest::refinement::next_batch;
1430 use crate::pipeline::IngestTrigger;
1431
1432 let repo = tempfile::tempdir().unwrap();
1433 let root = repo.path();
1434 let cache = root.join(".memstead.cache").join("ingest");
1435
1436 git(root, &["init", "-q"]);
1439 std::fs::write(root.join("keep.rs"), "one").unwrap();
1440 std::fs::write(root.join("denied.rs"), "secret-one").unwrap();
1441 git(root, &["add", "-A"]);
1442 git(root, &["commit", "-qm", "base"]);
1443 let baseline = head_sha(root);
1444
1445 std::fs::write(root.join("keep.rs"), "two").unwrap();
1447 std::fs::write(root.join("denied.rs"), "secret-two").unwrap();
1448 git(root, &["add", "-A"]);
1449 git(root, &["commit", "-qm", "move"]);
1450
1451 let source = primary(vec![PatternEntry {
1454 path: "**/*.rs".to_string(),
1455 mode: PatternMode::Allow,
1456 }]);
1457 let deny = vec!["denied.rs".to_string()];
1458
1459 match compute_git_slice(&source, &deny, root, Some(&baseline)) {
1461 SliceOutcome::Changed { slice, .. } => {
1462 assert_eq!(slice.modified, vec!["keep.rs"]);
1463 assert!(!slice_contains(&slice, "denied.rs"), "git deny leak");
1464 }
1465 other => panic!("git: expected Changed, got {other:?}"),
1466 }
1467 match compute_git_slice(&source, &[], root, Some(&baseline)) {
1470 SliceOutcome::Changed { slice, .. } => {
1471 assert!(
1472 slice_contains(&slice, "denied.rs"),
1473 "un-denied, denied.rs is a genuine git change"
1474 );
1475 }
1476 other => panic!("git(no-deny): expected Changed, got {other:?}"),
1477 }
1478
1479 assert_eq!(enumerate_facet_files(&source, &deny, root), vec!["keep.rs"]);
1481 assert!(
1482 enumerate_facet_files(&source, &[], root).contains(&"denied.rs".to_string()),
1483 "un-denied, denied.rs is enumerated"
1484 );
1485
1486 let token = match compute_mtime_slice(&source, "ing", &deny, root, &cache, None) {
1488 SliceOutcome::Reseed { token } => token,
1489 other => panic!("mtime reseed expected, got {other:?}"),
1490 };
1491 std::fs::write(root.join("keep.rs"), "three-longer").unwrap();
1492 std::fs::write(root.join("denied.rs"), "secret-three-longer").unwrap();
1493 match compute_mtime_slice(&source, "ing", &deny, root, &cache, Some(&token)) {
1494 SliceOutcome::Changed { slice, .. } => {
1495 assert_eq!(slice.modified, vec!["keep.rs"]);
1496 assert!(!slice_contains(&slice, "denied.rs"), "mtime deny leak");
1497 }
1498 other => panic!("mtime: expected Changed, got {other:?}"),
1499 }
1500
1501 let token_present = mtime_token(&source, &deny, root);
1506 std::fs::remove_file(root.join("denied.rs")).unwrap();
1507 let token_absent = mtime_token(&source, &deny, root);
1508 assert_eq!(
1509 token_present, token_absent,
1510 "denied.rs must not influence the mtime digest / source_moved token"
1511 );
1512 std::fs::write(root.join("denied.rs"), "secret-restored").unwrap();
1513
1514 let resolved = ResolvedIngest {
1516 name: "ing".to_string(),
1517 mode: BuildMode::Discovery,
1518 trigger: IngestTrigger::Loop,
1519 batch_size: 50,
1520 deny_paths: deny.clone(),
1521 projection_ref: "m/p".to_string(),
1522 projection_mem: "m".to_string(),
1523 projection_name: "p".to_string(),
1524 intent: None,
1525 sources: vec![ResolvedSource::Primary(source.clone())],
1526 destination_mem: "m".to_string(),
1527 rules: None,
1528 post_actions: None,
1529 };
1530 let batch = next_batch(&resolved, root, &cache, 20).unwrap();
1531 assert!(
1532 batch.files.contains(&"keep.rs".to_string()),
1533 "keep.rs batched"
1534 );
1535 assert!(
1536 !batch.files.contains(&"denied.rs".to_string()),
1537 "denied.rs must never enter a refinement batch"
1538 );
1539 }
1540
1541 #[test]
1548 fn unscoped_facet_refuses_uniformly_and_empty_deny_is_distinct() {
1549 let repo = tempfile::tempdir().unwrap();
1550 let root = repo.path();
1551 let cache = root.join(".memstead.cache").join("ingest");
1552 git(root, &["init", "-q"]);
1553 std::fs::write(root.join("a.rs"), "one").unwrap();
1554 git(root, &["add", "-A"]);
1555 git(root, &["commit", "-qm", "base"]);
1556 let baseline = head_sha(root);
1557 std::fs::write(root.join("a.rs"), "two").unwrap();
1558 git(root, &["add", "-A"]);
1559 git(root, &["commit", "-qm", "move"]);
1560
1561 let unscoped = primary(vec![PatternEntry {
1564 path: "target/**".to_string(),
1565 mode: PatternMode::Deny,
1566 }]);
1567 assert_eq!(
1568 compute_git_slice(&unscoped, &[], root, Some(&baseline)),
1569 SliceOutcome::NoSignal {
1570 reason: NoSignalReason::Unscoped
1571 },
1572 "git refuses an unscoped facet"
1573 );
1574 assert_eq!(
1575 compute_mtime_slice(&unscoped, "ing", &[], root, &cache, None),
1576 SliceOutcome::NoSignal {
1577 reason: NoSignalReason::Unscoped
1578 },
1579 "mtime refuses an unscoped facet identically"
1580 );
1581 let empty_scope = primary(vec![]);
1583 assert_eq!(
1584 compute_git_slice(&empty_scope, &[], root, Some(&baseline)),
1585 SliceOutcome::NoSignal {
1586 reason: NoSignalReason::Unscoped
1587 }
1588 );
1589
1590 let scoped = primary(vec![PatternEntry {
1593 path: "**/*.rs".to_string(),
1594 mode: PatternMode::Allow,
1595 }]);
1596 assert!(
1597 matches!(
1598 compute_git_slice(&scoped, &[], root, Some(&baseline)),
1599 SliceOutcome::Changed { .. }
1600 ),
1601 "scoped facet + empty deny_paths → normal git slice, not a refusal"
1602 );
1603 assert!(
1604 matches!(
1605 compute_mtime_slice(&scoped, "ing", &[], root, &cache, None),
1606 SliceOutcome::Reseed { .. }
1607 ),
1608 "scoped facet + empty deny_paths → normal mtime reseed, not a refusal"
1609 );
1610 }
1611
1612 #[test]
1615 fn unscoped_facet_emits_no_refinement_batch() {
1616 use crate::binding::BuildMode;
1617 use crate::ingest::refinement::next_batch;
1618 use crate::pipeline::IngestTrigger;
1619
1620 let ws = tempfile::tempdir().unwrap();
1621 let root = ws.path();
1622 let cache = root.join(".memstead.cache").join("ingest");
1623 std::fs::write(root.join("a.rs"), "x").unwrap();
1624
1625 let resolved = ResolvedIngest {
1626 name: "ing".to_string(),
1627 mode: BuildMode::Discovery,
1628 trigger: IngestTrigger::Loop,
1629 batch_size: 50,
1630 deny_paths: vec![],
1631 projection_ref: "m/p".to_string(),
1632 projection_mem: "m".to_string(),
1633 projection_name: "p".to_string(),
1634 intent: None,
1635 sources: vec![ResolvedSource::Primary(primary(vec![]))],
1637 destination_mem: "m".to_string(),
1638 rules: None,
1639 post_actions: None,
1640 };
1641 assert!(
1642 next_batch(&resolved, root, &cache, 20).is_none(),
1643 "an all-unscoped ingest emits no refinement batch"
1644 );
1645 }
1646
1647 #[test]
1653 fn compute_source_cursor_notes_no_signal_reasons() {
1654 use crate::binding::BuildMode;
1655 use crate::pipeline::IngestTrigger;
1656
1657 let engine = crate::Engine::from_mounts(Vec::new()).unwrap();
1658 let ws = tempfile::tempdir().unwrap();
1660 let root = ws.path();
1661 std::fs::write(root.join("a.rs"), "x").unwrap();
1662
1663 let allow_rs = || {
1664 vec![PatternEntry {
1665 path: "**/*.rs".to_string(),
1666 mode: PatternMode::Allow,
1667 }]
1668 };
1669 let src = |facet: &str, declared: &str, scope: Vec<PatternEntry>| {
1670 ResolvedSource::Primary(Source {
1671 name: facet.to_string(),
1672 medium_type: MediumType::Filesystem,
1673 pointer: String::new(),
1674 change_detection: Some(declared.to_string()),
1675 scope,
1676 engagement: None,
1677 preparation: None,
1678 })
1679 };
1680
1681 let resolved = ResolvedIngest {
1682 name: "ing".to_string(),
1683 mode: BuildMode::Discovery,
1684 trigger: IngestTrigger::Loop,
1685 batch_size: 20,
1686 deny_paths: vec![],
1687 projection_ref: "m/p".to_string(),
1688 projection_mem: "m".to_string(),
1689 projection_name: "p".to_string(),
1690 intent: None,
1691 sources: vec![
1692 src("plan", "none", allow_rs()),
1694 src("blind", "mtime", vec![]),
1696 src("watched", "mtime", allow_rs()),
1698 ],
1699 destination_mem: "m".to_string(),
1700 rules: None,
1701 post_actions: None,
1702 };
1703
1704 let cursor = compute_source_cursor(&engine, &resolved, root);
1705 let reasons: BTreeMap<&str, NoSignalReason> = cursor
1706 .no_signal
1707 .iter()
1708 .map(|n| (n.source.as_str(), n.reason))
1709 .collect();
1710 assert_eq!(reasons.get("plan"), Some(&NoSignalReason::DetectionNone));
1711 assert_eq!(reasons.get("blind"), Some(&NoSignalReason::Unscoped));
1712 assert!(
1713 !reasons.contains_key("watched"),
1714 "a first-seen (reseed) source is not a no-signal note"
1715 );
1716 assert_eq!(cursor.no_signal.len(), 2);
1717 assert!(cursor.reseed.iter().any(|c| c.key == "ing/watched#synced"));
1719
1720 let out = crate::ingest::brief::render_changed_slice(&cursor);
1722 assert!(out.contains("- `plan`: `signal:none`"));
1723 assert!(out.contains("- `blind`: unscoped facet"));
1724 }
1725
1726 fn stat_map_for(paths: &[&str]) -> super::super::change_detection::StatMap {
1727 paths
1728 .iter()
1729 .map(|p| {
1730 (
1731 (*p).to_string(),
1732 super::super::change_detection::StatEntry { mtime: 1, size: 1 },
1733 )
1734 })
1735 .collect()
1736 }
1737
1738 #[test]
1744 fn engine_state_never_enumerates_even_when_allowed() {
1745 let ws = tempfile::tempdir().unwrap();
1746 let root = ws.path();
1747 for rel in [
1748 ".memstead/state/findings/muehle/f.json",
1749 ".memstead/projections/muehle/f.json",
1750 ".memstead.cache/ingest/source-cursor/muehle/f/f.json",
1751 "custom-repo/README.md",
1752 "Allgemein/Protokoll.md",
1753 "Allgemein/Vertrag.md",
1754 ] {
1755 let path = root.join(rel);
1756 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1757 std::fs::write(&path, "x").unwrap();
1758 }
1759 std::fs::write(
1763 root.join(".memstead/workspace.toml"),
1764 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1765 )
1766 .unwrap();
1767 std::fs::write(
1768 root.join(".memstead/state/mounts.json"),
1769 serde_json::json!({
1770 "format": "memstead-mounts-3",
1771 "mounts": [{
1772 "mem": "muehle",
1773 "schema": "default@1.0.0",
1774 "storage": {
1775 "type": "git-branch",
1776 "gitdir": "custom-repo/.git",
1777 "branch": "refs/heads/muehle"
1778 },
1779 "capability": "write",
1780 "lifecycle": "eager",
1781 "cross_linkable": true
1782 }]
1783 })
1784 .to_string(),
1785 )
1786 .unwrap();
1787
1788 let source = primary(vec![
1790 PatternEntry {
1791 path: "**/*".to_string(),
1792 mode: PatternMode::Allow,
1793 },
1794 PatternEntry {
1795 path: ".memstead/**".to_string(),
1796 mode: PatternMode::Allow,
1797 },
1798 PatternEntry {
1799 path: "custom-repo/**".to_string(),
1800 mode: PatternMode::Allow,
1801 },
1802 ]);
1803 let got = enumerate_facet_files(&source, &[], root);
1804 assert_eq!(
1805 got,
1806 vec!["Allgemein/Protokoll.md", "Allgemein/Vertrag.md"],
1807 "only source artifacts may enter the denominator"
1808 );
1809 }
1810
1811 #[test]
1816 fn git_slice_excludes_engine_state() {
1817 let repo = tempfile::tempdir().unwrap();
1818 let root = repo.path();
1819 git(root, &["init", "-q"]);
1820 std::fs::write(
1821 root.join("workspace.rs"), "x",
1823 )
1824 .unwrap();
1825 std::fs::create_dir_all(root.join(".memstead/state")).unwrap();
1826 std::fs::write(
1827 root.join(".memstead/workspace.toml"),
1828 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1829 )
1830 .unwrap();
1831 std::fs::write(
1832 root.join(".memstead/state/mounts.json"),
1833 serde_json::json!({
1834 "format": "memstead-mounts-3",
1835 "mounts": [{
1836 "mem": "muehle",
1837 "schema": "default@1.0.0",
1838 "storage": {
1839 "type": "git-branch",
1840 "gitdir": "custom-repo/.git",
1841 "branch": "refs/heads/muehle"
1842 },
1843 "capability": "write",
1844 "lifecycle": "eager",
1845 "cross_linkable": true
1846 }]
1847 })
1848 .to_string(),
1849 )
1850 .unwrap();
1851 git(root, &["add", "-A"]);
1852 git(root, &["commit", "-qm", "base"]);
1853 let baseline = String::from_utf8(
1854 std::process::Command::new("git")
1855 .args(["rev-parse", "HEAD"])
1856 .current_dir(root)
1857 .output()
1858 .unwrap()
1859 .stdout,
1860 )
1861 .unwrap()
1862 .trim()
1863 .to_string();
1864
1865 std::fs::write(root.join("real.md"), "signal").unwrap();
1867 std::fs::write(root.join(".memstead/state/findings.json"), "self").unwrap();
1868 std::fs::create_dir_all(root.join("custom-repo")).unwrap();
1869 std::fs::write(root.join("custom-repo/README.md"), "repo").unwrap();
1870 git(root, &["add", "-A"]);
1871 git(root, &["commit", "-qm", "move"]);
1872
1873 let source = primary(vec![PatternEntry {
1874 path: "**/*".to_string(),
1875 mode: PatternMode::Allow,
1876 }]);
1877 match compute_git_slice(&source, &[], root, Some(&baseline)) {
1878 SliceOutcome::Changed { slice, .. } => {
1879 assert_eq!(
1880 slice.added,
1881 vec!["real.md"],
1882 "engine state leaked: {slice:?}"
1883 );
1884 assert!(slice.modified.is_empty(), "{slice:?}");
1885 }
1886 other => panic!("expected Changed, got {other:?}"),
1887 }
1888 }
1889}