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
28 changes: 28 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,34 @@ pub enum MemoryAction {

#[derive(Subcommand)]
pub enum MigrateAction {
/// Consolidate two non-empty profile shards for one repository identity.
#[command(
long_about = CONSOLIDATE_LONG_ABOUT,
after_help = CONSOLIDATE_AFTER_HELP
)]
Consolidate {
/// Repository path whose git-common-dir identity owns both shards.
#[arg(long, default_value = ".")]
project: String,
/// Legacy/input project id to preserve and merge.
#[arg(long = "source-project-id")]
source_project_id: String,
/// Currently selected project id to use as the merge base.
#[arg(long = "target-project-id")]
target_project_id: String,
/// Profile root containing projects/<project-id> shards.
#[arg(long = "profile-root")]
profile_root: Option<String>,
/// Apply the planned consolidation. Omit for a read-only inventory.
#[arg(long)]
apply: bool,
/// Confirmation token printed by the read-only inventory.
#[arg(long = "confirm-token", requires = "apply")]
confirm_token: Option<String>,
/// Output as JSON.
#[arg(long)]
json: bool,
},
/// Build a readonly migration inventory or manifest plan
Plan {
/// Root directory to scan (repeatable). Defaults to the current directory.
Expand Down
22 changes: 21 additions & 1 deletion src/cli/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,8 @@ tracedecay dashboard (review UI), tracedecay memory curate.";
pub(crate) const MIGRATE_LONG_ABOUT: &str = "\
Plans and executes profile-storage migrations: inventory scans, manifest \
plans, staged copy + cutover, verification, rollback, and registry cleanup. \
Mutating steps require the confirmation token printed by `migrate plan`; \
Explicit split-store consolidation preserves both inputs and builds a new \
shard before marker cutover. Mutating steps require a dry-run confirmation token; \
start with plan/verify, which never touch source stores.";

pub(crate) const MIGRATE_AFTER_HELP: &str = "\
Expand All @@ -512,10 +513,29 @@ Examples:
tracedecay migrate plan --manifest plan.json Write a manifest plan
tracedecay migrate verify --manifest plan.json Check without mutating
tracedecay migrate apply --manifest plan.json --confirm-token <token>
tracedecay migrate consolidate --source-project-id <old> --target-project-id <current>
tracedecay migrate registry-gc Dry-run stale-registry cleanup

Related: tracedecay projects (registry view), tracedecay wipe.";

pub(crate) const CONSOLIDATE_LONG_ABOUT: &str = "\
Consolidates exactly two profile shards that belong to one git-common-dir identity. \
The default is a read-only dry-run: it freezes and inventories both inputs, reports \
collision semantics, and prints an immutable confirmation token. The complete workflow \
is offline-only: stop the daemon and all MCP/CLI writers first. Apply builds and verifies a third shard; \
the two originals and complete backups remain preserved. Registry and repository markers \
move only after verification succeeds.";

pub(crate) const CONSOLIDATE_AFTER_HELP: &str = "\
Dry-run:
tracedecay migrate consolidate --project . --source-project-id <old> --target-project-id <current>

Apply the exact frozen plan:
tracedecay migrate consolidate --project . --source-project-id <old> --target-project-id <current> --apply --confirm-token <immutable-token>

Stop the TraceDecay daemon and all MCP/CLI writers before the dry-run. Rerun it if \
either input changes; the prior token will be rejected.";

pub(crate) const WIPE_LONG_ABOUT: &str = "\
Deletes .tracedecay stores (code graph, memory, sessions) for the current \
folder, its parents, and its children — or every tracked project with --all. \
Expand Down
40 changes: 40 additions & 0 deletions src/cli/parse_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1367,6 +1367,46 @@ fn project_selector_flags_parse_for_cli_read_surfaces() {

#[test]
fn migrate_commands_parse_manifest_scaffolding_flags() {
let consolidate = Cli::try_parse_from([
"tracedecay",
"migrate",
"consolidate",
"--project",
"/tmp/project",
"--profile-root",
"/tmp/profile",
"--source-project-id",
"proj_old",
"--target-project-id",
"proj_current",
"--apply",
"--confirm-token",
"confirm-123",
"--json",
])
.expect("migrate consolidate should parse");
assert!(matches!(
consolidate.command,
Some(Commands::Migrate {
action:
MigrateAction::Consolidate {
project,
profile_root,
source_project_id,
target_project_id,
apply,
confirm_token,
json,
}
}) if project == "/tmp/project"
&& profile_root.as_deref() == Some("/tmp/profile")
&& source_project_id == "proj_old"
&& target_project_id == "proj_current"
&& apply
&& confirm_token.as_deref() == Some("confirm-123")
&& json
));

let plan = Cli::try_parse_from([
"tracedecay",
"migrate",
Expand Down
62 changes: 62 additions & 0 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,68 @@ fn read_llm_ops_payload(source: &str) -> tracedecay::errors::Result<serde_json::

pub(crate) async fn handle_migrate_action(action: MigrateAction) -> tracedecay::errors::Result<()> {
match action {
MigrateAction::Consolidate {
project,
source_project_id,
target_project_id,
profile_root,
apply,
confirm_token,
json,
} => {
let profile_root = profile_root.map_or_else(
|| {
tracedecay::config::user_data_dir().ok_or_else(|| {
tracedecay::errors::TraceDecayError::Config {
message: "could not determine TraceDecay profile root".to_string(),
}
})
},
|value| Ok(PathBuf::from(value)),
)?;
let options = tracedecay::migrate::consolidate::ConsolidationOptions {
project_root: PathBuf::from(project),
profile_root,
source_project_id,
target_project_id,
};
let report = if apply {
let token =
confirm_token.ok_or_else(|| tracedecay::errors::TraceDecayError::Config {
message: "--confirm-token is required with --apply".to_string(),
})?;
tracedecay::migrate::consolidate::apply(&options, &token).await?
} else {
tracedecay::migrate::consolidate::plan(&options).await?
};
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
println!("Migration: {}", report.migration_id);
println!("State: {:?}", report.state);
println!(
"Source: {} ({})",
report.source.project_id,
report.source.data_root.display()
);
println!(
"Target: {} ({})",
report.target.project_id,
report.target.data_root.display()
);
println!(
"Destination: {} ({})",
report.destination_project_id,
report.destination_data_root.display()
);
println!("Backups: {}", report.backup_root.display());
println!("Ledger: {}", report.ledger_path.display());
if report.dry_run {
println!("Confirmation token: {}", report.confirmation_token);
println!("No files changed.");
}
}
}
MigrateAction::Plan {
roots,
include_all_registered,
Expand Down
10 changes: 9 additions & 1 deletion src/doctor/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,15 @@ async fn current_project_store_surfaces_split_identity_conflict()
assert!(diagnostic.contains("proj_doctor_selected"), "{diagnostic}");
assert!(diagnostic.contains("proj_doctor_legacy"), "{diagnostic}");
assert!(
diagnostic.contains("backup-and-consolidate migration"),
diagnostic.contains("tracedecay migrate consolidate"),
"{diagnostic}"
);
assert!(
diagnostic.contains("--source-project-id proj_doctor_legacy"),
"{diagnostic}"
);
assert!(
diagnostic.contains("--target-project-id proj_doctor_selected"),
"{diagnostic}"
);
assert!(diagnostic.contains("no files changed"), "{diagnostic}");
Expand Down
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ pub mod mcp;
pub mod memory;
pub mod migrate;
pub mod monitor;
mod open_store_holders;
mod path_scope;
mod path_tree;
pub mod project_registry;
Expand All @@ -70,6 +71,7 @@ pub mod serde_util;
pub mod serve;
pub mod sessions;
mod shell;
mod sqlite_read_snapshot;
pub mod storage;
pub mod sync;
pub mod text;
Expand Down
16 changes: 16 additions & 0 deletions src/lifecycle_lease.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,22 @@ pub fn acquire_exclusive(operation: &str) -> Result<LifecycleLease> {
acquire_exclusive_at(&lifecycle_lock_path()?, operation)
}

/// Acquires the lifecycle lease rooted in an explicit profile. Migration
/// commands use this instead of ambient HOME so synthetic profiles and
/// user-selected profile roots cannot accidentally lock a different store.
pub fn acquire_exclusive_for_profile(
profile_root: &Path,
operation: &str,
) -> Result<LifecycleLease> {
std::fs::create_dir_all(profile_root).map_err(|error| TraceDecayError::Config {
message: format!(
"failed to create TraceDecay profile root '{}': {error}",
profile_root.display()
),
})?;
acquire_exclusive_at(&profile_root.join(LIFECYCLE_LOCK_FILENAME), operation)
}

pub fn acquire_shared(operation: &str) -> Result<LifecycleLease> {
acquire_shared_at(&lifecycle_lock_path()?, operation)
}
Expand Down
3 changes: 2 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -796,7 +796,8 @@ fn should_skip_agent_install_maintenance(command: &Commands) -> bool {
// update-plugin contract.
// - `Uninstall`: about to remove agent configs — don't reinstall them
// first (per the original #84 intent).
// - `Doctor` / `Migrate`: read-only diagnostics — must not mutate agent
// - `Doctor` / `Migrate`: diagnostics and explicitly confirmed storage
// maintenance manage their own lifecycle and must not mutate agent
// configs as a side effect.
// - `Tool`: per-invocation tool calls are a hot-ish path; skip the
// reinstall scan there too.
Expand Down
Loading
Loading