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
454 changes: 212 additions & 242 deletions crates/tracedecay-application/src/project_adoption.rs

Large diffs are not rendered by default.

6 changes: 1 addition & 5 deletions crates/tracedecay-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,14 +145,10 @@ pub enum Commands {
path_flag: Option<String>,
/// Adopt this registered project id onto the new path when its moved
/// non-git store no longer resolves at its recorded root. Adoption
/// never happens without this flag or `--yes`; a root that already
/// never happens without this flag; a root that already
/// belongs to a different project is refused.
#[arg(long = "adopt-project")]
adopt_project: Option<String>,
/// Mint a fresh project identity even when a moved non-git store
/// could be adopted at this root.
#[arg(long)]
fresh: bool,
/// Hold until the first code generation is ready, for as long as the
/// daemon reports indexing, restoration, or source verification in
/// flight. Without this flag, init returns a typed not-ready receipt
Expand Down
9 changes: 6 additions & 3 deletions crates/tracedecay-cli/src/cli/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,20 @@ without a systemd user bus, keep `tracedecay daemon run` running in another \
terminal. Storage is daemon-owned. Run once \
per repository; afterwards `tracedecay sync` keeps the index fresh \
incrementally. Indexes what Git sees (tracked files and untracked files \
.gitignore does not ignore) minus the project's `index.exclude.v1` patterns.";
.gitignore does not ignore) minus the project's `index.exclude.v1` patterns. \
For a moved non-git project, use `tracedecay projects list` to find its id and \
pass `--adopt-project <id>`; retry the same command after an interrupted \
adoption. Without that flag, an unregistered path gets a fresh identity. \
`--yes` no longer selects a moved project, and `--fresh` has been removed.";

pub(crate) const INIT_AFTER_HELP: &str = "\
Examples:
tracedecay daemon install-service Start the daemon init brokers through
tracedecay init Enroll and request the first index
tracedecay init --wait Enroll and hold until the first generation is ready
tracedecay init /path/to/repo Enroll another repository
tracedecay projects list Find a registered project id
tracedecay init /new/path --adopt-project proj_abc123
tracedecay init /new/path --yes Adopt the unique moved non-git store
tracedecay init /new/path --fresh Mint a new identity, never adopt

