Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions src/branch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -663,10 +663,11 @@ fn now_unix_secs() -> u64 {
/// Two independent sweeps, both age-gated so an in-flight branch that has not
/// yet synced or a just-deleted-then-recreated ref is never collected:
///
/// (a) **Tracked, ref-gone branches** — for each tracked non-default branch
/// whose git ref no longer exists AND whose `last_synced_at` is older than
/// `branch_gc_days`, remove its DB files and metadata entry. The default
/// branch is never removed.
/// (a) **Tracked, ref-gone branches** — for each unprotected tracked
/// non-default branch whose git ref no longer exists AND whose
/// `last_synced_at` is older than `branch_gc_days`, remove its DB files and
/// metadata entry. The default branch and GC-protected entries are never
/// removed.
/// (b) **Orphan DBs** — `branches/*.db` files not referenced by any meta entry
/// whose mtime is older than `orphan_db_gc_days` are deleted along with
/// their `-wal`/`-shm` sidecars.
Expand Down Expand Up @@ -698,7 +699,7 @@ pub fn gc_dead_branch_stores(
let candidates: Vec<(String, PathBuf, u64)> = meta
.branches
.iter()
.filter(|(name, _)| **name != default_branch)
.filter(|(name, entry)| **name != default_branch && !entry.gc_protected)
.map(|(name, entry)| {
(
name.clone(),
Expand Down
19 changes: 19 additions & 0 deletions src/branch/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,25 @@ fn gc_keeps_fresh_dead_branch() {
assert!(db.exists());
}

#[test]
fn gc_keeps_protected_ref_gone_stale_branch() {
let (_base, project_root, td) = setup_repo_with_meta();
let db = add_tracked_branch(&project_root, &td, "recovered/orphan", 0, false);
let mut meta = crate::branch_meta::load_branch_meta(&td).unwrap();
meta.branches
.get_mut("recovered/orphan")
.unwrap()
.gc_protected = true;
crate::branch_meta::save_branch_meta(&td, &meta).unwrap();

let report = gc_dead_branch_stores(&project_root, &td, 0, 0);

assert!(report.removed_tracked.is_empty());
assert!(db.exists());
let reloaded = crate::branch_meta::load_branch_meta(&td).unwrap();
assert!(reloaded.branches["recovered/orphan"].gc_protected);
}

#[test]
fn gc_keeps_branch_with_live_ref() {
let (_base, project_root, td) = setup_repo_with_meta();
Expand Down
15 changes: 15 additions & 0 deletions src/branch_meta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ pub struct BranchEntry {
pub created_at: String,
/// UNIX timestamp (seconds) of last successful sync.
pub last_synced_at: String,
/// Whether automatic branch-store GC must retain this entry even when it
/// has no matching git ref.
#[serde(default)]
pub gc_protected: bool,
}

/// Top-level branch metadata for a project.
Expand Down Expand Up @@ -57,6 +61,7 @@ impl BranchMeta {
parent: None,
created_at: now.clone(),
last_synced_at: now,
gc_protected: false,
},
);
Self {
Expand All @@ -75,6 +80,7 @@ impl BranchMeta {
parent: Some(parent.to_string()),
created_at: now.clone(),
last_synced_at: now,
gc_protected: false,
},
);
}
Expand Down Expand Up @@ -259,6 +265,15 @@ mod tests {
assert!(parse("[]").is_err());
}

#[test]
fn parse_old_entry_defaults_gc_protected_to_false() {
let meta = parse(
r#"{"default_branch":"main","branches":{"main":{"db_file":"tracedecay.db","created_at":"1","last_synced_at":"1"}}}"#,
)
.unwrap();
assert!(!meta.branches["main"].gc_protected);
}

#[test]
fn update_synced_timestamp_advances_tracked_branch() {
let dir = tempfile::tempdir().unwrap();
Expand Down
1 change: 1 addition & 0 deletions src/migrate/consolidate/evidence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ async fn capture_graph_evidence(
{
peak_scratch_bytes = peak_scratch_bytes.max(snapshot.copied_bytes());
}
sqlite::quick_check_connection(snapshot.connection(), path).await?;
sqlite::extend_graph_identities(snapshot.connection(), &mut identities).await?;
let fingerprint =
crate::sqlite_read_snapshot::family_fingerprint(path).map_err(io_error)?;
Expand Down
99 changes: 89 additions & 10 deletions src/migrate/consolidate/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ use finalize::{cut_over_markers, register_destination, verify_destination};
use preflight::{acquire_store_locks, ensure_profile_offline, preflight_disk_space};
use prepare::prepare_destination;

use crate::branch_meta::{self, BranchMeta};
use crate::branch_meta::{self, BranchEntry, BranchMeta};
use crate::errors::{Result, TraceDecayError};
use crate::global_db::{GlobalDb, GraphScopeUpsert, StoreArtifactUpsert, StoreInstanceUpsert};
use crate::storage::{
Expand Down Expand Up @@ -377,8 +377,10 @@ async fn resolve_plan_inner(
&destination_project_id,
)?;

let source_meta = load_required_branch_meta(&source_layout)?;
let target_meta = load_required_branch_meta(&target_layout)?;
let mut source_meta = load_required_branch_meta(&source_layout)?;
let mut target_meta = load_required_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)?;
let target_graphs = graph_db_paths(&target_layout, &target_meta)?;
let session_paths = vec![
Expand Down Expand Up @@ -583,6 +585,53 @@ fn load_required_branch_meta(layout: &StoreLayout) -> Result<BranchMeta> {
})
}

fn recover_untracked_branch_graphs(layout: &StoreLayout, meta: &mut BranchMeta) -> Result<()> {
for (relative, path) in relative_file_map(&layout.data_root)? {
if !relative.starts_with("branches") || !is_sqlite_database(&relative) {
continue;
}
if meta
.branches
.values()
.any(|entry| same_path(&layout.data_root.join(&entry.db_file), &path))
{
continue;
}
let db_file = relative.to_str().ok_or_else(|| {
config_error(format!(
"untracked branch graph '{}' cannot be represented in branch metadata",
path.display()
))
})?;
let base = relative
.file_stem()
.and_then(|value| value.to_str())
.map(crate::branch::sanitize_branch_name)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "branch".to_string());
let mut hash = Sha256::new();
hash.update(relative.as_os_str().as_encoded_bytes());
let name = format!("recovered/{base}-{}", &hex::encode(hash.finalize())[..16]);
if meta.branches.contains_key(&name) {
return Err(config_error(format!(
"recovered branch name collision for '{}'",
path.display()
)));
}
meta.branches.insert(
name,
BranchEntry {
db_file: db_file.to_string(),
parent: Some(meta.default_branch.clone()),
created_at: "0".to_string(),
last_synced_at: "0".to_string(),
gc_protected: true,
},
);
}
Ok(())
}

async fn inventory_store(
graph: &GraphStoreEvidence,
sessions: &crate::sqlite_read_snapshot::SnapshotSet,
Expand Down Expand Up @@ -927,13 +976,7 @@ fn graph_db_paths_for_root(root: &Path, meta: &BranchMeta) -> Result<Vec<PathBuf
let main = root.join(crate::config::DB_FILENAME);
let mut paths = BTreeSet::new();
for entry in meta.branches.values() {
let path = root.join(&entry.db_file);
if !path.is_file() {
return Err(config_error(format!(
"branch graph '{}' is missing",
path.display()
)));
}
let path = confined_branch_graph_path(root, &entry.db_file)?;
paths.insert(path);
}
if !paths.remove(&main) {
Expand All @@ -945,6 +988,42 @@ fn graph_db_paths_for_root(root: &Path, meta: &BranchMeta) -> Result<Vec<PathBuf
Ok(std::iter::once(main).chain(paths).collect())
}

fn confined_branch_graph_path(root: &Path, db_file: &str) -> Result<PathBuf> {
let relative = Path::new(db_file);
if relative.as_os_str().is_empty()
|| relative.is_absolute()
|| relative
.components()
.any(|component| !matches!(component, std::path::Component::Normal(_)))
{
return Err(config_error(format!(
"branch graph path '{db_file}' is not a normalized store-relative path"
)));
}
let path = root.join(relative);
let canonical_root = root.canonicalize().map_err(io_error)?;
let canonical_path = path.canonicalize().map_err(|error| {
config_error(format!(
"branch graph '{}' is missing: {error}",
path.display()
))
})?;
if !canonical_path.starts_with(&canonical_root) {
return Err(config_error(format!(
"branch graph '{}' escapes profile shard '{}'",
path.display(),
root.display()
)));
}
if !canonical_path.is_file() {
return Err(config_error(format!(
"branch graph '{}' is not a file",
path.display()
)));
}
Ok(path)
}

fn same_path(left: &Path, right: &Path) -> bool {
canonical_or_original(left) == canonical_or_original(right)
}
Expand Down
2 changes: 2 additions & 0 deletions src/migrate/consolidate/prepare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ fn preserve_source_branch_graphs(
.map(|parent| format!("{prefix}{parent}")),
created_at: entry.created_at.clone(),
last_synced_at: entry.last_synced_at.clone(),
gc_protected: true,
},
);
maybe_stop(stop, PrepareStop::SourceBranch(index + 1))?;
Expand Down Expand Up @@ -177,6 +178,7 @@ fn expected_prepared_files(
.map(|parent| format!("{prefix}{parent}")),
created_at: entry.created_at.clone(),
last_synced_at: entry.last_synced_at.clone(),
gc_protected: true,
},
);
}
Expand Down
Loading
Loading