1use crate::schema::sqlite_sidecar_path;
4use crate::{
5 AtlasStore, DbError, DbResult, IndexPublicationState, ProjectRootTransition, set_metadata,
6 validate_database_location, verify_project_database,
7};
8use projectatlas_core::graph::ProjectInstanceId;
9#[cfg(test)]
10use projectatlas_core::normalize_native_path_display;
11use projectatlas_core::{CanonicalProjectRoot, IndexGeneration, IndexWorkControl, IndexWorkStage};
12use rusqlite::Connection;
13use rusqlite::backup::{Backup, StepResult};
14use std::fs;
15use std::path::{Path, PathBuf};
16use std::thread;
17use std::time::{Duration, SystemTime, UNIX_EPOCH};
18use tempfile::{Builder, TempPath};
19
20const HYDRATION_BACKUP_PAGES_PER_STEP: i32 = 256;
22const HYDRATION_BACKUP_BUSY_PAUSE: Duration = Duration::from_millis(1);
24const HYDRATION_BACKUP_BUSY_ATTEMPTS: usize = 5_000;
26const HYDRATION_CANDIDATE_PREFIX: &str = ".projectatlas-hydration-";
28const HYDRATION_SOURCE_PROJECT_KEY: &str = "worktree_hydration_source_project_instance_id";
30const HYDRATION_SOURCE_GENERATION_KEY: &str = "worktree_hydration_source_generation";
32const HYDRATION_PREPARED_AT_KEY: &str = "worktree_hydration_prepared_at_epoch";
34
35#[derive(Debug)]
37pub struct WorktreeHydrationCandidate {
38 path: Option<TempPath>,
40 destination_database: PathBuf,
42 target_root: CanonicalProjectRoot,
44 source_project_instance_id: ProjectInstanceId,
46 target_project_instance_id: ProjectInstanceId,
48 baseline_generation: IndexGeneration,
50 source_state_verified: bool,
52}
53
54impl WorktreeHydrationCandidate {
55 pub fn path(&self) -> DbResult<&Path> {
61 self.path
62 .as_deref()
63 .ok_or(DbError::WorktreeHydrationInvalid {
64 reason: "hydration candidate path was already consumed",
65 })
66 }
67
68 #[must_use]
70 pub fn destination_database(&self) -> &Path {
71 &self.destination_database
72 }
73
74 #[must_use]
76 pub const fn source_project_instance_id(&self) -> ProjectInstanceId {
77 self.source_project_instance_id
78 }
79
80 #[must_use]
82 pub const fn target_project_instance_id(&self) -> ProjectInstanceId {
83 self.target_project_instance_id
84 }
85
86 #[must_use]
88 pub const fn baseline_generation(&self) -> IndexGeneration {
89 self.baseline_generation
90 }
91
92 pub fn accept_verified_source_state(&mut self, control: &IndexWorkControl) -> DbResult<()> {
100 control.check(IndexWorkStage::Publication)?;
101 let store = AtlasStore::open_for_project(self.path()?, self.target_root.as_path())?;
102 let publication = store.index_publication()?;
103 if !publication.as_ref().is_some_and(|publication| {
104 publication.state == IndexPublicationState::Complete
105 && publication.generation == self.baseline_generation
106 }) {
107 return Err(DbError::WorktreeHydrationNotReconciled {
108 baseline: self.baseline_generation,
109 found: publication.map_or(IndexGeneration::ZERO, |value| value.generation),
110 });
111 }
112 self.source_state_verified = true;
113 Ok(())
114 }
115
116 pub fn prepare_activation(
123 mut self,
124 control: &IndexWorkControl,
125 ) -> DbResult<PreparedWorktreeHydrationCandidate> {
126 control.check(IndexWorkStage::Publication)?;
127 let candidate_path = self.path()?.to_path_buf();
128 let store = AtlasStore::open_for_project(&candidate_path, self.target_root.as_path())?;
129 let publication = store.index_publication()?.filter(|publication| {
130 publication.state == IndexPublicationState::Complete
131 && (publication.generation > self.baseline_generation
132 || self.source_state_verified
133 && publication.generation == self.baseline_generation)
134 });
135 let found_generation = publication
136 .as_ref()
137 .map_or(IndexGeneration::ZERO, |publication| publication.generation);
138 if publication.is_none() {
139 return Err(DbError::WorktreeHydrationNotReconciled {
140 baseline: self.baseline_generation,
141 found: found_generation,
142 });
143 }
144 let (busy, log_frames, checkpointed_frames) =
145 store
146 .connection
147 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
148 Ok((
149 row.get::<_, i64>(0)?,
150 row.get::<_, i64>(1)?,
151 row.get::<_, i64>(2)?,
152 ))
153 })?;
154 if busy != 0 || log_frames != checkpointed_frames {
155 return Err(DbError::WorktreeHydrationInvalid {
156 reason: "candidate WAL checkpoint remained busy or incomplete",
157 });
158 }
159 drop(store);
160
161 verify_project_database(&candidate_path, self.target_root.as_path())?;
162 fs::OpenOptions::new()
163 .write(true)
164 .open(&candidate_path)
165 .and_then(|file| file.sync_all())
166 .map_err(|source| DbError::WorktreeHydrationIo {
167 path: candidate_path.clone(),
168 source,
169 })?;
170 remove_candidate_sidecars(&candidate_path)?;
171 control.check(IndexWorkStage::Publication)?;
172
173 let path = self.path.take().ok_or(DbError::WorktreeHydrationInvalid {
174 reason: "hydration candidate path was already consumed",
175 })?;
176 Ok(PreparedWorktreeHydrationCandidate {
177 path: Some(path),
178 destination_database: self.destination_database.clone(),
179 source_project_instance_id: self.source_project_instance_id,
180 target_project_instance_id: self.target_project_instance_id,
181 baseline_generation: self.baseline_generation,
182 reconciled_generation: found_generation,
183 })
184 }
185
186 pub fn activate(self, control: &IndexWorkControl) -> DbResult<WorktreeHydrationActivation> {
196 self.prepare_activation(control)?.activate(control)
197 }
198}
199
200#[derive(Debug)]
202pub struct PreparedWorktreeHydrationCandidate {
203 path: Option<TempPath>,
205 destination_database: PathBuf,
207 source_project_instance_id: ProjectInstanceId,
209 target_project_instance_id: ProjectInstanceId,
211 baseline_generation: IndexGeneration,
213 reconciled_generation: IndexGeneration,
215}
216
217impl PreparedWorktreeHydrationCandidate {
218 pub fn activate(mut self, control: &IndexWorkControl) -> DbResult<WorktreeHydrationActivation> {
226 control.check(IndexWorkStage::Publication)?;
227
228 let path = self.path.take().ok_or(DbError::WorktreeHydrationInvalid {
229 reason: "hydration candidate path was already consumed",
230 })?;
231 path.persist_noclobber(&self.destination_database)
232 .map_err(|error| {
233 if error.error.kind() == std::io::ErrorKind::AlreadyExists {
234 DbError::WorktreeHydrationDestinationExists {
235 path: self.destination_database.clone(),
236 }
237 } else {
238 DbError::WorktreeHydrationIo {
239 path: self.destination_database.clone(),
240 source: error.error,
241 }
242 }
243 })?;
244 #[cfg(unix)]
245 sync_activation_directory(&self.destination_database)?;
246
247 Ok(WorktreeHydrationActivation {
248 database: self.destination_database.clone(),
249 source_project_instance_id: self.source_project_instance_id,
250 target_project_instance_id: self.target_project_instance_id,
251 baseline_generation: self.baseline_generation,
252 reconciled_generation: self.reconciled_generation,
253 })
254 }
255}
256
257#[cfg(unix)]
259fn sync_activation_directory(destination_database: &Path) -> DbResult<()> {
260 let parent = destination_database
261 .parent()
262 .ok_or(DbError::WorktreeHydrationInvalid {
263 reason: "hydration destination database has no parent",
264 })?;
265 fs::File::open(parent)
266 .and_then(|directory| directory.sync_all())
267 .map_err(|source| DbError::WorktreeHydrationIo {
268 path: parent.to_path_buf(),
269 source,
270 })
271}
272
273impl Drop for WorktreeHydrationCandidate {
274 fn drop(&mut self) {
275 if let Some(path) = self.path.as_deref() {
276 remove_candidate_sidecars_best_effort(path);
277 }
278 }
279}
280
281impl Drop for PreparedWorktreeHydrationCandidate {
282 fn drop(&mut self) {
283 if let Some(path) = self.path.as_deref() {
284 remove_candidate_sidecars_best_effort(path);
285 }
286 }
287}
288
289#[derive(Clone, Debug, Eq, PartialEq)]
291pub struct WorktreeHydrationActivation {
292 pub database: PathBuf,
294 pub source_project_instance_id: ProjectInstanceId,
296 pub target_project_instance_id: ProjectInstanceId,
298 pub baseline_generation: IndexGeneration,
300 pub reconciled_generation: IndexGeneration,
302}
303
304impl AtlasStore {
305 pub fn prepare_worktree_hydration(
316 &self,
317 target_root: &Path,
318 destination_database: &Path,
319 control: &IndexWorkControl,
320 ) -> DbResult<WorktreeHydrationCandidate> {
321 control.check(IndexWorkStage::Publication)?;
322 let source_root = self
323 .project_root_identity()?
324 .ok_or(DbError::ProjectRootIdentityMissing)?;
325 let source_project_instance_id = self
326 .project_instance_id()?
327 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
328 let source_database =
329 self.database_path
330 .as_deref()
331 .ok_or(DbError::WorktreeHydrationInvalid {
332 reason: "hydration source is not a file-backed database",
333 })?;
334 if !source_database.exists() {
335 return Err(DbError::WorktreeHydrationInvalid {
336 reason: "hydration source database is missing",
337 });
338 }
339
340 let target_root_identity = canonical_target_root(target_root)?;
341 if target_root_identity == source_root {
342 return Err(DbError::WorktreeHydrationInvalid {
343 reason: "hydration target matches the source project root",
344 });
345 }
346 match crate::project_identity::prove_existing_root_equivalence(
347 target_root_identity.as_path(),
348 source_root.as_path(),
349 ) {
350 Ok(_) => {
351 return Err(DbError::WorktreeHydrationInvalid {
352 reason: "hydration target matches the source project root",
353 });
354 }
355 Err(DbError::ProjectRootMismatch { .. }) => {}
356 Err(error) => return Err(error),
357 }
358 let destination_database =
359 validated_target_database(target_root_identity.as_path(), destination_database)?;
360 if destination_database.exists() {
361 return Err(DbError::WorktreeHydrationDestinationExists {
362 path: destination_database,
363 });
364 }
365 validate_database_location(&destination_database)?;
366 let destination_parent =
367 destination_database
368 .parent()
369 .ok_or(DbError::WorktreeHydrationInvalid {
370 reason: "hydration destination has no target-local parent",
371 })?;
372 let reservation = Builder::new()
373 .prefix(HYDRATION_CANDIDATE_PREFIX)
374 .suffix(".sqlite")
375 .tempfile_in(destination_parent)
376 .map_err(|source| DbError::WorktreeHydrationIo {
377 path: destination_parent.to_path_buf(),
378 source,
379 })?;
380 let candidate_path = reservation.into_temp_path();
381
382 let mut capture = Connection::open(&candidate_path)?;
383 copy_online_backup(&self.connection, &mut capture, control)?;
384 drop(capture);
385
386 let copied = AtlasStore::open_for_project(&candidate_path, source_root.as_path())?;
387 let snapshot = copied.export_derived_graph_snapshot_from_stable_copy()?;
388 drop(copied);
389 control.check(IndexWorkStage::Publication)?;
390
391 let transition = AtlasStore::transition_project_root(
392 &candidate_path,
393 target_root_identity.as_path(),
394 ProjectRootTransition::Detach,
395 )?;
396 let mut target =
397 AtlasStore::open_for_project(&candidate_path, target_root_identity.as_path())?;
398 clear_nontransferable_state(
399 &target,
400 source_project_instance_id,
401 snapshot.metadata().source_generation,
402 )?;
403 let baseline = target.import_worktree_hydration_snapshot(&snapshot)?;
404 if baseline.previous_generation != IndexGeneration::ZERO {
405 return Err(DbError::WorktreeHydrationInvalid {
406 reason: "hydration baseline did not start from generation zero",
407 });
408 }
409 drop(target);
410 verify_project_database(&candidate_path, target_root_identity.as_path())?;
411
412 Ok(WorktreeHydrationCandidate {
413 path: Some(candidate_path),
414 destination_database,
415 target_root: target_root_identity,
416 source_project_instance_id,
417 target_project_instance_id: transition.project_instance_id,
418 baseline_generation: baseline.published_generation,
419 source_state_verified: false,
420 })
421 }
422}
423
424fn copy_online_backup(
426 source: &Connection,
427 destination: &mut Connection,
428 control: &IndexWorkControl,
429) -> DbResult<()> {
430 let backup = Backup::new(source, destination)?;
431 let mut busy_attempts = 0usize;
432 loop {
433 control.check(IndexWorkStage::Publication)?;
434 match backup.step(HYDRATION_BACKUP_PAGES_PER_STEP)? {
435 StepResult::Done => return Ok(()),
436 StepResult::More => busy_attempts = 0,
437 StepResult::Busy | StepResult::Locked => {
438 busy_attempts = busy_attempts.saturating_add(1);
439 if busy_attempts > HYDRATION_BACKUP_BUSY_ATTEMPTS {
440 return Err(DbError::WorktreeHydrationBackupBusy {
441 attempts: busy_attempts,
442 });
443 }
444 thread::sleep(HYDRATION_BACKUP_BUSY_PAUSE);
445 }
446 _ => {
447 return Err(DbError::WorktreeHydrationInvalid {
448 reason: "SQLite returned an unsupported online-backup state",
449 });
450 }
451 }
452 }
453}
454
455fn canonical_target_root(target_root: &Path) -> DbResult<CanonicalProjectRoot> {
457 if !target_root.is_absolute() {
458 return Err(DbError::WorktreeHydrationInvalid {
459 reason: "hydration target root is not absolute",
460 });
461 }
462 CanonicalProjectRoot::from_path(target_root).map_err(DbError::from)
463}
464
465fn validated_target_database(target_root: &Path, destination_database: &Path) -> DbResult<PathBuf> {
467 if !destination_database.is_absolute() {
468 return Err(DbError::WorktreeHydrationInvalid {
469 reason: "hydration destination database is not absolute",
470 });
471 }
472 let parent = destination_database
473 .parent()
474 .ok_or(DbError::WorktreeHydrationInvalid {
475 reason: "hydration destination database has no parent",
476 })?;
477 let parent = fs::canonicalize(parent).map_err(|source| DbError::WorktreeHydrationIo {
478 path: parent.to_path_buf(),
479 source,
480 })?;
481 let target_identity = CanonicalProjectRoot::from_path(target_root)?;
482 let parent_identity = CanonicalProjectRoot::from_path(&parent)?;
483 if parent_identity == target_identity
484 || !parent_identity
485 .as_path()
486 .starts_with(target_identity.as_path())
487 {
488 return Err(DbError::WorktreeHydrationInvalid {
489 reason: "hydration destination is not inside a target-local subdirectory",
490 });
491 }
492 let file_name = destination_database
493 .file_name()
494 .ok_or(DbError::WorktreeHydrationInvalid {
495 reason: "hydration destination database has no file name",
496 })?;
497 Ok(parent.join(file_name))
498}
499
500fn clear_nontransferable_state(
502 target: &AtlasStore,
503 source_project: ProjectInstanceId,
504 source_generation: IndexGeneration,
505) -> DbResult<()> {
506 let prepared_at = SystemTime::now()
507 .duration_since(UNIX_EPOCH)
508 .map_err(|_source| DbError::WorktreeHydrationInvalid {
509 reason: "system clock precedes the Unix epoch",
510 })?
511 .as_secs();
512 target.with_validated_write(|connection| {
513 connection.execute_batch(
514 "DELETE FROM usage_instance_worktree_origins;
515 DELETE FROM worktree_usage_aggregates;
516 DELETE FROM worktree_registrations;
517 DELETE FROM usage_aggregate_revisions;
518 DELETE FROM health_resolutions;",
519 )?;
520 crate::telemetry::reset_usage_storage_for_hydration(connection)?;
521 set_metadata(
522 connection,
523 HYDRATION_SOURCE_PROJECT_KEY,
524 &source_project.to_string(),
525 )?;
526 set_metadata(
527 connection,
528 HYDRATION_SOURCE_GENERATION_KEY,
529 &source_generation.get().to_string(),
530 )?;
531 set_metadata(
532 connection,
533 HYDRATION_PREPARED_AT_KEY,
534 &prepared_at.to_string(),
535 )?;
536 Ok(())
537 })
538}
539
540fn remove_candidate_sidecars(path: &Path) -> DbResult<()> {
542 for sidecar in [
543 sqlite_sidecar_path(path, "-wal"),
544 sqlite_sidecar_path(path, "-shm"),
545 sqlite_sidecar_path(path, "-journal"),
546 ] {
547 match fs::remove_file(&sidecar) {
548 Ok(()) => {}
549 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
550 Err(source) => {
551 return Err(DbError::WorktreeHydrationIo {
552 path: sidecar,
553 source,
554 });
555 }
556 }
557 }
558 Ok(())
559}
560
561fn remove_candidate_sidecars_best_effort(path: &Path) {
563 for sidecar in [
564 sqlite_sidecar_path(path, "-wal"),
565 sqlite_sidecar_path(path, "-shm"),
566 sqlite_sidecar_path(path, "-journal"),
567 ] {
568 let _ignored = fs::remove_file(sidecar);
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575 use crate::{WorktreeAlias, WorktreeRegistrationState};
576 use projectatlas_core::IndexCancellation;
577 use rusqlite::params;
578 use std::error::Error;
579 use std::io;
580
581 fn require(condition: bool, message: &'static str) -> Result<(), Box<dyn Error>> {
583 if condition {
584 Ok(())
585 } else {
586 Err(io::Error::other(message).into())
587 }
588 }
589
590 #[cfg(windows)]
591 fn hydration_sidecar_snapshot(database: &Path) -> [Option<Vec<u8>>; 3] {
592 ["-wal", "-shm", "-journal"]
593 .map(|suffix| fs::read(sqlite_sidecar_path(database, suffix)).ok())
594 }
595
596 #[cfg(windows)]
597 fn hydration_directory_inventory(root: &Path) -> Result<Vec<String>, Box<dyn Error>> {
598 let mut inventory = fs::read_dir(root)?
599 .map(|entry| Ok(entry?.file_name().to_string_lossy().into_owned()))
600 .collect::<Result<Vec<_>, io::Error>>()?;
601 inventory.sort();
602 Ok(inventory)
603 }
604
605 #[cfg(windows)]
606 fn hydration_state_snapshot(
607 store: &AtlasStore,
608 ) -> Result<
609 (
610 Option<ProjectInstanceId>,
611 Option<CanonicalProjectRoot>,
612 Option<String>,
613 (Option<String>, Option<String>, i64, i64, i64, i64, i64),
614 Option<crate::IndexPublication>,
615 ),
616 Box<dyn Error>,
617 > {
618 let authored_and_private_state = store.connection.query_row(
619 "SELECT
620 (SELECT purpose FROM purposes JOIN nodes ON nodes.id = purposes.node_id
621 WHERE nodes.path = '.'),
622 (SELECT summary FROM summaries JOIN nodes ON nodes.id = summaries.node_id
623 WHERE nodes.path = '.'),
624 (SELECT COUNT(*) FROM nodes),
625 (SELECT COUNT(*) FROM purposes),
626 (SELECT COUNT(*) FROM summaries),
627 (SELECT COUNT(*) FROM health_resolutions),
628 (SELECT COUNT(*) FROM usage_global_aggregates)",
629 [],
630 |row| {
631 Ok((
632 row.get::<_, Option<String>>(0)?,
633 row.get::<_, Option<String>>(1)?,
634 row.get::<_, i64>(2)?,
635 row.get::<_, i64>(3)?,
636 row.get::<_, i64>(4)?,
637 row.get::<_, i64>(5)?,
638 row.get::<_, i64>(6)?,
639 ))
640 },
641 )?;
642 Ok((
643 store.project_instance_id()?,
644 store.project_root_identity()?,
645 store.project_root()?,
646 authored_and_private_state,
647 store.index_publication()?,
648 ))
649 }
650
651 fn seed_source(store: &mut AtlasStore, target_root: &Path) -> Result<(), Box<dyn Error>> {
653 let project = store
654 .project_instance_id()?
655 .ok_or_else(|| io::Error::other("source project identity missing"))?;
656 store.connection.execute_batch(
657 "INSERT INTO nodes(path, kind) VALUES('.', 'folder');
658 INSERT INTO purposes(node_id, purpose, source, status, updated_by)
659 SELECT id, 'Own the repository.', 'agent', 'approved', 'agent'
660 FROM nodes WHERE path = '.';
661 INSERT INTO summaries(node_id, summary_level, subject, summary)
662 SELECT id, 'node', '', 'Repository summary.' FROM nodes WHERE path = '.';
663 INSERT INTO health_resolutions(
664 finding_id, category, path, rationale, resolved_by
665 ) VALUES('hydration-health', 'test', '.', 'source-only state', 'agent');
666 INSERT INTO usage_bucket_dimensions(
667 token_savings_bucket, provider, model, tokenizer_backend,
668 accuracy, baseline_kind, confidence, accounting_layer, estimate_method,
669 denominator_kind, dedupe_scope, overflow
670 ) VALUES(
671 'navigation_avoidance', 'heuristic', 'unknown', 'chars_div_4',
672 'heuristic_estimate', 'selected_candidates', 'inferred',
673 'modeled_avoidance', 'heuristic_chars_or_bytes_div_ceil_4',
674 'selected_candidates', 'session', 0
675 );",
676 )?;
677 let dimension_id = store.connection.last_insert_rowid();
678 store.connection.execute(
679 "INSERT INTO usage_global_aggregates(
680 project_instance_id, dimension_id, calls, estimated_without,
681 estimated_with, modeled_without, modeled_with,
682 deduped_modeled_without, deduped_modeled_with
683 ) VALUES(?1, ?2, 1, 100, 10, 100, 10, 100, 10)",
684 params![project.as_bytes().as_slice(), dimension_id],
685 )?;
686 store.connection.execute(
687 "INSERT INTO usage_aggregate_revisions(project_instance_id, revision)
688 VALUES(?1, 1)",
689 [project.as_bytes().as_slice()],
690 )?;
691 let alias = WorktreeAlias::parse("seeded")?;
692 let source_root = store
693 .project_root_identity()?
694 .ok_or_else(|| io::Error::other("source root identity missing"))?
695 .as_path()
696 .to_path_buf();
697 store.register_worktree(
698 &alias,
699 &source_root.join(".git"),
700 &source_root.join(".git/worktrees/seeded"),
701 &"22".repeat(32),
702 target_root,
703 None,
704 1,
705 )?;
706 let mut publication = store.begin_index_publication("hydration-test")?;
707 publication.replace_repository_graph(project, &[], &[], &[], &[])?;
708 publication.complete()?;
709 Ok(())
710 }
711
712 #[test]
714 fn hydration_rebinds_reconciles_and_activates_without_private_state_or_clobber()
715 -> Result<(), Box<dyn Error>> {
716 let fixture = tempfile::tempdir()?;
717 let source_root = fixture.path().join("source");
718 let target_root = fixture.path().join("target");
719 let source_dir = source_root.join(".projectatlas");
720 let target_dir = target_root.join(".projectatlas");
721 fs::create_dir_all(&source_dir)?;
722 fs::create_dir_all(&target_dir)?;
723 let source_database = source_dir.join("projectatlas.db");
724 let destination_database = target_dir.join("projectatlas.db");
725 let mut source = AtlasStore::open_for_project(&source_database, &source_root)?;
726 seed_source(&mut source, &target_root)?;
727 let source_identity = source
728 .project_instance_id()?
729 .ok_or_else(|| io::Error::other("source identity missing after seed"))?;
730 let control = IndexWorkControl::new(IndexCancellation::new(), None);
731
732 let unreconciled =
733 source.prepare_worktree_hydration(&target_root, &destination_database, &control)?;
734 let unreconciled_path = unreconciled.path()?.to_path_buf();
735 let error = match unreconciled.activate(&control) {
736 Ok(_activation) => {
737 return Err(io::Error::other("unreconciled candidate activated").into());
738 }
739 Err(error) => error,
740 };
741 require(
742 matches!(error, DbError::WorktreeHydrationNotReconciled { .. }),
743 "unreconciled activation returned the wrong typed failure",
744 )?;
745 require(
746 !unreconciled_path.exists() && !destination_database.exists(),
747 "unreconciled activation left a candidate or destination",
748 )?;
749
750 let verified_root = fixture.path().join("verified-target");
751 let verified_dir = verified_root.join(".projectatlas");
752 fs::create_dir_all(&verified_dir)?;
753 let verified_database = verified_dir.join("projectatlas.db");
754 let mut verified =
755 source.prepare_worktree_hydration(&verified_root, &verified_database, &control)?;
756 let verified_baseline = verified.baseline_generation();
757 verified.accept_verified_source_state(&control)?;
758 let verified_activation = verified.activate(&control)?;
759 require(
760 verified_activation.reconciled_generation == verified_baseline
761 && verified_database.exists(),
762 "exact no-delta source verification did not activate the copied baseline",
763 )?;
764
765 let candidate =
766 source.prepare_worktree_hydration(&target_root, &destination_database, &control)?;
767 let candidate_path = candidate.path()?.to_path_buf();
768 let baseline_generation = candidate.baseline_generation();
769 let target_identity = candidate.target_project_instance_id();
770 require(
771 target_identity != source_identity,
772 "hydration did not rotate the target identity",
773 )?;
774 {
775 let mut target = AtlasStore::open_for_project(&candidate_path, &target_root)?;
776 let copied = target.connection.query_row(
777 "SELECT
778 (SELECT purpose FROM purposes JOIN nodes ON nodes.id = purposes.node_id
779 WHERE nodes.path = '.'),
780 (SELECT summary FROM summaries JOIN nodes ON nodes.id = summaries.node_id
781 WHERE nodes.path = '.'),
782 (SELECT COUNT(*) FROM usage_global_aggregates),
783 (SELECT COUNT(*) FROM worktree_registrations),
784 (SELECT COUNT(*) FROM health_resolutions)",
785 [],
786 |row| {
787 Ok((
788 row.get::<_, String>(0)?,
789 row.get::<_, String>(1)?,
790 row.get::<_, i64>(2)?,
791 row.get::<_, i64>(3)?,
792 row.get::<_, i64>(4)?,
793 ))
794 },
795 )?;
796 require(
797 copied
798 == (
799 "Own the repository.".to_string(),
800 "Repository summary.".to_string(),
801 0,
802 0,
803 0,
804 ),
805 "hydration did not preserve authored state or clear private state",
806 )?;
807 let provenance = target.connection.query_row(
808 "SELECT source.value, generation.value
809 FROM metadata AS source
810 JOIN metadata AS generation
811 ON generation.key = ?2
812 WHERE source.key = ?1",
813 params![
814 HYDRATION_SOURCE_PROJECT_KEY,
815 HYDRATION_SOURCE_GENERATION_KEY
816 ],
817 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
818 )?;
819 require(
820 provenance.0 == source_identity.to_string() && provenance.1 == "1",
821 "hydration provenance does not identify the source baseline",
822 )?;
823 let mut publication = target.begin_index_projection_refresh("hydration-test")?;
824 publication.replace_repository_graph(target_identity, &[], &[], &[], &[])?;
825 publication.complete()?;
826 }
827 let prepared = candidate.prepare_activation(&control)?;
828 require(
829 candidate_path.exists() && !destination_database.exists(),
830 "activation preparation published the candidate early",
831 )?;
832 let activation = prepared.activate(&control)?;
833 require(
834 normalize_native_path_display(&activation.database)
835 == normalize_native_path_display(&destination_database)
836 && activation.baseline_generation == baseline_generation
837 && activation.reconciled_generation > baseline_generation
838 && activation.target_project_instance_id == target_identity,
839 "activation report lost exact hydration identities",
840 )?;
841 require(
842 destination_database.exists() && !candidate_path.exists(),
843 "activation did not publish exactly one database path",
844 )?;
845
846 let raced_root = fixture.path().join("raced-target");
847 let raced_dir = raced_root.join(".projectatlas");
848 let raced_database = raced_dir.join("projectatlas.db");
849 fs::create_dir_all(&raced_dir)?;
850 let mut raced =
851 source.prepare_worktree_hydration(&raced_root, &raced_database, &control)?;
852 let raced_candidate = raced.path()?.to_path_buf();
853 raced.accept_verified_source_state(&control)?;
854 fs::write(&raced_database, b"competing initializer")?;
855 let raced = raced.prepare_activation(&control)?;
856 require(
857 matches!(
858 raced.activate(&control),
859 Err(DbError::WorktreeHydrationDestinationExists { .. })
860 ),
861 "activation collision did not retain the destination-exists fallback",
862 )?;
863 require(
864 fs::read(&raced_database)? == b"competing initializer" && !raced_candidate.exists(),
865 "activation collision changed the winning destination or retained its candidate",
866 )?;
867
868 let activated = AtlasStore::open_for_project(&destination_database, &target_root)?;
869 require(
870 activated.project_instance_id()? == Some(target_identity),
871 "activated database identity changed",
872 )?;
873 let source_private = source.connection.query_row(
874 "SELECT
875 (SELECT COUNT(*) FROM usage_global_aggregates),
876 (SELECT COUNT(*) FROM worktree_registrations WHERE state = ?1)",
877 [WorktreeRegistrationState::Active.as_str()],
878 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
879 )?;
880 require(
881 source_private == (1, 1),
882 "hydration mutated source telemetry or registration authority",
883 )?;
884
885 let occupied =
886 source.prepare_worktree_hydration(&target_root, &destination_database, &control);
887 require(
888 matches!(
889 occupied,
890 Err(DbError::WorktreeHydrationDestinationExists { .. })
891 ),
892 "existing destination was not preserved through a typed no-clobber failure",
893 )?;
894
895 let cancelled = IndexWorkControl::new(IndexCancellation::new(), None);
896 cancelled.cancel();
897 let canceled_result = source.prepare_worktree_hydration(
898 &target_root,
899 &target_dir.join("cancelled.db"),
900 &cancelled,
901 );
902 require(
903 matches!(canceled_result, Err(DbError::IndexWork(_))),
904 "canceled hydration did not return the shared typed work failure",
905 )?;
906 Ok(())
907 }
908
909 #[cfg(windows)]
910 #[test]
911 fn hydration_rejects_case_only_renamed_source_target_before_reservation()
912 -> Result<(), Box<dyn Error>> {
913 let fixture = tempfile::tempdir()?;
914 let original_root = fixture.path().join("HydrationCaseOnly");
915 let staging_root = fixture.path().join("HydrationCaseOnlyStaging");
916 let renamed_root = fixture.path().join("hydrationcaseonly");
917 let source_dir = original_root.join(".projectatlas");
918 fs::create_dir_all(&source_dir)?;
919 let source_database = source_dir.join("projectatlas.db");
920 let mut seeded = AtlasStore::open_for_project(&source_database, &original_root)?;
921 seed_source(&mut seeded, &original_root)?;
922 let expected_state = hydration_state_snapshot(&seeded)?;
923 drop(seeded);
924
925 fs::rename(&original_root, &staging_root)?;
926 fs::rename(&staging_root, &renamed_root)?;
927 let Ok(recanonicalized_original) = CanonicalProjectRoot::from_path(&original_root) else {
928 return Ok(());
929 };
930 let renamed_identity = CanonicalProjectRoot::from_path(&renamed_root)?;
931 require(
932 recanonicalized_original == renamed_identity,
933 "case-only root rename did not preserve a re-canonicalizable directory",
934 )?;
935
936 let source = AtlasStore::open_read_only_for_project(&source_database, &original_root)?;
937 let persisted_identity = source
938 .project_root_identity()?
939 .ok_or_else(|| io::Error::other("hydration source identity is missing"))?;
940 require(
941 persisted_identity != renamed_identity,
942 "case-only fixture lost its stale persisted spelling",
943 )?;
944 let source_state = hydration_state_snapshot(&source)?;
945 let database_before = fs::read(&source_database)?;
946 let sidecars_before = hydration_sidecar_snapshot(&source_database);
947 let inventory_before = hydration_directory_inventory(&source_dir)?;
948 let destination_database = renamed_root.join(".projectatlas").join("alternate.db");
949 require(
950 !destination_database.exists(),
951 "case-only hydration destination unexpectedly exists",
952 )?;
953 let control = IndexWorkControl::new(IndexCancellation::new(), None);
954 let Err(error) =
955 source.prepare_worktree_hydration(&renamed_root, &destination_database, &control)
956 else {
957 return Err(
958 io::Error::other("equivalent case-only hydration target was admitted").into(),
959 );
960 };
961 require(
962 matches!(
963 error,
964 DbError::WorktreeHydrationInvalid {
965 reason: "hydration target matches the source project root"
966 }
967 ),
968 "equivalent case-only hydration target returned the wrong error",
969 )?;
970 require(
971 fs::read(&source_database)? == database_before,
972 "case-only self-target refusal changed the source database",
973 )?;
974 require(
975 hydration_sidecar_snapshot(&source_database) == sidecars_before,
976 "case-only self-target refusal changed SQLite sidecars",
977 )?;
978 require(
979 hydration_directory_inventory(&source_dir)? == inventory_before,
980 "case-only self-target refusal reserved or removed a candidate",
981 )?;
982 let state_after = hydration_state_snapshot(&source)?;
983 require(
984 state_after == source_state && state_after == expected_state,
985 "case-only self-target refusal changed source identity or authored state",
986 )?;
987 require(
988 !destination_database.exists(),
989 "case-only self-target refusal created the destination database",
990 )?;
991 Ok(())
992 }
993
994 #[cfg(windows)]
995 #[test]
996 fn hydration_allows_distinct_case_sensitive_roots_without_source_mutation()
997 -> Result<(), Box<dyn Error>> {
998 use std::process::Command;
999
1000 let fixture = tempfile::tempdir()?;
1001 let case_sensitive_parent = fixture.path().join("hydration-case-sensitive-parent");
1002 fs::create_dir(&case_sensitive_parent)?;
1003 let enabled = Command::new("fsutil")
1004 .args(["file", "SetCaseSensitiveInfo"])
1005 .arg(&case_sensitive_parent)
1006 .arg("enable")
1007 .status()
1008 .is_ok_and(|status| status.success());
1009 if !enabled {
1010 return Ok(());
1011 }
1012 let source_root = case_sensitive_parent.join("Repo");
1013 let target_root = case_sensitive_parent.join("repo");
1014 if fs::create_dir(&source_root).is_err() || fs::create_dir(&target_root).is_err() {
1015 return Ok(());
1016 }
1017 let source_identity = CanonicalProjectRoot::from_path(&source_root)?;
1018 let target_identity = CanonicalProjectRoot::from_path(&target_root)?;
1019 if source_identity == target_identity {
1020 return Ok(());
1021 }
1022 let source_dir = source_root.join(".projectatlas");
1023 let target_dir = target_root.join(".projectatlas");
1024 fs::create_dir_all(&source_dir)?;
1025 fs::create_dir_all(&target_dir)?;
1026 let source_database = source_dir.join("projectatlas.db");
1027 let destination_database = target_dir.join("alternate.db");
1028 let mut source = AtlasStore::open_for_project(&source_database, &source_root)?;
1029 seed_source(&mut source, &target_root)?;
1030 let source_state = hydration_state_snapshot(&source)?;
1031 let source_database_before = fs::read(&source_database)?;
1032 let source_identity_before = source.project_root_identity()?;
1033 let source_instance_before = source.project_instance_id()?;
1034 let control = IndexWorkControl::new(IndexCancellation::new(), None);
1035 let candidate =
1036 source.prepare_worktree_hydration(&target_root, &destination_database, &control)?;
1037 require(
1038 candidate.target_root == target_identity,
1039 "case-sensitive distinct hydration selected the wrong target root",
1040 )?;
1041 require(
1042 candidate.target_project_instance_id
1043 != source_instance_before
1044 .ok_or_else(|| io::Error::other("source project identity is missing"))?,
1045 "case-sensitive distinct hydration did not prepare a new target identity",
1046 )?;
1047 require(
1048 !destination_database.exists(),
1049 "case-sensitive distinct hydration published before activation",
1050 )?;
1051 drop(candidate);
1052 require(
1053 fs::read(&source_database)? == source_database_before
1054 && source.project_root_identity()? == source_identity_before
1055 && source.project_instance_id()? == source_instance_before,
1056 "case-sensitive distinct hydration changed the source binding",
1057 )?;
1058 require(
1059 hydration_state_snapshot(&source)? == source_state,
1060 "case-sensitive distinct hydration changed source authored state",
1061 )?;
1062 Ok(())
1063 }
1064
1065 #[cfg(unix)]
1066 #[test]
1067 fn hydration_preserves_non_utf8_target_identity_and_display_collisions()
1068 -> Result<(), Box<dyn Error>> {
1069 use std::os::unix::ffi::OsStringExt;
1070
1071 let fixture = tempfile::tempdir()?;
1072 let source_root = fixture.path().join("src");
1073 let target_root = fixture
1074 .path()
1075 .join(std::ffi::OsString::from_vec(vec![b't', b'g', b't', 0x81]));
1076 let collision_root = fixture.path().join("tgt-�");
1077 let source_dir = source_root.join(".projectatlas");
1078 let target_dir = target_root.join(".projectatlas");
1079 let collision_dir = collision_root.join(".projectatlas");
1080 fs::create_dir_all(&source_dir)?;
1081 fs::create_dir_all(&target_dir)?;
1082 fs::create_dir_all(&collision_dir)?;
1083 let source_database = source_dir.join("projectatlas.db");
1084 let target_database = target_dir.join("projectatlas.db");
1085 let collision_database = collision_dir.join("projectatlas.db");
1086 let mut source = AtlasStore::open_for_project(&source_database, &source_root)?;
1087 seed_source(&mut source, &collision_root)?;
1088 let control = IndexWorkControl::new(IndexCancellation::new(), None);
1089
1090 let mut candidate =
1091 source.prepare_worktree_hydration(&target_root, &target_database, &control)?;
1092 let target_identity = candidate.target_project_instance_id();
1093 candidate.accept_verified_source_state(&control)?;
1094 let prepared = candidate.prepare_activation(&control)?;
1095 let activation = prepared.activate(&control)?;
1096 require(
1097 activation.target_project_instance_id == target_identity,
1098 "non-UTF-8 hydration changed target identity",
1099 )?;
1100 verify_project_database(&target_database, &target_root)?;
1101 let target = AtlasStore::open_read_only_for_project(&target_database, &target_root)?;
1102 require(
1103 target.project_root_identity()? == Some(CanonicalProjectRoot::from_path(&target_root)?),
1104 "hydrated non-UTF-8 target identity was not persisted",
1105 )?;
1106 drop(target);
1107
1108 let mut collision =
1109 source.prepare_worktree_hydration(&collision_root, &collision_database, &control)?;
1110 let collision_identity = collision.target_project_instance_id();
1111 require(
1112 collision_identity != target_identity,
1113 "hydration collapsed replacement-character target identity",
1114 )?;
1115 collision.accept_verified_source_state(&control)?;
1116 let collision = collision.prepare_activation(&control)?;
1117 collision.activate(&control)?;
1118 verify_project_database(&collision_database, &collision_root)?;
1119 Ok(())
1120 }
1121}