Skip to content

Commit 976de05

Browse files
Merge pull request #463 from BleedingDev/fix/consolidate-legacy-single-db
fix(migrate): harden profile consolidation
2 parents e0aa9a9 + a0d5680 commit 976de05

10 files changed

Lines changed: 771 additions & 109 deletions

File tree

src/branch.rs

Lines changed: 144 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -134,11 +134,15 @@ fn gix_rev_distance(
134134
Some(count)
135135
}
136136

137-
/// Auto-detects the default branch (main or master).
137+
/// Auto-detects the repository's default branch.
138138
///
139139
/// Strategy:
140140
/// 1. Try `git symbolic-ref refs/remotes/origin/HEAD`
141141
/// 2. Fall back to checking if `main` or `master` exists locally
142+
/// 3. Fall back to the currently checked-out local branch
143+
///
144+
/// The final fallback deliberately returns `None` for detached HEAD rather
145+
/// than inventing a default branch.
142146
pub fn detect_default_branch(project_root: &Path) -> Option<String> {
143147
let repo = gix::open(project_root).ok()?;
144148

@@ -164,7 +168,72 @@ pub fn detect_default_branch(project_root: &Path) -> Option<String> {
164168
}
165169
}
166170

167-
None
171+
current_branch(project_root)
172+
}
173+
174+
#[cfg(test)]
175+
mod default_branch_tests {
176+
use super::*;
177+
178+
fn run_git(project_root: &Path, args: &[&str]) {
179+
let output = std::process::Command::new(crate::git::git_program())
180+
.args(args)
181+
.current_dir(project_root)
182+
.output()
183+
.unwrap();
184+
assert!(
185+
output.status.success(),
186+
"git {args:?} failed: {}",
187+
String::from_utf8_lossy(&output.stderr)
188+
);
189+
}
190+
191+
fn custom_default_repo() -> (tempfile::TempDir, PathBuf) {
192+
let temp = tempfile::tempdir().unwrap();
193+
let project_root = temp.path().to_path_buf();
194+
run_git(&project_root, &["init", "-b", "trunk"]);
195+
run_git(&project_root, &["config", "user.email", "test@example.com"]);
196+
run_git(&project_root, &["config", "user.name", "TraceDecay Test"]);
197+
std::fs::write(project_root.join("fixture"), b"fixture").unwrap();
198+
run_git(&project_root, &["add", "fixture"]);
199+
run_git(&project_root, &["commit", "-m", "fixture"]);
200+
(temp, project_root)
201+
}
202+
203+
#[test]
204+
fn detects_checked_out_custom_default_without_origin_head() {
205+
let (_temp, project_root) = custom_default_repo();
206+
207+
assert_eq!(
208+
detect_default_branch(&project_root).as_deref(),
209+
Some("trunk")
210+
);
211+
}
212+
213+
#[test]
214+
fn detached_custom_default_does_not_guess() {
215+
let (_temp, project_root) = custom_default_repo();
216+
run_git(&project_root, &["checkout", "--detach", "HEAD"]);
217+
218+
assert_eq!(detect_default_branch(&project_root), None);
219+
}
220+
221+
#[tokio::test]
222+
async fn detached_legacy_store_refuses_to_invent_default_metadata() {
223+
let (temp, project_root) = custom_default_repo();
224+
run_git(&project_root, &["checkout", "--detach", "HEAD"]);
225+
let data_dir = temp.path().join("profile-shard");
226+
std::fs::create_dir(&data_dir).unwrap();
227+
std::fs::write(data_dir.join(crate::config::DB_FILENAME), b"graph").unwrap();
228+
229+
let Err(error) = prepare_branch_tracking_in_layout(&project_root, "trunk", &data_dir).await
230+
else {
231+
panic!("detached legacy store must not invent a default branch")
232+
};
233+
234+
assert!(error.to_string().contains("default branch is unknown"));
235+
assert!(!data_dir.join(crate::storage::BRANCH_META_FILENAME).exists());
236+
}
168237
}
169238

