From dc57ec3db4f66bbcbc8db6f8207754a940ab17cf Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Tue, 13 Oct 2026 15:09:24 -0500 Subject: [PATCH 01/19] fix(record): route generated-file modifies and multi-line deletions through the sentinel whole-file replace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corruption classes in `record_modified_file` surfaced through `atomic git import` of a real repository (atomicdotdev/atomic, 344 commits), which failed staged synthesis verification at commit 5: 1. Generated-file modify on opaque-shape state (`::26 R1` second follow-up). The opaque fast path ADDS a lockfile as one whole-content branch; a later MODIFY fell through the fast-path guard (existing branches non-empty) into a per-line diff whose line numbers have no vertices. Cargo.lock rendered 3,050 of 89,750 bytes. Route the opaque-shape modify through the sentinel replace. 2. Multi-line contiguous deletion after an insertion commit (third follow-up). Deleting a contiguous multi-line region from a file whose vertices span an earlier insertion commit leaves the additive graph in a state the alive walk classifies as a cyclic conflict: the render emits conflict markers and keeps the deleted lines. Reproduced NATIVELY — `atomic record` of the deletion commit made `atomic status` report the file modified forever (silent corruption on every multi-line deletion after an insertion). Single-line deletions are unaffected. Both classes now route through the sentinel whole-file replace — the same fail-safe shape already used for binary content and explicit forces: every alive vertex is deleted, the full content is inserted fresh, and the CRDT ops tombstone bound branches, so the render is byte-exact regardless of diamond structure. Import of atomicdotdev/atomic advances from 4 landed commits to 18 (the next failure is a distinct merge-followup render regression, filed separately). Regression tests replay the real content states from that repository, including the exact helpers.rs transition that failed verification. --- atomic-cli/tests/fixtures/helpers_c1.rs | 557 ++++++++++++ atomic-cli/tests/fixtures/helpers_c5.rs | 796 ++++++++++++++++++ atomic-cli/tests/fixtures/helpers_c6.rs | 726 ++++++++++++++++ .../tests/git_import_generated_modify_test.rs | 186 ++++ atomic-core/src/record/workflow/record/mod.rs | 48 +- 5 files changed, 2309 insertions(+), 4 deletions(-) create mode 100644 atomic-cli/tests/fixtures/helpers_c1.rs create mode 100644 atomic-cli/tests/fixtures/helpers_c5.rs create mode 100644 atomic-cli/tests/fixtures/helpers_c6.rs create mode 100644 atomic-cli/tests/git_import_generated_modify_test.rs 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/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-core/src/record/workflow/record/mod.rs b/atomic-core/src/record/workflow/record/mod.rs index 14c74f76..43021f61 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; @@ -537,6 +538,48 @@ 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, + }); + 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; + // 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 +594,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 +629,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 From ac2a8aff995b4297f32cdc6640b4d3771f2e6a4e Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Tue, 13 Oct 2026 15:49:01 -0500 Subject: [PATCH 02/19] fix(globalize): fail closed on line/vertex divergence in insert anchoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit classify_insert maps diff line numbers positionally onto the alive content vertices. That mapping is only provable when the graph holds exactly one vertex per old-file line. Merge diamonds leave extra alive vertices in an isolated leg closure — an insertion commit on a file whose history spans a merge anchored at silent wrong vertices and the staged render lost the inserted content (b110aa70 in atomicdotdev/atomic: rendered 68,762 of 74,206 bytes; the same content sequence recorded natively is clean, so the divergence is specific to leg assembly). The guard refuses the positional anchor with a named divergence error (1672 alive content vertices for 1793 old lines) instead of silently misrendering. Escalating to the whole-file replace was tried and is NOT safe inside a leg assembly: its unfiltered delete_all_content walk spans lineages the leg must not touch (the render tripled). The RFC-conformant complete fix — leg anchoring resolved strictly inside the leg's interpreted parent closure — is scoped in PR discussion. --- .../src/record/workflow/globalize/error.rs | 19 +++++++++ .../src/record/workflow/globalize/hunk.rs | 40 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/atomic-core/src/record/workflow/globalize/error.rs b/atomic-core/src/record/workflow/globalize/error.rs index 08d6f42d..82972b23 100644 --- a/atomic-core/src/record/workflow/globalize/error.rs +++ b/atomic-core/src/record/workflow/globalize/error.rs @@ -85,6 +85,25 @@ pub enum GlobalizeError { line: u64, }, + /// The alive graph holds a different number of content vertices than the + /// diff's old-file line model expects. + /// + /// Merge diamonds leave extra alive vertices in an isolated closure, so + /// the positional line→vertex mapping cannot be proven. Callers must + /// escalate to the whole-file replace instead of anchoring at unproven + /// vertices. + #[error( + "Vertex/line divergence for {path}: {vertices} alive content vertices for {expected} old lines" + )] + LineVertexDivergence { + /// The file path + path: String, + /// Alive content vertices found + vertices: usize, + /// Old-file lines the diff was computed against + expected: usize, + }, + /// The file has no content to globalize. /// /// This is not necessarily an error - empty files may be intentional. diff --git a/atomic-core/src/record/workflow/globalize/hunk.rs b/atomic-core/src/record/workflow/globalize/hunk.rs index 86630ac0..341d8e4a 100644 --- a/atomic-core/src/record/workflow/globalize/hunk.rs +++ b/atomic-core/src/record/workflow/globalize/hunk.rs @@ -141,6 +141,26 @@ where encoding, ) { Ok(ops) => Ok(ops), + Err(GlobalizeError::LineVertexDivergence { + path, + vertices, + expected, + }) => { + // The alive graph's vertex count diverges from the diff's + // line model — merge diamonds leave extra alive vertices + // in an isolated leg closure, so positional anchoring is + // unprovable. Fail closed: escalating to the whole-file + // replace is NOT safe inside a leg assembly (its + // delete_all_content walk spans lineages the leg must + // not touch), and silently anchoring at unproven + // vertices loses content. The import refuses this + // commit naming the divergence. + Err(GlobalizeError::LineVertexDivergence { + path, + vertices, + expected, + }) + } Err(e) => { log::debug!( "globalize_hunk: insert failed ({:?}), falling back to replace", @@ -192,6 +212,26 @@ where T: GraphTxnT + TreeTxnT + InodeGraphOps, { let vertices = collect_sorted_content_vertices_cached(ctx, inode, inode_pos)?; + + // ::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. Merge diamonds + // leave extra alive vertices in the isolated assembly closure — an + // insertion commit on a file whose history spans a merge anchored at + // silent wrong vertices and the staged render lost the inserted content + // (b110aa70 in atomicdotdev/atomic: rendered 68,762 of 74,206 bytes). + // When the counts diverge, refuse the positional anchor; the caller + // escalates to the whole-file replace. + if let Some(expected) = old_line_count { + if vertices.len() != expected { + return Err(GlobalizeError::LineVertexDivergence { + path: local.path.clone(), + vertices: vertices.len(), + expected, + }); + } + } + let position = classify_insert(&vertices, inode_pos, built.old_start, old_line_count)?; let (predecessors, successors) = match position { From 3d6db78fa630045a8e6d0c2e1b3c0be44bf749f0 Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Tue, 13 Oct 2026 16:14:37 -0500 Subject: [PATCH 03/19] fix(globalize): escalate line/vertex divergence to the sentinel whole-file replace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A divergence between the alive vertex count and the diff's old-line count (merge diamonds add vertices; empty-line accounting can shift it by one — observed 347 vertices for 346 lines) is detected and now escalates to the sentinel whole-file replace instead of failing the commit. The escalation renders byte-exact at the graph layer and stays fail-closed: the staged synthesis verification still refuses the commit when any layer disagrees. On atomicdotdev/atomic this advances the fresh-clone import past the packages/opencode-atomic-hooks/README.md commit. The same commit then fails one layer deeper — the staged semantic (CRDT) closure replay renders the right byte total but not the expected content/order (15,636 == 15,636, refused) — the merge-diamond divergence surfacing in the semantic layer, to be resolved with the leg-anchoring redesign. --- .../src/record/workflow/globalize/hunk.rs | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/atomic-core/src/record/workflow/globalize/hunk.rs b/atomic-core/src/record/workflow/globalize/hunk.rs index 341d8e4a..e65b43e8 100644 --- a/atomic-core/src/record/workflow/globalize/hunk.rs +++ b/atomic-core/src/record/workflow/globalize/hunk.rs @@ -147,19 +147,24 @@ where expected, }) => { // The alive graph's vertex count diverges from the diff's - // line model — merge diamonds leave extra alive vertices - // in an isolated leg closure, so positional anchoring is - // unprovable. Fail closed: escalating to the whole-file - // replace is NOT safe inside a leg assembly (its - // delete_all_content walk spans lineages the leg must - // not touch), and silently anchoring at unproven - // vertices loses content. The import refuses this - // commit naming the divergence. - Err(GlobalizeError::LineVertexDivergence { - path, - vertices, - expected, - }) + // line model (merge diamonds add vertices; empty-line + // accounting can shift it by one). Escalate to the + // whole-file replace — every alive vertex is deleted and + // the full content inserted fresh per line, which renders + // byte-exact regardless of the diamond structure. This + // stays fail-closed: the staged synthesis verification + // checks the render against the commit tree and refuses + // the commit when it disagrees, so a wrong escalation is + // a refusal, never silent corruption. + let _ = (path, vertices, expected); + globalize_replace_whole_file( + ctx, + inode, + inode_pos, + full_content, + local, + encoding, + ) } Err(e) => { log::debug!( From a608047542e93153de5e14ef18e55ad6a6649c93 Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Tue, 13 Oct 2026 23:01:16 -0500 Subject: [PATCH 04/19] fix(import): restore the linearized first-parent walk for plain imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CB-9B made every `atomic git import` walk the complete reachable closure (all side-branch commits) and assemble multi-parent merges as `GitResolution` unions with leg isolation. That major change broke plain imports on real histories: concurrent side-branch commits assemble against a view whose files have already advanced on the mainline, so leg anchors bind to diverged graph structures and the staged verification refuses — the import of atomicdotdev/atomic stopped at 15-18 of 344 commits with three distinct corruption/refusal classes. The original design document (docs/git-import-design.md) specifies the linearized behavior: merge commits are handled as ordinary first-parent diffs ("merge commits linearized"), which keeps each file's graph a linear per-line chain that every layer — positional anchoring, the sentinel whole-file replace, the semantic closure replay — proves correctly. Plain import (`mainline_only`, i.e. everything except `--all`) now walks the first-parent chain again, stopping at the imported frontier for incremental runs, and routes multi-parent merges through normal recorded-file synthesis with a FirstParent derivation. The CB-9B complete-closure walk and the GitResolution path remain for `--all` and the bridge flows, where the RFC §5.3 union semantics are actually required. The three earlier fixes stay: generated-file modifies, multi-line deletions, and the line/vertex divergence escalation all still route through the sentinel whole-file replace and are verified byte-exact. --- atomic-cli/src/commands/git/parallel.rs | 56 ++++++++++++------- .../src/record/workflow/record/crdt.rs | 15 ++++- atomic-repository/src/repository/record.rs | 41 ++++++++------ atomic-repository/src/repository/synthesis.rs | 43 ++++++++++++++ 4 files changed, 115 insertions(+), 40 deletions(-) diff --git a/atomic-cli/src/commands/git/parallel.rs b/atomic-cli/src/commands/git/parallel.rs index 6042224f..2fbd7550 100644 --- a/atomic-cli/src/commands/git/parallel.rs +++ b/atomic-cli/src/commands/git/parallel.rs @@ -4647,36 +4647,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), })?; @@ -5160,8 +5172,10 @@ impl ParallelImporter { // 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 +5185,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 +5197,7 @@ impl ParallelImporter { touched_only, )? } else { - self.write_empty_commit_synthesized( + self.write_commit_synthesized( repo, git, ledger, @@ -5402,7 +5416,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", diff --git a/atomic-core/src/record/workflow/record/crdt.rs b/atomic-core/src/record/workflow/record/crdt.rs index f3962454..01e0925b 100644 --- a/atomic-core/src/record/workflow/record/crdt.rs +++ b/atomic-core/src/record/workflow/record/crdt.rs @@ -699,9 +699,18 @@ 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. - let mut prev_branch: Option = None; + // 2. Insert every new line fresh, chained after the LAST tombstoned + // branch (not after the trunk head). The old branches remain in the + // trunk's file-order chain as tombstones, so the fresh lines must + // continue the chain after them — the real apply and the closure + // replay then derive the same file order. Wiring the first fresh + // line with `after: None` starts a second chain, and the two + // interpreters walk a two-chain trunk in different orders: the graph + // render stays byte-exact while the semantic closure replay rotates + // the file order and refuses the commit (equal byte totals, rotated + // content — observed on atomicdotdev/atomic README.md). + let mut prev_branch: Option = + existing_branches.and_then(|branches| branches.last().copied()); for (new_line_idx, line) in new_lines.iter().enumerate() { let branch_id = alloc_branch(); let mut prev_leaf: Option = None; diff --git a/atomic-repository/src/repository/record.rs b/atomic-repository/src/repository/record.rs index a314c2eb..e06b783a 100644 --- a/atomic-repository/src/repository/record.rs +++ b/atomic-repository/src/repository/record.rs @@ -595,30 +595,39 @@ impl Repository { .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()); + // A merge-era path can hold TWO trunk incarnations: the path-indexed + // row (possibly a stale pre-merge incarnation) and the inode-indexed + // trunk of the graph's current incarnation. Enumerate the alive + // branches of BOTH — tombstoning only one trunk's branches leaves the + // other trunk's stale lines alive in the semantic chain, and the + // staged semantic closure then refuses the change (equal byte totals, + // interleaved stale content). + let mut trunk_ids: Vec = Vec::new(); + if let Ok(Some(trunk_key)) = txn.get_trunk_by_path(path) { + trunk_ids.push(trunk_key); + } + if let Ok(Some(trunk_key)) = txn.get_crdt_inode_trunk(inode_u64) { + let decoded = decode_trunk_id(&trunk_key); + if !trunk_ids.contains(&decoded) { + trunk_ids.push(decoded); + } + } + let existing_trunk_id = trunk_ids.first().copied(); + let existing_branches: Vec = { + let mut alive: Vec = Vec::new(); + for trunk_id in &trunk_ids { + if let Ok(all) = iter_trunk_branches_in_file_order(&txn, *trunk_id) { 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() { + if bd.state.is_alive() && !alive.contains(&b) { alive.push(b); } } } - alive } - Err(_) => Vec::new(), - }, - None => Vec::new(), + } + alive }; Ok((existing_trunk_id, existing_branches)) } diff --git a/atomic-repository/src/repository/synthesis.rs b/atomic-repository/src/repository/synthesis.rs index 559ae80c..d516b04a 100644 --- a/atomic-repository/src/repository/synthesis.rs +++ b/atomic-repository/src/repository/synthesis.rs @@ -4697,9 +4697,15 @@ impl Repository { 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 { + 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 @@ -4726,10 +4732,47 @@ impl Repository { continue; }; if expected.state.is_alive() { + if debug_semantic + && replay_alive_bytes.len() <= 1254 + && replay_alive_bytes.len() + expected.bytes.len() > 1200 + { + eprintln!( + "[debug-deletion] chain branch {branch_id:?} alive bytes={} crossing offset {} preview={:?}", + expected.bytes.len(), + replay_alive_bytes.len(), + String::from_utf8_lossy(&expected.bytes[..expected.bytes.len().min(60)]) + ); + } replay_alive_bytes.extend_from_slice(&expected.bytes); } } if replay_alive_bytes.as_slice() != expected_bytes { + if std::env::var_os("ATOMIC_DEBUG_DELETION").is_some() { + let first_diff = replay_alive_bytes + .iter() + .zip(expected_bytes.iter()) + .position(|(a, b)| a != b) + .unwrap_or_else(|| replay_alive_bytes.len().min(expected_bytes.len())); + eprintln!( + "[debug-deletion] semantic MISMATCH path={path} replay={} expected={} first_diff_at={first_diff} chain_len={}", + replay_alive_bytes.len(), + expected_bytes.len(), + actual_chain.len() + ); + eprintln!( + "[debug-deletion] replay[..first_diff+64]={:?}", + String::from_utf8_lossy( + &replay_alive_bytes + [first_diff..(first_diff + 64).min(replay_alive_bytes.len())] + ) + ); + eprintln!( + "[debug-deletion] expected[..first_diff+64]={:?}", + String::from_utf8_lossy( + &expected_bytes[first_diff..(first_diff + 64).min(expected_bytes.len())] + ) + ); + } return Err(format!( "the closure's semantic operations render {} byte(s) for the alive lines but \ the commit tree expects {} byte(s); the semantic closure must regenerate \ From f1a3987c210cdbedb2a35e0cc0d29e1fe0471f80 Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Tue, 13 Oct 2026 23:36:45 -0500 Subject: [PATCH 05/19] fix(record): sentinel-route records whose semantic branch count diverges from the diff's line model; trace the per-line vertex split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README.md blocker isolation (packages/opencode-atomic-hooks/ README.md, 346 lines, no trailing newline: 347 alive content vertices after its add) pointed at the record/assembly boundary. Two additions: - record_modified_file detects when the existing semantic branch count diverges from the diff's old-file line count and sentinel-routes the modify at record time, so the graph hunks and the CRDT FileOps are decided together (the previous globalize-time escalation left the change carrying positional semantic ops under whole-file graph ops). - create_content_vertices_per_line traces its produced vertex count under ATOMIC_TRACE_HYPER (symmetric with the sorted-vertices trace), which is where the phantom 347th vertex for the 346-line file will surface. Remaining blocker (unchanged): the staged semantic closure replay's chain order for tombstone+fresh-insert chains — the rotation is documented in the PR thread with reproduction fixtures. --- .../src/record/workflow/globalize/vertex.rs | 7 +++++ atomic-core/src/record/workflow/record/mod.rs | 15 ++++++++++- atomic-repository/src/repository/synthesis.rs | 26 ------------------- 3 files changed, 21 insertions(+), 27 deletions(-) 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/mod.rs b/atomic-core/src/record/workflow/record/mod.rs index 43021f61..88cb0d0e 100644 --- a/atomic-core/src/record/workflow/record/mod.rs +++ b/atomic-core/src/record/workflow/record/mod.rs @@ -575,10 +575,23 @@ where DiffOp::Replace { old_len, .. } => *old_len > 1, _ => false, }); + // ::26 R1 fifth follow-up (2026-10-13): the semantic branch count must + // match the diff's old-file line model — the recorder creates one branch + // per line, and every positional hunk below indexes that 1:1 domain. A + // divergence (the add-time tokenizer created 347 branches for a + // 346-line file without a trailing newline) means the diff's line + // numbers do not address the live branch domain: the graph and the + // semantic layer would silently disagree. Detect it at record time — + // where both counts are available — and route through the sentinel + // whole-file replace, which tombstones every prior branch and rebuilds + // the semantic chain fresh so both layers agree again. + let branch_line_divergence = existing_branches + .is_some_and(|branches| branches.len() != Line::from_bytes(old_content).len()); 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; + || has_multi_line_deletion + || branch_line_divergence; // Whole-file-replace safety fallback: if the caller's `old_content` came // from resolving a fork or cyclic conflict (see diff --git a/atomic-repository/src/repository/synthesis.rs b/atomic-repository/src/repository/synthesis.rs index d516b04a..4dab2329 100644 --- a/atomic-repository/src/repository/synthesis.rs +++ b/atomic-repository/src/repository/synthesis.rs @@ -4747,32 +4747,6 @@ impl Repository { } } if replay_alive_bytes.as_slice() != expected_bytes { - if std::env::var_os("ATOMIC_DEBUG_DELETION").is_some() { - let first_diff = replay_alive_bytes - .iter() - .zip(expected_bytes.iter()) - .position(|(a, b)| a != b) - .unwrap_or_else(|| replay_alive_bytes.len().min(expected_bytes.len())); - eprintln!( - "[debug-deletion] semantic MISMATCH path={path} replay={} expected={} first_diff_at={first_diff} chain_len={}", - replay_alive_bytes.len(), - expected_bytes.len(), - actual_chain.len() - ); - eprintln!( - "[debug-deletion] replay[..first_diff+64]={:?}", - String::from_utf8_lossy( - &replay_alive_bytes - [first_diff..(first_diff + 64).min(replay_alive_bytes.len())] - ) - ); - eprintln!( - "[debug-deletion] expected[..first_diff+64]={:?}", - String::from_utf8_lossy( - &expected_bytes[first_diff..(first_diff + 64).min(expected_bytes.len())] - ) - ); - } return Err(format!( "the closure's semantic operations render {} byte(s) for the alive lines but \ the commit tree expects {} byte(s); the semantic closure must regenerate \ From ecb6751d011df24ad58ebc7db2d91260f3432e6d Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Wed, 14 Oct 2026 00:50:57 -0500 Subject: [PATCH 06/19] fix(record): count lines, not newlines, for the modify's old-line model; drop the count-based divergence guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root cause of the git-import failures is an off-by-one in the modify's old-line accounting: `old_content.iter().filter(== b'\\n')` counts NEWLINES, but the graph's per-line vertex split, the CRDT branch-per-line model, and the diff's line numbering all count LINES — a file without a trailing newline has one more line than it has newlines (packages/opencode-atomic-hooks/README.md: 346 newlines, 347 lines). Every positional hunk index then addresses the branch and vertex domains one off, and the whole cascade of divergence escalations and semantic-closure refusals follows. Count lines with `Line::from_bytes(old_content).len()` (same splitter as the diff and the graph). With the count consistent, the original failing commit records through the normal positional path and both the graph render and the semantic closure replay agree with the commit tree. Also drops the two count-based divergence guards added earlier in this PR (the globalize escalation arm and the record-time branch/line check): cross-view merges legitimately hold MORE alive branches than lines (the sanctioned both-sides-present conflict fork), so a count divergence is not proof of corruption — the guards misfired on those forks and broke three cross-view merge tests. The sentinel routing for generated-file modifies and multi-line deletions (count-independent, verified classes) stays. --- atomic-cli/tests/fixtures/readme_new.md | 354 ++++++++++++++++++ atomic-cli/tests/fixtures/readme_old.md | 347 +++++++++++++++++ .../src/record/workflow/globalize/error.rs | 19 - .../src/record/workflow/globalize/hunk.rs | 44 --- .../src/record/workflow/record/crdt.rs | 19 +- atomic-core/src/record/workflow/record/mod.rs | 32 +- atomic-core/tests/fixtures/readme_new.md | 354 ++++++++++++++++++ atomic-core/tests/fixtures/readme_old.md | 347 +++++++++++++++++ 8 files changed, 1426 insertions(+), 90 deletions(-) create mode 100644 atomic-cli/tests/fixtures/readme_new.md create mode 100644 atomic-cli/tests/fixtures/readme_old.md create mode 100644 atomic-core/tests/fixtures/readme_new.md create mode 100644 atomic-core/tests/fixtures/readme_old.md 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-core/src/record/workflow/globalize/error.rs b/atomic-core/src/record/workflow/globalize/error.rs index 82972b23..08d6f42d 100644 --- a/atomic-core/src/record/workflow/globalize/error.rs +++ b/atomic-core/src/record/workflow/globalize/error.rs @@ -85,25 +85,6 @@ pub enum GlobalizeError { line: u64, }, - /// The alive graph holds a different number of content vertices than the - /// diff's old-file line model expects. - /// - /// Merge diamonds leave extra alive vertices in an isolated closure, so - /// the positional line→vertex mapping cannot be proven. Callers must - /// escalate to the whole-file replace instead of anchoring at unproven - /// vertices. - #[error( - "Vertex/line divergence for {path}: {vertices} alive content vertices for {expected} old lines" - )] - LineVertexDivergence { - /// The file path - path: String, - /// Alive content vertices found - vertices: usize, - /// Old-file lines the diff was computed against - expected: usize, - }, - /// The file has no content to globalize. /// /// This is not necessarily an error - empty files may be intentional. diff --git a/atomic-core/src/record/workflow/globalize/hunk.rs b/atomic-core/src/record/workflow/globalize/hunk.rs index e65b43e8..8d90cf96 100644 --- a/atomic-core/src/record/workflow/globalize/hunk.rs +++ b/atomic-core/src/record/workflow/globalize/hunk.rs @@ -141,31 +141,6 @@ where encoding, ) { Ok(ops) => Ok(ops), - Err(GlobalizeError::LineVertexDivergence { - path, - vertices, - expected, - }) => { - // The alive graph's vertex count diverges from the diff's - // line model (merge diamonds add vertices; empty-line - // accounting can shift it by one). Escalate to the - // whole-file replace — every alive vertex is deleted and - // the full content inserted fresh per line, which renders - // byte-exact regardless of the diamond structure. This - // stays fail-closed: the staged synthesis verification - // checks the render against the commit tree and refuses - // the commit when it disagrees, so a wrong escalation is - // a refusal, never silent corruption. - let _ = (path, vertices, expected); - globalize_replace_whole_file( - ctx, - inode, - inode_pos, - full_content, - local, - encoding, - ) - } Err(e) => { log::debug!( "globalize_hunk: insert failed ({:?}), falling back to replace", @@ -218,25 +193,6 @@ where { let vertices = collect_sorted_content_vertices_cached(ctx, inode, inode_pos)?; - // ::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. Merge diamonds - // leave extra alive vertices in the isolated assembly closure — an - // insertion commit on a file whose history spans a merge anchored at - // silent wrong vertices and the staged render lost the inserted content - // (b110aa70 in atomicdotdev/atomic: rendered 68,762 of 74,206 bytes). - // When the counts diverge, refuse the positional anchor; the caller - // escalates to the whole-file replace. - if let Some(expected) = old_line_count { - if vertices.len() != expected { - return Err(GlobalizeError::LineVertexDivergence { - path: local.path.clone(), - vertices: vertices.len(), - expected, - }); - } - } - let position = classify_insert(&vertices, inode_pos, built.old_start, old_line_count)?; let (predecessors, successors) = match position { diff --git a/atomic-core/src/record/workflow/record/crdt.rs b/atomic-core/src/record/workflow/record/crdt.rs index 01e0925b..a93086c2 100644 --- a/atomic-core/src/record/workflow/record/crdt.rs +++ b/atomic-core/src/record/workflow/record/crdt.rs @@ -699,18 +699,13 @@ pub(crate) fn build_crdt_ops_for_whole_file_replace( } } - // 2. Insert every new line fresh, chained after the LAST tombstoned - // branch (not after the trunk head). The old branches remain in the - // trunk's file-order chain as tombstones, so the fresh lines must - // continue the chain after them — the real apply and the closure - // replay then derive the same file order. Wiring the first fresh - // line with `after: None` starts a second chain, and the two - // interpreters walk a two-chain trunk in different orders: the graph - // render stays byte-exact while the semantic closure replay rotates - // the file order and refuses the commit (equal byte totals, rotated - // content — observed on atomicdotdev/atomic README.md). - let mut prev_branch: Option = - existing_branches.and_then(|branches| branches.last().copied()); + // 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(); let mut prev_leaf: Option = None; diff --git a/atomic-core/src/record/workflow/record/mod.rs b/atomic-core/src/record/workflow/record/mod.rs index 88cb0d0e..b938c923 100644 --- a/atomic-core/src/record/workflow/record/mod.rs +++ b/atomic-core/src/record/workflow/record/mod.rs @@ -470,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 @@ -575,23 +581,19 @@ where DiffOp::Replace { old_len, .. } => *old_len > 1, _ => false, }); - // ::26 R1 fifth follow-up (2026-10-13): the semantic branch count must - // match the diff's old-file line model — the recorder creates one branch - // per line, and every positional hunk below indexes that 1:1 domain. A - // divergence (the add-time tokenizer created 347 branches for a - // 346-line file without a trailing newline) means the diff's line - // numbers do not address the live branch domain: the graph and the - // semantic layer would silently disagree. Detect it at record time — - // where both counts are available — and route through the sentinel - // whole-file replace, which tombstones every prior branch and rebuilds - // the semantic chain fresh so both layers agree again. - let branch_line_divergence = existing_branches - .is_some_and(|branches| branches.len() != Line::from_bytes(old_content).len()); + // ::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); + 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 - || branch_line_divergence; + || has_multi_line_deletion; // Whole-file-replace safety fallback: if the caller's `old_content` came // from resolving a fork or cyclic conflict (see diff --git a/atomic-core/tests/fixtures/readme_new.md b/atomic-core/tests/fixtures/readme_new.md new file mode 100644 index 00000000..696cf59a --- /dev/null +++ b/atomic-core/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-core/tests/fixtures/readme_old.md b/atomic-core/tests/fixtures/readme_old.md new file mode 100644 index 00000000..fc4ecc55 --- /dev/null +++ b/atomic-core/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 From fdc3c30ecef1f99a2612f19e5b691532ea396219 Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Wed, 14 Oct 2026 01:38:58 -0500 Subject: [PATCH 07/19] fix(record): sentinel-route EOF-boundary edits on files without a trailing newline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A modify whose old or new content lacks the final newline (replacing the last no-newline line, adding a trailing newline, appending after it) loses or shifts the final byte in the per-line hunk slicing — observed on atomicdotdev/atomic: a +1-byte trailing-newline commit to atomic-cli/Cargo.toml rendered 1,934 of 1,935 bytes and the staged semantic closure refused the equal-total mismatch. 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. Files whose both sides end with a newline — the common source-file case — keep the fine-grained per-line path. --- atomic-core/src/record/workflow/record/mod.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/atomic-core/src/record/workflow/record/mod.rs b/atomic-core/src/record/workflow/record/mod.rs index b938c923..4f94a260 100644 --- a/atomic-core/src/record/workflow/record/mod.rs +++ b/atomic-core/src/record/workflow/record/mod.rs @@ -590,10 +590,21 @@ where 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; + || 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 From 202290d63de19044172a13c4f73102a3c723eefa Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Wed, 14 Oct 2026 02:26:25 -0500 Subject: [PATCH 08/19] chore(debug): env-gated first-diff probe for staged path verification; drop unused fixtures --- atomic-cli/tests/debug_vertex_count.rs | 50 +++ atomic-core/tests/fixtures/readme_new.md | 354 ------------------ atomic-core/tests/fixtures/readme_old.md | 347 ----------------- atomic-repository/src/repository/synthesis.rs | 32 ++ 4 files changed, 82 insertions(+), 701 deletions(-) create mode 100644 atomic-cli/tests/debug_vertex_count.rs delete mode 100644 atomic-core/tests/fixtures/readme_new.md delete mode 100644 atomic-core/tests/fixtures/readme_old.md diff --git a/atomic-cli/tests/debug_vertex_count.rs b/atomic-cli/tests/debug_vertex_count.rs new file mode 100644 index 00000000..de5824e7 --- /dev/null +++ b/atomic-cli/tests/debug_vertex_count.rs @@ -0,0 +1,50 @@ +//! Temporary debug harness: dumps the alive BLOCK vertices of a path sorted +//! by position, with spans, to expose phantom/empty vertices. +#![cfg(unix)] + +use atomic_core::pristine::InodeGraphOps; +use atomic_core::types::EdgeFlags; +use atomic_core::TreeTxnT; + +#[test] +fn dump_vertex_count() { + let root = std::path::Path::new( + "/private/var/folders/1s/0dxgtx3x7g918slqc05x8c5r0000gn/T/opencode/atomic-victory", + ); + let path = std::env::var("ATOMIC_DUMP_PATH") + .unwrap_or_else(|_| "atomic-cli/Cargo.toml".to_string()); + let repo = atomic_repository::Repository::open(root).unwrap(); + let rendered = repo.get_file_content(&path).unwrap(); + println!("render len: {:?}", rendered.as_ref().map(|b| b.len())); + println!( + "render head: {:?}", + rendered + .as_ref() + .map(|b| String::from_utf8_lossy(&b[..b.len().min(40)]).to_string()) + ); + + let txn = repo.pristine().read_txn().unwrap(); + let inode = txn.get_inode(&path).unwrap().expect("tracked"); + let pos = txn.inode_position(inode).unwrap().expect("position"); + println!("inode pos: {pos:?}"); + + let edges: Vec<_> = txn + .iter_inode_edges(inode, EdgeFlags::BLOCK, EdgeFlags::BLOCK) + .unwrap() + .collect::, _>>() + .unwrap(); + println!("graph alive BLOCK edges: {}", edges.len()); + + let mut rows: Vec<(u64, u64)> = Vec::new(); + for edge in &edges { + rows.push((edge.dest().pos.get(), edge.dest().change.0.into())); + } + rows.sort(); + for (index, (pos, change)) in rows.iter().enumerate() { + let next = rows.get(index + 1).map(|(p, _)| *p).unwrap_or(*pos); + println!( + " v{index:03} pos={pos} change={change} span_to_next={}", + next.saturating_sub(*pos) + ); + } +} diff --git a/atomic-core/tests/fixtures/readme_new.md b/atomic-core/tests/fixtures/readme_new.md deleted file mode 100644 index 696cf59a..00000000 --- a/atomic-core/tests/fixtures/readme_new.md +++ /dev/null @@ -1,354 +0,0 @@ -# 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-core/tests/fixtures/readme_old.md b/atomic-core/tests/fixtures/readme_old.md deleted file mode 100644 index fc4ecc55..00000000 --- a/atomic-core/tests/fixtures/readme_old.md +++ /dev/null @@ -1,347 +0,0 @@ -# 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-repository/src/repository/synthesis.rs b/atomic-repository/src/repository/synthesis.rs index 4dab2329..d97b4e29 100644 --- a/atomic-repository/src/repository/synthesis.rs +++ b/atomic-repository/src/repository/synthesis.rs @@ -3389,6 +3389,38 @@ 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)", From bbf67c53d372f6a3898d544bae98e22eb4a7c7ae Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Wed, 14 Oct 2026 03:15:22 -0500 Subject: [PATCH 09/19] fix(globalize): escalate insert vertex/line divergence to whole-file replace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores the divergence escalation from the insert path, now correctly scoped to the Insert arm only (whole-file replaces and native records route elsewhere and legitimately carry sanctioned multi-branch forks). After long accumulated in-place-edit histories the alive graph holds more content vertices than the diff's line model — the positional anchor then lands at silent wrong vertices (b110aa70-era failures at landed-count 71: renders 1,934 of 1,935 bytes). The escalation deletes every alive vertex and reinserts the file fresh, rendering byte-exactly regardless of diamond remnants. --- atomic-cli/tests/debug_vertex_count.rs | 4 +-- .../src/record/workflow/globalize/hunk.rs | 31 +++++++++++++++++++ atomic-repository/src/repository/synthesis.rs | 3 +- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/atomic-cli/tests/debug_vertex_count.rs b/atomic-cli/tests/debug_vertex_count.rs index de5824e7..4ee7c467 100644 --- a/atomic-cli/tests/debug_vertex_count.rs +++ b/atomic-cli/tests/debug_vertex_count.rs @@ -11,8 +11,8 @@ fn dump_vertex_count() { let root = std::path::Path::new( "/private/var/folders/1s/0dxgtx3x7g918slqc05x8c5r0000gn/T/opencode/atomic-victory", ); - let path = std::env::var("ATOMIC_DUMP_PATH") - .unwrap_or_else(|_| "atomic-cli/Cargo.toml".to_string()); + let path = + std::env::var("ATOMIC_DUMP_PATH").unwrap_or_else(|_| "atomic-cli/Cargo.toml".to_string()); let repo = atomic_repository::Repository::open(root).unwrap(); let rendered = repo.get_file_content(&path).unwrap(); println!("render len: {:?}", rendered.as_ref().map(|b| b.len())); diff --git a/atomic-core/src/record/workflow/globalize/hunk.rs b/atomic-core/src/record/workflow/globalize/hunk.rs index 8d90cf96..98b877c3 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, diff --git a/atomic-repository/src/repository/synthesis.rs b/atomic-repository/src/repository/synthesis.rs index d97b4e29..7eb052fc 100644 --- a/atomic-repository/src/repository/synthesis.rs +++ b/atomic-repository/src/repository/synthesis.rs @@ -3410,7 +3410,8 @@ impl Repository { eprintln!( "[debug-deletion] expected[..first_diff+72]={:?}", String::from_utf8_lossy( - &expected.bytes[first_diff..(first_diff + 72).min(expected.bytes.len())] + &expected.bytes + [first_diff..(first_diff + 72).min(expected.bytes.len())] ) ); eprintln!( From 3aebf1b5b8edc4537ed0ed7ff8fe6696edeb9d11 Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Wed, 14 Oct 2026 03:59:18 -0500 Subject: [PATCH 10/19] fix(import): sentinel-route modifies when semantic branches diverge from the diff's line model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wall at landed-count 71: after many accumulated in-place edits the file's alive branch count drifts above the diff's old-line count (diamond remnants), and the modify's positional hunks index the branch domain with stale line numbers — the render loses bytes inside the modified region (atomic-cli/Cargo.toml: rendered 1,934 of 1,935). The import's foreign-modify record path now detects the branch/line divergence (both counts available at record time) and forces the sentinel whole-file replace: every prior branch is tombstoned and the content rebuilt fresh, so the graph and semantic layers agree with the commit tree byte-exactly. Native and merge flows are unaffected — the check lives on the import-only record path. --- atomic-repository/src/repository/record.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/atomic-repository/src/repository/record.rs b/atomic-repository/src/repository/record.rs index e06b783a..81c61b90 100644 --- a/atomic-repository/src/repository/record.rs +++ b/atomic-repository/src/repository/record.rs @@ -178,7 +178,17 @@ impl Repository { 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() From 4ddfd7f233975f784dd14c423f6f6156da40a1bc Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Wed, 14 Oct 2026 17:15:44 -0500 Subject: [PATCH 11/19] fix(agent): honor bridge opt-in for native hook and view operations Complete #212's enrollment gating in agent view selection, post-record Git projection, and view switching. A retained /.atomic/ exclude marker is not bridge consent. Native repositories skip Git coordination and checkpoint bootstrap; enrolled repositories retain verified-baseline checks and actionable remediation. Hook dispatch outside an Atomic repository exits quietly only for RepositoryNotFound. Includes native-with-marker, no-repository, enrolled-unanchored, and HEAD-drift regression coverage. Implemented by GLM 5.3 Prime in an isolated sandbox; reviewed and targeted tests rerun by GPT-6.1 Sol. --- atomic-cli/src/commands/agent/hooks.rs | 29 +- atomic-cli/src/commands/git/shadow.rs | 304 ++++++++++++++++++++- atomic-cli/src/commands/view/switch.rs | 38 +++ atomic-cli/tests/git_bridge_opt_in_test.rs | 156 +++++++++++ 4 files changed, 511 insertions(+), 16 deletions(-) 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/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/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" + ); +} From 6138000a75753f66e0ad4c620b0692cb0c697765 Mon Sep 17 00:00:00 2001 From: Vincent Date: Wed, 14 Oct 2026 23:32:16 -0400 Subject: [PATCH 12/19] fix(git): preserve file identity and byte-exact imports --- atomic-cli/src/commands/git/import.rs | 41 +- atomic-cli/src/commands/git/parallel.rs | 322 +++++++------- .../git/parallel_import_regression_tests.rs | 134 ++++++ atomic-cli/tests/git_import_cb9b_test.rs | 12 +- .../git_import_corpus_regression_test.rs | 303 +++++++++++++ .../tests/git_import_path_audit_test.rs | 420 ++++++++++++++++++ atomic-core/src/apply/file_ops.rs | 38 +- atomic-core/src/output/alive/retrieve/mod.rs | 169 ++++--- .../src/output/alive/retrieve/tests.rs | 213 +++++++++ .../src/record/workflow/assembly/mod.rs | 12 + .../src/record/workflow/globalize/context.rs | 15 + .../src/record/workflow/globalize/hunk.rs | 5 +- .../src/record/workflow/globalize/pipeline.rs | 6 +- atomic-repository/src/repository/content.rs | 34 +- atomic-repository/src/repository/record.rs | 328 +++++++------- atomic-repository/src/repository/synthesis.rs | 66 ++- .../repository/tests/merge_property_tests.rs | 25 ++ atomic-repository/src/tracking/helpers.rs | 13 +- atomic-repository/src/tracking/tests.rs | 3 + atomic-repository/src/tracking/tree_ops.rs | 21 +- .../tests/content_roundtrip_fidelity_test.rs | 34 ++ 21 files changed, 1765 insertions(+), 449 deletions(-) create mode 100644 atomic-cli/src/commands/git/parallel_import_regression_tests.rs create mode 100644 atomic-cli/tests/git_import_corpus_regression_test.rs create mode 100644 atomic-cli/tests/git_import_path_audit_test.rs 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 2fbd7550..6873f59e 100644 --- a/atomic-cli/src/commands/git/parallel.rs +++ b/atomic-cli/src/commands/git/parallel.rs @@ -4224,7 +4224,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" ))); @@ -4936,7 +4943,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); @@ -4949,8 +4956,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)); } } @@ -5892,13 +5898,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!( @@ -5936,10 +5943,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", @@ -6193,7 +6203,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); } @@ -7894,17 +7906,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 @@ -7926,15 +7943,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; @@ -7947,16 +7959,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) }; - union_paths.insert( - path.clone(), - ParsedFile { + union_paths.entry(path.clone()).or_insert_with(|| { + vec![ParsedFile { path, operation, new_content, @@ -7964,11 +7975,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(); @@ -8145,37 +8156,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 @@ -8202,27 +8187,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(); @@ -8378,23 +8343,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( @@ -8414,6 +8396,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()) @@ -8432,83 +8415,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(tree, &path) + }; + files.push(ParsedFile { + path, + operation, + new_content, + old_content, + diff_lines: None, + old_path, + new_mode, + }); } Ok(files) @@ -8618,20 +8588,20 @@ fn expected_materialization(git_mode: u32) -> CliResult } fn git_mode_at(tree: &Tree<'_>, path: &str) -> Option { - tree.get_path(Path::new(path)) + tree.get_path(&canonical_tree_path(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() + tree.get_path(&canonical_tree_path(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 { +pub(super) fn canonical_tree_path(path: &str) -> PathBuf { match atomic_repository::unescape_repo_path(path) { Ok(raw) => { #[cfg(unix)] @@ -8658,6 +8628,10 @@ fn get_file_content(git_repo: &GitRepository, tree: &Tree, path: &str) -> CliRes message: format!("Path not found in tree: {}", e), })?; + if entry.kind() == Some(ObjectType::Commit) { + return Ok(entry.id().to_string().into_bytes()); + } + if entry.kind() != Some(ObjectType::Blob) { return Err(CliError::GitError { message: "Not a file".to_string(), @@ -8703,6 +8677,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..466c05e0 --- /dev/null +++ b/atomic-cli/src/commands/git/parallel_import_regression_tests.rs @@ -0,0 +1,134 @@ +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); +} diff --git a/atomic-cli/tests/git_import_cb9b_test.rs b/atomic-cli/tests/git_import_cb9b_test.rs index 16e4ee06..f2a734c9 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); @@ -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..567910ea --- /dev/null +++ b/atomic-cli/tests/git_import_corpus_regression_test.rs @@ -0,0 +1,303 @@ +#![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(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"]) + .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); +} 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-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..746133eb 100644 --- a/atomic-core/src/output/alive/retrieve/mod.rs +++ b/atomic-core/src/output/alive/retrieve/mod.rs @@ -128,6 +128,7 @@ 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(); // Add dummy span at index 0 result.graph.push_vertex(AliveVertex::DUMMY); @@ -160,14 +161,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,20 +198,6 @@ 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(); @@ -261,6 +240,7 @@ pub fn retrieve_graph( )?; if !dest_alive { + dead_contexts.insert((vid, resolved_vertex)); let successors = walk_through_dead( txn, &options, @@ -308,6 +288,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, @@ -333,6 +314,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, @@ -379,47 +361,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 +389,100 @@ pub fn retrieve_graph( } } + // 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. +fn replacement_roots( + txn: &T, + options: &RetrieveOptions, + dead: GraphNode, + memo: &mut HashMap, Vec<(GraphNode, GraphNode)>>, +) -> Result, GraphNode)>, 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, @@ -678,7 +719,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..1e82c757 100644 --- a/atomic-core/src/output/alive/retrieve/tests.rs +++ b/atomic-core/src/output/alive/retrieve/tests.rs @@ -958,3 +958,216 @@ 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) + )); + } + } +} 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 98b877c3..c7b9c4ac 100644 --- a/atomic-core/src/record/workflow/globalize/hunk.rs +++ b/atomic-core/src/record/workflow/globalize/hunk.rs @@ -546,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..c9d4b274 100644 --- a/atomic-core/src/record/workflow/globalize/pipeline.rs +++ b/atomic-core/src/record/workflow/globalize/pipeline.rs @@ -975,7 +975,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-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/record.rs b/atomic-repository/src/repository/record.rs index 81c61b90..23f00c0f 100644 --- a/atomic-repository/src/repository/record.rs +++ b/atomic-repository/src/repository/record.rs @@ -171,7 +171,7 @@ impl Repository { .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)?; + 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); @@ -227,52 +227,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) = + 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); @@ -315,6 +275,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 @@ -325,7 +294,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) @@ -375,78 +344,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) = + 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); @@ -501,24 +432,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 (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, existing_branches) = - self.resolve_existing_semantic_state(path, inode)?; + 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 \ @@ -589,6 +520,8 @@ impl Repository { &self, path: &str, inode: atomic_core::types::Inode, + view_name: &str, + excluded: &[Hash], ) -> Result< ( Option, @@ -597,48 +530,119 @@ impl Repository { String, > { 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::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(); - // A merge-era path can hold TWO trunk incarnations: the path-indexed - // row (possibly a stale pre-merge incarnation) and the inode-indexed - // trunk of the graph's current incarnation. Enumerate the alive - // branches of BOTH — tombstoning only one trunk's branches leaves the - // other trunk's stale lines alive in the semantic chain, and the - // staged semantic closure then refuses the change (equal byte totals, - // interleaved stale content). - let mut trunk_ids: Vec = Vec::new(); - if let Ok(Some(trunk_key)) = txn.get_trunk_by_path(path) { - trunk_ids.push(trunk_key); - } - if let Ok(Some(trunk_key)) = txn.get_crdt_inode_trunk(inode_u64) { - let decoded = decode_trunk_id(&trunk_key); - if !trunk_ids.contains(&decoded) { - trunk_ids.push(decoded); + // 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 + }); + } + } + } } } - let existing_trunk_id = trunk_ids.first().copied(); - let existing_branches: Vec = { - let mut alive: Vec = Vec::new(); - for trunk_id in &trunk_ids { - if let Ok(all) = iter_trunk_branches_in_file_order(&txn, *trunk_id) { - 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.contains(&b) { - alive.push(b); - } + 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())); + }; + 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(); + 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; + match line.operation() { + BranchOp::Insert { .. } + | BranchOp::Modify { .. } + | BranchOp::Restore { .. } => { + alive.insert(branch, true); + } + BranchOp::Delete { .. } => { + alive.insert(branch, false); } + _ => {} } } } - alive - }; + } + let existing_branches = iter_trunk_branches_in_file_order(&txn, trunk) + .map_err(|e| e.to_string())? + .into_iter() + .filter(|branch| alive.get(branch) == Some(&true)) + .collect(); Ok((existing_trunk_id, existing_branches)) } diff --git a/atomic-repository/src/repository/synthesis.rs b/atomic-repository/src/repository/synthesis.rs index 7eb052fc..da6489d4 100644 --- a/atomic-repository/src/repository/synthesis.rs +++ b/atomic-repository/src/repository/synthesis.rs @@ -3803,6 +3803,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 @@ -3852,7 +3899,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", @@ -3920,9 +3967,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, .. }) => { @@ -4095,7 +4149,9 @@ 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 { 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) -> 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] + ); +} From 73b56469124f5827ddbe9487ffba57f33bf88b84 Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Thu, 15 Oct 2026 11:11:59 -0500 Subject: [PATCH 13/19] fix(import): retain sibling ancestry during complete-closure imports Separate assembly visibility exclusions from genuinely replaced history. Only Git-origin members outside the import target's complete ancestry may be superseded; sibling legs remain view members so descendant commits retain canonical file/trunk baselines. Carry the explicit supersede subset through synthesis and choose staged verification against the surviving interpretation. Add sibling-leg, sibling-closure, and bypass-cycle regressions. Remove the temporary vertex diagnostic that depended on a machine-local repository and failed CI. Targeted CB9B, corpus, path-audit, synthesis, merge-property, alive, and roundtrip suites passed in the isolated investigation; the 356-commit rerun remains outstanding. Implemented by GLM 5.3 Prime; reviewed and formatted by GPT-6.1 Sol. All work performed in /tmp without changing the user's checkout. --- atomic-cli/src/commands/git/parallel.rs | 151 ++++++++- atomic-cli/tests/debug_vertex_count.rs | 50 --- .../git_import_bypass_cycle_regression.rs | 204 ++++++++++++ .../git_import_sibling_closure_regression.rs | 293 ++++++++++++++++++ ...it_import_sibling_leg_modify_regression.rs | 258 +++++++++++++++ atomic-repository/src/repository/synthesis.rs | 184 ++++++----- .../src/repository/tests/synthesis_tests.rs | 64 ++-- 7 files changed, 1037 insertions(+), 167 deletions(-) delete mode 100644 atomic-cli/tests/debug_vertex_count.rs create mode 100644 atomic-cli/tests/git_import_bypass_cycle_regression.rs create mode 100644 atomic-cli/tests/git_import_sibling_closure_regression.rs create mode 100644 atomic-cli/tests/git_import_sibling_leg_modify_regression.rs diff --git a/atomic-cli/src/commands/git/parallel.rs b/atomic-cli/src/commands/git/parallel.rs index 6873f59e..ae9a23be 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 @@ -4416,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. @@ -4806,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() @@ -4817,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 @@ -5468,6 +5568,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 { @@ -6289,14 +6401,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(); @@ -6449,6 +6563,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 { @@ -6477,9 +6601,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 diff --git a/atomic-cli/tests/debug_vertex_count.rs b/atomic-cli/tests/debug_vertex_count.rs deleted file mode 100644 index 4ee7c467..00000000 --- a/atomic-cli/tests/debug_vertex_count.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! Temporary debug harness: dumps the alive BLOCK vertices of a path sorted -//! by position, with spans, to expose phantom/empty vertices. -#![cfg(unix)] - -use atomic_core::pristine::InodeGraphOps; -use atomic_core::types::EdgeFlags; -use atomic_core::TreeTxnT; - -#[test] -fn dump_vertex_count() { - let root = std::path::Path::new( - "/private/var/folders/1s/0dxgtx3x7g918slqc05x8c5r0000gn/T/opencode/atomic-victory", - ); - let path = - std::env::var("ATOMIC_DUMP_PATH").unwrap_or_else(|_| "atomic-cli/Cargo.toml".to_string()); - let repo = atomic_repository::Repository::open(root).unwrap(); - let rendered = repo.get_file_content(&path).unwrap(); - println!("render len: {:?}", rendered.as_ref().map(|b| b.len())); - println!( - "render head: {:?}", - rendered - .as_ref() - .map(|b| String::from_utf8_lossy(&b[..b.len().min(40)]).to_string()) - ); - - let txn = repo.pristine().read_txn().unwrap(); - let inode = txn.get_inode(&path).unwrap().expect("tracked"); - let pos = txn.inode_position(inode).unwrap().expect("position"); - println!("inode pos: {pos:?}"); - - let edges: Vec<_> = txn - .iter_inode_edges(inode, EdgeFlags::BLOCK, EdgeFlags::BLOCK) - .unwrap() - .collect::, _>>() - .unwrap(); - println!("graph alive BLOCK edges: {}", edges.len()); - - let mut rows: Vec<(u64, u64)> = Vec::new(); - for edge in &edges { - rows.push((edge.dest().pos.get(), edge.dest().change.0.into())); - } - rows.sort(); - for (index, (pos, change)) in rows.iter().enumerate() { - let next = rows.get(index + 1).map(|(p, _)| *p).unwrap_or(*pos); - println!( - " v{index:03} pos={pos} change={change} span_to_next={}", - next.saturating_sub(*pos) - ); - } -} 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_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-repository/src/repository/synthesis.rs b/atomic-repository/src/repository/synthesis.rs index da6489d4..fa85efb9 100644 --- a/atomic-repository/src/repository/synthesis.rs +++ b/atomic-repository/src/repository/synthesis.rs @@ -1839,6 +1839,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 +1864,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 +1885,7 @@ impl Repository { expectation, git_commit_sha, parent_interpretation_closure, - supersede_excluded, + supersede, ) } @@ -1934,7 +1943,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 +1968,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 +1987,7 @@ impl Repository { expectation, git_commit_sha, parent_interpretation_closure, - supersede_excluded, + supersede, ) } @@ -2000,7 +2009,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 +2026,7 @@ impl Repository { expectation, git_commit_sha, parent_interpretation_closure, - supersede_excluded, + supersede, ) } @@ -2121,7 +2130,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; @@ -2292,19 +2301,19 @@ impl Repository { }; 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 +2346,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 +2362,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 +2399,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 +2431,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 +2545,7 @@ impl Repository { expectation, git_commit_sha, parent_interpretation_closure, - supersede_excluded, + supersede, ); match apply_result { @@ -2555,10 +2574,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 +2599,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 +2614,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 +2630,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 +2729,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 +2767,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 +2782,7 @@ impl Repository { assembly_excluded, expectation, hash, - supersede_excluded, + verify_effective, )?; let outcome = ImportWriteOutcome { hash: *hash, @@ -2808,7 +2836,7 @@ 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; @@ -2965,21 +2993,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 +3030,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 +3048,7 @@ impl Repository { assembly_excluded, expectation, hash, - supersede_excluded, + verify_effective, ) .map_err(|error| RepositoryError::InvalidOperation { message: format!( diff --git a/atomic-repository/src/repository/tests/synthesis_tests.rs b/atomic-repository/src/repository/tests/synthesis_tests.rs index d7b3f3e2..721c8396 100644 --- a/atomic-repository/src/repository/tests/synthesis_tests.rs +++ b/atomic-repository/src/repository/tests/synthesis_tests.rs @@ -256,7 +256,7 @@ fn git_deletion_preserves_native_empty_directories_inside_imported_parents() { &single_file_expectation("sub/inner/file.txt", b"file\n"), None, &[], - false, + &[], ) .unwrap(); repo.materialize(wc).unwrap(); @@ -299,7 +299,7 @@ fn git_deletion_preserves_native_empty_directories_inside_imported_parents() { &full_tree_expectation(&[], &[]), None, &[], - false, + &[], ) .unwrap(); repo.materialize(wc).unwrap(); @@ -357,7 +357,7 @@ fn synthesized_multi_file_root_change_reconstructs_every_path() { &expectation, None, &[], - false, + &[], ) .expect("synthesize multi-file root change"); @@ -402,7 +402,7 @@ fn synthesized_root_change_carries_hashed_origin_tagged_oids_and_bridge_sequenci &single_file_expectation("src/domain/model.rs", b"pub struct Model;\n"), None, &[], - false, + &[], ) .expect("synthesize root change"); @@ -522,7 +522,7 @@ fn synthesized_first_parent_dependencies_come_from_graph_context_after_reopen() &single_file_expectation("src/domain/model.rs", b"pub struct Model;\n"), None, &[], - false, + &[], ) .unwrap(); @@ -560,7 +560,7 @@ fn synthesized_first_parent_dependencies_come_from_graph_context_after_reopen() ), None, &[], - false, + &[], ) .unwrap(); @@ -625,7 +625,7 @@ fn deletion_synthesis_uses_repository_state_and_canonical_filedel() { &single_file_expectation("plain.txt", b"payload\n"), None, &[], - false, + &[], ) .unwrap(); @@ -657,7 +657,7 @@ fn deletion_synthesis_uses_repository_state_and_canonical_filedel() { }, None, &[], - false, + &[], ) .unwrap(); assert_eq!( @@ -731,7 +731,7 @@ fn legacy_changes_are_never_rewritten_and_repeated_synthesis_is_idempotent() { ), None, &[], - false, + &[], ) .unwrap(); assert_ne!(first.write.hash, legacy_outcome.hash); @@ -777,7 +777,7 @@ fn legacy_changes_are_never_rewritten_and_repeated_synthesis_is_idempotent() { ), None, &[], - false, + &[], ) .unwrap(); assert!(again.already_in_view); @@ -826,7 +826,7 @@ fn synthesis_failure_aborts_the_prepared_operation_without_view_advance() { &single_file_expectation("doomed.txt", b"doomed\n"), None, &[], - false, + &[], ) .expect_err("the injected mutation-stage fault must fail the synthesis"); super::super::synthesis::SYNTHESIS_APPLY_FAULT.with(|cell| cell.set(false)); @@ -917,7 +917,7 @@ fn synthesis_under_workspace_transaction_journals_synthesize_git_and_verifies() &[], &single_file_expectation("bridge.txt", b"bridged\n"), &[], - false, + &[], ) .expect("workspace-backed synthesis"); assert!(outcome.operation.is_some()); @@ -1194,7 +1194,7 @@ fn reference_registered_change_verifies_the_effective_projection_before_publicat &expectation, None, &[], - false, + &[], ) .expect("publish the change"); @@ -1221,7 +1221,7 @@ fn reference_registered_change_verifies_the_effective_projection_before_publicat &expectation, None, &[], - true, + &[], ) .expect("the reference path verifies and publishes into the new view"); @@ -1249,7 +1249,7 @@ fn reference_registered_change_verifies_the_effective_projection_before_publicat &wrong_bytes, None, &[], - true, + &[], ) .expect_err( "the reference path must verify the effective projection and refuse a \ @@ -1281,7 +1281,7 @@ fn rejected_synthesis_journal_does_not_poison_a_different_retry() { &single_file_expectation("doc.txt", b"different expected bytes\n"), None, &[], - false, + &[], ) .unwrap_err(); assert!( @@ -1312,7 +1312,7 @@ fn rejected_synthesis_journal_does_not_poison_a_different_retry() { &single_file_expectation("doc.txt", b"accepted bytes\n"), None, &[], - false, + &[], ) .unwrap(); assert_eq!( @@ -1368,7 +1368,7 @@ fn edited_lines_render_exact_alive_tokens_after_reload() { &base_expectation, None, &[], - false, + &[], ) .expect("publish base"); @@ -1402,7 +1402,7 @@ fn edited_lines_render_exact_alive_tokens_after_reload() { &edit_expectation, None, &[], - true, + &[], ) .expect("publish the edit"); @@ -1513,7 +1513,7 @@ fn reference_registered_change_with_target_local_removal_prepares_exact_leases() &single_file_expectation("doc.txt", b"referenced content\n"), None, &[], - false, + &[], ) .expect("publish A into the default view") .write @@ -1543,7 +1543,7 @@ fn reference_registered_change_with_target_local_removal_prepares_exact_leases() &single_file_expectation("other.txt", b"superseded\n"), None, &[], - false, + &[], ) .expect("publish B") .write @@ -1568,7 +1568,7 @@ fn reference_registered_change_with_target_local_removal_prepares_exact_leases() ), None, &[], - false, + &[], ) .expect("publish C") .write @@ -1580,6 +1580,8 @@ fn reference_registered_change_with_target_local_removal_prepares_exact_leases() // historical defect prepared the pre-removal lease, committed // membership [C, A], then rejected its own lease after the commit — // leaving a Prepared head that blocked ordinary reopen (review D1). + // B is the genuinely replaced history this rewrite supersedes, so it is + // named in the supersede list (and stays assembly-excluded). let outcome = repo .synthesize_git_change( fixed_header("import doc"), @@ -1601,7 +1603,7 @@ fn reference_registered_change_with_target_local_removal_prepares_exact_leases() ), None, &[], - true, + &[hash_b], ) .expect("the reference with removal must prepare exact leases and publish"); @@ -1652,7 +1654,7 @@ fn already_in_view_refuses_a_diverged_expectation() { &correct, None, &[], - false, + &[], ) .expect("publish the change"); @@ -1675,7 +1677,7 @@ fn already_in_view_refuses_a_diverged_expectation() { &wrong, None, &[], - true, + &[], ) .expect_err("a wrong already-present expectation must be refused"); assert!( @@ -1701,7 +1703,7 @@ fn already_in_view_refuses_a_diverged_expectation() { &correct, None, &[], - true, + &[], ) .expect("the matching expectation is a clean no-op"); assert!(outcome.already_in_view, "the retry reads already-in-view"); @@ -1732,7 +1734,7 @@ fn reference_path_verification_failure_aborts_cleanly_and_retries() { &single_file_expectation("doc.txt", b"referenced content\n"), None, &[], - false, + &[], ) .expect("publish A"); repo.create_shared_view("retry-target") @@ -1762,7 +1764,7 @@ fn reference_path_verification_failure_aborts_cleanly_and_retries() { &wrong, None, &[], - true, + &[], ) .expect_err("the diverged expectation must refuse before publication"); assert!( @@ -1797,7 +1799,7 @@ fn reference_path_verification_failure_aborts_cleanly_and_retries() { &correct, None, &[], - true, + &[], ) .expect("the clean retry publishes"); assert_eq!(view_members(&repo, "retry-target").len(), 1); @@ -1850,7 +1852,7 @@ impl CorruptedBase { &expectation, None, &[], - false, + &[], ) .expect("publish the base"); Self { directory, repo } @@ -1881,7 +1883,7 @@ impl CorruptedBase { &expectation, None, &[], - true, + &[], ) .expect_err("the corrupted ambient semantic state must refuse the successor"); let message = error.to_string(); From d0ced4ee61fb6b40874d76efd8670db8569cf152 Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Thu, 15 Oct 2026 14:18:54 -0500 Subject: [PATCH 14/19] fix(ci): preserve raw Git tree paths across platforms and exercise merge failpoints Decode canonical repository paths and walk Git tree entries by raw component bytes for content, mode, gitlink and existence lookups. Avoid Windows host-path conversion of escaped or non-UTF-8 Git names. Add a platform-independent nested raw-name/mode regression and decode representable physical paths on non-Unix hosts. Run the merge-resolution dependency-index failpoint in explicit --all mode; default first-parent mode intentionally does not synthesize that resolution. Factor canonical replacement witnesses into type aliases to satisfy Clippy without disabling the lint. Addresses CI run 38766979060: six Windows path tests, the Linux/macOS merge failpoint test, and two Clippy type-complexity errors. Formatting and diff checks passed; focused feature-enabled tests and workspace Clippy are running locally, and hosted CI must still confirm all platforms. --- atomic-cli/src/commands/git/parallel.rs | 60 +++++++++++++------ .../git/parallel_import_regression_tests.rs | 27 +++++++++ atomic-cli/tests/git_import_cb9b_test.rs | 6 +- atomic-core/src/output/alive/retrieve/mod.rs | 7 ++- 4 files changed, 78 insertions(+), 22 deletions(-) diff --git a/atomic-cli/src/commands/git/parallel.rs b/atomic-cli/src/commands/git/parallel.rs index ae9a23be..6ce2588a 100644 --- a/atomic-cli/src/commands/git/parallel.rs +++ b/atomic-cli/src/commands/git/parallel.rs @@ -8089,7 +8089,7 @@ fn parse_commit( let mode = if operation == FileOperation::Deleted { None } else { - git_mode_at(&tree, &path) + git_mode_at(git_repo, &tree, &path) }; union_paths.entry(path.clone()).or_insert_with(|| { vec![ParsedFile { @@ -8363,7 +8363,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| { @@ -8593,7 +8593,7 @@ fn parse_diff_files_via_git_cli( let new_mode = if operation == FileOperation::Deleted { None } else { - git_mode_at(tree, &path) + git_mode_at(git_repo, tree, &path) }; files.push(ParsedFile { path, @@ -8712,20 +8712,18 @@ fn expected_materialization(git_mode: u32) -> CliResult Ok(expected) } -fn git_mode_at(tree: &Tree<'_>, path: &str) -> Option { - tree.get_path(&canonical_tree_path(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(&canonical_tree_path(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). +/// 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) => { @@ -8736,22 +8734,50 @@ pub(super) 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()); + } + current = git_repo + .find_tree(entry.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()); diff --git a/atomic-cli/src/commands/git/parallel_import_regression_tests.rs b/atomic-cli/src/commands/git/parallel_import_regression_tests.rs index 466c05e0..12d71b7d 100644 --- a/atomic-cli/src/commands/git/parallel_import_regression_tests.rs +++ b/atomic-cli/src/commands/git/parallel_import_regression_tests.rs @@ -132,3 +132,30 @@ fn fallback_rename_preserves_both_raw_paths() { ); 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/tests/git_import_cb9b_test.rs b/atomic-cli/tests/git_import_cb9b_test.rs index f2a734c9..febfb997 100644 --- a/atomic-cli/tests/git_import_cb9b_test.rs +++ b/atomic-cli/tests/git_import_cb9b_test.rs @@ -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); diff --git a/atomic-core/src/output/alive/retrieve/mod.rs b/atomic-core/src/output/alive/retrieve/mod.rs index 746133eb..6a2b2768 100644 --- a/atomic-core/src/output/alive/retrieve/mod.rs +++ b/atomic-core/src/output/alive/retrieve/mod.rs @@ -449,12 +449,15 @@ pub fn retrieve_graph( /// 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 HashMap, Vec<(GraphNode, GraphNode)>>, -) -> Result, GraphNode)>, PristineError> { + memo: &mut ReplacementRootsMemo, +) -> Result, PristineError> { if let Some(roots) = memo.get(&dead) { return Ok(roots.clone()); } From 0b36794580226511fdd9927a7e493c7cbc1d1e8a Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Thu, 15 Oct 2026 14:19:55 -0500 Subject: [PATCH 15/19] fix(import): release borrowed tree entry before advancing raw-path traversal Copy the subtree object ID and drop the borrowed TreeEntry before replacing its owning tree. Fixes E0506 found by the local feature-enabled CI validation. The raw-byte traversal and regression assertions remain unchanged. --- atomic-cli/src/commands/git/parallel.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/atomic-cli/src/commands/git/parallel.rs b/atomic-cli/src/commands/git/parallel.rs index 6ce2588a..424831d2 100644 --- a/atomic-cli/src/commands/git/parallel.rs +++ b/atomic-cli/src/commands/git/parallel.rs @@ -8767,8 +8767,10 @@ fn git_tree_entry( if components.peek().is_none() { return Ok(entry.to_owned()); } + let subtree_id = entry.id(); + drop(entry); current = git_repo - .find_tree(entry.id()) + .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}'"))) From 2b2b4ca4a7253db15d91c24e54bb517e7500dc8d Mon Sep 17 00:00:00 2001 From: Vincent Date: Thu, 15 Oct 2026 15:41:44 -0400 Subject: [PATCH 16/19] fix(git): preserve closure and file identity during full-history import (#261) --- atomic-cli/src/commands/git/parallel.rs | 210 +++- .../git_import_corpus_regression_test.rs | 921 +++++++++++++++++- atomic-core/src/output/alive/retrieve/mod.rs | 48 +- .../src/output/alive/retrieve/tests.rs | 90 ++ atomic-core/src/pristine/applied_changes.rs | 245 +++++ atomic-core/src/pristine/mod.rs | 1 + atomic-core/src/pristine/schema.rs | 1 + atomic-core/src/pristine/tables.rs | 5 + atomic-core/src/pristine/txn/mod.rs | 2 +- atomic-core/src/pristine/txn/pristine.rs | 2 + atomic-core/src/pristine/txn/read.rs | 6 +- .../src/record/workflow/globalize/pipeline.rs | 59 +- .../src/record/workflow/globalize/tests.rs | 23 + atomic-repository/src/apply/mod.rs | 5 + .../src/repository/deferred_tree.rs | 12 + atomic-repository/src/repository/insert.rs | 20 +- atomic-repository/src/repository/record.rs | 517 +++++++++- .../src/repository/resurrection.rs | 2 +- atomic-repository/src/repository/synthesis.rs | 428 +++++--- .../src/repository/tests/synthesis_tests.rs | 124 ++- 20 files changed, 2472 insertions(+), 249 deletions(-) create mode 100644 atomic-core/src/pristine/applied_changes.rs diff --git a/atomic-cli/src/commands/git/parallel.rs b/atomic-cli/src/commands/git/parallel.rs index 424831d2..8e003e83 100644 --- a/atomic-cli/src/commands/git/parallel.rs +++ b/atomic-cli/src/commands/git/parallel.rs @@ -5274,6 +5274,26 @@ 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 @@ -5480,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 @@ -6240,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, ) @@ -6659,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() @@ -6742,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 @@ -6753,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(&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 )) })? @@ -6776,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| { @@ -6786,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 @@ -6802,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}", @@ -6840,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!( @@ -6866,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( diff --git a/atomic-cli/tests/git_import_corpus_regression_test.rs b/atomic-cli/tests/git_import_corpus_regression_test.rs index 567910ea..6ba8b3ea 100644 --- a/atomic-cli/tests/git_import_corpus_regression_test.rs +++ b/atomic-cli/tests/git_import_corpus_regression_test.rs @@ -28,6 +28,15 @@ fn tree(git: &GitRepository, files: &[(&str, &[u8], i32)]) -> Oid { } 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() @@ -38,7 +47,7 @@ fn commit(git: &GitRepository, files: &[(&str, &[u8], i32)], parents: &[Oid]) -> None, &signature, &signature, - "fixture", + message, &tree, &parents.iter().collect::>(), ) @@ -46,6 +55,10 @@ fn commit(git: &GitRepository, files: &[(&str, &[u8], i32)], parents: &[Oid]) -> } 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(); @@ -61,6 +74,7 @@ fn import(root: &Path, git: &GitRepository, tip: Oid, config: &Path) { .env("ATOMIC_SERVICE", "local") .env("ATOMIC_NONINTERACTIVE", "1") .args(["git", "import", "--no-vault", "--no-color"]) + .args(args) .output() .unwrap(); assert!( @@ -301,3 +315,908 @@ fn symlink_to_regular_file_survives_simultaneous_sibling_deletion() { ); 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-core/src/output/alive/retrieve/mod.rs b/atomic-core/src/output/alive/retrieve/mod.rs index 6a2b2768..4a5a03cd 100644 --- a/atomic-core/src/output/alive/retrieve/mod.rs +++ b/atomic-core/src/output/alive/retrieve/mod.rs @@ -129,6 +129,14 @@ pub fn retrieve_graph( // 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); @@ -202,6 +210,9 @@ pub fn retrieve_graph( 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; @@ -251,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 @@ -299,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 { @@ -325,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 { @@ -389,6 +406,11 @@ 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 @@ -528,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. // @@ -550,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 @@ -560,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); @@ -574,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 @@ -630,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() { @@ -675,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) } diff --git a/atomic-core/src/output/alive/retrieve/tests.rs b/atomic-core/src/output/alive/retrieve/tests.rs index 1e82c757..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 { @@ -1171,3 +1175,89 @@ fn adjacent_replacement_contexts_follow_visible_graph_witnesses() { } } } + +#[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/globalize/pipeline.rs b/atomic-core/src/record/workflow/globalize/pipeline.rs index c9d4b274..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); } 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-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/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 23f00c0f..77c51e45 100644 --- a/atomic-repository/src/repository/record.rs +++ b/atomic-repository/src/repository/record.rs @@ -8,6 +8,66 @@ use atomic_core::pristine::{ CachedGraphTxn, GraphTxnT, GraphVisibilityClosure, PathClaimId, ViewGraph, ViewMembershipSet, }; +/// 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 +181,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,7 +602,7 @@ 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) = + 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(); @@ -206,6 +638,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 @@ -231,7 +664,7 @@ impl Repository { .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) = + 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(); @@ -257,6 +690,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 @@ -373,7 +807,7 @@ impl Repository { .ok_or_else(|| { format!("move source '{old_path}' has no alive path claim on view '{view_name}'") })?; - let (existing_trunk_id, existing_branches) = + 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") @@ -412,7 +846,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 @@ -448,7 +882,7 @@ impl Repository { .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, existing_branches) = + 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!( @@ -468,9 +902,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); @@ -512,10 +947,10 @@ 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, @@ -526,10 +961,10 @@ impl Repository { ( Option, Vec, + Vec, ), String, > { - use atomic_core::crdt::queries::iter_trunk_branches_in_file_order; use atomic_core::crdt::tables::decode_trunk_id; use atomic_core::pristine::CrdtTxnT; @@ -580,7 +1015,7 @@ impl Repository { } let existing_trunk_id = trunks.into_iter().next(); let Some(trunk) = existing_trunk_id else { - return Ok((None, Vec::new())); + return Ok((None, Vec::new(), Vec::new())); }; let indexed = txn .get_crdt_inode_trunk(inode.get()) @@ -596,6 +1031,7 @@ impl Repository { 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() @@ -624,26 +1060,40 @@ impl Repository { 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 { .. } - | BranchOp::Modify { .. } - | BranchOp::Restore { .. } => { + 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 existing_branches = iter_trunk_branches_in_file_order(&txn, trunk) - .map_err(|e| e.to_string())? - .into_iter() + 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)) + Ok((existing_trunk_id, existing_branches, observed)) } /// This is the main entry point for creating a change from working copy @@ -1772,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) => { @@ -3293,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, @@ -3302,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!( @@ -3378,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 fa85efb9..f18a2eb6 100644 --- a/atomic-repository/src/repository/synthesis.rs +++ b/atomic-repository/src/repository/synthesis.rs @@ -961,116 +961,99 @@ 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>; + +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 +1069,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 +1101,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 +1148,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 +1159,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 +1168,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 +1182,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 +1227,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() { @@ -2295,7 +2278,7 @@ 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) }; @@ -2840,7 +2823,6 @@ impl Repository { ) -> 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. @@ -2890,12 +2872,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( @@ -4193,6 +4175,68 @@ impl Repository { 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())? @@ -4208,7 +4252,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; @@ -4310,7 +4354,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; @@ -4331,6 +4375,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) @@ -4458,6 +4504,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); @@ -4478,6 +4541,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())?; @@ -4515,7 +4643,7 @@ impl Repository { Some(node) => graph_proves_dead_with_ownership( self, txn, - path, + trunk_key, *branch_id, node, &global_alive, @@ -4526,9 +4654,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( @@ -4606,7 +4739,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 \ @@ -4684,8 +4817,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 \ @@ -4781,7 +4915,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!( @@ -4813,21 +4947,29 @@ 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)" @@ -4852,26 +4994,13 @@ impl Repository { *branch_id, visibility, &global_alive, + &deletion_evidence, )? { consumer_domain.insert(*branch_id, ConsumerLine::Writer(writer_vertex)); concurrent_state = true; } continue; }; - if expected.state.is_alive() { - if debug_semantic - && replay_alive_bytes.len() <= 1254 - && replay_alive_bytes.len() + expected.bytes.len() > 1200 - { - eprintln!( - "[debug-deletion] chain branch {branch_id:?} alive bytes={} crossing offset {} preview={:?}", - expected.bytes.len(), - replay_alive_bytes.len(), - String::from_utf8_lossy(&expected.bytes[..expected.bytes.len().min(60)]) - ); - } - replay_alive_bytes.extend_from_slice(&expected.bytes); - } } if replay_alive_bytes.as_slice() != expected_bytes { return Err(format!( @@ -4967,6 +5096,7 @@ impl Repository { global_alive: &std::collections::HashSet< atomic_core::types::GraphNode, >, + deletion_evidence: &SemanticDeletionCache, ) -> Result>, String> { use atomic_core::crdt::tables::encode_branch_id; use atomic_core::pristine::{CrdtTxnT, GraphTxnT}; @@ -5003,13 +5133,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 \ @@ -5056,7 +5194,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/synthesis_tests.rs b/atomic-repository/src/repository/tests/synthesis_tests.rs index 721c8396..d21849a7 100644 --- a/atomic-repository/src/repository/tests/synthesis_tests.rs +++ b/atomic-repository/src/repository/tests/synthesis_tests.rs @@ -1918,6 +1918,22 @@ fn first_branch( /// after-chain with no BRANCH_VERTEX is refused — the historical silent /// skip let the consumer walker fail with OrphanBranch while the graph /// output stayed intact. +#[test] +fn staged_verification_refuses_an_unattributed_after_reference() { + use atomic_core::crdt::{tables::encode_branch_id, BranchId}; + use atomic_core::pristine::{CrdtTxnT, MutTxnT}; + let mut base = CorruptedBase::new(false); + let (key, _, _) = first_branch(&base.repo, "f.txt"); + let mut txn = base.repo.pristine().write_txn().unwrap(); + txn.put_crdt_branch_after( + &key, + &encode_branch_id(&BranchId::new(NodeId::new(9999), 0)), + ) + .unwrap(); + txn.commit().unwrap(); + base.finish_refusal(b"inherited text\n", "no applied canonical writer recorded"); +} + #[test] fn staged_verification_refuses_an_unexpected_chain_branch() { use atomic_core::crdt::tables::{ @@ -2096,7 +2112,7 @@ fn out_of_closure_tombstone_attribution_requires_an_applied_writer() { let branch_attributed = super::super::synthesis::out_of_closure_writer_deleted_branch( &loader, &txn, - "f.txt", + trunk, branch, &visibility, ) @@ -2108,7 +2124,7 @@ fn out_of_closure_tombstone_attribution_requires_an_applied_writer() { let trunk_attributed = super::super::synthesis::out_of_closure_writer_deleted_trunk( &loader, &txn, - "f.txt", + trunk, &visibility, ) .unwrap(); @@ -2116,4 +2132,108 @@ fn out_of_closure_tombstone_attribution_requires_an_applied_writer() { !trunk_attributed, "a registered-but-unapplied change must not attribute a trunk tombstone" ); + + // Once ingestion is proven, attribution uses stable identity. A different + // incarnation at the same spelling must not borrow this writer's delete. + txn.mark_change_applied(id).unwrap(); + assert!( + super::super::synthesis::out_of_closure_writer_deleted_branch( + &loader, + &txn, + trunk, + branch, + &visibility, + ) + .unwrap() + ); + assert!( + super::super::synthesis::out_of_closure_writer_deleted_trunk( + &loader, + &txn, + trunk, + &visibility, + ) + .unwrap() + ); + let other_trunk = TrunkId::new(NodeId::new(8), 0); + assert!( + !super::super::synthesis::out_of_closure_writer_deleted_branch( + &loader, + &txn, + other_trunk, + branch, + &visibility, + ) + .unwrap() + ); + assert!( + !super::super::synthesis::out_of_closure_writer_deleted_trunk( + &loader, + &txn, + other_trunk, + &visibility, + ) + .unwrap() + ); +} + +#[test] +fn deletion_evidence_loads_each_applied_writer_once_for_all_its_lines() { + use super::super::synthesis::SemanticDeletionEvidence; + use atomic_core::change::{Change, ChangeHeader, FileOps, LineOps}; + use atomic_core::crdt::{BranchId, TrunkId}; + use atomic_core::pristine::{GraphVisibilityClosure, MutTxnT, ViewMembershipSet}; + use atomic_core::types::{Hash, NodeId}; + + let (_temp, repo) = create_temp_repo(); + let trunk = TrunkId::new(NodeId::new(7), 0); + let mut ops = FileOps::edit(trunk, "same-path.txt".into()); + for index in 0..80 { + ops.add_line_op(LineOps::delete_empty(BranchId::new(NodeId::ROOT, index))); + } + let change = Change::with_file_ops( + ChangeHeader::builder() + .message("many semantic deletions") + .build(), + Vec::new(), + vec![ops], + Vec::new(), + Vec::new(), + ); + let hash = change.serialize(&mut Vec::new()).unwrap(); + let calls = std::cell::Cell::new(0); + let loader = |candidate: &Hash| -> 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" + ); } From 284ebded01d88765d04f7d4cbd2d14d10a79513b Mon Sep 17 00:00:00 2001 From: Vincent Date: Thu, 15 Oct 2026 16:57:10 -0400 Subject: [PATCH 17/19] fix(git): resolve full-history import CI failures (#262) --- atomic-cli/src/commands/git/parallel.rs | 2 +- atomic-repository/src/repository/record.rs | 16 +++++------ atomic-repository/src/repository/synthesis.rs | 27 +++++++++++++------ .../src/repository/tests/synthesis_tests.rs | 2 +- 4 files changed, 29 insertions(+), 18 deletions(-) diff --git a/atomic-cli/src/commands/git/parallel.rs b/atomic-cli/src/commands/git/parallel.rs index 8e003e83..05daceaa 100644 --- a/atomic-cli/src/commands/git/parallel.rs +++ b/atomic-cli/src/commands/git/parallel.rs @@ -6809,7 +6809,7 @@ impl ParallelImporter { files.insert( path.clone(), ParsedFile { - new_mode: git_mode_at(&merge_tree, &path), + new_mode: git_mode_at(git, &merge_tree, &path), path, operation: FileOperation::Modified, new_content: Some(entry.repository_bytes.clone()), diff --git a/atomic-repository/src/repository/record.rs b/atomic-repository/src/repository/record.rs index 77c51e45..8d02bb8e 100644 --- a/atomic-repository/src/repository/record.rs +++ b/atomic-repository/src/repository/record.rs @@ -8,6 +8,13 @@ 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( @@ -957,14 +964,7 @@ impl Repository { inode: atomic_core::types::Inode, view_name: &str, excluded: &[Hash], - ) -> Result< - ( - Option, - Vec, - Vec, - ), - String, - > { + ) -> Result { use atomic_core::crdt::tables::decode_trunk_id; use atomic_core::pristine::CrdtTxnT; diff --git a/atomic-repository/src/repository/synthesis.rs b/atomic-repository/src/repository/synthesis.rs index f18a2eb6..f29e404d 100644 --- a/atomic-repository/src/repository/synthesis.rs +++ b/atomic-repository/src/repository/synthesis.rs @@ -1009,6 +1009,14 @@ pub(super) struct SemanticDeletionEvidence { 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, @@ -4992,9 +5000,11 @@ impl Repository { path, trunk_key, *branch_id, - visibility, - &global_alive, - &deletion_evidence, + SemanticBranchContext { + visibility, + global_alive: &global_alive, + deletion_evidence: &deletion_evidence, + }, )? { consumer_domain.insert(*branch_id, ConsumerLine::Writer(writer_vertex)); concurrent_state = true; @@ -5092,14 +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, - >, - deletion_evidence: &SemanticDeletionCache, + 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) diff --git a/atomic-repository/src/repository/tests/synthesis_tests.rs b/atomic-repository/src/repository/tests/synthesis_tests.rs index d21849a7..0e7a49bd 100644 --- a/atomic-repository/src/repository/tests/synthesis_tests.rs +++ b/atomic-repository/src/repository/tests/synthesis_tests.rs @@ -1921,7 +1921,7 @@ fn first_branch( #[test] fn staged_verification_refuses_an_unattributed_after_reference() { use atomic_core::crdt::{tables::encode_branch_id, BranchId}; - use atomic_core::pristine::{CrdtTxnT, MutTxnT}; + use atomic_core::pristine::MutTxnT; let mut base = CorruptedBase::new(false); let (key, _, _) = first_branch(&base.repo, "f.txt"); let mut txn = base.repo.pristine().write_txn().unwrap(); From 255e5ff6442f60f334a569d45744c1d7b721a592 Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Thu, 15 Oct 2026 15:43:25 -0500 Subject: [PATCH 18/19] test(agent): make WIP preservation failure deterministic across Unix hosts Block recovery-ref creation with a valid namespace-root ref instead of relying on permissions of the Git object-store root. Existing writable object fan-out directories can allow writes despite that chmod, making the Linux CI failure hash-dependent. Supply pending tracked work and preserve the nonzero-exit, durable incomplete reason, zero-recording, and untouched-worktree assertions. Validated all seven workspace_txn_boundaries_cb5c tests with adoption-test-injection enabled, plus formatting and diff checks. Addresses the sole failing Linux test in CI run 38793035390; the other jobs, including Windows and macOS, passed. --- .../workspace_txn_boundaries_cb5c_test.rs | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) 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] From 6f4883922ba355b6f8e08f7455f0c0a880d60317 Mon Sep 17 00:00:00 2001 From: Vincent Date: Thu, 15 Oct 2026 19:29:04 -0400 Subject: [PATCH 19/19] fix(diff): render textual deltas for whole-file replacements (#263) --- atomic-cli/src/commands/diff/helpers.rs | 25 +++ atomic-repository/src/diff_engine.rs | 83 +++++++++ .../tests/whole_file_diff_test.rs | 173 ++++++++++++++++++ 3 files changed, 281 insertions(+) create mode 100644 atomic-repository/tests/whole_file_diff_test.rs 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-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/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()) + ); +}