Related: tracedecay daemon (the daemon init requires), tracedecay sync
(incremental refresh), tracedecay status, tracedecay wipe (delete local \
Expand Down
20 changes: 20 additions & 0 deletions crates/tracedecay-cli/src/cli/parse_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ fn init_accepts_short_and_long_path_flag_like_dashboard_does() {
#[test]
fn brokered_index_commands_refuse_flags_the_daemon_never_honors_at_parse_time() {
for argv in [
&["tracedecay", "init", "--fresh"][..],
&["tracedecay", "init", "--skip-folder", "vendor"][..],
&["tracedecay", "init", "--include-folder", "dist/generated"][..],
&["tracedecay", "sync", "--skip-folder", "vendor"][..],
Expand All @@ -207,6 +208,25 @@ fn brokered_index_commands_refuse_flags_the_daemon_never_honors_at_parse_time()
));
}

#[test]
fn init_preserves_the_explicit_adoption_identity() {
let cli = Cli::try_parse_from([
"tracedecay",
"init",
"/new/project",
"--adopt-project",
"proj_selected",
])
.expect("named adoption should parse");
assert!(matches!(
cli.command,
Some(Commands::Init { path, adopt_project, .. })
if path.as_deref() == Some("/new/project")
&& adopt_project.as_deref() == Some("proj_selected")
));
assert!(Cli::try_parse_from(["tracedecay", "init", "--adopt-project"]).is_err());
}

#[test]
fn bench_is_not_a_subcommand() {
let error = match Cli::try_parse_from(["tracedecay", "bench", "--json"]) {
Expand Down
45 changes: 27 additions & 18 deletions crates/tracedecay-cli/src/commands/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ pub(crate) async fn handle_init(
profile: &ProfileRoot,
path: Option<String>,
adopt_project: Option<String>,
fresh: bool,
assume_yes: bool,
wait: bool,
) -> tracedecay_domain::errors::Result<()> {
Expand All @@ -80,7 +79,7 @@ pub(crate) async fn handle_init(
{
return Err(tracedecay_domain::errors::TraceDecayError::Config { message });
}
let adoption = moved_store_adoption_request(adopt_project, fresh, assume_yes)?;
let adoption = moved_store_adoption_request(adopt_project, assume_yes)?;
let mut handshake = tracedecay::daemon::handshake_for_current_client(
profile,
Some(project_path.clone()),
Expand Down Expand Up @@ -118,30 +117,22 @@ fn init_daemon_available(profile: &ProfileRoot) -> bool {
)
}

/// Maps explicit `tracedecay init` flags to the adoption request the daemon
/// honors. Only init escalates past `Never`: `--adopt-project` names the
/// project, bare `--yes` confirms a unique candidate, `--fresh` opts out of
/// adoption entirely, and no flags means candidates are offered in a typed
/// refusal instead of silently remapped.
/// Only a named project authorizes adoption. Refuse the former `init --yes`
/// selection so an old command cannot silently mint a different identity.
fn moved_store_adoption_request(
adopt_project: Option<String>,
fresh: bool,
assume_yes: bool,
) -> tracedecay_domain::errors::Result<tracedecay_project::project::MovedStoreAdoption> {
if fresh && adopt_project.is_some() {
if assume_yes && adopt_project.is_none() {
return Err(tracedecay_domain::errors::TraceDecayError::Config {
message: "--fresh mints a new project identity and contradicts --adopt-project; \
pass exactly one"
message: "init --yes no longer selects a moved project; use --adopt-project <id> \
to adopt a named project, or omit --yes to initialize a fresh identity"
.to_owned(),
});
}
Ok(match (adopt_project, fresh, assume_yes) {
(Some(project_id), _, _) => {
tracedecay_project::project::MovedStoreAdoption::AdoptNamed(project_id)
}
(None, true, _) => tracedecay_project::project::MovedStoreAdoption::Never,
(None, false, true) => tracedecay_project::project::MovedStoreAdoption::AdoptUnique,
(None, false, false) => tracedecay_project::project::MovedStoreAdoption::OfferCandidates,
Ok(match adopt_project {
Some(project_id) => tracedecay_project::project::MovedStoreAdoption::AdoptNamed(project_id),
None => tracedecay_project::project::MovedStoreAdoption::Never,
})
}

Expand Down Expand Up @@ -609,6 +600,24 @@ mod daemon_precondition_tests {
use tracedecay_contracts::retrieval::StatusCodeIndexFreshnessV1;
use tracedecay_domain::errors::TraceDecayError;
use tracedecay_mcp::handlers::info::readiness_wait_outcome;
use tracedecay_project::project::MovedStoreAdoption;

#[test]
fn init_adoption_requires_a_named_identity() {
assert_eq!(
super::moved_store_adoption_request(None, false).unwrap(),
MovedStoreAdoption::Never
);
for assume_yes in [false, true] {
assert_eq!(
super::moved_store_adoption_request(Some("proj_selected".to_owned()), assume_yes,)
.unwrap(),
MovedStoreAdoption::AdoptNamed("proj_selected".to_owned())
);
}
let error = super::moved_store_adoption_request(None, true).unwrap_err();
assert!(error.to_string().contains("--adopt-project <id>"));
}

pub(super) struct SocketEnvGuard {
previous: Option<std::ffi::OsString>,
Expand Down
12 changes: 2 additions & 10 deletions crates/tracedecay-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1071,19 +1071,11 @@ async fn dispatch_project_command(
path,
path_flag,
adopt_project,
fresh,
wait,
} => {
// clap enforces that at most one of these is present.
commands::handle_init(
profile,
path.or(path_flag),
adopt_project,
fresh,
assume_yes,
wait,
)
.await?;
commands::handle_init(profile, path.or(path_flag), adopt_project, assume_yes, wait)
.await?;
}
Commands::Sync { path, verbose } => {
commands::handle_sync(profile, path, verbose).await?;
Expand Down
1 change: 0 additions & 1 deletion crates/tracedecay-cli/src/startup_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,6 @@ fn representative_commands_route_to_their_dispatch_family() {
path: None,
path_flag: None,
adopt_project: None,
fresh: false,
wait: false,
},
CommandFamily::Project,
Expand Down
22 changes: 15 additions & 7 deletions crates/tracedecay-daemon-protocol/src/handshake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,9 @@ use tracedecay_domain::errors::Result;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MovedStoreAdoption {
/// Mint a fresh identity without consulting moved-store candidates.
/// Mint a fresh identity without consulting other registered projects.
#[default]
Never,
/// Explicit `tracedecay init` without adoption flags: mint fresh when no
/// moved store could claim this root; otherwise refuse with the candidates.
OfferCandidates,
/// `tracedecay init --yes`: adopt when candidates identify exactly one
/// moved store; anything ambiguous remains a typed refusal.
AdoptUnique,
/// `tracedecay init --adopt-project <id>`: adopt exactly this project.
AdoptNamed(String),
}
Expand Down Expand Up @@ -317,6 +311,20 @@ mod decoding_tests {
use super::{DaemonHandshake, MovedStoreAdoption};
use std::path::PathBuf;

#[test]
fn moved_store_adoption_rejects_implicit_selection_on_the_wire() {
for retired in ["offer_candidates", "adopt_unique"] {
let encoded = serde_json::to_string(retired).unwrap();
assert!(serde_json::from_str::<MovedStoreAdoption>(&encoded).is_err());
}
let named = MovedStoreAdoption::AdoptNamed("proj_selected".to_owned());
assert_eq!(
serde_json::from_str::<MovedStoreAdoption>(&serde_json::to_string(&named).unwrap())
.unwrap(),
named
);
}

/// Every non-optional handshake field is required: a sender that omits one
/// is refused (as `unsupported_revision` on the wire) instead of decoded with
/// a default.
Expand Down
133 changes: 130 additions & 3 deletions crates/tracedecay-global-db/src/project_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -894,12 +894,16 @@ impl RegisteredGlobalDb {
/// two or more project ids already claim this root or its git common
/// directory. Consolidating them would silently pick a winner, so the
/// profile is reported as needing a reset instead.
/// * [`TraceDecayError::Config`]: the registered root's metadata could not
/// be inspected before remapping its authority. The previous directory
/// is not assumed to have moved when inspection fails.
/// * [`TraceDecayError::Database`]: the registry read, write, or
/// post-commit read-back failed. Uncommitted work is dropped with the
/// transaction.
///
/// [`TraceDecayError::ProjectRoute`]: tracedecay_domain::errors::TraceDecayError::ProjectRoute
/// [`TraceDecayError::ResetRequired`]: tracedecay_domain::errors::TraceDecayError::ResetRequired
/// [`TraceDecayError::Config`]: tracedecay_domain::errors::TraceDecayError::Config
/// [`TraceDecayError::Database`]: tracedecay_domain::errors::TraceDecayError::Database
pub fn upsert_code_project<'a>(
&'a self,
Expand Down Expand Up @@ -1070,12 +1074,31 @@ impl RegisteredGlobalDb {
&& match registered_root(&transaction, project_id).await? {
None => true,
// The first enrolled checkout stays the registered root
// while it exists; a linked worktree of it may repair it.
// while its directory exists; a linked worktree of it may
// repair it. A replacement file does not own the root.
Some((registered_root, registered_common_dir)) => {
registered_root == canonical_root
|| !Path::new(&registered_root).exists()
if registered_root == canonical_root
|| (registered_common_dir.is_some()
&& registered_common_dir == git_common_dir_text)
{
true
} else {
match std::fs::metadata(&registered_root) {
Ok(metadata) => !metadata.is_dir(),
Err(error)
if error.kind() == std::io::ErrorKind::NotFound =>
{
true
}
Err(error) => {
return Err(tracedecay_domain::errors::TraceDecayError::Config {
message: format!(
"cannot inspect registered root '{registered_root}' for project '{project_id}': {error}"
),
});
}
}
}
}
};
if rewrite_registered_root {
Expand Down Expand Up @@ -2507,3 +2530,107 @@ pub async fn registered_enrollment_roots(
}
Ok(roots)
}

#[cfg(test)]
mod root_remap_tests {
use crate::tests::harness::RegisteredGlobalDbHarness;

#[asupersync::test]
async fn replacement_file_at_former_root_allows_registry_remap() {
let harness = RegisteredGlobalDbHarness::open("registry-replacement-file-remap").await;
let db = &harness.registered;
let storage_root = db.db_path().parent().expect("registered storage root");
let original_root = storage_root.join("original-checkout");
let moved_root = storage_root.join("moved-checkout");
let project_id = "replacement-file-project";
std::fs::create_dir_all(&original_root).expect("create original checkout");
let original = db
.upsert_code_project(project_id, &original_root, None, None, Some("main"))
.await
.expect("register original checkout");

std::fs::rename(&original_root, &moved_root).expect("move registered checkout");
let replacement_contents = b"unrelated replacement file";
std::fs::write(&original_root, replacement_contents)
.expect("replace former root with file");

let remapped = db
.upsert_code_project(project_id, &moved_root, None, None, Some("main"))
.await
.expect("remap past replacement file");
let canonical_moved_root = moved_root.canonicalize().expect("canonical moved root");
assert_eq!(remapped.project_id, original.project_id);
assert_eq!(
remapped.canonical_root,
canonical_moved_root.to_string_lossy().into_owned()
);
assert_eq!(remapped.created_at, original.created_at);
assert_eq!(
db.get_code_project(project_id)
.await
.expect("read remapped authority"),
Some(remapped)
);
for alias in [&original_root, &moved_root] {
let context = db
.project_registry_context_by_alias(alias)
.await
.expect("resolve checkout alias")
.expect("checkout alias remains registered");
assert_eq!(context.project.project_id, project_id);
}
assert_eq!(
std::fs::read(&original_root).expect("read replacement file"),
replacement_contents
);
}

#[cfg(unix)]
#[asupersync::test]
async fn uninspectable_former_root_refuses_registry_remap_without_writes() {
let harness = RegisteredGlobalDbHarness::open("registry-uninspectable-root-remap").await;
let db = &harness.registered;
let storage_root = db.db_path().parent().expect("registered storage root");
let original_root = storage_root.join("original-checkout");
let moved_root = storage_root.join("moved-checkout");
let project_id = "uninspectable-root-project";
std::fs::create_dir_all(&original_root).expect("create original checkout");
let original = db
.upsert_code_project(project_id, &original_root, None, None, Some("main"))
.await
.expect("register original checkout");

std::fs::rename(&original_root, &moved_root).expect("move registered checkout");
// A self-referential symlink fails metadata inspection even when the
// test runs as root, unlike a directory with its read permissions removed.
std::os::unix::fs::symlink(&original_root, &original_root)
.expect("make former root uninspectable");
let inspection_error = std::fs::metadata(&original_root)
.expect_err("self-referential symlink must fail inspection");
assert_ne!(inspection_error.kind(), std::io::ErrorKind::NotFound);

let error = db
.upsert_code_project(project_id, &moved_root, None, None, Some("replacement"))
.await
.expect_err("inspection failure must refuse remapping");
let tracedecay_domain::errors::TraceDecayError::Config { message } = error else {
panic!("expected a configuration refusal, got {error:?}");
};
assert!(message.contains("cannot inspect registered root"));
assert!(message.contains(&original.canonical_root));
assert!(message.contains(project_id));
assert_eq!(
db.get_code_project(project_id)
.await
.expect("read unchanged authority"),
Some(original)
);
assert!(
db.project_registry_context_by_alias(&moved_root)
.await
.expect("check moved-root alias")
.is_none(),
"refused remapping must not publish the destination alias"
);
}
}
Loading
Loading