170239
/// Sanitizes a branch name for use as a filename.
@@ -355,7 +424,8 @@ pub fn find_nearest_tracked_ancestor(
355424
pub enum BranchAddOutcome {
356425
/// The project has no `.tracedecay/` index; nothing was done.
357426
NotIndexed,
358-
/// The branch was already tracked; no copy/sync was performed.
427+
/// The branch was already tracked; no copy/sync was performed. Legacy
428+
/// single-DB metadata may have been persisted for the default branch.
359429
AlreadyTracked,
360430
/// A new branch DB was created from the nearest ancestor and synced.
361431
Added,
@@ -408,8 +478,8 @@ pub async fn prepare_branch_tracking_in_layout(
408478
};
409479

410480
let meta_path = tracedecay_dir.join("branch-meta.json");
411-
let mut meta = match branch_meta::load_branch_meta(tracedecay_dir) {
412-
Some(meta) => meta,
481+
let (mut meta, metadata_was_missing) = match branch_meta::load_branch_meta(tracedecay_dir) {
482+
Some(meta) => (meta, false),
413483
None if meta_path.exists() => {
414484
return Err(crate::errors::TraceDecayError::Config {
415485
message: format!(
@@ -419,13 +489,26 @@ pub async fn prepare_branch_tracking_in_layout(
419489
});
420490
}
421491
None => {
422-
let default = detect_default_branch(project_root).unwrap_or_else(|| "main".to_string());
423-
branch_meta::BranchMeta::new_for_dir(tracedecay_dir, &default)
492+
let default = detect_default_branch(project_root).ok_or_else(|| {
493+
crate::errors::TraceDecayError::Config {
494+
message: format!(
495+
"cannot initialize missing branch metadata at '{}': repository default branch is unknown (detached HEAD or no default ref)",
496+
meta_path.display()
497+
),
498+
}
499+
})?;
500+
(
501+
branch_meta::BranchMeta::for_legacy_single_db(tracedecay_dir, &default),
502+
true,
503+
)
424504
}
425505
};
426506
prune_missing_branch_dbs(tracedecay_dir, &mut meta);
427507

428508
if meta.is_tracked(branch_name) {
509+
if metadata_was_missing {
510+
branch_meta::save_branch_meta(tracedecay_dir, &meta)?;
511+
}
429512
return Ok(BranchTrackingPreparation::AlreadyTracked);
430513
}
431514

@@ -485,6 +568,60 @@ pub async fn prepare_branch_tracking_in_layout(
485568
}))
486569
}
487570

571+
#[cfg(test)]
572+
#[tokio::test]
573+
async fn default_branch_bootstrap_persists_canonical_metadata() {
574+
let temp = tempfile::tempdir().unwrap();
575+
let project_root = temp.path().join("repo");
576+
std::fs::create_dir_all(&project_root).unwrap();
577+
let run_git = |args: &[&str]| {
578+
let output = std::process::Command::new(crate::git::git_program())
579+
.args(args)
580+
.current_dir(&project_root)
581+
.output()
582+
.unwrap();
583+
assert!(
584+
output.status.success(),
585+
"git {args:?} failed: {}",
586+
String::from_utf8_lossy(&output.stderr)
587+
);
588+
};
589+
run_git(&["init", "-b", "main"]);
590+
std::fs::write(project_root.join("fixture"), b"fixture").unwrap();
591+
run_git(&["add", "fixture"]);
592+
run_git(&[
593+
"-c",
594+
"user.email=test@example.com",
595+
"-c",
596+
"user.name=TraceDecay Test",
597+
"commit",
598+
"-m",
599+
"fixture",
600+
]);
601+
602+
let data_dir = temp.path().join("profile-shard");
603+
std::fs::create_dir_all(&data_dir).unwrap();
604+
std::fs::write(data_dir.join(crate::config::DB_FILENAME), b"graph").unwrap();
605+
let meta_path = data_dir.join(crate::storage::BRANCH_META_FILENAME);
606+
assert!(!meta_path.exists());
607+
608+
let outcome = prepare_branch_tracking_in_layout(&project_root, "main", &data_dir)
609+
.await
610+
.unwrap();
611+
612+
assert!(matches!(outcome, BranchTrackingPreparation::AlreadyTracked));
613+
let meta = crate::branch_meta::load_branch_meta(&data_dir).unwrap();
614+
assert_eq!(meta.default_branch, "main");
615+
assert_eq!(meta.branches.len(), 1);
616+
let default = meta.branches.get("main").unwrap();
617+
assert_eq!(default.db_file, crate::config::db_filename(&data_dir));
618+
assert!(default.parent.is_none());
619+
assert_eq!(default.created_at, "0");
620+
assert_eq!(default.last_synced_at, "0");
621+
assert!(!meta_path.with_extension("json.tmp").exists());
622+
assert!(!data_dir.join("branches").exists());
623+
}
624+
488625
pub fn finalize_prepared_branch_tracking(tracedecay_dir: &Path, prepared: &PreparedBranchTracking) {
489626
if let Some(mut meta) = crate::branch_meta::load_branch_meta(tracedecay_dir) {
490627
meta.touch_synced(&prepared.branch_name);

0 commit comments

Comments
 (0)