From af76b313cc9b64b58ff26e5e1a4bc0da706edf6a Mon Sep 17 00:00:00 2001 From: Bradley Hilton Date: Sat, 19 Sep 2026 17:33:32 -0500 Subject: [PATCH 1/3] fix(record): resolve name conflicts as namespace patches --- atomic-core/src/change/graph_op.rs | 1 + atomic-core/src/output/repo/mod.rs | 2 + atomic-core/src/output/repo/names.rs | 64 +++++ .../src/record/workflow/globalize/pipeline.rs | 57 +++++ .../src/record/workflow/record/types.rs | 35 ++- .../src/repository/deferred_tree.rs | 63 ++++- atomic-repository/src/repository/insert.rs | 16 +- .../src/repository/materialize.rs | 125 ++++++---- atomic-repository/src/repository/record.rs | 143 ++++++++++- atomic-repository/src/repository/status.rs | 29 +-- .../tests/causal_file_identity_test.rs | 225 ++++++++++++++++++ docs/record-status-name-conflict.md | 114 +++++++++ .../harness/41_record_status_name_conflict.sh | 137 +++++++++++ 13 files changed, 944 insertions(+), 67 deletions(-) create mode 100644 atomic-core/src/output/repo/names.rs create mode 100644 atomic-repository/tests/causal_file_identity_test.rs create mode 100644 docs/record-status-name-conflict.md create mode 100644 tests/harness/41_record_status_name_conflict.sh diff --git a/atomic-core/src/change/graph_op.rs b/atomic-core/src/change/graph_op.rs index 61a43185..b120fc86 100644 --- a/atomic-core/src/change/graph_op.rs +++ b/atomic-core/src/change/graph_op.rs @@ -247,6 +247,7 @@ pub enum GraphOp { /// resolves the conflict by choosing one version. SolveNameConflict { /// The resolution operation + /// (an empty edge list selects `inode` as the surviving path identity). name: EdgeUpdate, /// Path where conflict occurred path: String, diff --git a/atomic-core/src/output/repo/mod.rs b/atomic-core/src/output/repo/mod.rs index 5b6c5634..d1382111 100644 --- a/atomic-core/src/output/repo/mod.rs +++ b/atomic-core/src/output/repo/mod.rs @@ -105,6 +105,7 @@ mod content; mod error; mod file; mod fork; +mod names; mod options; mod outcome; mod repository; @@ -121,6 +122,7 @@ pub use file::{ output_file, output_file_to_buffer, output_file_to_buffer_with_options, FileOutputError, FileOutputOptions, FileOutputResult, }; +pub use names::live_inode_names; pub use options::OutputOptions; pub use outcome::{FileWritten, OutputOutcome}; pub use repository::{ diff --git a/atomic-core/src/output/repo/names.rs b/atomic-core/src/output/repo/names.rs new file mode 100644 index 00000000..05fc5bb4 --- /dev/null +++ b/atomic-core/src/output/repo/names.rs @@ -0,0 +1,64 @@ +//! View-visible namespace bindings, independent of file content aliveness. + +use crate::output::RetrieveOptions; +use crate::pristine::{GraphTxnT, PristineError}; +use crate::types::{ChangePosition, EdgeFlags, GraphNode, NodeId, Position}; + +/// Find live name vertices attached to an inode under a view's change filter. +/// +/// A name resolution or rename can remove a name without deleting the inode's +/// content. Callers can inspect these vertices' bytes to match a filename. +/// +/// # Examples +/// +/// ```rust,ignore +/// let options = RetrieveOptions::new().with_change_filter(visible_changes); +/// let names = live_inode_names(&txn, inode_position, &options)?; +/// ``` +pub fn live_inode_names( + txn: &T, + inode: Position, + options: &RetrieveOptions, +) -> Result>, PristineError> { + let mut names = Vec::new(); + for edge in txn.get_edges(inode.inode_node())? { + let flags = edge.flag(); + if !flags.contains(EdgeFlags::FOLDER | EdgeFlags::PARENT) + || flags.intersects(EdgeFlags::DELETED | EdgeFlags::PSEUDO) + || !options.passes_filter(edge.introduced_by()) + { + continue; + } + let end = edge.dest(); + if end.change.is_root() || end.pos.get() == 0 { + continue; + } + // The predecessor name ends at this position. find_block_end would + // prefer the empty inode marker at the same position for a FileAdd. + let name = txn.find_block(Position::new( + end.change, + ChangePosition::new(end.pos.get() - 1), + ))?; + let mut linked = false; + let mut unlinked = false; + // Namespace edges carry BLOCK|FOLDER together. Inspect those flags + // directly; the content-oriented typed parent iterator can omit them. + for parent in txn.get_edges(name)? { + let flags = parent.flag(); + if flags.contains(EdgeFlags::PARENT | EdgeFlags::FOLDER) + && !flags.contains(EdgeFlags::PSEUDO) + && options.passes_filter(parent.introduced_by()) + { + if flags.contains(EdgeFlags::DELETED) { + unlinked = true; + } else { + linked = true; + } + } + } + if options.passes_filter(name.change) && linked && !unlinked && !names.contains(&name) { + names.push(name); + } + } + Ok(names) +} diff --git a/atomic-core/src/record/workflow/globalize/pipeline.rs b/atomic-core/src/record/workflow/globalize/pipeline.rs index a39af205..97588cd1 100644 --- a/atomic-core/src/record/workflow/globalize/pipeline.rs +++ b/atomic-core/src/record/workflow/globalize/pipeline.rs @@ -328,6 +328,63 @@ where field: "position", })?; + 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(); + for &name in recorded.name_conflict_bindings() { + ctx.add_dependency_by_id(name.change)?; + for parent in ctx.txn().get_edges(name)? { + let flag = parent.flag(); + if !flag.contains(EdgeFlags::PARENT | EdgeFlags::FOLDER) + || flag.intersects(EdgeFlags::DELETED | EdgeFlags::PSEUDO) + { + continue; + } + 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()), + }); + } + } + if edges.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(), + }); + if let Some(ops) = recorded.crdt_ops().cloned() { + result.set_file_ops(ops); + } + return Ok(result); + } + // Track content positions for each hunk to enrich FileOps later let mut hunk_content_ranges: Vec = Vec::new(); diff --git a/atomic-core/src/record/workflow/record/types.rs b/atomic-core/src/record/workflow/record/types.rs index 3a6ce70d..b0041e63 100644 --- a/atomic-core/src/record/workflow/record/types.rs +++ b/atomic-core/src/record/workflow/record/types.rs @@ -9,7 +9,7 @@ use crate::change::{Encoding, FileOps}; use crate::record::workflow::crdt::CrdtBuildStats; use crate::record::workflow::detect::DetectionKind; use crate::record::workflow::graph_op::BuiltHunk; -use crate::types::{Inode, NodeId, Position}; +use crate::types::{GraphNode, Inode, NodeId, Position}; // ============================================================================ // RECORDING STATS @@ -181,6 +181,10 @@ pub struct RecordedFile { /// graph and CRDT detail is not worth the cost. opaque_generated: bool, + /// Select an identity while unlinking competing name vertices. + name_conflict_resolution: Option>, + name_conflict_bindings: Vec>, + /// Pre-globalized graph operations. When set, `assemble_change` uses /// these directly instead of calling `globalize_recorded_file`, which /// avoids re-walking the graph. Produced by the record path when it @@ -218,6 +222,8 @@ impl RecordedFile { crdt_ops: None, crdt_stats: None, opaque_generated: false, + name_conflict_resolution: None, + name_conflict_bindings: Vec::new(), pre_globalized: None, } } @@ -227,6 +233,25 @@ impl RecordedFile { self.old_line_count = Some(count); } + /// Resolve competing names without deleting their inode or content. The + /// bindings must be live name vertices from the recording view's filter. + pub fn set_name_conflict_resolution( + &mut self, + retained: Position, + bindings: Vec>, + ) { + self.name_conflict_resolution = Some(retained); + self.name_conflict_bindings = bindings; + } + + pub fn name_conflict_resolution(&self) -> Option> { + self.name_conflict_resolution + } + + pub fn name_conflict_bindings(&self) -> &[GraphNode] { + &self.name_conflict_bindings + } + /// Get the old (pristine) line count. pub fn old_line_count(&self) -> Option { self.old_line_count @@ -461,11 +486,13 @@ impl RecordedFile { /// Check if empty (no hunks). /// - /// Note: Moved files are never considered empty even if they have no hunks, - /// because the move itself is a meaningful operation that must be recorded. + /// Moves and name resolutions are meaningful namespace operations even + /// when there are no content hunks. #[must_use] pub fn is_empty(&self) -> bool { - if matches!(self.kind, Some(DetectionKind::Moved)) { + if matches!(self.kind, Some(DetectionKind::Moved)) + || self.name_conflict_resolution.is_some() + { return false; } self.hunks.is_empty() diff --git a/atomic-repository/src/repository/deferred_tree.rs b/atomic-repository/src/repository/deferred_tree.rs index 2669bf73..03e1f711 100644 --- a/atomic-repository/src/repository/deferred_tree.rs +++ b/atomic-repository/src/repository/deferred_tree.rs @@ -12,8 +12,16 @@ const DEFERRED_TREE_ALIGNMENT_LOCK: &str = "deferred-tree-alignment.lock"; #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub(super) enum DeferredTreeAction { - Set { path: String }, + Set { + path: String, + }, Delete, + /// Unbind `path` from the inode's desired state — a name-conflict + /// resolution that surrenders the name keeps the inode's identity + /// (and its REV_TREE claim) while ceasing to occupy the path. + UnlinkName { + path: String, + }, } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -108,6 +116,7 @@ where .filter_map(|(_, op)| match &op.action { DeferredTreeAction::Set { path } => Some(path.clone()), DeferredTreeAction::Delete => None, + DeferredTreeAction::UnlinkName { .. } => None, }) .collect(); desired.insert(inode, DesiredTreePaths { paths }); @@ -334,6 +343,24 @@ pub(super) fn collect_tree_ops( // deferred rename for views where it is visible. push_unique(&mut ops, op); } + GraphOp::SolveNameConflict { name, path } => { + if let Some(inode) = external_position(change_hash, name.inode) { + push_unique( + &mut ops, + DeferredTreeOp { + change: change_hash, + inode, + baseline_path: current_path_for_position(txn, inode)? + .or_else(|| Some(path.clone())), + action: if name.edges.is_empty() { + DeferredTreeAction::Set { path: path.clone() } + } else { + DeferredTreeAction::UnlinkName { path: path.clone() } + }, + }, + ); + } + } GraphOp::FileDel { del, path, .. } | GraphOp::DirDel { del, path } => { if let Some(inode) = external_position(change_hash, del.inode) { push_unique( @@ -386,6 +413,40 @@ pub(super) fn collect_tree_ops( Ok(ops) } +/// Materialize explicit name selections into TREE using stable graph identity. +/// Recording and cross-view insertion share this operation; no content is copied. +pub(super) fn apply_name_selections( + txn: &mut T, + change_id: NodeId, + change: &Change, +) -> Result<(), RepositoryError> { + for op in change.hunks() { + let GraphOp::SolveNameConflict { name, path } = op else { + continue; + }; + if !name.edges.is_empty() { + continue; + } + let unresolved = || RepositoryError::InvalidOperation { + message: format!("cannot resolve retained identity for {path}: name conflict"), + }; + let inode_change = match name.inode.change { + Some(hash) => txn + .get_internal(&hash) + .map_err(|e| RepositoryError::Database(e.to_string()))? + .ok_or_else(unresolved)?, + None => change_id, + }; + let inode = txn + .position_inode(Position::new(inode_change, name.inode.pos)) + .map_err(|e| RepositoryError::Database(e.to_string()))? + .ok_or_else(unresolved)?; + txn.put_tree(path, inode) + .map_err(|e| RepositoryError::Database(e.to_string()))?; + } + Ok(()) +} + impl Repository { fn deferred_tree_journal_path(&self) -> PathBuf { self.dot_dir.join(DEFERRED_TREE_JOURNAL) diff --git a/atomic-repository/src/repository/insert.rs b/atomic-repository/src/repository/insert.rs index 32ea85e5..1882aaae 100644 --- a/atomic-repository/src/repository/insert.rs +++ b/atomic-repository/src/repository/insert.rs @@ -5,7 +5,7 @@ use crate::apply::{ write_change_to_graph, CrossViewInsertOptions, CrossViewInsertOutcome, InsertOptions, InsertOutcome, InsertStats, }; -use crate::repository::deferred_tree::collect_tree_ops; +use crate::repository::deferred_tree::{apply_name_selections, collect_tree_ops}; use atomic_core::change::Insertion; use atomic_core::pristine::InodeGraphOps; use atomic_core::types::{ChangePosition, EdgeFlags, GraphNode, SerializedGraphEdge}; @@ -851,8 +851,12 @@ impl Repository { if let Ok(Some(inode)) = txn.position_inode(inode_pos) { if let Ok(Some(old_path)) = txn.get_path(inode) { + // Only unlink when OUR inode still claims the old + // path — never a sibling inode's claim (a same-path + // name-conflict binds multiple inodes; del_tree_binding + // is inode-scoped per #206). if old_path != *path { - let _ = txn.del_tree(&old_path); + let _ = txn.del_tree_binding(&old_path, inode); } } let _ = txn.put_tree(path, inode); @@ -1816,6 +1820,7 @@ impl Repository { // idempotently. let mut moved_from_disk: Vec = Vec::new(); if !preserve_existing_tree_paths { + apply_name_selections(&mut txn, change_id, &change)?; for graph_op in change.hunks() { if let GraphOp::FileMove { add, path, .. } = graph_op { // add.inode is Position>; resolve to Position. @@ -2278,8 +2283,12 @@ impl Repository { if let Ok(Some(inode)) = txn.position_inode(inode_pos) { if let Ok(Some(old_path)) = txn.get_path(inode) { + // Only unlink when OUR inode still claims the old + // path — never a sibling inode's claim (a same-path + // name-conflict binds multiple inodes; del_tree_binding + // is inode-scoped per #206). if old_path != *path { - let _ = txn.del_tree(&old_path); + let _ = txn.del_tree_binding(&old_path, inode); } } let _ = txn.put_tree(path, inode); @@ -2294,6 +2303,7 @@ impl Repository { // we need to explicitly remove deleted files from the tree tables. // View-aware: only remove if no other view still references the file. if !preserve_existing_tree_paths { + apply_name_selections(&mut txn, change_id, change)?; for deleted_path in outcome.deleted_files() { if let Ok(Some(inode)) = txn.get_inode(deleted_path) { let dominated = is_file_only_on_view(&txn, inode, view_name); diff --git a/atomic-repository/src/repository/materialize.rs b/atomic-repository/src/repository/materialize.rs index cf1f2ba2..3a2916a1 100644 --- a/atomic-repository/src/repository/materialize.rs +++ b/atomic-repository/src/repository/materialize.rs @@ -94,6 +94,85 @@ impl Repository { } } +pub(crate) type NameConflicts = std::collections::HashMap)>>; + +pub(crate) fn live_names_for_path( + txn: &T, + store: &C, + position: Position, + path: &str, + filter: &HashSet, +) -> Result>, RepositoryError> { + let options = atomic_core::output::RetrieveOptions::new().with_change_filter(filter.clone()); + let names = atomic_core::output::repo::live_inode_names(txn, position, &options) + .map_err(|e| RepositoryError::Database(format!("{path}: {e}")))?; + let filename = path.rsplit('/').next().unwrap_or(path).as_bytes(); + let mut matching = Vec::new(); + for name in names { + let mut bytes = vec![0; (name.end.get() - name.start.get()) as usize]; + store + .get_contents(|id| txn.get_external(id).ok().flatten(), name, &mut bytes) + .map_err(|e| RepositoryError::Database(format!("{path}: {e}")))?; + if bytes == filename { + matching.push(name); + } + } + Ok(matching) +} + +/// Find all live identities for same-path creates using the current view's +/// filter. Both record and materialize must see the identities hidden by TREE. +pub(crate) fn collect_name_conflicts( + txn: &atomic_core::pristine::ReadTxn, + store: &C, + paths: &HashSet<&str>, + filter: &HashSet, +) -> Result { + let mut by_path: std::collections::HashMap> = + std::collections::HashMap::new(); + for (inode, path) in txn + .iter_rev_tree() + .map_err(|e| RepositoryError::Database(e.to_string()))? + { + if paths.contains(path.as_str()) { + by_path.entry(path).or_default().push(inode); + } + } + let mut conflicts = std::collections::HashMap::new(); + for (path, candidates) in by_path { + if candidates.len() < 2 { + continue; + } + let mut live = Vec::new(); + for inode in candidates { + let Some(pos) = txn + .inode_position(inode) + .map_err(|e| RepositoryError::Database(format!("{path}: {e}")))? + else { + continue; + }; + if !pos.change.is_root() && !filter.contains(&pos.change) { + continue; + } + if live_names_for_path(txn, store, pos, &path, filter)?.is_empty() { + continue; + } + if super::status::try_is_file_alive_via_retrieval(txn, pos, filter).map_err(|e| { + RepositoryError::Database(format!( + "cannot resolve recorded baseline for {path}: name conflict: {e}" + )) + })? { + live.push((inode, pos)); + } + } + if live.len() >= 2 { + live.sort_by_key(|(inode, pos)| (pos.change.get(), pos.pos.get(), inode.get())); + conflicts.insert(path, live); + } + } + Ok(conflicts) +} + /// Render a name conflict: two or more inodes are alive at the same path on /// this view, so instead of silently emitting whichever inode `TREE` happened /// to keep, wrap every side's materialized content in conflict markers. @@ -536,46 +615,12 @@ impl Repository { // // The (relatively expensive) aliveness probe runs ONLY for paths with // ≥ 2 candidate inodes, so the common single-inode file pays nothing. - let name_conflicts: std::collections::HashMap)>> = { - use atomic_core::pristine::TreeTxnT; - let mut by_path: std::collections::HashMap> = - std::collections::HashMap::new(); - if let Ok(pairs) = txn.iter_rev_tree() { - for (inode, path) in pairs { - by_path.entry(path).or_default().push(inode); - } - } - let filter = change_filter_arc.as_ref(); - let mut conflicts: std::collections::HashMap)>> = - std::collections::HashMap::new(); - for item in &file_items { - let candidates = match by_path.get(&item.path) { - Some(c) if c.len() >= 2 => c, - _ => continue, - }; - let mut live: Vec<(Inode, Position)> = Vec::new(); - for &inode in candidates { - let pos = match txn.inode_position(inode) { - Ok(Some(p)) => p, - _ => continue, - }; - if !pos.change.is_root() && !filter.contains(&pos.change) { - continue; // not visible on this view - } - if crate::repository::status::is_file_alive_via_retrieval( - &txn, inode, pos, filter, - ) { - live.push((inode, pos)); - } - } - if live.len() >= 2 { - // Deterministic order: creating change, then position, then inode. - live.sort_by_key(|(ino, p)| (p.change.get(), p.pos.get(), ino.get())); - conflicts.insert(item.path.clone(), live); - } - } - conflicts - }; + let name_conflicts = collect_name_conflicts( + &txn, + &self.change_store, + &file_items.iter().map(|item| item.path.as_str()).collect(), + &change_filter_arc, + )?; // Phase 3: Create directories needed by passing files let mut result = MaterializeResult::new(); diff --git a/atomic-repository/src/repository/record.rs b/atomic-repository/src/repository/record.rs index a9c9dddf..ffbda3f3 100644 --- a/atomic-repository/src/repository/record.rs +++ b/atomic-repository/src/repository/record.rs @@ -324,6 +324,64 @@ impl Repository { let shared_cached_txn = CachedGraphTxn::new(&shared_txn).map_err(|e| RecordError::Database(e.to_string()))?; + // TREE selects just one inode per path. Recover competing live + // identities exactly as materialization does before diffing that inode. + let selected_paths: HashSet<&str> = files_to_record + .iter() + .filter_map(|e| e.path().to_str()) + .collect(); + let name_conflicts = super::materialize::collect_name_conflicts( + &shared_txn, + &self.change_store, + &selected_paths, + &shared_change_filter, + )?; + + // A namespace conflict has several legitimate identities. TREE's + // occupant is an index artifact, not a choice of which file to keep. + // Prefer an identity whose bytes match the user's resolution. Break + // ties (including newly edited content) by external graph identity, + // never repository-local allocation or cross-view index update order. + let mut selected_name_inodes = std::collections::HashMap::new(); + for entry in &files_to_record { + if entry.status() != FileStatus::Modified { + continue; + } + let path = entry.path().to_string_lossy(); + let Some(sides) = name_conflicts.get(path.as_ref()) else { + continue; + }; + let working = std::fs::read(self.root.join(path.as_ref())).map_err(|e| { + RecordError::Database(format!("cannot resolve name conflict for {path}: {e}")) + })?; + let mut candidates = Vec::new(); + for &(inode, position) in sides { + let options = atomic_core::output::RetrieveOptions::new() + .with_change_filter(shared_change_filter.clone()); + let (content, _) = retrieve_content_with_filter_fast_with_fork_info( + &shared_cached_txn, + &self.change_store, + inode, + position, + options, + ) + .map_err(|e| { + RecordError::Database(format!("cannot resolve name conflict for {path}: {e}")) + })?; + let hash = shared_txn + .get_external(position.change) + .map_err(|e| RecordError::Database(e.to_string()))? + .ok_or_else(|| { + RecordError::Database(format!("{path}: missing identity hash")) + })?; + candidates.push(((content != working, hash, position.pos), (inode, position))); + } + candidates.sort_by_key(|(key, _)| *key); + if let Some((_, identity)) = candidates.first() { + selected_name_inodes.insert(path.into_owned(), *identity); + } + } + if trace_record { eprintln!( "[record] change filter: {} visible changes", @@ -630,7 +688,8 @@ impl Repository { let par_results: Vec = modified_work .par_iter() .map(|(path, _full_path, _)| { - let (file_inode, file_position) = match get_inode_position(&shared_txn, path) { + let (file_inode, file_position) = match selected_name_inodes.get(path).copied() + .map(Ok).unwrap_or_else(|| get_inode_position(&shared_txn, path)) { Ok(v) => v, Err(e) => return ModifiedResult::Error(path.clone(), e), }; @@ -771,6 +830,70 @@ impl Repository { // Merge parallel results back into sequential state for result in par_results { + let resolved_path = match &result { + ModifiedResult::Recorded(path, _) | ModifiedResult::Skipped(path) => Some(path), + ModifiedResult::Error(path, msg) if name_conflicts.contains_key(path) => { + return Err(RecordError::Database(format!( + "cannot resolve recorded baseline for {path}: name conflict: {msg}" + ))); + } + _ => None, + }; + let mut resolved_name = false; + if let Some(path) = resolved_path.filter(|path| { + // --allow-conflict-markers records literal marker bytes; it + // must not implicitly choose a winner for the name conflict. + !options.get_allow_conflict_markers() + || std::fs::read(self.root.join(path)) + .map(|bytes| { + super::materialize::first_conflict_marker_line(&bytes).is_none() + }) + .unwrap_or(false) + }) { + if let Some(sides) = name_conflicts.get(path) { + use atomic_core::crdt::tables::decode_trunk_id; + use atomic_core::pristine::CrdtTxnT; + let (retained, retained_position) = selected_name_inodes[path]; + if !sides.iter().any(|(inode, _)| *inode == retained) { + return Err(RecordError::Database(format!( + "cannot resolve recorded baseline for {path}: selected identity is not visible in the name conflict" + ))); + } + for &(inode, position) in sides { + if inode == retained { + continue; + } + let bindings = super::materialize::live_names_for_path( + &shared_txn, + &self.change_store, + position, + path, + &shared_change_filter, + )?; + let mut unlinked = RecordedFile::new(path); + unlinked.set_inode(inode); + unlinked.set_position(position); + unlinked.set_name_conflict_resolution(retained_position, bindings); + // Namespace resolution preserves the semantic trunk, + // branches, and tokens. It is not a file deletion. + if let Some(key) = shared_txn + .get_crdt_inode_trunk(inode.get()) + .map_err(|e| RecordError::Database(e.to_string()))? + { + let trunk = decode_trunk_id(&key); + unlinked.set_crdt_ops(atomic_core::change::FileOps::new( + trunk, + path.clone(), + None, + )); + } + stats.hunks_created += 2; // identity selection + namespace unlink + stats.edges_modified += 1; + recorded_files.push(unlinked); + } + resolved_name = true; + } + } match result { ModifiedResult::Recorded(path, recorded) => { stats.files_recorded += 1; @@ -795,8 +918,13 @@ impl Repository { recorded_files.push(*recorded); } ModifiedResult::Skipped(path) => { - skipped_paths.push(path); - stats.files_skipped += 1; + if resolved_name { + recorded_paths.push(path); + stats.files_recorded += 1; + } else { + skipped_paths.push(path); + stats.files_skipped += 1; + } } ModifiedResult::Error(path, msg) => { errors.push((path, msg)); @@ -816,6 +944,15 @@ impl Repository { // Check if we actually recorded anything if recorded_files.is_empty() { + if !errors.is_empty() { + return Err(RecordError::Database( + errors + .iter() + .map(|(path, error)| format!("cannot record {path}: {error}")) + .collect::>() + .join("; "), + )); + } return Err(RecordError::NothingToRecord); } diff --git a/atomic-repository/src/repository/status.rs b/atomic-repository/src/repository/status.rs index b7eab48c..af1dfabe 100644 --- a/atomic-repository/src/repository/status.rs +++ b/atomic-repository/src/repository/status.rs @@ -668,6 +668,14 @@ pub(crate) fn is_file_alive_via_retrieval( position: Position, visible_changes: &HashSet, ) -> bool { + try_is_file_alive_via_retrieval(txn, position, visible_changes).unwrap_or(false) +} + +pub(crate) fn try_is_file_alive_via_retrieval( + txn: &T, + position: Position, + visible_changes: &HashSet, +) -> Result { use atomic_core::output::alive::RetrieveOptions; let inode_node = position.inode_node(); @@ -676,14 +684,7 @@ pub(crate) fn is_file_alive_via_retrieval( // Check forward edges from the inode vertex. If any destination // content vertex is alive (per the full supersession logic), the // file has live content. - let edges = match txn.iter_forward(inode_node, false) { - Ok(edges) => edges, - Err(_) => return false, - }; - - if edges.is_empty() { - return false; - } + let edges = txn.iter_forward(inode_node, false)?; for edge in &edges { // Only consider edges introduced by visible changes @@ -691,18 +692,14 @@ pub(crate) fn is_file_alive_via_retrieval( continue; } // Build the destination vertex from the edge - let dest_vertex = match txn.find_block(edge.dest) { - Ok(v) => v, - Err(_) => continue, - }; + let dest_vertex = txn.find_block(edge.dest)?; // Use the retrieval pipeline's supersession-aware aliveness check - match options.is_vertex_alive(txn, dest_vertex) { - Ok(true) => return true, - _ => continue, + if options.is_vertex_alive(txn, dest_vertex)? { + return Ok(true); } } - false + Ok(false) } /// Normalize a tracked path from the TREE table to a relative PathBuf diff --git a/atomic-repository/tests/causal_file_identity_test.rs b/atomic-repository/tests/causal_file_identity_test.rs new file mode 100644 index 00000000..fa76a33f --- /dev/null +++ b/atomic-repository/tests/causal_file_identity_test.rs @@ -0,0 +1,225 @@ +//! File identities are selected by causal visibility, not ambient path equality. + +use atomic_core::change::GraphOp; +use atomic_core::pristine::{GraphTxnT, MutTxnT, TreeTxnT, ViewScope, ViewTxnT}; +use atomic_core::types::{Hash, Inode, Position}; +use atomic_repository::apply::InsertOptions; +use atomic_repository::{RecordOptions, Repository}; + +fn record(repo: &Repository, message: &str) -> Hash { + let outcome = repo + .record_with_message(message, RecordOptions::default()) + .unwrap(); + assert!(!outcome.has_errors(), "{:?}", outcome.errors()); + *outcome.hash() +} + +fn identity(repo: &Repository, path: &str) -> (Inode, Position) { + let txn = repo.pristine().read_txn().unwrap(); + let inode = txn.get_inode(path).unwrap().expect("tracked path"); + let pos = txn.inode_position(inode).unwrap().expect("recorded inode"); + let hash = txn.get_external(pos.change).unwrap().unwrap(); + (inode, Position::new(hash, pos.pos)) +} + +fn seed(repo: &Repository) { + std::fs::write(repo.root().join("seed.txt"), "seed\n").unwrap(); + repo.add("seed.txt", Default::default()).unwrap(); + record(repo, "seed"); +} + +#[test] +fn descendant_add_and_edit_preserve_inherited_inode() { + let dir = tempfile::tempdir().unwrap(); + let mut repo = Repository::init(dir.path()).unwrap(); + seed(&repo); + repo.create_view_from("parent", "dev").unwrap(); + repo.switch_view("parent").unwrap(); + std::fs::write(dir.path().join("f.txt"), "ancestor\n").unwrap(); + repo.add("f.txt", Default::default()).unwrap(); + let creator = record(&repo, "create inherited file"); + let original = identity(&repo, "f.txt"); + + // Empty own change set: this must be ordinary ancestor closure, not a + // copied change log or an ambient fallback to a sibling identity. + let mut txn = repo.pristine().write_txn().unwrap(); + let parent = txn.get_view("parent").unwrap().unwrap(); + txn.create_view("child", ViewScope::Draft, Some(parent.id)) + .unwrap(); + txn.commit().unwrap(); + repo.switch_view("child").unwrap(); + assert_eq!(identity(&repo, "f.txt"), original); + std::fs::write(dir.path().join("f.txt"), "descendant\n").unwrap(); + repo.add("f.txt", Default::default()).unwrap(); + let edit = record(&repo, "edit inherited file"); + assert_eq!(identity(&repo, "f.txt"), original); + let change = repo.load_change(&edit).unwrap(); + assert!(change.dependencies().contains(&creator)); + assert!(!change + .hunks() + .iter() + .any(|h| matches!(h, GraphOp::FileAdd { path, .. } if path == "f.txt"))); +} + +#[test] +fn siblings_create_distinct_identities_even_with_identical_bytes() { + let dir = tempfile::tempdir().unwrap(); + let mut repo = Repository::init(dir.path()).unwrap(); + seed(&repo); + repo.create_view_from("left", "dev").unwrap(); + repo.create_view_from("right", "dev").unwrap(); + repo.switch_view("left").unwrap(); + std::fs::write(dir.path().join("f.txt"), "same bytes\n").unwrap(); + repo.add("f.txt", Default::default()).unwrap(); + let left = record(&repo, "left creates"); + let left_identity = identity(&repo, "f.txt"); + repo.switch_view("right").unwrap(); + std::fs::write(dir.path().join("f.txt"), "same bytes\n").unwrap(); + repo.add("f.txt", Default::default()).unwrap(); + record(&repo, "right independently creates"); + let right_identity = identity(&repo, "f.txt"); + assert_ne!(left_identity.0, right_identity.0); + assert_ne!(left_identity.1, right_identity.1); + repo.insert_change_rec(&left, InsertOptions::default()) + .unwrap(); + repo.materialize().unwrap(); + let bytes = std::fs::read_to_string(dir.path().join("f.txt")).unwrap(); + assert!(bytes.contains("(name conflict)"), "{bytes}"); +} + +#[test] +fn inserting_the_same_creation_preserves_its_inode_and_graph_identity() { + let dir = tempfile::tempdir().unwrap(); + let mut repo = Repository::init(dir.path()).unwrap(); + seed(&repo); + repo.create_view_from("left", "dev").unwrap(); + repo.create_view_from("right", "dev").unwrap(); + repo.switch_view("left").unwrap(); + std::fs::write(dir.path().join("f.txt"), "original\n").unwrap(); + repo.add("f.txt", Default::default()).unwrap(); + let creator = record(&repo, "create once"); + let original = identity(&repo, "f.txt"); + repo.switch_view("right").unwrap(); + repo.insert_change_rec(&creator, InsertOptions::default()) + .unwrap(); + repo.materialize().unwrap(); + assert_eq!(identity(&repo, "f.txt"), original); + repo.insert_change_rec(&creator, InsertOptions::default()) + .unwrap(); + assert_eq!(identity(&repo, "f.txt"), original); + assert_eq!( + std::fs::read(dir.path().join("f.txt")).unwrap(), + b"original\n" + ); +} + +#[test] +fn name_resolution_changes_namespace_edges_not_file_content() { + let dir = tempfile::tempdir().unwrap(); + let mut repo = Repository::init(dir.path()).unwrap(); + seed(&repo); + repo.create_view_from("left", "dev").unwrap(); + repo.create_view_from("right", "dev").unwrap(); + repo.switch_view("left").unwrap(); + std::fs::write(dir.path().join("f.txt"), "left\n").unwrap(); + repo.add("f.txt", Default::default()).unwrap(); + let left = record(&repo, "left creates"); + repo.switch_view("right").unwrap(); + std::fs::write(dir.path().join("f.txt"), "right\n").unwrap(); + repo.add("f.txt", Default::default()).unwrap(); + record(&repo, "right creates"); + let retained = identity(&repo, "f.txt"); + repo.insert_change_rec(&left, InsertOptions::default()) + .unwrap(); + repo.materialize().unwrap(); + std::fs::write(dir.path().join("f.txt"), "right\n").unwrap(); + let resolution = record(&repo, "select right name"); + assert_eq!(identity(&repo, "f.txt"), retained); + let change = repo.load_change(&resolution).unwrap(); + let mut namespace_edges = 0; + for op in change.hunks() { + if let GraphOp::SolveNameConflict { name, .. } = op { + for edge in &name.edges { + assert!( + edge.flag.is_folder(), + "name resolution tombstoned a content edge: {edge:?}" + ); + namespace_edges += 1; + } + } + } + assert!( + namespace_edges > 0, + "resolution must be represented in the canonical graph" + ); + assert!( + !change.file_ops().iter().any(|ops| matches!( + ops.trunk_op(), + Some(atomic_core::crdt::TrunkOp::Delete { .. }) + )), + "removing a name must not delete its semantic trunk" + ); +} + +#[test] +fn namespace_resolution_and_concurrent_rename_preserve_both_identities() { + for rename_first in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let mut repo = Repository::init(dir.path()).unwrap(); + seed(&repo); + repo.create_view_from("left", "dev").unwrap(); + repo.create_view_from("right", "dev").unwrap(); + repo.switch_view("left").unwrap(); + std::fs::write(dir.path().join("f.txt"), "left\n").unwrap(); + repo.add("f.txt", Default::default()).unwrap(); + let left = record(&repo, "left creates"); + let left_identity = identity(&repo, "f.txt"); + repo.switch_view("right").unwrap(); + std::fs::write(dir.path().join("f.txt"), "right\n").unwrap(); + repo.add("f.txt", Default::default()).unwrap(); + record(&repo, "right creates"); + let right_identity = identity(&repo, "f.txt"); + repo.insert_change_rec(&left, InsertOptions::default()) + .unwrap(); + repo.materialize().unwrap(); + + let rename = |repo: &mut Repository| { + repo.switch_view("left").unwrap(); + std::fs::rename(dir.path().join("f.txt"), dir.path().join("saved.txt")).unwrap(); + let hash = record(repo, "concurrent rename"); + assert_eq!(identity(repo, "saved.txt"), left_identity); + repo.switch_view("right").unwrap(); + hash + }; + let mut rename_hash = None; + if rename_first { + rename_hash = Some(rename(&mut repo)); + } + std::fs::write(dir.path().join("f.txt"), "right\n").unwrap(); + record(&repo, "select right name"); + assert_eq!( + identity(&repo, "f.txt"), + right_identity, + "rename_first={rename_first}" + ); + let rename_hash = rename_hash.unwrap_or_else(|| rename(&mut repo)); + repo.insert_change_rec(&rename_hash, InsertOptions::default()) + .unwrap(); + repo.materialize().unwrap(); + assert_eq!(identity(&repo, "f.txt"), right_identity); + assert_eq!(identity(&repo, "saved.txt"), left_identity); + assert_eq!(std::fs::read(dir.path().join("f.txt")).unwrap(), b"right\n"); + assert_eq!( + std::fs::read(dir.path().join("saved.txt")).unwrap(), + b"left\n" + ); + repo.create_view_from("combined", "right").unwrap(); + repo.switch_view("combined").unwrap(); + assert_eq!(identity(&repo, "f.txt"), right_identity); + assert_eq!(identity(&repo, "saved.txt"), left_identity); + assert_eq!( + std::fs::read(dir.path().join("saved.txt")).unwrap(), + b"left\n" + ); + } +} diff --git a/docs/record-status-name-conflict.md b/docs/record-status-name-conflict.md new file mode 100644 index 00000000..f793be08 --- /dev/null +++ b/docs/record-status-name-conflict.md @@ -0,0 +1,114 @@ +# Recording a name-conflict resolution + +## Symptom + +After independently creating the same path on two views and inserting both +changes into one view, materialization reports a name conflict. Replacing the +markers with the content of the identity selected by `TREE` leaves `status` +reporting a modification, but `record` previously returned “Nothing to record”. + +The regression is `tests/harness/41_record_status_name_conflict.sh`. It first +reproduced that failure against the unchanged parent branch, before the +implementation was modified. + +## Cause + +`TREE` selects one inode per path; `REV_TREE` can retain multiple independent +identities claiming that path. Materialization examines all live, view-visible +claimants, but recording previously diffed only the selected inode's content. +Choosing that content exactly produces no content edits, while the unresolved +namespace conflict still requires a patch. + +Deferred TREE replay also needs to distinguish an inode's reverse path from +ownership of the forward path. Removing a stale claimant by path alone can +remove another inode's mapping. Changing which claimant TREE selects must +preserve the competing reverse mappings until the graph resolution makes them +inactive for the current view. + +## Causal identity invariants + +- A descendant draft edits the inherited inode, even when its own change log + is empty. Parent-chain visibility is sufficient; no ambient fallback is used. +- Sibling drafts may independently create different identities at the same + path, even with identical bytes. Combining them produces a name conflict. +- Inserting the same creating change preserves its inode and external graph + identity. Repository-local inode numbers are not a cross-repository identity. + +These are asserted directly in +`atomic-repository/tests/causal_file_identity_test.rs`, in addition to the CLI +scenarios in harness 41. + +## Atomic representation + +The resolution is recorded as graph operations, with dependencies on the +creating changes of the identities involved and the name bindings being removed: + +- An empty `SolveNameConflict` edge update identifies the retained inode. TREE + lifecycle replay and insertion interpret it as selecting that identity for + the path, including when the patch is already present in the canonical graph. +- A separate `SolveNameConflict` update tombstones the competing **name's + incoming FOLDER edges**, not its content edges. Its inode, content, semantic + trunk, branches, and tokens remain intact. A concurrent rename can therefore + preserve that identity under a different path. +- Any content edits to the retained identity use the existing edit pipeline. + +Only identities with live names in the recording view's effective filter are +candidates. Restored bytes matching an existing side prefer that identity. +Ambiguous matches or newly edited content use a stable ordering of external +creating-change hash and inode position. The global TREE occupant and local ID +allocation order do not select the winner. Matching bytes are used only when +recording an explicit resolution; they never deduplicate independent creates. + +TREE replay records the removal of a specific path binding, rather than an +unconditional inode deletion, so it does not erase a concurrent rename. Eager +rename handling also checks forward-path ownership before deleting a stale +reverse mapping's path. + +The canonical graph and historical change objects remain available. A sibling +view whose effective filter excludes the resolution still sees its original +content. A child inheriting the resolution sees the resolved identity. The +regression checks both perspectives, dependency membership, restoration, and +round-trip materialization for either original side and for newly edited content. +It also inserts only the resolution patch into a separate view, letting Atomic +bring in its dependency closure, and verifies the resulting materialized file. + +### Additional test-first findings + +The original resolution attempt deleted competing content. A new CLI regression +first demonstrated that resolving the conflict and combining a rename lost the +renamed file. A structural test independently caught non-FOLDER deletion edges +inside `SolveNameConflict`. Both pass after making resolution namespace-only. + +The reverse recording order then exposed selection of the wrong inode through +TREE. The new identity-selection policy and ownership checks pass both orders +while preserving both original identities and their distinct contents. + +Errors resolving the competing graph identities propagate with the path instead +of becoming a clean no-op. Recording errors also surface when no file could be +recorded. + +## Repair workflow + +On the affected view, replace the conflict markers with the intended file +content, then record the path normally: + +```sh +atomic status +atomic record path/to/file -m "Resolve file name conflict" +atomic status +``` + +This works even when the intended content exactly matches the currently selected +inode. Removing and re-adding the file is unnecessary. + +## Verification + +```sh +cargo build -p atomic-cli --release +ATOMIC_BIN="$PWD/target/release/atomic" bash tests/harness/run_all.sh 39 41 +cargo test -p atomic-core -p atomic-repository --lib +cargo test -p atomic-repository --test causal_file_identity_test +``` + +Suite 39 is the unchanged prior view-switch regression. Suite 41 adds the +record/status regression and view-filter checks. diff --git a/tests/harness/41_record_status_name_conflict.sh b/tests/harness/41_record_status_name_conflict.sh new file mode 100644 index 00000000..4bfc025b --- /dev/null +++ b/tests/harness/41_record_status_name_conflict.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# 41_record_status_name_conflict.sh +# Regression: resolving a same-path, independent-inode name conflict must not +# leave status saying modified while record silently says the tree is clean. + +HARNESS_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$HARNESS_DIR/helpers.sh" +source "$HARNESS_DIR/merge_helpers.sh" + +for resolution in retained other edited; do + begin_section "Independent file identities produce a materialized name conflict" + make_temp_repo "record-status-name-conflict-$resolution" + init_repo + create_file "seed.txt" $'seed\n' + atomic add seed.txt + record_change "seed base" + new_view "feature" --from dev + new_view "target" --from dev + switch_view "feature" + create_file "f.txt" $'from-feature first line\nbody line\n' + atomic add f.txt + record_change "feature creates f.txt" + switch_view "target" + create_file "f.txt" $'from-target first line\nbody line\n' + atomic add f.txt + record_change "target creates f.txt" + insert_from_view "feature" "target" + assert_output_contains "fixture has a name conflict" "(name conflict)" cat f.txt + + begin_section "Record the clean resolution ($resolution)" + # Keeping TREE's selected side exactly must still produce a patch that + # resolves the competing identity, even though its content diff is empty. + case "$resolution" in + retained) clean=$'from-target first line\nbody line\n' ;; + other) clean=$'from-feature first line\nbody line\n' ;; + edited) clean=$'new resolved first line\nmerged body\n' ;; + esac + create_file "f.txt" "$clean" + assert_status_flag "status detects the unrecorded resolution" "M" "f.txt" + if output=$(record_change "resolve name conflict" f.txt); then + _pass "record accepts the resolution" + else + _fail "record accepts the resolution" "$output" + fi + assert_output_contains "resolution is recorded, not a clean no-op" "resolve name conflict" atomic change + assert_output_contains "patch depends on the competing identity" "feature creates f.txt" atomic change --show-deps + assert_output_contains "patch depends on the retained identity" "target creates f.txt" atomic change --show-deps + assert_status_no_entry "status is clean after recording the resolution" "f.txt" + resolution_hash="$(tip_hash target)" + + begin_section "Resolution survives content retrieval" + create_file "f.txt" $'unrecorded replacement to force retrieval\n' + assert_success "restore the recorded resolution" atomic restore --force + assert_file_content "resolved file survives without conflict markers" "f.txt" "${clean%$'\n'}" + assert_file_content "unrelated seed survives" "seed.txt" "seed" + assert_status_no_entry "restored resolution is clean" "f.txt" + assert_output_not_contains "resolution clears conflict reporting" "f.txt" atomic conflicts + + begin_section "Materialize the resolution on an inheriting view" + new_view replay --from target + switch_view replay + assert_file_content "replayed graph has only the resolved content" "f.txt" "${clean%$'\n'}" + assert_status_no_entry "replayed resolution is clean" "f.txt" + assert_output_not_contains "replayed resolution has no conflict" "f.txt" atomic conflicts + + begin_section "Resolution respects the source view's change filter" + switch_view feature + assert_file_content "competing content survives on its source view" "f.txt" $'from-feature first line\nbody line' + switch_view replay + assert_file_content "resolved content survives the round trip" "f.txt" "${clean%$'\n'}" + assert_status_no_entry "round-trip resolution is clean" "f.txt" + + begin_section "Insert the resolution patch with its dependency closure" + new_view receiver --from dev + switch_view receiver + assert_success "insert just the resolution and its dependencies" atomic insert "$resolution_hash" --deps + assert_file_content "inserted patch materializes the resolved content" "f.txt" "${clean%$'\n'}" + assert_status_no_entry "inserted resolution is clean" "f.txt" + assert_output_not_contains "inserted resolution has no conflict" "f.txt" atomic conflicts +done + +begin_section "Descendant drafts edit the inherited identity" +make_temp_repo "inherited-file-identity" +init_repo +create_file seed.txt $'seed\n' +atomic add seed.txt +record_change "seed" +new_view parent --from dev +switch_view parent +create_file inherited.txt $'ancestor content\n' +atomic add inherited.txt +record_change "ancestor creates inherited.txt" +new_view child --draft --parent parent +switch_view child +assert_file_content "descendant sees inherited file" inherited.txt "ancestor content" +create_file inherited.txt $'descendant edit\n' +atomic add inherited.txt +assert_status_flag "inherited file is modified, not a new file" M inherited.txt +assert_success "record inherited edit" record_change "edit inherited identity" inherited.txt +assert_output_not_contains "inherited edit does not create another identity" '"hunk_type": "FileAdd"' atomic change -f json +assert_output_contains "inherited edit depends on original creation" "ancestor creates inherited.txt" atomic change --show-deps + +begin_section "Namespace resolution must preserve a concurrently renamed identity" +make_temp_repo "resolve-name-versus-rename" +init_repo +create_file seed.txt $'seed\n' +atomic add seed.txt +record_change "seed" +new_view left --from dev +new_view right --from dev +switch_view left +create_file f.txt $'left identity content\n' +atomic add f.txt +record_change "left creates f.txt" +switch_view right +create_file f.txt $'right identity content\n' +atomic add f.txt +record_change "right creates f.txt" +insert_from_view left right +assert_output_contains "two visible identities really conflict" "(name conflict)" cat f.txt +create_file f.txt $'right identity content\n' +assert_success "record namespace selection" record_change "select right identity" f.txt +switch_view left +assert_file_content "left has its original content before rename" f.txt "left identity content" +atomic move f.txt saved.txt +record_change "rename left identity" +rename_hash="$(tip_hash left)" +switch_view right +assert_success "insert concurrent rename" atomic insert "$rename_hash" --deps +assert_file_content "selected identity still owns f.txt" f.txt "right identity content" +assert_file_content "renamed identity retains its content" saved.txt "left identity content" +new_view combined --from right +switch_view combined +assert_file_content "selected identity survives replay" f.txt "right identity content" +assert_file_content "renamed identity survives replay" saved.txt "left identity content" + +print_summary From 778ab65d2640467c2f6e25537e4a7afef3b9369a Mon Sep 17 00:00:00 2001 From: Bradley Hilton Date: Mon, 21 Sep 2026 16:46:05 -0500 Subject: [PATCH 2/3] =?UTF-8?q?chore(harness):=20renumber=2041=5Frecord=5F?= =?UTF-8?q?status=20=E2=86=92=2044=20for=20unique=20suite=20numbers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/record-status-name-conflict.md | 4 ++-- ...tus_name_conflict.sh => 44_record_status_name_conflict.sh} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename tests/harness/{41_record_status_name_conflict.sh => 44_record_status_name_conflict.sh} (99%) diff --git a/docs/record-status-name-conflict.md b/docs/record-status-name-conflict.md index f793be08..a598e627 100644 --- a/docs/record-status-name-conflict.md +++ b/docs/record-status-name-conflict.md @@ -7,7 +7,7 @@ changes into one view, materialization reports a name conflict. Replacing the markers with the content of the identity selected by `TREE` leaves `status` reporting a modification, but `record` previously returned “Nothing to record”. -The regression is `tests/harness/41_record_status_name_conflict.sh`. It first +The regression is `tests/harness/44_record_status_name_conflict.sh`. It first reproduced that failure against the unchanged parent branch, before the implementation was modified. @@ -36,7 +36,7 @@ inactive for the current view. These are asserted directly in `atomic-repository/tests/causal_file_identity_test.rs`, in addition to the CLI -scenarios in harness 41. +scenarios in harness 44. ## Atomic representation diff --git a/tests/harness/41_record_status_name_conflict.sh b/tests/harness/44_record_status_name_conflict.sh similarity index 99% rename from tests/harness/41_record_status_name_conflict.sh rename to tests/harness/44_record_status_name_conflict.sh index 4bfc025b..c4cd340b 100644 --- a/tests/harness/41_record_status_name_conflict.sh +++ b/tests/harness/44_record_status_name_conflict.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# 41_record_status_name_conflict.sh +# 44_record_status_name_conflict.sh # Regression: resolving a same-path, independent-inode name conflict must not # leave status saying modified while record silently says the tree is clean. From 8fc02e009b7c1491cfb92ff5661ff58e8dc20a7b Mon Sep 17 00:00:00 2001 From: Bradley Hilton Date: Mon, 21 Sep 2026 17:04:05 -0500 Subject: [PATCH 3/3] =?UTF-8?q?chore(harness):=20renumber=2044=5Frecord=5F?= =?UTF-8?q?status=20=E2=86=92=2043=20(gapless=20numbering)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/record-status-name-conflict.md | 4 ++-- ...tus_name_conflict.sh => 43_record_status_name_conflict.sh} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename tests/harness/{44_record_status_name_conflict.sh => 43_record_status_name_conflict.sh} (99%) diff --git a/docs/record-status-name-conflict.md b/docs/record-status-name-conflict.md index a598e627..f40ea4eb 100644 --- a/docs/record-status-name-conflict.md +++ b/docs/record-status-name-conflict.md @@ -7,7 +7,7 @@ changes into one view, materialization reports a name conflict. Replacing the markers with the content of the identity selected by `TREE` leaves `status` reporting a modification, but `record` previously returned “Nothing to record”. -The regression is `tests/harness/44_record_status_name_conflict.sh`. It first +The regression is `tests/harness/43_record_status_name_conflict.sh`. It first reproduced that failure against the unchanged parent branch, before the implementation was modified. @@ -36,7 +36,7 @@ inactive for the current view. These are asserted directly in `atomic-repository/tests/causal_file_identity_test.rs`, in addition to the CLI -scenarios in harness 44. +scenarios in harness 43. ## Atomic representation diff --git a/tests/harness/44_record_status_name_conflict.sh b/tests/harness/43_record_status_name_conflict.sh similarity index 99% rename from tests/harness/44_record_status_name_conflict.sh rename to tests/harness/43_record_status_name_conflict.sh index c4cd340b..30465621 100644 --- a/tests/harness/44_record_status_name_conflict.sh +++ b/tests/harness/43_record_status_name_conflict.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# 44_record_status_name_conflict.sh +# 43_record_status_name_conflict.sh # Regression: resolving a same-path, independent-inode name conflict must not # leave status saying modified while record silently says the tree is clean.