Add local file conflict metadata

This commit is contained in:
Eric Wendland 2026-05-18 03:57:26 +02:00
commit 20c88af800
13 changed files with 673 additions and 15 deletions

View file

@ -121,8 +121,9 @@ Roadmap items should be actionable and checkable:
- The CAS crate can build deterministic tree objects for local file trees and
store those manifests as CAS blobs. The daemon can register and scan local
file roots, reporting create/update/delete/rename changes without writing back
to the working tree. Cross-node file sync and conflict handling are still
roadmap work.
to the working tree. Durable local file-conflict records can be listed and
resolved manually; automatic cross-node conflict detection and file sync are
still roadmap work.
- DB resources can be registered locally and report local-only status plus a
read-only SQLite schema summary/hash and `crsql_changes` metadata when
present. The DB crate and daemon can extract typed read-only `crsql_changes`

2
Cargo.lock generated
View file

@ -1047,6 +1047,7 @@ dependencies = [
"geth-control",
"geth-node",
"geth-store",
"geth-types",
"rusqlite",
"tempfile",
"tokio",
@ -1083,6 +1084,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"geth-cas",
"geth-config",
"geth-control",
"geth-node",

View file

@ -89,6 +89,8 @@ The bootstrap implementation provides:
`cleanup`, `list`
- local CAS tree objects describe file trees and are stored as CAS blobs
- local file-root commands: `geth cas root add/list/scan`
- local file conflict metadata commands:
`geth cas conflict record/list/resolve`
- local DB resource registration: `geth db add <name> <path>` and
`geth db status <name>` with schema and `crsql_changes` metadata; the DB
crate and daemon can extract typed local `crsql_changes` batches through

View file

@ -21,6 +21,12 @@ pub enum CasError {
NonRelativeTreePath(String),
#[error("invalid file root name: {0}")]
InvalidFileRootName(String),
#[error("invalid file conflict kind: {0}")]
InvalidFileConflictKind(String),
#[error("invalid file conflict resolution: {0}")]
InvalidFileConflictResolution(String),
#[error("invalid file conflict status: {0}")]
InvalidFileConflictStatus(String),
}
#[derive(Clone, Debug)]
@ -96,6 +102,108 @@ pub enum FileRootChange {
Renamed { from: String, to: String },
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileConflict {
pub id: String,
pub root: String,
pub resource: String,
pub path: String,
pub kind: FileConflictKind,
pub status: FileConflictStatus,
pub base_tree: Option<BlobHash>,
pub local_tree: Option<BlobHash>,
pub remote_tree: Option<BlobHash>,
pub detail: String,
pub resolution: Option<FileConflictResolution>,
pub resolution_note: Option<String>,
pub created_at_ms: i64,
pub resolved_at_ms: Option<i64>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FileConflictKind {
ConcurrentEdit,
DeleteEdit,
Rename,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FileConflictStatus {
Open,
Resolved,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FileConflictResolution {
KeepLocal,
AcceptRemote,
KeepBoth,
Manual,
}
impl FileConflictKind {
pub fn parse(value: &str) -> Result<Self, CasError> {
match value {
"concurrent-edit" => Ok(Self::ConcurrentEdit),
"delete-edit" => Ok(Self::DeleteEdit),
"rename" => Ok(Self::Rename),
_ => Err(CasError::InvalidFileConflictKind(value.to_owned())),
}
}
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::ConcurrentEdit => "concurrent-edit",
Self::DeleteEdit => "delete-edit",
Self::Rename => "rename",
}
}
}
impl FileConflictResolution {
pub fn parse(value: &str) -> Result<Self, CasError> {
match value {
"keep-local" => Ok(Self::KeepLocal),
"accept-remote" => Ok(Self::AcceptRemote),
"keep-both" => Ok(Self::KeepBoth),
"manual" => Ok(Self::Manual),
_ => Err(CasError::InvalidFileConflictResolution(value.to_owned())),
}
}
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::KeepLocal => "keep-local",
Self::AcceptRemote => "accept-remote",
Self::KeepBoth => "keep-both",
Self::Manual => "manual",
}
}
}
impl FileConflictStatus {
pub fn parse(value: &str) -> Result<Self, CasError> {
match value {
"open" => Ok(Self::Open),
"resolved" => Ok(Self::Resolved),
_ => Err(CasError::InvalidFileConflictStatus(value.to_owned())),
}
}
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Open => "open",
Self::Resolved => "resolved",
}
}
}
impl LocalCas {
#[must_use]
pub fn new(root: impl Into<PathBuf>) -> Self {

View file

@ -12,4 +12,5 @@ serde_json.workspace = true
tokio.workspace = true
geth-config = { path = "../geth-config" }
geth-control = { path = "../geth-control" }
geth-cas = { path = "../geth-cas" }
geth-node = { path = "../geth-node" }

View file

@ -220,6 +220,10 @@ pub enum CasCommand {
#[command(subcommand)]
command: CasRootCommand,
},
Conflict {
#[command(subcommand)]
command: CasConflictCommand,
},
}
#[derive(Debug, Subcommand)]
@ -229,6 +233,33 @@ pub enum CasRootCommand {
Scan { name: String },
}
#[derive(Debug, Subcommand)]
pub enum CasConflictCommand {
Record {
root: String,
path: String,
kind: String,
#[arg(long)]
detail: String,
#[arg(long)]
base_tree: Option<String>,
#[arg(long)]
local_tree: Option<String>,
#[arg(long)]
remote_tree: Option<String>,
},
List {
#[arg(long)]
root: Option<String>,
},
Resolve {
conflict_id: String,
resolution: String,
#[arg(long)]
note: Option<String>,
},
}
#[derive(Debug, Subcommand)]
pub enum KvCommand {
Create {
@ -480,6 +511,35 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
CasRootCommand::List => ControlRequest::CasRootList,
CasRootCommand::Scan { name } => ControlRequest::CasRootScan { name },
},
CasCommand::Conflict { command } => match command {
CasConflictCommand::Record {
root,
path,
kind,
detail,
base_tree,
local_tree,
remote_tree,
} => ControlRequest::CasConflictRecord {
root,
path,
kind,
detail,
base_tree: base_tree.map(Into::into),
local_tree: local_tree.map(Into::into),
remote_tree: remote_tree.map(Into::into),
},
CasConflictCommand::List { root } => ControlRequest::CasConflictList { root },
CasConflictCommand::Resolve {
conflict_id,
resolution,
note,
} => ControlRequest::CasConflictResolve {
conflict_id,
resolution,
note,
},
},
},
Command::Kv { command } => match command {
KvCommand::Create { name } => ControlRequest::KvCreate { name },
@ -744,6 +804,30 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
}
println!("note: {}", scan.note);
}
ControlResponse::CasConflictRecorded { conflict } => {
println!("recorded conflict: {}", conflict.id);
print_file_conflict(&conflict);
}
ControlResponse::CasConflictList { conflicts } => {
if conflicts.is_empty() {
println!("no file conflicts");
} else {
for conflict in conflicts {
println!(
"{}\t{}\t{}\t{}\t{}",
conflict.id,
conflict.root,
conflict.path,
conflict.kind.as_str(),
conflict.status.as_str()
);
}
}
}
ControlResponse::CasConflictResolved { conflict } => {
println!("resolved conflict: {}", conflict.id);
print_file_conflict(&conflict);
}
ControlResponse::KeychainStatus(status) => {
println!("initialized: {}", status.initialized);
println!("admin_keys: {}", status.admin_keys);
@ -1091,6 +1175,30 @@ fn print_service_report(report: ServiceReport, json: bool) -> Result<()> {
Ok(())
}
fn print_file_conflict(conflict: &geth_cas::FileConflict) {
println!("root: {}", conflict.root);
println!("resource: {}", conflict.resource);
println!("path: {}", conflict.path);
println!("kind: {}", conflict.kind.as_str());
println!("status: {}", conflict.status.as_str());
if let Some(hash) = &conflict.base_tree {
println!("base_tree: {hash}");
}
if let Some(hash) = &conflict.local_tree {
println!("local_tree: {hash}");
}
if let Some(hash) = &conflict.remote_tree {
println!("remote_tree: {hash}");
}
println!("detail: {}", conflict.detail);
if let Some(resolution) = &conflict.resolution {
println!("resolution: {}", resolution.as_str());
}
if let Some(note) = &conflict.resolution_note {
println!("resolution_note: {note}");
}
}
fn shell_quote_command(command: &[String]) -> String {
command
.iter()

View file

@ -1,5 +1,5 @@
use geth_auth::{AuthExplanation, AuthOp};
use geth_cas::{FileRoot, FileRootScan};
use geth_cas::{FileConflict, FileRoot, FileRootScan};
use geth_db::{CrSqliteChangeBatch, DbResource};
use geth_document::{DocumentResource, DocumentState};
use geth_keychain::KeychainOp;
@ -56,6 +56,23 @@ pub enum ControlRequest {
CasRootScan {
name: String,
},
CasConflictRecord {
root: String,
path: String,
kind: String,
detail: String,
base_tree: Option<BlobHash>,
local_tree: Option<BlobHash>,
remote_tree: Option<BlobHash>,
},
CasConflictList {
root: Option<String>,
},
CasConflictResolve {
conflict_id: String,
resolution: String,
note: Option<String>,
},
KeychainInit {
admin_key_path: Option<PathBuf>,
},
@ -227,6 +244,15 @@ pub enum ControlResponse {
CasRootScanned {
scan: FileRootScan,
},
CasConflictRecorded {
conflict: FileConflict,
},
CasConflictList {
conflicts: Vec<FileConflict>,
},
CasConflictResolved {
conflict: FileConflict,
},
KeychainStatus(KeychainStatusResponse),
KeychainInitialized {
ops: Vec<KeychainOp>,
@ -472,5 +498,15 @@ mod tests {
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
request
);
let request = ControlRequest::CasConflictResolve {
conflict_id: "file-conflict:notes:1".to_owned(),
resolution: "keep-local".to_owned(),
note: Some("local file is authoritative".to_owned()),
};
assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
request
);
}
}

View file

@ -1,7 +1,10 @@
pub mod service;
use geth_auth::{AuthExplanation, AuthOp, AuthOpKind};
use geth_cas::{BlobInfoSummary, FileRoot, FileRootScan, LocalCas, hash_path};
use geth_cas::{
BlobInfoSummary, FileConflict, FileConflictKind, FileConflictResolution, FileConflictStatus,
FileRoot, FileRootScan, LocalCas, hash_path,
};
use geth_config::{GethConfig, GethPaths, RelayMode};
use geth_control::{
CasBlob, ControlRequest, ControlResponse, KeychainStatusResponse, NodeIdResponse,
@ -23,9 +26,9 @@ use geth_ssh_identity::{
cert_request_id, certificate_id, openssh_krl_spec, revocation_id, ssh_public_key_fingerprint,
};
use geth_store::{
Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileRoot,
StoredKeychainOp, StoredKvEntry, StoredKvStore, StoredResource, StoredResourceSecret,
StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation,
Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileConflict,
StoredFileRoot, StoredKeychainOp, StoredKvEntry, StoredKvStore, StoredResource,
StoredResourceSecret, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation,
};
use geth_types::{
AuthOpId, Capability, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind, ResourceName,
@ -414,6 +417,81 @@ pub fn handle_request(
},
})
}
ControlRequest::CasConflictRecord {
root,
path,
kind,
detail,
base_tree,
local_tree,
remote_tree,
} => {
geth_cas::validate_file_root_name(&root)?;
if let Some(hash) = &base_tree {
geth_cas::validate_hash(hash)?;
}
if let Some(hash) = &local_tree {
geth_cas::validate_hash(hash)?;
}
if let Some(hash) = &remote_tree {
geth_cas::validate_hash(hash)?;
}
let root_record = store
.get_file_root_by_name(&root)?
.ok_or_else(|| NodeError::ResourceNotFound(format!("file-root:{root}")))?;
let kind = FileConflictKind::parse(&kind)?;
let created_at = geth_store::now_ms();
let conflict_id = generated_file_conflict_id(&root, &path, kind.as_str(), created_at);
let conflict = StoredFileConflict {
conflict_id,
root_name: root,
resource_id: root_record.resource_id,
path,
kind: kind.as_str().to_owned(),
status: FileConflictStatus::Open.as_str().to_owned(),
base_tree_hash: base_tree.map(|hash| hash.to_string()),
local_tree_hash: local_tree.map(|hash| hash.to_string()),
remote_tree_hash: remote_tree.map(|hash| hash.to_string()),
detail,
resolution: None,
resolution_note: None,
created_at_ms: created_at,
resolved_at_ms: None,
};
store.upsert_file_conflict(&conflict)?;
Ok(ControlResponse::CasConflictRecorded {
conflict: file_conflict_from_stored(conflict)?,
})
}
ControlRequest::CasConflictList { root } => {
if let Some(root) = root.as_deref() {
geth_cas::validate_file_root_name(root)?;
}
let conflicts = store
.list_file_conflicts(root.as_deref())?
.into_iter()
.map(file_conflict_from_stored)
.collect::<Result<Vec<_>, _>>()?;
Ok(ControlResponse::CasConflictList { conflicts })
}
ControlRequest::CasConflictResolve {
conflict_id,
resolution,
note,
} => {
let resolution = FileConflictResolution::parse(&resolution)?;
let mut conflict = store
.get_file_conflict(&conflict_id)?
.ok_or_else(|| NodeError::ResourceNotFound(conflict_id.clone()))?;
conflict.status = FileConflictStatus::Resolved.as_str().to_owned();
conflict.resolution = Some(resolution.as_str().to_owned());
conflict.resolution_note = note;
conflict.resolved_at_ms = Some(geth_store::now_ms());
store.upsert_file_conflict(&conflict)?;
Ok(ControlResponse::CasConflictResolved {
conflict: file_conflict_from_stored(conflict)?,
})
}
ControlRequest::KeychainInit { admin_key_path } => {
let mut ops = Vec::new();
let created_at = UnixMillis(geth_store::now_ms());
@ -1136,6 +1214,29 @@ fn file_root_from_stored(stored: &StoredFileRoot) -> FileRoot {
}
}
fn file_conflict_from_stored(stored: StoredFileConflict) -> Result<FileConflict, NodeError> {
Ok(FileConflict {
id: stored.conflict_id,
root: stored.root_name,
resource: stored.resource_id,
path: stored.path,
kind: FileConflictKind::parse(&stored.kind)?,
status: FileConflictStatus::parse(&stored.status)?,
base_tree: stored.base_tree_hash.map(Into::into),
local_tree: stored.local_tree_hash.map(Into::into),
remote_tree: stored.remote_tree_hash.map(Into::into),
detail: stored.detail,
resolution: stored
.resolution
.as_deref()
.map(FileConflictResolution::parse)
.transpose()?,
resolution_note: stored.resolution_note,
created_at_ms: stored.created_at_ms,
resolved_at_ms: stored.resolved_at_ms,
})
}
fn ensure_resource_exists(store: &Store, resource_id: &str) -> Result<(), NodeError> {
if store
.list_resources()?
@ -1240,6 +1341,13 @@ fn generated_grant_id(subject: &str, resource: &str, capability: &str) -> String
)
}
fn generated_file_conflict_id(root: &str, path: &str, kind: &str, created_at_ms: i64) -> String {
format!(
"file-conflict:{}",
geth_crypto::blake3_hex(format!("{created_at_ms}\0{root}\0{path}\0{kind}").as_bytes())
)
}
fn generated_auth_op_id(
kind: &str,
resource: &str,

View file

@ -133,6 +133,22 @@ impl Store {
latest_tree_json TEXT,
updated_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS file_conflicts (
conflict_id TEXT PRIMARY KEY,
root_name TEXT NOT NULL,
resource_id TEXT NOT NULL,
path TEXT NOT NULL,
kind TEXT NOT NULL,
status TEXT NOT NULL,
base_tree_hash TEXT,
local_tree_hash TEXT,
remote_tree_hash TEXT,
detail TEXT NOT NULL,
resolution TEXT,
resolution_note TEXT,
created_at_ms INTEGER NOT NULL,
resolved_at_ms INTEGER
);
CREATE TABLE IF NOT EXISTS peer_cards (
peer_id TEXT PRIMARY KEY,
card_json TEXT NOT NULL,
@ -450,6 +466,78 @@ impl Store {
.map_err(StoreError::from)
}
pub fn upsert_file_conflict(&self, conflict: &StoredFileConflict) -> Result<(), StoreError> {
self.conn.execute(
r#"INSERT OR REPLACE INTO file_conflicts(
conflict_id, root_name, resource_id, path, kind, status, base_tree_hash,
local_tree_hash, remote_tree_hash, detail, resolution, resolution_note,
created_at_ms, resolved_at_ms
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)"#,
params![
conflict.conflict_id,
conflict.root_name,
conflict.resource_id,
conflict.path,
conflict.kind,
conflict.status,
conflict.base_tree_hash,
conflict.local_tree_hash,
conflict.remote_tree_hash,
conflict.detail,
conflict.resolution,
conflict.resolution_note,
conflict.created_at_ms,
conflict.resolved_at_ms
],
)?;
Ok(())
}
pub fn get_file_conflict(
&self,
conflict_id: &str,
) -> Result<Option<StoredFileConflict>, StoreError> {
let mut stmt = self.conn.prepare(
r#"SELECT conflict_id, root_name, resource_id, path, kind, status, base_tree_hash,
local_tree_hash, remote_tree_hash, detail, resolution, resolution_note,
created_at_ms, resolved_at_ms
FROM file_conflicts WHERE conflict_id = ?1"#,
)?;
let mut rows = stmt.query(params![conflict_id])?;
if let Some(row) = rows.next()? {
Ok(Some(stored_file_conflict_from_row(row)?))
} else {
Ok(None)
}
}
pub fn list_file_conflicts(
&self,
root_name: Option<&str>,
) -> Result<Vec<StoredFileConflict>, StoreError> {
let sql = if root_name.is_some() {
r#"SELECT conflict_id, root_name, resource_id, path, kind, status, base_tree_hash,
local_tree_hash, remote_tree_hash, detail, resolution, resolution_note,
created_at_ms, resolved_at_ms
FROM file_conflicts WHERE root_name = ?1
ORDER BY status, created_at_ms, conflict_id"#
} else {
r#"SELECT conflict_id, root_name, resource_id, path, kind, status, base_tree_hash,
local_tree_hash, remote_tree_hash, detail, resolution, resolution_note,
created_at_ms, resolved_at_ms
FROM file_conflicts
ORDER BY status, root_name, created_at_ms, conflict_id"#
};
let mut stmt = self.conn.prepare(sql)?;
let rows = if let Some(root_name) = root_name {
stmt.query_map(params![root_name], stored_file_conflict_from_row)?
} else {
stmt.query_map([], stored_file_conflict_from_row)?
};
rows.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
pub fn insert_resource_secret(&self, secret: &StoredResourceSecret) -> Result<(), StoreError> {
self.conn.execute(
r#"INSERT OR REPLACE INTO resource_secrets(
@ -857,6 +945,27 @@ fn stored_ssh_cert_request_from_row(
})
}
fn stored_file_conflict_from_row(
row: &rusqlite::Row<'_>,
) -> Result<StoredFileConflict, rusqlite::Error> {
Ok(StoredFileConflict {
conflict_id: row.get(0)?,
root_name: row.get(1)?,
resource_id: row.get(2)?,
path: row.get(3)?,
kind: row.get(4)?,
status: row.get(5)?,
base_tree_hash: row.get(6)?,
local_tree_hash: row.get(7)?,
remote_tree_hash: row.get(8)?,
detail: row.get(9)?,
resolution: row.get(10)?,
resolution_note: row.get(11)?,
created_at_ms: row.get(12)?,
resolved_at_ms: row.get(13)?,
})
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredResource {
pub resource_id: String,
@ -908,6 +1017,24 @@ pub struct StoredFileRoot {
pub updated_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredFileConflict {
pub conflict_id: String,
pub root_name: String,
pub resource_id: String,
pub path: String,
pub kind: String,
pub status: String,
pub base_tree_hash: Option<String>,
pub local_tree_hash: Option<String>,
pub remote_tree_hash: Option<String>,
pub detail: String,
pub resolution: Option<String>,
pub resolution_note: Option<String>,
pub created_at_ms: i64,
pub resolved_at_ms: Option<i64>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredResourceSecret {
pub secret_id: String,
@ -1204,6 +1331,62 @@ mod tests {
assert_eq!(store.list_file_roots().expect("list roots"), vec![root]);
}
#[test]
fn file_conflicts_roundtrip_and_resolve() {
let store = Store::open_memory().expect("open");
let conflict = StoredFileConflict {
conflict_id: "file-conflict:1".to_owned(),
root_name: "notes".to_owned(),
resource_id: "resource:cas-tree:notes".to_owned(),
path: "notes/todo.md".to_owned(),
kind: "concurrent-edit".to_owned(),
status: "open".to_owned(),
base_tree_hash: None,
local_tree_hash: Some(
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_owned(),
),
remote_tree_hash: Some(
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210".to_owned(),
),
detail: "local and remote edits touched the same path".to_owned(),
resolution: None,
resolution_note: None,
created_at_ms: 10,
resolved_at_ms: None,
};
store
.upsert_file_conflict(&conflict)
.expect("insert conflict");
assert_eq!(
store
.get_file_conflict("file-conflict:1")
.expect("get conflict"),
Some(conflict.clone())
);
assert_eq!(
store
.list_file_conflicts(Some("notes"))
.expect("list by root"),
vec![conflict.clone()]
);
let resolved = StoredFileConflict {
status: "resolved".to_owned(),
resolution: Some("keep-local".to_owned()),
resolution_note: Some("local file is authoritative".to_owned()),
resolved_at_ms: Some(20),
..conflict
};
store
.upsert_file_conflict(&resolved)
.expect("resolve conflict");
assert_eq!(
store.list_file_conflicts(None).expect("list all"),
vec![resolved]
);
}
#[test]
fn kv_store_and_entries_roundtrip() {
let store = Store::open_memory().expect("open");

View file

@ -21,5 +21,6 @@ geth-config = { path = "../geth-config" }
geth-control = { path = "../geth-control" }
geth-node = { path = "../geth-node" }
geth-store = { path = "../geth-store" }
geth-types = { path = "../geth-types" }
rusqlite.workspace = true
tempfile.workspace = true

View file

@ -247,6 +247,106 @@ fn cas_file_root_add_list_scan_detects_changes() {
}
}
#[test]
fn cas_file_conflict_record_list_and_resolve() {
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 root_path = home.path().join("notes-root");
std::fs::create_dir_all(&root_path).expect("create root");
std::fs::write(root_path.join("todo.md"), b"local").expect("write file");
geth_node::handle_request(
&node,
geth_control::ControlRequest::CasRootAdd {
name: "notes".to_owned(),
path: root_path,
},
)
.expect("add root");
let local_tree = match geth_node::handle_request(
&node,
geth_control::ControlRequest::CasRootScan {
name: "notes".to_owned(),
},
)
.expect("scan root")
{
geth_control::ControlResponse::CasRootScanned { scan } => scan.tree.hash,
other => panic!("unexpected response: {other:?}"),
};
let remote_tree = geth_types::BlobHash::new(
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
);
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::CasConflictRecord {
root: "notes".to_owned(),
path: "todo.md".to_owned(),
kind: "concurrent-edit".to_owned(),
detail: "local and remote edits touched todo.md".to_owned(),
base_tree: None,
local_tree: Some(local_tree.clone()),
remote_tree: Some(remote_tree.clone()),
},
)
.expect("record conflict");
let conflict_id = match response {
geth_control::ControlResponse::CasConflictRecorded { conflict } => {
assert_eq!(conflict.root, "notes");
assert_eq!(conflict.path, "todo.md");
assert_eq!(conflict.kind, geth_cas::FileConflictKind::ConcurrentEdit);
assert_eq!(conflict.status, geth_cas::FileConflictStatus::Open);
assert_eq!(conflict.local_tree, Some(local_tree));
assert_eq!(conflict.remote_tree, Some(remote_tree));
conflict.id
}
other => panic!("unexpected response: {other:?}"),
};
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::CasConflictList {
root: Some("notes".to_owned()),
},
)
.expect("list conflicts");
match response {
geth_control::ControlResponse::CasConflictList { conflicts } => {
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].id, conflict_id);
}
other => panic!("unexpected response: {other:?}"),
}
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::CasConflictResolve {
conflict_id: conflict_id.clone(),
resolution: "keep-local".to_owned(),
note: Some("local file is authoritative".to_owned()),
},
)
.expect("resolve conflict");
match response {
geth_control::ControlResponse::CasConflictResolved { conflict } => {
assert_eq!(conflict.id, conflict_id);
assert_eq!(conflict.status, geth_cas::FileConflictStatus::Resolved);
assert_eq!(
conflict.resolution,
Some(geth_cas::FileConflictResolution::KeepLocal)
);
assert_eq!(
conflict.resolution_note,
Some("local file is authoritative".to_owned())
);
assert!(conflict.resolved_at_ms.is_some());
}
other => panic!("unexpected response: {other:?}"),
}
}
#[test]
fn cas_cleanup_removes_unpinned_and_retains_pinned_blobs() {
let home = tempfile::tempdir().expect("tempdir");

View file

@ -94,9 +94,12 @@ blobs. The CAS crate can build deterministic tree objects that describe
directories, files, executable bits, and file blob hashes; those tree objects
are stored as CAS blobs. The daemon can register local file roots and scan them
into CAS tree objects while reporting create/update/delete/rename changes. These
scans are local metadata only and never overwrite the working tree. Iroh-blobs,
providers, encrypted blobs, richer cache policies, cross-node file roots, and
sync conflict handling are future work.
scans are local metadata only and never overwrite the working tree. The daemon
also has durable local file-conflict records with explicit resolution choices;
future cross-node file sync will create those records automatically instead of
silently applying ambiguous remote changes. Iroh-blobs, providers, encrypted
blobs, richer cache policies, cross-node file roots, and automatic conflict
detection are future work.
`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

View file

@ -361,11 +361,16 @@ and future group key evolution.
recorded future sync decision.
- `[ ]` File roots can be synced across nodes.
- `[ ]` Conflict handling.
- `[~]` Conflict handling.
Acceptance criteria:
- Conflicts are represented as durable metadata.
- CLI can list conflicts and choose a resolution.
- Tests cover concurrent edit, delete/edit, and rename conflicts.
- `[x]` Conflicts are represented as durable metadata.
- `[x]` CLI/control can record, list, and choose a resolution for local
conflict metadata.
- `[x]` Tests cover local concurrent edit conflict record/list/resolve.
- `[ ]` Future sync records conflicts automatically from base/local/remote
tree comparisons.
- `[ ]` Tests cover automatic concurrent edit, delete/edit, and rename
conflict detection.
- `[ ]` Keyhive-like convergent capabilities.
Acceptance criteria: