From 257425b14021fa574a2a8b03fcbcc17733120bbb Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Sun, 12 Jul 2026 11:51:43 +0200 Subject: [PATCH 1/5] fix(migrate): harden profile consolidation --- src/branch.rs | 152 ++++++++++++++++++++++++-- src/branch_meta.rs | 182 ++++++++++++++++++++++++++++++- src/doctor/heal.rs | 56 ++++++---- src/migrate/consolidate/mod.rs | 92 ++++++++++++++-- src/migrate/consolidate/tests.rs | 172 ++++++++++++++++++++++++++++- tests/update_health_pass_test.rs | 25 +++-- 6 files changed, 622 insertions(+), 57 deletions(-) diff --git a/src/branch.rs b/src/branch.rs index 48a76fd5a..66bf5af3e 100644 --- a/src/branch.rs +++ b/src/branch.rs @@ -134,11 +134,15 @@ fn gix_rev_distance( Some(count) } -/// Auto-detects the default branch (main or master). +/// Auto-detects the repository's default branch. /// /// Strategy: /// 1. Try `git symbolic-ref refs/remotes/origin/HEAD` /// 2. Fall back to checking if `main` or `master` exists locally +/// 3. Fall back to the currently checked-out local branch +/// +/// The final fallback deliberately returns `None` for detached HEAD rather +/// than inventing a default branch. pub fn detect_default_branch(project_root: &Path) -> Option { let repo = gix::open(project_root).ok()?; @@ -164,7 +168,73 @@ pub fn detect_default_branch(project_root: &Path) -> Option { } } - None + current_branch(project_root) +} + +#[cfg(test)] +mod default_branch_tests { + use super::*; + + fn run_git(project_root: &Path, args: &[&str]) { + let output = std::process::Command::new(crate::git::git_program()) + .args(args) + .current_dir(project_root) + .output() + .unwrap(); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + fn custom_default_repo() -> (tempfile::TempDir, PathBuf) { + let temp = tempfile::tempdir().unwrap(); + let project_root = temp.path().to_path_buf(); + run_git(&project_root, &["init", "-b", "trunk"]); + run_git(&project_root, &["config", "user.email", "test@example.com"]); + run_git(&project_root, &["config", "user.name", "TraceDecay Test"]); + std::fs::write(project_root.join("fixture"), b"fixture").unwrap(); + run_git(&project_root, &["add", "fixture"]); + run_git(&project_root, &["commit", "-m", "fixture"]); + (temp, project_root) + } + + #[test] + fn detects_checked_out_custom_default_without_origin_head() { + let (_temp, project_root) = custom_default_repo(); + + assert_eq!( + detect_default_branch(&project_root).as_deref(), + Some("trunk") + ); + } + + #[test] + fn detached_custom_default_does_not_guess() { + let (_temp, project_root) = custom_default_repo(); + run_git(&project_root, &["checkout", "--detach", "HEAD"]); + + assert_eq!(detect_default_branch(&project_root), None); + } + + #[tokio::test] + async fn detached_legacy_store_refuses_to_invent_default_metadata() { + let (temp, project_root) = custom_default_repo(); + run_git(&project_root, &["checkout", "--detach", "HEAD"]); + let data_dir = temp.path().join("profile-shard"); + std::fs::create_dir(&data_dir).unwrap(); + std::fs::write(data_dir.join(crate::config::DB_FILENAME), b"graph").unwrap(); + + let error = match prepare_branch_tracking_in_layout(&project_root, "trunk", &data_dir).await + { + Ok(_) => panic!("detached legacy store must not invent a default branch"), + Err(error) => error, + }; + + assert!(error.to_string().contains("default branch is unknown")); + assert!(!data_dir.join(crate::storage::BRANCH_META_FILENAME).exists()); + } } /// Sanitizes a branch name for use as a filename. @@ -355,7 +425,8 @@ pub fn find_nearest_tracked_ancestor( pub enum BranchAddOutcome { /// The project has no `.tracedecay/` index; nothing was done. NotIndexed, - /// The branch was already tracked; no copy/sync was performed. + /// The branch was already tracked; no copy/sync was performed. Legacy + /// single-DB metadata may have been persisted for the default branch. AlreadyTracked, /// A new branch DB was created from the nearest ancestor and synced. Added, @@ -408,8 +479,8 @@ pub async fn prepare_branch_tracking_in_layout( }; let meta_path = tracedecay_dir.join("branch-meta.json"); - let mut meta = match branch_meta::load_branch_meta(tracedecay_dir) { - Some(meta) => meta, + let (mut meta, metadata_was_missing) = match branch_meta::load_branch_meta(tracedecay_dir) { + Some(meta) => (meta, false), None if meta_path.exists() => { return Err(crate::errors::TraceDecayError::Config { message: format!( @@ -419,13 +490,26 @@ pub async fn prepare_branch_tracking_in_layout( }); } None => { - let default = detect_default_branch(project_root).unwrap_or_else(|| "main".to_string()); - branch_meta::BranchMeta::new_for_dir(tracedecay_dir, &default) + let default = detect_default_branch(project_root).ok_or_else(|| { + crate::errors::TraceDecayError::Config { + message: format!( + "cannot initialize missing branch metadata at '{}': repository default branch is unknown (detached HEAD or no default ref)", + meta_path.display() + ), + } + })?; + ( + branch_meta::BranchMeta::for_legacy_single_db(tracedecay_dir, &default), + true, + ) } }; prune_missing_branch_dbs(tracedecay_dir, &mut meta); if meta.is_tracked(branch_name) { + if metadata_was_missing { + branch_meta::save_branch_meta(tracedecay_dir, &meta)?; + } return Ok(BranchTrackingPreparation::AlreadyTracked); } @@ -485,6 +569,60 @@ pub async fn prepare_branch_tracking_in_layout( })) } +#[cfg(test)] +#[tokio::test] +async fn default_branch_bootstrap_persists_canonical_metadata() { + let temp = tempfile::tempdir().unwrap(); + let project_root = temp.path().join("repo"); + std::fs::create_dir_all(&project_root).unwrap(); + let run_git = |args: &[&str]| { + let output = std::process::Command::new(crate::git::git_program()) + .args(args) + .current_dir(&project_root) + .output() + .unwrap(); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + }; + run_git(&["init", "-b", "main"]); + std::fs::write(project_root.join("fixture"), b"fixture").unwrap(); + run_git(&["add", "fixture"]); + run_git(&[ + "-c", + "user.email=test@example.com", + "-c", + "user.name=TraceDecay Test", + "commit", + "-m", + "fixture", + ]); + + let data_dir = temp.path().join("profile-shard"); + std::fs::create_dir_all(&data_dir).unwrap(); + std::fs::write(data_dir.join(crate::config::DB_FILENAME), b"graph").unwrap(); + let meta_path = data_dir.join(crate::storage::BRANCH_META_FILENAME); + assert!(!meta_path.exists()); + + let outcome = prepare_branch_tracking_in_layout(&project_root, "main", &data_dir) + .await + .unwrap(); + + assert!(matches!(outcome, BranchTrackingPreparation::AlreadyTracked)); + let meta = crate::branch_meta::load_branch_meta(&data_dir).unwrap(); + assert_eq!(meta.default_branch, "main"); + assert_eq!(meta.branches.len(), 1); + let default = meta.branches.get("main").unwrap(); + assert_eq!(default.db_file, crate::config::db_filename(&data_dir)); + assert!(default.parent.is_none()); + assert_eq!(default.created_at, "0"); + assert_eq!(default.last_synced_at, "0"); + assert!(!meta_path.with_extension("json.tmp").exists()); + assert!(!data_dir.join("branches").exists()); +} + pub fn finalize_prepared_branch_tracking(tracedecay_dir: &Path, prepared: &PreparedBranchTracking) { if let Some(mut meta) = crate::branch_meta::load_branch_meta(tracedecay_dir) { meta.touch_synced(&prepared.branch_name); diff --git a/src/branch_meta.rs b/src/branch_meta.rs index c5f5fc732..daa4b18dd 100644 --- a/src/branch_meta.rs +++ b/src/branch_meta.rs @@ -3,7 +3,8 @@ //! Stores tracking information in `branch-meta.json` inside the project data //! dir. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; +use std::ffi::OsStr; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; @@ -35,6 +36,7 @@ pub struct BranchMeta { /// The auto-detected or configured default branch name. pub default_branch: String, /// Map of branch name → entry. + #[serde(serialize_with = "serialize_branches")] pub branches: HashMap, } @@ -51,16 +53,27 @@ impl BranchMeta { Self::with_db_file(default_branch, crate::config::db_filename(data_dir)) } + /// Synthesizes metadata for a legacy store that only has the canonical + /// main database. The timestamps are deliberately unknown (`0`) so the + /// same input produces byte-identical metadata across interrupted retries. + pub fn for_legacy_single_db(data_dir: &Path, default_branch: &str) -> Self { + Self::with_db_file_and_timestamp(default_branch, crate::config::db_filename(data_dir), "0") + } + fn with_db_file(default_branch: &str, db_file: &str) -> Self { let now = now_unix_str(); + Self::with_db_file_and_timestamp(default_branch, db_file, &now) + } + + fn with_db_file_and_timestamp(default_branch: &str, db_file: &str, timestamp: &str) -> Self { let mut branches = HashMap::new(); branches.insert( default_branch.to_string(), BranchEntry { db_file: db_file.to_string(), parent: None, - created_at: now.clone(), - last_synced_at: now, + created_at: timestamp.to_string(), + last_synced_at: timestamp.to_string(), gc_protected: false, }, ); @@ -119,6 +132,86 @@ impl BranchMeta { pub fn is_tracked(&self, name: &str) -> bool { self.branches.contains_key(name) } + + fn validate(&self) -> Result<(), String> { + if self.default_branch.is_empty() { + return Err("default_branch must not be empty".to_string()); + } + let default = self.branches.get(&self.default_branch).ok_or_else(|| { + format!( + "default_branch '{}' has no matching branch entry", + self.default_branch + ) + })?; + let canonical_main = crate::config::DB_FILENAME; + if default.db_file != canonical_main { + return Err(format!( + "default branch '{}' must reference canonical main database '{canonical_main}', found '{}'", + self.default_branch, default.db_file + )); + } + if default.parent.is_some() { + return Err(format!( + "default branch '{}' must not have a parent", + self.default_branch + )); + } + + let mut db_files = BTreeMap::new(); + for (name, entry) in &self.branches { + if name.is_empty() { + return Err("branch names must not be empty".to_string()); + } + validate_db_file(name, entry, name == &self.default_branch)?; + if entry.parent.as_deref() == Some(name.as_str()) { + return Err(format!("branch '{name}' must not be its own parent")); + } + if let Some(previous) = db_files.insert(entry.db_file.as_str(), name.as_str()) { + return Err(format!( + "branches '{previous}' and '{name}' reference the same database '{}'", + entry.db_file + )); + } + } + Ok(()) + } +} + +fn serialize_branches( + branches: &HashMap, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + branches + .iter() + .collect::>() + .serialize(serializer) +} + +fn validate_db_file(name: &str, entry: &BranchEntry, is_default: bool) -> Result<(), String> { + let relative = Path::new(&entry.db_file); + if relative.as_os_str().is_empty() + || relative.is_absolute() + || relative + .components() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + { + return Err(format!( + "branch '{name}' database path '{}' is not a normalized store-relative path", + entry.db_file + )); + } + if !is_default + && (!relative.starts_with("branches") || relative.extension() != Some(OsStr::new("db"))) + { + return Err(format!( + "non-default branch '{name}' database path '{}' must be under 'branches/' with a .db extension", + entry.db_file + )); + } + Ok(()) } /// Parses `branch-meta.json` content into [`BranchMeta`]. @@ -129,7 +222,10 @@ impl BranchMeta { /// runtime, quarantining in the post-update health pass) must go through this /// one predicate so they agree on what corrupt means. pub fn parse(content: &str) -> serde_json::Result { - serde_json::from_str(content) + let meta: BranchMeta = serde_json::from_str(content)?; + meta.validate() + .map_err(::custom)?; + Ok(meta) } /// Loads branch metadata from `branch-meta.json` in the project data dir. @@ -138,7 +234,34 @@ pub fn parse(content: &str) -> serde_json::Result { /// Prints a warning to stderr if the file exists but is malformed. pub fn load_branch_meta(data_dir: &Path) -> Option { let path = data_dir.join(BRANCH_META_FILENAME); - let content = std::fs::read_to_string(&path).ok()?; + let metadata = match std::fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None, + Err(error) => { + eprintln!( + "warning: could not inspect branch metadata at '{}': {error} — falling back to single-DB mode", + path.display() + ); + return None; + } + }; + if !metadata.file_type().is_file() { + eprintln!( + "warning: corrupt branch metadata at '{}': path is not a regular file — falling back to single-DB mode", + path.display() + ); + return None; + } + let content = match std::fs::read_to_string(&path) { + Ok(content) => content, + Err(error) => { + eprintln!( + "warning: could not read branch metadata at '{}': {error} — falling back to single-DB mode", + path.display() + ); + return None; + } + }; match parse(&content) { Ok(meta) => Some(meta), Err(e) => { @@ -157,6 +280,8 @@ pub fn load_branch_meta(data_dir: &Path) -> Option { /// atomic-write helper used for `store-manifest.json`), so a concurrent /// reader never observes a torn or truncated file. pub fn save_branch_meta(data_dir: &Path, meta: &BranchMeta) -> std::io::Result<()> { + meta.validate() + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; let path = data_dir.join(BRANCH_META_FILENAME); let json = serde_json::to_string_pretty(meta).map_err(std::io::Error::other)?; let temp_path = path.with_extension("json.tmp"); @@ -259,12 +384,57 @@ mod tests { #[test] fn parse_rejects_schema_mismatch_as_corrupt() { - assert!(parse(r#"{"default_branch":"main","branches":{}}"#).is_ok()); + assert!(parse(r#"{"default_branch":"main","branches":{}}"#).is_err()); assert!(parse("{not valid json").is_err()); assert!(parse(r#"{"default_branch": 5}"#).is_err()); assert!(parse("[]").is_err()); } + #[test] + fn parse_rejects_semantically_invalid_branch_metadata() { + for content in [ + r#"{"default_branch":"main","branches":{"main":{"db_file":"branches/main.db","created_at":"0","last_synced_at":"0"}}}"#, + r#"{"default_branch":"main","branches":{"main":{"db_file":"tracedecay.db","parent":"main","created_at":"0","last_synced_at":"0"}}}"#, + r#"{"default_branch":"main","branches":{"main":{"db_file":"tracedecay.db","created_at":"0","last_synced_at":"0"},"escape":{"db_file":"../escape.db","created_at":"0","last_synced_at":"0"}}}"#, + r#"{"default_branch":"main","branches":{"main":{"db_file":"tracedecay.db","created_at":"0","last_synced_at":"0"},"duplicate":{"db_file":"tracedecay.db","created_at":"0","last_synced_at":"0"}}}"#, + ] { + assert!( + parse(content).is_err(), + "accepted invalid metadata: {content}" + ); + } + } + + #[test] + fn legacy_single_db_metadata_is_byte_stable() { + let first = BranchMeta::for_legacy_single_db(Path::new("/profile/project"), "trunk"); + let second = BranchMeta::for_legacy_single_db(Path::new("/profile/project"), "trunk"); + + assert_eq!(first.branches["trunk"].created_at, "0"); + assert_eq!(first.branches["trunk"].last_synced_at, "0"); + assert_eq!( + serde_json::to_vec_pretty(&first).unwrap(), + serde_json::to_vec_pretty(&second).unwrap() + ); + } + + #[cfg(unix)] + #[test] + fn load_rejects_symlinked_branch_metadata() { + let dir = tempfile::tempdir().unwrap(); + let outside = dir.path().join("outside.json"); + let data_dir = dir.path().join("data"); + std::fs::create_dir(&data_dir).unwrap(); + std::fs::write( + &outside, + serde_json::to_vec_pretty(&BranchMeta::new("main")).unwrap(), + ) + .unwrap(); + std::os::unix::fs::symlink(&outside, data_dir.join(BRANCH_META_FILENAME)).unwrap(); + + assert!(load_branch_meta(&data_dir).is_none()); + } + #[test] fn parse_old_entry_defaults_gc_protected_to_false() { let meta = parse( diff --git a/src/doctor/heal.rs b/src/doctor/heal.rs index 3c84b73da..e20d7896a 100644 --- a/src/doctor/heal.rs +++ b/src/doctor/heal.rs @@ -407,34 +407,48 @@ mod tests { fn quarantine_renames_only_corrupt_branch_meta() { let dir = tempfile::TempDir::new().unwrap(); let projects_root = dir.path().join("projects"); - let corrupt = write_branch_meta(&projects_root, "proj_corrupt", "{not valid json"); - let valid = write_branch_meta( + let syntax_corrupt = + write_branch_meta(&projects_root, "proj_syntax_corrupt", "{not valid json"); + let semantic_corrupt = write_branch_meta( &projects_root, - "proj_valid", + "proj_semantic_corrupt", r#"{"default_branch":"main","branches":{}}"#, ); + let valid_content = + serde_json::to_string(&crate::branch_meta::BranchMeta::new("main")).unwrap(); + let valid = write_branch_meta(&projects_root, "proj_valid", &valid_content); let (quarantines, warnings) = quarantine_corrupt_branch_meta(dir.path()); - assert_eq!(quarantines.len(), 1); + assert_eq!(quarantines.len(), 2); assert!(warnings.is_empty()); - let quarantine = &quarantines[0]; - assert_eq!(quarantine.original, corrupt); - assert!(!corrupt.exists(), "corrupt file should be renamed away"); - assert_eq!( - std::fs::read_to_string(&quarantine.quarantined).unwrap(), - "{not valid json", - "quarantined file must preserve the corrupt content as evidence" - ); - assert!( - quarantine - .quarantined - .file_name() - .unwrap() - .to_string_lossy() - .starts_with(BRANCH_META_QUARANTINE_PREFIX), - "quarantine name should be branch-meta.json.corrupt-: {quarantine:?}" - ); + for (original, content) in [ + (&syntax_corrupt, "{not valid json"), + ( + &semantic_corrupt, + r#"{"default_branch":"main","branches":{}}"#, + ), + ] { + let quarantine = quarantines + .iter() + .find(|quarantine| &quarantine.original == original) + .unwrap(); + assert!(!original.exists(), "corrupt file should be renamed away"); + assert_eq!( + std::fs::read_to_string(&quarantine.quarantined).unwrap(), + content, + "quarantined file must preserve the corrupt content as evidence" + ); + assert!( + quarantine + .quarantined + .file_name() + .unwrap() + .to_string_lossy() + .starts_with(BRANCH_META_QUARANTINE_PREFIX), + "quarantine name should be branch-meta.json.corrupt-: {quarantine:?}" + ); + } assert!(valid.exists(), "valid branch-meta must be left untouched"); } diff --git a/src/migrate/consolidate/mod.rs b/src/migrate/consolidate/mod.rs index 47e798e8c..220ed4994 100644 --- a/src/migrate/consolidate/mod.rs +++ b/src/migrate/consolidate/mod.rs @@ -436,8 +436,8 @@ async fn resolve_plan_inner( )?; } - let mut source_meta = load_required_branch_meta(&source_layout)?; - let mut target_meta = load_required_branch_meta(&target_layout)?; + let mut source_meta = load_input_branch_meta(&source_layout)?; + let mut target_meta = load_input_branch_meta(&target_layout)?; recover_untracked_branch_graphs(&source_layout, &mut source_meta)?; recover_untracked_branch_graphs(&target_layout, &mut target_meta)?; let source_graphs = graph_db_paths(&source_layout, &source_meta)?; @@ -486,7 +486,9 @@ async fn resolve_plan_inner( let input_fingerprint = fingerprint_inputs( &evidence, &source_layout, + &source_meta, &target_layout, + &target_meta, applied_ledger .as_ref() .map(|_| destination_project_id.as_str()), @@ -578,10 +580,7 @@ fn validate_manifest_path( &manifest.data_root.join(&manifest.sessions_db_relpath), &layout.sessions_db_path, ) - || !same_path( - &manifest.data_root.join(&manifest.branch_meta_relpath), - &layout.branch_meta_path, - ) + || !manifest_branch_meta_path_matches_layout(&manifest, layout) { return Err(config_error(format!( "store manifest '{}' does not match profile shard '{}'", @@ -592,6 +591,16 @@ fn validate_manifest_path( Ok(manifest) } +fn manifest_branch_meta_path_matches_layout( + manifest: &StoreManifest, + layout: &StoreLayout, +) -> bool { + layout + .branch_meta_path + .strip_prefix(&layout.data_root) + .is_ok_and(|relative| manifest.branch_meta_relpath == relative) +} + fn manifest_matches_identity( candidate: &StoreManifest, selected: &StoreManifest, @@ -658,6 +667,53 @@ fn reject_ambiguous_shards( Ok(()) } +fn load_input_branch_meta(layout: &StoreLayout) -> Result { + match fs::symlink_metadata(&layout.branch_meta_path) { + Ok(metadata) if !metadata.file_type().is_file() => Err(config_error(format!( + "corrupt branch metadata at '{}': path is not a regular file", + layout.branch_meta_path.display() + ))), + Ok(_) => { + let content = fs::read_to_string(&layout.branch_meta_path).map_err(|error| { + config_error(format!( + "could not read branch metadata at '{}': {error}", + layout.branch_meta_path.display() + )) + })?; + branch_meta::parse(&content).map_err(|error| { + config_error(format!( + "corrupt branch metadata at '{}': {error}", + layout.branch_meta_path.display() + )) + }) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + if !layout.graph_db_path.is_file() { + return Err(config_error(format!( + "missing branch metadata at '{}' and default graph database at '{}'", + layout.branch_meta_path.display(), + layout.graph_db_path.display() + ))); + } + let default_branch = crate::branch::detect_default_branch(&layout.project_root) + .ok_or_else(|| { + config_error(format!( + "cannot synthesize missing branch metadata at '{}': repository default branch is unknown (detached HEAD or no default ref)", + layout.branch_meta_path.display() + )) + })?; + Ok(BranchMeta::for_legacy_single_db( + &layout.data_root, + &default_branch, + )) + } + Err(error) => Err(config_error(format!( + "could not inspect branch metadata at '{}': {error}", + layout.branch_meta_path.display() + ))), + } +} + fn load_required_branch_meta(layout: &StoreLayout) -> Result { branch_meta::load_branch_meta(&layout.data_root).ok_or_else(|| { config_error(format!( @@ -827,15 +883,33 @@ fn confirmation_token(fingerprint: &str, migration_id: &str) -> String { fn fingerprint_inputs( evidence: &InputReadEvidence, source: &StoreLayout, + source_meta: &BranchMeta, target: &StoreLayout, + target_meta: &BranchMeta, retired_destination_project_id: Option<&str>, ) -> Result { let mut hash = Sha256::new(); - for (label, root, graph) in [ - ("source", &source.data_root, &evidence.source_graph), - ("target", &target.data_root, &evidence.target_graph), + for (label, root, graph, meta) in [ + ( + "source", + &source.data_root, + &evidence.source_graph, + source_meta, + ), + ( + "target", + &target.data_root, + &evidence.target_graph, + target_meta, + ), ] { hash.update(label.as_bytes()); + hash.update(b"\0branch-meta\0"); + hash.update(serde_json::to_vec(meta).map_err(|error| { + config_error(format!( + "could not canonicalize {label} branch metadata for input fingerprint: {error}" + )) + })?); let mut files = BTreeMap::new(); for (relative, path) in relative_file_map(root)? { let retired_manifest_name = retired_destination_project_id diff --git a/src/migrate/consolidate/tests.rs b/src/migrate/consolidate/tests.rs index b8aa6c529..a607d1294 100644 --- a/src/migrate/consolidate/tests.rs +++ b/src/migrate/consolidate/tests.rs @@ -227,6 +227,167 @@ async fn dry_run_reports_live_split_shape_without_mutation() { ); } +#[tokio::test] +async fn legacy_single_db_plan_is_read_only_and_apply_preserves_source_graph() { + let fixture = fixture().await; + let source = layout_for_id(&fixture.project, &fixture.profile, &fixture.source_id).unwrap(); + fs::remove_file(&source.branch_meta_path).unwrap(); + storage::write_store_manifest(&source).unwrap(); + let source_graph_before = file_digest(&source.graph_db_path).unwrap(); + let profile_before = full_tree_snapshot(&fixture.profile); + + let options = fixture.options(); + let planned = plan(&options).await.unwrap(); + + assert_eq!(planned.source.graph_databases, 1); + assert_eq!(planned.source.branches, 1); + assert!(!source.branch_meta_path.exists()); + assert_eq!( + full_tree_snapshot(&fixture.profile), + profile_before, + "legacy single-DB planning mutated an input" + ); + + let applied = apply(&options, &planned.confirmation_token).await.unwrap(); + + assert!(!source.branch_meta_path.exists()); + assert_eq!( + file_digest(&source.graph_db_path).unwrap(), + source_graph_before + ); + let default_branch = crate::branch::detect_default_branch(&fixture.project) + .unwrap_or_else(|| "main".to_string()); + let preserved_name = format!("consolidated/{}/{}", fixture.source_id, default_branch); + let destination_meta = branch_meta::load_branch_meta(&applied.destination_data_root).unwrap(); + let preserved = destination_meta.branches.get(&preserved_name).unwrap(); + assert_eq!(preserved.created_at, "0"); + assert_eq!(preserved.last_synced_at, "0"); + let preserved_path = applied.destination_data_root.join(&preserved.db_file); + let (db, _) = Database::open_read_only(&preserved_path).await.unwrap(); + let facts = MemoryStore::new(db.conn()) + .list_facts(None, Some(0.0), 100) + .await + .unwrap(); + assert!( + facts + .iter() + .any(|fact| fact.content == "legacy durable fact"), + "the preserved source graph lost its legacy fact" + ); + db.close(); +} + +#[tokio::test] +async fn legacy_single_db_retry_after_destination_publish_is_deterministic() { + let fixture = fixture().await; + let source = layout_for_id(&fixture.project, &fixture.profile, &fixture.source_id).unwrap(); + fs::remove_file(&source.branch_meta_path).unwrap(); + storage::write_store_manifest(&source).unwrap(); + let options = fixture.options(); + let planned = plan(&options).await.unwrap(); + + let interrupted = apply_with_prepare_stop( + &options, + &planned.confirmation_token, + prepare::PrepareStop::Publish, + ) + .await + .unwrap_err(); + assert!( + interrupted.to_string().contains("synthetic interruption"), + "{interrupted}" + ); + + std::thread::sleep(std::time::Duration::from_secs(1)); + let applied = apply(&options, &planned.confirmation_token).await.unwrap(); + + assert_eq!(applied.state, ConsolidationState::Applied); + let destination_meta = branch_meta::load_branch_meta(&applied.destination_data_root).unwrap(); + let default_branch = crate::branch::detect_default_branch(&fixture.project).unwrap(); + let preserved = &destination_meta.branches + [&format!("consolidated/{}/{}", fixture.source_id, default_branch)]; + assert_eq!(preserved.created_at, "0"); + assert_eq!(preserved.last_synced_at, "0"); +} + +#[tokio::test] +async fn target_legacy_single_db_metadata_is_synthesized_without_input_mutation() { + let fixture = fixture().await; + let target = layout_for_id(&fixture.project, &fixture.profile, &fixture.target_id).unwrap(); + fs::remove_file(&target.branch_meta_path).unwrap(); + storage::write_store_manifest(&target).unwrap(); + let profile_before = full_tree_snapshot(&fixture.profile); + let options = fixture.options(); + + let planned = plan(&options).await.unwrap(); + + assert_eq!(planned.target.branches, 1); + assert_eq!(full_tree_snapshot(&fixture.profile), profile_before); + let applied = apply(&options, &planned.confirmation_token).await.unwrap(); + assert!(!target.branch_meta_path.exists()); + let destination_meta = branch_meta::load_branch_meta(&applied.destination_data_root).unwrap(); + let default_branch = crate::branch::detect_default_branch(&fixture.project).unwrap(); + assert_eq!(destination_meta.default_branch, default_branch); + assert_eq!(destination_meta.branches[&default_branch].created_at, "0"); + assert_eq!( + destination_meta.branches[&default_branch].last_synced_at, + "0" + ); +} + +#[tokio::test] +async fn synthesized_branch_metadata_change_invalidates_confirmation_token() { + let fixture = fixture().await; + let source = layout_for_id(&fixture.project, &fixture.profile, &fixture.source_id).unwrap(); + fs::remove_file(&source.branch_meta_path).unwrap(); + storage::write_store_manifest(&source).unwrap(); + run_git(&fixture.project, &["branch", "-m", "trunk"]); + let options = fixture.options(); + + let planned = plan(&options).await.unwrap(); + + run_git(&fixture.project, &["branch", "-m", "release"]); + let error = apply(&options, &planned.confirmation_token) + .await + .unwrap_err(); + + assert!( + error.to_string().contains("confirmation token mismatch"), + "{error}" + ); + assert!(!source.branch_meta_path.exists()); + assert!(!planned.destination_data_root.exists()); + assert!(!planned.backup_root.exists()); + assert!(!planned.ledger_path.exists()); +} + +#[tokio::test] +async fn corrupt_branch_metadata_fails_closed_without_mutation() { + for content in [ + b"{\"default_branch\":".as_slice(), + br#"{"default_branch":"main","branches":{}}"#.as_slice(), + br#"{"default_branch":"main","branches":{"main":{"db_file":"branches/not-main.db","created_at":"0","last_synced_at":"0"}}}"#.as_slice(), + ] { + let fixture = fixture().await; + let source = layout_for_id(&fixture.project, &fixture.profile, &fixture.source_id).unwrap(); + fs::write(&source.branch_meta_path, content).unwrap(); + storage::write_store_manifest(&source).unwrap(); + let profile_before = full_tree_snapshot(&fixture.profile); + + let error = plan(&fixture.options()).await.unwrap_err(); + + assert!( + error.to_string().contains("corrupt branch metadata"), + "{error}" + ); + assert_eq!( + full_tree_snapshot(&fixture.profile), + profile_before, + "corrupt metadata planning mutated an input" + ); + } +} + #[tokio::test] async fn many_branch_plan_retains_constant_database_handles_and_bounded_scratch() { const BRANCHES_PER_SHARD: usize = 48; @@ -2022,13 +2183,14 @@ async fn branch_metadata_paths_cannot_escape_the_profile_shard() { let source = layout_for_id(&fixture.project, &fixture.profile, &fixture.source_id).unwrap(); let outside = fixture.profile.join("outside.db"); fs::copy(&source.graph_db_path, &outside).unwrap(); - let mut meta = branch_meta::load_branch_meta(&source.data_root).unwrap(); + let meta = branch_meta::load_branch_meta(&source.data_root).unwrap(); for db_file in [ "../outside.db".to_string(), outside.to_string_lossy().to_string(), ] { - meta.branches.insert( + let mut invalid = meta.clone(); + invalid.branches.insert( "unsafe".to_string(), BranchEntry { db_file, @@ -2038,7 +2200,11 @@ async fn branch_metadata_paths_cannot_escape_the_profile_shard() { gc_protected: false, }, ); - branch_meta::save_branch_meta(&source.data_root, &meta).unwrap(); + fs::write( + &source.branch_meta_path, + serde_json::to_vec_pretty(&invalid).unwrap(), + ) + .unwrap(); let error = plan(&fixture.options()).await.unwrap_err(); assert!(error.to_string().contains("store-relative path"), "{error}"); } diff --git a/tests/update_health_pass_test.rs b/tests/update_health_pass_test.rs index 0d7605b2c..8a12fddfc 100644 --- a/tests/update_health_pass_test.rs +++ b/tests/update_health_pass_test.rs @@ -145,7 +145,7 @@ fn post_update_quarantines_corrupt_branch_meta() { let valid = write_branch_meta( &profile_root, "proj_valid", - r#"{"default_branch":"main","branches":{}}"#, + r#"{"default_branch":"main","branches":{"main":{"db_file":"tracedecay.db","created_at":"0","last_synced_at":"0"}}}"#, ); let mut command = post_update_command(&home_root); @@ -170,7 +170,7 @@ fn post_update_quarantines_corrupt_branch_meta() { ); assert_eq!( std::fs::read_to_string(&valid).unwrap(), - r#"{"default_branch":"main","branches":{}}"#, + r#"{"default_branch":"main","branches":{"main":{"db_file":"tracedecay.db","created_at":"0","last_synced_at":"0"}}}"#, "valid branch-meta.json must be left untouched" ); let stderr = String::from_utf8_lossy(&output.stderr); @@ -181,14 +181,17 @@ fn post_update_quarantines_corrupt_branch_meta() { } #[test] -fn post_update_quarantines_schema_corrupt_branch_meta() { +fn post_update_quarantines_semantically_corrupt_branch_meta() { let home = TempDir::new().unwrap(); let home_root = canonical_temp_path(home.path()); let profile_root = home_root.join(".tracedecay"); - // Valid JSON, but not a valid BranchMeta — the runtime treats any schema - // mismatch as corrupt, so the health pass must quarantine it too. - let schema_corrupt = - write_branch_meta(&profile_root, "proj_schema", r#"{"default_branch": 5}"#); + // The schema is valid, but an empty branch map cannot represent the + // declared default branch and must be quarantined as semantic corruption. + let semantic_corrupt = write_branch_meta( + &profile_root, + "proj_semantic", + r#"{"default_branch":"main","branches":{}}"#, + ); let mut command = post_update_command(&home_root); command.args(["post-update", "--no-reinstall"]); @@ -196,10 +199,10 @@ fn post_update_quarantines_schema_corrupt_branch_meta() { assert_success(&output, "post-update"); assert!( - !schema_corrupt.exists(), - "schema-corrupt branch-meta.json should be quarantined away" + !semantic_corrupt.exists(), + "semantically corrupt branch-meta.json should be quarantined away" ); - let quarantined = quarantined_branch_meta_files(schema_corrupt.parent().unwrap()); + let quarantined = quarantined_branch_meta_files(semantic_corrupt.parent().unwrap()); assert_eq!( quarantined.len(), 1, @@ -207,7 +210,7 @@ fn post_update_quarantines_schema_corrupt_branch_meta() { ); assert_eq!( std::fs::read_to_string(&quarantined[0]).unwrap(), - r#"{"default_branch": 5}"#, + r#"{"default_branch":"main","branches":{}}"#, "quarantine must preserve the corrupt content as evidence" ); let stderr = String::from_utf8_lossy(&output.stderr); From b13f7039807f03c355ac14e69ae3c65444995b9c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 12 Jul 2026 23:35:44 +0000 Subject: [PATCH 2/5] fix(migrate): align branch metadata validation --- src/branch_meta.rs | 16 +++++- src/doctor/heal.rs | 86 +++++++++++++++++++++++++++----- src/migrate/consolidate/tests.rs | 9 +++- 3 files changed, 94 insertions(+), 17 deletions(-) diff --git a/src/branch_meta.rs b/src/branch_meta.rs index daa4b18dd..67ace7f28 100644 --- a/src/branch_meta.rs +++ b/src/branch_meta.rs @@ -4,7 +4,6 @@ //! dir. use std::collections::{BTreeMap, HashMap}; -use std::ffi::OsStr; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; @@ -204,7 +203,10 @@ fn validate_db_file(name: &str, entry: &BranchEntry, is_default: bool) -> Result )); } if !is_default - && (!relative.starts_with("branches") || relative.extension() != Some(OsStr::new("db"))) + && (!relative.starts_with("branches") + || !relative + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("db"))) { return Err(format!( "non-default branch '{name}' database path '{}' must be under 'branches/' with a .db extension", @@ -405,6 +407,16 @@ mod tests { } } + #[test] + fn parse_accepts_case_insensitive_branch_database_extensions() { + let mut meta = BranchMeta::new("main"); + meta.add_branch("legacy", "branches/legacy.DB", "main"); + + let content = serde_json::to_string(&meta).unwrap(); + + assert!(parse(&content).is_ok()); + } + #[test] fn legacy_single_db_metadata_is_byte_stable() { let first = BranchMeta::for_legacy_single_db(Path::new("/profile/project"), "trunk"); diff --git a/src/doctor/heal.rs b/src/doctor/heal.rs index e20d7896a..f34e148c0 100644 --- a/src/doctor/heal.rs +++ b/src/doctor/heal.rs @@ -9,8 +9,8 @@ //! prints a warning but never fails the update itself. Only remedies that //! are safe to automate are applied: //! -//! - corrupt `branch-meta.json` files (anything [`crate::branch_meta::parse`] -//! rejects) are quarantined — renamed to +//! - corrupt `branch-meta.json` paths (non-regular files or anything +//! [`crate::branch_meta::parse`] rejects) are quarantined — renamed to //! `branch-meta.json.corrupt-`, never deleted — preserving the //! evidence while restoring the silent single-DB fallback, //! - registry rows whose project root no longer exists AND lives under the @@ -247,11 +247,11 @@ fn render_warnings(warnings: &[String]) { } } -/// Renames every `branch-meta.json` under `/projects/*` that -/// [`crate::branch_meta::parse`] rejects — the runtime's own definition of -/// corrupt, covering both invalid JSON and schema mismatches — to -/// `branch-meta.json.corrupt-`, preserving the corrupt content as -/// evidence while restoring the single-DB fallback. +/// Renames every `branch-meta.json` under `/projects/*` that is +/// not a regular file or that [`crate::branch_meta::parse`] rejects. This is +/// the runtime's own definition of corrupt, covering invalid JSON, schema +/// mismatches, and non-regular paths. Quarantine preserves the original path +/// as evidence while restoring the single-DB fallback. /// /// Returns the performed quarantines and any warnings. fn quarantine_corrupt_branch_meta(profile_root: &Path) -> (Vec, Vec) { @@ -264,20 +264,31 @@ fn quarantine_corrupt_branch_meta(profile_root: &Path) -> (Vec = entries .flatten() .map(|entry| entry.path().join(BRANCH_META_FILENAME)) - .filter(|path| path.is_file()) .collect(); meta_paths.sort(); let now = crate::tracedecay::current_timestamp(); for path in meta_paths { - let content = match std::fs::read_to_string(&path) { - Ok(content) => content, - Err(err) => { - warnings.push(format!("could not read '{}': {err}", path.display())); + let metadata = match std::fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + warnings.push(format!("could not inspect '{}': {error}", path.display())); continue; } }; - if crate::branch_meta::parse(&content).is_ok() { + let corrupt = if metadata.file_type().is_file() { + match std::fs::read_to_string(&path) { + Ok(content) => crate::branch_meta::parse(&content).is_err(), + Err(error) => { + warnings.push(format!("could not read '{}': {error}", path.display())); + continue; + } + } + } else { + true + }; + if !corrupt { continue; } let quarantined = path.with_file_name(format!("{BRANCH_META_QUARANTINE_PREFIX}{now}")); @@ -478,6 +489,55 @@ mod tests { ); } + #[test] + fn quarantine_renames_non_regular_branch_meta() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir + .path() + .join("projects") + .join("proj_directory") + .join(BRANCH_META_FILENAME); + std::fs::create_dir_all(&path).unwrap(); + + let (quarantines, warnings) = quarantine_corrupt_branch_meta(dir.path()); + + assert!(warnings.is_empty()); + assert_eq!(quarantines.len(), 1); + assert_eq!(quarantines[0].original, path); + assert!(!path.exists()); + assert!(quarantines[0].quarantined.is_dir()); + } + + #[cfg(unix)] + #[test] + fn quarantine_renames_symlinked_valid_branch_meta() { + let dir = tempfile::TempDir::new().unwrap(); + let outside = dir.path().join("outside.json"); + std::fs::write( + &outside, + serde_json::to_vec_pretty(&crate::branch_meta::BranchMeta::new("main")).unwrap(), + ) + .unwrap(); + let shard = dir.path().join("projects").join("proj_symlink"); + std::fs::create_dir_all(&shard).unwrap(); + let path = shard.join(BRANCH_META_FILENAME); + std::os::unix::fs::symlink(&outside, &path).unwrap(); + + let (quarantines, warnings) = quarantine_corrupt_branch_meta(dir.path()); + + assert!(warnings.is_empty()); + assert_eq!(quarantines.len(), 1); + assert_eq!(quarantines[0].original, path); + assert!(!path.exists()); + assert!(outside.exists()); + assert!( + std::fs::symlink_metadata(&quarantines[0].quarantined) + .unwrap() + .file_type() + .is_symlink() + ); + } + #[test] fn quarantine_is_a_no_op_without_a_projects_dir() { let dir = tempfile::TempDir::new().unwrap(); diff --git a/src/migrate/consolidate/tests.rs b/src/migrate/consolidate/tests.rs index a607d1294..80da93e40 100644 --- a/src/migrate/consolidate/tests.rs +++ b/src/migrate/consolidate/tests.rs @@ -2087,7 +2087,7 @@ async fn identity_survives_symlink_and_repository_move() { } #[tokio::test] -async fn untracked_branch_databases_are_recovered_into_the_destination() { +async fn untracked_branch_databases_with_mixed_case_extensions_are_recovered() { const SOURCE_ORPHAN_FACT: &str = "fact unique to the source orphan branch"; const TARGET_ORPHAN_FACT: &str = "fact unique to the target orphan branch"; let fixture = fixture().await; @@ -2095,6 +2095,11 @@ async fn untracked_branch_databases_are_recovered_into_the_destination() { let target = layout_for_id(&fixture.project, &fixture.profile, &fixture.target_id).unwrap(); add_untracked_branch(&source, "orphan-source", SOURCE_ORPHAN_FACT).await; add_untracked_branch(&target, "orphan-target", TARGET_ORPHAN_FACT).await; + fs::rename( + target.data_root.join("branches/orphan-target.db"), + target.data_root.join("branches/orphan-target.DB"), + ) + .unwrap(); let options = fixture.options(); let planned = plan(&options).await.unwrap(); @@ -2116,7 +2121,7 @@ async fn untracked_branch_databases_are_recovered_into_the_destination() { .collect::>(); assert_eq!(recovered.len(), 2); assert!(recovered.iter().any(|(name, db_file)| { - name.starts_with("recovered/orphan-target-") && db_file == "branches/orphan-target.db" + name.starts_with("recovered/orphan-target-") && db_file == "branches/orphan-target.DB" })); assert!(recovered.iter().any(|(name, db_file)| { name.starts_with(&format!( From 4de38a9979f2555915507060079ce0e95f4b83ea Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 12 Jul 2026 23:43:37 +0000 Subject: [PATCH 3/5] fix(ci): satisfy clippy let-else lint --- src/branch.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/branch.rs b/src/branch.rs index 66bf5af3e..eb3c6dc20 100644 --- a/src/branch.rs +++ b/src/branch.rs @@ -226,10 +226,9 @@ mod default_branch_tests { std::fs::create_dir(&data_dir).unwrap(); std::fs::write(data_dir.join(crate::config::DB_FILENAME), b"graph").unwrap(); - let error = match prepare_branch_tracking_in_layout(&project_root, "trunk", &data_dir).await - { - Ok(_) => panic!("detached legacy store must not invent a default branch"), - Err(error) => error, + let Err(error) = prepare_branch_tracking_in_layout(&project_root, "trunk", &data_dir).await + else { + panic!("detached legacy store must not invent a default branch") }; assert!(error.to_string().contains("default branch is unknown")); From d622fd14e4aecc228f8449dec6936371980aca2d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 12 Jul 2026 23:55:28 +0000 Subject: [PATCH 4/5] fix(migrate): report invalid reconstruction metadata --- src/migrate/registry.rs | 38 ++++++++++++++++++- .../profile_storage_migration_test.rs | 4 +- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/migrate/registry.rs b/src/migrate/registry.rs index 2640d3394..1765c4aab 100644 --- a/src/migrate/registry.rs +++ b/src/migrate/registry.rs @@ -1056,8 +1056,42 @@ fn reconstruct_graph_scopes( let Some(branch_dir) = branch_meta_path.parent() else { return (None, Vec::new(), Vec::new()); }; - let Some(meta) = branch_meta::load_branch_meta(branch_dir) else { - return (None, Vec::new(), Vec::new()); + let invalid = |message| (None, Vec::new(), vec![message]); + let metadata = match fs::symlink_metadata(branch_meta_path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return (None, Vec::new(), Vec::new()); + } + Err(error) => { + return invalid(format!( + "could not inspect branch metadata '{}': {error}", + branch_meta_path.display() + )); + } + }; + if !metadata.file_type().is_file() { + return invalid(format!( + "branch metadata '{}' is not a regular file", + branch_meta_path.display() + )); + } + let content = match fs::read_to_string(branch_meta_path) { + Ok(content) => content, + Err(error) => { + return invalid(format!( + "could not read branch metadata '{}': {error}", + branch_meta_path.display() + )); + } + }; + let meta = match branch_meta::parse(&content) { + Ok(meta) => meta, + Err(error) => { + return invalid(format!( + "branch metadata '{}' is invalid: {error}", + branch_meta_path.display() + )); + } }; let mut scopes = Vec::new(); let mut issues = Vec::new(); diff --git a/tests/storage_suite/profile_storage_migration_test.rs b/tests/storage_suite/profile_storage_migration_test.rs index b20bf9271..ae206cce3 100644 --- a/tests/storage_suite/profile_storage_migration_test.rs +++ b/tests/storage_suite/profile_storage_migration_test.rs @@ -351,7 +351,9 @@ fn unsafe_branch_database_path_blocks_reconstruction() { report .issues .iter() - .any(|issue| issue.contains("unsafe database path")) + .any(|issue| issue.contains("must reference canonical main database")), + "unexpected reconstruction issues: {:?}", + report.issues ); } From a0d56807acb1f163f552b9d6395e21b289cadbd9 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 13 Jul 2026 00:03:05 +0000 Subject: [PATCH 5/5] test(migrate): use valid branch metadata fixtures --- .../cli_non_interactive_test.rs | 12 +----- .../storage_suite/migration_manifest_test.rs | 41 ++++++++----------- 2 files changed, 19 insertions(+), 34 deletions(-) diff --git a/tests/core_cli_suite/cli_non_interactive_test.rs b/tests/core_cli_suite/cli_non_interactive_test.rs index 6aa4f3b63..54cbefdab 100644 --- a/tests/core_cli_suite/cli_non_interactive_test.rs +++ b/tests/core_cli_suite/cli_non_interactive_test.rs @@ -234,11 +234,7 @@ fn write_profile_sharded_fixture(home: &std::path::Path, project: &std::path::Pa .unwrap(); std::fs::write(shard_root.join("tracedecay.db"), b"profile graph").unwrap(); std::fs::write(shard_root.join("sessions.db"), b"sessions").unwrap(); - std::fs::write( - shard_root.join("branch-meta.json"), - r#"{"default_branch":"main","branches":{}}"#, - ) - .unwrap(); + write_branch_meta(&shard_root, &[], false); let manifest = StoreManifest { schema_version: STORE_MANIFEST_SCHEMA_VERSION, project_id: Some("proj_cli".to_string()), @@ -1967,11 +1963,7 @@ fn migrate_cleanup_sources_removes_source_artifacts_but_preserves_enrollment_mar std::fs::create_dir_all(&target_root).unwrap(); std::fs::write(&source_graph, b"graph").unwrap(); std::fs::write(target_root.join("tracedecay.db"), b"graph").unwrap(); - std::fs::write( - target_root.join("branch-meta.json"), - r#"{"default_branch":"main","branches":{}}"#, - ) - .unwrap(); + write_branch_meta(&target_root, &[], false); let store_manifest = StoreManifest { schema_version: STORE_MANIFEST_SCHEMA_VERSION, project_id: Some("proj_cli".to_string()), diff --git a/tests/storage_suite/migration_manifest_test.rs b/tests/storage_suite/migration_manifest_test.rs index 68484e0e7..e180bb776 100644 --- a/tests/storage_suite/migration_manifest_test.rs +++ b/tests/storage_suite/migration_manifest_test.rs @@ -6,6 +6,7 @@ use crate::common::sample_node; #[cfg(unix)] use std::os::unix::fs::symlink; use tempfile::TempDir; +use tracedecay::branch_meta::BranchMeta; use tracedecay::global_db::GlobalDb; use tracedecay::lifecycle_lease::acquire_exclusive_for_profile; use tracedecay::migrate::inventory::{ @@ -48,6 +49,14 @@ fn canonical_temp_path(path: &Path) -> PathBuf { path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) } +fn write_valid_branch_meta(path: &Path) { + fs::write( + path, + serde_json::to_vec_pretty(&BranchMeta::new("main")).unwrap(), + ) + .unwrap(); +} + #[cfg(unix)] #[test] fn save_manifest_rejects_symlinked_parent_components() { @@ -390,11 +399,7 @@ fn verify_manifest_validates_profile_store_manifest_registry_records() { fs::create_dir_all(&data_root).unwrap(); fs::write(data_root.join("tracedecay.db"), b"graph").unwrap(); fs::write(data_root.join("sessions.db"), b"sessions").unwrap(); - fs::write( - data_root.join("branch-meta.json"), - r#"{"default_branch":"main","branches":{}}"#, - ) - .unwrap(); + write_valid_branch_meta(&data_root.join("branch-meta.json")); let store_manifest = StoreManifest { schema_version: STORE_MANIFEST_SCHEMA_VERSION, project_id: Some("proj_123".to_string()), @@ -492,11 +497,7 @@ async fn verify_manifest_accepts_logically_equal_sqlite_artifacts_with_different target.close(); assert_ne!(fs::read(&source_db).unwrap(), fs::read(&target_db).unwrap()); - fs::write( - data_root.join("branch-meta.json"), - r#"{"default_branch":"main","branches":{}}"#, - ) - .unwrap(); + write_valid_branch_meta(&data_root.join("branch-meta.json")); fs::write(data_root.join("sessions.db"), b"sessions").unwrap(); let store_manifest = StoreManifest { schema_version: STORE_MANIFEST_SCHEMA_VERSION, @@ -578,7 +579,7 @@ fn apply_migration_manifest_stops_at_verified_before_cutover() { fs::create_dir_all(&data_dir).unwrap(); fs::write(&graph_db, b"graph").unwrap(); fs::write(&sessions_db, b"sessions").unwrap(); - fs::write(&branch_meta, r#"{"default_branch":"main","branches":{}}"#).unwrap(); + write_valid_branch_meta(&branch_meta); let mut manifest = build_plan_manifest( MigrationInventory { stores: vec![StoreInventory { @@ -604,7 +605,7 @@ fn apply_migration_manifest_stops_at_verified_before_cutover() { StoreArtifact { kind: "branch_meta".to_string(), path: branch_meta.clone(), - size_bytes: 39, + size_bytes: fs::metadata(&branch_meta).unwrap().len(), }, ], }], @@ -649,11 +650,7 @@ fn finalize_migration_apply_marks_cutover_complete() { let profile_root = root.join("profile"); fs::create_dir_all(&data_dir).unwrap(); fs::write(&graph_db, b"graph").unwrap(); - fs::write( - data_dir.join("branch-meta.json"), - r#"{"default_branch":"main","branches":{}}"#, - ) - .unwrap(); + write_valid_branch_meta(&data_dir.join("branch-meta.json")); let mut manifest = build_plan_manifest( MigrationInventory { stores: vec![StoreInventory { @@ -944,7 +941,7 @@ fn migrate_apply_copies_single_store_and_cuts_over_profile_shard() { fs::create_dir_all(&data_dir).unwrap(); fs::write(&graph_db, b"graph").unwrap(); fs::write(&sessions_db, b"sessions").unwrap(); - fs::write(&branch_meta, r#"{"default_branch":"main","branches":{}}"#).unwrap(); + write_valid_branch_meta(&branch_meta); let manifest = build_plan_manifest( MigrationInventory { stores: vec![StoreInventory { @@ -970,7 +967,7 @@ fn migrate_apply_copies_single_store_and_cuts_over_profile_shard() { StoreArtifact { kind: "branch_meta".to_string(), path: branch_meta.clone(), - size_bytes: 39, + size_bytes: fs::metadata(&branch_meta).unwrap().len(), }, ], }], @@ -1185,11 +1182,7 @@ fn migrate_reconstruct_reports_registry_plans_without_applying_them() { fs::create_dir_all(&project).unwrap(); fs::create_dir_all(&data_root).unwrap(); fs::write(data_root.join("tracedecay.db"), b"graph").unwrap(); - fs::write( - data_root.join("branch-meta.json"), - r#"{"default_branch":"main","branches":{}}"#, - ) - .unwrap(); + write_valid_branch_meta(&data_root.join("branch-meta.json")); let store_manifest = StoreManifest { schema_version: STORE_MANIFEST_SCHEMA_VERSION, project_id: Some("proj_123".to_string()),