Add three-way file root apply
This commit is contained in:
parent
d9150024dd
commit
606641dd4a
5 changed files with 602 additions and 27 deletions
|
|
@ -232,8 +232,12 @@ against the current local same-named root and the newly imported remote tree.
|
||||||
Concurrent edit, delete/edit, and divergent rename conflicts are recorded in
|
Concurrent edit, delete/edit, and divergent rename conflicts are recorded in
|
||||||
the local conflict table for later resolution.
|
the local conflict table for later resolution.
|
||||||
`geth cas root apply <root> --to <path>` can then materialize that tree locally:
|
`geth cas root apply <root> --to <path>` can then materialize that tree locally:
|
||||||
it creates missing directories/files, never deletes extra files, never
|
without a registered local root base it creates missing directories/files, never
|
||||||
overwrites differing local files, and records conflicts for manual resolution.
|
deletes extra files, never overwrites differing local files, and records
|
||||||
|
conflicts for manual resolution. When the target path is a registered local file
|
||||||
|
root with a previous scan, apply uses a three-way base/local/remote check and
|
||||||
|
can safely apply non-conflicting remote creates, updates, deletes, and renames
|
||||||
|
only where local state still matches the recorded base.
|
||||||
Named KV stores participate in the same live-sync loop once they exist locally:
|
Named KV stores participate in the same live-sync loop once they exist locally:
|
||||||
manual `geth kv sync <node-id> <name>` and background ticks require `kv.read`
|
manual `geth kv sync <node-id> <name>` and background ticks require `kv.read`
|
||||||
on the remote `resource:kv:<name>` and import only remote entries that are not
|
on the remote `resource:kv:<name>` and import only remote entries that are not
|
||||||
|
|
|
||||||
|
|
@ -7917,6 +7917,44 @@ fn apply_file_root_tree(
|
||||||
std::fs::create_dir_all(&target)?;
|
std::fs::create_dir_all(&target)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some((local_root, base_tree, local_tree, local_tree_hash)) =
|
||||||
|
load_apply_three_way_state(store, &cas, &target)?
|
||||||
|
{
|
||||||
|
return apply_file_root_tree_three_way(
|
||||||
|
store,
|
||||||
|
&source_root,
|
||||||
|
&local_root,
|
||||||
|
&cas,
|
||||||
|
target,
|
||||||
|
dry_run,
|
||||||
|
&tree_hash,
|
||||||
|
&tree,
|
||||||
|
&base_tree,
|
||||||
|
&local_tree,
|
||||||
|
local_tree_hash.as_ref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_file_root_tree_conservative(
|
||||||
|
store,
|
||||||
|
&source_root,
|
||||||
|
&cas,
|
||||||
|
target,
|
||||||
|
dry_run,
|
||||||
|
&tree_hash,
|
||||||
|
&tree,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_file_root_tree_conservative(
|
||||||
|
store: &Store,
|
||||||
|
source_root: &StoredFileRoot,
|
||||||
|
cas: &LocalCas,
|
||||||
|
target: PathBuf,
|
||||||
|
dry_run: bool,
|
||||||
|
tree_hash: &BlobHash,
|
||||||
|
tree: &CasTreeObject,
|
||||||
|
) -> Result<ControlResponse, NodeError> {
|
||||||
let mut files_written = 0;
|
let mut files_written = 0;
|
||||||
let mut dirs_created = 0;
|
let mut dirs_created = 0;
|
||||||
let mut conflicts = Vec::new();
|
let mut conflicts = Vec::new();
|
||||||
|
|
@ -7928,9 +7966,9 @@ fn apply_file_root_tree(
|
||||||
if !out.is_dir() {
|
if !out.is_dir() {
|
||||||
conflicts.push(record_apply_conflict(
|
conflicts.push(record_apply_conflict(
|
||||||
store,
|
store,
|
||||||
&source_root,
|
source_root,
|
||||||
&entry.path,
|
&entry.path,
|
||||||
&tree_hash,
|
tree_hash,
|
||||||
"remote directory conflicts with a local non-directory path",
|
"remote directory conflicts with a local non-directory path",
|
||||||
dry_run,
|
dry_run,
|
||||||
)?);
|
)?);
|
||||||
|
|
@ -7946,9 +7984,9 @@ fn apply_file_root_tree(
|
||||||
let Some(blob) = &entry.blob else {
|
let Some(blob) = &entry.blob else {
|
||||||
conflicts.push(record_apply_conflict(
|
conflicts.push(record_apply_conflict(
|
||||||
store,
|
store,
|
||||||
&source_root,
|
source_root,
|
||||||
&entry.path,
|
&entry.path,
|
||||||
&tree_hash,
|
tree_hash,
|
||||||
"remote file entry is missing a CAS blob hash",
|
"remote file entry is missing a CAS blob hash",
|
||||||
dry_run,
|
dry_run,
|
||||||
)?);
|
)?);
|
||||||
|
|
@ -7958,9 +7996,9 @@ fn apply_file_root_tree(
|
||||||
if !out.is_file() {
|
if !out.is_file() {
|
||||||
conflicts.push(record_apply_conflict(
|
conflicts.push(record_apply_conflict(
|
||||||
store,
|
store,
|
||||||
&source_root,
|
source_root,
|
||||||
&entry.path,
|
&entry.path,
|
||||||
&tree_hash,
|
tree_hash,
|
||||||
"remote file conflicts with a local non-file path",
|
"remote file conflicts with a local non-file path",
|
||||||
dry_run,
|
dry_run,
|
||||||
)?);
|
)?);
|
||||||
|
|
@ -7969,9 +8007,9 @@ fn apply_file_root_tree(
|
||||||
if hash_path(&out)? != *blob {
|
if hash_path(&out)? != *blob {
|
||||||
conflicts.push(record_apply_conflict(
|
conflicts.push(record_apply_conflict(
|
||||||
store,
|
store,
|
||||||
&source_root,
|
source_root,
|
||||||
&entry.path,
|
&entry.path,
|
||||||
&tree_hash,
|
tree_hash,
|
||||||
"local file differs from remote CAS tree; refusing to overwrite",
|
"local file differs from remote CAS tree; refusing to overwrite",
|
||||||
dry_run,
|
dry_run,
|
||||||
)?);
|
)?);
|
||||||
|
|
@ -7989,7 +8027,7 @@ fn apply_file_root_tree(
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(ControlResponse::CasRootApplied {
|
Ok(ControlResponse::CasRootApplied {
|
||||||
source: source.to_owned(),
|
source: source_root.name.clone(),
|
||||||
target,
|
target,
|
||||||
files_written,
|
files_written,
|
||||||
dirs_created,
|
dirs_created,
|
||||||
|
|
@ -7999,6 +8037,325 @@ fn apply_file_root_tree(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ApplyThreeWayState = (
|
||||||
|
StoredFileRoot,
|
||||||
|
CasTreeObject,
|
||||||
|
CasTreeObject,
|
||||||
|
Option<BlobHash>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fn load_apply_three_way_state(
|
||||||
|
store: &Store,
|
||||||
|
cas: &LocalCas,
|
||||||
|
target: &Path,
|
||||||
|
) -> Result<Option<ApplyThreeWayState>, NodeError> {
|
||||||
|
if !target.is_dir() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let target = std::fs::canonicalize(target)?;
|
||||||
|
let Some(local_root) = store
|
||||||
|
.list_file_roots()?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|root| !root.path.starts_with("remote:"))
|
||||||
|
.find(|root| {
|
||||||
|
std::fs::canonicalize(&root.path)
|
||||||
|
.map(|path| path == target)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Some(base_tree_json) = local_root.latest_tree_json.as_deref() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let base_tree = serde_json::from_str(base_tree_json)?;
|
||||||
|
let local_scan = cas.add_tree_path(&target)?;
|
||||||
|
Ok(Some((
|
||||||
|
local_root,
|
||||||
|
base_tree,
|
||||||
|
local_scan.tree,
|
||||||
|
Some(local_scan.object.hash),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn apply_file_root_tree_three_way(
|
||||||
|
store: &Store,
|
||||||
|
source_root: &StoredFileRoot,
|
||||||
|
local_root: &StoredFileRoot,
|
||||||
|
cas: &LocalCas,
|
||||||
|
target: PathBuf,
|
||||||
|
dry_run: bool,
|
||||||
|
remote_tree_hash: &BlobHash,
|
||||||
|
remote_tree: &CasTreeObject,
|
||||||
|
base_tree: &CasTreeObject,
|
||||||
|
local_tree: &CasTreeObject,
|
||||||
|
local_tree_hash: Option<&BlobHash>,
|
||||||
|
) -> Result<ControlResponse, NodeError> {
|
||||||
|
let mut files_written = 0;
|
||||||
|
let mut dirs_created = 0;
|
||||||
|
let mut conflicts = record_three_way_apply_conflicts(
|
||||||
|
store,
|
||||||
|
source_root,
|
||||||
|
local_root,
|
||||||
|
base_tree,
|
||||||
|
local_tree_hash,
|
||||||
|
remote_tree_hash,
|
||||||
|
remote_tree,
|
||||||
|
local_tree,
|
||||||
|
dry_run,
|
||||||
|
)?;
|
||||||
|
let conflict_paths = conflicts
|
||||||
|
.iter()
|
||||||
|
.map(|conflict| conflict.path.clone())
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
let base_entries = owned_tree_entry_map(base_tree);
|
||||||
|
let local_entries = owned_tree_entry_map(local_tree);
|
||||||
|
let remote_entries = owned_tree_entry_map(remote_tree);
|
||||||
|
let mut consumed_base_paths = BTreeSet::new();
|
||||||
|
let mut consumed_remote_paths = BTreeSet::new();
|
||||||
|
|
||||||
|
for (from, to) in remote_rename_pairs(&base_entries, &remote_entries) {
|
||||||
|
if conflict_paths.contains(&from) || conflict_paths.contains(&to) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(base_entry) = base_entries.get(&from) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if local_entries.get(&from) != Some(base_entry) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(remote_entry) = remote_entries.get(&to) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let from_path = safe_tree_output_path(&target, &from)?;
|
||||||
|
let to_path = safe_tree_output_path(&target, &to)?;
|
||||||
|
if to_path.exists() {
|
||||||
|
if path_matches_entry(cas, &to_path, remote_entry)? {
|
||||||
|
consumed_base_paths.insert(from);
|
||||||
|
consumed_remote_paths.insert(to);
|
||||||
|
} else {
|
||||||
|
conflicts.push(record_apply_conflict_with_trees(
|
||||||
|
store,
|
||||||
|
source_root,
|
||||||
|
&to,
|
||||||
|
local_root.latest_tree_hash.as_deref(),
|
||||||
|
local_tree_hash,
|
||||||
|
remote_tree_hash,
|
||||||
|
"remote rename target conflicts with an existing local path",
|
||||||
|
dry_run,
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !from_path.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !dry_run {
|
||||||
|
if let Some(parent) = to_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
std::fs::rename(&from_path, &to_path)?;
|
||||||
|
set_executable_bit(&to_path, remote_entry.executable)?;
|
||||||
|
}
|
||||||
|
consumed_base_paths.insert(from);
|
||||||
|
consumed_remote_paths.insert(to);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut remote_paths = remote_entries.keys().cloned().collect::<Vec<_>>();
|
||||||
|
remote_paths.sort_by(|left, right| {
|
||||||
|
kind_order_for_apply(remote_entries.get(left))
|
||||||
|
.cmp(&kind_order_for_apply(remote_entries.get(right)))
|
||||||
|
.then_with(|| left.cmp(right))
|
||||||
|
});
|
||||||
|
for path in remote_paths {
|
||||||
|
if consumed_remote_paths.contains(&path) || conflict_paths.contains(&path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(remote_entry) = remote_entries.get(&path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let base_entry = base_entries.get(&path);
|
||||||
|
let local_entry = local_entries.get(&path);
|
||||||
|
if local_entry == Some(remote_entry) || base_entry == Some(remote_entry) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if base_entry.is_some() && local_entry != base_entry {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if base_entry.is_none() && local_entry.is_some() {
|
||||||
|
conflicts.push(record_apply_conflict_with_trees(
|
||||||
|
store,
|
||||||
|
source_root,
|
||||||
|
&path,
|
||||||
|
local_root.latest_tree_hash.as_deref(),
|
||||||
|
local_tree_hash,
|
||||||
|
remote_tree_hash,
|
||||||
|
"remote create conflicts with an existing local path",
|
||||||
|
dry_run,
|
||||||
|
)?);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match remote_entry.kind {
|
||||||
|
CasTreeEntryKind::Directory => {
|
||||||
|
let out = safe_tree_output_path(&target, &path)?;
|
||||||
|
if !out.exists() {
|
||||||
|
dirs_created += 1;
|
||||||
|
if !dry_run {
|
||||||
|
std::fs::create_dir_all(out)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CasTreeEntryKind::File => {
|
||||||
|
let Some(blob) = &remote_entry.blob else {
|
||||||
|
conflicts.push(record_apply_conflict_with_trees(
|
||||||
|
store,
|
||||||
|
source_root,
|
||||||
|
&path,
|
||||||
|
local_root.latest_tree_hash.as_deref(),
|
||||||
|
local_tree_hash,
|
||||||
|
remote_tree_hash,
|
||||||
|
"remote file entry is missing a CAS blob hash",
|
||||||
|
dry_run,
|
||||||
|
)?);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let out = safe_tree_output_path(&target, &path)?;
|
||||||
|
files_written += 1;
|
||||||
|
if !dry_run {
|
||||||
|
let bytes = cas.read_bytes(blob)?;
|
||||||
|
write_file_atomic(&out, &bytes)?;
|
||||||
|
set_executable_bit(&out, remote_entry.executable)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut deleted_paths = base_entries
|
||||||
|
.keys()
|
||||||
|
.filter(|path| {
|
||||||
|
!remote_entries.contains_key(*path)
|
||||||
|
&& !consumed_base_paths.contains(*path)
|
||||||
|
&& !conflict_paths.contains(*path)
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
deleted_paths.sort_by(|left, right| right.cmp(left));
|
||||||
|
for path in deleted_paths {
|
||||||
|
let Some(base_entry) = base_entries.get(&path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if local_entries.get(&path) != Some(base_entry) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let out = safe_tree_output_path(&target, &path)?;
|
||||||
|
if dry_run || !out.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match base_entry.kind {
|
||||||
|
CasTreeEntryKind::Directory => {
|
||||||
|
let _ = std::fs::remove_dir(&out);
|
||||||
|
}
|
||||||
|
CasTreeEntryKind::File => {
|
||||||
|
std::fs::remove_file(&out)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ControlResponse::CasRootApplied {
|
||||||
|
source: source_root.name.clone(),
|
||||||
|
target,
|
||||||
|
files_written,
|
||||||
|
dirs_created,
|
||||||
|
conflicts,
|
||||||
|
dry_run,
|
||||||
|
note: "three-way file-root apply updated only paths where local state still matched the recorded base; ambiguous edits remain durable conflicts".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn record_three_way_apply_conflicts(
|
||||||
|
store: &Store,
|
||||||
|
source_root: &StoredFileRoot,
|
||||||
|
local_root: &StoredFileRoot,
|
||||||
|
base_tree: &CasTreeObject,
|
||||||
|
local_tree_hash: Option<&BlobHash>,
|
||||||
|
remote_tree_hash: &BlobHash,
|
||||||
|
remote_tree: &CasTreeObject,
|
||||||
|
local_tree: &CasTreeObject,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> Result<Vec<FileConflict>, NodeError> {
|
||||||
|
geth_cas::detect_tree_conflicts(base_tree, local_tree, remote_tree)
|
||||||
|
.into_iter()
|
||||||
|
.map(|conflict| {
|
||||||
|
record_apply_conflict_with_trees(
|
||||||
|
store,
|
||||||
|
source_root,
|
||||||
|
&conflict.path,
|
||||||
|
local_root.latest_tree_hash.as_deref(),
|
||||||
|
local_tree_hash,
|
||||||
|
remote_tree_hash,
|
||||||
|
&conflict.detail,
|
||||||
|
dry_run,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn owned_tree_entry_map(tree: &CasTreeObject) -> BTreeMap<String, geth_cas::CasTreeEntry> {
|
||||||
|
tree.entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| (entry.path.clone(), entry.clone()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remote_rename_pairs(
|
||||||
|
base_entries: &BTreeMap<String, geth_cas::CasTreeEntry>,
|
||||||
|
remote_entries: &BTreeMap<String, geth_cas::CasTreeEntry>,
|
||||||
|
) -> Vec<(String, String)> {
|
||||||
|
let mut pairs = Vec::new();
|
||||||
|
for (base_path, base_entry) in base_entries {
|
||||||
|
let Some(base_blob) = &base_entry.blob else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if remote_entries.contains_key(base_path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some((remote_path, _)) = remote_entries.iter().find(|(remote_path, remote_entry)| {
|
||||||
|
!base_entries.contains_key(*remote_path)
|
||||||
|
&& remote_entry.blob.as_ref() == Some(base_blob)
|
||||||
|
}) {
|
||||||
|
pairs.push((base_path.clone(), remote_path.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pairs.sort();
|
||||||
|
pairs
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_matches_entry(
|
||||||
|
cas: &LocalCas,
|
||||||
|
path: &Path,
|
||||||
|
entry: &geth_cas::CasTreeEntry,
|
||||||
|
) -> Result<bool, NodeError> {
|
||||||
|
match entry.kind {
|
||||||
|
CasTreeEntryKind::Directory => Ok(path.is_dir()),
|
||||||
|
CasTreeEntryKind::File => {
|
||||||
|
let Some(blob) = &entry.blob else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
Ok(path.is_file() && hash_path(path)? == *blob && cas.has(blob)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind_order_for_apply(entry: Option<&geth_cas::CasTreeEntry>) -> u8 {
|
||||||
|
match entry.map(|entry| &entry.kind) {
|
||||||
|
Some(CasTreeEntryKind::Directory) => 0,
|
||||||
|
Some(CasTreeEntryKind::File) => 1,
|
||||||
|
None => 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn safe_tree_output_path(root: &Path, relative: &str) -> Result<PathBuf, NodeError> {
|
fn safe_tree_output_path(root: &Path, relative: &str) -> Result<PathBuf, NodeError> {
|
||||||
let relative_path = Path::new(relative);
|
let relative_path = Path::new(relative);
|
||||||
if relative_path.is_absolute() {
|
if relative_path.is_absolute() {
|
||||||
|
|
@ -8052,6 +8409,45 @@ fn record_apply_conflict(
|
||||||
file_conflict_from_stored(stored)
|
file_conflict_from_stored(stored)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn record_apply_conflict_with_trees(
|
||||||
|
store: &Store,
|
||||||
|
root: &StoredFileRoot,
|
||||||
|
path: &str,
|
||||||
|
base_tree: Option<&str>,
|
||||||
|
local_tree: Option<&BlobHash>,
|
||||||
|
remote_tree: &BlobHash,
|
||||||
|
detail: &str,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> Result<FileConflict, NodeError> {
|
||||||
|
let created_at = geth_store::now_ms();
|
||||||
|
let stored = StoredFileConflict {
|
||||||
|
conflict_id: generated_file_conflict_id(
|
||||||
|
&root.name,
|
||||||
|
path,
|
||||||
|
FileConflictKind::ConcurrentEdit.as_str(),
|
||||||
|
created_at,
|
||||||
|
),
|
||||||
|
root_name: root.name.clone(),
|
||||||
|
resource_id: root.resource_id.clone(),
|
||||||
|
path: path.to_owned(),
|
||||||
|
kind: FileConflictKind::ConcurrentEdit.as_str().to_owned(),
|
||||||
|
status: FileConflictStatus::Open.as_str().to_owned(),
|
||||||
|
base_tree_hash: base_tree.map(ToOwned::to_owned),
|
||||||
|
local_tree_hash: local_tree.map(ToString::to_string),
|
||||||
|
remote_tree_hash: Some(remote_tree.to_string()),
|
||||||
|
detail: detail.to_owned(),
|
||||||
|
resolution: None,
|
||||||
|
resolution_note: None,
|
||||||
|
created_at_ms: created_at,
|
||||||
|
resolved_at_ms: None,
|
||||||
|
};
|
||||||
|
if !dry_run {
|
||||||
|
store.upsert_file_conflict(&stored)?;
|
||||||
|
}
|
||||||
|
file_conflict_from_stored(stored)
|
||||||
|
}
|
||||||
|
|
||||||
fn record_sync_tree_conflicts(
|
fn record_sync_tree_conflicts(
|
||||||
store: &Store,
|
store: &Store,
|
||||||
local_root: &StoredFileRoot,
|
local_root: &StoredFileRoot,
|
||||||
|
|
|
||||||
|
|
@ -1651,6 +1651,174 @@ fn cas_file_root_apply_writes_missing_files_and_records_conflicts() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cas_file_root_apply_three_way_updates_deletes_and_renames_safe_changes() {
|
||||||
|
let home = tempfile::tempdir().expect("tempdir");
|
||||||
|
let paths = geth_config::GethPaths::from_home(home.path());
|
||||||
|
let node = geth_node::init_node(&paths).expect("init node");
|
||||||
|
let target_path = home.path().join("target-root");
|
||||||
|
std::fs::create_dir_all(&target_path).expect("create target root");
|
||||||
|
std::fs::write(target_path.join("update.txt"), b"base").expect("write base update");
|
||||||
|
std::fs::write(target_path.join("delete.txt"), b"base delete").expect("write base delete");
|
||||||
|
std::fs::write(target_path.join("rename.txt"), b"base rename").expect("write base rename");
|
||||||
|
|
||||||
|
geth_node::handle_request(
|
||||||
|
&node,
|
||||||
|
geth_control::ControlRequest::CasRootAdd {
|
||||||
|
name: "shared".to_owned(),
|
||||||
|
path: target_path.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("add local root");
|
||||||
|
geth_node::handle_request(
|
||||||
|
&node,
|
||||||
|
geth_control::ControlRequest::CasRootScan {
|
||||||
|
name: "shared".to_owned(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("scan local base");
|
||||||
|
|
||||||
|
let source_path = home.path().join("source-root");
|
||||||
|
std::fs::create_dir_all(&source_path).expect("create source root");
|
||||||
|
std::fs::write(source_path.join("update.txt"), b"remote").expect("write remote update");
|
||||||
|
std::fs::write(source_path.join("rename-new.txt"), b"base rename")
|
||||||
|
.expect("write remote rename");
|
||||||
|
std::fs::write(source_path.join("created.txt"), b"created").expect("write remote create");
|
||||||
|
geth_node::handle_request(
|
||||||
|
&node,
|
||||||
|
geth_control::ControlRequest::CasRootAdd {
|
||||||
|
name: "remote-node-peer-shared".to_owned(),
|
||||||
|
path: source_path,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("add remote root");
|
||||||
|
geth_node::handle_request(
|
||||||
|
&node,
|
||||||
|
geth_control::ControlRequest::CasRootScan {
|
||||||
|
name: "remote-node-peer-shared".to_owned(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("scan remote root");
|
||||||
|
|
||||||
|
let response = geth_node::handle_request(
|
||||||
|
&node,
|
||||||
|
geth_control::ControlRequest::CasRootApply {
|
||||||
|
source: "remote-node-peer-shared".to_owned(),
|
||||||
|
target: target_path.clone(),
|
||||||
|
dry_run: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("three-way apply");
|
||||||
|
match response {
|
||||||
|
geth_control::ControlResponse::CasRootApplied {
|
||||||
|
files_written,
|
||||||
|
conflicts,
|
||||||
|
note,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(files_written, 2);
|
||||||
|
assert!(conflicts.is_empty());
|
||||||
|
assert!(note.contains("three-way"));
|
||||||
|
}
|
||||||
|
other => panic!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(target_path.join("update.txt")).expect("read updated"),
|
||||||
|
b"remote"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!target_path.join("delete.txt").exists(),
|
||||||
|
"safe remote delete should remove unchanged local base file"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!target_path.join("rename.txt").exists(),
|
||||||
|
"safe remote rename should remove old path"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(target_path.join("rename-new.txt")).expect("read renamed"),
|
||||||
|
b"base rename"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(target_path.join("created.txt")).expect("read created"),
|
||||||
|
b"created"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cas_file_root_apply_three_way_keeps_ambiguous_changes_as_conflicts() {
|
||||||
|
let home = tempfile::tempdir().expect("tempdir");
|
||||||
|
let paths = geth_config::GethPaths::from_home(home.path());
|
||||||
|
let node = geth_node::init_node(&paths).expect("init node");
|
||||||
|
let target_path = home.path().join("target-root");
|
||||||
|
std::fs::create_dir_all(&target_path).expect("create target root");
|
||||||
|
std::fs::write(target_path.join("note.txt"), b"base").expect("write base");
|
||||||
|
|
||||||
|
geth_node::handle_request(
|
||||||
|
&node,
|
||||||
|
geth_control::ControlRequest::CasRootAdd {
|
||||||
|
name: "shared".to_owned(),
|
||||||
|
path: target_path.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("add local root");
|
||||||
|
geth_node::handle_request(
|
||||||
|
&node,
|
||||||
|
geth_control::ControlRequest::CasRootScan {
|
||||||
|
name: "shared".to_owned(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("scan local base");
|
||||||
|
std::fs::write(target_path.join("note.txt"), b"local edit").expect("write local edit");
|
||||||
|
|
||||||
|
let source_path = home.path().join("source-root");
|
||||||
|
std::fs::create_dir_all(&source_path).expect("create source root");
|
||||||
|
std::fs::write(source_path.join("note.txt"), b"remote edit").expect("write remote edit");
|
||||||
|
geth_node::handle_request(
|
||||||
|
&node,
|
||||||
|
geth_control::ControlRequest::CasRootAdd {
|
||||||
|
name: "remote-node-peer-shared".to_owned(),
|
||||||
|
path: source_path,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("add remote root");
|
||||||
|
geth_node::handle_request(
|
||||||
|
&node,
|
||||||
|
geth_control::ControlRequest::CasRootScan {
|
||||||
|
name: "remote-node-peer-shared".to_owned(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("scan remote root");
|
||||||
|
|
||||||
|
let response = geth_node::handle_request(
|
||||||
|
&node,
|
||||||
|
geth_control::ControlRequest::CasRootApply {
|
||||||
|
source: "remote-node-peer-shared".to_owned(),
|
||||||
|
target: target_path.clone(),
|
||||||
|
dry_run: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("three-way conflict apply");
|
||||||
|
match response {
|
||||||
|
geth_control::ControlResponse::CasRootApplied { conflicts, .. } => {
|
||||||
|
assert_eq!(conflicts.len(), 1);
|
||||||
|
assert_eq!(conflicts[0].path, "note.txt");
|
||||||
|
assert_eq!(
|
||||||
|
conflicts[0].kind,
|
||||||
|
geth_cas::FileConflictKind::ConcurrentEdit
|
||||||
|
);
|
||||||
|
assert!(conflicts[0].base_tree.is_some());
|
||||||
|
assert!(conflicts[0].local_tree.is_some());
|
||||||
|
assert!(conflicts[0].remote_tree.is_some());
|
||||||
|
}
|
||||||
|
other => panic!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(target_path.join("note.txt")).expect("read local after conflict"),
|
||||||
|
b"local edit"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cas_file_conflict_record_list_and_resolve() {
|
fn cas_file_conflict_record_list_and_resolve() {
|
||||||
let home = tempfile::tempdir().expect("tempdir");
|
let home = tempfile::tempdir().expect("tempdir");
|
||||||
|
|
|
||||||
|
|
@ -161,12 +161,15 @@ watermarks. Repeated remote file-root syncs retain the previous imported remote
|
||||||
tree as the base and compare base/local/remote tree state. When both local and
|
tree as the base and compare base/local/remote tree state. When both local and
|
||||||
remote roots changed, geth records durable concurrent edit, delete/edit, or
|
remote roots changed, geth records durable concurrent edit, delete/edit, or
|
||||||
divergent rename conflicts instead of applying remote content. `geth cas root
|
divergent rename conflicts instead of applying remote content. `geth cas root
|
||||||
apply <root> --to <path>` is the first conservative
|
apply <root> --to <path>` is conservative when the target has no registered
|
||||||
materialization path: it creates missing files and directories from the CAS tree,
|
base: it creates missing files and directories from the CAS tree, does not
|
||||||
does not delete extra local files, does not overwrite differing local files, and
|
delete extra local files, does not overwrite differing local files, and records
|
||||||
records conflicts for manual resolution. The daemon also has durable local
|
conflicts for manual resolution. When the target path matches a registered
|
||||||
file-conflict records with explicit resolution choices; richer three-way apply
|
local root with a previous scan, apply uses that scan as the base for a
|
||||||
will extend those records instead of silently applying ambiguous remote changes.
|
three-way base/local/remote check. It can safely accept remote creates, updates,
|
||||||
|
deletes, and renames only when the current local filesystem still matches the
|
||||||
|
base; ambiguous paths remain durable conflicts. The daemon also has durable
|
||||||
|
local file-conflict records with explicit resolution choices.
|
||||||
|
|
||||||
As a bootstrap network path, `geth cas fetch <node-id> <hash>` dials an
|
As a bootstrap network path, `geth cas fetch <node-id> <hash>` dials an
|
||||||
imported signed peer card over the daemon-owned Iroh control ALPN. The serving
|
imported signed peer card over the daemon-owned Iroh control ALPN. The serving
|
||||||
|
|
@ -176,7 +179,7 @@ before returning blob bytes. The requester verifies that the returned bytes hash
|
||||||
to the requested BLAKE3 CAS hash before storing them. Successful fetches update
|
to the requested BLAKE3 CAS hash before storing them. Successful fetches update
|
||||||
durable local provider metadata keyed by CAS hash and peer node, which can be
|
durable local provider metadata keyed by CAS hash and peer node, which can be
|
||||||
inspected through `geth cas providers <hash>`. Iroh-blobs provider/fetch
|
inspected through `geth cas providers <hash>`. Iroh-blobs provider/fetch
|
||||||
integration and richer three-way file application are future work.
|
integration is future work.
|
||||||
|
|
||||||
`geth-db` currently registers local SQLite paths as DB resources and reports
|
`geth-db` currently registers local SQLite paths as DB resources and reports
|
||||||
local-only sync status plus a read-only SQLite schema summary/hash. It also
|
local-only sync status plus a read-only SQLite schema summary/hash. It also
|
||||||
|
|
|
||||||
|
|
@ -40,12 +40,12 @@ Implementation order:
|
||||||
- `[x]` `geth status` reports the current bootstrap backend, target crate,
|
- `[x]` `geth status` reports the current bootstrap backend, target crate,
|
||||||
target version, and blocker for CAS, KV, and pubsub.
|
target version, and blocker for CAS, KV, and pubsub.
|
||||||
|
|
||||||
3. `[ ]` Polish file sync reconciliation.
|
3. `[x]` Polish file sync reconciliation.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
- `[ ]` Add a safe three-way apply path for non-conflicting
|
- `[x]` Add a safe three-way apply path for non-conflicting
|
||||||
create/update/delete/rename changes.
|
create/update/delete/rename changes.
|
||||||
- `[ ]` Keep ambiguous changes as durable conflicts.
|
- `[x]` Keep ambiguous changes as durable conflicts.
|
||||||
- `[ ]` Add two-root integration coverage.
|
- `[x]` Add two-root integration coverage.
|
||||||
|
|
||||||
4. `[ ]` Replace JSON document state with durable Automerge documents.
|
4. `[ ]` Replace JSON document state with durable Automerge documents.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
|
|
@ -108,11 +108,11 @@ Implementation order:
|
||||||
|
|
||||||
- `[~]` File sync reconciliation polish.
|
- `[~]` File sync reconciliation polish.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
- `[ ]` `cas root apply` has a richer three-way base/local/remote reconcile
|
- `[x]` `cas root apply` has a richer three-way base/local/remote reconcile
|
||||||
path for safe updates and deletes.
|
path for safe updates and deletes.
|
||||||
- `[ ]` Ambiguous changes continue to produce durable conflicts instead of
|
- `[x]` Ambiguous changes continue to produce durable conflicts instead of
|
||||||
overwriting local files.
|
overwriting local files.
|
||||||
- `[ ]` Tests cover create/update/delete/rename application across two local
|
- `[x]` Tests cover create/update/delete/rename application across two local
|
||||||
roots.
|
roots.
|
||||||
|
|
||||||
- `[~]` Durable Automerge documents.
|
- `[~]` Durable Automerge documents.
|
||||||
|
|
@ -717,8 +717,12 @@ and future group key evolution.
|
||||||
and directories from a CAS tree without deleting extras or overwriting local
|
and directories from a CAS tree without deleting extras or overwriting local
|
||||||
edits.
|
edits.
|
||||||
- `[x]` Apply records durable conflicts for differing local paths.
|
- `[x]` Apply records durable conflicts for differing local paths.
|
||||||
- `[ ]` Future completion adds a richer three-way apply using base/local/remote
|
- `[x]` `geth cas root apply <root> --to <path>` uses a registered local
|
||||||
trees for automatic safe updates and deletes.
|
root's previous scan as the base for automatic safe creates, updates,
|
||||||
|
deletes, and renames when the current local filesystem still matches that
|
||||||
|
base.
|
||||||
|
- `[x]` Ambiguous three-way changes remain durable conflicts instead of being
|
||||||
|
overwritten.
|
||||||
|
|
||||||
- `[x]` Conflict handling.
|
- `[x]` Conflict handling.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue