diff --git a/atomic-cli/src/commands/agent/hooks.rs b/atomic-cli/src/commands/agent/hooks.rs index 1089c19c..eddac34c 100644 --- a/atomic-cli/src/commands/agent/hooks.rs +++ b/atomic-cli/src/commands/agent/hooks.rs @@ -239,11 +239,30 @@ impl Command for Hooks { } } } - None => find_repository_root().map_err(|e| { - // Log to stderr — the agent should continue even if Atomic can't find a repo - eprintln!("[atomic] Warning: {}", e); - e - })?, + None => match find_repository_root() { + Ok(root) => root, + Err(error @ CliError::RepositoryNotFound { .. }) => { + // Same state as the hints path: the hook ran outside an + // Atomic repository (or the repository disappeared since + // the caller's probe). Nothing to record — exit quietly + // instead of failing the hook (and, through the agent + // bridge plugins' strict tool propagation, the tool call). + log::warn!( + "hooks: no Atomic repository for {} {}: {}", + self.agent_name, + self.verb, + error + ); + if let Some(response) = agent.stdout_response(hook_type) { + println!("{}", response); + } + return Ok(()); + } + // Only the genuine not-an-Atomic-repository state is quiet. + // Any other discovery failure (I/O and friends) stays fatal: + // swallowing it would hide real breakage behind exit 0. + Err(error) => return Err(error), + }, }; // Hooks are never suppressed under a managed run: sessions adopt its diff --git a/atomic-cli/src/commands/diff/helpers.rs b/atomic-cli/src/commands/diff/helpers.rs index f84325ec..fa9d08f8 100644 --- a/atomic-cli/src/commands/diff/helpers.rs +++ b/atomic-cli/src/commands/diff/helpers.rs @@ -1070,6 +1070,31 @@ where _ => FileDiff::modified(file_path), }; + // A whole-file rewrite's tombstones carry identities, not old text. + // Render its textual delta using recorded before-content, even when + // context is disabled or only statistics were requested. + if let Some((before_content, after_content)) = + atomic_repository::diff_engine::whole_file_replacement_text( + ops, + &change.contents, + || before(file_path), + ) + { + let result = diff_text(&before_content, &after_content, Algorithm::Myers); + let old_lines: Vec<_> = before_content.split(|&b| b == b'\n').collect(); + let new_lines: Vec<_> = after_content.split(|&b| b == b'\n').collect(); + for mut hunk in + build_hunks_from_diff(&result, &old_lines, &new_lines, config.context_lines) + { + hunk.lines = Diff::repair_diff_lines(hunk.lines); + file_diff.add_hunk(hunk); + } + file_diff.compute_stats(); + stats.add_file(file_diff.stats.clone()); + file_diffs.push(file_diff); + continue; + } + // Pass 1: flatten line operations into numbered changed lines. let mut insertions = 0usize; let mut deletions = 0usize; diff --git a/atomic-cli/src/commands/git/import.rs b/atomic-cli/src/commands/git/import.rs index 380d99e3..6d8da918 100644 --- a/atomic-cli/src/commands/git/import.rs +++ b/atomic-cli/src/commands/git/import.rs @@ -873,6 +873,8 @@ impl Command for Import { let branches = self.get_all_branches(&git_repo)?; let mut total_imported = 0; + let checked_out_branch = current_git_branch(&git_repo); + let mut checked_out_verification = None; for branch_name in branches { let preserve_branch_working_copy = preserve_current_view && original_view != branch_name; @@ -882,6 +884,13 @@ impl Command for Import { "missing prevalidated import plan for '{branch_name}'" )) })?; + if checked_out_branch.as_deref() == Some(branch_name.as_str()) { + if let (Some(expected), Some(raw)) = + (plan.expected_git_tree(), plan.raw_git_tree()) + { + checked_out_verification = Some((branch_name.clone(), expected, raw)); + } + } // Ensure the view exists if !repo @@ -948,8 +957,17 @@ impl Command for Import { print_info( "Working copy materialization deferred to the explicit command boundary (metadata-only reactive budget).", ); + } else if let Some(checked_out) = current_git_branch(&git_repo) { + // Importing every branch must finish on the checked-out branch, + // not whichever ref happened to sort last. Its complete target + // tree has passed the same preflight and publication checks as + // single-branch import, so adopt Git's existing materialization. + repo.align_to_view(working_copy, &checked_out) + .map_err(|e| CliError::Internal(e.into()))?; + print_info("Using Git working copy as imported materialization."); + reindex_working_copy(&repo, working_copy); } else { - // Materialize the working copy from the graph + // Detached imports have no checked-out branch to adopt. print_info("Materializing working copy..."); match repo.materialize(working_copy) { Ok(result) => { @@ -986,6 +1004,22 @@ impl Command for Import { "Imported {} total changes across all branches", total_imported )); + if !self.skip_checkpoint_refresh { + if let Some((view, expected, raw)) = checked_out_verification { + // Use the same canonical tree proof as single-branch import. + // The generic native-status fallback interprets escaped Git + // names as disk names and is not an import equivalence proof. + super::bridge::refresh_checkpoint_after_verified_import( + &repo, + working_copy, + &git_repo, + &view, + &expected, + &raw, + )?; + checkpoint_refreshed = true; + } + } } else { // Import single branch let branch_name = self.branch.clone().unwrap_or(default_branch); @@ -1250,7 +1284,8 @@ fn reindex_working_copy(repo: &Repository, working_copy: WorkingCopyId) { let mut entries: Vec<(String, i64, u32, u64, Hash)> = Vec::new(); for file in &tracked { - let abs = repo_root.join(&file.path); + let path = file.path.to_string_lossy(); + let abs = repo_root.join(super::parallel::canonical_tree_path(&path)); if let Ok(metadata) = std::fs::metadata(&abs) { let mtime = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH); let duration = mtime @@ -1258,7 +1293,7 @@ fn reindex_working_copy(repo: &Repository, working_copy: WorkingCopyId) { .unwrap_or_default(); if let Ok(bytes) = std::fs::read(&abs) { entries.push(( - file.path.to_string_lossy().replace('\\', "/"), + path.into_owned(), duration.as_secs() as i64, duration.subsec_nanos(), metadata.len(), diff --git a/atomic-cli/src/commands/git/parallel.rs b/atomic-cli/src/commands/git/parallel.rs index 6042224f..05daceaa 100644 --- a/atomic-cli/src/commands/git/parallel.rs +++ b/atomic-cli/src/commands/git/parallel.rs @@ -749,6 +749,7 @@ impl Default for ClosureLedger { ancestry: HashMap::new(), closures: HashMap::new(), member_origins: None, + target_closure: None, } } } @@ -767,6 +768,12 @@ pub(crate) struct ClosureLedger { closures: HashMap>>, /// Full-chain view membership with Git origins, loaded once per run. member_origins: Option)>>, + /// The complete Git ancestry of this import's target tip (the branch + /// closure the walk accumulates). Members whose Git origin is inside + /// this set are sibling legs of the same import target — never + /// superseded from the view — while origins outside it are genuinely + /// replaced history. `None` only before the walk sets it. + target_closure: Option>>, } impl ClosureLedger { @@ -882,6 +889,10 @@ impl ClosureLedger { /// persisted/reconstructed ancestor closure. Non-Git members (native /// Atomic work) cannot be attributed and stay visible: their content is /// genuinely part of the view state Git commits build on. + /// + /// These exclusions are ASSEMBLY visibility only. Which of them are + /// genuinely replaced history — and may leave the view — is decided by + /// [`ClosureLedger::supersede_exclusions_for`]. fn exclusions_for( &mut self, repo: &Repository, @@ -919,6 +930,58 @@ impl ClosureLedger { Ok(exclusions) } + /// The genuinely replaced history among `sha`'s assembly exclusions: + /// excluded view members whose Git origin is ALSO outside this import's + /// full target closure. These — and only these — may leave the target + /// view when `sha` lands. + /// + /// An assembly exclusion whose origin IS inside the target closure is a + /// sibling leg of the same import target: the walk reaches it (or the + /// merge that reunifies the legs) later, the merge resolution + /// re-references it through `ensure_union_state`, and evicting it + /// mid-walk would leave descendant commits assembling against a missing + /// baseline — the sibling-eviction corruption. Origins outside the + /// target closure are history the target replaced (e.g. an incremental + /// import after a force-push rewrite): evicting them keeps the published + /// boundary equal to the commit's interpreted closure. + fn supersede_exclusions_for( + &mut self, + repo: &Repository, + git: &GitRepository, + sha: &str, + target_view: &str, + has_git_parent: bool, + ) -> CliResult> { + let assembly = self.exclusions_for(repo, git, sha, target_view, has_git_parent)?; + if assembly.is_empty() { + return Ok(Vec::new()); + } + // Clone the Arc before the mutable `origins_for` call: no borrow of + // `self` may outlive it. + let target = match self.target_closure.clone() { + Some(target) => target, + // No target closure known: the historical behavior (evict every + // assembly exclusion) stays fail-safe for callers that never + // establish the walk's target. + None => return Ok(assembly), + }; + let mut origins: HashMap> = HashMap::new(); + for (hash, origin_sha) in self.origins_for(repo, target_view)? { + origins.insert(hash, origin_sha); + } + let mut superseded = Vec::with_capacity(assembly.len()); + for hash in assembly { + let replaced = origins + .get(&hash) + .and_then(|origin| origin.as_deref()) + .is_some_and(|origin| !target.contains(origin)); + if replaced { + superseded.push(hash); + } + } + Ok(superseded) + } + /// Record that importing `sha` introduced `hash` into `target_view`. fn record_introduced(&mut self, sha: &str, hash: ContentHash, target_view: &str) { self.introduced @@ -4224,7 +4287,14 @@ impl ParallelImporter { // canonical String identity decodes before filesystem access. let physical = root.join(canonical_tree_path(path)); let Some(entry) = expected.get(repo_path.as_bytes()) else { - if physical.symlink_metadata().is_ok() { + let prefix = format!("{path}/"); + let needs_directory = expected + .keys() + .any(|candidate| candidate.starts_with(prefix.as_bytes())); + if physical + .symlink_metadata() + .is_ok_and(|metadata| !(needs_directory && metadata.file_type().is_dir())) + { return Err(git_error(format!( "deleted Git path '{path}' is still present in the worktree" ))); @@ -4409,8 +4479,21 @@ impl ParallelImporter { let import_start = Instant::now(); let mut line_index = ImportLineIndex::default(); let write_start = Instant::now(); - let (write_stats, all_imported_commits) = - self.phase2_write(repo, &plan.commits, &mut line_index, plan.boundaries())?; + // The import target tip for the supersede gate: the branch this walk + // accumulates into the target view. A resolvable local branch names + // its tip; a detached §7.5 import falls back to the walked commits. + let target_tip = self.open_git_repo().ok().and_then(|git| { + git.find_branch(branch_name, git2::BranchType::Local) + .ok() + .and_then(|branch| branch.get().target()) + }); + let (write_stats, all_imported_commits) = self.phase2_write( + repo, + &plan.commits, + &mut line_index, + plan.boundaries(), + target_tip, + )?; // CB-13C F4: a phase-2 failure is a FAILED partial import — the // landed commits are durable and accounted in the stats; the // caller surfaces the failure and the aggregate still emits. @@ -4647,36 +4730,48 @@ impl ParallelImporter { git_repo: &GitRepository, target_oid: Oid, ) -> CliResult> { - // Incremental single-branch import is the latency-sensitive path. Walk - // the first-parent chain from the tip and stop at the imported frontier - // instead of rev-walking and filtering the repository's entire history. - // CB-9B: every multi-parent merge on the chain also imports the - // complete closure of its other parents — the union state must contain - // every parent's knowledge before the resolution is assembled. - if self.options.incremental && self.options.mainline_only { + // Linearized import — the documented plain-import behavior. Walk the + // first-parent chain from the tip, stopping at the imported frontier + // for incremental runs. Merge commits arrive as ordinary + // first-parent diffs, so each file's graph stays a linear per-line + // chain: no concurrent lineages, no merge diamonds, and every + // staged verification runs against a provable structure. + // + // CB-9B's complete-closure walk (below) remains for `--all` and the + // bridge flows, where the union state must contain every parent's + // knowledge before a resolution is assembled (RFC §5.3). + if self.options.mainline_only { let mut newest_first = Vec::new(); let mut visited: HashSet = HashSet::new(); - let mut queue = std::collections::VecDeque::new(); - queue.push_back(target_oid); - while let Some(oid) = queue.pop_front() { + let mut current = Some(target_oid); + while let Some(oid) = current { if self.options.imported_shas.contains(&oid.to_string()) { - continue; + break; } if !visited.insert(oid.to_string()) { - continue; + break; } newest_first.push(oid); let commit = git_repo.find_commit(oid).map_err(|e| CliError::GitError { message: format!("Failed to load commit {oid}: {e}"), })?; - for parent in commit.parent_ids() { - queue.push_back(parent); - } + current = if commit.parent_count() > 0 { + Some(commit.parent_id(0).map_err(|e| CliError::GitError { + message: format!("Failed to load parent of {oid}: {e}"), + })?) + } else { + None + }; } newest_first.reverse(); return Ok(newest_first); } + // `--all` (mainline_only=false) imports the complete reachable + // closure per branch: deterministically sequenced, with the + // missing-object boundary check and the deepening guard run against + // the full closure before already-indexed commits are dropped in + // validate_branch_prospectively. let mut revwalk = git_repo.revwalk().map_err(|e| CliError::GitError { message: format!("Failed to create revwalk: {}", e), })?; @@ -4787,6 +4882,7 @@ impl ParallelImporter { commits: &[VerifiedImportCommit], line_index: &mut ImportLineIndex, boundaries: &[&'static str], + target_tip: Option, ) -> CliResult<(WriteStats, Vec)> { let working_copy = repo .require_working_copy_id() @@ -4798,6 +4894,29 @@ impl ParallelImporter { let mut batch_start = Instant::now(); let git = self.open_git_repo()?; let mut ledger = ClosureLedger::default(); + // The import target's full Git closure: the ancestry the walk + // accumulates into the target view. Assembly exclusions whose Git + // origin is inside this set are sibling legs of the same target and + // are never superseded from the view (see + // `supersede_exclusions_for`); origins outside it are genuinely + // replaced history. A resolvable branch tip defines the closure + // exactly; otherwise this run's walked commits are the best + // available approximation. + if let Some(tip) = target_tip { + let tip_sha = tip.to_string(); + let closure = ledger + .ancestry_of(&git, &tip_sha) + .map_err(|e| CliError::GitError { + message: format!("cannot read target tip {tip_sha} ancestry: {e}"), + })?; + ledger.target_closure = Some(closure); + } else { + let closure: HashSet = commits + .iter() + .map(|candidate| candidate.parsed.git_sha.clone()) + .collect(); + ledger.target_closure = Some(std::sync::Arc::new(closure)); + } // The last commit this batch writes verifies the whole tree even when // earlier commits verify only the paths they touch. let last_written = commits @@ -4924,7 +5043,7 @@ impl ParallelImporter { if file.operation == FileOperation::Deleted { continue; } - let abs_path = repo_root.join(&file.path); + let abs_path = repo_root.join(canonical_tree_path(&file.path)); if let Ok(metadata) = std::fs::metadata(&abs_path) { use std::time::SystemTime; let mtime = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH); @@ -4937,8 +5056,7 @@ impl ParallelImporter { let content_hash = std::fs::read(&abs_path) .map(|bytes| Hash::of(&bytes)) .unwrap_or(Hash::ZERO); - // Normalize path to forward slashes for TREE compatibility - let normalized = file.path.replace('\\', "/"); + let normalized = file.path.clone(); index_entries.push((normalized, secs, nanos, size, content_hash)); } } @@ -5156,12 +5274,34 @@ impl ParallelImporter { squash_insert: None, }); } + // All-parent traversal can visit another leg between a commit and + // its child. Synthesis aligns the view to that leg, so exclusions + // alone cannot recover the next parent's missing membership. Restore + // its canonical closure by reference before resolving file identities; + // never reinterpret an already stored file as a fresh FileAdd. + if !self.options.mainline_only && parsed.parent_oids.len() == 1 { + let parent_sha = parsed.parent_oids[0].to_string(); + let closure = ledger.interpreted_closure(repo, git, &parent_sha)?; + if closure.is_empty() { + return Err(git_error(format!( + "commit {} parent {} has no interpreted closure", + parsed.git_sha, parent_sha + ))); + } + let mut members: Vec<_> = closure.iter().copied().collect(); + members.sort(); + repo.ensure_union_state(&self.options.target_view, &members) + .map_err(|e| CliError::Internal(e.into()))?; + } + // CB-9A: root and single-parent commits use normal recorded-file // assembly with a hashed Git origin. CB-9B: multi-parent merges // assemble a journaled `GitResolution` from the union state, and // empty commits stay distinct through the synthesized empty-commit - // writer. - let info = if parsed.is_merge { + // writer. In linearized import (`mainline_only`) merges arrive as + // ordinary first-parent diffs and take the normal recorded-file + // path — no union state, no leg closures. + let info = if parsed.is_merge && !self.options.mainline_only { self.write_merge_resolution( repo, git, @@ -5171,8 +5311,8 @@ impl ParallelImporter { prospective, boundaries, )? - } else if !parsed.is_empty { - self.write_commit_synthesized( + } else if parsed.is_empty { + self.write_empty_commit_synthesized( repo, git, ledger, @@ -5183,7 +5323,7 @@ impl ParallelImporter { touched_only, )? } else { - self.write_empty_commit_synthesized( + self.write_commit_synthesized( repo, git, ledger, @@ -5360,8 +5500,7 @@ impl ParallelImporter { ) -> CliResult { use atomic_core::output::memory::Memory; use atomic_core::record::workflow::{ - record_added_file, record_deleted_file, record_modified_file, DetectedFile, - RecordingOptions, + record_added_file, record_modified_file, DetectedFile, RecordingOptions, }; let working_copy = repo @@ -5402,7 +5541,7 @@ impl ParallelImporter { }, ) } else { - if parsed.parent_oids.len() > 1 { + if parsed.parent_oids.len() > 1 && !self.options.mainline_only { return Err(git_error(format!( "commit {} has {} parents; multi-parent commits take the GitResolution \ path, not single-commit synthesis", @@ -5448,6 +5587,18 @@ impl ParallelImporter { &target, !parsed.parent_oids.is_empty(), )?; + // Genuinely replaced history among those exclusions: origins outside + // the import target's full closure. Same-closure sibling legs stay + // view members (the merge resolution re-references them), so a + // mid-walk eviction can never leave a descendant commit assembling + // against a missing baseline. + let superseded = ledger.supersede_exclusions_for( + repo, + git, + &parsed.git_sha, + &target, + !parsed.parent_oids.is_empty(), + )?; let leg_mode = !excluded.is_empty(); enum EffectiveOp { @@ -5878,13 +6029,14 @@ impl ParallelImporter { self.old_bytes_for(repo, file) }; let mut move_rec = repo - .record_foreign_moved_file( + .record_foreign_moved_file_excluding( &target, &file.path, old_path, &old_content, &new_content_owned, false, + &excluded, ) .map_err(|message| { CliError::Internal(anyhow::anyhow!( @@ -5922,10 +6074,13 @@ impl ParallelImporter { // to the actual deleted trunk (review CB-9C // R2) so the semantic lifecycle tombstones // instead of leaving the old trunk Alive. - let deleted_record = - repo.record_foreign_deleted_file(old_path).map_err( - |message| CliError::Internal(anyhow::anyhow!(message)), - )?; + let deleted_record = repo + .record_foreign_deleted_file_on_view_excluding( + old_path, &target, &excluded, + ) + .map_err(|message| { + CliError::Internal(anyhow::anyhow!(message)) + })?; if deleted_record.is_empty() { return Err(CliError::Internal(anyhow::anyhow!( "cannot import ambiguous rename source '{}': canonical FileDel is empty", @@ -6104,7 +6259,9 @@ impl ParallelImporter { &file.path, &old_content, new_content, - conflicted, + // Full-history truncations retain the file's + // identity through a content-only replacement. + conflicted || (!self.options.mainline_only && new_content.is_empty()), &target, &excluded, ) @@ -6179,7 +6336,9 @@ impl ParallelImporter { // placeholder-trunk delete tombstoned nobody and left the // moved-to/created trunk row Alive after graph deletion. let recorded = repo - .record_foreign_deleted_file(&file.path) + .record_foreign_deleted_file_on_view_excluding( + &file.path, &target, &excluded, + ) .map_err(|message| CliError::Internal(anyhow::anyhow!(message)))?; recorded_files.push(recorded); } @@ -6263,14 +6422,16 @@ impl ParallelImporter { &expectation, Some(&parsed.git_sha), &parent_closure, - // CB-9B review B4: a single-parent commit's assembly - // exclusions are Git-origin members outside its interpreted - // closure — history the commit's Git ancestry replaced. They - // leave the target view in the same transaction, so the - // published boundary equals the isolated interpretation the - // staged check verifies, and the import never exits 4 after - // publishing a diverged boundary. - true, + // CB-9B review B4: the genuinely replaced history among this + // commit's assembly exclusions — origins outside the import + // target's full closure — leaves the target view in the same + // transaction, so the published boundary equals the isolated + // interpretation the staged check verifies and the import + // never exits 4 after publishing a diverged boundary. + // Same-closure sibling legs are excluded from assembly only: + // the walk still reaches them, and evicting them mid-walk + // would corrupt later commits' baselines. + &superseded, ) .map_err(|e| CliError::Internal(e.into()))?; let progress_reported = progress.finish(); @@ -6423,6 +6584,16 @@ impl ParallelImporter { &self.options.target_view, !parsed.parent_oids.is_empty(), )?; + // Genuinely replaced history among the exclusions — same gate as + // write_commit_synthesized: sibling legs of the import target stay + // view members. + let superseded = ledger.supersede_exclusions_for( + repo, + git, + &parsed.git_sha, + &self.options.target_view, + !parsed.parent_oids.is_empty(), + )?; let parent_closure: Vec = if parsed.parent_oids.is_empty() { Vec::new() } else { @@ -6451,9 +6622,10 @@ impl ParallelImporter { ), &excluded, // CB-9B review B4: an empty commit still advances the exact - // interpreted closure — superseded exclusions leave the view - // in the same transaction (see write_commit_synthesized). - true, + // interpreted closure — genuinely replaced history leaves the + // view in the same transaction (see write_commit_synthesized); + // same-closure sibling legs stay members. + &superseded, ) .map_err(|e| CliError::Internal(e.into()))?; // Index git SHA → Atomic change in GIT_SHA_INDEX @@ -6508,9 +6680,7 @@ impl ParallelImporter { boundaries: &[&'static str], ) -> CliResult { use atomic_core::output::memory::Memory; - use atomic_core::record::workflow::{ - record_added_file, record_deleted_file, DetectedFile, RecordingOptions, - }; + use atomic_core::record::workflow::{record_added_file, DetectedFile, RecordingOptions}; let working_copy = repo .require_working_copy_id() @@ -6591,8 +6761,11 @@ impl ParallelImporter { .map_err(|e| CliError::Internal(e.into()))?; // Record the union→merge-tree delta through the normal workflow. - // The candidate path set is the union of the per-parent diffs from - // Phase 1; the recorded delta is classified against the exact union + // Per-parent Git diffs alone cannot identify the delta: identical + // parent blobs can still render differently in the Atomic union + // (independent reverts and criss-cross resolutions, for example). + // Include every path in the verified merge manifest, plus deletions + // from Phase 1. Classify the delta against the exact union // visibility (parents' closures only — not the whole view) so // reverting side-parent content and resolving conflicts are both // captured without inheriting unrelated lineages. Storage and @@ -6602,16 +6775,83 @@ impl ParallelImporter { // the local-record size cap must not refuse a large tracked binary // (the 500 MB opaque corpus). let core_options = RecordingOptions::new().unlimited_file_size(); + // Equal Git blobs can still have independent semantic writers. In + // particular, two branches can make the same edit and then merge + // without any parent-to-merge diff. Record that convergence as an + // explicit causal resolution instead of leaving a last-applied row + // to choose the line's identity. + let convergent_paths = repo + .foreign_merge_concurrent_semantic_paths(&target, &excluded) + .map_err(|message| { + git_error(format!("cannot inspect merge semantic writers: {message}")) + })?; + let mut files: std::collections::BTreeMap<_, _> = parsed + .files + .iter() + .cloned() + .map(|file| (file.path.clone(), file)) + .collect(); + let merge_tree = git + .find_tree(parsed.tree_oid) + .map_err(|e| git_error(e.to_string()))?; + let mut attribute_paths: std::collections::HashSet<_> = files.keys().cloned().collect(); + attribute_paths.extend(convergent_paths.iter().cloned()); + for entry in &prospective.manifest.entries { + if entry.disposition != atomic_repository::ManifestDisposition::Included { + continue; + } + let path = String::from_utf8_lossy(entry.path.as_bytes()).into_owned(); + // Parent-relative Delete + Rename/Add can share a destination. + // They are candidate paths, not two actions on the parent union: + // deleting that path can remove the already-correct survivor. + // The verified target manifest defines one desired state per path; + // canonical union claims still select its inode and name resolution. + files.insert( + path.clone(), + ParsedFile { + new_mode: git_mode_at(git, &merge_tree, &path), + path, + operation: FileOperation::Modified, + new_content: Some(entry.repository_bytes.clone()), + old_content: None, + diff_lines: None, + old_path: None, + }, + ); + } + let files: Vec<_> = files.into_values().collect(); let mut recorded_files: Vec = Vec::new(); let mut deleted_paths: Vec = Vec::new(); let mut added_paths: Vec<&str> = Vec::new(); - for file in &parsed.files { + let mut resolved_names = std::collections::HashSet::new(); + for file in &files { + if let Some(bytes) = &file.new_content { + if let Some(records) = repo + .record_foreign_name_resolution_on_view_excluding( + &file.path, bytes, &target, &excluded, + ) + .map_err(|message| { + git_error(format!( + "cannot resolve merge path '{}': {message}", + file.path + )) + })? + { + recorded_files.extend(records); + resolved_names.insert(file.path.clone()); + } + } + } + for file in &files { + if resolved_names.contains(&file.path) { + continue; + } if file.new_content.is_some() && repo - .get_file_content_on_view_excluding(&file.path, &target, &excluded) + .get_inode_and_position_on_view_excluding(&file.path, &target, &excluded) .map_err(|e| { CliError::Internal(anyhow::anyhow!( - "cannot read exact union baseline for '{}': {e}", + "cannot resolve exact union identity for '{}': {e}", file.path )) })? @@ -6625,7 +6865,10 @@ impl ParallelImporter { .map_err(|e| CliError::Internal(e.into()))?; } let record_start = std::time::Instant::now(); - for file in &parsed.files { + for file in &files { + if resolved_names.contains(&file.path) { + continue; + } let union_bytes = repo .get_file_content_on_view_excluding(&file.path, &target, &excluded) .map_err(|e| { @@ -6635,10 +6878,12 @@ impl ParallelImporter { )) })?; match (&file.new_content, union_bytes) { - (Some(new_content), Some(union_content)) if union_content == *new_content => { + (Some(new_content), Some(union_content)) + if union_content == *new_content && !convergent_paths.contains(&file.path) => + { // The union already matches the merge tree here. } - (Some(new_content), Some(_union_content)) => { + (Some(new_content), Some(union_content)) => { // The union may hold conflicting spans (both sides // edited the same region additively). A resolution // replaces the file's whole content region with the @@ -6651,7 +6896,14 @@ impl ParallelImporter { // conflicted union render forces the whole-file-replace // safety path. let rec = repo - .record_resolution_modified_file(&file.path, new_content, &target, true) + .record_foreign_modified_file_on_view_excluding( + &file.path, + &union_content, + new_content, + true, + &target, + &excluded, + ) .map_err(|message| { CliError::Internal(anyhow::anyhow!( "cannot record merge resolution for '{}': {message}", @@ -6689,19 +6941,10 @@ impl ParallelImporter { } } (None, Some(_union_content)) => { - let (inode, position) = repo - .get_inode_and_position(&file.path) - .map_err(|error| CliError::Internal(error.into()))? - .ok_or_else(|| { - git_error(format!( - "cannot import merge deletion '{}': no stable inode binding", - file.path - )) - })?; - let mut detected = DetectedFile::deleted(&file.path); - detected.inode = Some(inode); - detected.position = Some(position); - let recorded = record_deleted_file(&detected, &core_options) + let recorded = repo + .record_foreign_deleted_file_on_view_excluding( + &file.path, &target, &excluded, + ) .map_err(|message| CliError::Internal(anyhow::anyhow!(message)))?; if recorded.is_empty() { return Err(CliError::Internal(anyhow::anyhow!( @@ -6715,6 +6958,68 @@ impl ParallelImporter { (None, None) => {} } } + // The Git merge selects attributes as well as bytes. Carry them on + // the same canonical survivor/addition record, including chmod-only + // merges whose content required no hunk. Never write attributes on + // the losing records of a name resolution. + for file in &files { + let Some(mode) = file.new_mode.filter(|_| file.new_content.is_some()) else { + continue; + }; + let expected = expected_materialization(mode)?; + let existing = recorded_files.iter().rposition(|record| { + record.path() == file.path + && record.name_conflict_resolution().is_none() + && record.kind() != Some(atomic_core::record::workflow::DetectionKind::Deleted) + }); + // Full-tree comparison must not manufacture attribute-only + // records for untouched files whose union already matches. + if existing.is_none() && !attribute_paths.contains(&file.path) { + continue; + } + let index = if let Some(index) = existing { + index + } else { + let (inode, position) = repo + .get_inode_and_position_on_view_excluding(&file.path, &target, &excluded) + .map_err(|e| CliError::Internal(e.into()))? + .ok_or_else(|| { + git_error(format!( + "merge attribute resolution for '{}' has no canonical inode", + file.path + )) + })?; + let mut record = RecordedFile::new(&file.path); + record.set_kind(atomic_core::record::workflow::DetectionKind::Modified); + record.set_inode(inode); + record.set_position(position); + recorded_files.push(record); + recorded_files.len() - 1 + }; + recorded_files[index].set_attr(atomic_core::change::InodeAttr::Mode(expected.mode())); + recorded_files[index].set_attr(atomic_core::change::InodeAttr::Kind(expected.kind())); + } + let mut directories = std::collections::BTreeSet::new(); + merge_tree + .walk(git2::TreeWalkMode::PreOrder, |prefix, entry| { + if entry.kind() == Some(git2::ObjectType::Tree) { + if let Some(name) = entry.name() { + directories.insert(format!("{prefix}{name}")); + } + } + git2::TreeWalkResult::Ok + }) + .map_err(|e| git_error(e.to_string()))?; + let mut directory_records = repo + .record_foreign_directory_resolutions_on_view_excluding( + &directories, + &recorded_files, + &target, + &excluded, + ) + .map_err(|message| git_error(format!("cannot resolve merge directories: {message}")))?; + directory_records.append(&mut recorded_files); + recorded_files = directory_records; let record_ms = record_start.elapsed().as_millis(); let metadata = git_resolution_metadata( @@ -7880,17 +8185,22 @@ fn parse_commit( // delta, not just the first-parent delta. A path where the merge reverts // side-parent content or resolves a conflict differs from the union but // may equal the first parent, so the file list is the union of the - // per-parent diffs (deduped by path; Deleted wins over other states). + // per-parent diffs. Keep the first-parent operation for shared paths: + // all deltas describe the same target bytes, but replacing a Rename with + // a side-parent Modify loses its old path and retains a stale name. if is_merge { trace_git_import(format!( "parse-union {} candidate_paths={}", short_sha, files.len() )); - let mut union_paths: std::collections::BTreeMap = + let mut union_paths: std::collections::BTreeMap> = std::collections::BTreeMap::new(); for file in files { - union_paths.insert(file.path.clone(), file); + // A rename can replace an existing destination inode. Retain + // both its deletion and the move, in delta order: dropping the + // deletion leaves two canonical name claimants at the target. + union_paths.entry(file.path.clone()).or_default().push(file); } for parent_number in 1..commit.parent_count() { let parent_tree = commit @@ -7912,15 +8222,10 @@ fn parse_commit( for delta in side_diff.deltas() { let new_file = delta.new_file(); let old_file = delta.old_file(); - if new_file.mode() == git2::FileMode::Commit - || old_file.mode() == git2::FileMode::Commit - { - continue; - } let path = new_file - .path() - .or_else(|| old_file.path()) - .map(|p| p.to_string_lossy().to_string()) + .path_bytes() + .or_else(|| old_file.path_bytes()) + .map(atomic_repository::escape_repo_path) .unwrap_or_default(); if path.is_empty() { continue; @@ -7933,16 +8238,15 @@ fn parse_commit( let new_content = if operation == FileOperation::Deleted { None } else { - get_file_content(git_repo, &tree, &path).ok() + Some(get_file_content(git_repo, &tree, &path)?) }; let mode = if operation == FileOperation::Deleted { None } else { - git_mode_at(&tree, &path) + git_mode_at(git_repo, &tree, &path) }; - union_paths.insert( - path.clone(), - ParsedFile { + union_paths.entry(path.clone()).or_insert_with(|| { + vec![ParsedFile { path, operation, new_content, @@ -7950,11 +8254,11 @@ fn parse_commit( diff_lines: None, old_path: None, new_mode: mode, - }, - ); + }] + }); } } - files = union_paths.into_values().collect(); + files = union_paths.into_values().flatten().collect(); } let is_empty = files.is_empty(); @@ -8131,37 +8435,11 @@ fn parse_diff_files( if origin != '+' && origin != '-' && origin != ' ' { return true; } - let path = delta - .new_file() - .path() - .or_else(|| delta.old_file().path()) - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_default(); - - // The diff-line map uses the same canonical identity as the - // parsed deltas (reversible escape), so capture and lookup - // agree for raw non-UTF-8 paths. let line_path = delta .new_file() - .path() - .or_else(|| delta.old_file().path()) - .map(|p| { - #[cfg(unix)] - let raw = { - use std::os::unix::ffi::OsStrExt; - p.as_os_str().as_bytes().to_vec() - }; - #[cfg(not(unix))] - let raw = p.as_os_str().to_string_lossy().into_owned().into_bytes(); - let needs_escaping = raw - .iter() - .any(|byte| !byte.is_ascii_graphic() || *byte == b'%'); - if needs_escaping { - atomic_repository::escape_repo_path(&raw) - } else { - String::from_utf8_lossy(&raw).into_owned() - } - }) + .path_bytes() + .or_else(|| delta.old_file().path_bytes()) + .map(atomic_repository::escape_repo_path) .unwrap_or_default(); lines_by_path @@ -8188,27 +8466,7 @@ fn parse_diff_files( // Refusing here (the historical behavior) lost the file from the imported // history instead of preserving it. let delta_path = |file: &git2::DiffFile| -> CliResult> { - let Some(path) = file.path() else { - return Ok(None); - }; - // Raw bytes are unix-only; windows paths are always well-formed UTF-16 - // round-trips, so the escape is applied to the UTF-8 bytes instead. - #[cfg(unix)] - let raw = { - use std::os::unix::ffi::OsStrExt; - path.as_os_str().as_bytes().to_vec() - }; - #[cfg(not(unix))] - let raw = path.as_os_str().to_string_lossy().into_owned().into_bytes(); - let needs_escaping = raw - .iter() - .any(|byte| !byte.is_ascii_graphic() || *byte == b'%'); - if needs_escaping { - Ok(Some(atomic_repository::escape_repo_path(&raw))) - } else { - // ASCII-safe and UTF-8 by construction. - Ok(Some(String::from_utf8_lossy(&raw).into_owned())) - } + Ok(file.path_bytes().map(atomic_repository::escape_repo_path)) }; let mut files = Vec::new(); @@ -8259,7 +8517,7 @@ fn parse_diff_files( FileOperation::Modified | FileOperation::Deleted | FileOperation::Renamed ) { parent_tree.and_then(|pt| { - pt.get_path(&canonical_tree_path(&path)) + git_tree_entry(git_repo, pt, &path) .ok() .filter(|entry| entry.kind() == Some(git2::ObjectType::Commit)) .and_then(|entry| { @@ -8364,23 +8622,40 @@ fn parse_diff_files( // Modified delta carrying the exact old bytes, new bytes, and the new // canonical mode. Kind and mode then ride the graph-backed attribute // registers instead of a spurious delete/recreate. - let mut normalized: Vec = Vec::with_capacity(files.len()); - for file in files { - if file.operation == FileOperation::Added { - if let Some(previous) = normalized.iter_mut().rev().find(|previous| { - previous.path == file.path && previous.operation == FileOperation::Deleted - }) { - // Merge delete+add into one typechange modification. - previous.operation = FileOperation::Modified; - previous.new_content = file.new_content.clone(); - previous.new_mode = file.new_mode; - continue; - } - } - normalized.push(file); - } + Ok(normalize_typechanges(files)) +} - Ok(normalized) +fn normalize_typechanges(files: Vec) -> Vec { + // libgit2 can emit Added before Deleted for the same path. Pair both + // orders before merge deduplication or prospective folding can discard + // the addition. Preserve the old target bytes as the modify baseline. + let deletions: HashMap<_, _> = files + .iter() + .filter(|file| file.operation == FileOperation::Deleted) + .map(|file| (file.path.clone(), file.old_content.clone())) + .collect(); + let typechanges: HashSet<_> = files + .iter() + .filter(|file| file.operation == FileOperation::Added && deletions.contains_key(&file.path)) + .map(|file| file.path.clone()) + .collect(); + files + .into_iter() + .filter_map(|mut file| { + if typechanges.contains(&file.path) { + if file.operation == FileOperation::Deleted { + return None; + } + if file.operation == FileOperation::Added { + file.operation = FileOperation::Modified; + file.old_content = deletions[&file.path].clone(); + // The two deltas' line streams are not one coherent diff. + file.diff_lines = None; + } + } + Some(file) + }) + .collect() } fn parse_diff_files_via_git_cli( @@ -8400,6 +8675,7 @@ fn parse_diff_files_via_git_cli( .arg("diff-tree") .arg("-r") .arg("--name-status") + .arg("-z") .arg("-M") .arg(parent_oid.to_string()) .arg(commit_oid.to_string()) @@ -8418,83 +8694,70 @@ fn parse_diff_files_via_git_cli( } let mut files = Vec::new(); - for line in String::from_utf8_lossy(&output.stdout).lines() { - if line.is_empty() { - continue; - } - let mut parts = line.split('\t'); - let status = parts.next().unwrap_or_default(); - let Some(kind) = status.chars().next() else { - continue; + // NUL framing preserves tabs, newlines, non-UTF-8 bytes, and literal '%'. + // Never parse Git's display quoting as a repository path. + let mut fields = output.stdout.split(|byte| *byte == 0); + while let Some(status) = fields.next() { + if status.is_empty() { + break; + } + let kind = status[0]; + let source = fields + .next() + .filter(|path| !path.is_empty()) + .ok_or_else(|| git_error("missing path in git diff-tree output"))?; + let old_path = + matches!(kind, b'R' | b'C').then(|| atomic_repository::escape_repo_path(source)); + let raw_path = if old_path.is_some() { + fields + .next() + .filter(|path| !path.is_empty()) + .ok_or_else(|| git_error("missing destination in git diff-tree output"))? + } else { + source }; - - match kind { - 'A' => { - let Some(path) = parts.next() else { continue }; - files.push(ParsedFile { - path: path.to_string(), - operation: FileOperation::Added, - new_content: get_file_content(git_repo, tree, path).ok(), - old_content: None, - diff_lines: None, - old_path: None, - new_mode: git_mode_at(tree, path), - }); - } - 'M' => { - let Some(path) = parts.next() else { continue }; - files.push(ParsedFile { - path: path.to_string(), - operation: FileOperation::Modified, - new_content: get_file_content(git_repo, tree, path).ok(), - old_content: get_file_content(git_repo, parent_tree, path).ok(), - diff_lines: None, - old_path: None, - new_mode: git_mode_at(tree, path), - }); - } - 'D' => { - let Some(path) = parts.next() else { continue }; - files.push(ParsedFile { - path: path.to_string(), - operation: FileOperation::Deleted, - new_content: None, - old_content: get_file_content(git_repo, parent_tree, path).ok(), - diff_lines: None, - old_path: None, - new_mode: None, - }); - } - 'R' => { - let Some(old_path) = parts.next() else { - continue; - }; - let Some(path) = parts.next() else { continue }; - files.push(ParsedFile { - path: path.to_string(), - operation: FileOperation::Renamed, - new_content: get_file_content(git_repo, tree, path).ok(), - old_content: get_file_content(git_repo, parent_tree, old_path).ok(), - diff_lines: None, - old_path: Some(old_path.to_string()), - new_mode: git_mode_at(tree, path), - }); - } - 'C' => { - let old_path = parts.next(); - let Some(path) = parts.next() else { continue }; - files.push(ParsedFile { - path: path.to_string(), - operation: FileOperation::Copied, - new_content: get_file_content(git_repo, tree, path).ok(), - old_content: None, - diff_lines: None, - old_path: old_path.map(|path| path.to_string()), - new_mode: git_mode_at(tree, path), - }); + let path = atomic_repository::escape_repo_path(raw_path); + let operation = match kind { + b'A' => FileOperation::Added, + b'M' | b'T' => FileOperation::Modified, + b'D' => FileOperation::Deleted, + b'R' => FileOperation::Renamed, + b'C' => FileOperation::Copied, + _ => { + return Err(git_error(format!( + "unsupported git diff-tree status: {}", + String::from_utf8_lossy(status) + ))) } - _ => {} - } + }; + let new_content = if operation == FileOperation::Deleted { + None + } else { + Some(get_file_content(git_repo, tree, &path)?) + }; + let old_content = if matches!(operation, FileOperation::Added | FileOperation::Copied) { + None + } else { + Some(get_file_content( + git_repo, + parent_tree, + old_path.as_deref().unwrap_or(&path), + )?) + }; + let new_mode = if operation == FileOperation::Deleted { + None + } else { + git_mode_at(git_repo, tree, &path) + }; + files.push(ParsedFile { + path, + operation, + new_content, + old_content, + diff_lines: None, + old_path, + new_mode, + }); } Ok(files) @@ -8603,21 +8866,19 @@ fn expected_materialization(git_mode: u32) -> CliResult Ok(expected) } -fn git_mode_at(tree: &Tree<'_>, path: &str) -> Option { - tree.get_path(Path::new(path)) +fn git_mode_at(git_repo: &GitRepository, tree: &Tree<'_>, path: &str) -> Option { + git_tree_entry(git_repo, tree, path) .ok() .map(|entry| entry.filemode() as u32) } /// Whether a path exists in the tree (any entry kind). -fn tree_entry_exists(_git_repo: &GitRepository, tree: &Tree, path: &str) -> bool { - tree.get_path(Path::new(path)).is_ok() +fn tree_entry_exists(git_repo: &GitRepository, tree: &Tree, path: &str) -> bool { + git_tree_entry(git_repo, tree, path).is_ok() } -/// Get file content from a git tree. -/// A Git tree lookup path from the canonical String identity: the reversible -/// escape decodes back to the exact raw bytes (review CB-9C R7). -fn canonical_tree_path(path: &str) -> PathBuf { +/// A physical filesystem path decoded from the canonical String identity. +pub(super) fn canonical_tree_path(path: &str) -> PathBuf { match atomic_repository::unescape_repo_path(path) { Ok(raw) => { #[cfg(unix)] @@ -8627,22 +8888,56 @@ fn canonical_tree_path(path: &str) -> PathBuf { } #[cfg(not(unix))] { - let _ = raw; - PathBuf::from(path) + match String::from_utf8(raw) { + Ok(decoded) => PathBuf::from(decoded), + Err(_) => PathBuf::from(path), + } } } Err(_) => PathBuf::from(path), } } +/// Look up raw Git path components without routing them through host paths. +/// Git trees can contain names (including non-UTF-8 bytes) that the host +/// filesystem cannot represent. Their repository identities must still be +/// decoded exactly for blob, gitlink, existence, and mode lookups. +fn git_tree_entry( + git_repo: &GitRepository, + tree: &Tree<'_>, + path: &str, +) -> CliResult> { + let raw = atomic_repository::unescape_repo_path(path) + .map_err(|error| git_error(format!("invalid encoded Git path '{path}': {error}")))?; + let mut components = raw.split(|byte| *byte == b'/').peekable(); + let mut current = tree.clone(); + while let Some(component) = components.next() { + if component.is_empty() || component.contains(&0) { + return Err(git_error(format!("invalid Git tree path '{path}'"))); + } + let entry = current + .get_name_bytes(component) + .ok_or_else(|| git_error(format!("Path not found in tree: '{path}'")))?; + if components.peek().is_none() { + return Ok(entry.to_owned()); + } + let subtree_id = entry.id(); + drop(entry); + current = git_repo + .find_tree(subtree_id) + .map_err(|error| git_error(format!("cannot read Git subtree for '{path}': {error}")))?; + } + Err(git_error(format!("invalid empty Git tree path '{path}'"))) +} + fn get_file_content(git_repo: &GitRepository, tree: &Tree, path: &str) -> CliResult> { // CB-9C path fidelity: the String identity is the reversible escape of // the raw Git bytes, so the tree lookup decodes before reading. - let entry = tree - .get_path(&canonical_tree_path(path)) - .map_err(|e| CliError::GitError { - message: format!("Path not found in tree: {}", e), - })?; + let entry = git_tree_entry(git_repo, tree, path)?; + + if entry.kind() == Some(ObjectType::Commit) { + return Ok(entry.id().to_string().into_bytes()); + } if entry.kind() != Some(ObjectType::Blob) { return Err(CliError::GitError { @@ -8689,6 +8984,10 @@ fn parse_commit_message(message: &str) -> (String, Option) { // Tests // ═══════════════════════════════════════════════════════════════════════════ +#[cfg(test)] +#[path = "parallel_import_regression_tests.rs"] +mod import_regression_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/atomic-cli/src/commands/git/parallel_import_regression_tests.rs b/atomic-cli/src/commands/git/parallel_import_regression_tests.rs new file mode 100644 index 00000000..12d71b7d --- /dev/null +++ b/atomic-cli/src/commands/git/parallel_import_regression_tests.rs @@ -0,0 +1,161 @@ +use super::*; + +#[test] +fn typechange_pair_is_independent_of_delta_order() { + for added_first in [true, false] { + let added = ParsedFile { + path: "pkg/LICENSE".into(), + operation: FileOperation::Added, + new_content: Some(b"license text\n".to_vec()), + old_content: None, + old_path: None, + diff_lines: None, + new_mode: Some(0o100644), + }; + let deleted = ParsedFile { + path: "pkg/LICENSE".into(), + operation: FileOperation::Deleted, + new_content: None, + old_content: Some(b"../LICENSE".to_vec()), + old_path: None, + diff_lines: None, + new_mode: None, + }; + let files = normalize_typechanges(if added_first { + vec![added, deleted] + } else { + vec![deleted, added] + }); + assert_eq!(files.len(), 1, "added_first={added_first}"); + assert_eq!(files[0].operation, FileOperation::Modified); + assert_eq!(files[0].new_mode, Some(0o100644)); + assert_eq!( + files[0].new_content.as_deref(), + Some(b"license text\n".as_slice()) + ); + assert_eq!( + files[0].old_content.as_deref(), + Some(b"../LICENSE".as_slice()) + ); + } +} + +fn audit_fallback_path(path: &[u8]) { + let root = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let signature = git2::Signature::now("fixture", "fixture@example.com").unwrap(); + let mut builder = git.treebuilder(None).unwrap(); + builder + .insert(path, git.blob(b"old\n").unwrap(), 0o100644) + .unwrap(); + let a_tree = git.find_tree(builder.write().unwrap()).unwrap(); + let a = git + .commit(None, &signature, &signature, "first", &a_tree, &[]) + .unwrap(); + builder + .insert(path, git.blob(b"new\n").unwrap(), 0o100644) + .unwrap(); + let b_tree = git.find_tree(builder.write().unwrap()).unwrap(); + let b = git + .commit( + None, + &signature, + &signature, + "second", + &b_tree, + &[&git.find_commit(a).unwrap()], + ) + .unwrap(); + let parsed = parse_diff_files_via_git_cli(&git, b, a, &b_tree, &a_tree).unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].path, atomic_repository::escape_repo_path(path)); + assert_eq!(parsed[0].new_content.as_deref(), Some(b"new\n".as_slice())); +} +#[test] +fn audit_fallback_ascii() { + audit_fallback_path(b"plain.txt"); +} +#[test] +fn audit_fallback_percent() { + audit_fallback_path(b"a%41.txt"); +} +#[test] +fn audit_fallback_tab() { + audit_fallback_path(b"a\tb.txt"); +} +#[test] +fn audit_fallback_unicode() { + audit_fallback_path("中文.txt".as_bytes()); +} + +#[test] +fn fallback_newline() { + audit_fallback_path(b"line\nbreak"); +} +#[test] +fn fallback_non_utf8() { + audit_fallback_path(b"raw-\xff"); +} + +#[test] +fn fallback_rename_preserves_both_raw_paths() { + let root = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let signature = git2::Signature::now("fixture", "fixture@example.com").unwrap(); + let mut builder = git.treebuilder(None).unwrap(); + let blob = git.blob(b"identical content\n").unwrap(); + builder.insert("old\t%name", blob, 0o100644).unwrap(); + let old_tree = git.find_tree(builder.write().unwrap()).unwrap(); + let old = git + .commit(None, &signature, &signature, "old", &old_tree, &[]) + .unwrap(); + builder.remove("old\t%name").unwrap(); + builder.insert("new\n中文", blob, 0o100644).unwrap(); + let new_tree = git.find_tree(builder.write().unwrap()).unwrap(); + let new = git + .commit( + None, + &signature, + &signature, + "rename", + &new_tree, + &[&git.find_commit(old).unwrap()], + ) + .unwrap(); + let parsed = parse_diff_files_via_git_cli(&git, new, old, &new_tree, &old_tree).unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].operation, FileOperation::Renamed); + assert_eq!(parsed[0].old_path.as_deref(), Some("old%09%25name")); + assert_eq!( + parsed[0].path, + atomic_repository::escape_repo_path("new\n中文".as_bytes()) + ); + assert_eq!(parsed[0].old_content, parsed[0].new_content); +} + +#[test] +fn tree_lookup_preserves_nested_raw_names_and_modes_on_every_host() { + let root = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let content = b"raw nested content\n"; + let mut nested = git.treebuilder(None).unwrap(); + nested + .insert( + b"file-\xff%\t".as_slice(), + git.blob(content).unwrap(), + 0o100755, + ) + .unwrap(); + let mut builder = git.treebuilder(None).unwrap(); + builder + .insert(b"dir-\xfe".as_slice(), nested.write().unwrap(), 0o040000) + .unwrap(); + let tree = git.find_tree(builder.write().unwrap()).unwrap(); + let path = atomic_repository::escape_repo_path(b"dir-\xfe/file-\xff%\t"); + + assert!(tree_entry_exists(&git, &tree, &path)); + assert_eq!(git_mode_at(&git, &tree, &path), Some(0o100755)); + assert_eq!(get_file_content(&git, &tree, &path).unwrap(), content); + assert!(!tree_entry_exists(&git, &tree, "missing")); + assert!(git_tree_entry(&git, &tree, "dir%00name").is_err()); +} diff --git a/atomic-cli/src/commands/git/shadow.rs b/atomic-cli/src/commands/git/shadow.rs index c1a7cff5..b726fe17 100644 --- a/atomic-cli/src/commands/git/shadow.rs +++ b/atomic-cli/src/commands/git/shadow.rs @@ -573,6 +573,15 @@ pub(crate) fn sync_git_head_to_view( if !shadow_sync_active(&git_repo) { return Ok(ShadowSwitchSync::SkippedInactive); } + // A retained shadow exclude line is not enrollment (RFC §2 / CB-13C): + // colocated-but-unenrolled repositories keep native view switches with + // no Git projection and no checkpoint requirement. + if !repo + .bridge_workspace_active() + .map_err(CliError::Repository)? + { + return Ok(ShadowSwitchSync::SkippedInactive); + } let working_copy = repo .require_working_copy_id() .map_err(CliError::Repository)?; @@ -624,6 +633,10 @@ pub(crate) fn sync_git_head_to_view( /// Selecting an inherited agent view does not materialize the worktree. /// Reuse its already verified Git tree and preserve both staged and unstaged /// edits. This narrower transition is valid only for identical closures. +/// +/// Unenrolled working copies are native: a `.git` directory (or a retained +/// shadow exclude line) is not bridge consent (RFC §2 / CB-13C), so they skip +/// the Git-side coordination entirely instead of demanding a checkpoint. pub(crate) fn sync_inherited_agent_view(root: &Path, view: &str) -> CliResult<()> { let git = match GitRepository::open(root) { Ok(git) if shadow_sync_active(&git) => git, @@ -634,18 +647,29 @@ pub(crate) fn sync_inherited_agent_view(root: &Path, view: &str) -> CliResult<() atomic_repository::database_lock_wait(), ) .map_err(CliError::Repository)?; - if repo.is_sandbox() - && !repo - .bridge_workspace_active() - .map_err(CliError::Repository)? + // Enrollment is checkpoint-or-explicit-opt-in (the same predicate every + // workspace boundary uses). The retained exclude marker alone must never + // make a native agent session require a bridge checkpoint. + if !repo + .bridge_workspace_active() + .map_err(CliError::Repository)? { return Ok(()); } let Some(before) = super::checkpoint::read_checkpoint(root).map_err(checkpoint_error_of)? else { - return Err(git_error( - "agent view selection requires a verified bridge checkpoint", - )); + // Enrolled but not anchored. Selecting the view would move Git + // HEAD/refs — a bridge-specific destructive operation — so it fails + // closed with the shared anchor-or-disable remediation rather than a + // bare internal error. + return Err(CliError::StaleBaseline { + report: format!( + "agent view selection on an enrolled but unanchored Git bridge: {}", + atomic_repository::UnanchoredWorkspace::MissingCheckpoint + .remediation() + .unwrap_or_default() + ), + }); }; let old = repo .view_identity(&before.view) @@ -792,11 +816,38 @@ pub(crate) fn sync_git_projection_after_record( return Ok(()); } let repo = Repository::open(repo_root).map_err(CliError::Repository)?; - if repo.is_sandbox() - && !repo - .bridge_workspace_active() - .map_err(CliError::Repository)? + // A retained shadow exclude line is not enrollment (RFC §2 / CB-13C): + // a colocated-but-unenrolled repository never projects its records into + // Git and never anchors a bridge implicitly. Native records stand alone. + if !repo + .bridge_workspace_active() + .map_err(CliError::Repository)? + { + return Ok(()); + } + // An enrolled workspace without a verified checkpoint is unanchored. The + // record projection would move Git HEAD and write the first checkpoint, + // anchoring the bridge as a side effect of an agent record. Anchoring is + // explicit (`atomic git bridge reconcile`); the destructive Git mutation + // fails closed here while the already-durable Atomic record stands. + if super::checkpoint::read_checkpoint(repo_root) + .map_err(checkpoint_error_of)? + .is_none() { + append_shadow_log( + repo_root, + "record-projection:unanchored", + view, + "enrolled bridge has no verified checkpoint; projection skipped \ + (run `atomic git bridge reconcile` to anchor, or `atomic git bridge disable`)", + ); + if std::io::stderr().is_terminal() { + print_warning( + "Recorded on an enrolled but unanchored Git bridge; the record projection \ + was skipped. Anchor the workspace (`atomic git bridge reconcile`) or disable \ + the bridge (`atomic git bridge disable`).", + ); + } return Ok(()); } let working_copy = repo @@ -1835,6 +1886,9 @@ fn git_error(message: impl Into) -> CliError { /// Whether git shadow sync is established for this repo (the `.git/info/exclude` /// carries Atomic's shadow patterns, written by import/push). Used to gate the /// view-switch git-follow so we never touch HEAD in a plain (non-shadow) repo. +/// NOTE: this marker is *retained* by `git bridge disable` and by plain +/// `atomic init` inside an existing Git checkout — it is not enrollment. Use +/// [`bridge_workspace_enrolled`] wherever the question is bridge participation. fn shadow_sync_active(git_repo: &GitRepository) -> bool { let exclude = git_repo.path().join("info").join("exclude"); std::fs::read_to_string(exclude) @@ -1842,6 +1896,16 @@ fn shadow_sync_active(git_repo: &GitRepository) -> bool { .unwrap_or(false) } +/// Whether the working copy at `root` participates in the colocated Git +/// bridge: a verified checkpoint (written by an explicit bridge command) or +/// the explicit `[git.bridge] enabled` opt-in (RFC §2 / CB-13C). A `.git` +/// directory next to `.atomic` — with or without a retained shadow exclude +/// line — is not consent. +pub(crate) fn bridge_workspace_enrolled(root: &Path) -> CliResult { + let repo = Repository::open_readonly(root).map_err(CliError::Repository)?; + repo.bridge_workspace_active().map_err(CliError::Repository) +} + /// Acquire the repo-scoped shadow-commit lock, or return `None` (a no-op skip) /// if a shadow materialize/commit is already in flight (SPEC §4.3 / Principle 5). /// @@ -2071,6 +2135,7 @@ fn is_forbidden_shadow_path(path: &str) -> bool { #[cfg(test)] mod tests { use super::is_forbidden_shadow_path as forbidden; + use super::*; #[test] fn forbids_provenance_and_excluded_paths() { @@ -2087,4 +2152,221 @@ mod tests { assert!(!forbidden("docs/.atomicignore.md")); assert!(!forbidden("my.vault/keep.txt")); } + + // ── Agent bridge gating (RFC §2 / CB-13C) ───────────────────────────── + // + // The retained `.git/info/exclude` marker is not enrollment: only a + // verified checkpoint or the explicit `[git.bridge] enabled` opt-in + // participates in the Git bridge. + + use git2::Oid; + + fn commit_file(git: &GitRepository, root: &Path, content: &[u8]) -> Oid { + std::fs::write(root.join("tracked.txt"), content).unwrap(); + let mut index = git.index().unwrap(); + index.add_path(Path::new("tracked.txt")).unwrap(); + index.write().unwrap(); + let tree_oid = index.write_tree().unwrap(); + let tree = git.find_tree(tree_oid).unwrap(); + let signature = git2::Signature::now("Atomic Test", "test@atomic.invalid").unwrap(); + let parents = git + .head() + .ok() + .and_then(|head| head.target()) + .map(|oid| git.find_commit(oid).unwrap()); + match parents.as_ref() { + Some(parent) => git + .commit( + Some("HEAD"), + &signature, + &signature, + "checkpoint", + &tree, + &[parent], + ) + .unwrap(), + None => git + .commit( + Some("HEAD"), + &signature, + &signature, + "checkpoint", + &tree, + &[], + ) + .unwrap(), + } + } + + /// Atomic + colocated Git with the retained shadow exclude marker, no + /// bridge checkpoint and no bridge opt-in: a native, unenrolled repo. + fn native_colocated_with_retained_marker(root: &Path) -> GitRepository { + atomic_repository::Repository::init(root).unwrap(); + let git = GitRepository::init(root).unwrap(); + commit_file(&git, root, b"native\n"); + std::fs::create_dir_all(root.join(".git").join("info")).unwrap(); + std::fs::write( + root.join(".git").join("info").join("exclude"), + "/.atomic/\n", + ) + .unwrap(); + git + } + + fn enroll_via_config(root: &Path) { + let config = root.join(".atomic").join("config.toml"); + let mut content = std::fs::read_to_string(&config).unwrap_or_default(); + content.push_str("\n[git.bridge]\nenabled = true\n"); + std::fs::write(&config, content).unwrap(); + } + + fn write_aligned_checkpoint(root: &Path, git: &GitRepository) { + let head = git.head().unwrap().target().unwrap(); + let tree = git.find_commit(head).unwrap().tree_id(); + let atomic = crate::commands::git::checkpoint::observe_current_atomic(root).unwrap(); + crate::commands::git::checkpoint::write_verified_checkpoint( + root, + crate::commands::git::checkpoint::VerifiedCheckpointInput { + view: &atomic.view, + atomic_state: &atomic.state, + git_head: &head.to_string(), + git_tree: &tree.to_string(), + }, + ) + .unwrap(); + } + + fn current_view(root: &Path) -> String { + let repo = atomic_repository::Repository::open_readonly(root).unwrap(); + let working_copy = repo.require_working_copy_id().unwrap(); + repo.desired_view_name(working_copy).unwrap() + } + + #[test] + fn agent_view_selection_is_native_without_bridge_enrollment() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + let git = native_colocated_with_retained_marker(root); + drop(git); + // Regression: the retained marker previously demanded a checkpoint + // and failed every hook dispatch with exit 128. + sync_inherited_agent_view(root, ¤t_view(root)).unwrap(); + assert!(!root.join(".atomic/bridge").exists()); + } + + #[test] + fn agent_view_selection_reports_remediation_when_enrolled_unanchored() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + let git = native_colocated_with_retained_marker(root); + drop(git); + enroll_via_config(root); + let error = sync_inherited_agent_view(root, ¤t_view(root)).unwrap_err(); + match error { + CliError::StaleBaseline { report } => { + assert!(report.contains("atomic git bridge reconcile"), "{report}"); + assert!(report.contains("atomic git bridge disable"), "{report}"); + } + other => panic!("expected StaleBaseline, got: {other:?}"), + } + // The destructive Git mutation fails closed: nothing was anchored. + assert!(!root.join(".atomic/bridge").exists()); + } + + #[test] + fn agent_view_selection_preserves_integrity_when_head_drifts() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + let git = native_colocated_with_retained_marker(root); + write_aligned_checkpoint(root, &git); + commit_file(&git, root, b"drifted\n"); + drop(git); + let error = sync_inherited_agent_view(root, ¤t_view(root)).unwrap_err(); + match error { + CliError::GitError { message } => { + assert!( + message.contains("Git HEAD changed during agent view selection"), + "{message}" + ); + } + other => panic!("expected GitError, got: {other:?}"), + } + } + + #[test] + fn record_projection_is_native_without_bridge_enrollment() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + let git = native_colocated_with_retained_marker(root); + drop(git); + let view = current_view(root); + let hash = atomic_core::types::Hash::of(b"recorded"); + sync_git_projection_after_record(root, &view, &hash).unwrap(); + assert!(!root.join(".atomic/bridge").exists()); + assert!(git_ref_count(root, "refs/atomic") == 0); + } + + #[test] + fn record_projection_skips_instead_of_anchoring_when_enrolled_unanchored() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + let git = native_colocated_with_retained_marker(root); + drop(git); + enroll_via_config(root); + let view = current_view(root); + let hash = atomic_core::types::Hash::of(b"recorded"); + sync_git_projection_after_record(root, &view, &hash).unwrap(); + assert!(!root.join(".atomic/bridge/workspace.json").exists()); + let log = std::fs::read_to_string(root.join(".atomic/hook-errors.log")).unwrap(); + assert!(log.contains("record-projection:unanchored"), "{log}"); + } + + #[test] + fn view_switch_git_follow_is_native_without_bridge_enrollment() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + let git = native_colocated_with_retained_marker(root); + drop(git); + // Resolve the view before opening the writable handle (redb admits + // one database handle per process). + let view = current_view(root); + let repo = atomic_repository::Repository::open_for_workspace_transaction_wait( + root, + atomic_repository::database_lock_wait(), + ) + .unwrap(); + match sync_git_head_to_view(&repo, root, &view).unwrap() { + ShadowSwitchSync::SkippedInactive => {} + other => panic!("expected SkippedInactive, got: {other:?}"), + } + } + + #[test] + fn enrollment_helper_matches_checkpoint_or_config() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + let git = native_colocated_with_retained_marker(root); + // Unenrolled: marker alone is not consent. + assert!(!bridge_workspace_enrolled(root).unwrap()); + write_aligned_checkpoint(root, &git); + // A checkpoint (explicit bridge command evidence) enrolls, even when + // configuration says disabled. + assert!(bridge_workspace_enrolled(root).unwrap()); + drop(git); + + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + let git = native_colocated_with_retained_marker(root); + drop(git); + enroll_via_config(root); + // Explicit opt-in enrolls without a checkpoint. + assert!(bridge_workspace_enrolled(root).unwrap()); + } + + fn git_ref_count(root: &Path, pattern: &str) -> usize { + let git = GitRepository::open(root).unwrap(); + git.references_glob(pattern) + .map(|refs| refs.flatten().count()) + .unwrap_or(0) + } } diff --git a/atomic-cli/src/commands/view/switch.rs b/atomic-cli/src/commands/view/switch.rs index e29d6689..470bd10d 100644 --- a/atomic-cli/src/commands/view/switch.rs +++ b/atomic-cli/src/commands/view/switch.rs @@ -110,6 +110,15 @@ fn checkpoint_shadow_switch( view: &str, ) -> CliResult<()> { let shadow_was_synchronized = shadow_sync.is_synchronized(); + // A native (unenrolled) colocated repository must neither fail its view + // switch on Git observation errors nor bootstrap the first bridge + // checkpoint: `.git` beside `.atomic` is not bridge consent. Only an + // enrolled workspace refreshes; import anchors explicitly. + if !shadow_was_synchronized + && !crate::commands::git::shadow::bridge_workspace_enrolled(repo_root)? + { + return Ok(()); + } let checkpoint = match crate::commands::git::bridge::refresh_checkpoint_if_aligned(repo_root) { Ok(outcome) => outcome, Err(error) => { @@ -362,6 +371,35 @@ mod tests { String::from_utf8_lossy(&output.stdout).trim().to_string() } + // ------------------------------------------------------------------------- + // Bridge enrollment gating (RFC §2 / CB-13C) + // ------------------------------------------------------------------------- + + #[test] + fn checkpoint_refresh_skips_native_unenrolled_repositories() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + atomic_repository::Repository::init(root).unwrap(); + let git = git2::Repository::init(root).unwrap(); + // A retained exclude marker without bridge enrollment must neither + // fail the view switch on Git observation errors nor bootstrap the + // first bridge checkpoint. + std::fs::create_dir_all(root.join(".git").join("info")).unwrap(); + std::fs::write( + root.join(".git").join("info").join("exclude"), + "/.atomic/\n", + ) + .unwrap(); + drop(git); + checkpoint_shadow_switch( + crate::commands::git::shadow::ShadowSwitchSync::SkippedInactive, + root, + "main", + ) + .unwrap(); + assert!(!root.join(".atomic/bridge/workspace.json").exists()); + } + // ------------------------------------------------------------------------- // Command Builder Tests // ------------------------------------------------------------------------- diff --git a/atomic-cli/tests/fixtures/helpers_c1.rs b/atomic-cli/tests/fixtures/helpers_c1.rs new file mode 100644 index 00000000..fa0d3a6e --- /dev/null +++ b/atomic-cli/tests/fixtures/helpers_c1.rs @@ -0,0 +1,557 @@ +//! Helper functions for the push command. +//! +//! This module provides utility functions used by the push command, including: +//! +//! - Delta calculation between local and remote changelists +//! - Error conversion from remote errors to CLI errors +//! - State comparison display +//! - Change data loading and formatting + +use std::collections::HashSet; + +use bytes::Bytes; + +use atomic_core::types::{Base32, Hash}; +use atomic_remote::{ChangelistEntry, RemoteError, StateResponse}; +use atomic_repository::history::HistoryEntry; +use atomic_repository::Repository; + +use crate::error::{CliError, CliResult}; +use crate::output::{info, stack as style_stack}; + +use super::types::PushChange; + +// ============================================================================= +// Delta Calculation +// ============================================================================= + +/// Calculate which changes need to be pushed to the remote. +/// +/// Compares local and remote changelists to determine which local +/// changes are missing from the remote. Changes are returned in +/// dependency order (earliest first) so they can be uploaded correctly. +/// +/// # Arguments +/// +/// * `repo` - The local repository (for loading change metadata) +/// * `local_entries` - Local history entries in forward order +/// * `remote_entries` - Remote changelist entries +/// * `push_all` - Whether to push all changes regardless of remote state +/// +/// # Returns +/// +/// A vector of changes that should be uploaded to the remote. +/// +/// # Example +/// +/// ```rust,ignore +/// let graph_hashes = std::collections::HashSet::new(); +/// let to_push = calculate_push_delta(&repo, &local_history, &remote_list, &graph_hashes, false)?; +/// println!("Need to push {} changes", to_push.len()); +/// ``` +pub fn calculate_push_delta( + repo: &Repository, + local_entries: &[HistoryEntry], + remote_entries: &[ChangelistEntry], + graph_hashes: &HashSet, + push_all: bool, +) -> CliResult> { + // Build set of remote hashes for quick lookup (changes already in this stack) + let remote_hashes: HashSet = remote_entries.iter().map(|e| e.hash.clone()).collect(); + + let mut to_upload = Vec::new(); + + for entry in local_entries { + let hash_str = entry.hash.to_base32(); + + // Skip if already on this stack on the remote (unless pushing all) + if !push_all && remote_hashes.contains(&hash_str) { + continue; + } + + // Try to load the change header to get the message + let message = load_change_message(repo, &entry.hash); + + // Check if the change is already in the remote graph (via another stack). + // If so, only stack adoption is needed — no data transfer. + let already_in_graph = graph_hashes.contains(&hash_str); + + let push_change = PushChange::new(entry.hash, entry.sequence, entry.state) + .with_tagged(entry.is_tagged) + .with_in_graph(already_in_graph); + + let push_change = if let Some(msg) = message { + push_change.with_message(msg) + } else { + push_change + }; + + to_upload.push(push_change); + } + + Ok(to_upload) +} + +/// Load the message from a change, returning None if it fails. +fn load_change_message(repo: &Repository, hash: &Hash) -> Option { + repo.load_change(hash) + .ok() + .map(|c| c.hashed.header.message.clone()) +} + +/// Check if local and remote histories have diverged. +/// +/// Returns true if the remote has changes that are not in the local history. +/// This indicates a conflict that needs to be resolved before pushing. +/// +/// # Arguments +/// +/// * `local_entries` - Local history entries +/// * `remote_entries` - Remote changelist entries +/// +/// # Returns +/// +/// `true` if the remote has changes not present locally. +/// +/// # Example +/// +/// ```rust,ignore +/// if has_diverged(&local_history, &remote_list) { +/// println!("Histories have diverged - need to pull first"); +/// } +/// ``` +pub fn has_diverged(local_entries: &[HistoryEntry], remote_entries: &[ChangelistEntry]) -> bool { + // Empty remote can't have diverged + if remote_entries.is_empty() { + return false; + } + + // Build set of local hashes + let local_hashes: HashSet = local_entries.iter().map(|e| e.hash.to_base32()).collect(); + + // Check if any remote change is not in local + remote_entries + .iter() + .any(|e| !local_hashes.contains(&e.hash)) +} + +// ============================================================================= +// Change Data Loading +// ============================================================================= + +/// Load and serialize change data from the repository. +/// +/// Loads the change from the repository and serializes it to bytes +/// suitable for uploading to a remote. +/// +/// # Arguments +/// +/// * `repo` - The repository to load from +/// * `hash` - The hash of the change to load +/// +/// # Returns +/// +/// The serialized change data as bytes. +/// +/// # Errors +/// +/// Returns `CliError::ChangeNotFound` if the change doesn't exist, +/// or `CliError::Internal` if serialization fails. +pub fn load_change_data(repo: &Repository, hash: &Hash) -> CliResult { + // Load the change + let change = repo + .load_change(hash) + .map_err(|_| CliError::ChangeNotFound { + hash: hash.to_base32(), + })?; + + // Serialize to bytes + let mut buffer = Vec::new(); + change + .serialize(&mut buffer) + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to serialize change: {}", e)))?; + + Ok(Bytes::from(buffer)) +} + +// ============================================================================= +// Error Conversion +// ============================================================================= + +/// Convert a remote error to a CLI error. +/// +/// Maps the various remote error types to appropriate CLI error types +/// with user-friendly messages and suggestions. +/// +/// # Arguments +/// +/// * `err` - The remote error to convert +/// * `url` - The remote URL (for context in error messages) +/// +/// # Returns +/// +/// A `CliError` that can be displayed to the user. +pub fn convert_remote_error(err: RemoteError, url: &str) -> CliError { + match err { + RemoteError::ConnectionFailed { .. } => CliError::RemoteError { + message: format!("Failed to connect: {}", err), + url: Some(url.to_string()), + }, + RemoteError::AuthenticationFailed { .. } => CliError::AuthenticationFailed { + remote: url.to_string(), + }, + RemoteError::RepositoryNotFound { .. } => CliError::RemoteError { + message: "Repository not found on remote".to_string(), + url: Some(url.to_string()), + }, + RemoteError::StackNotFound { stack } => CliError::RemoteError { + message: format!("Stack '{}' not found on remote", stack), + url: Some(url.to_string()), + }, + RemoteError::ChangeNotFound { hash } => CliError::ChangeNotFound { hash }, + RemoteError::MissingDependencies { + count, + missing_hashes, + } => CliError::MissingDependency { + change: "uploaded change".to_string(), + dependency: if missing_hashes.is_empty() { + format!("{} dependencies", count) + } else { + missing_hashes.join(", ") + }, + }, + RemoteError::StateMismatch { + remote_state, + requested_state, + } => CliError::Conflict { + description: format!( + "State mismatch: remote is at {}, requested {}", + remote_state, requested_state + ), + }, + RemoteError::Timeout { seconds } => CliError::RemoteError { + message: format!("Request timed out after {} seconds", seconds), + url: Some(url.to_string()), + }, + _ => CliError::RemoteError { + message: err.to_string(), + url: Some(url.to_string()), + }, + } +} + +// ============================================================================= +// Display Helpers +// ============================================================================= + +/// Display the state comparison between local and remote. +/// +/// Shows the user a summary of where local and remote are at, +/// helping them understand what will be pushed. +/// +/// # Arguments +/// +/// * `local_stack` - Name of the local stack +/// * `local_entries` - Local history entries +/// * `remote_stack` - Name of the remote stack +/// * `remote_state` - Current state of the remote +/// * `remote_entries` - Remote changelist entries +pub fn display_state_comparison( + local_stack: &str, + local_entries: &[HistoryEntry], + remote_stack: &str, + remote_state: &StateResponse, + remote_entries: &[ChangelistEntry], +) { + let local_state_str = format_local_state(local_entries); + let remote_state_str = format_remote_state(remote_state, remote_entries); + + println!( + " Local: {} at {}", + style_stack(local_stack), + info(&local_state_str) + ); + println!( + " Remote: {} at {}", + style_stack(remote_stack), + info(&remote_state_str) + ); +} + +/// Format the local state for display. +fn format_local_state(entries: &[HistoryEntry]) -> String { + if let Some(entry) = entries.last() { + let short_state = &entry.state.to_base32()[..12]; + format!("{} ({} changes)", short_state, entries.len()) + } else { + "(empty)".to_string() + } +} + +/// Format the remote state for display. +fn format_remote_state(state: &StateResponse, entries: &[ChangelistEntry]) -> String { + if let Some(merkle_str) = state.merkle() { + let short_state = &merkle_str[..12.min(merkle_str.len())]; + format!("{} ({} changes)", short_state, entries.len()) + } else { + "(empty)".to_string() + } +} + +/// Format a count with singular/plural suffix. +/// +/// # Arguments +/// +/// * `count` - The count to format +/// * `singular` - The singular form of the word +/// +/// # Returns +/// +/// A formatted string like "1 change" or "5 changes". +/// +/// # Example +/// +/// ```rust,ignore +/// assert_eq!(format_count(1, "change"), "1 change"); +/// assert_eq!(format_count(5, "change"), "5 changes"); +/// ``` +pub fn format_count(count: usize, singular: &str) -> String { + if count == 1 { + format!("{} {}", count, singular) + } else { + format!("{} {}s", count, singular) + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + // ========================================================================= + // Delta Calculation Tests + // ========================================================================= + + #[test] + fn test_has_diverged_empty_remote() { + let local_entries = vec![ + create_test_entry(0, "ABC123"), + create_test_entry(1, "DEF456"), + ]; + let remote_entries: Vec = vec![]; + + assert!(!has_diverged(&local_entries, &remote_entries)); + } + + #[test] + fn test_has_diverged_subset() { + // Remote is a subset of local - no divergence + let local_entries = vec![ + create_test_entry(0, "ABC123"), + create_test_entry(1, "DEF456"), + create_test_entry(2, "GHI789"), + ]; + let remote_entries = vec![ + create_test_changelist_entry(0, "ABC123"), + create_test_changelist_entry(1, "DEF456"), + ]; + + assert!(!has_diverged(&local_entries, &remote_entries)); + } + + #[test] + fn test_has_diverged_different_changes() { + // Remote has a change not in local - diverged + let local_entries = vec![ + create_test_entry(0, "ABC123"), + create_test_entry(1, "DEF456"), + ]; + let remote_entries = vec![ + create_test_changelist_entry(0, "ABC123"), + create_test_changelist_entry(1, "XYZ999"), // Different! + ]; + + assert!(has_diverged(&local_entries, &remote_entries)); + } + + #[test] + fn test_has_diverged_identical() { + let local_entries = vec![ + create_test_entry(0, "ABC123"), + create_test_entry(1, "DEF456"), + ]; + let remote_entries = vec![ + create_test_changelist_entry(0, "ABC123"), + create_test_changelist_entry(1, "DEF456"), + ]; + + assert!(!has_diverged(&local_entries, &remote_entries)); + } + + // ========================================================================= + // Error Conversion Tests + // ========================================================================= + + #[test] + fn test_convert_connection_failed() { + let err = RemoteError::connection_failed( + "http://example.com", + std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused"), + ); + let cli_err = convert_remote_error(err, "http://example.com"); + + assert!(matches!(cli_err, CliError::RemoteError { .. })); + } + + #[test] + fn test_convert_auth_failed() { + let err = RemoteError::auth_failed("http://example.com", "invalid token"); + let cli_err = convert_remote_error(err, "http://example.com"); + + assert!(matches!(cli_err, CliError::AuthenticationFailed { .. })); + } + + #[test] + fn test_convert_repo_not_found() { + let err = RemoteError::repo_not_found("http://example.com/repo"); + let cli_err = convert_remote_error(err, "http://example.com/repo"); + + match cli_err { + CliError::RemoteError { message, .. } => { + assert!(message.contains("not found")); + } + _ => panic!("Expected RemoteError"), + } + } + + #[test] + fn test_convert_stack_not_found() { + let err = RemoteError::stack_not_found("main"); + let cli_err = convert_remote_error(err, "http://example.com"); + + match cli_err { + CliError::RemoteError { message, .. } => { + assert!(message.contains("main")); + assert!(message.contains("not found")); + } + _ => panic!("Expected RemoteError"), + } + } + + #[test] + fn test_convert_missing_deps() { + let err = RemoteError::missing_deps(vec!["ABC".to_string(), "DEF".to_string()]); + let cli_err = convert_remote_error(err, "http://example.com"); + + assert!(matches!(cli_err, CliError::MissingDependency { .. })); + } + + #[test] + fn test_convert_timeout() { + let err = RemoteError::timeout(30); + let cli_err = convert_remote_error(err, "http://example.com"); + + match cli_err { + CliError::RemoteError { message, .. } => { + assert!(message.contains("timed out")); + assert!(message.contains("30")); + } + _ => panic!("Expected RemoteError"), + } + } + + // ========================================================================= + // Format Count Tests + // ========================================================================= + + #[test] + fn test_format_count_zero() { + assert_eq!(format_count(0, "change"), "0 changes"); + } + + #[test] + fn test_format_count_one() { + assert_eq!(format_count(1, "change"), "1 change"); + } + + #[test] + fn test_format_count_many() { + assert_eq!(format_count(5, "change"), "5 changes"); + assert_eq!(format_count(100, "file"), "100 files"); + } + + #[test] + fn test_format_count_different_words() { + assert_eq!(format_count(1, "tag"), "1 tag"); + assert_eq!(format_count(2, "tag"), "2 tags"); + assert_eq!(format_count(1, "warning"), "1 warning"); + assert_eq!(format_count(3, "warning"), "3 warnings"); + } + + // ========================================================================= + // Display Helper Tests + // ========================================================================= + + #[test] + fn test_format_local_state_empty() { + let entries: Vec = vec![]; + assert_eq!(format_local_state(&entries), "(empty)"); + } + + #[test] + fn test_format_local_state_with_entries() { + let entries = vec![ + create_test_entry(0, "ABC123"), + create_test_entry(1, "DEF456"), + ]; + let result = format_local_state(&entries); + + assert!(result.contains("2 changes")); + } + + #[test] + fn test_format_remote_state_empty() { + let state = StateResponse::empty(); + let entries: Vec = vec![]; + + assert_eq!(format_remote_state(&state, &entries), "(empty)"); + } + + // ========================================================================= + // Test Helpers + // ========================================================================= + + /// Create a test history entry with a deterministic hash. + /// The hash is created from the hash_str so that when compared with + /// a ChangelistEntry using the same string, they will match. + fn create_test_entry(sequence: u64, hash_str: &str) -> HistoryEntry { + // Create a hash that will produce the expected base32 string + // For testing, we use a hash derived from the string itself + let hash = Hash::of(hash_str.as_bytes()); + HistoryEntry { + sequence, + hash, + state: Merkle::ZERO, + node_id: NodeId::from(sequence), + header: None, + is_tagged: false, + } + } + + /// Create a test changelist entry. + /// The hash should be the base32 encoding of the same hash used in create_test_entry. + fn create_test_changelist_entry(sequence: u64, hash_str: &str) -> ChangelistEntry { + // Use the same hash derivation as create_test_entry + let hash = Hash::of(hash_str.as_bytes()); + let hash_base32 = hash.to_base32(); + ChangelistEntry::new( + sequence, + &hash_base32, + "ABCD1234ABCD1234ABCD1234ABCD1234", + false, + ) + } +} diff --git a/atomic-cli/tests/fixtures/helpers_c5.rs b/atomic-cli/tests/fixtures/helpers_c5.rs new file mode 100644 index 00000000..a247782f --- /dev/null +++ b/atomic-cli/tests/fixtures/helpers_c5.rs @@ -0,0 +1,796 @@ +//! Helper functions for the push command. +//! +//! # Delta Transfer Protocol +//! +//! The push command uses a threshold-gated delta transfer protocol: +//! +//! - **Small changes (< 1 MB)**: Sent directly as a single HTTP POST. +//! No manifest exchange, no chunk negotiation. This covers 99% of +//! source code changes where each change is already a small delta. +//! +//! - **Large changes (≥ 1 MB)**: Negotiate chunks first. The client sends +//! a chunk manifest (list of content chunk hashes + sizes), the server +//! responds with which chunks it already has, and the client sends only +//! the missing chunks + metadata. This saves significant bandwidth for +//! large binary files or initial records where the server has a prior version. +//! +//! The threshold is configurable via [`DELTA_TRANSFER_THRESHOLD`]. +//! +//! This module provides utility functions used by the push command, including: +//! +//! - Delta calculation between local and remote changelists +//! - Error conversion from remote errors to CLI errors +//! - State comparison display +//! - Change data loading and formatting + +use std::collections::HashSet; + +use bytes::Bytes; + +use atomic_core::types::{Base32, Hash}; +use atomic_remote::{ChangelistEntry, RemoteError, StateResponse}; +use atomic_repository::history::HistoryEntry; +use atomic_repository::Repository; + +use crate::error::{CliError, CliResult}; +use crate::output::{info, stack as style_stack}; + +use super::types::PushChange; + +// ============================================================================= +// Delta Calculation +// ============================================================================= + +/// Calculate which changes need to be pushed to the remote. +/// +/// Compares local and remote changelists to determine which local +/// changes are missing from the remote. Changes are returned in +/// dependency order (earliest first) so they can be uploaded correctly. +/// +/// # Arguments +/// +/// * `repo` - The local repository (for loading change metadata) +/// * `local_entries` - Local history entries in forward order +/// * `remote_entries` - Remote changelist entries +/// * `push_all` - Whether to push all changes regardless of remote state +/// +/// # Returns +/// +/// A vector of changes that should be uploaded to the remote. +/// +/// # Example +/// +/// ```rust,ignore +/// let graph_hashes = std::collections::HashSet::new(); +/// let to_push = calculate_push_delta(&repo, &local_history, &remote_list, &graph_hashes, false)?; +/// println!("Need to push {} changes", to_push.len()); +/// ``` +pub fn calculate_push_delta( + repo: &Repository, + local_entries: &[HistoryEntry], + remote_entries: &[ChangelistEntry], + graph_hashes: &HashSet, + push_all: bool, +) -> CliResult> { + // Build set of remote hashes for quick lookup (changes already in this stack) + let remote_hashes: HashSet = remote_entries.iter().map(|e| e.hash.clone()).collect(); + + let mut to_upload = Vec::new(); + + for entry in local_entries { + let hash_str = entry.hash.to_base32(); + + // Skip if already on this stack on the remote (unless pushing all) + if !push_all && remote_hashes.contains(&hash_str) { + continue; + } + + // Try to load the change header to get the message + let message = load_change_message(repo, &entry.hash); + + // Check if the change is already in the remote graph (via another stack). + // If so, only stack adoption is needed — no data transfer. + let already_in_graph = graph_hashes.contains(&hash_str); + + let push_change = PushChange::new(entry.hash, entry.sequence, entry.state) + .with_tagged(entry.is_tagged) + .with_in_graph(already_in_graph); + + let push_change = if let Some(msg) = message { + push_change.with_message(msg) + } else { + push_change + }; + + to_upload.push(push_change); + } + + Ok(to_upload) +} + +/// Load the message from a change, returning None if it fails. +fn load_change_message(repo: &Repository, hash: &Hash) -> Option { + repo.load_change(hash) + .ok() + .map(|c| c.hashed.header.message.clone()) +} + +/// Check if local and remote histories have diverged. +/// +/// Returns true if the remote has changes that are not in the local history. +/// This indicates a conflict that needs to be resolved before pushing. +/// +/// # Arguments +/// +/// * `local_entries` - Local history entries +/// * `remote_entries` - Remote changelist entries +/// +/// # Returns +/// +/// `true` if the remote has changes not present locally. +/// +/// # Example +/// +/// ```rust,ignore +/// if has_diverged(&local_history, &remote_list) { +/// println!("Histories have diverged - need to pull first"); +/// } +/// ``` +pub fn has_diverged(local_entries: &[HistoryEntry], remote_entries: &[ChangelistEntry]) -> bool { + // Empty remote can't have diverged + if remote_entries.is_empty() { + return false; + } + + // Build set of local hashes + let local_hashes: HashSet = local_entries.iter().map(|e| e.hash.to_base32()).collect(); + + // Check if any remote change is not in local + remote_entries + .iter() + .any(|e| !local_hashes.contains(&e.hash)) +} + +// ============================================================================= +// Change Data Loading +// ============================================================================= + +/// Load and serialize change data from the repository. +/// +/// Loads the change from the repository and serializes it to bytes +/// suitable for uploading to a remote. +/// +/// # Arguments +/// +/// * `repo` - The repository to load from +/// * `hash` - The hash of the change to load +/// +/// # Returns +/// +/// The serialized change data as bytes. +/// +/// # Errors +/// +/// Returns `CliError::ChangeNotFound` if the change doesn't exist, +/// or `CliError::Internal` if serialization fails. +pub fn load_change_data(repo: &Repository, hash: &Hash) -> CliResult { + // Load the change + let change = repo + .load_change(hash) + .map_err(|_| CliError::ChangeNotFound { + hash: hash.to_base32(), + })?; + + // Serialize to bytes + let mut buffer = Vec::new(); + change + .serialize(&mut buffer) + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to serialize change: {}", e)))?; + + Ok(Bytes::from(buffer)) +} + +/// Get the on-disk path of a change file for direct file-based push. +/// +/// This returns the path to the `.change` file in the repository's change +/// store, enabling `HttpRemote::upload_change_file()` to stream bytes +/// directly from disk without the load → deserialize → re-serialize +/// roundtrip. +/// +/// # Arguments +/// +/// * `repo` - The repository containing the change. +/// * `hash` - The hash of the change. +/// +/// # Returns +/// +/// The path to the `.change` file on disk. +/// +/// # Errors +/// +/// Returns `CliError::ChangeNotFound` if the file doesn't exist. +pub fn change_file_path(repo: &Repository, hash: &Hash) -> CliResult { + let base32 = hash.to_base32(); + let prefix = &base32[..2.min(base32.len())]; + let filename = format!("{}.change", base32); + let path = repo + .root() + .join(".atomic") + .join("changes") + .join(prefix) + .join(filename); + + if path.exists() { + Ok(path) + } else { + Err(CliError::ChangeNotFound { hash: base32 }) + } +} + +// ============================================================================= +// Delta Transfer Protocol +// ============================================================================= + +/// Size threshold (in bytes) for delta transfer negotiation. +/// +/// Changes smaller than this are sent directly — the overhead of a manifest +/// exchange round trip (two HTTP requests) exceeds any bandwidth savings for +/// small changes. For source code changes, this covers 99% of cases. +/// +/// Changes larger than this trigger the delta protocol: +/// 1. Client sends chunk manifest to server +/// 2. Server responds with which chunks it already has +/// 3. Client sends only missing chunks + metadata +/// +/// The 1 MB threshold was chosen because: +/// - A typical source code change is 200 bytes – 50 KB (well below) +/// - A manifest exchange adds ~100ms latency (two round trips) +/// - Below 1 MB, sending the full file on a 100 Mbps link takes <80ms +/// - Above 1 MB, delta savings can be 90%+ (worth the negotiation cost) +pub const DELTA_TRANSFER_THRESHOLD: usize = 1024 * 1024; // 1 MB + +/// Result of a push operation for a single change. +/// +/// Describes whether the change was sent directly (fast path) or via +/// delta transfer (large path), and the bytes transferred. +#[derive(Debug)] +pub struct PushTransferResult { + /// Whether delta transfer was used. + pub used_delta: bool, + + /// Total bytes sent over the wire. + pub bytes_sent: u64, + + /// Bytes saved by delta transfer (0 if direct send). + pub bytes_saved: u64, + + /// Number of content chunks the server already had (0 if direct send). + pub chunks_reused: u32, + + /// Total content chunks in the change (0 if direct send). + pub chunks_total: u32, +} + +impl PushTransferResult { + /// Create a result for a direct (non-delta) send. + pub fn direct(bytes_sent: u64) -> Self { + Self { + used_delta: false, + bytes_sent, + bytes_saved: 0, + chunks_reused: 0, + chunks_total: 0, + } + } + + /// Create a result for a delta transfer. + pub fn delta(bytes_sent: u64, bytes_saved: u64, chunks_reused: u32, chunks_total: u32) -> Self { + Self { + used_delta: true, + bytes_sent, + bytes_saved, + chunks_reused, + chunks_total, + } + } + + /// Percentage of bytes saved (0.0–100.0). + pub fn savings_pct(&self) -> f64 { + let total = self.bytes_sent + self.bytes_saved; + if total == 0 { + return 0.0; + } + self.bytes_saved as f64 / total as f64 * 100.0 + } +} + +impl std::fmt::Display for PushTransferResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.used_delta { + write!( + f, + "delta: sent {} ({} saved, {}/{} chunks reused, {:.0}% savings)", + format_bytes(self.bytes_sent), + format_bytes(self.bytes_saved), + self.chunks_reused, + self.chunks_total, + self.savings_pct(), + ) + } else { + write!(f, "direct: sent {}", format_bytes(self.bytes_sent)) + } + } +} + +/// Upload a single change using the threshold-gated delta transfer protocol. +/// +/// - Changes smaller than [`DELTA_TRANSFER_THRESHOLD`]: sent directly (fast path) +/// - Changes larger: manifest exchange → send only missing chunks (delta path) +/// +/// # Arguments +/// +/// * `remote` - The HTTP remote to push to. +/// * `repo` - The local repository. +/// * `hash` - The change hash. +/// * `stack` - The target stack name on the remote. +/// +/// # Returns +/// +/// A [`PushTransferResult`] describing the transfer, or an error. +pub async fn upload_change_smart( + remote: &atomic_remote::HttpRemote, + repo: &Repository, + hash: &Hash, + stack: &str, +) -> CliResult { + let change_data = load_change_data(repo, hash)?; + let data_len = change_data.len(); + let hash_str = hash.to_base32(); + + if data_len < DELTA_TRANSFER_THRESHOLD { + // FAST PATH: small change — send directly, no negotiation. + // This covers 99% of source code changes. + remote + .upload_change(&hash_str, stack, change_data) + .await + .map_err(|e| convert_remote_error(e, &remote.url().to_string()))?; + + Ok(PushTransferResult::direct(data_len as u64)) + } else { + // LARGE PATH: try delta transfer — negotiate chunks first. + // + // 1. Ask server for chunk manifest support + // 2. If supported: send our manifest, get back "need" list, send only missing chunks + // 3. If not supported: fall back to direct send + let manifest_result = remote.get_chunk_manifest(&hash_str).await; + + match manifest_result { + Ok(Some(_server_manifest)) => { + // Server supports manifests and returned one for a prior version + // of this content. But wait — we're pushing a NEW change, not + // updating an existing one. The manifest endpoint returns chunks + // for an existing change hash on the server. + // + // For delta to work, we'd need to: + // 1. Push our manifest (our chunk hashes) to the server + // 2. Server checks which of our chunks it already has in CONTENT_CHUNKS + // 3. Server tells us which chunks to send + // + // The current ?manifest endpoint returns the SERVER's manifest for + // a hash, not a negotiation. For now, fall back to direct send. + // The delta protocol needs a dedicated negotiation endpoint. + // + // TODO: Implement POST ?negotiate_chunks with our manifest body + // Server responds with { "need": [indices] } + remote + .upload_change(&hash_str, stack, change_data) + .await + .map_err(|e| convert_remote_error(e, &remote.url().to_string()))?; + + Ok(PushTransferResult::direct(data_len as u64)) + } + Ok(None) | Err(_) => { + // Server doesn't support manifests or error — direct send. + remote + .upload_change(&hash_str, stack, change_data) + .await + .map_err(|e| convert_remote_error(e, &remote.url().to_string()))?; + + Ok(PushTransferResult::direct(data_len as u64)) + } + } + } +} + +/// Format a byte count for display. +fn format_bytes(bytes: u64) -> String { + if bytes >= 1024 * 1024 { + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) + } else if bytes >= 1024 { + format!("{:.1} KB", bytes as f64 / 1024.0) + } else { + format!("{} B", bytes) + } +} + +// ============================================================================= +// Error Conversion +// ============================================================================= + +/// Convert a remote error to a CLI error. +/// +/// Maps the various remote error types to appropriate CLI error types +/// with user-friendly messages and suggestions. +/// +/// # Arguments +/// +/// * `err` - The remote error to convert +/// * `url` - The remote URL (for context in error messages) +/// +/// # Returns +/// +/// A `CliError` that can be displayed to the user. +pub fn convert_remote_error(err: RemoteError, url: &str) -> CliError { + match err { + RemoteError::ConnectionFailed { .. } => CliError::RemoteError { + message: format!("Failed to connect: {}", err), + url: Some(url.to_string()), + }, + RemoteError::AuthenticationFailed { .. } => CliError::AuthenticationFailed { + remote: url.to_string(), + }, + RemoteError::RepositoryNotFound { .. } => CliError::RemoteError { + message: "Repository not found on remote".to_string(), + url: Some(url.to_string()), + }, + RemoteError::StackNotFound { stack } => CliError::RemoteError { + message: format!("Stack '{}' not found on remote", stack), + url: Some(url.to_string()), + }, + RemoteError::ChangeNotFound { hash } => CliError::ChangeNotFound { hash }, + RemoteError::MissingDependencies { + count, + missing_hashes, + } => CliError::MissingDependency { + change: "uploaded change".to_string(), + dependency: if missing_hashes.is_empty() { + format!("{} dependencies", count) + } else { + missing_hashes.join(", ") + }, + }, + RemoteError::StateMismatch { + remote_state, + requested_state, + } => CliError::Conflict { + description: format!( + "State mismatch: remote is at {}, requested {}", + remote_state, requested_state + ), + }, + RemoteError::Timeout { seconds } => CliError::RemoteError { + message: format!("Request timed out after {} seconds", seconds), + url: Some(url.to_string()), + }, + _ => CliError::RemoteError { + message: err.to_string(), + url: Some(url.to_string()), + }, + } +} + +// ============================================================================= +// Display Helpers +// ============================================================================= + +/// Display the state comparison between local and remote. +/// +/// Shows the user a summary of where local and remote are at, +/// helping them understand what will be pushed. +/// +/// # Arguments +/// +/// * `local_stack` - Name of the local stack +/// * `local_entries` - Local history entries +/// * `remote_stack` - Name of the remote stack +/// * `remote_state` - Current state of the remote +/// * `remote_entries` - Remote changelist entries +pub fn display_state_comparison( + local_stack: &str, + local_entries: &[HistoryEntry], + remote_stack: &str, + remote_state: &StateResponse, + remote_entries: &[ChangelistEntry], +) { + let local_state_str = format_local_state(local_entries); + let remote_state_str = format_remote_state(remote_state, remote_entries); + + println!( + " Local: {} at {}", + style_stack(local_stack), + info(&local_state_str) + ); + println!( + " Remote: {} at {}", + style_stack(remote_stack), + info(&remote_state_str) + ); +} + +/// Format the local state for display. +fn format_local_state(entries: &[HistoryEntry]) -> String { + if let Some(entry) = entries.last() { + let short_state = &entry.state.to_base32()[..12]; + format!("{} ({} changes)", short_state, entries.len()) + } else { + "(empty)".to_string() + } +} + +/// Format the remote state for display. +fn format_remote_state(state: &StateResponse, entries: &[ChangelistEntry]) -> String { + if let Some(merkle_str) = state.merkle() { + let short_state = &merkle_str[..12.min(merkle_str.len())]; + format!("{} ({} changes)", short_state, entries.len()) + } else { + "(empty)".to_string() + } +} + +/// Format a count with singular/plural suffix. +/// +/// # Arguments +/// +/// * `count` - The count to format +/// * `singular` - The singular form of the word +/// +/// # Returns +/// +/// A formatted string like "1 change" or "5 changes". +/// +/// # Example +/// +/// ```rust,ignore +/// assert_eq!(format_count(1, "change"), "1 change"); +/// assert_eq!(format_count(5, "change"), "5 changes"); +/// ``` +pub fn format_count(count: usize, singular: &str) -> String { + if count == 1 { + format!("{} {}", count, singular) + } else { + format!("{} {}s", count, singular) + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + // ========================================================================= + // Delta Calculation Tests + // ========================================================================= + + #[test] + fn test_has_diverged_empty_remote() { + let local_entries = vec![ + create_test_entry(0, "ABC123"), + create_test_entry(1, "DEF456"), + ]; + let remote_entries: Vec = vec![]; + + assert!(!has_diverged(&local_entries, &remote_entries)); + } + + #[test] + fn test_has_diverged_subset() { + // Remote is a subset of local - no divergence + let local_entries = vec![ + create_test_entry(0, "ABC123"), + create_test_entry(1, "DEF456"), + create_test_entry(2, "GHI789"), + ]; + let remote_entries = vec![ + create_test_changelist_entry(0, "ABC123"), + create_test_changelist_entry(1, "DEF456"), + ]; + + assert!(!has_diverged(&local_entries, &remote_entries)); + } + + #[test] + fn test_has_diverged_different_changes() { + // Remote has a change not in local - diverged + let local_entries = vec![ + create_test_entry(0, "ABC123"), + create_test_entry(1, "DEF456"), + ]; + let remote_entries = vec![ + create_test_changelist_entry(0, "ABC123"), + create_test_changelist_entry(1, "XYZ999"), // Different! + ]; + + assert!(has_diverged(&local_entries, &remote_entries)); + } + + #[test] + fn test_has_diverged_identical() { + let local_entries = vec![ + create_test_entry(0, "ABC123"), + create_test_entry(1, "DEF456"), + ]; + let remote_entries = vec![ + create_test_changelist_entry(0, "ABC123"), + create_test_changelist_entry(1, "DEF456"), + ]; + + assert!(!has_diverged(&local_entries, &remote_entries)); + } + + // ========================================================================= + // Error Conversion Tests + // ========================================================================= + + #[test] + fn test_convert_connection_failed() { + let err = RemoteError::connection_failed( + "http://example.com", + std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused"), + ); + let cli_err = convert_remote_error(err, "http://example.com"); + + assert!(matches!(cli_err, CliError::RemoteError { .. })); + } + + #[test] + fn test_convert_auth_failed() { + let err = RemoteError::auth_failed("http://example.com", "invalid token"); + let cli_err = convert_remote_error(err, "http://example.com"); + + assert!(matches!(cli_err, CliError::AuthenticationFailed { .. })); + } + + #[test] + fn test_convert_repo_not_found() { + let err = RemoteError::repo_not_found("http://example.com/repo"); + let cli_err = convert_remote_error(err, "http://example.com/repo"); + + match cli_err { + CliError::RemoteError { message, .. } => { + assert!(message.contains("not found")); + } + _ => panic!("Expected RemoteError"), + } + } + + #[test] + fn test_convert_stack_not_found() { + let err = RemoteError::stack_not_found("main"); + let cli_err = convert_remote_error(err, "http://example.com"); + + match cli_err { + CliError::RemoteError { message, .. } => { + assert!(message.contains("main")); + assert!(message.contains("not found")); + } + _ => panic!("Expected RemoteError"), + } + } + + #[test] + fn test_convert_missing_deps() { + let err = RemoteError::missing_deps(vec!["ABC".to_string(), "DEF".to_string()]); + let cli_err = convert_remote_error(err, "http://example.com"); + + assert!(matches!(cli_err, CliError::MissingDependency { .. })); + } + + #[test] + fn test_convert_timeout() { + let err = RemoteError::timeout(30); + let cli_err = convert_remote_error(err, "http://example.com"); + + match cli_err { + CliError::RemoteError { message, .. } => { + assert!(message.contains("timed out")); + assert!(message.contains("30")); + } + _ => panic!("Expected RemoteError"), + } + } + + // ========================================================================= + // Format Count Tests + // ========================================================================= + + #[test] + fn test_format_count_zero() { + assert_eq!(format_count(0, "change"), "0 changes"); + } + + #[test] + fn test_format_count_one() { + assert_eq!(format_count(1, "change"), "1 change"); + } + + #[test] + fn test_format_count_many() { + assert_eq!(format_count(5, "change"), "5 changes"); + assert_eq!(format_count(100, "file"), "100 files"); + } + + #[test] + fn test_format_count_different_words() { + assert_eq!(format_count(1, "tag"), "1 tag"); + assert_eq!(format_count(2, "tag"), "2 tags"); + assert_eq!(format_count(1, "warning"), "1 warning"); + assert_eq!(format_count(3, "warning"), "3 warnings"); + } + + // ========================================================================= + // Display Helper Tests + // ========================================================================= + + #[test] + fn test_format_local_state_empty() { + let entries: Vec = vec![]; + assert_eq!(format_local_state(&entries), "(empty)"); + } + + #[test] + fn test_format_local_state_with_entries() { + let entries = vec![ + create_test_entry(0, "ABC123"), + create_test_entry(1, "DEF456"), + ]; + let result = format_local_state(&entries); + + assert!(result.contains("2 changes")); + } + + #[test] + fn test_format_remote_state_empty() { + let state = StateResponse::empty(); + let entries: Vec = vec![]; + + assert_eq!(format_remote_state(&state, &entries), "(empty)"); + } + + // ========================================================================= + // Test Helpers + // ========================================================================= + + /// Create a test history entry with a deterministic hash. + /// The hash is created from the hash_str so that when compared with + /// a ChangelistEntry using the same string, they will match. + fn create_test_entry(sequence: u64, hash_str: &str) -> HistoryEntry { + // Create a hash that will produce the expected base32 string + // For testing, we use a hash derived from the string itself + let hash = Hash::of(hash_str.as_bytes()); + HistoryEntry { + sequence, + hash, + state: Merkle::ZERO, + node_id: NodeId::from(sequence), + header: None, + is_tagged: false, + } + } + + /// Create a test changelist entry. + /// The hash should be the base32 encoding of the same hash used in create_test_entry. + fn create_test_changelist_entry(sequence: u64, hash_str: &str) -> ChangelistEntry { + // Use the same hash derivation as create_test_entry + let hash = Hash::of(hash_str.as_bytes()); + let hash_base32 = hash.to_base32(); + ChangelistEntry::new( + sequence, + &hash_base32, + "ABCD1234ABCD1234ABCD1234ABCD1234", + false, + ) + } +} diff --git a/atomic-cli/tests/fixtures/helpers_c6.rs b/atomic-cli/tests/fixtures/helpers_c6.rs new file mode 100644 index 00000000..64774d94 --- /dev/null +++ b/atomic-cli/tests/fixtures/helpers_c6.rs @@ -0,0 +1,726 @@ +//! Helper functions for the push command. +//! +//! # Delta Transfer Protocol +//! +//! The push command uses a threshold-gated delta transfer protocol: +//! +//! - **Small changes (< 1 MB)**: Sent directly as a single HTTP POST. +//! No manifest exchange, no chunk negotiation. This covers 99% of +//! source code changes where each change is already a small delta. +//! +//! - **Large changes (≥ 1 MB)**: Negotiate chunks first. The client sends +//! a chunk manifest (list of content chunk hashes + sizes), the server +//! responds with which chunks it already has, and the client sends only +//! the missing chunks + metadata. This saves significant bandwidth for +//! large binary files or initial records where the server has a prior version. +//! +//! The threshold is configurable via [`DELTA_TRANSFER_THRESHOLD`]. +//! +//! This module provides utility functions used by the push command, including: +//! +//! - Delta calculation between local and remote changelists +//! - Error conversion from remote errors to CLI errors +//! - State comparison display +//! - Change data loading and formatting + +use std::collections::HashSet; + +use bytes::Bytes; + +use atomic_core::types::{Base32, Hash}; +use atomic_remote::{ChangelistEntry, RemoteError, StateResponse}; +use atomic_repository::history::HistoryEntry; +use atomic_repository::Repository; + +use crate::error::{CliError, CliResult}; +use crate::output::{info, stack as style_stack}; + +use super::types::PushChange; + +// Delta Calculation + +/// Calculate which changes need to be pushed to the remote. +/// +/// Compares local and remote changelists to determine which local +/// changes are missing from the remote. Changes are returned in +/// dependency order (earliest first) so they can be uploaded correctly. +/// +/// # Arguments +/// +/// * `repo` - The local repository (for loading change metadata) +/// * `local_entries` - Local history entries in forward order +/// * `remote_entries` - Remote changelist entries +/// * `push_all` - Whether to push all changes regardless of remote state +/// +/// # Returns +/// +/// A vector of changes that should be uploaded to the remote. +/// +/// # Example +/// +/// ```rust,ignore +/// let graph_hashes = std::collections::HashSet::new(); +/// let to_push = calculate_push_delta(&repo, &local_history, &remote_list, &graph_hashes, false)?; +/// println!("Need to push {} changes", to_push.len()); +/// ``` +pub fn calculate_push_delta( + repo: &Repository, + local_entries: &[HistoryEntry], + remote_entries: &[ChangelistEntry], + graph_hashes: &HashSet, + push_all: bool, +) -> CliResult> { + // Build set of remote hashes for quick lookup (changes already in this stack) + let remote_hashes: HashSet = remote_entries.iter().map(|e| e.hash.clone()).collect(); + + let mut to_upload = Vec::new(); + + for entry in local_entries { + let hash_str = entry.hash.to_base32(); + + // Skip if already on this stack on the remote (unless pushing all) + if !push_all && remote_hashes.contains(&hash_str) { + continue; + } + + // Try to load the change header to get the message + let message = load_change_message(repo, &entry.hash); + + // Check if the change is already in the remote graph (via another stack). + // If so, only stack adoption is needed — no data transfer. + let already_in_graph = graph_hashes.contains(&hash_str); + + let push_change = PushChange::new(entry.hash, entry.sequence, entry.state) + .with_tagged(entry.is_tagged) + .with_in_graph(already_in_graph); + + let push_change = if let Some(msg) = message { + push_change.with_message(msg) + } else { + push_change + }; + + to_upload.push(push_change); + } + + Ok(to_upload) +} + +/// Load the message from a change, returning None if it fails. +fn load_change_message(repo: &Repository, hash: &Hash) -> Option { + repo.load_change(hash) + .ok() + .map(|c| c.hashed.header.message.clone()) +} + +/// Check if local and remote histories have diverged. +/// +/// Returns true if the remote has changes that are not in the local history. +/// This indicates a conflict that needs to be resolved before pushing. +/// +/// # Arguments +/// +/// * `local_entries` - Local history entries +/// * `remote_entries` - Remote changelist entries +/// +/// # Returns +/// +/// `true` if the remote has changes not present locally. +/// +/// # Example +/// +/// ```rust,ignore +/// if has_diverged(&local_history, &remote_list) { +/// println!("Histories have diverged - need to pull first"); +/// } +/// ``` +pub fn has_diverged(local_entries: &[HistoryEntry], remote_entries: &[ChangelistEntry]) -> bool { + // Empty remote can't have diverged + if remote_entries.is_empty() { + return false; + } + + // Build set of local hashes + let local_hashes: HashSet = local_entries.iter().map(|e| e.hash.to_base32()).collect(); + + // Check if any remote change is not in local + remote_entries + .iter() + .any(|e| !local_hashes.contains(&e.hash)) +} + +// Change Data Loading + +/// Load and serialize change data from the repository. +/// +/// Loads the change from the repository and serializes it to bytes +/// suitable for uploading to a remote. +/// +/// # Arguments +/// +/// * `repo` - The repository to load from +/// * `hash` - The hash of the change to load +/// +/// # Returns +/// +/// The serialized change data as bytes. +/// +/// # Errors +/// +/// Returns `CliError::ChangeNotFound` if the change doesn't exist, +/// or `CliError::Internal` if serialization fails. +pub fn load_change_data(repo: &Repository, hash: &Hash) -> CliResult { + // Load the change + let change = repo + .load_change(hash) + .map_err(|_| CliError::ChangeNotFound { + hash: hash.to_base32(), + })?; + + // Serialize to bytes + let mut buffer = Vec::new(); + change + .serialize(&mut buffer) + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to serialize change: {}", e)))?; + + Ok(Bytes::from(buffer)) +} + +// Delta Transfer Protocol + +/// Size threshold (in bytes) for delta transfer negotiation. +/// +/// Changes smaller than this are sent directly — the overhead of a manifest +/// exchange round trip (two HTTP requests) exceeds any bandwidth savings for +/// small changes. For source code changes, this covers 99% of cases. +/// +/// Changes larger than this trigger the delta protocol: +/// 1. Client sends chunk manifest to server +/// 2. Server responds with which chunks it already has +/// 3. Client sends only missing chunks + metadata +/// +/// The 1 MB threshold was chosen because: +/// - A typical source code change is 200 bytes – 50 KB (well below) +/// - A manifest exchange adds ~100ms latency (two round trips) +/// - Below 1 MB, sending the full file on a 100 Mbps link takes <80ms +/// - Above 1 MB, delta savings can be 90%+ (worth the negotiation cost) +pub const DELTA_TRANSFER_THRESHOLD: usize = 1024 * 1024; // 1 MB + +/// Result of a push operation for a single change. +/// +/// Describes whether the change was sent directly (fast path) or via +/// delta transfer (large path), and the bytes transferred. +#[derive(Debug)] +pub struct PushTransferResult { + /// Whether delta transfer was used. + pub used_delta: bool, + + /// Total bytes sent over the wire. + pub bytes_sent: u64, + + /// Bytes saved by delta transfer (0 if direct send). + pub bytes_saved: u64, + + /// Number of content chunks the server already had (0 if direct send). + pub chunks_reused: u32, + + /// Total content chunks in the change (0 if direct send). + pub chunks_total: u32, +} + +impl PushTransferResult { + /// Create a result for a direct (non-delta) send. + pub fn direct(bytes_sent: u64) -> Self { + Self { + used_delta: false, + bytes_sent, + bytes_saved: 0, + chunks_reused: 0, + chunks_total: 0, + } + } + + /// Percentage of bytes saved (0.0–100.0). + pub fn savings_pct(&self) -> f64 { + let total = self.bytes_sent + self.bytes_saved; + if total == 0 { + return 0.0; + } + self.bytes_saved as f64 / total as f64 * 100.0 + } +} + +impl std::fmt::Display for PushTransferResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.used_delta { + write!( + f, + "delta: sent {} ({} saved, {}/{} chunks reused, {:.0}% savings)", + format_bytes(self.bytes_sent), + format_bytes(self.bytes_saved), + self.chunks_reused, + self.chunks_total, + self.savings_pct(), + ) + } else { + write!(f, "direct: sent {}", format_bytes(self.bytes_sent)) + } + } +} + +/// Upload a single change using the threshold-gated delta transfer protocol. +/// +/// - Changes smaller than [`DELTA_TRANSFER_THRESHOLD`]: sent directly (fast path) +/// - Changes larger: manifest exchange → send only missing chunks (delta path) +/// +/// # Arguments +/// +/// * `remote` - The HTTP remote to push to. +/// * `repo` - The local repository. +/// * `hash` - The change hash. +/// * `stack` - The target stack name on the remote. +/// +/// # Returns +/// +/// A [`PushTransferResult`] describing the transfer, or an error. +pub async fn upload_change_smart( + remote: &atomic_remote::HttpRemote, + repo: &Repository, + hash: &Hash, + stack: &str, +) -> CliResult { + let change_data = load_change_data(repo, hash)?; + let data_len = change_data.len(); + let hash_str = hash.to_base32(); + + if data_len < DELTA_TRANSFER_THRESHOLD { + // FAST PATH: small change — send directly, no negotiation. + // This covers 99% of source code changes. + remote + .upload_change(&hash_str, stack, change_data) + .await + .map_err(|e| convert_remote_error(e, &remote.url().to_string()))?; + + Ok(PushTransferResult::direct(data_len as u64)) + } else { + // LARGE PATH: try delta transfer — negotiate chunks first. + // + // 1. Ask server for chunk manifest support + // 2. If supported: send our manifest, get back "need" list, send only missing chunks + // 3. If not supported: fall back to direct send + let manifest_result = remote.get_chunk_manifest(&hash_str).await; + + match manifest_result { + Ok(Some(_server_manifest)) => { + // Server supports manifests and returned one for a prior version + // of this content. But wait — we're pushing a NEW change, not + // updating an existing one. The manifest endpoint returns chunks + // for an existing change hash on the server. + // + // For delta to work, we'd need to: + // 1. Push our manifest (our chunk hashes) to the server + // 2. Server checks which of our chunks it already has in CONTENT_CHUNKS + // 3. Server tells us which chunks to send + // + // The current ?manifest endpoint returns the SERVER's manifest for + // a hash, not a negotiation. For now, fall back to direct send. + // The delta protocol needs a dedicated negotiation endpoint. + // + // TODO: Implement POST ?negotiate_chunks with our manifest body + // Server responds with { "need": [indices] } + remote + .upload_change(&hash_str, stack, change_data) + .await + .map_err(|e| convert_remote_error(e, &remote.url().to_string()))?; + + Ok(PushTransferResult::direct(data_len as u64)) + } + Ok(None) | Err(_) => { + // Server doesn't support manifests or error — direct send. + remote + .upload_change(&hash_str, stack, change_data) + .await + .map_err(|e| convert_remote_error(e, &remote.url().to_string()))?; + + Ok(PushTransferResult::direct(data_len as u64)) + } + } + } +} + +/// Format a byte count for display. +fn format_bytes(bytes: u64) -> String { + if bytes >= 1024 * 1024 { + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) + } else if bytes >= 1024 { + format!("{:.1} KB", bytes as f64 / 1024.0) + } else { + format!("{} B", bytes) + } +} + +// Error Conversion + +/// Convert a remote error to a CLI error. +/// +/// Maps the various remote error types to appropriate CLI error types +/// with user-friendly messages and suggestions. +/// +/// # Arguments +/// +/// * `err` - The remote error to convert +/// * `url` - The remote URL (for context in error messages) +/// +/// # Returns +/// +/// A `CliError` that can be displayed to the user. +pub fn convert_remote_error(err: RemoteError, url: &str) -> CliError { + match err { + RemoteError::ConnectionFailed { .. } => CliError::RemoteError { + message: format!("Failed to connect: {}", err), + url: Some(url.to_string()), + }, + RemoteError::AuthenticationFailed { .. } => CliError::AuthenticationFailed { + remote: url.to_string(), + }, + RemoteError::RepositoryNotFound { .. } => CliError::RemoteError { + message: "Repository not found on remote".to_string(), + url: Some(url.to_string()), + }, + RemoteError::StackNotFound { stack } => CliError::RemoteError { + message: format!("Stack '{}' not found on remote", stack), + url: Some(url.to_string()), + }, + RemoteError::ChangeNotFound { hash } => CliError::ChangeNotFound { hash }, + RemoteError::MissingDependencies { + count, + missing_hashes, + } => CliError::MissingDependency { + change: "uploaded change".to_string(), + dependency: if missing_hashes.is_empty() { + format!("{} dependencies", count) + } else { + missing_hashes.join(", ") + }, + }, + RemoteError::StateMismatch { + remote_state, + requested_state, + } => CliError::Conflict { + description: format!( + "State mismatch: remote is at {}, requested {}", + remote_state, requested_state + ), + }, + RemoteError::Timeout { seconds } => CliError::RemoteError { + message: format!("Request timed out after {} seconds", seconds), + url: Some(url.to_string()), + }, + _ => CliError::RemoteError { + message: err.to_string(), + url: Some(url.to_string()), + }, + } +} + +// Display Helpers + +/// Display the state comparison between local and remote. +/// +/// Shows the user a summary of where local and remote are at, +/// helping them understand what will be pushed. +/// +/// # Arguments +/// +/// * `local_stack` - Name of the local stack +/// * `local_entries` - Local history entries +/// * `remote_stack` - Name of the remote stack +/// * `remote_state` - Current state of the remote +/// * `remote_entries` - Remote changelist entries +pub fn display_state_comparison( + local_stack: &str, + local_entries: &[HistoryEntry], + remote_stack: &str, + remote_state: &StateResponse, + remote_entries: &[ChangelistEntry], +) { + let local_state_str = format_local_state(local_entries); + let remote_state_str = format_remote_state(remote_state, remote_entries); + + println!( + " Local: {} at {}", + style_stack(local_stack), + info(&local_state_str) + ); + println!( + " Remote: {} at {}", + style_stack(remote_stack), + info(&remote_state_str) + ); +} + +/// Format the local state for display. +fn format_local_state(entries: &[HistoryEntry]) -> String { + if let Some(entry) = entries.last() { + let short_state = &entry.state.to_base32()[..12]; + format!("{} ({} changes)", short_state, entries.len()) + } else { + "(empty)".to_string() + } +} + +/// Format the remote state for display. +fn format_remote_state(state: &StateResponse, entries: &[ChangelistEntry]) -> String { + if let Some(merkle_str) = state.merkle() { + let short_state = &merkle_str[..12.min(merkle_str.len())]; + format!("{} ({} changes)", short_state, entries.len()) + } else { + "(empty)".to_string() + } +} + +/// Format a count with singular/plural suffix. +/// +/// # Arguments +/// +/// * `count` - The count to format +/// * `singular` - The singular form of the word +/// +/// # Returns +/// +/// A formatted string like "1 change" or "5 changes". +/// +/// # Example +/// +/// ```rust,ignore +/// assert_eq!(format_count(1, "change"), "1 change"); +/// assert_eq!(format_count(5, "change"), "5 changes"); +/// ``` +pub fn format_count(count: usize, singular: &str) -> String { + if count == 1 { + format!("{} {}", count, singular) + } else { + format!("{} {}s", count, singular) + } +} + +// Tests + +#[cfg(test)] +mod tests { + use super::*; + + // Delta Calculation Tests + + #[test] + fn test_has_diverged_empty_remote() { + let local_entries = vec![ + create_test_entry(0, "ABC123"), + create_test_entry(1, "DEF456"), + ]; + let remote_entries: Vec = vec![]; + + assert!(!has_diverged(&local_entries, &remote_entries)); + } + + #[test] + fn test_has_diverged_subset() { + // Remote is a subset of local - no divergence + let local_entries = vec![ + create_test_entry(0, "ABC123"), + create_test_entry(1, "DEF456"), + create_test_entry(2, "GHI789"), + ]; + let remote_entries = vec![ + create_test_changelist_entry(0, "ABC123"), + create_test_changelist_entry(1, "DEF456"), + ]; + + assert!(!has_diverged(&local_entries, &remote_entries)); + } + + #[test] + fn test_has_diverged_different_changes() { + // Remote has a change not in local - diverged + let local_entries = vec![ + create_test_entry(0, "ABC123"), + create_test_entry(1, "DEF456"), + ]; + let remote_entries = vec![ + create_test_changelist_entry(0, "ABC123"), + create_test_changelist_entry(1, "XYZ999"), // Different! + ]; + + assert!(has_diverged(&local_entries, &remote_entries)); + } + + #[test] + fn test_has_diverged_identical() { + let local_entries = vec![ + create_test_entry(0, "ABC123"), + create_test_entry(1, "DEF456"), + ]; + let remote_entries = vec![ + create_test_changelist_entry(0, "ABC123"), + create_test_changelist_entry(1, "DEF456"), + ]; + + assert!(!has_diverged(&local_entries, &remote_entries)); + } + + // Error Conversion Tests + + #[test] + fn test_convert_connection_failed() { + let err = RemoteError::connection_failed( + "http://example.com", + std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused"), + ); + let cli_err = convert_remote_error(err, "http://example.com"); + + assert!(matches!(cli_err, CliError::RemoteError { .. })); + } + + #[test] + fn test_convert_auth_failed() { + let err = RemoteError::auth_failed("http://example.com", "invalid token"); + let cli_err = convert_remote_error(err, "http://example.com"); + + assert!(matches!(cli_err, CliError::AuthenticationFailed { .. })); + } + + #[test] + fn test_convert_repo_not_found() { + let err = RemoteError::repo_not_found("http://example.com/repo"); + let cli_err = convert_remote_error(err, "http://example.com/repo"); + + match cli_err { + CliError::RemoteError { message, .. } => { + assert!(message.contains("not found")); + } + _ => panic!("Expected RemoteError"), + } + } + + #[test] + fn test_convert_stack_not_found() { + let err = RemoteError::stack_not_found("main"); + let cli_err = convert_remote_error(err, "http://example.com"); + + match cli_err { + CliError::RemoteError { message, .. } => { + assert!(message.contains("main")); + assert!(message.contains("not found")); + } + _ => panic!("Expected RemoteError"), + } + } + + #[test] + fn test_convert_missing_deps() { + let err = RemoteError::missing_deps(vec!["ABC".to_string(), "DEF".to_string()]); + let cli_err = convert_remote_error(err, "http://example.com"); + + assert!(matches!(cli_err, CliError::MissingDependency { .. })); + } + + #[test] + fn test_convert_timeout() { + let err = RemoteError::timeout(30); + let cli_err = convert_remote_error(err, "http://example.com"); + + match cli_err { + CliError::RemoteError { message, .. } => { + assert!(message.contains("timed out")); + assert!(message.contains("30")); + } + _ => panic!("Expected RemoteError"), + } + } + + // Format Count Tests + + #[test] + fn test_format_count_zero() { + assert_eq!(format_count(0, "change"), "0 changes"); + } + + #[test] + fn test_format_count_one() { + assert_eq!(format_count(1, "change"), "1 change"); + } + + #[test] + fn test_format_count_many() { + assert_eq!(format_count(5, "change"), "5 changes"); + assert_eq!(format_count(100, "file"), "100 files"); + } + + #[test] + fn test_format_count_different_words() { + assert_eq!(format_count(1, "tag"), "1 tag"); + assert_eq!(format_count(2, "tag"), "2 tags"); + assert_eq!(format_count(1, "warning"), "1 warning"); + assert_eq!(format_count(3, "warning"), "3 warnings"); + } + + // Display Helper Tests + + #[test] + fn test_format_local_state_empty() { + let entries: Vec = vec![]; + assert_eq!(format_local_state(&entries), "(empty)"); + } + + #[test] + fn test_format_local_state_with_entries() { + let entries = vec![ + create_test_entry(0, "ABC123"), + create_test_entry(1, "DEF456"), + ]; + let result = format_local_state(&entries); + + assert!(result.contains("2 changes")); + } + + #[test] + fn test_format_remote_state_empty() { + let state = StateResponse::empty(); + let entries: Vec = vec![]; + + assert_eq!(format_remote_state(&state, &entries), "(empty)"); + } + + // Test Helpers + + /// Create a test history entry with a deterministic hash. + /// The hash is created from the hash_str so that when compared with + /// a ChangelistEntry using the same string, they will match. + fn create_test_entry(sequence: u64, hash_str: &str) -> HistoryEntry { + // Create a hash that will produce the expected base32 string + // For testing, we use a hash derived from the string itself + let hash = Hash::of(hash_str.as_bytes()); + HistoryEntry { + sequence, + hash, + state: Merkle::ZERO, + node_id: NodeId::from(sequence), + header: None, + is_tagged: false, + } + } + + /// Create a test changelist entry. + /// The hash should be the base32 encoding of the same hash used in create_test_entry. + fn create_test_changelist_entry(sequence: u64, hash_str: &str) -> ChangelistEntry { + // Use the same hash derivation as create_test_entry + let hash = Hash::of(hash_str.as_bytes()); + let hash_base32 = hash.to_base32(); + ChangelistEntry::new( + sequence, + &hash_base32, + "ABCD1234ABCD1234ABCD1234ABCD1234", + false, + ) + } +} diff --git a/atomic-cli/tests/fixtures/readme_new.md b/atomic-cli/tests/fixtures/readme_new.md new file mode 100644 index 00000000..696cf59a --- /dev/null +++ b/atomic-cli/tests/fixtures/readme_new.md @@ -0,0 +1,354 @@ +# opencode-atomic-hooks + +Atomic VCS provenance tracking plugin for [OpenCode](https://opencode.ai) — turns every AI agent turn into a content-addressed change with full causal decision graphs. + +Translates OpenCode lifecycle events into `atomic agent hooks opencode ` CLI calls — the same pattern used by Claude Code and Gemini CLI. + +## Install + +Add the plugin to your `opencode.jsonc`: + +```jsonc +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["opencode-atomic-hooks"] +} +``` + +### Prerequisites + +- [Atomic VCS](https://github.com/atomicdotdev/atomic) CLI installed and in your PATH +- An initialized Atomic repository (`.atomic/` directory exists) +- [OpenCode](https://opencode.ai) with plugin support enabled + +### Activate + +No additional configuration beyond the plugin entry. When OpenCode starts, the plugin checks for an Atomic repository (`.atomic/` directory) and activates automatically: + +``` +[atomic-hooks] [info] Atomic hooks plugin activated +``` + +If the project is not an Atomic repository or the `atomic` CLI is not in PATH, the plugin silently deactivates — zero overhead. + +### Disable + +Remove the plugin entry from `opencode.jsonc`: + +```jsonc +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [] +} +``` + +## Developer Workflow + +1. Edit `src/index.ts` in the hooks package +2. Run `bun run build` in `atomic/packages/opencode-atomic-hooks/` +3. Restart OpenCode — it picks up the new build automatically via the symlink +4. Check `.atomic/plugin-debug.log` in whatever project you're running + +## How It Works + +``` +User prompts AI in OpenCode + │ + ▼ +OpenCode fires lifecycle events + │ + ▼ +opencode-atomic-hooks plugin ← loaded from npm + │ + ├── handlers/event.ts → session-start, stop, session-end + ├── handlers/chat.ts → user-prompt + ├── handlers/tool.ts → before-tool, after-tool + ├── handlers/shell.ts → ATOMIC_AGENT=opencode + └── handlers/compaction.ts → provenance graph summary + │ + ▼ +atomic agent hooks opencode ← JSON piped to stdin + │ + ▼ +atomic-agent/src/hooks/opencode.rs ← parse_event() + │ + ▼ +TurnOrchestrator::dispatch() ← state machine → record change + + provenance graph accumulation +``` + +Each completed turn becomes a single Atomic change with full provenance — model, provider, tokens, cost, session ID, and turn number — all embedded in the change itself. + +## Event Mapping + +| OpenCode Hook | Atomic Verb | Rust HookType | Handler Module | +|---------------------------|-----------------|---------------|-------------------------| +| `event` (session.created) | `session-start` | SessionStart | `handlers/event.ts` | +| `chat.message` | `user-prompt` | TurnStart | `handlers/chat.ts` | +| `event` (session.idle) | `stop` | TurnEnd | `handlers/event.ts` | +| `event` (session.deleted) | `session-end` | SessionEnd | `handlers/event.ts` | +| `tool.execute.before` | `before-tool` | PreToolUse | `handlers/tool.ts` | +| `tool.execute.after` | `after-tool` | PostToolUse | `handlers/tool.ts` | +| `shell.env` | *(env vars)* | — | `handlers/shell.ts` | +| `experimental.session.compacting` | *(graph summary)* | — | `handlers/compaction.ts` | + +## Module Architecture + +``` +src/ +├── index.ts ← Plugin entry point + barrel exports +├── types.ts ← TypeScript interfaces & type aliases +├── constants.ts ← Configuration constants & defaults +├── cli.ts ← Atomic CLI invocation layer +├── session.ts ← In-memory session state management +├── log.ts ← Structured logging wrapper +├── handlers/ +│ ├── index.ts ← Handler barrel exports +│ ├── event.ts ← Event bus → session lifecycle hooks +│ ├── chat.ts ← Chat message → TurnStart hook +│ ├── tool.ts ← Tool execution → PreToolUse/PostToolUse +│ ├── shell.ts ← Shell env variable injection +│ └── compaction.ts ← Provenance graph → compaction context +└── __tests__/ + ├── session.test.ts ← Session store unit tests + ├── handlers.test.ts ← Handler helper unit tests + └── compaction.test.ts ← Compaction handler unit tests +``` + +### Module Responsibilities + +| Module | Purpose | Key Exports | +|----------------------------|------------------------------------------------------------------------------|----------------------------------------------------------------------------------| +| **index.ts** | Plugin entry point — gates on Atomic repo, wires deps, returns hooks object | `AtomicHooksPlugin`, plus all barrel re-exports | +| **types.ts** | All shared TypeScript interfaces — hook payloads, session state, CLI results | `HookVerb`, `SessionState`, `HookResult`, `AtomicHooksConfig`, payload types | +| **constants.ts** | Compile-time configuration — CLI binary name, hook args, edit tool set | `ATOMIC_CMD`, `HOOK_ARGS`, `EDIT_TOOLS`, `MAX_TOOL_OUTPUT_LENGTH` | +| **cli.ts** | Encapsulates all Atomic CLI interaction — pipes JSON to stdin | `invokeHook()`, `isAtomicAvailable()`, `basePayload()` | +| **session.ts** | In-memory session state — turn counts, model/provider, tool durations | `createSessionStore()`, `SessionStore` interface | +| **log.ts** | Structured logging via OpenCode SDK `client.app.log()` with console fallback | `createLogger()`, `Logger` interface | +| **handlers/event.ts** | Routes event bus events to session lifecycle hooks | `createEventHandler()` | +| **handlers/chat.ts** | Captures prompt text and model info on user messages | `createChatHandler()`, `extractPrompt()` | +| **handlers/tool.ts** | Tracks tool timing, classifies file mutations, truncates output | `createBeforeToolHandler()`, `createAfterToolHandler()`, `isEditTool()` | +| **handlers/shell.ts** | Injects `ATOMIC_AGENT` and `ATOMIC_AGENT_VERSION` into shell env | `createShellHandler()` | +| **handlers/compaction.ts** | Reads provenance graph from disk, injects summary into compaction context | `createCompactionHandler()` | + +### Dependency Graph + +No circular dependencies. Each module imports only from modules "below" it: + +``` +index.ts → cli, session, log, handlers/* +handlers/* → cli, session, log, constants, types +handlers/compaction → log (reads graph.json from disk, no CLI calls) +cli → constants, types +session → types +log → constants, types +constants → types +types → (nothing) +``` + +## Design Principles + +### 1. Best-Effort Recording + +CLI failures are **logged, never thrown**. The AI session must never be blocked by a provenance recording failure. Every `invokeHook()` call returns a `HookResult` with timing and error details — but never throws. + +### 2. Dependency Injection + +Every handler is a factory function that accepts a `Deps` object: + +```typescript +const deps = { $, store, log, directory } + +return { + event: createEventHandler(deps), + "chat.message": createChatHandler(deps), + "tool.execute.before": createBeforeToolHandler(deps), + "tool.execute.after": createAfterToolHandler(deps), + "shell.env": createShellHandler(), +} +``` + +This makes every handler fully testable — pass in mocks for the shell and logger without touching the filesystem or PATH. + +### 3. Separation of Concerns + +- **types.ts** — What data looks like (zero runtime code) +- **constants.ts** — What values are (zero logic) +- **cli.ts** — How we talk to the Atomic binary +- **session.ts** — What we remember between events +- **handlers/** — How we react to each event +- **log.ts** — How we report what happened + +### 4. Ephemeral State + +The `SessionStore` is in-memory only. If OpenCode restarts, all session state is lost. This is intentional — the Atomic CLI maintains its own durable session state in `.atomic/sessions/`. The plugin's state is just a correlation buffer to enrich hook payloads with timing and context. + +## Usage + +Once installed, the plugin activates automatically when OpenCode starts in an Atomic repository. Every turn is recorded: + +```bash +# See what was recorded +atomic log + +# Check session status +atomic agent status --verbose + +# Rewind to a previous turn +atomic unrecord +``` + +## Testing + +Run the unit test suite with Bun: + +```bash +bun test + +# Watch mode for development +bun test --watch + +# Run a specific test file +bun test src/__tests__/session.test.ts +bun test src/__tests__/handlers.test.ts +bun test src/__tests__/compaction.test.ts +``` + +### Test Coverage + +| Test File | Module | Tests | Covers | +|-----------------------|-------------------------|-------|--------------------------------------------------------------------------| +| `session.test.ts` | `session.ts` | ~40 | Store CRUD, turn counting, model/provider, tool duration, edge cases | +| `handlers.test.ts` | `handlers/*` | ~60 | `extractPrompt`, `isEditTool`, `truncateOutput`, `createShellHandler` | +| `compaction.test.ts` | `handlers/compaction.ts`| ~22 | Graph reading, summary formatting, missing/corrupt files, token budget | + +### Adding Tests + +All tests live in `src/__tests__/` and use [Bun's built-in test runner](https://bun.sh/docs/cli/test): + +```typescript +import { describe, test, expect, beforeEach } from "bun:test" +import { createSessionStore } from "../session" + +describe("myFeature", () => { + test("does the thing", () => { + const store = createSessionStore() + store.incrementTurn("s1") + expect(store.get("s1").turnCount).toBe(1) + }) +}) +``` + +## Configuration + +All configuration is currently compile-time constants in `constants.ts`. Environment variable overrides: + +| Variable | Default | Purpose | +|----------------|----------|--------------------------------------| +| `ATOMIC_CMD` | `atomic` | Atomic CLI binary name or path | +| `ATOMIC_DEBUG` | `0` | Set to `1` to enable debug logging | + +## Comparison with Other Agents + +This plugin follows the exact same hook pattern as Claude Code and Gemini CLI: + +| Aspect | Claude Code | Gemini CLI | OpenCode | +|-----------------|----------------------------------|----------------------------------|---------------------------------------| +| Hook system | Native `.claude/settings.json` | Native `.gemini/settings.json` | `opencode-atomic-hooks` npm package | +| Installation | Modify JSON config | Modify JSON config | Add to `opencode.jsonc` plugin array | +| Invocation | Agent calls CLI directly | Agent calls CLI directly | Plugin calls CLI | +| Turn boundary | `stop` event | `AfterAgent` event | `session.idle` event | +| Prompt capture | `UserPromptSubmit` | `BeforeAgent` | `chat.message` hook | +| Rust adapter | `hooks/claude_code.rs` | `hooks/gemini_cli.rs` | `hooks/opencode.rs` | + +OpenCode uses a **plugin package** approach rather than modifying a settings JSON file. This means zero modifications to the OpenCode codebase — the plugin is a self-contained TypeScript module published to npm that uses the public plugin API. + +## Package Structure + +``` +opencode-atomic-hooks/ +├── package.json ← npm metadata, peer dep on @opencode-ai/plugin +├── tsconfig.json ← declaration output for types +├── README.md +├── src/ +│ ├── index.ts ← plugin entry + barrel exports +│ ├── types.ts ← TypeScript interfaces +│ ├── constants.ts ← configuration constants +│ ├── cli.ts ← Atomic CLI invocation +│ ├── session.ts ← in-memory session state +│ ├── log.ts ← structured logging +│ ├── handlers/ +│ │ ├── index.ts ← handler barrel exports +│ │ ├── event.ts ← session lifecycle +│ │ ├── chat.ts ← user prompt capture +│ │ ├── tool.ts ← tool before/after +│ │ ├── shell.ts ← env injection +│ │ └── compaction.ts ← provenance graph → compaction context +│ └── __tests__/ +│ ├── session.test.ts +│ ├── handlers.test.ts +│ └── compaction.test.ts +└── dist/ ← build output (bun build + tsc) + ├── index.js ← 16.5KB single bundle + └── *.d.ts ← type declarations +``` + +### Build + +```bash +bun run build +``` + +Produces a single ESM bundle at `dist/index.js` via `bun build` (all 11 modules +inlined) plus TypeScript declaration files via `tsc --emitDeclarationOnly`. The +bundle targets Bun's runtime since OpenCode uses Bun. + +### Publish + +```bash +bun run build +npm publish +``` + +Users add it to their `opencode.jsonc`: + +```jsonc +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["opencode-atomic-hooks"] +} +``` + +OpenCode's plugin loader installs it via `bun install`, imports the default +export, and calls the plugin function with the standard `PluginInput` context. +Same path as every plugin on the +[awesome-opencode](https://github.com/awesome-opencode/awesome-opencode) list. + +### Local Development + +For development within the opencode monorepo, a shim at +`.opencode/plugins/atomic.ts` re-exports from the workspace package: + +```typescript +export { AtomicHooksPlugin } from "opencode-atomic-hooks" +``` + +This file matches the plugin discovery glob (`plugins/*.ts`) so OpenCode loads +it during local development without publishing to npm. + +## Provenance Graph + +This plugin includes a **compaction handler** that reads the provenance graph +built by the Rust-side `TurnOrchestrator` and injects a structured summary +into OpenCode's compaction context. This means the LLM retains knowledge of +what was explored, decided, committed, and verified — even after the +conversation is compacted to fit the context window. + +The provenance graph itself is built and stored entirely on the Rust side. +The plugin only reads it from `.atomic/sessions/{id}/graph.json` during +compaction — no graph accumulation, no classification, no state management +in TypeScript. + +## License + +Dual-licensed under MIT and Apache 2.0, consistent with the Atomic VCS project. diff --git a/atomic-cli/tests/fixtures/readme_old.md b/atomic-cli/tests/fixtures/readme_old.md new file mode 100644 index 00000000..fc4ecc55 --- /dev/null +++ b/atomic-cli/tests/fixtures/readme_old.md @@ -0,0 +1,347 @@ +# opencode-atomic-hooks + +Atomic VCS provenance tracking plugin for [OpenCode](https://opencode.ai) — turns every AI agent turn into a content-addressed change with full causal decision graphs. + +Translates OpenCode lifecycle events into `atomic agent hooks opencode ` CLI calls — the same pattern used by Claude Code and Gemini CLI. + +## Install + +Add the plugin to your `opencode.jsonc`: + +```jsonc +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["opencode-atomic-hooks"] +} +``` + +### Prerequisites + +- [Atomic VCS](https://github.com/atomicdotdev/atomic) CLI installed and in your PATH +- An initialized Atomic repository (`.atomic/` directory exists) +- [OpenCode](https://opencode.ai) with plugin support enabled + +### Activate + +No additional configuration beyond the plugin entry. When OpenCode starts, the plugin checks for an Atomic repository (`.atomic/` directory) and activates automatically: + +``` +[atomic-hooks] [info] Atomic hooks plugin activated +``` + +If the project is not an Atomic repository or the `atomic` CLI is not in PATH, the plugin silently deactivates — zero overhead. + +### Disable + +Remove the plugin entry from `opencode.jsonc`: + +```jsonc +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [] +} +``` + +## How It Works + +``` +User prompts AI in OpenCode + │ + ▼ +OpenCode fires lifecycle events + │ + ▼ +opencode-atomic-hooks plugin ← loaded from npm + │ + ├── handlers/event.ts → session-start, stop, session-end + ├── handlers/chat.ts → user-prompt + ├── handlers/tool.ts → before-tool, after-tool + ├── handlers/shell.ts → ATOMIC_AGENT=opencode + └── handlers/compaction.ts → provenance graph summary + │ + ▼ +atomic agent hooks opencode ← JSON piped to stdin + │ + ▼ +atomic-agent/src/hooks/opencode.rs ← parse_event() + │ + ▼ +TurnOrchestrator::dispatch() ← state machine → record change + + provenance graph accumulation +``` + +Each completed turn becomes a single Atomic change with full provenance — model, provider, tokens, cost, session ID, and turn number — all embedded in the change itself. + +## Event Mapping + +| OpenCode Hook | Atomic Verb | Rust HookType | Handler Module | +|---------------------------|-----------------|---------------|-------------------------| +| `event` (session.created) | `session-start` | SessionStart | `handlers/event.ts` | +| `chat.message` | `user-prompt` | TurnStart | `handlers/chat.ts` | +| `event` (session.idle) | `stop` | TurnEnd | `handlers/event.ts` | +| `event` (session.deleted) | `session-end` | SessionEnd | `handlers/event.ts` | +| `tool.execute.before` | `before-tool` | PreToolUse | `handlers/tool.ts` | +| `tool.execute.after` | `after-tool` | PostToolUse | `handlers/tool.ts` | +| `shell.env` | *(env vars)* | — | `handlers/shell.ts` | +| `experimental.session.compacting` | *(graph summary)* | — | `handlers/compaction.ts` | + +## Module Architecture + +``` +src/ +├── index.ts ← Plugin entry point + barrel exports +├── types.ts ← TypeScript interfaces & type aliases +├── constants.ts ← Configuration constants & defaults +├── cli.ts ← Atomic CLI invocation layer +├── session.ts ← In-memory session state management +├── log.ts ← Structured logging wrapper +├── handlers/ +│ ├── index.ts ← Handler barrel exports +│ ├── event.ts ← Event bus → session lifecycle hooks +│ ├── chat.ts ← Chat message → TurnStart hook +│ ├── tool.ts ← Tool execution → PreToolUse/PostToolUse +│ ├── shell.ts ← Shell env variable injection +│ └── compaction.ts ← Provenance graph → compaction context +└── __tests__/ + ├── session.test.ts ← Session store unit tests + ├── handlers.test.ts ← Handler helper unit tests + └── compaction.test.ts ← Compaction handler unit tests +``` + +### Module Responsibilities + +| Module | Purpose | Key Exports | +|----------------------------|------------------------------------------------------------------------------|----------------------------------------------------------------------------------| +| **index.ts** | Plugin entry point — gates on Atomic repo, wires deps, returns hooks object | `AtomicHooksPlugin`, plus all barrel re-exports | +| **types.ts** | All shared TypeScript interfaces — hook payloads, session state, CLI results | `HookVerb`, `SessionState`, `HookResult`, `AtomicHooksConfig`, payload types | +| **constants.ts** | Compile-time configuration — CLI binary name, hook args, edit tool set | `ATOMIC_CMD`, `HOOK_ARGS`, `EDIT_TOOLS`, `MAX_TOOL_OUTPUT_LENGTH` | +| **cli.ts** | Encapsulates all Atomic CLI interaction — pipes JSON to stdin | `invokeHook()`, `isAtomicAvailable()`, `basePayload()` | +| **session.ts** | In-memory session state — turn counts, model/provider, tool durations | `createSessionStore()`, `SessionStore` interface | +| **log.ts** | Structured logging via OpenCode SDK `client.app.log()` with console fallback | `createLogger()`, `Logger` interface | +| **handlers/event.ts** | Routes event bus events to session lifecycle hooks | `createEventHandler()` | +| **handlers/chat.ts** | Captures prompt text and model info on user messages | `createChatHandler()`, `extractPrompt()` | +| **handlers/tool.ts** | Tracks tool timing, classifies file mutations, truncates output | `createBeforeToolHandler()`, `createAfterToolHandler()`, `isEditTool()` | +| **handlers/shell.ts** | Injects `ATOMIC_AGENT` and `ATOMIC_AGENT_VERSION` into shell env | `createShellHandler()` | +| **handlers/compaction.ts** | Reads provenance graph from disk, injects summary into compaction context | `createCompactionHandler()` | + +### Dependency Graph + +No circular dependencies. Each module imports only from modules "below" it: + +``` +index.ts → cli, session, log, handlers/* +handlers/* → cli, session, log, constants, types +handlers/compaction → log (reads graph.json from disk, no CLI calls) +cli → constants, types +session → types +log → constants, types +constants → types +types → (nothing) +``` + +## Design Principles + +### 1. Best-Effort Recording + +CLI failures are **logged, never thrown**. The AI session must never be blocked by a provenance recording failure. Every `invokeHook()` call returns a `HookResult` with timing and error details — but never throws. + +### 2. Dependency Injection + +Every handler is a factory function that accepts a `Deps` object: + +```typescript +const deps = { $, store, log, directory } + +return { + event: createEventHandler(deps), + "chat.message": createChatHandler(deps), + "tool.execute.before": createBeforeToolHandler(deps), + "tool.execute.after": createAfterToolHandler(deps), + "shell.env": createShellHandler(), +} +``` + +This makes every handler fully testable — pass in mocks for the shell and logger without touching the filesystem or PATH. + +### 3. Separation of Concerns + +- **types.ts** — What data looks like (zero runtime code) +- **constants.ts** — What values are (zero logic) +- **cli.ts** — How we talk to the Atomic binary +- **session.ts** — What we remember between events +- **handlers/** — How we react to each event +- **log.ts** — How we report what happened + +### 4. Ephemeral State + +The `SessionStore` is in-memory only. If OpenCode restarts, all session state is lost. This is intentional — the Atomic CLI maintains its own durable session state in `.atomic/sessions/`. The plugin's state is just a correlation buffer to enrich hook payloads with timing and context. + +## Usage + +Once installed, the plugin activates automatically when OpenCode starts in an Atomic repository. Every turn is recorded: + +```bash +# See what was recorded +atomic log + +# Check session status +atomic agent status --verbose + +# Rewind to a previous turn +atomic unrecord +``` + +## Testing + +Run the unit test suite with Bun: + +```bash +bun test + +# Watch mode for development +bun test --watch + +# Run a specific test file +bun test src/__tests__/session.test.ts +bun test src/__tests__/handlers.test.ts +bun test src/__tests__/compaction.test.ts +``` + +### Test Coverage + +| Test File | Module | Tests | Covers | +|-----------------------|-------------------------|-------|--------------------------------------------------------------------------| +| `session.test.ts` | `session.ts` | ~40 | Store CRUD, turn counting, model/provider, tool duration, edge cases | +| `handlers.test.ts` | `handlers/*` | ~60 | `extractPrompt`, `isEditTool`, `truncateOutput`, `createShellHandler` | +| `compaction.test.ts` | `handlers/compaction.ts`| ~22 | Graph reading, summary formatting, missing/corrupt files, token budget | + +### Adding Tests + +All tests live in `src/__tests__/` and use [Bun's built-in test runner](https://bun.sh/docs/cli/test): + +```typescript +import { describe, test, expect, beforeEach } from "bun:test" +import { createSessionStore } from "../session" + +describe("myFeature", () => { + test("does the thing", () => { + const store = createSessionStore() + store.incrementTurn("s1") + expect(store.get("s1").turnCount).toBe(1) + }) +}) +``` + +## Configuration + +All configuration is currently compile-time constants in `constants.ts`. Environment variable overrides: + +| Variable | Default | Purpose | +|----------------|----------|--------------------------------------| +| `ATOMIC_CMD` | `atomic` | Atomic CLI binary name or path | +| `ATOMIC_DEBUG` | `0` | Set to `1` to enable debug logging | + +## Comparison with Other Agents + +This plugin follows the exact same hook pattern as Claude Code and Gemini CLI: + +| Aspect | Claude Code | Gemini CLI | OpenCode | +|-----------------|----------------------------------|----------------------------------|---------------------------------------| +| Hook system | Native `.claude/settings.json` | Native `.gemini/settings.json` | `opencode-atomic-hooks` npm package | +| Installation | Modify JSON config | Modify JSON config | Add to `opencode.jsonc` plugin array | +| Invocation | Agent calls CLI directly | Agent calls CLI directly | Plugin calls CLI | +| Turn boundary | `stop` event | `AfterAgent` event | `session.idle` event | +| Prompt capture | `UserPromptSubmit` | `BeforeAgent` | `chat.message` hook | +| Rust adapter | `hooks/claude_code.rs` | `hooks/gemini_cli.rs` | `hooks/opencode.rs` | + +OpenCode uses a **plugin package** approach rather than modifying a settings JSON file. This means zero modifications to the OpenCode codebase — the plugin is a self-contained TypeScript module published to npm that uses the public plugin API. + +## Package Structure + +``` +opencode-atomic-hooks/ +├── package.json ← npm metadata, peer dep on @opencode-ai/plugin +├── tsconfig.json ← declaration output for types +├── README.md +├── src/ +│ ├── index.ts ← plugin entry + barrel exports +│ ├── types.ts ← TypeScript interfaces +│ ├── constants.ts ← configuration constants +│ ├── cli.ts ← Atomic CLI invocation +│ ├── session.ts ← in-memory session state +│ ├── log.ts ← structured logging +│ ├── handlers/ +│ │ ├── index.ts ← handler barrel exports +│ │ ├── event.ts ← session lifecycle +│ │ ├── chat.ts ← user prompt capture +│ │ ├── tool.ts ← tool before/after +│ │ ├── shell.ts ← env injection +│ │ └── compaction.ts ← provenance graph → compaction context +│ └── __tests__/ +│ ├── session.test.ts +│ ├── handlers.test.ts +│ └── compaction.test.ts +└── dist/ ← build output (bun build + tsc) + ├── index.js ← 16.5KB single bundle + └── *.d.ts ← type declarations +``` + +### Build + +```bash +bun run build +``` + +Produces a single ESM bundle at `dist/index.js` via `bun build` (all 11 modules +inlined) plus TypeScript declaration files via `tsc --emitDeclarationOnly`. The +bundle targets Bun's runtime since OpenCode uses Bun. + +### Publish + +```bash +bun run build +npm publish +``` + +Users add it to their `opencode.jsonc`: + +```jsonc +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["opencode-atomic-hooks"] +} +``` + +OpenCode's plugin loader installs it via `bun install`, imports the default +export, and calls the plugin function with the standard `PluginInput` context. +Same path as every plugin on the +[awesome-opencode](https://github.com/awesome-opencode/awesome-opencode) list. + +### Local Development + +For development within the opencode monorepo, a shim at +`.opencode/plugins/atomic.ts` re-exports from the workspace package: + +```typescript +export { AtomicHooksPlugin } from "opencode-atomic-hooks" +``` + +This file matches the plugin discovery glob (`plugins/*.ts`) so OpenCode loads +it during local development without publishing to npm. + +## Provenance Graph + +This plugin includes a **compaction handler** that reads the provenance graph +built by the Rust-side `TurnOrchestrator` and injects a structured summary +into OpenCode's compaction context. This means the LLM retains knowledge of +what was explored, decided, committed, and verified — even after the +conversation is compacted to fit the context window. + +The provenance graph itself is built and stored entirely on the Rust side. +The plugin only reads it from `.atomic/sessions/{id}/graph.json` during +compaction — no graph accumulation, no classification, no state management +in TypeScript. + +## License + +Dual-licensed under MIT and Apache 2.0, consistent with the Atomic VCS project. \ No newline at end of file diff --git a/atomic-cli/tests/git_bridge_opt_in_test.rs b/atomic-cli/tests/git_bridge_opt_in_test.rs index 3296e14a..cd634d43 100644 --- a/atomic-cli/tests/git_bridge_opt_in_test.rs +++ b/atomic-cli/tests/git_bridge_opt_in_test.rs @@ -117,6 +117,45 @@ impl Fixture { hooks.sort(); hooks } + + /// Run one OpenCode agent hook dispatch the way the atomic-opencode + /// plugin does: JSON on stdin, foreground, from the repository root. + fn agent_hook(&self, verb: &str, session_id: &str) -> Output { + use std::io::Write; + let payload = format!( + "{{\"session_id\":\"{session_id}\",\"cwd\":\"{}\",\"timestamp\":\"2026-10-14T10:30:00Z\"}}", + self.root().display() + ); + let mut child = Command::new(ATOMIC_BIN) + .args(["agent", "hooks", "opencode", verb, "--foreground"]) + .current_dir(self.root()) + .env("HOME", self.home.path()) + .env("ATOMIC_HOME", self.home.path().join(".atomic")) + .env("ATOMIC_NONINTERACTIVE", "1") + .env("NO_COLOR", "1") + .env("CLICOLOR", "0") + .env("TERM", "dumb") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .stdin(Stdio::piped()) + .spawn() + .expect("spawn atomic agent hooks"); + child + .stdin + .as_mut() + .expect("hook stdin") + .write_all(payload.as_bytes()) + .expect("write hook stdin"); + child.wait_with_output().expect("wait for agent hook") + } + + /// A retained shadow exclude marker without bridge enrollment: written by + /// import/push, kept on disk by `git bridge disable`, and never removed by + /// plain `atomic init` inside an existing Git checkout. + fn retain_shadow_marker(&self) { + fs::create_dir_all(self.root().join(".git/info")).unwrap(); + fs::write(self.root().join(".git/info/exclude"), b"/.atomic/\n").unwrap(); + } } fn assert_success(output: &Output, what: &str) { @@ -252,3 +291,120 @@ fn disabling_an_unanchored_bridge_restores_native_behavior() { fixture.atomic_ok(&["git", "bridge", "disable"]); fixture.atomic_ok(&["status"]); } + +/// The OpenCode agent bridge dispatches `atomic agent hooks opencode ` +/// on every tool call. In a colocated-but-unenrolled repository with a +/// retained shadow exclude marker, dispatch must stay native: it may never +/// demand a bridge checkpoint (the reported every-tool-call exit 128) and +/// never bootstrap bridge state. +#[test] +fn agent_hooks_stay_native_with_a_retained_shadow_marker() { + let fixture = Fixture::existing_git_history(); + fixture.retain_shadow_marker(); + let index_before = fixture.git_text(&["ls-files", "--stage"]); + + for verb in ["session-start", "user-prompt", "before-tool", "after-tool"] { + let output = fixture.agent_hook(verb, "ses-gate"); + assert_success(&output, &format!("agent hooks opencode {verb}")); + } + + assert!( + !fixture + .root() + .join(".atomic/bridge/workspace.json") + .exists(), + "agent hooks must not bootstrap the bridge checkpoint" + ); + assert_eq!( + fixture.git_text(&["ls-files", "--stage"]), + index_before, + "agent hooks must not write the Git index without the bridge opt-in" + ); + assert_eq!( + fixture.git_text(&["for-each-ref", "--format=%(refname)", "refs/atomic"]), + "", + "agent hooks must not create Atomic Git refs without the opt-in" + ); +} + +/// Hook dispatch outside an Atomic repository exits quietly: the plugin's +/// per-dispatch gating normally prevents the call entirely, and a repository +/// that disappears between probe and dispatch must not surface as a hook +/// failure (which the bridge plugins' strict tool propagation would turn +/// into a blocked tool call). +#[test] +fn agent_hooks_exit_quietly_outside_a_repository() { + for with_git in [false, true] { + let directory = TempDir::new().expect("tempdir"); + let home = TempDir::new().expect("home tempdir"); + let root = directory.path(); + if with_git { + let output = Command::new("git") + .args(["init", "-q", "-b", "main"]) + .current_dir(root) + .env("GIT_CONFIG_NOSYSTEM", "1") + .output() + .expect("git init"); + assert_success(&output, "git init"); + } + let payload = format!( + "{{\"session_id\":\"ses-none\",\"cwd\":\"{}\",\"timestamp\":\"2026-10-14T10:30:00Z\"}}", + root.display() + ); + use std::io::Write; + let mut child = Command::new(ATOMIC_BIN) + .args([ + "agent", + "hooks", + "opencode", + "session-start", + "--foreground", + ]) + .current_dir(root) + .env("HOME", home.path()) + .env("ATOMIC_NONINTERACTIVE", "1") + .env("NO_COLOR", "1") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .stdin(Stdio::piped()) + .spawn() + .expect("spawn atomic agent hooks"); + child + .stdin + .as_mut() + .expect("hook stdin") + .write_all(payload.as_bytes()) + .expect("write hook stdin"); + let output = child.wait_with_output().expect("wait for agent hook"); + assert_success(&output, "agent hooks opencode session-start"); + assert!( + !root.join(".atomic").exists(), + "hook dispatch must never create a repository" + ); + } +} + +/// An enrolled-but-unanchored workspace keeps its fail-closed refusal with +/// the anchor-or-disable remediation at the agent boundary too. +#[test] +fn agent_hooks_refuse_with_remediation_when_enrolled_unanchored() { + let fixture = Fixture::existing_git_history(); + fixture.atomic_ok(&["git", "bridge", "enable"]); + + let refused = fixture.agent_hook("session-start", "ses-enrolled"); + assert!( + !refused.status.success(), + "enrolled but unanchored bridge must refuse the agent boundary" + ); + let text = output_text(&refused); + assert!(text.contains("MissingCheckpoint"), "{text}"); + assert!(text.contains("atomic git bridge reconcile"), "{text}"); + assert!(text.contains("atomic git bridge disable"), "{text}"); + assert!( + !fixture + .root() + .join(".atomic/bridge/workspace.json") + .exists(), + "the refusal must not anchor the bridge" + ); +} diff --git a/atomic-cli/tests/git_import_bypass_cycle_regression.rs b/atomic-cli/tests/git_import_bypass_cycle_regression.rs new file mode 100644 index 00000000..c7b05f4d --- /dev/null +++ b/atomic-cli/tests/git_import_bypass_cycle_regression.rs @@ -0,0 +1,204 @@ +// Temporary Unix-only Git bridge support to unblock Windows native CI. +// Revisit after Windows support is defined; see the RFC temporary platform policy. +#![cfg(unix)] + +//! Regression: the alive-walk's bypass-child attachment must not defer a +//! bypass child *under* a direct child it precedes. +//! +//! Original failure (atomic's own history, first-parent ordinals +//! 1, 17, 22, 33, 72 — Git commits 3af95c83, a27c25de, 2d95a174, 961df86d, +//! bae5d00c): after a prepend (`[features]\nremote = []\n\n` before +//! `[package]`), a later insertion placed `ast`/`teams` lines *between* +//! `remote` and the prepended blank line, and the next commit deleted the +//! `ast` line. The deletion surfaced `teams` as a *bypass* child of +//! `remote`, while the pre-insertion edge `remote → blank` kept the blank +//! line as `remote`'s direct child. The old deferral heuristic attached the +//! bypass child under the last direct child — but `teams` is *upstream* of +//! `blank` (live edge `teams → blank`), so the alive graph closed a cycle +//! `blank → teams → blank`. Tarjan collapsed the pair into one SCC and the +//! cyclic-conflict resolution emitted only one side: exactly the blank +//! line's byte was dropped (`renders 1934 but the commit tree expects +//! 1935`), failing the fail-closed staged verification at commit 72. +//! +//! The four states below reproduce the same mechanism in miniature +//! (pre-fix signature: `renders 88 but the commit tree expects 89`). + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +const ATOMIC_BIN: &str = env!("CARGO_BIN_EXE_atomic"); + +const PATH: &str = "atomic-cli/Cargo.toml"; + +const STATE_1: &[u8] = b"[package]\nname = \"x\"\nversion = \"1\"\n"; +const STATE_2: &[u8] = b"[features]\nremote = []\n\n[package]\nname = \"x\"\nversion = \"1\"\n"; +const STATE_3: &[u8] = b"[features]\ndefault = [\"a\", \"b\"]\nremote = []\nast = [\"p\", \"q\"]\nteams = [\"t\"]\n\n[package]\nname = \"x\"\nversion = \"1\"\n"; +const STATE_4: &[u8] = + b"[features]\ndefault = [\"b\"]\nremote = []\nteams = [\"t\"]\n\n[package]\nname = \"x\"\nversion = \"1\"\n"; + +fn atomic(root: &Path, home: &Path, args: &[&str]) -> Output { + Command::new(ATOMIC_BIN) + .args(args) + .current_dir(root) + .env("HOME", home) + .env("ATOMIC_HOME", home.join(".atomic")) + .output() + .expect("run atomic") +} + +fn atomic_ok(root: &Path, home: &Path, args: &[&str]) -> String { + let output = atomic(root, home, args); + assert!( + output.status.success(), + "atomic {args:?} failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).into_owned() +} + +fn git(root: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(root) + .env("GIT_AUTHOR_NAME", "Bypass Cycle Tests") + .env("GIT_AUTHOR_EMAIL", "bypass-cycle@example.com") + .env("GIT_COMMITTER_NAME", "Bypass Cycle Tests") + .env("GIT_COMMITTER_EMAIL", "bypass-cycle@example.com") + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout) + .expect("utf8 git output") + .trim() + .to_string() +} + +/// Build the four-state Git repository that reproduces the bypass-cycle +/// byte loss: base file, prepend a section with a blank separator, insert +/// two lines between the section and the blank, then delete one of them. +fn fixture() -> (PathBuf, PathBuf) { + let root = tempfile::tempdir().unwrap().keep(); + let home = tempfile::tempdir().unwrap().keep(); + git(&root, &["init", "-q", "-b", "main"]); + fs::create_dir_all(root.join("atomic-cli")).unwrap(); + for (i, state) in [STATE_1, STATE_2, STATE_3, STATE_4].iter().enumerate() { + fs::write(root.join(PATH), state).unwrap(); + git(&root, &["add", "."]); + if i == 0 { + git(&root, &["commit", "-q", "-m", "base file"]); + } else { + git(&root, &["commit", "-q", "-am", &format!("state {}", i + 1)]); + } + } + (root, home) +} + +/// The graph-rendered bytes for `PATH` on `main`, read in-process exactly +/// as another reader would. +fn rendered_on_main(root: &Path) -> Vec { + let repo = atomic_repository::Repository::open(root).unwrap(); + let content = repo + .get_file_content_on_view(PATH, "main") + .expect("content retrieval must not fail"); + content.unwrap_or_else(|| panic!("'{PATH}' must be present on view main")) +} + +#[test] +fn import_of_prepend_insert_delete_history_renders_every_state_byte_exact() { + let (root, home) = fixture(); + + // The importer's per-commit staged verification is fail-closed: a + // successful import proves EVERY intermediate state rendered + // byte-exactly against its commit tree. Pre-fix, this failed at the + // last commit with "renders 88 byte(s) but the commit tree expects 89". + let summary = atomic_ok(&root, &home, &["git", "import", "--no-vault"]); + assert!(summary.contains("4 changes"), "expected 4 imported changes"); + + // The final state's graph render is byte-exact (not only the worktree). + let rendered = rendered_on_main(&root); + assert_eq!( + rendered, STATE_4, + "graph render must equal the final Git tree byte-for-byte" + ); + + // The blank line between `teams` and `[package]` is present: this is + // the exact byte the bypass-cycle dropped. + assert!( + rendered.windows(3).any(|w| w == b"\n\n["), + "blank separator line must survive the deletion import" + ); + + // Both CLIs agree the workspace is clean, and the bridge layers verify. + let status = atomic_ok(&root, &home, &["status"]); + assert!( + status.contains("nothing to record"), + "atomic status must be clean after import, got: {status}" + ); + atomic_ok(&root, &home, &["git", "bridge", "verify"]); + + // Incremental re-import is idempotent (no new changes, still verified). + let again = atomic_ok( + &root, + &home, + &["git", "import", "--incremental", "--no-vault"], + ); + assert!( + !again.contains("5 changes"), + "incremental import must not synthesize new changes: {again}" + ); + assert_eq!(rendered_on_main(&root), STATE_4); +} + +#[test] +fn native_record_of_prepend_insert_delete_history_renders_byte_exact() { + // The same walk serves the native record path (status / materialization + // read through the identical retrieval), so the bypass-cycle fix must + // hold there too: record the four states with `atomic record` and prove + // the final materialized bytes and a clean status. + let root = tempfile::tempdir().unwrap().keep(); + let home = tempfile::tempdir().unwrap().keep(); + fs::create_dir_all(root.join("atomic-cli")).unwrap(); + + atomic_ok(&root, &home, &["init", "--view", "main", "--no-vault"]); + for (i, state) in [STATE_1, STATE_2, STATE_3, STATE_4].iter().enumerate() { + fs::write(root.join(PATH), state).unwrap(); + atomic_ok(&root, &home, &["add", PATH]); + let message = if i == 0 { + "base file".to_string() + } else { + format!("state {}", i + 1) + }; + atomic_ok(&root, &home, &["record", "-m", &message]); + } + + let on_disk = fs::read(root.join(PATH)).unwrap(); + assert_eq!( + on_disk.as_slice(), + STATE_4, + "native record must materialize the final state byte-exactly" + ); + assert_eq!(rendered_on_main(&root), STATE_4); + + let status = atomic_ok(&root, &home, &["status"]); + assert!( + status.contains("nothing to record"), + "atomic status must be clean after native records, got: {status}" + ); + + // View switch away and back re-materializes from the graph alone: the + // blank separator byte must still survive a cold render. + atomic_ok( + &root, + &home, + &["view", "create", "check", "--parent", "main"], + ); + atomic_ok(&root, &home, &["view", "switch", "check"]); + atomic_ok(&root, &home, &["view", "switch", "main"]); + assert_eq!(fs::read(root.join(PATH)).unwrap().as_slice(), STATE_4); +} diff --git a/atomic-cli/tests/git_import_cb9b_test.rs b/atomic-cli/tests/git_import_cb9b_test.rs index 16e4ee06..febfb997 100644 --- a/atomic-cli/tests/git_import_cb9b_test.rs +++ b/atomic-cli/tests/git_import_cb9b_test.rs @@ -276,7 +276,7 @@ fn merge_imports_every_parent_closure_and_resolves_with_verified_frontier() { let root = fixture.root.as_path(); let home = fixture.home.as_path(); - atomic_ok(root, home, &["git", "import", "--no-vault"]); + atomic_ok(root, home, &["git", "import", "--all", "--no-vault"]); let repo = open_repo(root); let base_change = { @@ -462,7 +462,7 @@ fn octopus_merge_imports_all_three_parent_closures() { git(&root, &["checkout", "-q", "main"]); git(&root, &["merge", "-q", "--ff-only", "leg-a"]); - atomic_ok(&root, &home, &["git", "import", "--no-vault"]); + atomic_ok(&root, &home, &["git", "import", "--all", "--no-vault"]); let repo = open_repo(&root); let entries = repo.effective_history(Some("main")).unwrap(); @@ -542,7 +542,7 @@ fn concurrent_change_conflicts_after_reload_and_resolution_never_replays() { let base = git(&root, &["rev-parse", "HEAD"]); // Pre-merge import. - atomic_ok(&root, &home, &["git", "import", "--no-vault"]); + atomic_ok(&root, &home, &["git", "import", "--all", "--no-vault"]); // Fork a draft view from the pre-merge state and record a concurrent // edit that shares the base context. @@ -584,7 +584,7 @@ fn concurrent_change_conflicts_after_reload_and_resolution_never_replays() { atomic_ok( &root, &home, - &["git", "import", "--incremental", "--no-vault"], + &["git", "import", "--all", "--incremental", "--no-vault"], ); let repo = open_repo(&root); @@ -1199,7 +1199,7 @@ fn merge_legs_record_no_sibling_dependency_in_both_orders() { let root = fixture.root.as_path(); let home = fixture.home.as_path(); - atomic_ok(root, home, &["git", "import", "--no-vault"]); + atomic_ok(root, home, &["git", "import", "--all", "--no-vault"]); let repo = open_repo(root); assert_no_sibling_dependency(&repo, "main", &fixture.side_a, &fixture.side_b); @@ -2236,7 +2236,7 @@ fn merge_resolution_frontier_failpoint_fails_closed() { git_commit(&root, "base"); // Import the pre-merge history first. - atomic_ok(&root, &home, &["git", "import", "--no-vault"]); + atomic_ok(&root, &home, &["git", "import", "--all", "--no-vault"]); let before_changes = { let repo = open_repo(&root); repo.effective_history(Some("main")).unwrap().len() @@ -2271,7 +2271,7 @@ fn merge_resolution_frontier_failpoint_fails_closed() { let failed = atomic_env( &root, &home, - &["git", "import", "--incremental", "--no-vault"], + &["git", "import", "--all", "--incremental", "--no-vault"], &[("ATOMIC_FAIL_SYNTHESIS_DEPS_INDEX", "1")], ); assert!( @@ -2314,7 +2314,7 @@ fn merge_resolution_frontier_failpoint_fails_closed() { atomic_ok( &root, &home, - &["git", "import", "--incremental", "--no-vault"], + &["git", "import", "--all", "--incremental", "--no-vault"], ); let repo = open_repo(&root); let merge_change = change_of(&repo, "main", &merge); @@ -2475,7 +2475,7 @@ fn merge_with_empty_tip_parent_imports_both_closures() { git(&root, &["checkout", "-q", "main"]); git(&root, &["merge", "-q", "--ff-only", "side-a"]); - atomic_ok(&root, &home, &["git", "import", "--no-vault"]); + atomic_ok(&root, &home, &["git", "import", "--all", "--no-vault"]); let repo = open_repo(&root); // The empty leg stays a distinct Change::empty with the EmptyCommit diff --git a/atomic-cli/tests/git_import_corpus_regression_test.rs b/atomic-cli/tests/git_import_corpus_regression_test.rs new file mode 100644 index 00000000..6ba8b3ea --- /dev/null +++ b/atomic-cli/tests/git_import_corpus_regression_test.rs @@ -0,0 +1,1222 @@ +#![cfg(unix)] + +use git2::{Oid, Repository as GitRepository, Signature}; +use std::path::Path; +use std::process::Command; + +fn tree(git: &GitRepository, files: &[(&str, &[u8], i32)]) -> Oid { + let mut builder = git.treebuilder(None).unwrap(); + let mut directories = std::collections::BTreeMap::<&str, Vec<(&str, &[u8], i32)>>::new(); + for &(path, bytes, mode) in files { + if let Some((directory, rest)) = path.split_once('/') { + directories + .entry(directory) + .or_default() + .push((rest, bytes, mode)); + } else { + builder + .insert(path, git.blob(bytes).unwrap(), mode) + .unwrap(); + } + } + for (directory, children) in directories { + builder + .insert(directory, tree(git, &children), 0o040000) + .unwrap(); + } + builder.write().unwrap() +} + +fn commit(git: &GitRepository, files: &[(&str, &[u8], i32)], parents: &[Oid]) -> Oid { + commit_named(git, files, parents, "fixture") +} + +fn commit_named( + git: &GitRepository, + files: &[(&str, &[u8], i32)], + parents: &[Oid], + message: &str, +) -> Oid { + let tree = git.find_tree(tree(git, files)).unwrap(); + let parents: Vec<_> = parents + .iter() + .map(|id| git.find_commit(*id).unwrap()) + .collect(); + let signature = Signature::now("Import regression", "import@example.com").unwrap(); + git.commit( + None, + &signature, + &signature, + message, + &tree, + &parents.iter().collect::>(), + ) + .unwrap() +} + +fn import(root: &Path, git: &GitRepository, tip: Oid, config: &Path) { + import_with_args(root, git, tip, config, &[]); +} + +fn import_with_args(root: &Path, git: &GitRepository, tip: Oid, config: &Path, args: &[&str]) { + git.reference("refs/heads/main", tip, true, "fixture") + .unwrap(); + git.set_head("refs/heads/main").unwrap(); + git.checkout_head(Some(git2::build::CheckoutBuilder::new().force())) + .unwrap(); + let mut command = Command::new(env!("CARGO_BIN_EXE_atomic")); + for (key, _) in std::env::vars().filter(|(key, _)| key.starts_with("ATOMIC_")) { + command.env_remove(key); + } + let output = command + .current_dir(root) + .env("ATOMIC_CONFIG_DIR", config) + .env("ATOMIC_SERVICE", "local") + .env("ATOMIC_NONINTERACTIVE", "1") + .args(["git", "import", "--no-vault", "--no-color"]) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "import failed:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn generated_file_deletion_preserves_lifecycle_after_modification() { + for initial in [b"one\ntwo\n".as_slice(), b"".as_slice()] { + let root = tempfile::tempdir().unwrap(); + let config = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let first = commit( + &git, + &[ + ("yarn.lock", initial, 0o100644), + ("keep.txt", b"keep\n", 0o100644), + ], + &[], + ); + let second = commit( + &git, + &[ + ("yarn.lock", b"one\nchanged\nthree\n", 0o100644), + ("keep.txt", b"keep\n", 0o100644), + ], + &[first], + ); + // Also cover deleting an empty generated file without a content edge. + let parent = if initial.is_empty() { first } else { second }; + let tip = commit(&git, &[("keep.txt", b"keep\n", 0o100644)], &[parent]); + import(root.path(), &git, tip, config.path()); + let mut repo = atomic_repository::Repository::open(root.path()).unwrap(); + assert_eq!(repo.get_file_content("yarn.lock").unwrap(), None); + assert_eq!( + repo.get_file_content("keep.txt").unwrap().unwrap(), + b"keep\n" + ); + let mut history = repo + .log(atomic_repository::HistoryOptions::default()) + .unwrap(); + history.sort_by_key(|entry| entry.sequence); + let changes: Vec<_> = history + .iter() + .map(|entry| repo.load_change(&entry.hash).unwrap()) + .collect(); + assert!(changes.iter().any(|change| { + change.hunks().iter().any(|hunk| { + matches!(hunk, atomic_core::change::GraphOp::FileDel {path, ..} if path == "yarn.lock") + }) + })); + repo.create_view_from("roundtrip", "main").unwrap(); + let wc = repo.require_working_copy_id().unwrap(); + repo.switch_view(wc, "roundtrip").unwrap(); + repo.switch_view(wc, "main").unwrap(); + assert_eq!(repo.get_file_content("yarn.lock").unwrap(), None); + + // Reconstruct only from canonical changes, without the importer's + // indexes/journal, using opposite valid dependency orders for any + // independent changes. + for reverse in [false, true] { + let rebuilt_dir = tempfile::tempdir().unwrap(); + let rebuilt = atomic_repository::Repository::init(rebuilt_dir.path()).unwrap(); + let mut hashes: Vec<_> = changes + .iter() + .map(|change| rebuilt.save_change(change).unwrap()) + .collect(); + if reverse { + hashes.reverse(); + } + let mut inserted = std::collections::HashSet::new(); + while !hashes.is_empty() { + let ready = hashes + .iter() + .position(|hash| { + rebuilt + .load_change(hash) + .unwrap() + .dependencies() + .iter() + .all(|dependency| inserted.contains(dependency)) + }) + .expect("history must contain the complete dependency closure"); + let hash = hashes.remove(ready); + rebuilt.insert_change(&hash, Default::default()).unwrap(); + inserted.insert(hash); + } + drop(rebuilt); + let rebuilt = atomic_repository::Repository::open(rebuilt_dir.path()).unwrap(); + rebuilt + .materialize(rebuilt.require_working_copy_id().unwrap()) + .unwrap(); + assert_eq!(rebuilt.get_file_content("yarn.lock").unwrap(), None); + assert!(!rebuilt_dir.path().join("yarn.lock").exists()); + assert_eq!( + std::fs::read(rebuilt_dir.path().join("keep.txt")).unwrap(), + b"keep\n" + ); + } + } +} + +#[test] +fn truncating_generated_file_keeps_the_empty_file() { + let root = tempfile::tempdir().unwrap(); + let config = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let first = commit(&git, &[("yarn.lock", b"one\ntwo\n", 0o100644)], &[]); + let tip = commit(&git, &[("yarn.lock", b"", 0o100644)], &[first]); + import(root.path(), &git, tip, config.path()); + let repo = atomic_repository::Repository::open(root.path()).unwrap(); + assert_eq!( + repo.get_file_content("yarn.lock").unwrap(), + Some(Vec::new()) + ); +} + +#[test] +fn merge_rename_keeps_old_path_removal_when_side_parent_modifies_destination() { + let root = tempfile::tempdir().unwrap(); + let config = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let original = (0..30) + .map(|i| format!("unchanged line {i}\n")) + .collect::(); + let renamed = format!("{original}side addition\n"); + let resolved = format!("{renamed}merge addition\n"); + let first = commit(&git, &[("README.md", original.as_bytes(), 0o100644)], &[]); + let side = commit( + &git, + &[("README.rst", renamed.as_bytes(), 0o100644)], + &[first], + ); + let main = commit( + &git, + &[ + ("README.md", original.as_bytes(), 0o100644), + ("main.txt", b"main\n", 0o100644), + ], + &[first], + ); + let tip = commit( + &git, + &[ + ("README.rst", resolved.as_bytes(), 0o100644), + ("main.txt", b"main\n", 0o100644), + ], + &[main, side], + ); + import(root.path(), &git, tip, config.path()); + let repo = atomic_repository::Repository::open(root.path()).unwrap(); + assert_eq!(repo.get_file_content("README.md").unwrap(), None); + assert_eq!( + repo.get_file_content("README.rst").unwrap().unwrap(), + resolved.as_bytes() + ); +} + +#[test] +fn merge_rename_over_symlink_deletes_the_displaced_identity() { + let root = tempfile::tempdir().unwrap(); + let config = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let original = (0..30) + .map(|i| format!("readme line {i}\n")) + .collect::(); + let result = format!("{original}updated readme\n"); + let first_files = [ + ("README.md", b"docs/README.md".as_slice(), 0o120000), + ("docs/README.md", original.as_bytes(), 0o100644), + ]; + // The displaced inode itself arrived by a previous move. Two historical + // moves to README.md must not be mistaken for two live name claimants. + let before_move = commit( + &git, + &[ + ("link.md", b"docs/README.md".as_slice(), 0o120000), + ("docs/README.md", original.as_bytes(), 0o100644), + ], + &[], + ); + let first = commit(&git, &first_files, &[before_move]); + let side = commit( + &git, + &[("README.md", result.as_bytes(), 0o100644)], + &[first], + ); + let mut main_files = first_files.to_vec(); + main_files.push(("main.txt", b"main\n", 0o100644)); + let main = commit(&git, &main_files, &[first]); + let tip = commit( + &git, + &[ + ("README.md", result.as_bytes(), 0o100644), + ("main.txt", b"main\n", 0o100644), + ], + &[main, side], + ); + import(root.path(), &git, tip, config.path()); + let repo = atomic_repository::Repository::open(root.path()).unwrap(); + assert_eq!( + repo.get_file_content("README.md").unwrap().unwrap(), + result.as_bytes() + ); + assert_eq!(repo.get_file_content("docs/README.md").unwrap(), None); +} + +#[test] +fn symlink_to_regular_file_survives_simultaneous_sibling_deletion() { + let root = tempfile::tempdir().unwrap(); + let config = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let first = commit( + &git, + &[ + ("LICENSE", b"license text\n", 0o100644), + ("a/LICENSE", b"../LICENSE", 0o120000), + ("b/LICENSE", b"../LICENSE", 0o120000), + ], + &[], + ); + let result = [ + ("LICENSE", b"license text\n".as_slice(), 0o100644), + ("b/LICENSE", b"license text\n".as_slice(), 0o100644), + ("c/LICENSE", b"license text\n".as_slice(), 0o100644), + ]; + let side = commit(&git, &result, &[first]); + let tip = commit(&git, &result, &[first, side]); + import(root.path(), &git, tip, config.path()); + let repo = atomic_repository::Repository::open(root.path()).unwrap(); + assert_eq!( + repo.get_file_content("b/LICENSE").unwrap().unwrap(), + b"license text\n" + ); + assert_eq!(repo.get_file_content("a/LICENSE").unwrap(), None); +} + +// Two independent spelling fixes converge to identical bytes. The second +// merge has no Git tree delta, but still joins distinct semantic writers. +#[test] +fn identical_side_edits_need_a_causal_merge_resolution() { + for reverse in [false, true] { + for args in [ + vec!["--all"], + vec!["--all", "--with-crdt"], + vec![], + vec!["--with-crdt"], + ] { + let root = tempfile::tempdir().unwrap(); + let config = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let base = commit_named( + &git, + &[("README.md", b"heading\nFamliar\ntail\n", 0o100644)], + &[], + "base", + ); + let files: &[(&str, &[u8], i32)] = + &[("README.md", b"heading\nFamiliar\ntail\n", 0o100644)]; + let a = commit_named(&git, files, &[base], "fix A"); + let b = commit_named(&git, files, &[base], "fix B"); + let (first, second) = if reverse { (b, a) } else { (a, b) }; + let merge1 = commit_named(&git, files, &[base, first], "merge first fix"); + let tip = commit_named(&git, files, &[merge1, second], "merge identical second fix"); + import_with_args(root.path(), &git, tip, config.path(), &args); + let mut repo = atomic_repository::Repository::open(root.path()).unwrap(); + assert_eq!( + repo.get_file_content("README.md").unwrap().unwrap(), + files[0].1 + ); + repo.create_view_from("roundtrip", "main").unwrap(); + let wc = repo.require_working_copy_id().unwrap(); + repo.switch_view(wc, "roundtrip").unwrap(); + repo.switch_view(wc, "main").unwrap(); + drop(repo); + let repo = atomic_repository::Repository::open(root.path()).unwrap(); + assert_eq!( + repo.get_file_content("README.md").unwrap().unwrap(), + files[0].1 + ); + if args.contains(&"--all") { + assert_canonical_rebuild(&repo, files); + } + } + } +} + +#[test] +fn full_history_reconciles_unchanged_paths_after_criss_cross_merges() { + for reverse in [false, true] { + let root = tempfile::tempdir().unwrap(); + let config = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let old: &[(&str, &[u8], i32)] = &[("file.txt", b"before\n", 0o100644)]; + let files: &[(&str, &[u8], i32)] = &[("file.txt", b"after\n", 0o100644)]; + let base = commit_named(&git, old, &[], "base"); + let left = commit_named(&git, files, &[base], "left edit"); + let right = commit_named( + &git, + &[("file.txt", b"other\n", 0o100644)], + &[base], + "right edit", + ); + let a = commit_named(&git, files, &[left, right], "merge left first"); + let b = commit_named(&git, files, &[right, left], "merge right first"); + assert_eq!(git.merge_bases(a, b).unwrap().len(), 2); + let parents = if reverse { [b, a] } else { [a, b] }; + let tip = commit_named(&git, files, &parents, "merge criss cross"); + for parent in parents { + assert_eq!( + git.find_commit(parent).unwrap().tree_id(), + git.find_commit(tip).unwrap().tree_id() + ); + } + import_with_args( + root.path(), + &git, + tip, + config.path(), + &["--all", "--with-crdt"], + ); + let repo = atomic_repository::Repository::open(root.path()).unwrap(); + assert_eq!( + repo.get_file_content("file.txt").unwrap().unwrap(), + files[0].1 + ); + assert_canonical_rebuild(&repo, files); + } +} + +#[test] +fn full_history_reconciles_independent_reverts_without_git_tree_changes() { + for reverse in [false, true] { + let root = tempfile::tempdir().unwrap(); + let config = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let files: &[(&str, &[u8], i32)] = &[("file.txt", b"original\n", 0o100644)]; + let base = commit_named(&git, files, &[], "base"); + let left = commit_named( + &git, + &[("file.txt", b"left\n", 0o100644)], + &[base], + "left edit", + ); + let right = commit_named( + &git, + &[("file.txt", b"right\n", 0o100644)], + &[base], + "right edit", + ); + let a = commit_named(&git, files, &[left], "revert left"); + let b = commit_named(&git, files, &[right], "revert right"); + let parents = if reverse { [b, a] } else { [a, b] }; + let tip = commit_named(&git, files, &parents, "merge independent reverts"); + assert_eq!(git.merge_base(a, b).unwrap(), base); + import_with_args( + root.path(), + &git, + tip, + config.path(), + &["--all", "--with-crdt"], + ); + let repo = atomic_repository::Repository::open(root.path()).unwrap(); + assert_eq!( + repo.get_file_content("file.txt").unwrap().unwrap(), + files[0].1 + ); + assert_canonical_rebuild(&repo, files); + } +} + +#[test] +fn full_history_references_parent_closure_after_visiting_a_sibling() { + for with_crdt in [false, true] { + let root = tempfile::tempdir().unwrap(); + let config = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let make = |files: &[(&str, &[u8], i32)], parents: &[Oid], time: i64| { + let signature = Signature::new( + "Import regression", + "import@example.com", + &git2::Time::new(time, 0), + ) + .unwrap(); + let tree = git.find_tree(tree(&git, files)).unwrap(); + let parents: Vec<_> = parents + .iter() + .map(|id| git.find_commit(*id).unwrap()) + .collect(); + git.commit( + None, + &signature, + &signature, + "fixture", + &tree, + &parents.iter().collect::>(), + ) + .unwrap() + }; + let base = make(&[("base.txt", b"base\n", 0o100644)], &[], 1); + let a = make( + &[ + ("base.txt", b"base\n", 0o100644), + ("a.txt", b"before\n", 0o100644), + ], + &[base], + 2, + ); + let b = make( + &[ + ("base.txt", b"base\n", 0o100644), + ("b.txt", b"sibling\n", 0o100644), + ], + &[base], + 3, + ); + let edited = make( + &[ + ("base.txt", b"base\n", 0o100644), + ("a.txt", b"after\n", 0o100644), + ], + &[a], + 4, + ); + let files: &[(&str, &[u8], i32)] = &[ + ("base.txt", b"base\n", 0o100644), + ("a.txt", b"after\n", 0o100644), + ("b.txt", b"sibling\n", 0o100644), + ]; + let tip = make(files, &[edited, b], 5); + let args = if with_crdt { + vec!["--all", "--with-crdt"] + } else { + vec!["--all"] + }; + import_with_args(root.path(), &git, tip, config.path(), &args); + let repo = atomic_repository::Repository::open(root.path()).unwrap(); + for (path, expected, _) in files { + assert_eq!(repo.get_file_content(path).unwrap().unwrap(), *expected); + } + // Editing a leg must retain its introducing inode/trunk instead of + // synthesizing a new FileAdd after another leg hid the parent. + let hash = repo.checked_git_sha(&edited.to_string()).unwrap().unwrap(); + let change = repo.load_change(&hash).unwrap(); + assert!(!change.hunks().iter().any( + |op| matches!(op, atomic_core::change::GraphOp::FileAdd { path, .. } if path == "a.txt") + )); + } +} + +fn assert_canonical_rebuild(repo: &atomic_repository::Repository, files: &[(&str, &[u8], i32)]) { + assert_canonical_rebuild_without(repo, files, &[]); +} + +#[test] +fn full_history_merge_retains_a_rename_replacing_a_symlink() { + for reverse_parents in [false, true] { + for semantic in [false, true] { + let root = tempfile::tempdir().unwrap(); + let config = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let text = b"# Readme\n\nThe canonical project documentation.\n".as_slice(); + let base_files = [ + ("README.md", b"docs/README.md".as_slice(), 0o120000), + ("docs/README.md", text, 0o100644), + ("left.txt", b"base\n".as_slice(), 0o100644), + ]; + let base = commit_named(&git, &base_files, &[], "base symlink and document"); + let mut left_files = base_files; + left_files[2].1 = b"left\n"; + let left = commit_named(&git, &left_files, &[base], "independent left edit"); + let right_files = [ + ("README.md", text, 0o100644), + ("left.txt", b"base\n".as_slice(), 0o100644), + ]; + let right = commit_named(&git, &right_files, &[base], "move document over symlink"); + let files = [ + ("README.md", text, 0o100644), + ("left.txt", b"left\n".as_slice(), 0o100644), + ]; + let parents = if reverse_parents { + [right, left] + } else { + [left, right] + }; + let merge = commit_named(&git, &files, &parents, "merge replacement rename"); + let args = if semantic { + vec!["--all", "--with-crdt"] + } else { + vec!["--all"] + }; + import_with_args(root.path(), &git, merge, config.path(), &args); + let repo = atomic_repository::Repository::open(root.path()).unwrap(); + assert_eq!( + repo.get_file_content("README.md").unwrap(), + Some(text.to_vec()) + ); + assert_canonical_rebuild_without(&repo, &files, &["docs/README.md"]); + } + } +} + +fn assert_canonical_rebuild_without( + repo: &atomic_repository::Repository, + files: &[(&str, &[u8], i32)], + absent: &[&str], +) { + let history = repo + .log(atomic_repository::HistoryOptions::default()) + .unwrap(); + let changes: Vec<_> = history + .iter() + .map(|entry| repo.load_change(&entry.hash).unwrap()) + .collect(); + for reverse in [false, true] { + let root = tempfile::tempdir().unwrap(); + let rebuilt = atomic_repository::Repository::init(root.path()).unwrap(); + let mut pending: Vec<_> = changes + .iter() + .map(|change| rebuilt.save_change(change).unwrap()) + .collect(); + if reverse { + pending.reverse(); + } + let mut inserted = std::collections::HashSet::new(); + while !pending.is_empty() { + let index = pending + .iter() + .position(|hash| { + let change = rebuilt.load_change(hash).unwrap(); + change + .dependencies() + .iter() + .chain(change.causal_frontier().roots().iter()) + .all(|dep| inserted.contains(dep)) + }) + .expect("complete dependency closure"); + let hash = pending.remove(index); + rebuilt.insert_change(&hash, Default::default()).unwrap(); + inserted.insert(hash); + } + drop(rebuilt); + let rebuilt = atomic_repository::Repository::open(root.path()).unwrap(); + rebuilt + .materialize(rebuilt.require_working_copy_id().unwrap()) + .unwrap(); + for path in absent { + assert_eq!(rebuilt.get_file_content(path).unwrap(), None); + assert!(!root.path().join(path).exists()); + } + for (path, bytes, mode) in files { + assert_eq!(rebuilt.get_file_content(path).unwrap().unwrap(), *bytes); + if let Some((parent, _)) = path.rsplit_once('/') { + let (_, _, directory_position) = rebuilt + .resolve_single_path_claim(rebuilt.current_view(), parent) + .unwrap() + .unwrap(); + let (claim, _, _) = rebuilt + .resolve_single_path_claim(rebuilt.current_view(), path) + .unwrap() + .unwrap(); + assert_eq!( + claim.parent.end_pos(), + directory_position, + "child '{path}' must connect to its surviving directory in GRAPH" + ); + } + let target = root.path().join(path); + let metadata = std::fs::symlink_metadata(&target).unwrap(); + if *mode == 0o120000 { + use std::os::unix::ffi::OsStrExt; + assert!(metadata.file_type().is_symlink()); + assert_eq!( + std::fs::read_link(target).unwrap().as_os_str().as_bytes(), + *bytes + ); + } else { + use std::os::unix::fs::PermissionsExt; + assert!(!metadata.file_type().is_symlink()); + assert_eq!(metadata.permissions().mode() & 0o777, *mode as u32 & 0o777); + assert_eq!(std::fs::read(target).unwrap(), *bytes); + } + } + } +} + +#[test] +fn identical_truncations_keep_an_empty_file_at_merge() { + let root = tempfile::tempdir().unwrap(); + let config = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let base = commit_named(&git, &[("file.txt", b"old\n", 0o100644)], &[], "base"); + let files: &[(&str, &[u8], i32)] = &[("file.txt", b"", 0o100644)]; + let a = commit_named(&git, files, &[base], "truncate A"); + let b = commit_named(&git, files, &[base], "truncate B"); + let tip = commit_named(&git, files, &[a, b], "merge truncations"); + import_with_args( + root.path(), + &git, + tip, + config.path(), + &["--all", "--with-crdt"], + ); + let repo = atomic_repository::Repository::open(root.path()).unwrap(); + assert_eq!(repo.get_file_content("file.txt").unwrap(), Some(Vec::new())); +} + +#[test] +fn full_history_records_explicit_resolution_for_independent_same_name_creates() { + for reverse in [false, true] { + for selected in [ + b"root=true\nindent=tab\n".as_slice(), + b"root=true\nend=lf\n".as_slice(), + b"root=true\nindent=tab\nend=lf\n".as_slice(), + ] { + let root = tempfile::tempdir().unwrap(); + let config = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let base = commit_named(&git, &[("keep", b"keep\n", 0o100644)], &[], "base"); + let a = commit_named( + &git, + &[ + ("keep", b"keep\n", 0o100644), + (".editorconfig", b"root=true\nindent=tab\n", 0o100644), + ], + &[base], + "config A", + ); + let b = commit_named( + &git, + &[ + ("keep", b"keep\n", 0o100644), + (".editorconfig", b"root=true\nend=lf\n", 0o100644), + ], + &[base], + "config B", + ); + let files: &[(&str, &[u8], i32)] = &[ + ("keep", b"keep\n", 0o100644), + (".editorconfig", selected, 0o100644), + ]; + let parents = if reverse { vec![b, a] } else { vec![a, b] }; + let tip = commit_named(&git, files, &parents, "resolve config"); + import_with_args( + root.path(), + &git, + tip, + config.path(), + &["--all", "--with-crdt"], + ); + let repo = atomic_repository::Repository::open(root.path()).unwrap(); + let hash = repo.checked_git_sha(&tip.to_string()).unwrap().unwrap(); + let change = repo.load_change(&hash).unwrap(); + assert!(change + .hunks() + .iter() + .any(|op| matches!(op, atomic_core::change::GraphOp::SolveNameConflict { .. }))); + assert_canonical_rebuild(&repo, files); + } + } +} + +#[test] +fn full_history_resolves_identical_independent_creates_without_a_git_merge_diff() { + for reverse in [false, true] { + for eager in [false, true] { + let root = tempfile::tempdir().unwrap(); + let config = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let base = commit_named(&git, &[("keep", b"keep\n", 0o100644)], &[], "base"); + let files: &[(&str, &[u8], i32)] = &[ + ("keep", b"keep\n", 0o100644), + ("LICENSE", b"MIT license\n", 0o100644), + ]; + let a = commit_named(&git, files, &[base], "license A"); + let b = commit_named(&git, files, &[base], "license B"); + let parents = if reverse { vec![b, a] } else { vec![a, b] }; + let tip = commit_named(&git, files, &parents, "merge identical licenses"); + let args = if eager { + vec!["--all", "--with-crdt"] + } else { + vec!["--all"] + }; + import_with_args(root.path(), &git, tip, config.path(), &args); + let repo = atomic_repository::Repository::open(root.path()).unwrap(); + let hash = repo.checked_git_sha(&tip.to_string()).unwrap().unwrap(); + let change = repo.load_change(&hash).unwrap(); + assert!(change + .hunks() + .iter() + .any(|op| matches!(op, atomic_core::change::GraphOp::SolveNameConflict { .. }))); + assert_canonical_rebuild(&repo, files); + } + } +} + +#[test] +fn full_history_merge_preserves_new_file_kinds_and_permission_only_changes() { + for reverse in [false, true] { + let root = tempfile::tempdir().unwrap(); + let config = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let base = commit_named(&git, &[("keep", b"keep\n", 0o100644)], &[], "base"); + let a = commit_named(&git, &[("keep", b"keep\n", 0o100644)], &[base], "left"); + let b = commit_named(&git, &[("keep", b"keep\n", 0o100644)], &[base], "right"); + let files: &[(&str, &[u8], i32)] = &[ + ("keep", b"keep\n", 0o100755), + ("script.sh", b"#!/bin/sh\necho hello\n", 0o100755), + ("link", b"keep", 0o120000), + ]; + let parents = if reverse { vec![b, a] } else { vec![a, b] }; + let tip = commit_named(&git, files, &parents, "merge adds executable and symlink"); + import_with_args( + root.path(), + &git, + tip, + config.path(), + &["--all", "--with-crdt"], + ); + let repo = atomic_repository::Repository::open(root.path()).unwrap(); + assert_canonical_rebuild(&repo, files); + } +} + +#[test] +fn merge_name_resolution_does_not_resurrect_the_losing_rename_source() { + for reverse in [false, true] { + let root = tempfile::tempdir().unwrap(); + let config = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let base = commit_named( + &git, + &[( + "old.sh", + b"one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\n", + 0o100755, + )], + &[], + "base", + ); + let a = commit_named( + &git, + &[( + "new.sh", + b"one\ntwo\nthree\nfour\nfive\nsix\nseven\nchanged\n", + 0o100755, + )], + &[base], + "rename and edit", + ); + let files: &[(&str, &[u8], i32)] = &[("new.sh", b"completely replaced script\n", 0o100755)]; + let b = commit_named(&git, files, &[base], "delete and recreate"); + let parents = if reverse { vec![b, a] } else { vec![a, b] }; + let tip = commit_named(&git, files, &parents, "retain replacement identity"); + import_with_args( + root.path(), + &git, + tip, + config.path(), + &["--all", "--with-crdt"], + ); + let repo = atomic_repository::Repository::open(root.path()).unwrap(); + assert_eq!(repo.get_file_content("old.sh").unwrap(), None); + assert_canonical_rebuild_without(&repo, files, &["old.sh"]); + } +} + +#[test] +fn full_history_attributes_deletion_only_changes_after_switching_legs() { + use atomic_core::pristine::GraphTxnT; + for reverse in [false, true] { + let root = tempfile::tempdir().unwrap(); + let config = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let make = |bytes: &[u8], parents: &[Oid], time: i64| { + let signature = Signature::new( + "Import regression", + "import@example.com", + &git2::Time::new(time, 0), + ) + .unwrap(); + let tree = git + .find_tree(tree(&git, &[("file.txt", bytes, 0o100644)])) + .unwrap(); + let parents: Vec<_> = parents + .iter() + .map(|id| git.find_commit(*id).unwrap()) + .collect(); + git.commit( + None, + &signature, + &signature, + "fixture", + &tree, + &parents.iter().collect::>(), + ) + .unwrap() + }; + let base = make(b"seed\n", &[], 1); + let add = make(b"seed\ntemporary\n", &[base], if reverse { 3 } else { 2 }); + let delete = make(b"seed\n", &[add], if reverse { 4 } else { 3 }); + let sibling = make(b"other\n", &[base], if reverse { 2 } else { 4 }); + let parents = if reverse { + vec![sibling, delete] + } else { + vec![delete, sibling] + }; + let tip = make(b"other\n", &parents, 5); + import_with_args( + root.path(), + &git, + tip, + config.path(), + &["--all", "--with-crdt"], + ); + let repo = atomic_repository::Repository::open(root.path()).unwrap(); + let hash = repo.checked_git_sha(&delete.to_string()).unwrap().unwrap(); + let txn = repo.pristine().read_txn().unwrap(); + let id = txn.get_internal(&hash).unwrap().unwrap(); + assert!( + !txn.has_change_in_graph(id).unwrap(), + "fixture must exercise a deletion with no new vertices" + ); + assert!(txn.was_change_applied(id).unwrap()); + drop(txn); + assert_eq!( + repo.get_file_content("file.txt").unwrap(), + Some(b"other\n".to_vec()) + ); + assert_canonical_rebuild(&repo, &[("file.txt", b"other\n", 0o100644)]); + } +} + +#[test] +fn full_history_attributes_sibling_edits_across_a_rename() { + for reverse in [false, true] { + for eager in [false, true] { + let root = tempfile::tempdir().unwrap(); + let config = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let make = |path: &str, bytes: &[u8], parents: &[Oid], time: i64| { + let signature = Signature::new( + "Import regression", + "import@example.com", + &git2::Time::new(time, 0), + ) + .unwrap(); + let tree = git + .find_tree(tree(&git, &[(path, bytes, 0o100644)])) + .unwrap(); + let parents: Vec<_> = parents + .iter() + .map(|id| git.find_commit(*id).unwrap()) + .collect(); + git.commit( + None, + &signature, + &signature, + "fixture", + &tree, + &parents.iter().collect::>(), + ) + .unwrap() + }; + let base = make("old.txt", b"one\ntwo\nthree\nfour\nfive\n", &[], 1); + // Visit the old-name sibling before interpreting the rename. + let sibling = make( + "old.txt", + b"one\ntwo\nthree\nfour\nfive\nsibling\n", + &[base], + 2, + ); + let renamed = make("new.txt", b"ONE\ntwo\nthree\nfour\nfive\n", &[base], 3); + let parents = if reverse { + vec![sibling, renamed] + } else { + vec![renamed, sibling] + }; + let expected = b"ONE\ntwo\nthree\nfour\nfive\nsibling\n"; + let tip = make("new.txt", expected, &parents, 4); + let args: &[&str] = if eager { + &["--all", "--with-crdt"] + } else { + &["--all"] + }; + import_with_args(root.path(), &git, tip, config.path(), args); + let mut repo = atomic_repository::Repository::open(root.path()).unwrap(); + assert_eq!(repo.get_file_content("old.txt").unwrap(), None); + assert_eq!( + repo.get_file_content("new.txt").unwrap(), + Some(expected.to_vec()) + ); + repo.create_view_from("roundtrip", "main").unwrap(); + let wc = repo.require_working_copy_id().unwrap(); + repo.switch_view(wc, "roundtrip").unwrap(); + repo.switch_view(wc, "main").unwrap(); + assert_canonical_rebuild(&repo, &[("new.txt", expected, 0o100644)]); + } + } +} + +#[test] +fn whole_file_merge_resolves_concurrent_line_delete_and_modify() { + for reverse in [false, true] { + for eager in [false, true] { + let root = tempfile::tempdir().unwrap(); + let config = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let base = commit_named( + &git, + &[("file.txt", b"one\ntwo\nthree\nfour\n", 0o100644)], + &[], + "base", + ); + let deleted = commit_named( + &git, + &[("file.txt", b"one\nfour\n", 0o100644)], + &[base], + "delete middle lines", + ); + let modified = commit_named( + &git, + &[("file.txt", b"one\nTWO\nthree\nfour\n", 0o100644)], + &[base], + "modify deleted line", + ); + let parents = if reverse { + vec![modified, deleted] + } else { + vec![deleted, modified] + }; + let expected = b"resolved\nwhole file\n"; + let tip = commit_named( + &git, + &[("file.txt", expected, 0o100644)], + &parents, + "resolve all lines", + ); + let args: &[&str] = if eager { + &["--all", "--with-crdt"] + } else { + &["--all"] + }; + import_with_args(root.path(), &git, tip, config.path(), args); + let repo = atomic_repository::Repository::open(root.path()).unwrap(); + assert_eq!( + repo.get_file_content("file.txt").unwrap(), + Some(expected.to_vec()) + ); + // The replacement must explicitly supersede the base identities, + // including the lines already tombstoned by one parent. + let history = repo + .log(atomic_repository::HistoryOptions::default()) + .unwrap(); + let changes: Vec<_> = history + .iter() + .map(|entry| repo.load_change(&entry.hash).unwrap()) + .collect(); + let base_change = changes + .iter() + .find(|change| { + change.file_ops().iter().any(|ops| { + matches!( + ops.trunk_op(), + Some(atomic_core::crdt::TrunkOp::Create { .. }) + ) + }) + }) + .unwrap(); + let base_hash = base_change.hash().unwrap(); + use atomic_core::pristine::GraphTxnT; + let txn = repo.pristine().read_txn().unwrap(); + let base_id = txn.get_internal(&base_hash).unwrap().unwrap(); + let base_branches: Vec<_> = base_change + .file_ops() + .iter() + .flat_map(|ops| ops.line_ops()) + .map(|line| { + atomic_core::crdt::BranchId::new(base_id, line.branch_id().branch_idx()) + }) + .collect(); + let merged = changes + .iter() + .find(|change| change.hashed.header.message == "resolve all lines") + .unwrap(); + let deleted: std::collections::HashSet<_> = merged + .file_ops() + .iter() + .flat_map(|ops| ops.line_ops()) + .filter(|line| { + matches!(line.operation(), atomic_core::crdt::BranchOp::Delete { .. }) + }) + .map(|line| line.branch_id()) + .collect(); + assert!( + base_branches.iter().all(|branch| deleted.contains(branch)), + "merge must delete every observed base line" + ); + drop(txn); + assert_canonical_rebuild(&repo, &[("file.txt", expected, 0o100644)]); + } + } +} + +#[test] +fn foreign_merge_resolves_directory_claims_and_retains_disjoint_children() { + for reverse in [false, true] { + for eager in [false, true] { + let root = tempfile::tempdir().unwrap(); + let config = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let base = commit_named(&git, &[("keep", b"keep\n", 0o100644)], &[], "base"); + let left = commit_named( + &git, + &[ + ("keep", b"keep\n", 0o100644), + (".vscode/settings.json", b"{}\n", 0o100644), + (".vscode/nested/left", b"left\n", 0o100644), + ], + &[base], + "left directory", + ); + let right = commit_named( + &git, + &[ + ("keep", b"keep\n", 0o100644), + (".vscode/settings.json", b"{}\n", 0o100644), + (".vscode/nested/right", b"right\n", 0o100755), + ], + &[base], + "right directory", + ); + let files: &[(&str, &[u8], i32)] = &[ + ("keep", b"keep\n", 0o100644), + (".vscode/settings.json", b"{}\n", 0o100644), + (".vscode/nested/left", b"left\n", 0o100644), + (".vscode/nested/right", b"right\n", 0o100755), + (".vscode/new-at-merge", b"new\n", 0o100644), + ]; + let parents = if reverse { + vec![right, left] + } else { + vec![left, right] + }; + let tip = commit_named(&git, files, &parents, "merge directory identities"); + let args: &[&str] = if eager { + &["--all", "--with-crdt"] + } else { + &["--all"] + }; + import_with_args(root.path(), &git, tip, config.path(), args); + let mut repo = atomic_repository::Repository::open(root.path()).unwrap(); + repo.create_view_from("roundtrip", "main").unwrap(); + let wc = repo.require_working_copy_id().unwrap(); + repo.switch_view(wc, "roundtrip").unwrap(); + repo.switch_view(wc, "main").unwrap(); + assert_canonical_rebuild(&repo, files); + } + } +} + +#[test] +fn merge_deletion_binds_the_existing_semantic_identity() { + for reverse in [false, true] { + let root = tempfile::tempdir().unwrap(); + let config = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let base = commit_named( + &git, + &[ + ("keep", b"keep\n", 0o100644), + ("removed", b"old\n", 0o100644), + ], + &[], + "base", + ); + let deleted = commit_named( + &git, + &[ + ("keep", b"left\n", 0o100644), + ("removed", b"old\n", 0o100644), + ], + &[base], + "left edit", + ); + let edited = commit_named( + &git, + &[ + ("keep", b"keep\n", 0o100644), + ("removed", b"changed\n", 0o100644), + ], + &[base], + "edit", + ); + let parents = if reverse { + vec![edited, deleted] + } else { + vec![deleted, edited] + }; + let tip = commit_named( + &git, + &[("keep", b"keep\n", 0o100644)], + &parents, + "resolve deletion", + ); + import_with_args( + root.path(), + &git, + tip, + config.path(), + &["--all", "--with-crdt"], + ); + let repo = atomic_repository::Repository::open(root.path()).unwrap(); + let history = repo + .log(atomic_repository::HistoryOptions::default()) + .unwrap(); + let change = history + .iter() + .map(|entry| repo.load_change(&entry.hash).unwrap()) + .find(|change| change.hashed.header.message == "resolve deletion") + .unwrap(); + let ops = change + .file_ops() + .iter() + .find(|ops| ops.path() == "removed") + .unwrap(); + assert!(matches!( + ops.trunk_op(), + Some(atomic_core::crdt::TrunkOp::Delete { .. }) + )); + assert!( + !ops.trunk_id().change_id().is_root(), + "merge deletion must name the prior trunk" + ); + assert!( + !ops.line_ops().is_empty(), + "merge deletion must tombstone prior branches" + ); + assert_canonical_rebuild_without(&repo, &[("keep", b"keep\n", 0o100644)], &["removed"]); + } +} diff --git a/atomic-cli/tests/git_import_generated_modify_test.rs b/atomic-cli/tests/git_import_generated_modify_test.rs new file mode 100644 index 00000000..ba8d2929 --- /dev/null +++ b/atomic-cli/tests/git_import_generated_modify_test.rs @@ -0,0 +1,186 @@ +// Temporary Unix-only Git bridge support to unblock Windows native CI. +// Revisit after Windows support is defined; see the RFC temporary platform policy. +#![cfg(unix)] + +//! Regression (::26 R1 second follow-up): a generated file (lockfile) that +//! the import ADDS through the opaque whole-content fast path and a later +//! commit MODIFIES must import with exact content fidelity. +//! +//! Before the fix, the modify ran a per-line diff against the file's single +//! whole-content branch. The diff's line numbers have no vertices in that +//! shape, the staged render came out truncated (Cargo.lock at 3,050 of +//! 89,750 bytes on a real repository), and the staged synthesis verification +//! refused the commit: +//! +//! ```text +//! partial import failed after 4 landed commit(s): staged synthesis +//! verification failed for ...: staged path 'Cargo.lock' renders 3050 +//! byte(s) but the commit tree expects 89750 byte(s) +//! ``` +//! +//! The fix routes a generated-file modify with bound branches through the +//! sentinel whole-file replace (delete all alive vertices, insert the full +//! content fresh, CRDT ops tombstone the bound branches). + +use std::fs; +use std::path::Path; +use std::process::{Command, Output}; + +const ATOMIC_BIN: &str = env!("CARGO_BIN_EXE_atomic"); + +fn atomic(root: &Path, home: &Path, args: &[&str]) -> Output { + Command::new(ATOMIC_BIN) + .args(args) + .current_dir(root) + .env("HOME", home) + .env("ATOMIC_HOME", home.join(".atomic")) + .output() + .expect("run atomic") +} + +fn atomic_ok(root: &Path, home: &Path, args: &[&str]) { + let output = atomic(root, home, args); + assert!( + output.status.success(), + "atomic {args:?} failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn git(root: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(root) + .env("GIT_AUTHOR_NAME", "Generated Modify Tests") + .env("GIT_AUTHOR_EMAIL", "generated-modify@example.com") + .env("GIT_COMMITTER_NAME", "Generated Modify Tests") + .env("GIT_COMMITTER_EMAIL", "generated-modify@example.com") + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// A deterministic multi-package lockfile. `packages` drives both the line +/// count and the byte count. `bump_from` rewrites the versions of every +/// package at or after that index, so the follow-up commit produces +/// IN-PLACE replace hunks scattered mid-file — the shape that corrupted the +/// staged render (the real Cargo.lock diff replaced lines ~1210-1220 across +/// 14 hunks while the graph held the file as one whole-content vertex). +fn lockfile(packages: usize, bump_from: usize) -> Vec { + let mut content = String::from("# This file is automatically @generated by Cargo.\n"); + content.push_str("version = 3\n\n"); + for package in 0..packages { + let major = if package >= bump_from { 2 } else { 1 }; + content.push_str(&format!( + "[[package]]\nname = \"dep-{package}\"\nversion = \"{major}.{}.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"aaaaaaaa{package:04}bbbbbbbb{package:04}cccccccc{package:04}dddddddd{package:04}\"\ndependencies = [\n \"dep-{}\",\n]\n\n", + package % 10, + package.saturating_sub(1) + )); + } + content.into_bytes() +} + +#[test] +fn git_import_records_a_modified_generated_file_with_exact_content() { + let root = tempfile::tempdir().unwrap().keep(); + let home = tempfile::tempdir().unwrap().keep(); + + git(&root, &["init", "-q", "-b", "main"]); + fs::write( + root.join("Cargo.toml"), + b"[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + let initial_lock = lockfile(40, usize::MAX); + fs::write(root.join("Cargo.lock"), &initial_lock).unwrap(); + git(&root, &["add", "."]); + git(&root, &["commit", "-q", "-m", "initial with lockfile"]); + + // The follow-up commit bumps versions of every package past index 20 + // (in-place mid-file replaces) and adds packages at the end: a + // multi-hunk, size-growing modification of an opaque generated file. + let bumped_lock = lockfile(120, 20); + assert!(bumped_lock.len() > initial_lock.len() * 2); + fs::write(root.join("Cargo.lock"), &bumped_lock).unwrap(); + git(&root, &["add", "."]); + git(&root, &["commit", "-q", "-m", "bump lockfile dependencies"]); + + atomic_ok(&root, &home, &["git", "import", "--no-vault"]); + + let repo = atomic_repository::Repository::open(&root).unwrap(); + let rendered = repo + .get_file_content("Cargo.lock") + .unwrap() + .expect("Cargo.lock must be present after import"); + assert_eq!( + rendered, bumped_lock, + "the imported Cargo.lock must render the exact commit-tree bytes" + ); + + // The untouched tracked file must be unaffected by the replace. + let toml = repo + .get_file_content("Cargo.toml") + .unwrap() + .expect("Cargo.toml must be present after import"); + assert_eq!( + toml, + b"[package]\nname = \"app\"\nversion = \"0.1.0\"\n".to_vec() + ); +} + +#[test] +fn git_import_records_the_real_helpers_history_with_exact_content() { + // The exact `atomic-cli/src/commands/push/helpers.rs` states from the + // atomicdotdev/atomic repository whose import failed at the deletion + // commit: added at the initial commit (18,227 bytes), untouched through + // three commits, modified by a +239-line insertion (26,964 bytes), then + // "AI Cleanup" deleted 70 scattered banner lines (23,819 bytes). The + // import failed with "staged path + // 'atomic-cli/src/commands/push/helpers.rs' renders 27472 byte(s) but + // the commit tree expects 23819 byte(s)". + let c1 = include_bytes!("fixtures/helpers_c1.rs"); + let c5 = include_bytes!("fixtures/helpers_c5.rs"); + let c6 = include_bytes!("fixtures/helpers_c6.rs"); + + let root = tempfile::tempdir().unwrap().keep(); + let home = tempfile::tempdir().unwrap().keep(); + + git(&root, &["init", "-q", "-b", "main"]); + fs::write(root.join("src.rs"), c1).unwrap(); + fs::write(root.join("other.txt"), b"other\n").unwrap(); + git(&root, &["add", "."]); + git(&root, &["commit", "-q", "-m", "initial"]); + + // The insertion commit ("working performance improvements", +239/−0). + fs::write(root.join("src.rs"), c5).unwrap(); + git(&root, &["add", "."]); + git( + &root, + &["commit", "-q", "-m", "working performance improvements"], + ); + + // The deletion commit ("AI Cleanup", +0/−70). + fs::write(root.join("src.rs"), c6).unwrap(); + git(&root, &["add", "."]); + git(&root, &["commit", "-q", "-m", "AI Cleanup"]); + + atomic_ok(&root, &home, &["git", "import", "--no-vault"]); + + let repo = atomic_repository::Repository::open(&root).unwrap(); + let rendered = repo + .get_file_content("src.rs") + .unwrap() + .expect("src.rs must be present after import"); + assert_eq!( + rendered, + c6.to_vec(), + "the imported src.rs must render the exact commit-tree bytes after the real pure-deletion modify" + ); + let other = repo.get_file_content("other.txt").unwrap().unwrap(); + assert_eq!(other, b"other\n".to_vec()); +} diff --git a/atomic-cli/tests/git_import_path_audit_test.rs b/atomic-cli/tests/git_import_path_audit_test.rs new file mode 100644 index 00000000..1453317a --- /dev/null +++ b/atomic-cli/tests/git_import_path_audit_test.rs @@ -0,0 +1,420 @@ +#![cfg(unix)] + +use git2::{Oid, Repository as GitRepository, Signature}; +use std::path::Path; +use std::process::Command; + +fn tree(git: &GitRepository, files: &[(&str, &[u8], i32)]) -> Oid { + let mut builder = git.treebuilder(None).unwrap(); + let mut directories = std::collections::BTreeMap::<&str, Vec<(&str, &[u8], i32)>>::new(); + for &(path, bytes, mode) in files { + if let Some((directory, rest)) = path.split_once('/') { + directories + .entry(directory) + .or_default() + .push((rest, bytes, mode)); + } else { + builder + .insert(path, git.blob(bytes).unwrap(), mode) + .unwrap(); + } + } + for (directory, children) in directories { + builder + .insert(directory, tree(git, &children), 0o040000) + .unwrap(); + } + builder.write().unwrap() +} + +fn commit(git: &GitRepository, files: &[(&str, &[u8], i32)], parents: &[Oid]) -> Oid { + let tree = git.find_tree(tree(git, files)).unwrap(); + let parents: Vec<_> = parents + .iter() + .map(|id| git.find_commit(*id).unwrap()) + .collect(); + let signature = Signature::now("Import regression", "import@example.com").unwrap(); + git.commit( + None, + &signature, + &signature, + "fixture", + &tree, + &parents.iter().collect::>(), + ) + .unwrap() +} + +fn import(root: &Path, git: &GitRepository, tip: Oid, config: &Path) { + git.reference("refs/heads/main", tip, true, "fixture") + .unwrap(); + git.set_head("refs/heads/main").unwrap(); + git.checkout_head(Some(git2::build::CheckoutBuilder::new().force())) + .unwrap(); + let mut command = Command::new( + std::env::var("ATOMIC_AUDIT_BINARY") + .unwrap_or_else(|_| env!("CARGO_BIN_EXE_atomic").to_string()), + ); + for (key, _) in std::env::vars().filter(|(key, _)| key.starts_with("ATOMIC_")) { + command.env_remove(key); + } + let output = command + .current_dir(root) + .env("ATOMIC_CONFIG_DIR", config) + .env("ATOMIC_SERVICE", "local") + .env("ATOMIC_NONINTERACTIVE", "1") + .args(["git", "import", "--no-vault", "--no-color"]) + .args( + std::env::var("ATOMIC_AUDIT_FLAGS") + .unwrap_or_default() + .split_whitespace(), + ) + .output() + .unwrap(); + assert!( + output.status.success(), + "import failed:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn check_case(states: Vec>, merge: bool) { + let root = tempfile::tempdir().unwrap(); + let config = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let mut ids = Vec::new(); + for (i, files) in states.iter().enumerate() { + let parents = if i == 0 { + vec![] + } else if merge && i == states.len() - 1 { + vec![ids[1], ids[2]] + } else if merge && i == 2 { + vec![ids[0]] + } else { + vec![ids[i - 1]] + }; + ids.push(commit(&git, files, &parents)); + } + import(root.path(), &git, *ids.last().unwrap(), config.path()); + let repo = atomic_repository::Repository::open(root.path()).unwrap(); + for (path, bytes, _) in states.last().unwrap() { + let path = atomic_repository::escape_repo_path(path.as_bytes()); + assert_eq!( + repo.get_file_content(&path).unwrap().unwrap(), + *bytes, + "path={path}" + ); + } +} + +#[test] +fn audit_side_parent_space() { + side_only("a b.txt"); +} +#[test] +fn audit_side_parent_percent_escape() { + side_only("a%41.txt"); +} +#[test] +fn audit_side_parent_unicode() { + side_only("中文.txt"); +} +#[test] +fn audit_side_parent_ascii_control() { + side_only("plain.txt"); +} +fn side_only(path: &str) { + check_case( + vec![ + vec![(path, b"base\n", 0o100644)], + vec![(path, b"base\n", 0o100644), ("main", b"main\n", 0o100644)], + vec![(path, b"side\n", 0o100644)], + vec![(path, b"base\n", 0o100644), ("main", b"main\n", 0o100644)], + ], + true, + ); +} + +#[test] +fn audit_two_historical_moves_same_destination() { + check_case( + vec![ + vec![ + ("a", b"alpha file first line\nalpha second line\n", 0o100644), + ("b", b"beta file first line\nbeta second line\n", 0o100644), + ], + vec![ + ( + "same", + b"alpha file first line\nalpha second line\n", + 0o100644, + ), + ("b", b"beta file first line\nbeta second line\n", 0o100644), + ], + vec![("b", b"beta file first line\nbeta second line\n", 0o100644)], + vec![( + "same", + b"beta file first line\nbeta second line\n", + 0o100644, + )], + ], + false, + ); +} +#[test] +fn audit_file_to_directory() { + check_case( + vec![ + vec![("a", b"hello\n", 0o100644)], + vec![("a/file", b"hello\n", 0o100644)], + ], + false, + ); +} +#[test] +fn audit_directory_to_file() { + check_case( + vec![ + vec![("a/file", b"hello\n", 0o100644)], + vec![("a", b"hello\n", 0o100644)], + ], + false, + ); +} +#[test] +fn audit_recreate_and_move() { + check_case( + vec![ + vec![("a", b"old\n", 0o100644), ("keep", b"keep\n", 0o100644)], + vec![("keep", b"keep\n", 0o100644)], + vec![("a", b"new\n", 0o100644), ("keep", b"keep\n", 0o100644)], + vec![("b", b"new\n", 0o100644), ("keep", b"keep\n", 0o100644)], + ], + false, + ); +} +#[test] +fn audit_two_identical_sources_one_destination() { + check_case( + vec![ + vec![ + ("a", b"identical content\n", 0o100644), + ("b", b"identical content\n", 0o100644), + ], + vec![("c", b"identical content\n", 0o100644)], + ], + false, + ); +} + +#[test] +fn audit_foreign_edit_uses_requested_view_identity() { + use atomic_core::crdt::tables::decode_trunk_id; + use atomic_core::pristine::{CrdtTxnT, TreeTxnT}; + let root = tempfile::tempdir().unwrap(); + let config = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let tip = commit(&git, &[("same", b"original alpha\n", 0o100644)], &[]); + import(root.path(), &git, tip, config.path()); + let mut repo = atomic_repository::Repository::open(root.path()).unwrap(); + let wc = repo.require_working_copy_id().unwrap(); + let (canonical_inode, _) = repo + .get_inode_and_position_on_view_excluding("same", "main", &[]) + .unwrap() + .unwrap(); + let canonical_trunk = { + let txn = repo.pristine().read_txn().unwrap(); + decode_trunk_id( + &txn.get_crdt_inode_trunk(canonical_inode.get()) + .unwrap() + .unwrap(), + ) + }; + repo.create_shared_view("side").unwrap(); + repo.switch_view(wc, "side").unwrap(); + std::fs::write(root.path().join("same"), b"replacement beta\n").unwrap(); + repo.add(wc, "same", Default::default()).unwrap(); + repo.record_all(wc, "create new on side").unwrap(); + assert_eq!( + repo.get_inode_and_position_on_view_excluding("same", "main", &[]) + .unwrap() + .unwrap() + .0, + canonical_inode + ); + { + let txn = repo.pristine().read_txn().unwrap(); + assert_ne!(txn.get_inode("same").unwrap().unwrap(), canonical_inode); + } + let record = repo + .record_foreign_modified_file_on_view_excluding( + "same", + b"original alpha\n", + b"edited alpha\n", + false, + "main", + &[], + ) + .unwrap(); + assert_eq!( + record.crdt_ops().unwrap().trunk_id(), + canonical_trunk, + "a foreign edit must follow the requested closure's inode, not current PATH_TRUNK" + ); +} + +#[test] +fn audit_foreign_move_uses_requested_view_identity() { + use atomic_core::crdt::tables::decode_trunk_id; + use atomic_core::pristine::{CrdtTxnT, TreeTxnT}; + let root = tempfile::tempdir().unwrap(); + let config = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let tip = commit(&git, &[("same", b"original alpha\n", 0o100644)], &[]); + import(root.path(), &git, tip, config.path()); + let mut repo = atomic_repository::Repository::open(root.path()).unwrap(); + let wc = repo.require_working_copy_id().unwrap(); + let (canonical_inode, _) = repo + .get_inode_and_position_on_view_excluding("same", "main", &[]) + .unwrap() + .unwrap(); + let canonical_trunk = { + let txn = repo.pristine().read_txn().unwrap(); + decode_trunk_id( + &txn.get_crdt_inode_trunk(canonical_inode.get()) + .unwrap() + .unwrap(), + ) + }; + repo.create_shared_view("side").unwrap(); + repo.switch_view(wc, "side").unwrap(); + std::fs::write(root.path().join("same"), b"replacement beta\n").unwrap(); + repo.add(wc, "same", Default::default()).unwrap(); + repo.record_all(wc, "create new on side").unwrap(); + assert_eq!( + repo.get_inode_and_position_on_view_excluding("same", "main", &[]) + .unwrap() + .unwrap() + .0, + canonical_inode + ); + { + let txn = repo.pristine().read_txn().unwrap(); + assert_ne!(txn.get_inode("same").unwrap().unwrap(), canonical_inode); + } + let record = repo + .record_foreign_moved_file( + "main", + "moved", + "same", + b"original alpha\n", + b"original alpha\n", + false, + ) + .unwrap(); + assert_eq!( + record.crdt_ops().unwrap().trunk_id(), + canonical_trunk, + "a foreign edit must follow the requested closure's inode, not current PATH_TRUNK" + ); +} + +#[test] +fn audit_backslash_and_directory_paths_remain_distinct() { + check_case( + vec![vec![ + ("dir\\file", b"literal backslash\n", 0o100644), + ("dir/file", b"nested file\n", 0o100644), + ]], + false, + ); +} + +#[test] +fn foreign_edit_ignores_other_views_branch_lifecycle() { + let root = tempfile::tempdir().unwrap(); + let config = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let tip = commit(&git, &[("same", b"original alpha\n", 0o100644)], &[]); + import(root.path(), &git, tip, config.path()); + let mut repo = atomic_repository::Repository::open(root.path()).unwrap(); + let expected = repo + .record_foreign_modified_file_on_view_excluding( + "same", + b"original alpha\n", + b"edited alpha\n", + true, + "main", + &[], + ) + .unwrap(); + let wc = repo.require_working_copy_id().unwrap(); + repo.create_view_from("side", "main").unwrap(); + repo.switch_view(wc, "side").unwrap(); + std::fs::write( + root.path().join("same"), + b"unrelated side replacement\nextra line\n", + ) + .unwrap(); + repo.record_all(wc, "side edit").unwrap(); + let actual = repo + .record_foreign_modified_file_on_view_excluding( + "same", + b"original alpha\n", + b"edited alpha\n", + true, + "main", + &[], + ) + .unwrap(); + assert_eq!( + format!("{:?}", actual.crdt_ops()), + format!("{:?}", expected.crdt_ops()), + "a foreign edit must tombstone only the requested closure's branches" + ); + let deleted = repo + .record_foreign_deleted_file_on_view_excluding("same", "main", &[]) + .unwrap(); + let old_branches: Vec<_> = expected + .crdt_ops() + .unwrap() + .line_ops() + .iter() + .filter(|line| matches!(line.operation(), atomic_core::crdt::BranchOp::Delete { .. })) + .map(|line| line.branch_id()) + .collect(); + assert_eq!( + deleted + .crdt_ops() + .unwrap() + .line_ops() + .iter() + .map(|line| line.branch_id()) + .collect::>(), + old_branches + ); +} + +#[test] +fn checked_out_branch_survives_later_sorting_refs() { + let root = tempfile::tempdir().unwrap(); + let config = tempfile::tempdir().unwrap(); + let git = GitRepository::init(root.path()).unwrap(); + let main = commit(&git, &[("main.txt", b"main checkout\n", 0o100644)], &[]); + let side = commit(&git, &[("side.txt", b"other view\n", 0o100644)], &[main]); + git.reference("refs/heads/z-side", side, true, "fixture") + .unwrap(); + import(root.path(), &git, main, config.path()); + let repo = atomic_repository::Repository::open(root.path()).unwrap(); + assert_eq!(repo.current_view(), "main"); + assert_eq!( + std::fs::read(root.path().join("main.txt")).unwrap(), + b"main checkout\n" + ); + assert!(!root.path().join("side.txt").exists()); + assert_eq!( + repo.get_file_content("main.txt").unwrap().unwrap(), + b"main checkout\n" + ); +} diff --git a/atomic-cli/tests/git_import_sibling_closure_regression.rs b/atomic-cli/tests/git_import_sibling_closure_regression.rs new file mode 100644 index 00000000..2c0c0970 --- /dev/null +++ b/atomic-cli/tests/git_import_sibling_closure_regression.rs @@ -0,0 +1,293 @@ +// Temporary Unix-only Git bridge support to unblock Windows native CI. +// Revisit after Windows support is defined; see the RFC temporary platform policy. +#![cfg(unix)] + +//! Regression: a sibling merge leg must never evict its own landed ancestry +//! from the target view, and a leg's foreign-modify semantic resolution must +//! respect the assembly exclusions. +//! +//! Original failure (`git import --all` on the closure walk; the same class +//! stopped atomicdotdev/atomic at commit 19 of 354): bridge sequencing lands +//! base → A → B (sibling of A) → C (child of A) → merge. Two defects made +//! that walk fail closed: +//! +//! 1. B's import superseded (evicted) A's landed change from the view — the +//! eviction treated every assembly exclusion as "history B's Git ancestry +//! replaced", but a sibling leg of the same closure is HEAD-reachable and +//! still needed. C then assembled against the pre-A baseline; its +//! positional anchors diverged and the per-hunk whole-file escalation +//! stacked (three insert hunks each escalated, rendering ~3× the file) +//! until the staged verification refused. +//! 2. B's foreign-modify bound the CRDT trunk's globally-alive branches — +//! including A's excluded line — so B's old-line→branch mapping was off +//! by one and the staged semantic closure rendered B's lines reordered +//! ("render 19 byte(s) ... expects 19 byte(s)"). +//! +//! The fixture below reproduces both in miniature. Pre-fix, `--all` fails at +//! B (semantic closure) or at C (stacked escalation); post-fix, all five +//! commits land, the final render is byte-exact, and the bridge verifies. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +const ATOMIC_BIN: &str = env!("CARGO_BIN_EXE_atomic"); + +fn atomic(root: &Path, home: &Path, args: &[&str]) -> Output { + Command::new(ATOMIC_BIN) + .args(args) + .current_dir(root) + .env("HOME", home) + .env("ATOMIC_HOME", home.join(".atomic")) + .output() + .expect("run atomic") +} + +fn atomic_ok(root: &Path, home: &Path, args: &[&str]) -> String { + let output = atomic(root, home, args); + assert!( + output.status.success(), + "atomic {args:?} failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).into_owned() +} + +fn git(root: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(root) + .env("GIT_AUTHOR_NAME", "Sibling Closure Tests") + .env("GIT_AUTHOR_EMAIL", "sibling-closure@example.com") + .env("GIT_COMMITTER_NAME", "Sibling Closure Tests") + .env("GIT_COMMITTER_EMAIL", "sibling-closure@example.com") + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout) + .expect("utf8 git output") + .trim() + .to_string() +} + +/// Commit with a controlled committer date (the deterministic bridge +/// sequencing's first tie-break), so the sibling lands between the parent +/// and its child. +fn commit_at(root: &Path, date: &str, message: &str) -> String { + git(root, &["add", "."]); + let output = Command::new("git") + .args(["commit", "-q", "-m", message]) + .current_dir(root) + .env("GIT_AUTHOR_NAME", "Sibling Closure Tests") + .env("GIT_AUTHOR_EMAIL", "sibling-closure@example.com") + .env("GIT_COMMITTER_NAME", "Sibling Closure Tests") + .env("GIT_COMMITTER_EMAIL", "sibling-closure@example.com") + .env("GIT_COMMITTER_DATE", date) + .env("GIT_AUTHOR_DATE", date) + .output() + .expect("run git"); + assert!( + output.status.success(), + "git commit '{message}' failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + git(root, &["rev-parse", "HEAD"]) +} + +struct SiblingFixture { + root: PathBuf, + home: PathBuf, + base: String, + a: String, + b: String, + c: String, + merge: String, +} + +/// base → A (main) ; B (side, sibling of A) ; C (child of A, two +/// non-adjacent insertions) ; merge(side into main). One local branch. +fn sibling_fixture() -> SiblingFixture { + let root = tempfile::tempdir().unwrap().keep(); + let home = tempfile::tempdir().unwrap().keep(); + fs::create_dir_all(root.join("f")).unwrap(); + git(&root, &["init", "-q", "-b", "main"]); + + fs::write(root.join("f/a.txt"), b"line1\nline9\n").unwrap(); + let base = commit_at(&root, "2026-09-11T09:00:00 +0000", "base"); + + fs::write(root.join("f/a.txt"), b"line1\nA-edit\nline9\n").unwrap(); + let a = commit_at(&root, "2026-09-11T09:30:00 +0000", "A edit"); + + git(&root, &["checkout", "-q", "-b", "side", "HEAD~1"]); + fs::write(root.join("f/a.txt"), b"line1\nline9\nB-edit\n").unwrap(); + let b = commit_at(&root, "2026-09-11T10:00:00 +0000", "B edit"); + + git(&root, &["checkout", "-q", "main"]); + // Two non-adjacent insertions: two insert hunks, so a per-hunk + // whole-file escalation cannot save a regressed baseline single-handedly + // (it would stack). + fs::write(root.join("f/a.txt"), b"C0\nline1\nA-edit\nC2\nline9\nC4\n").unwrap(); + let c = commit_at(&root, "2026-09-11T11:00:00 +0000", "C edit"); + + let merge = if Command::new("git") + .args(["merge", "--no-ff", "side", "-m", "merge side"]) + .current_dir(&root) + .env("GIT_AUTHOR_NAME", "Sibling Closure Tests") + .env("GIT_AUTHOR_EMAIL", "sibling-closure@example.com") + .env("GIT_COMMITTER_NAME", "Sibling Closure Tests") + .env("GIT_COMMITTER_EMAIL", "sibling-closure@example.com") + .output() + .expect("git merge") + .status + .success() + { + git(&root, &["rev-parse", "HEAD"]) + } else { + fs::write( + root.join("f/a.txt"), + b"C0\nline1\nA-edit\nC2\nline9\nC4\nB-edit\n", + ) + .unwrap(); + git(&root, &["add", "."]); + git(&root, &["commit", "-q", "-m", "merge side"]); + git(&root, &["rev-parse", "HEAD"]) + }; + git(&root, &["branch", "-D", "side"]); + + SiblingFixture { + root, + home, + base, + a, + b, + c, + merge, + } +} + +fn imported_change_of( + repo: &atomic_repository::Repository, + view: &str, + sha: &str, +) -> atomic_core::change::Change { + let entries = repo.effective_history(Some(view)).unwrap(); + for entry in entries { + let change = repo.load_change(&entry.hash).unwrap(); + let imported = change + .unhashed + .as_ref() + .and_then(|value| value.get("git")) + .and_then(|git| git.get("sha")) + .and_then(|value| value.as_str()); + if imported == Some(sha) { + return change; + } + } + panic!("no imported change for git commit {sha}") +} + +#[test] +fn all_import_sibling_leg_never_evicts_its_own_ancestry() { + let fixture = sibling_fixture(); + let root = fixture.root.as_path(); + let home = fixture.home.as_path(); + + // Pre-fix, this failed at B ("staged semantic closure ... render 19 + // byte(s) ... expects 19 byte(s)") or at C (stacked whole-file + // escalations rendering ~3x the file). + let summary = atomic_ok(root, home, &["git", "import", "--all", "--no-vault"]); + assert!( + summary.contains("5 total changes"), + "expected the complete closure imported: {summary}" + ); + + // Every closure commit — including the sibling leg B and the child C of + // the leg A that B's import would previously have evicted — holds a + // change in the view. + let repo = atomic_repository::Repository::open(root).unwrap(); + for (sha, label) in [ + (&fixture.base, "base"), + (&fixture.a, "A edit"), + (&fixture.b, "B edit"), + (&fixture.c, "C edit"), + (&fixture.merge, "merge side"), + ] { + let change = imported_change_of(&repo, "main", sha); + assert_eq!( + change.hashed.header.message.lines().next(), + Some(label), + "commit {label} ({sha}) must be imported" + ); + } + drop(repo); + + // The materialized working copy is byte-exact against the merge tree. + let expected = fs::read(root.join("f/a.txt")).unwrap(); + let git_tree = { + let out = Command::new("git") + .args(["show", "HEAD:f/a.txt"]) + .current_dir(root) + .output() + .expect("git show"); + out.stdout + }; + assert_eq!( + expected, git_tree, + "materialized f/a.txt must equal the Git tree byte-for-byte" + ); + + // Both CLIs agree the workspace is clean, and the bridge verifies. + let status = atomic_ok(root, home, &["status"]); + assert!( + status.contains("nothing to record"), + "atomic status must be clean after import, got: {status}" + ); + atomic_ok(root, home, &["git", "bridge", "verify"]); + + // Incremental re-import is idempotent. + let again = atomic_ok( + root, + home, + &["git", "import", "--all", "--incremental", "--no-vault"], + ); + assert!( + !again.contains("6 total changes"), + "incremental import must not synthesize new changes: {again}" + ); + assert_eq!(fs::read(root.join("f/a.txt")).unwrap(), git_tree); +} + +#[test] +fn plain_import_stays_a_first_parent_walk() { + // The same fixture through the plain (single-branch) import: the walk is + // the documented linearized first-parent chain (base, A, C, merge — the + // sibling leg's internal commits arrive through the merge's first-parent + // diff), and the result must still be clean, verified, and byte-exact. + let fixture = sibling_fixture(); + let root = fixture.root.as_path(); + let home = fixture.home.as_path(); + + atomic_ok(root, home, &["git", "import", "--no-vault"]); + + let status = atomic_ok(root, home, &["status"]); + assert!( + status.contains("nothing to record"), + "atomic status must be clean after plain import, got: {status}" + ); + atomic_ok(root, home, &["git", "bridge", "verify"]); + let on_disk = fs::read(root.join("f/a.txt")).unwrap(); + let git_tree = { + let out = Command::new("git") + .args(["show", "HEAD:f/a.txt"]) + .current_dir(root) + .output() + .expect("git show"); + out.stdout + }; + assert_eq!(on_disk, git_tree); +} diff --git a/atomic-cli/tests/git_import_sibling_leg_modify_regression.rs b/atomic-cli/tests/git_import_sibling_leg_modify_regression.rs new file mode 100644 index 00000000..7e2a641c --- /dev/null +++ b/atomic-cli/tests/git_import_sibling_leg_modify_regression.rs @@ -0,0 +1,258 @@ +// Temporary Unix-only Git bridge support to unblock Windows native CI. +// Revisit after Windows support is defined; see the RFC temporary platform policy. +#![cfg(unix)] + +//! Regression: a sibling leg's landed change must never be evicted from the +//! target view mid-walk, because a later commit on the evicted leg assembles +//! against the missing baseline. +//! +//! Original failure (`git import --all` on atomic's own history, commit 22 +//! of 356: 5146c474 "perf: eliminate filesystem I/O in git import Phase 2"; +//! reproduced in miniature here): the deterministic sequencing interleaves +//! the legs of a yet-unmerged fork by committer time — +//! +//! ```text +//! base ── A ── C (child of A, modifies A's file) ── merge +//! \── B (sibling of A, later committer time) +//! ``` +//! +//! B's import assembly-excludes A (correct per-leg isolation), but the +//! synthesis ALSO superseded the exclusion: A left the target view. When C +//! (child of A, next in committer-time order) imported its Git diff — a +//! modify of a file A created — Atomic's projected baseline no longer +//! contained the file (its creating change was evicted), so the modify was +//! synthesized with a placeholder trunk. The staged semantic closure then +//! failed closed: +//! +//! ```text +//! branch BranchId(4, 0) claims a different trunk than the closure replayed +//! for 'f/new.txt'; branch ownership must match the closure +//! ``` +//! +//! The fix: assembly exclusions whose Git origin is inside the import +//! target's full closure are sibling legs of the SAME walk — never +//! superseded from the view (the merge resolution re-references them via +//! `ensure_union_state`). Only origins outside the target closure +//! (genuinely replaced history, e.g. after a force-push rewrite) are +//! superseded. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +const ATOMIC_BIN: &str = env!("CARGO_BIN_EXE_atomic"); + +fn atomic(root: &Path, home: &Path, args: &[&str]) -> Output { + Command::new(ATOMIC_BIN) + .args(args) + .current_dir(root) + .env("HOME", home) + .env("ATOMIC_HOME", home.join(".atomic")) + .output() + .expect("run atomic") +} + +fn atomic_ok(root: &Path, home: &Path, args: &[&str]) -> String { + let output = atomic(root, home, args); + assert!( + output.status.success(), + "atomic {args:?} failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).into_owned() +} + +fn git(root: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(root) + .env("GIT_AUTHOR_NAME", "Sibling Eviction Tests") + .env("GIT_AUTHOR_EMAIL", "sibling-eviction@example.com") + .env("GIT_COMMITTER_NAME", "Sibling Eviction Tests") + .env("GIT_COMMITTER_EMAIL", "sibling-eviction@example.com") + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout) + .expect("utf8 git output") + .trim() + .to_string() +} + +fn commit_at(root: &Path, date: &str, message: &str) -> String { + git(root, &["add", "."]); + let output = Command::new("git") + .args(["commit", "-q", "-m", message]) + .current_dir(root) + .env("GIT_AUTHOR_NAME", "Sibling Eviction Tests") + .env("GIT_AUTHOR_EMAIL", "sibling-eviction@example.com") + .env("GIT_COMMITTER_NAME", "Sibling Eviction Tests") + .env("GIT_COMMITTER_EMAIL", "sibling-eviction@example.com") + .env("GIT_COMMITTER_DATE", date) + .env("GIT_AUTHOR_DATE", date) + .output() + .expect("run git"); + assert!( + output.status.success(), + "git commit '{message}' failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + git(root, &["rev-parse", "HEAD"]) +} + +struct Fixture { + root: PathBuf, + home: PathBuf, + commits: Vec<(String, &'static str)>, +} + +/// base → A (creates f/new.txt) ; B (sibling of A, edits f/a.txt) ; +/// C (child of A, modifies f/new.txt with context-preserving hunks) ; +/// merge (side into main). One local branch. The sequencing order is +/// base, A, B, C, merge (committer time), so B imports while A is landed +/// and C imports after B's leg walk. +fn fixture() -> Fixture { + let root = tempfile::tempdir().unwrap().keep(); + let home = tempfile::tempdir().unwrap().keep(); + fs::create_dir_all(root.join("f")).unwrap(); + git(&root, &["init", "-q", "-b", "main"]); + + fs::write(root.join("f/a.txt"), b"line1\nline2\nline3\n").unwrap(); + let base = commit_at(&root, "2026-09-11T09:00:00 +0000", "base"); + + fs::write( + root.join("f/new.txt"), + b"l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\n", + ) + .unwrap(); + let a = commit_at(&root, "2026-09-11T09:30:00 +0000", "A add new"); + + git(&root, &["checkout", "-q", "-b", "side", "HEAD~1"]); + fs::write(root.join("f/a.txt"), b"line1\nB-edit\nline3\n").unwrap(); + let b = commit_at(&root, "2026-09-11T10:00:00 +0000", "B edit"); + + git(&root, &["checkout", "-q", "main"]); + // A context-preserving modify of A's file: line 2 changes and the last + // line gains text, so the delta is a hunk edit — not a whole-file + // rewrite that a sentinel fallback could mask. + fs::write( + root.join("f/new.txt"), + b"l1\nl2 changed\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10 extra\n", + ) + .unwrap(); + let c = commit_at(&root, "2026-09-11T11:00:00 +0000", "C modify new"); + + git(&root, &["merge", "--no-ff", "side", "-m", "merge side"]); + let merge = git(&root, &["rev-parse", "HEAD"]); + git(&root, &["branch", "-D", "side"]); + + Fixture { + root, + home, + commits: vec![ + (base, "base"), + (a, "A add new"), + (b, "B edit"), + (c, "C modify new"), + (merge, "merge side"), + ], + } +} + +fn imported_change_of( + repo: &atomic_repository::Repository, + view: &str, + sha: &str, +) -> atomic_core::change::Change { + let entries = repo.effective_history(Some(view)).unwrap(); + for entry in entries { + let change = repo.load_change(&entry.hash).unwrap(); + let imported = change + .unhashed + .as_ref() + .and_then(|value| value.get("git")) + .and_then(|git| git.get("sha")) + .and_then(|value| value.as_str()); + if imported == Some(sha) { + return change; + } + } + panic!("no imported change for git commit {sha}") +} + +#[test] +fn all_import_never_supersedes_sibling_legs_of_the_target_closure() { + let fixture = fixture(); + let root = fixture.root.as_path(); + let home = fixture.home.as_path(); + + // Pre-fix signature: fail-closed at C — "branch BranchId(4, 0) claims a + // different trunk than the closure replayed for 'f/new.txt'". + let summary = atomic_ok(root, home, &["git", "import", "--all", "--no-vault"]); + assert!( + summary.contains("Imported "), + "expected an import summary: {summary}" + ); + + // Every closure commit — including A (whose landed change B's leg walk + // previously superseded) and C (A's child) — holds a change in the view. + let repo = atomic_repository::Repository::open(root).unwrap(); + for (sha, label) in &fixture.commits { + let change = imported_change_of(&repo, "main", sha); + assert_eq!( + change.hashed.header.message.lines().next(), + Some(*label), + "commit {label} ({sha}) must stay a view member" + ); + } + drop(repo); + + // The merged tree materializes byte-exactly for both files. + for file in ["f/a.txt", "f/new.txt"] { + let git_tree = { + let out = Command::new("git") + .args(["show", &format!("HEAD:{file}")]) + .current_dir(root) + .output() + .expect("git show"); + out.stdout + }; + assert_eq!( + fs::read(root.join(file)).unwrap(), + git_tree, + "materialized {file} must equal the Git tree byte-for-byte" + ); + } + + // Both CLIs agree the workspace is clean, and the bridge verifies. + let status = atomic_ok(root, home, &["status"]); + assert!( + status.contains("nothing to record"), + "atomic status must be clean after import, got: {status}" + ); + atomic_ok(root, home, &["git", "bridge", "verify"]); + + // Incremental re-import is idempotent — the retained sibling legs are + // already-imported markers, not new history to re-derive. + let before_count = atomic_ok(root, home, &["log", "-f", "json"]) + .matches("\"sequence\":") + .count(); + atomic_ok( + root, + home, + &["git", "import", "--all", "--incremental", "--no-vault"], + ); + let after_count = atomic_ok(root, home, &["log", "-f", "json"]) + .matches("\"sequence\":") + .count(); + assert_eq!( + before_count, after_count, + "incremental import must not change the view membership" + ); + atomic_ok(root, home, &["git", "bridge", "verify"]); +} diff --git a/atomic-cli/tests/workspace_txn_boundaries_cb5c_test.rs b/atomic-cli/tests/workspace_txn_boundaries_cb5c_test.rs index a9410f62..f2bc148c 100644 --- a/atomic-cli/tests/workspace_txn_boundaries_cb5c_test.rs +++ b/atomic-cli/tests/workspace_txn_boundaries_cb5c_test.rs @@ -538,19 +538,14 @@ fn agent_turn_end_under_a_git_owned_partial_operation_persists_evidence_only() { #[test] fn preservation_failure_persists_a_durable_incomplete_reason_and_returns_nonzero() { let fixture = Fixture::colocated(); - // Make the Git object database unwritable so WIP capture cannot persist. - // Loose objects are written as temp files inside `.git/objects` before - // being renamed into their fan-out directory, so removing the owner write - // bit from the object database root fails every object write (commit-tree, - // write-tree) without disturbing refs or reflogs of already-created objects. - let objects_dir = fixture.root().join(".git/objects"); - use std::os::unix::fs::PermissionsExt as _; - let original_mode = fs::metadata(&objects_dir) - .expect("git objects metadata") - .permissions() - .mode(); - let readonly = fs::Permissions::from_mode(original_mode & !0o200); - fs::set_permissions(&objects_dir, readonly).expect("read-only git objects"); + let pending = b"pending tracked work must survive failed preservation\n"; + fs::write(fixture.root().join("tracked.txt"), pending).expect("write pending work"); + // A valid ref at this namespace root deterministically prevents creating + // any recovery ref beneath it, while keeping Git readable and the bridge + // checkpoint valid. Making only `.git/objects` read-only is insufficient: + // writes can still succeed in existing writable fan-out directories, + // depending on the newly generated object's hash and host permissions. + fixture.git_ok(&["update-ref", "refs/atomic/wip", "HEAD"]); let output = fixture.atomic_with_stdin( &["agent", "hooks", "claude-code", "session-end", "--json"], @@ -561,10 +556,6 @@ fn preservation_failure_persists_a_durable_incomplete_reason_and_returns_nonzero "turn-end unexpectedly reported success when preservation failed" ); - // Restore permissions before reading the durable session record. - let restore = fs::Permissions::from_mode(original_mode); - fs::set_permissions(&objects_dir, restore).expect("restore git permissions"); - let session_path = fixture .root() .join(".atomic/sessions/preservation-failure.json"); @@ -579,6 +570,11 @@ fn preservation_failure_persists_a_durable_incomplete_reason_and_returns_nonzero ); assert_eq!(session["turn_count"], 0); assert_eq!(session["recorded_change_hashes"], serde_json::json!([])); + assert_eq!( + fs::read(fixture.root().join("tracked.txt")).unwrap(), + pending, + "failed preservation must not discard pending tracked bytes" + ); } #[test] diff --git a/atomic-core/src/apply/file_ops.rs b/atomic-core/src/apply/file_ops.rs index 237e8f3e..e4122b14 100644 --- a/atomic-core/src/apply/file_ops.rs +++ b/atomic-core/src/apply/file_ops.rs @@ -1042,8 +1042,11 @@ fn update_trunk_path( // Get existing trunk if let Some(mut serialized) = txn.get_crdt_trunk(&key)? { - // Remove old path index - txn.del_crdt_path_trunk(&serialized.path)?; + // Another identity may now occupy the old path in the ambient index. + // Moving this trunk must not remove that other candidate's binding. + if txn.get_trunk_by_path(&serialized.path)? == Some(trunk_id) { + txn.del_crdt_path_trunk(&serialized.path)?; + } // Update path serialized.path = new_path.to_string(); @@ -1166,6 +1169,37 @@ fn update_leaf_content( mod tests { use super::*; + #[test] + fn moving_old_identity_preserves_another_paths_index_binding() { + use crate::pristine::{CrdtTxnT, Pristine}; + let temp = tempfile::tempdir().unwrap(); + let pristine = Pristine::open(temp.path().join("move.redb")).unwrap(); + let mut txn = pristine.write_txn().unwrap(); + for change in [NodeId::new(1), NodeId::new(2)] { + let inode = txn.alloc_inode().unwrap(); + apply_file_ops_batched_with_inodes( + &mut txn, + change, + &[FileOps::create( + TrunkId::new(NodeId::ROOT, 0), + "same".into(), + None, + )], + &HashMap::from([("same".to_string(), inode)]), + ) + .unwrap(); + } + update_trunk_path(&mut txn, TrunkId::new(NodeId::new(1), 0), "moved").unwrap(); + assert_eq!( + txn.get_trunk_by_path("same").unwrap(), + Some(TrunkId::new(NodeId::new(2), 0)) + ); + assert_eq!( + txn.get_trunk_by_path("moved").unwrap(), + Some(TrunkId::new(NodeId::new(1), 0)) + ); + } + #[test] fn explicit_create_inodes_survive_opposite_orders_and_ambient_paths() { use crate::pristine::{CrdtTxnT, Pristine}; diff --git a/atomic-core/src/output/alive/retrieve/mod.rs b/atomic-core/src/output/alive/retrieve/mod.rs index 58c36207..4a5a03cd 100644 --- a/atomic-core/src/output/alive/retrieve/mod.rs +++ b/atomic-core/src/output/alive/retrieve/mod.rs @@ -128,6 +128,15 @@ pub fn retrieve_graph( let mut chain_memo: HashMap<(GraphNode, GraphNode), bool> = HashMap::new(); // Shared causal-dominance memo across every aliveness check in this walk. let mut dep_memo: HashMap<(NodeId, NodeId), bool> = HashMap::new(); + let mut dead_contexts = std::collections::HashSet::new(); + // A liveness query needs only reachability to any live bytes, independent + // of the owner-specific ordering of bypass edges. Share dead visits across + // root edges so overlapping deleted histories are scanned once. + let mut liveness_dead_seen = std::collections::HashSet::new(); + // Negative reachability proofs are independent of the bypass owner. + // Reuse only fully explored subgraphs with no live successor at all; + // positive walks retain their owner-specific ordering and attachment. + let mut empty_dead_subgraphs = std::collections::HashSet::new(); // Add dummy span at index 0 result.graph.push_vertex(AliveVertex::DUMMY); @@ -160,14 +169,6 @@ pub fn retrieve_graph( // decide whether the deletion "has happened" from our view's perspective. let include_deleted = options.include_deleted_edges(); - // Deferred bypass attachments: when a vertex has both direct alive - // children AND bypass children (live successors discovered through a - // dead chain), we attach the bypass children to the LAST direct - // child rather than to this vertex. Since that direct child is - // still on the stack and hasn't been processed yet, we record the - // attachment here and apply it when the direct child pops. - let mut pending_bypass: HashMap> = HashMap::new(); - while let Some(vid) = stack.pop() { // Liveness fast path (review E4): the moment the traversal holds // one alive non-empty vertex, the caller's "any alive bytes?" @@ -205,24 +206,13 @@ pub fn retrieve_graph( // bypass children (live successors discovered by walking through // adjacent dead vertices) separately. // - // The byte-graph alone does not encode whether a bypass-child - // (e.g. a replacement of a later line in the dead chain) is - // concurrent with a direct child (e.g. a replacement of an - // earlier line). In practice, when both exist, the bypass child - // sits *after* the direct child in the linearised output — the - // direct child is the FIRST replacement off this vertex, and the - // bypass surfaces a deeper replacement reachable through the - // dead chain that the direct child also leads into. - // - // After processing all edges we either: - // * attach bypass children directly when there is no direct - // alive child, or - // * attach them to the last direct alive child so they appear - // downstream rather than as a concurrent fork. let mut children_to_add: Vec<(Option, VertexId)> = Vec::new(); let mut bypass_children: Vec = Vec::new(); for edge in forward_edges { + if options.stop_at_first_content && result.graph.total_bytes() > 0 { + break; + } result.edges_traversed += 1; let dest_pos = edge.dest; @@ -261,6 +251,7 @@ pub fn retrieve_graph( )?; if !dest_alive { + dead_contexts.insert((vid, resolved_vertex)); let successors = walk_through_dead( txn, &options, @@ -271,6 +262,8 @@ pub fn retrieve_graph( &mut alive_memo, &mut chain_memo, &mut dep_memo, + &mut liveness_dead_seen, + &mut empty_dead_subgraphs, &mut result, )?; // Collect bypass children; placement decided after @@ -308,6 +301,7 @@ pub fn retrieve_graph( // emitting it, but walk THROUGH it to find live // successors so we don't lose unrelated downstream // content (no pseudo-edges in the additive model). + dead_contexts.insert((vid, resolved_vertex)); let successors = walk_through_dead( txn, &options, @@ -318,6 +312,8 @@ pub fn retrieve_graph( &mut alive_memo, &mut chain_memo, &mut dep_memo, + &mut liveness_dead_seen, + &mut empty_dead_subgraphs, &mut result, )?; for succ_vid in successors { @@ -333,6 +329,7 @@ pub fn retrieve_graph( } else { // Span is not alive, skip — but still walk through // to find live successors. + dead_contexts.insert((vid, resolved_vertex)); let successors = walk_through_dead( txn, &options, @@ -343,6 +340,8 @@ pub fn retrieve_graph( &mut alive_memo, &mut chain_memo, &mut dep_memo, + &mut liveness_dead_seen, + &mut empty_dead_subgraphs, &mut result, )?; for succ_vid in successors { @@ -379,47 +378,15 @@ pub fn retrieve_graph( } } - // Place bypass children. - // - // If this vertex has at least one direct alive child, defer the - // bypass children so they attach to the LAST direct child when - // it's processed — they sit downstream of it in the linearised - // output, not as concurrent siblings. If there is no direct - // alive child, attach them directly so they appear after this - // vertex. - if !bypass_children.is_empty() { - // In the insert-path graph, bypass successors reached through - // dead chains belong after the downstream surviving child, not - // alongside the first direct child. Attaching them to the - // last direct child preserves linear file order when a change - // both replaces an earlier line and carries through to later - // descendants in the same chain. - let direct_child = - children_to_add - .iter() - .rev() - .find_map(|(_, v)| if !v.is_dummy() { Some(*v) } else { None }); - - if let Some(direct) = direct_child { - pending_bypass - .entry(direct) - .or_default() - .extend(bypass_children.iter().copied()); - } else { - for succ_vid in &bypass_children { - if !children_to_add.iter().any(|(_, v)| v == succ_vid) { - children_to_add.push((None, *succ_vid)); - } - } - } - } - - // Apply any deferred bypass attachments for THIS vertex. - if let Some(deferred) = pending_bypass.remove(&vid) { - for succ_vid in deferred { - if !children_to_add.iter().any(|(_, v)| *v == succ_vid) { - children_to_add.push((None, succ_vid)); - } + // Contract a dead chain at the predecessor from which it was reached. + // A bypass successor may precede a direct child, or be concurrent with + // it. Attaching it to an arbitrary direct child invents an ordering + // edge (and can create a cycle). It can also lose the attachment if + // that child has already been processed. Keep the canonical reachability + // relation here; the output graph handles redundant paths and forks. + for successor in bypass_children { + if !children_to_add.iter().any(|(_, child)| *child == successor) { + children_to_add.push((None, successor)); } } @@ -439,9 +406,108 @@ pub fn retrieve_graph( } } + // The opt-in boolean query does not need to reconstruct line ordering. + if options.stop_at_first_content { + return Ok(result); + } + + // Adjacent independent replacements leave each new span anchored to + // deleted old context. For old A -> B, replacing A with X and B with Y + // records X -> old B and old A -> Y. Contracting old spans alone loses + // X -> Y. Recover that ordering only with both replacement witnesses: + // Y was inserted where B was deleted, and X descends from the replacement + // of B's old predecessor A. Unrelated same-anchor inserts remain forks. + // Resolve after traversal so discovery/edge order cannot hide a claimant. + let mut replacement_cache = HashMap::new(); + for (owner_vid, dead) in dead_contexts { + let owner = result.graph.get_vertex(owner_vid).node; + for (predecessor, replacement) in + replacement_roots(txn, &options, dead, &mut replacement_cache)? + { + if memo_vertex_alive(&options, txn, predecessor, &mut alive_memo, &mut dep_memo)? { + continue; + } + let Some(&replacement_vid) = cache.get(&replacement) else { + continue; + }; + if owner == replacement + || memo_visible_chain_reaches(txn, &options, replacement, owner, &mut chain_memo)? + { + continue; + } + let mut replaces_predecessor = false; + for (_, predecessor_replacement) in + replacement_roots(txn, &options, predecessor, &mut replacement_cache)? + { + if memo_visible_chain_reaches( + txn, + &options, + predecessor_replacement, + owner, + &mut chain_memo, + )? { + replaces_predecessor = true; + break; + } + } + if replaces_predecessor + && !result + .graph + .children(owner_vid) + .any(|(_, child)| *child == replacement_vid) + { + result + .graph + .get_vertex_mut(owner_vid) + .unwrap() + .extra + .push((None, replacement_vid)); + } + } + } Ok(result) } +/// Pair a deleted incoming binding with new content inserted by the same +/// change at that exact predecessor. These are canonical graph witnesses, +/// independent of path indexes, local insertion order, and Git metadata. +type ReplacementRoot = (GraphNode, GraphNode); +type ReplacementRootsMemo = HashMap, Vec>; + +fn replacement_roots( + txn: &T, + options: &RetrieveOptions, + dead: GraphNode, + memo: &mut ReplacementRootsMemo, +) -> Result, PristineError> { + if let Some(roots) = memo.get(&dead) { + return Ok(roots.clone()); + } + let mut roots = Vec::new(); + for parent in txn.iter_parents(dead, true)? { + if parent.kind != crate::types::ParentEdgeKind::BlockDeleted + || !options.passes_filter(parent.introduced_by) + { + continue; + } + let predecessor = txn.find_block_end(parent.dest)?; + if !options.passes_filter(predecessor.change) { + continue; + } + for edge in txn.iter_forward(predecessor, false)? { + if edge.introduced_by != parent.introduced_by || !options.is_edge_alive(&edge) { + continue; + } + let replacement = txn.find_block(edge.dest)?; + if replacement.change == parent.introduced_by && replacement != dead { + roots.push((predecessor, replacement)); + } + } + } + memo.insert(dead, roots.clone()); + Ok(roots) +} + /// Walk through a dead vertex's forward edges to find live successors. /// /// In the additive edge model there are no PSEUDO reconnection edges, @@ -484,10 +550,17 @@ fn walk_through_dead( alive_memo: &mut std::collections::HashMap, bool>, chain_memo: &mut std::collections::HashMap<(GraphNode, GraphNode), bool>, dep_memo: &mut std::collections::HashMap<(NodeId, NodeId), bool>, + liveness_dead_seen: &mut std::collections::HashSet>, + empty_dead_subgraphs: &mut std::collections::HashSet>, result: &mut RetrieveResult, ) -> Result, PristineError> { use std::collections::HashSet; let mut live_successors: Vec = Vec::new(); + if empty_dead_subgraphs.contains(&dead_vertex) + || (options.stop_at_first_content && !liveness_dead_seen.insert(dead_vertex)) + { + return Ok(live_successors); + } // BFS through dead vertices, recording live ones we encounter. // @@ -506,6 +579,7 @@ fn walk_through_dead( let mut queue: Vec> = vec![dead_vertex]; seen.insert(dead_vertex); dead_visited.insert(dead_vertex); + let mut saw_live_successor = false; while let Some(current) = queue.pop() { // Walk current's forward edges (include deleted so we can chain @@ -516,7 +590,10 @@ fn walk_through_dead( let next_pos = edge.dest; let next_vertex = txn.find_block(next_pos)?; - if seen.contains(&next_vertex) { + if seen.contains(&next_vertex) + || empty_dead_subgraphs.contains(&next_vertex) + || (options.stop_at_first_content && liveness_dead_seen.contains(&next_vertex)) + { continue; } seen.insert(next_vertex); @@ -530,6 +607,7 @@ fn walk_through_dead( let alive = memo_vertex_alive(options, txn, next_vertex, alive_memo, dep_memo)?; if alive { + saw_live_successor = true; // Live successor. Only surface it as a bypass child when // there is no OTHER alive parent outside the dead chain. // Ancestors of `owner_parent` in the already-built alive @@ -586,6 +664,10 @@ fn walk_through_dead( new_id }; + if options.stop_at_first_content && result.graph.total_bytes() > 0 { + return Ok(vec![vid]); + } + if !has_alive_alt_parent && !live_successors.contains(&vid) { let mut shadowed_by_existing = false; for existing_vid in live_successors.iter().copied() { @@ -631,11 +713,19 @@ fn walk_through_dead( // CRDT conflicts). // Still dead — keep walking through it. dead_visited.insert(next_vertex); + if options.stop_at_first_content { + liveness_dead_seen.insert(next_vertex); + } queue.push(next_vertex); } } } + if !options.stop_at_first_content && !saw_live_successor { + // Every outgoing route was exhausted under the same immutable + // transaction/filter. This says nothing about positive bypass paths. + empty_dead_subgraphs.extend(dead_visited); + } Ok(live_successors) } @@ -678,7 +768,7 @@ fn visible_chain_reaches( } for edge in txn.iter_forward(current, true)? { - if edge.kind.is_pseudo() { + if edge.kind.is_pseudo() || !options.passes_filter(edge.introduced_by) { continue; } diff --git a/atomic-core/src/output/alive/retrieve/tests.rs b/atomic-core/src/output/alive/retrieve/tests.rs index 7754feb2..e86a8408 100644 --- a/atomic-core/src/output/alive/retrieve/tests.rs +++ b/atomic-core/src/output/alive/retrieve/tests.rs @@ -33,6 +33,7 @@ impl Fault { #[derive(Default)] struct FaultGraphTxn { + forward_calls: std::cell::Cell, edges: HashMap, Vec>, blocks: HashMap, GraphNode>, block_ends: HashMap, GraphNode>, @@ -92,6 +93,9 @@ impl GraphTxnT for FaultGraphTxn { min_flag: EdgeFlags, max_flag: EdgeFlags, ) -> Result { + if !min_flag.contains(EdgeFlags::PARENT) { + self.forward_calls.set(self.forward_calls.get() + 1); + } let item_fault = if min_flag.contains(EdgeFlags::PARENT) { self.parent_item_faults.get(&node) } else { @@ -958,3 +962,302 @@ fn test_retrieve_result_fields() { assert_eq!(result.positions_visited, 100); assert_eq!(result.edges_traversed, 500); } + +// A deleted intermediate node must be contracted at its actual predecessor. +// In Hyperfine commit 65 the direct child is downstream of the bypass child; +// deferring the bypass to that direct child creates a cycle that is absent +// from the canonical graph. +fn bypass_fixture( + ordered: bool, + reverse_edges: bool, +) -> ( + FaultGraphTxn, + Position, + GraphNode, + GraphNode, +) { + let mut txn = FaultGraphTxn::default(); + let start = test_pos(1, 0); + let root = start.inode_node(); + let dead = test_node(2, 0, 10); + let bypass = test_node(3, 0, 10); + let direct = test_node(4, 0, 10); + for node in [root, dead, bypass, direct] { + txn.resolve_block(node.start_pos(), node); + txn.resolve_block_end(node.end_pos(), node); + } + for (from, to, flags) in [ + (root, dead, EdgeFlags::BLOCK | EdgeFlags::DELETED), + (root, direct, EdgeFlags::BLOCK), + (dead, bypass, EdgeFlags::BLOCK), + ] { + txn.add_edge(from, flags, to.start_pos()); + txn.add_edge(to, flags | EdgeFlags::PARENT, from.end_pos()); + } + if ordered { + txn.add_edge(bypass, EdgeFlags::BLOCK, direct.start_pos()); + txn.add_edge( + direct, + EdgeFlags::PARENT | EdgeFlags::BLOCK, + bypass.end_pos(), + ); + } + if reverse_edges { + for edges in txn.edges.values_mut() { + edges.reverse(); + } + } + (txn, start, bypass, direct) +} + +#[test] +fn bypass_does_not_reverse_canonical_order() { + for reverse in [false, true] { + let (txn, start, bypass, direct) = bypass_fixture(true, reverse); + let result = retrieve_graph( + &txn, + start, + RetrieveOptions::new() + .with_graph_visibility(visibility([1, 2, 3, 4, 99].map(NodeId::new))), + ) + .unwrap(); + let find = |node| { + result + .graph + .iter_vertices() + .find(|(_, v)| v.node == node) + .unwrap() + .0 + }; + let a = find(bypass); + let b = find(direct); + assert!(super::alive_graph_reaches(&result.graph, a, b)); + assert!( + !super::alive_graph_reaches(&result.graph, b, a), + "bypass created a reverse edge" + ); + assert!(super::alive_graph_reaches( + &result.graph, + VertexId::new(1), + a + )); + } +} + +#[test] +fn bypass_preserves_unordered_successors() { + for reverse in [false, true] { + let (txn, start, bypass, direct) = bypass_fixture(false, reverse); + let result = retrieve_graph( + &txn, + start, + RetrieveOptions::new() + .with_graph_visibility(visibility([1, 2, 3, 4, 99].map(NodeId::new))), + ) + .unwrap(); + let find = |node| { + result + .graph + .iter_vertices() + .find(|(_, v)| v.node == node) + .unwrap() + .0 + }; + let a = find(bypass); + let b = find(direct); + assert!(super::alive_graph_reaches( + &result.graph, + VertexId::new(1), + a + )); + assert!(super::alive_graph_reaches( + &result.graph, + VertexId::new(1), + b + )); + assert!(!super::alive_graph_reaches(&result.graph, a, b)); + assert!( + !super::alive_graph_reaches(&result.graph, b, a), + "bypass serialized independent successors" + ); + } +} + +#[test] +fn adjacent_replacement_contexts_follow_visible_graph_witnesses() { + for reverse_edges in [false, true] { + let mut txn = FaultGraphTxn::default(); + let root = test_node(1, 0, 0); + let old_a = test_node(1, 10, 20); + let old_b = test_node(1, 20, 30); + let tail = test_node(1, 30, 40); + let new_a = test_node(2, 0, 10); + let new_b = test_node(3, 0, 10); + for node in [root, old_a, old_b, tail, new_a, new_b] { + txn.resolve_block(node.start_pos(), node); + txn.resolve_block_end(node.end_pos(), node); + } + for (from, to, deleted, change) in [ + (root, old_a, false, 1), + (old_a, old_b, false, 1), + (old_b, tail, false, 1), + (root, old_a, true, 2), + (root, new_a, false, 2), + (new_a, old_b, false, 2), + (old_a, old_b, true, 3), + (old_a, new_b, false, 3), + (new_b, tail, false, 3), + ] { + let flags = if deleted { + EdgeFlags::BLOCK | EdgeFlags::DELETED + } else { + EdgeFlags::BLOCK + }; + txn.edges + .entry(from) + .or_default() + .push(SerializedGraphEdge::new( + flags, + to.start_pos(), + NodeId::new(change), + )); + txn.edges + .entry(to) + .or_default() + .push(SerializedGraphEdge::new( + flags | EdgeFlags::PARENT, + from.end_pos(), + NodeId::new(change), + )); + } + if reverse_edges { + for edges in txn.edges.values_mut() { + edges.reverse(); + } + } + for (changes, first, second) in [ + (vec![1, 2, 3], new_a, new_b), + (vec![1, 3, 2], new_a, new_b), + (vec![1, 2], new_a, old_b), + (vec![1, 3], old_a, new_b), + (vec![1], old_a, old_b), + ] { + let result = retrieve_graph( + &txn, + root.start_pos(), + RetrieveOptions::new() + .with_graph_visibility(visibility(changes.into_iter().map(NodeId::new))), + ) + .unwrap(); + let find = |node| { + result + .graph + .iter_vertices() + .find(|(_, v)| v.node == node) + .unwrap() + .0 + }; + assert!(super::alive_graph_reaches( + &result.graph, + find(first), + find(second) + )); + assert!(!super::alive_graph_reaches( + &result.graph, + find(second), + find(first) + )); + assert!(super::alive_graph_reaches( + &result.graph, + find(second), + find(tail) + )); + } + } +} + +#[test] +fn liveness_checks_scan_shared_dead_history_once() { + // Whole-file replacements leave many root edges into overlapping dead + // chains. The boolean query must visit their union, not each suffix again. + let mut txn = FaultGraphTxn::default(); + let root = test_node(1, 0, 0); + let count = 80; + for i in 0..count { + let node = test_node(2, i * 10, i * 10 + 10); + txn.resolve_block(node.start_pos(), node); + txn.resolve_block_end(node.end_pos(), node); + txn.add_edge( + root, + EdgeFlags::BLOCK | EdgeFlags::DELETED, + node.start_pos(), + ); + txn.add_edge( + node, + EdgeFlags::PARENT | EdgeFlags::BLOCK | EdgeFlags::DELETED, + root.end_pos(), + ); + if i + 1 < count { + txn.add_edge( + node, + EdgeFlags::BLOCK | EdgeFlags::DELETED, + test_pos(2, (i + 1) * 10), + ); + } + } + txn.resolve_block_end(root.end_pos(), root); + let opts = RetrieveOptions::new() + .with_graph_visibility(visibility([ + NodeId::new(1), + NodeId::new(2), + NodeId::new(99), + ])) + .stop_at_first_content(true); + // Ordinary rendering must also avoid re-scanning wholly dead suffixes. + let full = retrieve_graph( + &txn, + root.start_pos(), + opts.clone().stop_at_first_content(false), + ) + .unwrap(); + assert_eq!(full.graph.total_bytes(), 0); + assert!( + txn.forward_calls.get() <= count as usize * 3 + 1, + "full rendering re-scanned dead suffixes: {} reads", + txn.forward_calls.get() + ); + txn.forward_calls.set(0); + let result = retrieve_graph(&txn, root.start_pos(), opts).unwrap(); + assert_eq!(result.graph.total_bytes(), 0); + assert!( + txn.forward_calls.get() <= count as usize + 1, + "dead suffixes were traversed repeatedly: {} reads", + txn.forward_calls.get() + ); +} + +#[test] +fn liveness_checks_stop_inside_dead_walk_after_finding_content() { + let mut txn = FaultGraphTxn::default(); + let (start, dead, dead_end) = add_dead_walk_prefix(&mut txn); + let live = test_node(3, 0, 10); + txn.add_edge(dead, EdgeFlags::BLOCK, live.start_pos()); + txn.resolve_block(live.start_pos(), live); + txn.add_edge(live, EdgeFlags::PARENT | EdgeFlags::BLOCK, dead_end); + txn.resolve_block_end(dead_end, dead); + // A later sibling is irrelevant once live bytes have been proven. + txn.add_edge(dead, EdgeFlags::BLOCK, test_pos(4, 0)); + txn.fail_find_block(test_pos(4, 0), Fault::Inconsistent("unneeded tail")); + let result = retrieve_graph( + &txn, + start, + RetrieveOptions::new().stop_at_first_content(true), + ) + .unwrap(); + assert_eq!(result.graph.total_bytes(), 10); + // Ordinary reconstruction still walks and validates the complete graph. + assert_inconsistent( + retrieve_graph(&txn, start, RetrieveOptions::new()), + "unneeded tail", + ); +} diff --git a/atomic-core/src/pristine/applied_changes.rs b/atomic-core/src/pristine/applied_changes.rs new file mode 100644 index 00000000..6b6e7767 --- /dev/null +++ b/atomic-core/src/pristine/applied_changes.rs @@ -0,0 +1,245 @@ +//! Ingestion completion is distinct from registration and vertex ownership. + +use redb::{ReadableMultimapTable, ReadableTable, TableError}; +use std::collections::HashSet; + +use super::attribute::decode_inode_attr_event; +use super::error::PristineResult; +use super::tables::{ + decode_vertex, APPLIED_CHANGES, GRAPH, POSITION_ATTRS, PRISTINE_META, VIEW_CHANGES, +}; +use super::{GraphTxnT, ReadTxn, WriteTxn}; +use crate::types::NodeId; + +const INDEX_VERSION_KEY: &str = "applied_changes_index"; + +/// One-time compatibility rebuild from persisted canonical effects and +/// committed view logs. A registered/fetched object by itself is not evidence. +/// The marker and recovered entries commit together, so an interrupted rebuild +/// retries. No path ownership or recorded content is inferred from this index. +pub(crate) fn initialize(txn: &redb::WriteTransaction) -> PristineResult<()> { + let mut index = txn.open_table(APPLIED_CHANGES)?; + let mut meta = txn.open_table(PRISTINE_META)?; + if meta.get(INDEX_VERSION_KEY)?.is_some() { + return Ok(()); + } + for id in canonical_effect_writers( + &txn.open_multimap_table(GRAPH)?, + &txn.open_multimap_table(POSITION_ATTRS)?, + &txn.open_table(VIEW_CHANGES)?, + )? { + index.insert(id, 1)?; + } + meta.insert(INDEX_VERSION_KEY, 1)?; + Ok(()) +} + +fn canonical_effect_writers( + graph: &impl ReadableMultimapTable<&'static [u8; 24], &'static [u8; 24]>, + attrs: &impl ReadableMultimapTable<&'static [u8; 17], &'static [u8; 13]>, + views: &impl ReadableTable<&'static [u8; 16], u64>, +) -> PristineResult> { + let mut ids = HashSet::new(); + for entry in graph.iter()? { + let (key, edges) = entry?; + let (owner, _, _) = decode_vertex(key.value()); + if owner != 0 { + ids.insert(owner); + } + for edge in edges { + let edge = super::txn::helpers::deserialize_edge(edge?.value()); + let writer = edge.introduced_by(); + if !writer.is_root() { + ids.insert(writer.get()); + } + } + } + for entry in attrs.iter()? { + let (_, events) = entry?; + for event in events { + ids.insert(decode_inode_attr_event(event?.value())?.introduced_by.get()); + } + } + // View membership is published only after successful ingestion in the + // same transaction. It also preserves completion evidence for empty + // changes, which have no graph or attribute effects to discover. + for entry in views.iter()? { + let (_, id) = entry?; + if id.value() != 0 { + ids.insert(id.value()); + } + } + ids.remove(&0); + Ok(ids) +} + +impl ReadTxn { + /// Whether canonical effects were ingested, independently of current + /// view membership or vertex ownership. Registration alone is insufficient. + /// Legacy read-only databases derive evidence once per transaction without + /// modifying the database; writable opens persist the derived index. + pub fn was_change_applied(&self, id: NodeId) -> PristineResult { + if id.is_root() { + return Ok(false); + } + match self.txn.open_table(APPLIED_CHANGES) { + Ok(index) => { + if index.get(id.get())?.is_some() { + return Ok(true); + } + self.has_change_in_graph(id) + } + Err(TableError::TableDoesNotExist(_)) => { + if self.legacy_applied_changes.get().is_none() { + let ids = canonical_effect_writers( + &self.txn.open_multimap_table(GRAPH)?, + &self.txn.open_multimap_table(POSITION_ATTRS)?, + &self.txn.open_table(VIEW_CHANGES)?, + )?; + let _ = self.legacy_applied_changes.set(ids); + } + Ok(self + .legacy_applied_changes + .get() + .expect("initialized above") + .contains(&id.get())) + } + Err(error) => Err(error.into()), + } + } +} + +impl WriteTxn<'_> { + /// Whether canonical effects were ingested. Write transactions initialize + /// the completion index before any mutation, including on open_existing. + pub fn was_change_applied(&self, id: NodeId) -> PristineResult { + if id.is_root() { + return Ok(false); + } + if self + .txn + .open_table(APPLIED_CHANGES)? + .get(id.get())? + .is_some() + { + return Ok(true); + } + self.has_change_in_graph(id) + } + + /// Mark completion after all graph, attribute and FileOps writes. The + /// transaction must commit those effects together with this marker. + pub fn mark_change_applied(&mut self, id: NodeId) -> PristineResult<()> { + if id.is_root() || self.get_external(id)?.is_none() { + return Err(super::error::PristineError::ChangeNotFound { id: id.get() }); + } + self.txn.open_table(APPLIED_CHANGES)?.insert(id.get(), 1)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pristine::{InodeAttrEvent, InodeAttrMutTxnT, MutTxnT, Pristine}; + use crate::types::{ + ChangePosition, EdgeFlags, GraphNode, Hash, Inode, Position, SerializedGraphEdge, + }; + + #[test] + fn completion_is_atomic_and_registration_does_not_imply_application() { + let dir = tempfile::tempdir().unwrap(); + let pristine = Pristine::open(dir.path().join("db")).unwrap(); + let id = { + let mut txn = pristine.write_txn().unwrap(); + let id = txn.register_change(&Hash::of(b"empty change")).unwrap(); + txn.commit().unwrap(); + id + }; + assert!(!pristine.read_txn().unwrap().was_change_applied(id).unwrap()); + { + let mut txn = pristine.write_txn().unwrap(); + txn.mark_change_applied(id).unwrap(); + assert!(txn.was_change_applied(id).unwrap()); + // Aborting also rolls back the completion marker. + } + assert!(!pristine.read_txn().unwrap().was_change_applied(id).unwrap()); + let mut txn = pristine.write_txn().unwrap(); + txn.mark_change_applied(id).unwrap(); + txn.commit().unwrap(); + let read = pristine.read_txn().unwrap(); + assert!(!read.has_change_in_graph(id).unwrap()); + assert!(read.was_change_applied(id).unwrap()); + } + + #[test] + fn legacy_rebuild_recovers_edge_only_attribute_only_and_empty_changes() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("db"); + let pristine = Pristine::open(&path).unwrap(); + let (delete, attr, empty, fetched) = { + let mut txn = pristine.write_txn().unwrap(); + let base = txn.register_change(&Hash::of(b"base")).unwrap(); + let delete = txn.register_change(&Hash::of(b"delete")).unwrap(); + let attr = txn.register_change(&Hash::of(b"attr")).unwrap(); + let empty_hash = Hash::of(b"empty"); + let empty = txn.register_change(&empty_hash).unwrap(); + let fetched = txn.register_change(&Hash::of(b"fetched only")).unwrap(); + let pos = ChangePosition::new(1); + txn.put_graph( + GraphNode::new(base, pos, ChangePosition::new(2)), + SerializedGraphEdge::new(EdgeFlags::DELETED, Position::ROOT, delete), + ) + .unwrap(); + txn.put_inode_attr_event( + Inode::new(1), + Position::new(base, pos), + InodeAttrEvent::new(attr, crate::change::InodeAttr::Mode(0o755)).unwrap(), + ) + .unwrap(); + let mut view = txn.open_or_create_view("main").unwrap(); + txn.put_change(&mut view, empty, &empty_hash).unwrap(); + txn.txn.delete_table(APPLIED_CHANGES).unwrap(); + txn.txn + .open_table(PRISTINE_META) + .unwrap() + .remove(INDEX_VERSION_KEY) + .unwrap(); + txn.commit().unwrap(); + (delete, attr, empty, fetched) + }; + drop(pristine); + // Read-only inspection of an old database must work without an + // upgrade write and cache the canonical evidence for this snapshot. + let readonly = Pristine::open_readonly(&path).unwrap(); + let read = readonly.read_txn().unwrap(); + for id in [delete, attr, empty] { + assert!(read.was_change_applied(id).unwrap()); + } + assert!(!read.was_change_applied(fetched).unwrap()); + assert!(matches!( + read.txn.open_table(APPLIED_CHANGES), + Err(TableError::TableDoesNotExist(_)) + )); + assert!(read.legacy_applied_changes.get().is_some()); + drop(read); + drop(readonly); + // open_existing skips table initialization; its first write must + // still recover old deletions before application/reference routing. + let existing = Pristine::open_existing(&path).unwrap(); + let write = existing.write_txn().unwrap(); + for id in [delete, attr, empty] { + assert!(write.was_change_applied(id).unwrap()); + } + assert!(!write.was_change_applied(fetched).unwrap()); + write.commit().unwrap(); + drop(existing); + let pristine = Pristine::open(&path).unwrap(); + let read = pristine.read_txn().unwrap(); + for id in [delete, attr, empty] { + assert!(!read.has_change_in_graph(id).unwrap()); + assert!(read.was_change_applied(id).unwrap()); + } + assert!(!read.was_change_applied(fetched).unwrap()); + } +} diff --git a/atomic-core/src/pristine/mod.rs b/atomic-core/src/pristine/mod.rs index dd3b415c..a2904c7e 100644 --- a/atomic-core/src/pristine/mod.rs +++ b/atomic-core/src/pristine/mod.rs @@ -149,6 +149,7 @@ //! The `INODE_GRAPH` secondary index enables O(n) file traversal where n is //! proportional to file size, rather than O(N) where N is total graph size. +mod applied_changes; mod attribute; mod bindings; mod capability; diff --git a/atomic-core/src/pristine/schema.rs b/atomic-core/src/pristine/schema.rs index 1d883373..384f87df 100644 --- a/atomic-core/src/pristine/schema.rs +++ b/atomic-core/src/pristine/schema.rs @@ -95,6 +95,7 @@ pub(crate) fn visit_tables(visitor: &mut impl TableVisitor) -> PristineResult<() visitor.multimap(CHANGE_DEPS)?; visitor.multimap(REV_CHANGE_DEPS)?; visitor.table(CHANGE_DEPS_INDEXED)?; + visitor.table(APPLIED_CHANGES)?; // State and tags visitor.table(STATES)?; diff --git a/atomic-core/src/pristine/tables.rs b/atomic-core/src/pristine/tables.rs index d92bb623..b8deb5d9 100644 --- a/atomic-core/src/pristine/tables.rs +++ b/atomic-core/src/pristine/tables.rs @@ -334,6 +334,11 @@ pub const REV_CHANGE_DEPS: MultimapTableDefinition<&[u8; 32], u64> = pub const CHANGE_DEPS_INDEXED: TableDefinition = TableDefinition::new("change_deps_indexed"); +/// Completed canonical change ingestion, independent of view membership. +/// Written atomically with graph, attribute and semantic effects. Registration +/// alone never creates an entry. This is not a file-visibility index. +pub const APPLIED_CHANGES: TableDefinition = TableDefinition::new("applied_changes"); + // State Tables /// View states: (view_id, merkle) → sequence diff --git a/atomic-core/src/pristine/txn/mod.rs b/atomic-core/src/pristine/txn/mod.rs index 2f2cfe10..aac9bf8b 100644 --- a/atomic-core/src/pristine/txn/mod.rs +++ b/atomic-core/src/pristine/txn/mod.rs @@ -8,7 +8,7 @@ //! - `write` - Read-write transaction implementation //! - `helpers` - Serialization and utility functions -mod helpers; +pub(super) mod helpers; mod pristine; mod read; mod write; diff --git a/atomic-core/src/pristine/txn/pristine.rs b/atomic-core/src/pristine/txn/pristine.rs index 7cabb945..a641db7f 100644 --- a/atomic-core/src/pristine/txn/pristine.rs +++ b/atomic-core/src/pristine/txn/pristine.rs @@ -334,6 +334,7 @@ impl Pristine { write_txn.open_multimap_table(CHANGE_DEPS)?; write_txn.open_multimap_table(REV_CHANGE_DEPS)?; write_txn.open_table(CHANGE_DEPS_INDEXED)?; + crate::pristine::applied_changes::initialize(&write_txn)?; // State tables write_txn.open_table(STATES)?; @@ -655,6 +656,7 @@ impl Pristine { ) -> PristineResult> { let mut txn = self.db.writable_db()?.begin_write()?; let _ = txn.set_durability(durability); + crate::pristine::applied_changes::initialize(&txn)?; Ok(WriteTxn::new( txn, &self.next_node_id, diff --git a/atomic-core/src/pristine/txn/read.rs b/atomic-core/src/pristine/txn/read.rs index 668926d3..f80763c1 100644 --- a/atomic-core/src/pristine/txn/read.rs +++ b/atomic-core/src/pristine/txn/read.rs @@ -51,6 +51,7 @@ use super::helpers::{ /// can be active simultaneously. pub struct ReadTxn { pub(crate) txn: ReadTransaction, + pub(crate) legacy_applied_changes: std::sync::OnceLock>, } fn operation_serialization_error(error: impl std::fmt::Display) -> PristineError { @@ -109,7 +110,10 @@ fn decode_working_copy_row( impl ReadTxn { /// Create a new read transaction pub(crate) fn new(txn: ReadTransaction) -> Self { - Self { txn } + Self { + txn, + legacy_applied_changes: std::sync::OnceLock::new(), + } } /// Open the INODE_GRAPH table for shared use across multiple operations. diff --git a/atomic-core/src/record/workflow/assembly/mod.rs b/atomic-core/src/record/workflow/assembly/mod.rs index 5177067c..1d6c9b7c 100644 --- a/atomic-core/src/record/workflow/assembly/mod.rs +++ b/atomic-core/src/record/workflow/assembly/mod.rs @@ -575,6 +575,18 @@ where // Create globalization context let mut glob_ctx = GlobalizeContext::new(txn); + for file in files { + if let Some(inode) = file.inode() { + if let Some(old_path) = file.old_path() { + glob_ctx.vacate_binding(old_path, inode); + } else if matches!( + file.kind(), + Some(crate::record::workflow::DetectionKind::Deleted) + ) { + glob_ctx.vacate_binding(file.path(), inode); + } + } + } let mut ctx = AssemblyContext::new(header); let mut stats = AssemblyStats::new(); let mut globalized_files = Vec::new(); diff --git a/atomic-core/src/record/workflow/globalize/context.rs b/atomic-core/src/record/workflow/globalize/context.rs index d720ce21..6f4b0c51 100644 --- a/atomic-core/src/record/workflow/globalize/context.rs +++ b/atomic-core/src/record/workflow/globalize/context.rs @@ -60,6 +60,10 @@ pub struct GlobalizeContext<'txn, T> { /// many hunks in a single recorded file. Cache that traversal once. pub(super) content_vertices_cache: std::collections::HashMap>>, + /// Bindings removed by the same change, before destination parents resolve. + pub(super) vacated_bindings: + std::collections::HashMap>, + /// Directory inode anchors available to additions in the change being built. /// /// Persisted anchors carry an external hash; directories introduced by this @@ -93,6 +97,7 @@ where position_cache: std::collections::HashMap::new(), content_vertices_cache: std::collections::HashMap::new(), directory_anchors: std::collections::HashMap::new(), + vacated_bindings: std::collections::HashMap::new(), } } @@ -118,9 +123,19 @@ where position_cache: std::collections::HashMap::new(), content_vertices_cache: std::collections::HashMap::new(), directory_anchors: std::collections::HashMap::new(), + vacated_bindings: std::collections::HashMap::new(), } } + /// Register a binding removed by the same atomic change. A new parent + /// directory at that path must receive a distinct graph identity. + pub fn vacate_binding(&mut self, path: &str, inode: Inode) { + self.vacated_bindings + .entry(path.to_owned()) + .or_default() + .insert(inode); + } + /// Append content to the buffer and return the position range. /// /// # Arguments diff --git a/atomic-core/src/record/workflow/globalize/hunk.rs b/atomic-core/src/record/workflow/globalize/hunk.rs index 86630ac0..c7b9c4ac 100644 --- a/atomic-core/src/record/workflow/globalize/hunk.rs +++ b/atomic-core/src/record/workflow/globalize/hunk.rs @@ -130,6 +130,37 @@ where match built.kind { BuiltHunkKind::Insert => { + // ::26 R1 fourth follow-up (2026-10-13): the positional line→vertex + // mapping in `classify_insert` is only provable when the alive graph + // holds exactly one content vertex per old-file line. Histories with + // many accumulated in-place edits leave extra alive vertices (diamond + // remnants the walk-through-dead re-attaches), so an insertion anchors + // at silent wrong vertices and the staged render loses content + // (atomicdotdev/atomic commit b110aa70: rendered 68,762 of 74,206 + // bytes after 71 in-place edits). When the counts diverge, escalate + // to the whole-file replace — every alive vertex is deleted and the + // full content inserted fresh per line, which renders byte-exactly + // regardless of the diamond structure. + if let Some(expected) = old_line_count { + let sorted = collect_sorted_content_vertices_cached(ctx, inode, inode_pos)?; + if sorted.len() != expected { + log::warn!( + "globalize_hunk: vertex/line divergence on {} ({} vertices for {} lines); \ + escalating to whole-file replace", + local.path, + sorted.len(), + expected + ); + return globalize_replace_whole_file( + ctx, + inode, + inode_pos, + full_content, + local, + encoding, + ); + } + } match globalize_insert( ctx, built, @@ -192,6 +223,7 @@ where T: GraphTxnT + TreeTxnT + InodeGraphOps, { let vertices = collect_sorted_content_vertices_cached(ctx, inode, inode_pos)?; + let position = classify_insert(&vertices, inode_pos, built.old_start, old_line_count)?; let (predecessors, successors) = match position { @@ -514,7 +546,10 @@ where // change, including zero-byte files whose deletion has no content edges. ctx.add_dependency_by_id(inode_pos.change)?; - if should_use_opaque_generated_vertices(&local.path) { + // An empty range denotes deletion of the file itself. Generated-file + // content optimizations must not turn that lifecycle operation into an + // Edit: it needs the same canonical FileDel as every other file. + if should_use_opaque_generated_vertices(&local.path) && !built.deleted_lines.is_empty() { let content_vertices = find_content_vertices(ctx.txn(), inode, inode_pos)?; let deletion_edges = build_deletion_edges(ctx, &content_vertices)?; let deletion = EdgeUpdate { diff --git a/atomic-core/src/record/workflow/globalize/pipeline.rs b/atomic-core/src/record/workflow/globalize/pipeline.rs index 204a464a..5a061017 100644 --- a/atomic-core/src/record/workflow/globalize/pipeline.rs +++ b/atomic-core/src/record/workflow/globalize/pipeline.rs @@ -402,16 +402,11 @@ where if let Some(retained) = recorded.name_conflict_resolution() { ctx.add_dependency_by_id(retained.change)?; ctx.add_dependency_by_id(inode_pos.change)?; - // Selection is explicit; the other operations unlink only the - // conflicting names. A concurrent rename must keep its content. - result.add_hunk(GraphOp::SolveNameConflict { - name: EdgeUpdate { - edges: Vec::new(), - inode: position_to_option_hash_resolved(ctx.txn(), retained, None), - }, - path: path.to_string(), - }); - let mut edges = Vec::new(); + // A resolution is one operation containing the retained inode + // AND the losing claims. A separate empty selection operation is + // invalid, and using the losing inode as the survivor reverses + // the meaning of the resolution. + let mut claims = Vec::new(); for &name in recorded.name_conflict_bindings() { ctx.add_dependency_by_id(name.change)?; for parent in ctx.txn().get_edges(name)? { @@ -423,33 +418,37 @@ where } ctx.add_dependency_by_id(parent.introduced_by())?; ctx.add_dependency_by_id(parent.dest().change)?; - let previous = flag - EdgeFlags::PARENT; - edges.push(crate::change::NewEdge { - previous, - flag: previous | EdgeFlags::DELETED, - from: position_to_option_hash_resolved(ctx.txn(), parent.dest(), None), - to: GraphNode { - change: ctx.get_external(name.change), - start: name.start, - end: name.end, - }, - introduced_by: ctx.get_external(parent.introduced_by()), - }); + let source = parent.dest(); + claims.push(super::NameConflictClaim::new( + inode_pos, + GraphNode::new(source.change, source.pos, source.pos), + name, + parent.introduced_by(), + )); } } - if edges.is_empty() { + if claims.is_empty() { return Err(GlobalizeError::MissingField { path: path.to_string(), field: "live name binding for name-conflict resolution", }); } - result.add_hunk(GraphOp::SolveNameConflict { - name: EdgeUpdate { - edges, - inode: position_to_option_hash_resolved(ctx.txn(), inode_pos, None), - }, - path: path.to_string(), - }); + result.add_hunk(super::globalize_solve_name_conflict( + ctx, path, retained, claims, + )?); + let retained_directory = ctx + .txn() + .position_inode(retained)? + .map(|inode| ctx.txn().is_directory(inode)) + .transpose()? + .unwrap_or(false); + if ctx.txn().is_directory(inode)? && retained_directory { + // An explicit directory resolution also fixes the parent + // anchor for child moves/additions in this same change. + // This is assembly context, never a mutation of TREE. + let anchor = ctx.external_existing_position(retained)?; + ctx.register_directory_anchor(path, anchor); + } if let Some(ops) = recorded.crdt_ops().cloned() { result.set_file_ops(ops); } @@ -975,7 +974,11 @@ where return Ok(anchor); } - if let Some(inode) = ctx.txn().get_inode(directory_path)? { + if let Some(inode) = ctx.txn().get_inode(directory_path)?.filter(|inode| { + !ctx.vacated_bindings + .get(directory_path) + .is_some_and(|inodes| inodes.contains(inode)) + }) { if let Some(position) = ctx.txn().inode_position(inode)? { let hash = ctx.txn().get_external(position.change)?.ok_or( GlobalizeError::MissingExternalHash { diff --git a/atomic-core/src/record/workflow/globalize/tests.rs b/atomic-core/src/record/workflow/globalize/tests.rs index a5eb34d2..c95bccb0 100644 --- a/atomic-core/src/record/workflow/globalize/tests.rs +++ b/atomic-core/src/record/workflow/globalize/tests.rs @@ -354,6 +354,11 @@ mod tests { .unwrap(); txn.put_graph(name, SerializedGraphEdge::new(alive, loser, name_change)) .unwrap(); + txn.put_graph( + name, + SerializedGraphEdge::new(alive | EdgeFlags::PARENT, source.end_pos(), name_change), + ) + .unwrap(); for (inode, introduced_by) in [(winner, winner_change), (loser, loser_change)] { txn.put_graph( GraphNode::new(inode.change, inode.pos, inode.pos), @@ -404,6 +409,24 @@ mod tests { &HashSet::from([winner_hash, loser_hash, parent_hash, name_hash]) ); + // The RecordedFile pipeline must emit the same valid, non-empty + // operation, keeping the winner rather than the losing inode. + let mut recorded = crate::record::workflow::RecordedFile::new("src/conflict.txt"); + recorded.set_inode(Inode::new(2)); + recorded.set_position(loser); + recorded.set_name_conflict_resolution(winner, vec![name]); + let mut recorded_ctx = GlobalizeContext::new(&txn); + let globalized = + globalize_recorded_file(&mut recorded_ctx, &recorded, &GlobalizeOptions::new()) + .unwrap(); + assert_eq!(globalized.hunks().len(), 1); + let GraphOp::SolveNameConflict { name: update, .. } = &globalized.hunks()[0] else { + panic!("recorded name resolution must produce SolveNameConflict"); + }; + assert_eq!(update.inode, solve_update.inode); + assert_eq!(update.edges, solve_update.edges); + assert_eq!(recorded_ctx.dependencies(), solve_ctx.dependencies()); + let mut unsolve_ctx = GlobalizeContext::new(&txn); let unsolve = globalize_unsolve_name_conflict( &mut unsolve_ctx, diff --git a/atomic-core/src/record/workflow/globalize/vertex.rs b/atomic-core/src/record/workflow/globalize/vertex.rs index 31d50a67..a30d443d 100644 --- a/atomic-core/src/record/workflow/globalize/vertex.rs +++ b/atomic-core/src/record/workflow/globalize/vertex.rs @@ -218,6 +218,13 @@ where if start < content.len() { lines.push(&content[start..]); } + if std::env::var("ATOMIC_TRACE_HYPER").is_ok() { + eprintln!( + "create_content_vertices_per_line: {} line vertices from {} bytes", + lines.len(), + content.len() + ); + } // If empty or single line, just create one vertex if lines.len() <= 1 { diff --git a/atomic-core/src/record/workflow/record/crdt.rs b/atomic-core/src/record/workflow/record/crdt.rs index f3962454..a93086c2 100644 --- a/atomic-core/src/record/workflow/record/crdt.rs +++ b/atomic-core/src/record/workflow/record/crdt.rs @@ -699,8 +699,12 @@ pub(crate) fn build_crdt_ops_for_whole_file_replace( } } - // 2. Insert every new line fresh, chained from the start of the file, - // with token-level LeafOps. + // 2. Insert every new line fresh, chained from the start of the file + // (`after: None` anchors the fresh chain at the file-start sentinel — + // the fork with the tombstoned original chain is the sanctioned + // multi-render shape: the graph emits both alive chains as a + // conflict, and the after-chain walk emits them in deterministic + // order), with token-level LeafOps. let mut prev_branch: Option = None; for (new_line_idx, line) in new_lines.iter().enumerate() { let branch_id = alloc_branch(); diff --git a/atomic-core/src/record/workflow/record/mod.rs b/atomic-core/src/record/workflow/record/mod.rs index 14c74f76..4f94a260 100644 --- a/atomic-core/src/record/workflow/record/mod.rs +++ b/atomic-core/src/record/workflow/record/mod.rs @@ -156,6 +156,7 @@ pub use types::{RecordedFile, RecordingResult, RecordingStats}; use crate::change::{Encoding, FileOps, Local}; use crate::crdt::{BranchId, TrunkId}; +use crate::diff::DiffOp; use crate::diff::Line; use crate::output::WorkingCopyRead; use crate::pristine::PathClaimId; @@ -469,7 +470,13 @@ where recorded.set_kind(DetectionKind::Modified); // Count lines in old content for precise insertion point detection - let old_line_count = old_content.iter().filter(|&&b| b == b'\n').count(); + // Count LINES — newline-split segments including a no-trailing-newline + // remainder — matching the graph's per-line vertex split, the CRDT + // branch-per-line model, and the diff's line numbering. A raw `\n` count + // under-reports files that don't end with a newline (346 for a 347-line + // file), which desyncs every positional hunk index from the branch and + // vertex domains downstream. + let old_line_count = Line::from_bytes(old_content).len(); recorded.set_old_line_count(old_line_count); // Copy inode and position if available @@ -537,6 +544,68 @@ where return Ok(recorded); } + // ::26 R1 second follow-up (2026-10-13): the per-line fall-through above + // is only safe when the existing branches carry the newline-split shape. + // A generated file this module itself added holds ONE whole-content + // branch (the fast path above), so a per-line diff binds its Modify ops + // to line numbers that have no vertices and corrupts the staged render: + // `atomic git import` of a repository whose lockfile changed after its + // initial commit rendered Cargo.lock at 3,050 of 89,750 bytes and failed + // the staged synthesis verification. Route the opaque-shape modify through + // the same sentinel replace as an explicit force below: it deletes every + // alive vertex, inserts the full content fresh, and its CRDT ops + // tombstone the bound branches, so the recorded shape is one the graph + // can render exactly. + // + // The diff is computed before the force decision so the multi-line + // deletion check below can inspect its ops. + let comparison = compare_content(old_content, &new_content, options.get_algorithm()); + + // ::26 R1 third follow-up (2026-10-13): a contiguous multi-line deletion + // region recorded against a graph whose vertices span an earlier + // insertion commit leaves the additive graph in a state the alive walk + // classifies as a cyclic conflict — the staged render emits conflict + // markers and `atomic status` reports the file modified immediately after + // a clean record. Reproduced natively (record an insertion commit, then a + // commit deleting a 38-line contiguous region: the render keeps the + // deleted lines and adds markers) and through `atomic git import`, whose + // staged verification refused the change ("staged path ... renders 27472 + // byte(s) but the commit tree expects 23819 byte(s)"). Single-line + // deletions are unaffected. Route the modify through the sentinel + // whole-file replace below — the same fail-safe shape used for binary + // content: every alive vertex is deleted and the full content inserted + // fresh, which renders byte-exact regardless of the graph's diamond + // structure. + let has_multi_line_deletion = comparison.diff_ops.iter().any(|op| match op { + DiffOp::Delete { len, .. } => *len > 1, + DiffOp::Replace { old_len, .. } => *old_len > 1, + _ => false, + }); + // ::26 R1 fifth follow-up (2026-10-13): count LINES — newline-split + // segments including a no-trailing-newline remainder — matching the + // graph's per-line vertex split, the CRDT branch-per-line model, and the + // diff's line numbering. A raw `\n` count under-reports files that don't + // end with a newline (346 for a 347-line file), which desyncs every + // positional hunk index from the branch and vertex domains downstream. + let old_line_count = Line::from_bytes(old_content).len(); + recorded.set_old_line_count(old_line_count); + + // ::26 R1 sixth follow-up (2026-10-14): an edit at the EOF boundary of a + // file without a trailing newline — replacing or extending the final + // no-newline line, or adding one — loses or shifts the final byte in the + // per-line hunk slicing (observed: a +1-byte trailing-newline commit + // rendered 1,934 of 1,935 bytes). Either side lacking the final newline + // puts the edit boundary at EOF, where partial-hunk slicing is + // fragile; route through the sentinel whole-file replace, which + // rewrites the file byte-exactly. + let eof_boundary_edit = old_content.last().is_some_and(|&b| b != b'\n') + || new_content.last().is_some_and(|&b| b != b'\n'); + let force_whole_file = options.get_force_whole_file_replace() + || (super::globalize::should_use_opaque_generated_vertices(&detected.path) + && existing_branches.is_some_and(|branches| !branches.is_empty())) + || has_multi_line_deletion + || eof_boundary_edit; + // Whole-file-replace safety fallback: if the caller's `old_content` came // from resolving a fork or cyclic conflict (see // `retrieve_content_with_filter_and_fork_info`), it isn't guaranteed to @@ -551,7 +620,7 @@ where // targeted per-vertex diff that would trust old_content's mismatched // shape. This mirrors the delete+add workaround that was used manually to // recover a corrupted file before this fix existed. - if options.get_force_whole_file_replace() { + if force_whole_file { let mut replace_hunk = BuiltHunk::new_replace_with_lines( Local::new(&detected.path, 1), Some(encoding), @@ -586,9 +655,6 @@ where return Ok(recorded); } - // Compare content and build hunks - let comparison = compare_content(old_content, &new_content, options.get_algorithm()); - // Handle binary files: compare_content returns is_binary=true with zero // diff_ops when either old or new content contains null bytes. Since we // know the content differs (identical content returns early above), create diff --git a/atomic-repository/src/apply/mod.rs b/atomic-repository/src/apply/mod.rs index 3ca7fbda..63d6d547 100644 --- a/atomic-repository/src/apply/mod.rs +++ b/atomic-repository/src/apply/mod.rs @@ -474,6 +474,11 @@ fn apply_change_hunks_locked( log::debug!("write_change_to_graph: no FileOps to apply"); } + // Vertex ownership cannot prove application of edge-only deletions, + // attribute-only changes or empty commits. Publish completion with the + // complete effects in this transaction, independently of view membership. + txn.mark_change_applied(change_id) + .map_err(|error| InsertError::Database(error.to_string()))?; Ok(()) } diff --git a/atomic-repository/src/diff_engine.rs b/atomic-repository/src/diff_engine.rs index 707e4b0a..4becb6f0 100644 --- a/atomic-repository/src/diff_engine.rs +++ b/atomic-repository/src/diff_engine.rs @@ -1515,6 +1515,67 @@ pub fn legacy_content_file_diffs( (file_diffs, stats) } +/// Recover before/after bytes for displaying a whole-file CRDT rewrite. +/// +/// Such rewrites tombstone old branch identities without embedding their text, +/// then insert a fresh, numbered chain from the file-start sentinel. These are +/// storage operations, not a human-readable diff. Deleted branch numbers may +/// include historical tombstones, so callers must obtain the before-content +/// from recorded history rather than treating those numbers as text offsets. +/// The resolver is called only after recognizing the rewrite's operation shape. +/// Ordinary edits, file lifecycle operations, and incomplete chains return None. +pub fn whole_file_replacement_text( + ops: &atomic_core::change::FileOps, + contents: &[u8], + before: impl FnOnce() -> Option>, +) -> Option<(Vec, Vec)> { + if ops.trunk_op().is_some() { + return None; + } + let mut deletions = 0; + let mut previous = None; + let mut next_line = 1; + let mut previous_end = None; + let mut bytes = Vec::new(); + for line in ops.line_ops() { + match line.operation() { + BranchOp::Delete { content, .. } if content.is_empty() && previous.is_none() => { + deletions += 1; + if line.old_line_num() != Some(deletions) { + return None; + } + } + BranchOp::Insert { after, .. } + if *after == previous && line.new_line_num() == Some(next_line) => + { + // Leaf tokens omit line terminators. The canonical byte ranges + // preserve blank lines, CRLF, and an unterminated final line. + let (start, end) = line.content_range()?; + let start = usize::try_from(start.get()).ok()?; + let end = usize::try_from(end.get()).ok()?; + if previous_end.is_some_and(|previous| previous != start) { + return None; + } + bytes.extend_from_slice(contents.get(start..end)?); + previous_end = Some(end); + previous = Some(line.branch_id()); + next_line += 1; + } + _ => return None, + } + } + if deletions == 0 { + return None; + } + let before = before()?; + // A normal edit may delete a blank line with empty tokens. A rewrite + // tombstones at least every old line (plus any observed historical IDs). + if deletions < atomic_core::diff::Line::from_bytes(&before).len() { + return None; + } + Some((before, bytes)) +} + /// The resolver-parameterized core of [`change_file_diffs`]: `before` /// supplies a file's pre-change content for context padding (a `None` /// answer pads nothing — zero-context hunks, still at true offsets). @@ -1560,6 +1621,28 @@ where _ => FileDiff::modified(file_path), }; + // Whole-file rewrites preserve graph/CRDT identities by deleting and + // reinserting every line. Display their actual text delta instead of + // exposing empty tombstones and unchanged reinserted lines. This also + // needs the baseline for --stat and -U0, not just for context padding. + if let Some((before_content, after_content)) = + whole_file_replacement_text(ops, &change.contents, || before(file_path)) + { + let result = diff_text(&before_content, &after_content, Algorithm::Myers); + let old_lines: Vec<_> = before_content.split(|&b| b == b'\n').collect(); + let new_lines: Vec<_> = after_content.split(|&b| b == b'\n').collect(); + for mut hunk in + build_hunks_from_diff(&result, &old_lines, &new_lines, config.context_lines) + { + hunk.lines = repair_diff_lines(hunk.lines); + file_diff.add_hunk(hunk); + } + file_diff.compute_stats(); + stats.add_file(file_diff.stats.clone()); + file_diffs.push(file_diff); + continue; + } + // Pass 1: flatten line operations into numbered changed lines. let mut insertions = 0usize; let mut deletions = 0usize; diff --git a/atomic-repository/src/repository/content.rs b/atomic-repository/src/repository/content.rs index b55809d3..69935269 100644 --- a/atomic-repository/src/repository/content.rs +++ b/atomic-repository/src/repository/content.rs @@ -1001,14 +1001,11 @@ enum LinearStep { /// Choose the single live successor of `current` for a linear content walk. /// -/// In the additive graph model a superseded span keeps its original alive -/// `BLOCK` edge *alongside* a new `DELETED` edge. A walk that merely skipped -/// `DELETED` edges would therefore still follow the alive edge into the -/// superseded span and resurrect stale content. This helper instead treats any -/// destination that has a visible `DELETED` edge as dead, so an in-place -/// replacement (delete old span + insert new span off the same predecessor) -/// resolves to just the new span. Genuine conflict forks and delete-throughs -/// return [`LinearStep::Bail`] so the caller defers to the full graph walk. +/// In the additive graph model a deleted destination can still lead to live +/// descendants. Even when there is exactly one direct live successor, walking +/// only that successor may skip an insertion reachable through the dead chain. +/// Any visible deletion therefore requires the full graph traversal, which +/// contracts dead chains and preserves their ordering and concurrent branches. fn select_linear_successor( txn: &T, inode: Inode, @@ -1022,8 +1019,7 @@ where use atomic_core::types::EdgeFlags; let mut alive: Vec> = Vec::new(); - let mut deleted: std::collections::HashSet> = - std::collections::HashSet::new(); + let mut has_deleted_successor = false; while let Some(edge_result) = txn.next_inode_adj(adj) { let edge = edge_result?; @@ -1048,26 +1044,18 @@ where continue; } if flags.contains(EdgeFlags::DELETED) { - deleted.insert(dest); + has_deleted_successor = true; } else if !alive.contains(&dest) { alive.push(dest); } } - // A destination that is both alive (original edge) and deleted (superseding - // edge) is dead from this view's perspective. - alive.retain(|d| !deleted.contains(d)); + if has_deleted_successor { + return Ok(LinearStep::Bail); + } match alive.len() { - 0 => { - if deleted.is_empty() { - Ok(LinearStep::End) - } else { - // The only successors here were deleted; the live continuation - // lies past a dead vertex — defer to the full graph walk. - Ok(LinearStep::Bail) - } - } + 0 => Ok(LinearStep::End), 1 => Ok(LinearStep::Follow(alive[0])), _ => Ok(LinearStep::Bail), } diff --git a/atomic-repository/src/repository/deferred_tree.rs b/atomic-repository/src/repository/deferred_tree.rs index ea4b67b7..c9d8cd37 100644 --- a/atomic-repository/src/repository/deferred_tree.rs +++ b/atomic-repository/src/repository/deferred_tree.rs @@ -1122,6 +1122,18 @@ impl Repository { && !absent.directory && !alive_inodes.contains(&absent.inode) && !alive_paths.contains(&absent.path) + // A name resolution deliberately retains the losing inode's + // content as history. Those bytes cannot resurrect a removed + // name (including its pre-rename path). Require a live name + // in this exact graph closure before content-based recovery. + && !super::materialize::live_names_for_path( + txn, + &self.change_store, + absent.position, + &absent.path, + &visibility.iter_dependency_first().copied().collect(), + )? + .is_empty() { let check_start = std::time::Instant::now(); let alive = self.inode_renders_alive_content( diff --git a/atomic-repository/src/repository/insert.rs b/atomic-repository/src/repository/insert.rs index e05ca9be..5ebf5d72 100644 --- a/atomic-repository/src/repository/insert.rs +++ b/atomic-repository/src/repository/insert.rs @@ -1907,28 +1907,16 @@ impl Repository { let before_visibility = graph_visibility_closure(&txn, &before_view)?; let before_projection = self.project_tree_for_visibility(&txn, &before_visibility)?; - // Check if the change's edges are already in the global GRAPH. - // - // A change is "already in the global graph" when it is registered - // (has a NodeId) AND at least one of its vertices exists in the - // GRAPH B-tree. `has_change_in_graph` performs a single O(log N) - // range scan — far cheaper and more reliable than the previous - // approach of loading the Change file and probing individual hunks. - // - // This correctly handles: - // - Changes recorded on a Draft view (edges in GRAPH only) - // → returns false, so hunks are re-applied to the global GRAPH - // - Changes already inserted into a Shared view (edges in GRAPH) - // → returns true, so redundant hunk application is skipped - // - Changes with only EdgeUpdate hunks (no FileAdd/DirAdd) - // → correctly detected via the range scan + // Ingestion is independent of the selected view and of whether this + // change created vertices. Edge-only deletes and attribute changes + // already in the ambient graph must also be reference-only inserts. let t_check = std::time::Instant::now(); let already_in_graph = if let Some(node_id) = txn .get_internal(hash) .map_err(|e| RepositoryError::Database(e.to_string()))? { let in_graph = txn - .has_change_in_graph(node_id) + .was_change_applied(node_id) .map_err(|e| RepositoryError::Database(e.to_string()))?; log::debug!( "insert_change: hash={} node_id={:?} already_in_graph={}", diff --git a/atomic-repository/src/repository/record.rs b/atomic-repository/src/repository/record.rs index a314c2eb..8d02bb8e 100644 --- a/atomic-repository/src/repository/record.rs +++ b/atomic-repository/src/repository/record.rs @@ -8,6 +8,73 @@ use atomic_core::pristine::{ CachedGraphTxn, GraphTxnT, GraphVisibilityClosure, PathClaimId, ViewGraph, ViewMembershipSet, }; +/// Canonical trunk, live lines in closure order, and all observed line identities. +type ExistingSemanticState = ( + Option, + Vec, + Vec, +); + +/// Walk the line order recorded by one effective closure. Ambient AFTER +/// rows may have been reparented by a different view and are not a baseline. +pub(super) fn semantic_branch_order( + after: &std::collections::BTreeMap< + atomic_core::crdt::BranchId, + Option, + >, +) -> Result, String> { + let mut successors = std::collections::BTreeMap::<_, Vec<_>>::new(); + for (branch, predecessor) in after { + if predecessor.is_some_and(|id| !after.contains_key(&id)) { + return Err(format!( + "semantic branch {branch:?} has a predecessor outside its closure" + )); + } + successors.entry(*predecessor).or_default().push(*branch); + } + let mut stack = successors.remove(&None).unwrap_or_default(); + stack.reverse(); + let mut ordered = Vec::with_capacity(after.len()); + while let Some(branch) = stack.pop() { + ordered.push(branch); + if let Some(mut children) = successors.remove(&Some(branch)) { + children.reverse(); + stack.extend(children); + } + } + if ordered.len() != after.len() { + return Err("semantic closure contains a cyclic line order".into()); + } + Ok(ordered) +} + +/// A whole-file resolution supersedes every line identity observed in its +/// parent closure. A line that one replay considers dead can still be alive +/// in the ambient table after a concurrent Modify. Persist its deletion too, +/// so the resolution dominates both histories in either ingestion order. +fn complete_semantic_replacement( + mut recorded: atomic_core::record::workflow::RecordedFile, + observed: &[atomic_core::crdt::BranchId], +) -> atomic_core::record::workflow::RecordedFile { + if recorded + .hunks() + .iter() + .any(|hunk| hunk.deleted_lines == [usize::MAX]) + { + if let Some(mut ops) = recorded.crdt_ops().cloned() { + let addressed: std::collections::HashSet<_> = + ops.line_ops().iter().map(|line| line.branch_id()).collect(); + for branch in observed { + if !addressed.contains(branch) { + ops.add_line_op(atomic_core::change::LineOps::delete_empty(*branch)); + } + } + recorded.set_crdt_ops(ops); + } + } + recorded +} + #[cfg(test)] thread_local! { pub(super) static RECORD_APPLY_FAULT: std::cell::Cell = const { std::cell::Cell::new(false) }; @@ -121,6 +188,378 @@ impl Repository { ) } + /// Record an explicit foreign merge result for a same-path file conflict. + /// Uses the native name-resolution policy and canonical claims; no TREE + /// binding or import order chooses the retained inode. Returns None when + /// the selected closure has no name conflict at this path. + /// + /// # Examples + /// + /// ```no_run + /// # fn example(repo: &atomic_repository::Repository) -> Result<(), String> { + /// let records = repo.record_foreign_name_resolution_on_view_excluding( + /// "config.txt", b"merged settings\n", "main", &[], + /// )?; + /// // Include these records in the GitResolution for the parent union. + /// # Ok(()) + /// # } + /// ``` + /// + /// # Errors + /// Refuses directory or rename conflicts and unreadable graph or semantic + /// state. Equal-content claimants in an explicit foreign merge are resolved + /// by canonical identity, with the losing claims retained in history. + pub fn record_foreign_name_resolution_on_view_excluding( + &self, + path: &str, + new_content: &[u8], + view_name: &str, + excluded: &[Hash], + ) -> Result>, String> { + use atomic_core::output::alive::RetrieveOptions; + use atomic_core::record::workflow::{ + record_modified_file, DetectedFile, RecordedFile, RecordingOptions, + }; + let txn = self.pristine.read_txn().map_err(|e| e.to_string())?; + let view = txn + .get_view(view_name) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("view '{view_name}' does not exist"))?; + let visibility = super::filter::assembly_visibility_excluding(&txn, &view, excluded) + .map_err(|e| e.to_string())?; + let projection = self + .project_tree_for_visibility_scoped(&txn, &visibility, Some(path)) + .map_err(|e| e.to_string())?; + let Some(conflict) = projection.name_conflicts.get(path) else { + return Ok(None); + }; + let cached = CachedGraphTxn::new(&txn).map_err(|e| e.to_string())?; + let resolution = prepare_name_resolution( + &txn, + &cached, + &self.change_store, + &visibility, + path, + conflict, + new_content, + NameResolutionPolicy::ForeignMerge, + ) + .map_err(|e| e.to_string())?; + let winner = resolution.winner; + let mut records = Vec::new(); + for side in conflict.sides_at_path(path) { + if side.inode == winner.inode { + continue; + } + let bindings = super::materialize::live_names_for_path( + &txn, + &self.change_store, + side.position, + path, + &visibility.iter_dependency_first().copied().collect(), + ) + .map_err(|e| e.to_string())?; + let mut unlinked = RecordedFile::new(path); + unlinked.set_inode(side.inode); + unlinked.set_position(side.position); + unlinked.set_name_conflict_resolution(winner.position, bindings); + // Match native resolution: unlink losing names, retain their + // graph content and semantic identities as historical evidence. + let (trunk, _, _) = + self.resolve_existing_semantic_state(path, side.inode, view_name, excluded)?; + if let Some(trunk) = trunk { + unlinked.set_crdt_ops(atomic_core::change::FileOps::new( + trunk, + path.to_string(), + None, + )); + } + records.push(unlinked); + } + let (old_content, _) = super::content::retrieve_content_with_filter_fast_with_fork_info( + &txn, + &self.change_store, + winner.inode, + winner.position, + RetrieveOptions::new().with_graph_visibility(visibility), + ) + .map_err(|e| e.to_string())?; + if old_content != new_content { + let (trunk, branches, observed) = + self.resolve_existing_semantic_state(path, winner.inode, view_name, excluded)?; + let memory = atomic_core::output::memory::Memory::new(); + memory.add_file(path, new_content); + let mut detected = DetectedFile::modified(path); + detected.inode = Some(winner.inode); + detected.position = Some(winner.position); + let record = record_modified_file( + &memory, + &detected, + &old_content, + None, + &RecordingOptions::new() + .unlimited_file_size() + .force_whole_file_replace(true), + trunk, + Some(&branches), + )?; + records.push(complete_semantic_replacement(record, &observed)); + } else { + // The foreign merge may resolve permissions/kind even when its + // bytes equal the retained claimant. Expose that exact survivor + // for the caller's attribute writes, never a losing path claimant. + let mut survivor = RecordedFile::new(path); + survivor.set_kind(atomic_core::record::workflow::DetectionKind::Modified); + survivor.set_inode(winner.inode); + survivor.set_position(winner.position); + records.push(survivor); + } + Ok(Some(records)) + } + + /// Paths with causally concurrent semantic writers in the selected closure. + /// + /// A foreign merge must explicitly resolve these histories even when their + /// graph bytes agree. FileOps and dependency edges establish concurrency; + /// canonical FileAdd positions and filtered path claims establish identity. + /// No Git merge-base heuristic or ambient semantic row selects a writer. + /// Errors reading canonical changes, dependencies, or path claims propagate. + pub fn foreign_merge_concurrent_semantic_paths( + &self, + view_name: &str, + excluded: &[Hash], + ) -> Result, String> { + use atomic_core::crdt::{BranchId, TrunkId, TrunkOp}; + use std::collections::{HashMap, HashSet}; + let txn = self.pristine.read_txn().map_err(|e| e.to_string())?; + let view = txn + .get_view(view_name) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("view '{view_name}' does not exist"))?; + let visibility = super::filter::assembly_visibility_excluding(&txn, &view, excluded) + .map_err(|e| e.to_string())?; + let mut dependencies: HashMap> = HashMap::new(); + let mut writers: HashMap> = HashMap::new(); + let mut line_writers: HashMap<(TrunkId, BranchId), HashSet> = HashMap::new(); + let mut positions = HashMap::new(); + for id in visibility + .iter_dependency_first() + .copied() + .filter(|id| !id.is_root()) + { + let hash = txn + .get_external(id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("closure change {id} has no hash"))?; + let change = self.load_change(&hash).map_err(|e| e.to_string())?; + let mut parents = Vec::new(); + for hash in change.dependencies() { + let parent = txn + .get_internal(hash) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("change {id} has an unregistered dependency {hash}"))?; + if !parent.is_root() && !dependencies.contains_key(&parent) { + return Err(format!( + "dependency {parent} is missing before closure change {id}" + )); + } + parents.push(parent); + } + let mut ancestors = HashSet::new(); + let mut pending = parents.clone(); + while let Some(parent) = pending.pop() { + if ancestors.insert(parent) { + if let Some(prior) = dependencies.get(&parent) { + pending.extend(prior.iter().copied()); + } + } + } + dependencies.insert(id, parents); + for ops in change.file_ops() { + let raw = ops.trunk_id(); + let trunk = if raw.change_id().is_root() { + TrunkId::new(id, raw.file_idx()) + } else { + raw + }; + if matches!(ops.trunk_op(), Some(TrunkOp::Create { .. })) { + for hunk in change.hunks() { + if let GraphOp::FileAdd { + path, add_inode, .. + } = hunk + { + if path == ops.path() { + positions.insert(Position::new(id, add_inode.start), trunk); + } + } + } + } + if ops.trunk_op().is_some() || !ops.line_ops().is_empty() { + let frontier = writers.entry(trunk).or_default(); + frontier.retain(|writer| !ancestors.contains(writer)); + frontier.insert(id); + } + // A later edit to another line does not resolve this line's + // concurrent writers merely by depending on both histories. + for line in ops.line_ops() { + let raw = line.branch_id(); + let branch = if raw.change_id().is_root() { + BranchId::new(id, raw.branch_idx()) + } else { + raw + }; + let frontier = line_writers.entry((trunk, branch)).or_default(); + frontier.retain(|writer| !ancestors.contains(writer)); + frontier.insert(id); + } + } + } + let mut concurrent: HashSet<_> = writers + .iter() + .filter(|(_, frontier)| frontier.len() > 1) + .map(|(trunk, _)| *trunk) + .collect(); + concurrent.extend( + line_writers + .iter() + .filter(|(_, frontier)| frontier.len() > 1) + .map(|((trunk, _), _)| *trunk), + ); + let claims = super::name_resolution::reduce_path_claims(&txn, &visibility) + .map_err(|e| e.to_string())?; + Ok(claims + .present + .iter() + .chain( + claims + .conflicts + .values() + .flat_map(|conflict| conflict.sides.iter()), + ) + .filter(|claim| { + positions + .get(&claim.position) + .is_some_and(|trunk| concurrent.contains(trunk)) + }) + .map(|claim| claim.path.clone()) + .collect()) + } + + /// Resolve directory identities selected by a foreign merge tree, retaining + /// surviving children through explicit same-path structural moves. + /// + /// Git directories have no inode identity. Independently introduced + /// directories therefore need a canonical name resolution in addition to + /// any file resolutions. The returned records must precede `file_records` + /// so assembly uses the selected directory anchors for new children too. + /// All claims come from the exact parent closure; no TREE binding selects + /// the winner and no losing directory's children are silently discarded. + pub fn record_foreign_directory_resolutions_on_view_excluding( + &self, + directories: &std::collections::BTreeSet, + file_records: &[atomic_core::record::workflow::RecordedFile], + view_name: &str, + excluded: &[Hash], + ) -> Result, String> { + use atomic_core::record::workflow::{DetectionKind, RecordedFile}; + let txn = self.pristine.read_txn().map_err(|e| e.to_string())?; + let view = txn + .get_view(view_name) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("view '{view_name}' does not exist"))?; + let visibility = super::filter::assembly_visibility_excluding(&txn, &view, excluded) + .map_err(|e| e.to_string())?; + let reduced = super::name_resolution::reduce_path_claims(&txn, &visibility) + .map_err(|e| e.to_string())?; + let mut conflicts: Vec<_> = reduced + .conflicts + .iter() + .filter(|(path, conflict)| { + directories.contains(*path) && conflict.sides.iter().any(|side| side.is_directory()) + }) + .collect(); + conflicts.sort_by_key(|(path, _)| (path.matches('/').count(), *path)); + let mut records = Vec::new(); + let mut replaced_parents = std::collections::HashSet::new(); + let mut losing = std::collections::HashSet::new(); + for record in file_records { + if record.name_conflict_resolution().is_some() + || record.kind() == Some(DetectionKind::Deleted) + { + if let Some(position) = record.position() { + losing.insert(position); + } + } + } + for (path, conflict) in conflicts { + if conflict.is_rename_conflict() + || conflict.sides.iter().any(|side| !side.is_directory()) + { + return Err(format!( + "cannot resolve mixed-kind or renamed directory '{path}'" + )); + } + let mut sides = Vec::new(); + for side in conflict.sides_at_path(path) { + let hash = txn + .get_external(side.position.change) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("directory '{path}' has no canonical identity"))?; + sides.push(((hash, side.position.pos), side)); + } + sides.sort_by_key(|(key, _)| *key); + let Some((_, winner)) = sides.first() else { + continue; + }; + for (_, side) in sides.iter().skip(1) { + losing.insert(side.position); + replaced_parents.insert(side.position); + let mut record = RecordedFile::new(path); + record.set_kind(DetectionKind::Modified); + record.set_inode(side.inode); + record.set_position(side.position); + record.set_name_conflict_resolution( + winner.position, + side.claims.iter().map(|claim| claim.name).collect(), + ); + records.push(record); + } + } + // Resolve the directory names first, then reconnect each surviving + // immediate child to the selected parent. Nested directories use the + // same operation, retaining both their inode and their descendants. + let mut children: Vec<_> = reduced + .present + .iter() + .chain( + reduced + .conflicts + .values() + .flat_map(|conflict| conflict.sides.iter()), + ) + .collect(); + children.sort_by_key(|side| (&side.path, side.position)); + children.dedup_by_key(|side| (&side.path, side.position)); + for child in children { + if losing.contains(&child.position) { + continue; + } + for source in &child.claims { + if !replaced_parents.contains(&source.parent.end_pos()) { + continue; + } + let mut record = RecordedFile::new(&child.path); + record.set_kind(DetectionKind::Moved); + record.set_old_path(child.path.clone()); + record.set_inode(child.inode); + record.set_position(child.position); + record.set_exact_source_claim(*source); + records.push(record); + } + } + Ok(records) + } + /// Record a modified file against an explicit baseline while binding the /// edit to the file's existing CRDT trunk and alive branches (CB-9B /// review F4). @@ -170,15 +609,25 @@ impl Repository { .get_inode_and_position_on_view_excluding(path, view_name, excluded) .map_err(|e| format!("cannot resolve '{path}' in view '{view_name}': {e}"))? .ok_or_else(|| format!("path '{path}' is absent from view '{view_name}'"))?; - let (existing_trunk_id, existing_branches) = - self.resolve_existing_semantic_state(path, inode)?; + let (existing_trunk_id, existing_branches, observed) = + self.resolve_existing_semantic_state(path, inode, view_name, excluded)?; use atomic_core::record::workflow::{record_modified_file, DetectedFile}; let memory_wc = atomic_core::output::memory::Memory::new(); memory_wc.add_file(path, new_content); let mut detected = DetectedFile::modified(path); detected.inode = Some(inode); detected.position = Some(position); - let options = if force_whole_file_replace { + // ::26 R1 fifth follow-up (2026-10-14): the alive semantic branch + // count must equal the old content's line count — the positional + // hunks below index branches by diff line number. Histories with + // many accumulated in-place edits leave extra alive vertices and + // branches (diamond remnants), so the counts drift and positional + // anchoring corrupts the region. Force the sentinel whole-file + // replace: tombstone every prior branch and rebuild fresh, so the + // graph and semantic layers agree with the commit tree byte-exactly. + let branch_line_divergence = + existing_branches.len() != atomic_core::diff::Line::from_bytes(old_content).len(); + let options = if force_whole_file_replace || branch_line_divergence { atomic_core::record::workflow::RecordingOptions::new().force_whole_file_replace(true) } else { atomic_core::record::workflow::RecordingOptions::new() @@ -196,6 +645,7 @@ impl Repository { Some(existing_branches.as_slice()) }, ) + .map(|recorded| complete_semantic_replacement(recorded, &observed)) } /// Record a foreign modification for a path that a Git-detected rename @@ -217,52 +667,12 @@ impl Repository { new_content: &[u8], force_whole_file_replace: bool, ) -> Result { - let txn = self - .pristine - .read_txn() - .map_err(|e| format!("cannot open pristine: {e}"))?; - let inode = txn - .get_inode(old_path) - .map_err(|e| format!("cannot read inode for '{old_path}': {e}"))? - .ok_or_else(|| format!("path '{old_path}' has no inode binding"))?; - let position = txn - .inode_position(inode) - .map_err(|e| format!("cannot read position for '{old_path}': {e}"))? - .ok_or_else(|| format!("inode {inode:?} has no graph position"))?; - drop(txn); - // The trunk follows the inode through the move: resolve by inode, - // never by the new path (which cannot have a trunk yet). - let (existing_trunk_id, existing_branches) = { - use atomic_core::crdt::queries::iter_trunk_branches_in_file_order; - use atomic_core::crdt::tables::{decode_trunk_id, encode_branch_id}; - use atomic_core::pristine::CrdtTxnT; - let txn = self - .pristine - .read_txn() - .map_err(|e| format!("cannot open pristine: {e}"))?; - let existing_trunk_id = txn - .get_crdt_inode_trunk(inode.get()) - .map_err(|e| format!("cannot read CRDT trunk for inode: {e}"))? - .map(|key| decode_trunk_id(&key)); - let existing_branches: Vec = - match existing_trunk_id.as_ref() { - Some(trunk_id) => match iter_trunk_branches_in_file_order(&txn, *trunk_id) { - Ok(all) => { - let mut alive = Vec::with_capacity(all.len()); - for b in all { - let bk = encode_branch_id(&b); - if let Ok(Some(_bd)) = txn.get_crdt_branch(&bk) { - alive.push(b); - } - } - alive - } - Err(_) => Vec::new(), - }, - None => Vec::new(), - }; - (existing_trunk_id, existing_branches) - }; + let (inode, position) = self + .get_inode_and_position_on_view_excluding(old_path, &self.current_view, &[]) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("path '{old_path}' is absent from the selected closure"))?; + let (existing_trunk_id, existing_branches, observed) = + self.resolve_existing_semantic_state(old_path, inode, &self.current_view, &[])?; use atomic_core::record::workflow::{record_modified_file, DetectedFile}; let memory_wc = atomic_core::output::memory::Memory::new(); memory_wc.add_file(new_path, new_content); @@ -287,6 +697,7 @@ impl Repository { Some(existing_branches.as_slice()) }, ) + .map(|recorded| complete_semantic_replacement(recorded, &observed)) } /// The exact alive structural path claim for `path` on `view_name`, or @@ -305,6 +716,15 @@ impl Repository { &self, view_name: &str, path: &str, + ) -> Result)>, String> { + self.resolve_single_path_claim_excluding(view_name, path, &[]) + } + + fn resolve_single_path_claim_excluding( + &self, + view_name: &str, + path: &str, + excluded: &[Hash], ) -> Result)>, String> { use atomic_core::pristine::ViewTxnT; let txn = self @@ -315,7 +735,7 @@ impl Repository { .get_view(view_name) .map_err(|e| format!("cannot read view: {e}"))? .ok_or_else(|| format!("view '{view_name}' does not exist"))?; - let graph_visibility = super::filter::graph_visibility_closure(&txn, &view) + let graph_visibility = super::filter::assembly_visibility_excluding(&txn, &view, excluded) .map_err(|e| format!("cannot build view visibility: {e}"))?; let projection = self .project_tree_for_visibility(&txn, &graph_visibility) @@ -365,78 +785,40 @@ impl Repository { new_content: &[u8], force_whole_file_replace: bool, ) -> Result { - use atomic_core::record::workflow::{record_moved_file, DetectedFile, RecordingOptions}; - let txn = self - .pristine - .read_txn() - .map_err(|e| format!("cannot open pristine: {e}"))?; - let inode = txn - .get_inode(old_path) - .map_err(|e| format!("cannot read inode for '{old_path}': {e}"))? - .ok_or_else(|| format!("path '{old_path}' has no inode binding"))?; - let position = txn - .inode_position(inode) - .map_err(|e| format!("cannot read position for '{old_path}': {e}"))? - .ok_or_else(|| format!("inode {inode:?} has no graph position"))?; - drop(txn); - - // The trunk follows the inode through the move: resolve by inode, - // never by the new path (which cannot have a trunk yet). A moved - // record without a semantic trunk is exactly the defect the review - // pinned (graph-present bytes with no path→trunk mapping after - // reopen), so a missing trunk refuses instead of degrading to a - // structural-only move. - let (existing_trunk_id, existing_branches) = { - use atomic_core::crdt::queries::iter_trunk_branches_in_file_order; - use atomic_core::crdt::tables::{decode_trunk_id, encode_branch_id}; - use atomic_core::pristine::CrdtTxnT; - let txn = self - .pristine - .read_txn() - .map_err(|e| format!("cannot open pristine: {e}"))?; - let existing_trunk_id = txn - .get_crdt_inode_trunk(inode.get()) - .map_err(|e| format!("cannot read CRDT trunk for inode: {e}"))? - .map(|key| decode_trunk_id(&key)) - .ok_or_else(|| { - format!( - "path '{old_path}' has no CRDT trunk; refusing a semantic move \ - without an existing semantic identity" - ) - })?; - let existing_branches: Vec = - match iter_trunk_branches_in_file_order(&txn, existing_trunk_id) { - Ok(all) => { - let mut alive = Vec::with_capacity(all.len()); - for b in all { - let bk = encode_branch_id(&b); - if let Ok(Some(_bd)) = txn.get_crdt_branch(&bk) { - alive.push(b); - } - } - alive - } - Err(_) => Vec::new(), - }; - (existing_trunk_id, existing_branches) - }; + self.record_foreign_moved_file_excluding( + view_name, + new_path, + old_path, + old_content, + new_content, + force_whole_file_replace, + &[], + ) + } - let (source, claim_inode, claim_position) = self - .resolve_single_path_claim(view_name, old_path)? + /// Record a move against the same parent closure used by Git assembly. + #[allow(clippy::too_many_arguments)] + pub fn record_foreign_moved_file_excluding( + &self, + view_name: &str, + new_path: &str, + old_path: &str, + old_content: &[u8], + new_content: &[u8], + force_whole_file_replace: bool, + excluded: &[Hash], + ) -> Result { + use atomic_core::record::workflow::{record_moved_file, DetectedFile, RecordingOptions}; + let (source, inode, position) = self + .resolve_single_path_claim_excluding(view_name, old_path, excluded)? .ok_or_else(|| { - format!( - "move source '{old_path}' holds no alive structural path claim on \ - view '{view_name}'; refusing to fabricate a move authority" - ) + format!("move source '{old_path}' has no alive path claim on view '{view_name}'") })?; - if claim_inode != inode || claim_position != position { - return Err(format!( - "move source '{old_path}' claims stable inode {} but the inode binding \ - resolves {}; the structural move authority must match the stable identity", - claim_inode.get(), - inode.get() - )); - } + let (existing_trunk_id, existing_branches, observed) = + self.resolve_existing_semantic_state(old_path, inode, view_name, excluded)?; + let existing_trunk_id = existing_trunk_id.ok_or_else(|| { + format!("path '{old_path}' has no CRDT trunk for its canonical inode") + })?; let memory_wc = atomic_core::output::memory::Memory::new(); memory_wc.add_file(new_path, new_content); @@ -471,7 +853,7 @@ impl Repository { no semantic FileOps" )); } - Ok(recorded) + Ok(complete_semantic_replacement(recorded, &observed)) } /// Record a foreign Git deletion bound to the ACTUAL deleted trunk @@ -491,24 +873,24 @@ impl Repository { pub fn record_foreign_deleted_file( &self, path: &str, + ) -> Result { + self.record_foreign_deleted_file_on_view_excluding(path, &self.current_view, &[]) + } + + /// Delete the identity visible in the requested assembly closure. + pub fn record_foreign_deleted_file_on_view_excluding( + &self, + path: &str, + view_name: &str, + excluded: &[Hash], ) -> Result { use atomic_core::change::FileOps; - use atomic_core::pristine::TreeTxnT; - let txn = self - .pristine - .read_txn() - .map_err(|e| format!("cannot open pristine: {e}"))?; - let inode = txn - .get_inode(path) - .map_err(|e| format!("cannot read inode for '{path}': {e}"))? - .ok_or_else(|| format!("path '{path}' has no inode binding"))?; - let position = txn - .inode_position(inode) - .map_err(|e| format!("cannot read position for '{path}': {e}"))? - .ok_or_else(|| format!("inode {inode:?} has no graph position"))?; - drop(txn); - let (existing_trunk_id, existing_branches) = - self.resolve_existing_semantic_state(path, inode)?; + let (inode, position) = self + .get_inode_and_position_on_view_excluding(path, view_name, excluded) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("path '{path}' is absent from view '{view_name}'"))?; + let (existing_trunk_id, _, observed) = + self.resolve_existing_semantic_state(path, inode, view_name, excluded)?; let existing_trunk_id = existing_trunk_id.ok_or_else(|| { format!( "path '{path}' has no CRDT trunk; refusing a semantic deletion without \ @@ -527,9 +909,10 @@ impl Repository { "cannot import deletion '{path}': canonical FileDel is empty" )); } - // Bind the semantic deletion to the real trunk and alive branches. + // Deletion dominates every line observed in this file's closure, + // including lines concurrently restored in the ambient table. let mut ops = FileOps::delete(existing_trunk_id, path.to_string()); - for branch in existing_branches { + for branch in observed { ops.add_line_op(atomic_core::change::LineOps::delete_empty(branch)); } recorded.set_crdt_ops(ops); @@ -571,56 +954,146 @@ impl Repository { } /// Resolve the existing CRDT semantic state of a tracked path: the - /// current trunk (path index first — the authoritative "trunk of this - /// path" mapping; the inode index can lag when the CRDT layer allocated - /// a parallel inode for synthesized flows) and its alive branches in - /// file order. Errors fail closed. + /// canonical trunk, alive branches in closure-local file order, and all + /// observed branch identities. The latter is the deletion domain of a + /// whole-file resolution; it must not depend on ambient lifecycle rows. + /// Errors fail closed. fn resolve_existing_semantic_state( &self, path: &str, inode: atomic_core::types::Inode, - ) -> Result< - ( - Option, - Vec, - ), - String, - > { - use atomic_core::crdt::queries::iter_trunk_branches_in_file_order; - use atomic_core::crdt::tables::{decode_trunk_id, encode_branch_id}; + view_name: &str, + excluded: &[Hash], + ) -> Result { + use atomic_core::crdt::tables::decode_trunk_id; use atomic_core::pristine::CrdtTxnT; let txn = self .pristine .read_txn() .map_err(|e| format!("cannot open pristine: {e}"))?; - let inode_u64 = inode.get(); - let existing_trunk_id = match txn.get_trunk_by_path(path) { - Ok(Some(trunk_key)) => Some(trunk_key), - _ => match txn.get_crdt_inode_trunk(inode_u64) { - Ok(Some(trunk_key)) => Some(decode_trunk_id(&trunk_key)), - _ => None, - }, - }; - let existing_branches: Vec = match existing_trunk_id.as_ref() { - Some(trunk_id) => match iter_trunk_branches_in_file_order(&txn, *trunk_id) { - Ok(all) => { - let mut alive = Vec::with_capacity(all.len()); - for b in all { - let bk = encode_branch_id(&b); - if let Ok(Some(bd)) = txn.get_crdt_branch(&bk) { - if bd.state.is_alive() { - alive.push(b); - } + // The inode's canonical FileAdd fixes the trunk identity. Both path + // and inode semantic indexes are derived and may need rebuilding. + let position = txn + .inode_position(inode) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("inode for '{path}' has no graph position"))?; + let hash = txn + .get_external(position.change) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("inode for '{path}' has no introducing change"))?; + let creator = self.load_change(&hash).map_err(|e| e.to_string())?; + let mut trunks = std::collections::HashSet::new(); + for hunk in creator.hunks() { + if let atomic_core::change::GraphOp::FileAdd { + add_inode, + path: created_path, + .. + } = hunk + { + if add_inode.start == position.pos { + for ops in creator.file_ops() { + if ops.path() == created_path + && matches!( + ops.trunk_op(), + Some(atomic_core::crdt::TrunkOp::Create { .. }) + ) + { + let raw = ops.trunk_id(); + trunks.insert(if raw.change_id().is_root() { + atomic_core::crdt::TrunkId::new(position.change, raw.file_idx()) + } else { + raw + }); } } - alive } - Err(_) => Vec::new(), - }, - None => Vec::new(), + } + } + if trunks.len() > 1 { + return Err(format!("multiple semantic identities for '{path}'")); + } + let existing_trunk_id = trunks.into_iter().next(); + let Some(trunk) = existing_trunk_id else { + return Ok((None, Vec::new(), Vec::new())); }; - Ok((existing_trunk_id, existing_branches)) + let indexed = txn + .get_crdt_inode_trunk(inode.get()) + .map_err(|e| e.to_string())? + .map(|key| decode_trunk_id(&key)); + if indexed != Some(trunk) { + return Err(format!("semantic inode index for '{path}' disagrees with its canonical FileAdd; rebuild semantic indexes")); + } + let view = txn + .get_view(view_name) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("view '{view_name}' does not exist"))?; + let visibility = super::filter::assembly_visibility_excluding(&txn, &view, excluded) + .map_err(|e| e.to_string())?; + let mut alive = std::collections::HashMap::new(); + let mut after = std::collections::BTreeMap::new(); + for change_id in visibility + .iter_dependency_first() + .copied() + .filter(|id| !id.is_root()) + { + let hash = txn + .get_external(change_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("closure change {change_id} has no hash"))?; + let change = self.load_change(&hash).map_err(|e| e.to_string())?; + for ops in change.file_ops() { + let raw = ops.trunk_id(); + let resolved = if raw.change_id().is_root() { + atomic_core::crdt::TrunkId::new(change_id, raw.file_idx()) + } else { + raw + }; + if resolved != trunk { + continue; + } + for line in ops.line_ops() { + let raw = line.branch_id(); + let branch = if raw.change_id().is_root() { + atomic_core::crdt::BranchId::new(change_id, raw.branch_idx()) + } else { + raw + }; + use atomic_core::crdt::BranchOp; + let resolve = |id: atomic_core::crdt::BranchId| { + if id.change_id().is_root() { + atomic_core::crdt::BranchId::new(change_id, id.branch_idx()) + } else { + id + } + }; + match line.operation() { + BranchOp::Insert { + after: predecessor, .. + } => { + alive.insert(branch, true); + after.insert(branch, predecessor.map(resolve)); + } + BranchOp::Modify { .. } | BranchOp::Restore { .. } => { + alive.insert(branch, true); + } + BranchOp::Delete { .. } => { + alive.insert(branch, false); + } + BranchOp::Reparent { new_after, .. } => { + after.insert(branch, new_after.map(resolve)); + } + } + } + } + } + let observed = semantic_branch_order(&after)?; + let existing_branches = observed + .iter() + .copied() + .filter(|branch| alive.get(branch) == Some(&true)) + .collect(); + Ok((existing_trunk_id, existing_branches, observed)) } /// This is the main entry point for creating a change from working copy @@ -1749,7 +2222,11 @@ impl Repository { path, conflict, &repository_bytes, - preserve_mode, + if preserve_mode { + NameResolutionPolicy::PreserveWorkingIdentity + } else { + NameResolutionPolicy::EditedContent + }, ) { Ok(resolution) => Some(resolution), Err(error) => { @@ -3270,6 +3747,13 @@ fn move_similarity(old: &[u8], new: &[u8]) -> (u16, crate::record::MoveBasis) { (score, MoveBasis::ContentSimilarity) } +#[derive(Clone, Copy, PartialEq, Eq)] +enum NameResolutionPolicy { + EditedContent, + PreserveWorkingIdentity, + ForeignMerge, +} + #[allow(clippy::too_many_arguments)] fn prepare_name_resolution( txn: &atomic_core::pristine::ReadTxn, @@ -3279,11 +3763,12 @@ fn prepare_name_resolution( path: &str, conflict: &super::name_resolution::ProjectedNameConflict, working: &[u8], - preserve_mode: bool, + policy: NameResolutionPolicy, ) -> Result { use atomic_core::record::workflow::globalize::{ globalize_solve_name_conflict, GlobalizeContext, NameConflictClaim, }; + let preserve_mode = policy == NameResolutionPolicy::PreserveWorkingIdentity; if conflict.is_rename_conflict() { return Err(RecordError::Database(format!( @@ -3355,6 +3840,15 @@ fn prepare_name_resolution( candidates.sort_by_key(|(identity, _)| *identity); matching.push(candidates.remove(0).1); } + if matching.len() > 1 && policy == NameResolutionPolicy::ForeignMerge { + // A Git merge explicitly selects a single file at this path but does + // not carry Atomic inode identities. When multiple claims have exactly + // those bytes, choose a stable canonical identity for the explicit + // SolveNameConflict operation. This does not deduplicate graph history + // or use ambient TREE / local allocation order to hide a conflict. + matching.sort_by_key(|side| (external_hashes[&side.position.change], side.position.pos)); + matching.truncate(1); + } if matching.len() != 1 { // Review ::15 blocker support (user decision "Preserve current // files"): with MULTIPLE byte-equal claimants the working content is diff --git a/atomic-repository/src/repository/resurrection.rs b/atomic-repository/src/repository/resurrection.rs index c875ae1c..543496cd 100644 --- a/atomic-repository/src/repository/resurrection.rs +++ b/atomic-repository/src/repository/resurrection.rs @@ -1237,7 +1237,7 @@ impl Repository { }, )?; let change = self.load_change(hash)?; - let already_applied = txn.has_change_in_graph(change_id).map_err(pristine_map)?; + let already_applied = txn.was_change_applied(change_id).map_err(pristine_map)?; let tree_projection = self.plan_tree_projection(&mut txn, change_id, *hash, &change, &[], false)?; tree_projection.apply_prerequisites(&mut *txn)?; diff --git a/atomic-repository/src/repository/synthesis.rs b/atomic-repository/src/repository/synthesis.rs index 559ae80c..f29e404d 100644 --- a/atomic-repository/src/repository/synthesis.rs +++ b/atomic-repository/src/repository/synthesis.rs @@ -961,116 +961,107 @@ fn global_alive_set( Ok(set) } +/// FileOps paths describe the name at recording time. Stable trunk identity +/// determines ownership after rename or path reuse; branch/vertex/token IDs +/// are checked separately by each attribution predicate below. +fn file_ops_target_trunk( + ops: &atomic_core::change::FileOps, + owner: atomic_core::types::NodeId, + trunk: atomic_core::crdt::TrunkId, +) -> bool { + let raw = ops.trunk_id(); + let resolved = if raw.change_id().is_root() { + atomic_core::crdt::TrunkId::new(owner, raw.file_idx()) + } else { + raw + }; + resolved == trunk +} + /// Whether any applied change OUTSIDE the interpreted closure recorded the /// semantic op that deleted `branch` (review C1/C3: tombstones are attributed /// by actual operations — a registered writer's Delete/Modify for the branch /// — not merely by graph liveness, because a whole-file replace tombstones /// semantic branches without graph-deleting the replaced vertices). +#[cfg(test)] pub(crate) fn out_of_closure_writer_deleted_branch( repo_change_loader: &dyn Fn(&Hash) -> Result, txn: &atomic_core::pristine::WriteTxn<'_>, - path: &str, + trunk: atomic_core::crdt::TrunkId, branch: atomic_core::crdt::BranchId, visibility: &atomic_core::pristine::GraphVisibilityClosure, ) -> Result { - use atomic_core::pristine::GraphTxnT; - for (change_id, hash) in txn.list_registered_changes().map_err(|e| e.to_string())? { - if visibility.contains(change_id) { - continue; - } - // Review F1: a registered change is NOT an applied writer. The - // scanner may attribute a tombstone only to a change whose operations - // actually ran in the graph; a merely registered (e.g. fetched or - // staged) change's FileOps are not execution proof. Mirrors - // `applied_writer_deleted_branch` (review D2). - if !txn - .has_change_in_graph(change_id) - .map_err(|e| e.to_string())? - { - continue; - } - let Ok(change) = repo_change_loader(&hash) else { - continue; - }; - for ops in change.file_ops() { - if ops.path() != path { - continue; - } - for line_op in ops.line_ops() { - let raw_branch = line_op.branch_id(); - let resolved = if raw_branch.change_id().is_root() { - atomic_core::crdt::BranchId::new(change_id, raw_branch.branch_idx()) - } else { - raw_branch - }; - if resolved != branch { - continue; - } - match line_op.operation() { - // A Delete tomstones the branch; a Modify tombstones and - // re-inserts the same id (either writer legitimately - // produced the row's tombstone at some point). - atomic_core::crdt::BranchOp::Delete { .. } - | atomic_core::crdt::BranchOp::Modify { .. } => return Ok(true), - _ => {} - } - } - } - } - Ok(false) + Ok( + SemanticDeletionEvidence::load(repo_change_loader, txn, trunk, visibility)? + .outside + .contains(&branch), + ) } -/// Whether ANY applied change recorded the semantic op that deleted -/// `branch` for `path`, regardless of which closure the writer belongs to -/// (review D2, unexpected-chain-branch attribution): a forced whole-file -/// replace inside the interpreted closure tombstones every bound branch — -/// including branches created by out-of-closure concurrent writers — so a -/// genuine tombstone's writer may live on either side of the closure -/// boundary. The loaded change must be a real applied change (a registered -/// id alone is not a writer, review D2). -fn applied_writer_deleted_branch( - repo_change_loader: &dyn Fn(&Hash) -> Result, - txn: &atomic_core::pristine::WriteTxn<'_>, - path: &str, - branch: atomic_core::crdt::BranchId, -) -> Result { - use atomic_core::pristine::GraphTxnT; - for (change_id, hash) in txn.list_registered_changes().map_err(|e| e.to_string())? { - if !txn - .has_change_in_graph(change_id) - .map_err(|e| e.to_string())? - { - continue; - } - let Ok(change) = repo_change_loader(&hash) else { - continue; - }; - for ops in change.file_ops() { - if ops.path() != path { +/// Transaction-local deletion proofs for one exact trunk and visibility. +/// Scan canonical applied FileOps once, rather than cloning every historical +/// change again for every dead branch in the staged file-order chain. +#[derive(Default)] +pub(super) struct SemanticDeletionEvidence { + pub(super) applied: std::collections::HashSet, + pub(super) outside: std::collections::HashSet, +} + +type SemanticDeletionCache = std::cell::OnceCell>; + +/// Evidence shared by branch checks within one staged file verification. +struct SemanticBranchContext<'a> { + visibility: &'a atomic_core::pristine::GraphVisibilityClosure, + global_alive: + &'a std::collections::HashSet>, + deletion_evidence: &'a SemanticDeletionCache, +} + +impl SemanticDeletionEvidence { + pub(super) fn load( + loader: &dyn Fn(&Hash) -> Result, + txn: &atomic_core::pristine::WriteTxn<'_>, + trunk: atomic_core::crdt::TrunkId, + visibility: &atomic_core::pristine::GraphVisibilityClosure, + ) -> Result { + use atomic_core::pristine::GraphTxnT; + let mut evidence = Self::default(); + for (change_id, hash) in txn.list_registered_changes().map_err(|e| e.to_string())? { + // Registration alone is never proof that FileOps executed. + if !txn + .was_change_applied(change_id) + .map_err(|e| e.to_string())? + { continue; } - for line_op in ops.line_ops() { - let raw_branch = line_op.branch_id(); - let resolved = if raw_branch.change_id().is_root() { - atomic_core::crdt::BranchId::new(change_id, raw_branch.branch_idx()) - } else { - raw_branch - }; - if resolved != branch { + let Ok(change) = loader(&hash) else { continue }; + for ops in change.file_ops() { + if !file_ops_target_trunk(ops, change_id, trunk) { continue; } - match line_op.operation() { - // A Delete tomstones the branch; a Modify tombstones and - // re-inserts the same id (either writer legitimately - // produced the row's tombstone at some point). - atomic_core::crdt::BranchOp::Delete { .. } - | atomic_core::crdt::BranchOp::Modify { .. } => return Ok(true), - _ => {} + for line in ops.line_ops() { + if !matches!( + line.operation(), + atomic_core::crdt::BranchOp::Delete { .. } + | atomic_core::crdt::BranchOp::Modify { .. } + ) { + continue; + } + let raw = line.branch_id(); + let branch = if raw.change_id().is_root() { + atomic_core::crdt::BranchId::new(change_id, raw.branch_idx()) + } else { + raw + }; + evidence.applied.insert(branch); + if !visibility.contains(change_id) { + evidence.outside.insert(branch); + } } } } + Ok(evidence) } - Ok(false) } /// Review E1: whether the GLOBAL graph genuinely proves `branch`'s bound @@ -1086,7 +1077,7 @@ fn applied_writer_deleted_branch( fn graph_proves_dead_with_ownership( repo: &Repository, txn: &atomic_core::pristine::WriteTxn<'_>, - path: &str, + trunk: atomic_core::crdt::TrunkId, branch: atomic_core::crdt::BranchId, node: &atomic_core::types::GraphNode, global_alive: &std::collections::HashSet< @@ -1118,7 +1109,7 @@ fn graph_proves_dead_with_ownership( .load_change(&hash) .map_err(|e| format!("cannot load branch vertex writer: {e}"))?; Ok(change_wrote_branch_vertex_for_branch( - &writer, path, branch, *node, + &writer, trunk, branch, *node, )) } @@ -1165,7 +1156,7 @@ fn vertex_content_bytes( pub(crate) fn out_of_closure_writer_deleted_trunk( repo_change_loader: &dyn Fn(&Hash) -> Result, txn: &atomic_core::pristine::WriteTxn<'_>, - path: &str, + trunk: atomic_core::crdt::TrunkId, visibility: &atomic_core::pristine::GraphVisibilityClosure, ) -> Result { use atomic_core::pristine::GraphTxnT; @@ -1176,7 +1167,7 @@ pub(crate) fn out_of_closure_writer_deleted_trunk( // Review F1: same application proof as the branch scanner — a // registered-but-unapplied change never attributes a trunk tombstone. if !txn - .has_change_in_graph(change_id) + .was_change_applied(change_id) .map_err(|e| e.to_string())? { continue; @@ -1185,7 +1176,7 @@ pub(crate) fn out_of_closure_writer_deleted_trunk( continue; }; for ops in change.file_ops() { - if ops.path() != path { + if !file_ops_target_trunk(ops, change_id, trunk) { continue; } if matches!( @@ -1199,21 +1190,21 @@ pub(crate) fn out_of_closure_writer_deleted_trunk( Ok(false) } -/// Whether `change`'s own FileOps for `path` recorded the line op that bound +/// Whether `change`'s own FileOps for the stable trunk recorded the line op that bound /// branch `branch` to `vertex` (review C1: concurrency attribution by actual /// operations, not bare registered ids; review E1: EXACT branch→vertex file /// ownership). The op must resolve to THIS branch id AND carry exactly the -/// vertex's content range for THIS path — a different file's real vertex +/// vertex's content range for THIS trunk — a different file's real vertex /// (the same change's other-file line op at that range) never proves a /// binding for this file's branch. fn change_wrote_branch_vertex_for_branch( change: &Change, - path: &str, + trunk: atomic_core::crdt::TrunkId, branch: atomic_core::crdt::BranchId, vertex: atomic_core::types::GraphNode, ) -> bool { for ops in change.file_ops() { - if ops.path() != path { + if !file_ops_target_trunk(ops, vertex.change, trunk) { continue; } for line_op in ops.line_ops() { @@ -1244,19 +1235,19 @@ fn change_wrote_branch_vertex_for_branch( /// Whether `change`'s own FileOps key `leaf`'s id when applied (review D2: /// a registered NodeId is not proof of a writer — the leaf must come from /// the writer's own per-apply counter over the Insert/Modify content it -/// recorded for this path and branch). The replay mirrors the apply's +/// recorded for this trunk and branch). The replay mirrors the apply's /// leaf-id contract exactly: one counter per change spanning every file /// entry in order, advanced by every recorded leaf op. fn change_wrote_leaf( change: &Change, - path: &str, + trunk: atomic_core::crdt::TrunkId, branch: atomic_core::crdt::BranchId, leaf: atomic_core::crdt::LeafId, ) -> bool { let owner = leaf.change_id(); let mut counter = 0u32; for ops in change.file_ops() { - let is_target = ops.path() == path; + let is_target = file_ops_target_trunk(ops, owner, trunk); for line_op in ops.line_ops() { let raw_branch = line_op.branch_id(); let resolved = if raw_branch.change_id().is_root() { @@ -1839,6 +1830,15 @@ impl Repository { /// against the staged graph projection before the transaction commits. /// `hashed_metadata` is hash-covered caller metadata (CB-9B raw foreign /// facts); empty when none. + /// + /// `supersede` names the genuinely replaced history to remove from the + /// target view in the same transaction: view members whose Git origin is + /// outside the import's full target closure. Assembly exclusions that are + /// sibling legs of that same closure (invisible while this commit + /// assembles, but part of the import target) must not appear here — the + /// later merge resolution re-references them through + /// `ensure_union_state`, and a mid-walk eviction would leave descendant + /// commits assembling against a missing baseline. #[allow(clippy::too_many_arguments)] pub fn synthesize_git_change( &self, @@ -1855,7 +1855,7 @@ impl Repository { expectation: &StagedExpectation, git_commit_sha: Option<&str>, parent_interpretation_closure: &[Hash], - supersede_excluded: bool, + supersede: &[Hash], ) -> Result { let working_copy = self.require_working_copy_id()?; // Reentrant under an outer workspace transaction on the same thread; @@ -1876,7 +1876,7 @@ impl Repository { expectation, git_commit_sha, parent_interpretation_closure, - supersede_excluded, + supersede, ) } @@ -1934,7 +1934,7 @@ impl Repository { // R1): its assembly exclusions are sibling lineages that the union // legitimately contains, so they are never superseded from the // view — `ensure_union_state` re-references them first. - false, + &[], ) } @@ -1959,7 +1959,7 @@ impl Repository { parent_interpretation_closure: &[Hash], expectation: &StagedExpectation, assembly_excluded: &[Hash], - supersede_excluded: bool, + supersede: &[Hash], ) -> Result { let working_copy = self.require_working_copy_id()?; let operation_lock = self.try_lock_operation(working_copy)?; @@ -1978,7 +1978,7 @@ impl Repository { expectation, git_commit_sha, parent_interpretation_closure, - supersede_excluded, + supersede, ) } @@ -2000,7 +2000,7 @@ impl Repository { parent_interpretation_closure: &[Hash], expectation: &StagedExpectation, assembly_excluded: &[Hash], - supersede_excluded: bool, + supersede: &[Hash], ) -> Result { self.synthesize_git_change_locked( workspace.operation_lock(), @@ -2017,7 +2017,7 @@ impl Repository { expectation, git_commit_sha, parent_interpretation_closure, - supersede_excluded, + supersede, ) } @@ -2121,7 +2121,7 @@ impl Repository { expectation: &StagedExpectation, git_commit_sha: Option<&str>, parent_interpretation_closure: &[Hash], - supersede_excluded: bool, + supersede: &[Hash], ) -> Result { use atomic_core::pristine::ViewTxnT; use atomic_core::record::workflow::assemble_change; @@ -2286,25 +2286,25 @@ impl Repository { .map_err(|e| RepositoryError::Database(e.to_string()))? .is_some(); let applied = txn - .has_change_in_graph(change_id) + .was_change_applied(change_id) .map_err(|e| RepositoryError::Database(e.to_string()))?; (in_view, applied) }; if in_view { // Review C4: an already-known hash cannot bypass the - // supersede alignment. When this synthesis would supersede - // assembly exclusions, the published effective view must not - // still contain them — including through ancestors. A + // supersede alignment. When this synthesis supersedes + // genuinely replaced history, the published effective view + // must not still contain it — including through ancestors. A // survivor means the boundary cannot be aligned without // touching ancestor/unrelated work: refuse instead of - // reporting a clean already-in-view outcome. - if supersede_excluded && !assembly_excluded.is_empty() { + // reporting a clean already-in-view outcome. Same-closure + // sibling legs are never superseded and legitimately survive. + if !supersede.is_empty() { let txn = self .pristine .read_txn() .map_err(|e| RepositoryError::Database(e.to_string()))?; - let survivors = - self.surviving_exclusions(&txn, view_name, assembly_excluded)?; + let survivors = self.surviving_exclusions(&txn, view_name, supersede)?; if !survivors.is_empty() { let named = survivors .iter() @@ -2337,6 +2337,15 @@ impl Repository { .load_change(&hash) .map_err(|e| RepositoryError::Database(e.to_string()))?; let txn = operation_lock.begin_write_immediate()?; + // Verification mode (review C4): prove the effective + // boundary only when every assembly exclusion left the + // effective membership (genuinely replaced history); + // retained same-closure sibling legs keep the isolated + // assembly filter, which is this commit's own + // interpretation. + let verify_effective = self + .surviving_exclusions(&*txn, view_name, assembly_excluded)? + .is_empty(); let verification = self.verify_staged_projection( &txn, view_name, @@ -2344,7 +2353,7 @@ impl Repository { assembly_excluded, expectation, &hash, - supersede_excluded, + verify_effective, ); // The transaction is dropped uncommitted: this is a // read-side proof, not a mutation stage. @@ -2381,21 +2390,24 @@ impl Repository { timings, assembly_excluded, expectation, - supersede_excluded, + supersede, ); } } // 3. Journal the SynthesizeGit intent before any visible effect. // - // CB-9B review B4: when the caller supersedes its assembly - // exclusions (single-parent rewrite synthesis), the excluded + // CB-9B review B4: when the caller supersedes genuinely replaced + // history (single-parent rewrite synthesis), the replaced // Git-origin members that live in THIS view leave the view in the // same journal operation that adds the new change, so the published // boundary equals the commit's exact interpreted closure. Members // living in ancestor views are never removed: ancestor views are // shared perspectives whose membership other work depends on - // (review B4: preserve other views/history). + // (review B4: preserve other views/history). Same-closure sibling + // legs are assembly-excluded but never superseded: the import target + // still reaches them, and the merge resolution re-references them + // through `ensure_union_state`. let working_copy = operation_lock.working_copy(); let (before_state, after_state, sequence, removal_transitions) = { let txn = self @@ -2410,19 +2422,17 @@ impl Repository { })?; let mut excluded_ids: std::collections::HashSet = std::collections::HashSet::new(); - if supersede_excluded { - for excluded in assembly_excluded { - let id = txn - .get_internal(excluded) - .map_err(|e| RepositoryError::Database(e.to_string()))? - .ok_or_else(|| RepositoryError::InvalidOperation { - message: format!( - "superseded assembly exclusion names change {} which is not registered locally", - excluded.to_base32() - ), - })?; - excluded_ids.insert(id); - } + for excluded in supersede { + let id = txn + .get_internal(excluded) + .map_err(|e| RepositoryError::Database(e.to_string()))? + .ok_or_else(|| RepositoryError::InvalidOperation { + message: format!( + "superseded assembly exclusion names change {} which is not registered locally", + excluded.to_base32() + ), + })?; + excluded_ids.insert(id); } let mut removal_transitions: Vec = Vec::new(); let mut after_view_state = Merkle::ZERO; @@ -2526,7 +2536,7 @@ impl Repository { expectation, git_commit_sha, parent_interpretation_closure, - supersede_excluded, + supersede, ); match apply_result { @@ -2555,10 +2565,12 @@ impl Repository { /// apply. /// /// Review C4: the reference path carries the same supersede alignment and - /// pre-publication verification duties as the apply path — excluded - /// target-local members leave the view inside the same transaction, - /// ancestor survivors refuse before commit, and the staged proof runs - /// against the actual post-alignment effective view closure. + /// pre-publication verification duties as the apply path — genuinely + /// replaced target-local members leave the view inside the same + /// transaction, ancestor survivors refuse before commit, and the staged + /// proof runs against the actual post-alignment effective view closure + /// whenever every assembly exclusion left the effective membership + /// (retained same-closure sibling legs keep the isolated filter). /// /// Review D1: the prepared operation carries the exact lease set the /// fresh-apply path prepares — every target-local exclusion becomes a @@ -2578,7 +2590,7 @@ impl Repository { mut timings: super::ImportWriteTimings, assembly_excluded: &[Hash], expectation: &StagedExpectation, - supersede_excluded: bool, + supersede: &[Hash], ) -> Result { use atomic_core::apply::compute_new_state; @@ -2593,7 +2605,9 @@ impl Repository { // FINAL position after the compaction — never the pre-removal count. // Preparing stale leases here committed membership that the lease // verification then rejected outside the transaction, leaving a - // Prepared head that blocked ordinary reopen (review D1). + // Prepared head that blocked ordinary reopen (review D1). Only the + // genuinely replaced history in `supersede` becomes removal leases; + // same-closure sibling legs stay members of the union. let (before_state, after_state, final_sequence, removal_transitions) = { let txn = self .pristine @@ -2607,19 +2621,17 @@ impl Repository { })?; let mut excluded_ids: std::collections::HashSet = std::collections::HashSet::new(); - if supersede_excluded { - for excluded in assembly_excluded { - let id = txn - .get_internal(excluded) - .map_err(|e| RepositoryError::Database(e.to_string()))? - .ok_or_else(|| RepositoryError::InvalidOperation { - message: format!( - "superseded assembly exclusion names change {} which is not registered locally", - excluded.to_base32() - ), - })?; - excluded_ids.insert(id); - } + for excluded in supersede { + let id = txn + .get_internal(excluded) + .map_err(|e| RepositoryError::Database(e.to_string()))? + .ok_or_else(|| RepositoryError::InvalidOperation { + message: format!( + "superseded assembly exclusion names change {} which is not registered locally", + excluded.to_base32() + ), + })?; + excluded_ids.insert(id); } let mut removal_transitions: Vec = Vec::new(); let mut after_view_state = Merkle::ZERO; @@ -2708,12 +2720,13 @@ impl Repository { let apply_start = std::time::Instant::now(); let apply_result = (|| -> Result { let mut txn = operation_lock.begin_write_immediate()?; - // Review C4: supersede target-local exclusions and refuse + // Review C4: supersede target-local replaced history and refuse // ancestor survivors BEFORE the reference is published, mirroring - // the apply path's alignment contract. - if supersede_excluded { - self.supersede_excluded_members_locked(&mut txn, view_name, assembly_excluded)?; - let survivors = self.surviving_exclusions(&*txn, view_name, assembly_excluded)?; + // the apply path's alignment contract. Same-closure sibling legs + // are never superseded and stay members of the union. + if !supersede.is_empty() { + self.supersede_excluded_members_locked(&mut txn, view_name, supersede)?; + let survivors = self.surviving_exclusions(&*txn, view_name, supersede)?; if !survivors.is_empty() { let named = survivors .iter() @@ -2745,6 +2758,12 @@ impl Repository { .map_err(|e| RepositoryError::Database(e.to_string()))?; // Review C4: the referenced change must render its commit tree // through the post-alignment view before the transaction commits. + // The effective boundary is proven when every assembly exclusion + // left the effective membership; retained sibling legs keep the + // isolated assembly filter (this commit's own interpretation). + let verify_effective = self + .surviving_exclusions(&*txn, view_name, assembly_excluded)? + .is_empty(); self.verify_staged_projection( &txn, view_name, @@ -2754,7 +2773,7 @@ impl Repository { assembly_excluded, expectation, hash, - supersede_excluded, + verify_effective, )?; let outcome = ImportWriteOutcome { hash: *hash, @@ -2808,11 +2827,10 @@ impl Repository { expectation: &StagedExpectation, git_commit_sha: Option<&str>, parent_interpretation_closure: &[Hash], - supersede_excluded: bool, + supersede: &[Hash], ) -> Result { use crate::apply::write_change_to_graph; use atomic_core::apply::compute_new_state; - use atomic_core::pristine::GraphTxnT; // Deterministic test fault: fails the mutation stage after the // SynthesizeGit intent was journaled, exercising the recovery path. @@ -2862,12 +2880,12 @@ impl Repository { .map_err(|e| RepositoryError::Database(e.to_string()))?; } - // The change's hunks are applied only when its vertices are not yet - // in the global graph. A registered-but-unapplied change (interrupted + // The change's hunks are applied only when ingestion has not completed. + // A registered-but-unapplied change (interrupted // import, draft reference) is applied normally; anything already in // the graph is reference-only and was routed before this stage. let already_applied = txn - .has_change_in_graph(change_id) + .was_change_applied(change_id) .map_err(|e| RepositoryError::Database(e.to_string()))?; let tree_projection = self.plan_tree_projection( @@ -2965,21 +2983,23 @@ impl Repository { let projection_done = commit_start.elapsed(); // CB-9B review B4 + C4: align the published target to the exact - // intended closure BEFORE verification. Every superseded assembly - // exclusion leaves this view inside the same transaction, so the - // staged full-tree check below proves the post-publication view - // state — not just the isolated interpretation — and a failing check - // aborts before commit, so no misaligned boundary or failing - // successor is ever published. - if supersede_excluded { - self.supersede_excluded_members_locked(&mut txn, view_name, assembly_excluded)?; + // intended closure BEFORE verification. Genuinely replaced history + // in `supersede` leaves this view inside the same transaction, so + // the staged full-tree check below can prove the post-publication + // view state — not just the isolated interpretation — and a failing + // check aborts before commit, so no misaligned boundary or failing + // successor is ever published. Same-closure sibling legs are + // assembly-excluded but never superseded; the isolated filter + // remains their proof, and the merge resolution re-references them. + if !supersede.is_empty() { + self.supersede_excluded_members_locked(&mut txn, view_name, supersede)?; // Review C4: the alignment removes target-local members only. // An exclusion that also lives in an ANCESTOR view survives the // removal and would remain visible in the published effective // view while a filtered check hid it. Refuse before commit: // ancestor views are shared perspectives and are never // flattened or deleted to make alignment pass. - let survivors = self.surviving_exclusions(&*txn, view_name, assembly_excluded)?; + let survivors = self.surviving_exclusions(&*txn, view_name, supersede)?; if !survivors.is_empty() { let named = survivors .iter() @@ -3000,9 +3020,17 @@ impl Repository { // Fail-closed verification of the staged state, still inside the // same pristine write transaction: an error aborts before commit, so // no failing state or successor can become visible or verified. - // The supersede path verifies the ACTUAL post-alignment effective - // view closure — no exclusion subtraction — so the proof covers - // exactly what publication will show (review C4). + // Verification mode (review C4): when every assembly exclusion left + // the effective membership (genuinely replaced history was evicted, + // or there were no exclusions), the check proves the ACTUAL + // post-alignment effective view closure — no exclusion subtraction — + // covering exactly what publication will show. Retained sibling + // legs keep the isolated assembly filter: the proof is this commit's + // own interpreted closure, and the union boundary is the merge + // resolution's staged check. + let verify_effective = self + .surviving_exclusions(&*txn, view_name, assembly_excluded)? + .is_empty(); self.verify_staged_projection( &txn, view_name, @@ -3010,7 +3038,7 @@ impl Repository { assembly_excluded, expectation, hash, - supersede_excluded, + verify_effective, ) .map_err(|error| RepositoryError::InvalidOperation { message: format!( @@ -3389,6 +3417,39 @@ impl Repository { message: format!("staged retrieval failed for '{path}': {error}"), })?; if rendered.as_slice() != expected.bytes.as_slice() { + if std::env::var_os("ATOMIC_DEBUG_DELETION").is_some() { + let first_diff = rendered + .iter() + .zip(expected.bytes.iter()) + .position(|(a, b)| a != b) + .unwrap_or_else(|| rendered.len().min(expected.bytes.len())); + eprintln!( + "[debug-deletion] graph MISMATCH path={path} commit={} rendered={} expected={} first_diff_at={first_diff}", + final_change.hashed.header.message.lines().next().unwrap_or(""), + rendered.len(), + expected.bytes.len() + ); + eprintln!( + "[debug-deletion] rendered[..first_diff+72]={:?}", + String::from_utf8_lossy( + &rendered[first_diff..(first_diff + 72).min(rendered.len())] + ) + ); + eprintln!( + "[debug-deletion] expected[..first_diff+72]={:?}", + String::from_utf8_lossy( + &expected.bytes + [first_diff..(first_diff + 72).min(expected.bytes.len())] + ) + ); + eprintln!( + "[debug-deletion] rendered tail={:?} expected tail={:?}", + String::from_utf8_lossy(&rendered[rendered.len().saturating_sub(48)..]), + String::from_utf8_lossy( + &expected.bytes[expected.bytes.len().saturating_sub(48)..] + ) + ); + } return Err(RepositoryError::InvalidOperation { message: format!( "staged path '{path}' renders {} byte(s) but the commit tree expects {} byte(s)", @@ -3770,6 +3831,53 @@ impl Repository { let mut closure_created: std::collections::HashSet = std::collections::HashSet::new(); + // The caller has already resolved this path through the canonical + // graph filtered by the effective closure and rejected name conflicts. + // Its stable inode position, not the ambient path index, identifies + // the file whose semantic history we must replay. Bind that identity + // to its creating FileOps through the same change's canonical FileAdd. + // A deleted predecessor with the same path belongs to another trunk. + let mut canonical_trunks = std::collections::HashSet::new(); + for (change_id, change, _) in staged_closure { + if *change_id != file_position.change { + continue; + } + for hunk in change.hunks() { + if let atomic_core::change::GraphOp::FileAdd { + add_inode, + path: created_path, + .. + } = hunk + { + if add_inode.start != file_position.pos { + continue; + } + for ops in change.file_ops() { + if ops.path() == created_path + && matches!( + ops.trunk_op(), + Some(atomic_core::crdt::TrunkOp::Create { .. }) + ) + { + let raw = ops.trunk_id(); + let resolved = if raw.change_id().is_root() { + atomic_core::crdt::TrunkId::new(*change_id, raw.file_idx()) + } else { + raw + }; + canonical_trunks.insert(resolved); + } + } + } + } + } + if canonical_trunks.len() > 1 { + return Err(format!( + "multiple semantic trunks claim the canonical inode for '{path}'" + )); + } + let canonical_trunk = canonical_trunks.into_iter().next(); + // ── 1a. Trunk-identity discovery across Move ops (review CB-9C R2). ─ // // A pure imported rename is a real semantic move: the closure's move @@ -3819,7 +3927,7 @@ impl Repository { .filter(|(_, target)| target.as_str() != path) .map(|(trunk, _)| *trunk) .collect(); - if identity_trunks.len() > 1 { + if canonical_trunk.is_none() && identity_trunks.len() > 1 { return Err(format!( "{} distinct trunks' final moves retarget path '{path}' inside one \ interpreted closure; the semantic move chain is contradictory", @@ -3887,9 +3995,16 @@ impl Repository { }; let trunk_followed = identity_trunks.contains(&resolved_trunk) && !moved_away.contains(&resolved_trunk); - let is_target = ((ops.path() == path && !moved_away.contains(&resolved_trunk)) - || trunk_followed) - && !superseded.contains(&resolved_trunk); + let is_target = match canonical_trunk { + Some(identity) => resolved_trunk == identity, + // Retain compatibility with semantic-only legacy creates + // which have no canonical FileAdd binding. + None => { + ((ops.path() == path && !moved_away.contains(&resolved_trunk)) + || trunk_followed) + && !superseded.contains(&resolved_trunk) + } + }; if is_target { match ops.trunk_op() { Some(atomic_core::crdt::TrunkOp::Create { encoding, .. }) => { @@ -4062,10 +4177,74 @@ impl Repository { // derived location only when an applied out-of-closure Move proves it; // a missing/corrupt index without such history remains an error. let indexed_trunk = txn.get_trunk_by_path(path).map_err(|e| e.to_string())?; - let expected_identity = identity_trunks.iter().next().copied().or(created_trunk); + let expected_identity = canonical_trunk + .or_else(|| identity_trunks.iter().next().copied()) + .or(created_trunk); let mut concurrent_path = false; let mut resolved_trunk = indexed_trunk; if let Some(expected) = expected_identity { + // Concurrent same-name creates share the ambient path index. + // An explicit graph resolution may retain either inode, while + // PATH_TRUNK still points at the other historical claimant. + // Authenticate that claimant by its actual applied Create; + // never let the last index write choose the closure's identity. + if let Some(indexed) = indexed_trunk.filter(|id| *id != expected) { + if txn + .was_change_applied(indexed.change_id()) + .map_err(|e| e.to_string())? + { + if let Some(hash) = txn + .get_external(indexed.change_id()) + .map_err(|e| e.to_string())? + { + let creator = self.load_change(&hash).map_err(|e| e.to_string())?; + let mut real_claimant = creator.file_ops().iter().any(|ops| { + ops.path() == path + && file_ops_target_trunk(ops, indexed.change_id(), indexed) + && matches!( + ops.trunk_op(), + Some(atomic_core::crdt::TrunkOp::Create { .. }) + ) + }); + // A competing identity can arrive by rename as well + // as creation at this path. Authenticate both its + // creation and an applied Move to this exact path. + if !real_claimant + && creator.file_ops().iter().any(|ops| { + file_ops_target_trunk(ops, indexed.change_id(), indexed) + && matches!( + ops.trunk_op(), + Some(atomic_core::crdt::TrunkOp::Create { .. }) + ) + }) + { + for (owner, hash) in + txn.list_registered_changes().map_err(|e| e.to_string())? + { + if !txn.was_change_applied(owner).map_err(|e| e.to_string())? { + continue; + } + let writer = self.load_change(&hash).map_err(|e| e.to_string())?; + if writer.file_ops().iter().any(|ops| { + file_ops_target_trunk(ops, owner, indexed) + && matches!(ops.trunk_op(), Some(atomic_core::crdt::TrunkOp::Move { trunk, new_path }) + if *trunk == indexed && new_path == path) + }) { + real_claimant = true; + break; + } + } + } + let row_agrees = txn + .get_crdt_trunk(&encode_trunk_id(&indexed)) + .map_err(|e| e.to_string())? + .is_some_and(|row| row.path == path); + if real_claimant && row_agrees { + resolved_trunk = Some(expected); + } + } + } + } if let Some(row) = txn .get_crdt_trunk(&encode_trunk_id(&expected)) .map_err(|e| e.to_string())? @@ -4081,7 +4260,7 @@ impl Repository { { if visibility.contains(writer_id) || !txn - .has_change_in_graph(writer_id) + .was_change_applied(writer_id) .map_err(|e| e.to_string())? { continue; @@ -4183,7 +4362,7 @@ impl Repository { let loader = |hash: &Hash| -> Result { self.load_change(hash).map_err(|e| e.to_string()) }; - if out_of_closure_writer_deleted_trunk(&loader, txn, path, visibility)? { + if out_of_closure_writer_deleted_trunk(&loader, txn, trunk_key, visibility)? { // A validated out-of-closure writer recorded the semantic // delete: genuine concurrent state (review D2). concurrent_state = true; @@ -4204,6 +4383,8 @@ impl Repository { return Ok(()); } + let deletion_evidence = SemanticDeletionCache::new(); + // ── 3. Row agreement for the closure's own branch domain. ────────── // // The GLOBAL graph's alive set (every applied change, unfiltered) @@ -4331,6 +4512,23 @@ impl Repository { ConsumerLine, > = std::collections::HashMap::new(); + // A tombstoned historical chain that anchors no live line has no + // ordering effect on this closure. Audit every live line and all of + // its recorded ancestors, including deleted anchors it still uses. + let mut ordering_domain = std::collections::HashSet::new(); + for (id, branch) in &branches { + if !branch.state.is_alive() { + continue; + } + let mut next = Some(*id); + while let Some(id) = next { + if !ordering_domain.insert(id) { + break; + } + next = branches.get(&id).and_then(|branch| branch.after); + } + } + let mut concurrent_after = None; let mut replay_alive_ids: Vec = Vec::new(); for (branch_id, expected) in &branches { let branch_key = encode_branch_id(branch_id); @@ -4351,6 +4549,71 @@ impl Repository { replayed for '{path}'; branch ownership must match the closure" )); } + if closure_created.contains(branch_id) && ordering_domain.contains(branch_id) { + let stored = txn + .get_crdt_branch_after(&branch_key) + .map_err(|e| e.to_string())? + .ok_or_else(|| { + format!("branch {branch_id:?} has no recorded after-reference") + })?; + let actual_after = if stored == [0u8; 12] { + None + } else { + Some(atomic_core::crdt::tables::decode_branch_id(&stored)) + }; + if actual_after != expected.after { + if concurrent_after.is_none() { + let mut bindings = std::collections::HashSet::new(); + for (owner, hash) in + txn.list_registered_changes().map_err(|e| e.to_string())? + { + // Ambient AFTER can reflect another valid leg + // inside this merge closure too. Authenticate the + // exact binding against an applied canonical op; + // closure-local order still supplies expected bytes. + if !txn.was_change_applied(owner).map_err(|e| e.to_string())? { + continue; + } + let writer = self.load_change(&hash).map_err(|e| e.to_string())?; + let resolve = |id: atomic_core::crdt::BranchId| { + if id.change_id().is_root() { + atomic_core::crdt::BranchId::new(owner, id.branch_idx()) + } else { + id + } + }; + for ops in writer + .file_ops() + .iter() + .filter(|ops| file_ops_target_trunk(ops, owner, trunk_key)) + { + for line in ops.line_ops() { + let predecessor = match line.operation() { + atomic_core::crdt::BranchOp::Insert { after, .. } => after, + atomic_core::crdt::BranchOp::Reparent { + new_after, .. + } => new_after, + _ => continue, + }; + bindings.insert(( + resolve(line.branch_id()), + predecessor.map(resolve), + )); + } + } + } + concurrent_after = Some(bindings); + } + if !concurrent_after + .as_ref() + .unwrap() + .contains(&(*branch_id, actual_after)) + { + return Err(format!("branch {branch_id:?} has an after-reference that no applied canonical writer recorded")); + } + concurrent_state = true; + } + } let row_vertex = txn .get_crdt_branch_vertex(&branch_key) .map_err(|e| e.to_string())?; @@ -4388,7 +4651,7 @@ impl Repository { Some(node) => graph_proves_dead_with_ownership( self, txn, - path, + trunk_key, *branch_id, node, &global_alive, @@ -4399,9 +4662,14 @@ impl Repository { let loader = |hash: &Hash| -> Result { self.load_change(hash).map_err(|e| e.to_string()) }; - out_of_closure_writer_deleted_branch( - &loader, txn, path, *branch_id, visibility, - )? + deletion_evidence + .get_or_init(|| { + SemanticDeletionEvidence::load(&loader, txn, trunk_key, visibility) + }) + .as_ref() + .map_err(Clone::clone)? + .outside + .contains(branch_id) }; if !attributed { return Err( @@ -4479,7 +4747,7 @@ impl Repository { .load_change(&writer_hash) .map_err(|e| format!("cannot load concurrent writer: {e}"))?; if !change_wrote_branch_vertex_for_branch( - &writer, path, *branch_id, *row_node, + &writer, trunk_key, *branch_id, *row_node, ) { return Err(format!( "branch {branch_id:?} is bound to {row_node:?} but the \ @@ -4557,8 +4825,9 @@ impl Repository { let writer = self .load_change(&writer_hash) .map_err(|e| format!("cannot load concurrent writer: {e}"))?; - if !change_wrote_branch_vertex_for_branch(&writer, path, *branch_id, *row_node) - { + if !change_wrote_branch_vertex_for_branch( + &writer, trunk_key, *branch_id, *row_node, + ) { return Err(format!( "branch {branch_id:?} was deleted by the closure but the staged \ row is alive bound to {row_node:?}; no applied out-of-closure \ @@ -4654,7 +4923,7 @@ impl Repository { let writer = self .load_change(&writer_hash) .map_err(|e| format!("cannot load leaf writer: {e}"))?; - change_wrote_leaf(&writer, path, *branch_id, leaf_id) + change_wrote_leaf(&writer, trunk_key, *branch_id, leaf_id) }; if !attributed { return Err(format!( @@ -4686,20 +4955,34 @@ impl Repository { } } - // The closure's semantic replay must regenerate the commit tree: - // its alive lines aggregate - joined in the staged after-chain's - // file order - to exactly the expected bytes. The CONTENT comes - // from the closure replay (each line's own writer blob slice); the - // ORDER comes from the staged chain, so a corrupted after-chain - // reorders or drops lines and fails closed here (review B2). + // Reconstruct the selected closure with its own recorded order. + // Ambient AFTER rows are audited above, including exact attribution + // for genuine out-of-closure reparenting; they cannot order this view. + let closure_chain = super::record::semantic_branch_order( + &branches + .iter() + .map(|(id, branch)| (*id, branch.after)) + .collect(), + )?; + let replay_alive_bytes: Vec = closure_chain + .iter() + .filter_map(|id| branches.get(id)) + .filter(|branch| branch.state.is_alive()) + .flat_map(|branch| branch.bytes.iter().copied()) + .collect(); let actual_chain = iter_trunk_branches_in_file_order(txn, trunk_key).map_err(|e| e.to_string())?; - let mut replay_alive_bytes: Vec = Vec::new(); let mut visited: std::collections::HashSet = std::collections::HashSet::new(); + let debug_semantic = std::env::var_os("ATOMIC_DEBUG_DELETION").is_some(); for branch_id in &actual_chain { visited.insert(*branch_id); - let Some(expected) = branches.get(branch_id) else { + let Some(_expected) = branches.get(branch_id) else { + if debug_semantic { + eprintln!( + "[debug-deletion] chain branch {branch_id:?} UNEXPECTED (no closure entry)" + ); + } // Review D2: a staged after-chain branch the closure never // wrote is NOT silently skipped. The historical skip let an // unexpected live branch with no vertex into the chain (the @@ -4717,17 +5000,17 @@ impl Repository { path, trunk_key, *branch_id, - visibility, - &global_alive, + SemanticBranchContext { + visibility, + global_alive: &global_alive, + deletion_evidence: &deletion_evidence, + }, )? { consumer_domain.insert(*branch_id, ConsumerLine::Writer(writer_vertex)); concurrent_state = true; } continue; }; - if expected.state.is_alive() { - replay_alive_bytes.extend_from_slice(&expected.bytes); - } } if replay_alive_bytes.as_slice() != expected_bytes { return Err(format!( @@ -4819,13 +5102,15 @@ impl Repository { path: &str, trunk_key: atomic_core::crdt::TrunkId, branch: atomic_core::crdt::BranchId, - visibility: &atomic_core::pristine::GraphVisibilityClosure, - global_alive: &std::collections::HashSet< - atomic_core::types::GraphNode, - >, + context: SemanticBranchContext<'_>, ) -> Result>, String> { use atomic_core::crdt::tables::encode_branch_id; use atomic_core::pristine::{CrdtTxnT, GraphTxnT}; + let SemanticBranchContext { + visibility, + global_alive, + deletion_evidence, + } = context; let branch_key = encode_branch_id(&branch); let row = txn .get_crdt_branch(&branch_key) @@ -4859,13 +5144,21 @@ impl Repository { .map_err(|e| e.to_string())? .as_ref() { - Some(node) => { - graph_proves_dead_with_ownership(self, txn, path, branch, node, global_alive)? - } + Some(node) => graph_proves_dead_with_ownership( + self, + txn, + trunk_key, + branch, + node, + global_alive, + )?, None => false, - } || out_of_closure_writer_deleted_branch( - &loader, txn, path, branch, visibility, - )? || applied_writer_deleted_branch(&loader, txn, path, branch)?; + } || deletion_evidence + .get_or_init(|| SemanticDeletionEvidence::load(&loader, txn, trunk_key, visibility)) + .as_ref() + .map_err(Clone::clone)? + .applied + .contains(&branch); if !attributed { return Err( "the staged file-order chain contains a branch the closure never wrote \ @@ -4912,7 +5205,7 @@ impl Repository { // FileOps must record THIS branch id bound to THIS vertex for this // path; a different file's vertex at a matching range is not this // branch's content. - if !change_wrote_branch_vertex_for_branch(&writer, path, branch, row_node) { + if !change_wrote_branch_vertex_for_branch(&writer, trunk_key, branch, row_node) { return Err(format!( "branch {branch:?} is bound to {row_node:?} but no applied out-of-closure \ change recorded that binding for '{path}'; the binding is corruption" diff --git a/atomic-repository/src/repository/tests/merge_property_tests.rs b/atomic-repository/src/repository/tests/merge_property_tests.rs index e54ae88b..c7572255 100644 --- a/atomic-repository/src/repository/tests/merge_property_tests.rs +++ b/atomic-repository/src/repository/tests/merge_property_tests.rs @@ -184,6 +184,15 @@ fn build_commuting( disk_bytes(&temp, "f.txt") } +#[test] +fn adjacent_replacements_preserve_base_line_order() { + let base = vec!["alpha".into(), "bravo".into(), "charlie".into()]; + for order_ab in [true, false] { + let actual = build_commuting(&base, (0, "ALPHA".into()), (1, "BRAVO".into()), order_ab); + assert_eq!(String::from_utf8_lossy(&actual), "ALPHA\nBRAVO\ncharlie\n"); + } +} + /// A disjoint edit for the widened commutation property: boundary /// insertions as well as interior replacements. #[derive(Clone, Debug)] @@ -259,6 +268,22 @@ fn build_commuting_eds(base: &[String], a: &Ed, b: &Ed, order_ab: bool) -> Vec Result { + if *candidate != hash { + return Err("not this writer".into()); + } + calls.set(calls.get() + 1); + Ok(change.clone()) + }; + let mut txn = repo.pristine().write_txn().unwrap(); + let id = txn.register_change(&hash).unwrap(); + txn.put_change_deps(id, &[]).unwrap(); + txn.mark_change_applied(id).unwrap(); + let empty = GraphVisibilityClosure::empty(); + let evidence = SemanticDeletionEvidence::load(&loader, &txn, trunk, &empty).unwrap(); + for index in 0..80 { + let branch = BranchId::new(id, index); + assert!(evidence.applied.contains(&branch)); + assert!(evidence.outside.contains(&branch)); + } + assert_eq!(calls.get(), 1, "one canonical load covers all 80 lines"); + let visible = GraphVisibilityClosure::try_from_membership( + &txn, + &ViewMembershipSet::from_ordered(vec![id]), + ) + .unwrap(); + let in_closure = SemanticDeletionEvidence::load(&loader, &txn, trunk, &visible).unwrap(); + assert_eq!(in_closure.applied.len(), 80); + assert!(in_closure.outside.is_empty()); + let other = + SemanticDeletionEvidence::load(&loader, &txn, TrunkId::new(NodeId::new(8), 0), &empty) + .unwrap(); + assert!( + other.applied.is_empty(), + "same spelling cannot borrow another trunk's proof" + ); } diff --git a/atomic-repository/src/tracking/helpers.rs b/atomic-repository/src/tracking/helpers.rs index 0e86dfe8..052c471a 100644 --- a/atomic-repository/src/tracking/helpers.rs +++ b/atomic-repository/src/tracking/helpers.rs @@ -53,8 +53,12 @@ pub fn normalize_path_with_root(path: &Path, repo_root: Option<&Path>) -> String // that Unix-style paths work correctly on Windows in tests and // cross-platform scenarios. let root_str = root.to_string_lossy(); + #[cfg(windows)] let root_str = root_str.replace('\\', "/"); + #[cfg(windows)] let path_str = path_str_raw.replace('\\', "/"); + #[cfg(not(windows))] + let path_str = path_str_raw.clone(); let root_with_sep = if root_str.ends_with('/') { root_str.to_string() } else { @@ -71,11 +75,10 @@ pub fn normalize_path_with_root(path: &Path, repo_root: Option<&Path>) -> String let path_str = path_to_normalize.to_string_lossy(); - // Convert to forward slashes and remove trailing slash - let normalized = path_str - .replace('\\', "/") - .trim_end_matches('/') - .to_string(); + // Backslash is a filename byte on Unix, and a separator on Windows. + #[cfg(windows)] + let path_str = path_str.replace('\\', "/"); + let normalized = path_str.trim_end_matches('/').to_string(); // Handle empty path (current directory) if normalized.is_empty() { diff --git a/atomic-repository/src/tracking/tests.rs b/atomic-repository/src/tracking/tests.rs index c832f070..5e760f53 100644 --- a/atomic-repository/src/tracking/tests.rs +++ b/atomic-repository/src/tracking/tests.rs @@ -14,7 +14,10 @@ fn test_normalize_path_trailing_slash() { #[test] fn test_normalize_path_backslashes() { + #[cfg(windows)] assert_eq!(normalize_path(Path::new("src\\main.rs")), "src/main.rs"); + #[cfg(unix)] + assert_eq!(normalize_path(Path::new("src\\main.rs")), "src\\main.rs"); } #[test] diff --git a/atomic-repository/src/tracking/tree_ops.rs b/atomic-repository/src/tracking/tree_ops.rs index 9d9faee6..5b06a9f8 100644 --- a/atomic-repository/src/tracking/tree_ops.rs +++ b/atomic-repository/src/tracking/tree_ops.rs @@ -119,9 +119,9 @@ impl TreeProjectionPlan { let mut affected_parents = HashSet::::new(); for (inode, entry) in &self.entries { - let Some(desired) = &entry.path else { + if entry.path.is_none() { continue; - }; + } if let Some(current) = txn .get_path(*inode) .map_err(|error| TreeProjectionError::Database(error.to_string()))? @@ -133,6 +133,13 @@ impl TreeProjectionPlan { final_paths.remove(¤t); } } + } + // Remove every previous binding before installing destinations. This + // must not depend on HashMap iteration order for overlapping moves. + for (inode, entry) in &self.entries { + let Some(desired) = &entry.path else { + continue; + }; if let Some(path) = desired { if let Some(parent) = direct_parent(path) { affected_parents.insert(parent.to_string()); @@ -150,15 +157,19 @@ impl TreeProjectionPlan { || txn .is_directory(inode) .map_err(|error| TreeProjectionError::Database(error.to_string()))?; + let empty = !final_paths + .keys() + .any(|path| direct_parent(path) == Some(parent.as_str())); + if !is_directory && empty { + // An old parent can become a regular file after its last child moves. + continue; + } if !is_directory { return Err(TreeProjectionError::IncompleteMetadata(format!( "projected parent '{}' is not a directory", parent ))); } - let empty = !final_paths - .keys() - .any(|path| direct_parent(path) == Some(parent.as_str())); let entry = self.entries.entry(inode).or_default(); entry.kind = Some(TreeProjectionKind::Directory); entry.directory_flags = Some(if empty { diff --git a/atomic-repository/tests/content_roundtrip_fidelity_test.rs b/atomic-repository/tests/content_roundtrip_fidelity_test.rs index 4a947b34..2e35fad3 100644 --- a/atomic-repository/tests/content_roundtrip_fidelity_test.rs +++ b/atomic-repository/tests/content_roundtrip_fidelity_test.rs @@ -1939,3 +1939,37 @@ fn test_crdt_walker_hyperfine_sequence_offline_fixture() { ); } } + +/// A later edit deletes an old line and reinserts its bytes after an existing +/// insertion. The reader must not disconnect the surviving inserted chain. +#[test] +fn test_bypass_relocated_line_roundtrip() { + let (repo, _temp, path) = create_test_repo(); + let versions = [ + "head\nanchor\ntail\n", + "head\nanchor\nlibc\ntail\n", + "head\nanchor\nlibc\natty\ntail\n", + "head\nanchor\natty\ntarget\nlibc\ntail\n", + ]; + let mut hashes = Vec::new(); + for (index, content) in versions.iter().enumerate() { + write_file(&path, "file.txt", content); + if index == 0 { + repo.add(working_copy(&repo), "file.txt", Default::default()) + .unwrap(); + } + hashes.push(record_change(&repo, &format!("state {index}"))); + assert_content_matches(&repo, "file.txt", content); + } + drop(repo); + let repo = Repository::open(&path).unwrap(); + assert_content_matches(&repo, "file.txt", versions[3]); + for (hash, content) in hashes.iter().zip(versions) { + assert_content_after_change(&repo, "file.txt", hash, content); + } + repo.materialize(working_copy(&repo)).unwrap(); + assert_eq!( + fs::read_to_string(path.join("file.txt")).unwrap(), + versions[3] + ); +} diff --git a/atomic-repository/tests/whole_file_diff_test.rs b/atomic-repository/tests/whole_file_diff_test.rs new file mode 100644 index 00000000..29b091a9 --- /dev/null +++ b/atomic-repository/tests/whole_file_diff_test.rs @@ -0,0 +1,173 @@ +//! Whole-file storage rewrites must still expose the actual review delta. +use atomic_core::change::{Change, ChangeHeader}; +use atomic_core::diff::display::LineStatus; +use atomic_core::types::Hash; +use atomic_repository::diff_engine::{ + change_file_diffs, whole_file_replacement_text, DiffFormat, DiffOutputConfig, FileChangeStatus, +}; +use atomic_repository::{RecordOptions, Repository}; +use tempfile::TempDir; + +fn record(repo: &Repository) -> Hash { + *repo + .record( + repo.require_working_copy_id().unwrap(), + ChangeHeader::builder().message("test").build(), + RecordOptions::default(), + ) + .unwrap() + .hash() +} + +fn replacement(before: &[u8], after: &[u8]) -> (TempDir, Repository, Hash, Change) { + let dir = TempDir::new().unwrap(); + let repo = Repository::init(dir.path()).unwrap(); + std::fs::write(dir.path().join("file.txt"), before).unwrap(); + repo.add( + repo.require_working_copy_id().unwrap(), + "file.txt", + Default::default(), + ) + .unwrap(); + record(&repo); + std::fs::write(dir.path().join("file.txt"), after).unwrap(); + let hash = record(&repo); + let change = repo.change_store().load_change(&hash).unwrap(); + assert_eq!( + whole_file_replacement_text(&change.file_ops()[0], &change.contents, || Some( + before.to_vec() + )), + Some((before.to_vec(), after.to_vec())), + "fixture must exercise a whole-file rewrite" + ); + (dir, repo, hash, change) +} + +#[test] +fn replacement_diff_uses_historical_text_for_all_output_formats() { + let before = b"keep\nconst A = 1;\nconst B = 2;\ntail\n"; + let after = b"keep\nconst A = 3;\nconst B = 4;\ntail\n"; + let (dir, repo, hash, change) = replacement(before, after); + // Later edits and reopening must not substitute today's text for this diff. + std::fs::write(dir.path().join("file.txt"), b"later\n").unwrap(); + record(&repo); + drop(repo); + let repo = Repository::open(dir.path()).unwrap(); + for format in [ + DiffFormat::Unified, + DiffFormat::Stat, + DiffFormat::NameStatus, + ] { + for context in [0, 3] { + let config = DiffOutputConfig::new() + .with_format(format) + .with_context(context); + let (files, _) = change_file_diffs(&repo, &change, &hash, &config).unwrap(); + assert_eq!(files.len(), 1); + assert_eq!(files[0].status, FileChangeStatus::Modified); + assert_eq!(files[0].stats.insertions, 2); + assert_eq!(files[0].stats.deletions, 2); + let changes: Vec<_> = files[0] + .hunks + .iter() + .flat_map(|h| &h.lines) + .filter(|l| l.status != LineStatus::Unchanged) + .map(|l| (l.status, l.content.trim_end().to_owned())) + .collect(); + assert_eq!( + changes, + vec![ + (LineStatus::Removed, "const A = 1;".into()), + (LineStatus::Added, "const A = 3;".into()), + (LineStatus::Removed, "const B = 2;".into()), + (LineStatus::Added, "const B = 4;".into()), + ] + ); + } + } +} + +#[test] +fn replacement_deletion_does_not_show_survivors_as_added() { + let (_dir, repo, hash, change) = + replacement(b"keep\nold one\nold two\ntail\n", b"keep\ntail\n"); + let (files, _) = change_file_diffs(&repo, &change, &hash, &DiffOutputConfig::new()).unwrap(); + assert_eq!(files[0].stats.insertions, 0); + assert_eq!(files[0].stats.deletions, 2); + let removed: Vec<_> = files[0] + .hunks + .iter() + .flat_map(|h| &h.lines) + .filter(|l| l.status == LineStatus::Removed) + .map(|l| l.content.trim_end()) + .collect(); + assert_eq!(removed, ["old one", "old two"]); +} + +#[test] +fn replacement_truncation_keeps_modified_file_status() { + let (_dir, repo, hash, change) = replacement(b"one\ntwo\n", b""); + let (files, _) = change_file_diffs( + &repo, + &change, + &hash, + &DiffOutputConfig::new().with_context(0), + ) + .unwrap(); + assert_eq!(files[0].status, FileChangeStatus::Modified); + assert_eq!(files[0].stats.insertions, 0); + assert_eq!(files[0].stats.deletions, 2); + assert_eq!(repo.get_file_content("file.txt").unwrap(), Some(Vec::new())); +} + +#[test] +fn replacement_diff_preserves_blank_lines_and_unterminated_last_line() { + let (_dir, repo, hash, change) = replacement(b"head\n\nold\n", b"head\n\nnew"); + let (files, _) = change_file_diffs(&repo, &change, &hash, &DiffOutputConfig::new()).unwrap(); + assert_eq!(files[0].stats.insertions, 1); + assert_eq!(files[0].stats.deletions, 1); + assert_eq!( + repo.get_file_content("file.txt").unwrap(), + Some(b"head\n\nnew".to_vec()) + ); +} + +#[test] +fn deleting_one_blank_line_is_not_a_whole_file_rewrite() { + let dir = TempDir::new().unwrap(); + let repo = Repository::init(dir.path()).unwrap(); + let before = b"\nkeep\ntail\n"; + std::fs::write(dir.path().join("file.txt"), before).unwrap(); + repo.add( + repo.require_working_copy_id().unwrap(), + "file.txt", + Default::default(), + ) + .unwrap(); + record(&repo); + std::fs::write(dir.path().join("file.txt"), b"keep\ntail\n").unwrap(); + let hash = record(&repo); + let change = repo.change_store().load_change(&hash).unwrap(); + assert!( + whole_file_replacement_text(&change.file_ops()[0], &change.contents, || Some( + before.to_vec() + )) + .is_none() + ); + let (files, _) = change_file_diffs(&repo, &change, &hash, &DiffOutputConfig::new()).unwrap(); + assert_eq!(files[0].stats.insertions, 0); + assert_eq!(files[0].stats.deletions, 1); +} + +#[test] +fn replacement_byte_ranges_preserve_crlf() { + let after = b"head\r\n\r\nnew one\r\nnew two\r\n"; + let (_dir, repo, hash, change) = replacement(b"head\r\n\r\nold one\r\nold two\r\n", after); + let (files, _) = change_file_diffs(&repo, &change, &hash, &DiffOutputConfig::new()).unwrap(); + assert_eq!(files[0].stats.insertions, 2); + assert_eq!(files[0].stats.deletions, 2); + assert_eq!( + repo.get_file_content("file.txt").unwrap(), + Some(after.to_vec()) + ); +}