use std::collections::BTreeMap;
use std::fmt;
use std::fs;
use prikk_error::{PrikkError, Result};
use prikk_object::{
BlobKind, BlobPayload, CanonicalEncode, ChangePerm, CreateFile, DeleteNode, DeleteNodePreimage,
EditText, NodeId, NodeKind, ObjectEnvelope, ObjectId, ObjectType, Operation, OperationKind,
PatchPayload, PatchPurpose, ReplaceBinary, text_span_hash,
};
use crate::author_signing::AuthorSigner;
use crate::layout::RepositoryLayout;
use crate::lifecycle_cache::replay_derived_state;
use crate::lock::ActiveLock;
use crate::node_id_gen::{NodeIdEntropySource, NodeIdGenerator};
use crate::node_lifecycle::{LiveNode, NodeContent, NodeLifecycleState};
use crate::object_store::{FileObjectStore, ObjectReader, ObjectWriter};
use crate::patch_replay::{WorktreeBaseline, resolve_worktree_baseline};
use crate::path::RepoPath;
use crate::text_span;
use crate::wal::Wal;
use crate::worktree_patch::{
WorktreePatchCommitOptions, WorktreePatchCommitReport, WorktreePatchOperationKind,
WorktreePatchOperationSummary, next_op_seq,
};
const REGULAR_FILE_MODE: u32 = 0o100_644;
const EXECUTABLE_FILE_MODE: u32 = 0o100_755;
#[cfg(unix)]
fn normalize_file_mode(metadata: &fs::Metadata) -> u32 {
use std::os::unix::fs::PermissionsExt;
if metadata.permissions().mode() & 0o111 != 0 {
EXECUTABLE_FILE_MODE
} else {
REGULAR_FILE_MODE
}
}
#[cfg(not(unix))]
fn normalize_file_mode(_metadata: &fs::Metadata) -> u32 {
REGULAR_FILE_MODE
}
struct WorktreeFile {
bytes: Vec<u8>,
mode: u32,
}
#[derive(Debug)]
pub(crate) enum AuthorError {
NodeIdentityUnavailable(String),
UnsupportedKindTransition(String),
UnsupportedSymlinkAuthoring(String),
Mint(crate::node_id_gen::NodeIdMintError),
Store(PrikkError),
}
impl fmt::Display for AuthorError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NodeIdentityUnavailable(detail) => {
write!(f, "worktree authoring: node identity unavailable: {detail}")
}
Self::UnsupportedKindTransition(detail) => {
write!(
f,
"worktree authoring: unsupported kind transition: {detail}"
)
}
Self::UnsupportedSymlinkAuthoring(detail) => {
write!(
f,
"worktree authoring: unsupported symlink authoring: {detail}"
)
}
Self::Mint(e) => write!(f, "worktree authoring: {e}"),
Self::Store(e) => write!(f, "worktree authoring: {e}"),
}
}
}
impl From<AuthorError> for PrikkError {
fn from(e: AuthorError) -> Self {
match e {
AuthorError::Store(inner) => inner,
AuthorError::Mint(inner) => inner.into(),
other => PrikkError::Integrity(other.to_string()),
}
}
}
impl From<PrikkError> for AuthorError {
fn from(e: PrikkError) -> Self {
AuthorError::Store(e)
}
}
struct BaselineFile {
node_id: NodeId,
kind: NodeKind,
blob_id: ObjectId,
mode: u32,
}
struct PlannedOp {
kind: OperationKind,
path: String,
node_id: NodeId,
summary_kind: WorktreePatchOperationKind,
blob_refs: usize,
}
fn kind_rank(kind: &OperationKind) -> u8 {
match kind {
OperationKind::DeleteNode(_) => 0,
OperationKind::CreateFile(_) => 1,
OperationKind::ChangePerm(_) => 2,
OperationKind::ReplaceBinary(_) => 3,
OperationKind::EditText(_) => 4,
OperationKind::CreateSymlink(_) | OperationKind::RenamePath(_) => 5,
}
}
pub(crate) fn author_worktree_patch<S: NodeIdEntropySource, A: AuthorSigner>(
layout: &RepositoryLayout,
ref_name: &str,
message: &str,
_options: WorktreePatchCommitOptions,
generator: &mut NodeIdGenerator<S>,
signer: &A,
) -> Result<WorktreePatchCommitReport> {
if message.trim().is_empty() {
return Err(PrikkError::InvalidName(
"commit message must not be empty".to_string(),
));
}
author_inner(layout, ref_name, message, generator, signer).map_err(PrikkError::from)
}
fn author_inner<S: NodeIdEntropySource, A: AuthorSigner>(
layout: &RepositoryLayout,
ref_name: &str,
_message: &str,
generator: &mut NodeIdGenerator<S>,
signer: &A,
) -> std::result::Result<WorktreePatchCommitReport, AuthorError> {
let _active_lock =
ActiveLock::acquire(layout.default_active_lock_path()).map_err(AuthorError::Store)?;
let object_store = FileObjectStore::new(layout.clone());
let baseline = resolve_worktree_baseline(layout, ref_name)?;
let baseline_state: NodeLifecycleState = match &baseline {
WorktreeBaseline::Published {
baseline_block,
horizon,
} => replay_derived_state(&object_store, *baseline_block, *horizon)?
.state()
.clone(),
WorktreeBaseline::Genesis => {
let replay = Wal::new(layout.default_queue_wal_path())
.replay()
.map_err(AuthorError::Store)?;
if replay.trailing_partial_bytes != 0 {
return Err(AuthorError::Store(PrikkError::InvalidName(
"active WAL has trailing partial bytes on an unpublished ref; \
run `prikk doctor --repair-wal-tail` before committing"
.to_string(),
)));
}
if !replay.records.is_empty() {
return Err(AuthorError::Store(PrikkError::InvalidName(
"active WAL already contains patches on an unpublished ref; \
run `prikk seal` before committing again"
.to_string(),
)));
}
NodeLifecycleState::new()
}
};
let mut baseline_files: BTreeMap<String, BaselineFile> = BTreeMap::new();
let mut baseline_symlinks: BTreeMap<String, NodeId> = BTreeMap::new();
for (node_id, node) in baseline_state.live_nodes() {
match &node.content {
NodeContent::File { blob_id, mode } => {
baseline_files.insert(
node.path.as_str().to_string(),
BaselineFile {
node_id: *node_id,
kind: node.kind,
blob_id: *blob_id,
mode: *mode,
},
);
}
NodeContent::Symlink { .. } => {
baseline_symlinks.insert(node.path.as_str().to_string(), *node_id);
}
}
}
if let WorktreeBaseline::Published { baseline_block, .. } = &baseline {
if baseline_files.is_empty()
&& baseline_symlinks.is_empty()
&& baseline_block_has_snapshot_ref(&object_store, *baseline_block)?
{
return Err(AuthorError::NodeIdentityUnavailable(
"baseline is snapshot-derived and carries no node identity; \
a node-addressed baseline is required for worktree authoring"
.to_string(),
));
}
}
let worktree = enumerate_worktree_files(layout)?;
let mut working_state = baseline_state.clone();
let mut planned: Vec<PlannedOp> = Vec::new();
let mut create_candidates: Vec<(String, Vec<u8>, u32)> = Vec::new();
for (path, file) in &worktree {
let bytes = &file.bytes;
if let Some(base) = baseline_files.get(path) {
match base.kind {
NodeKind::TextFile => {
if std::str::from_utf8(bytes).is_err() {
return Err(AuthorError::UnsupportedKindTransition(format!(
"{path}: existing TextFile cannot accept non-UTF-8 content"
)));
}
let new_blob = text_span::text_blob_id(bytes)?;
if new_blob != base.blob_id {
planned.push(plan_edit_text(&object_store, base, bytes, path)?);
}
}
NodeKind::BinaryFile => {
let new_blob = binary_blob_id(bytes)?;
if new_blob != base.blob_id {
planned.push(plan_replace_binary(&object_store, base, bytes, path)?);
}
}
NodeKind::Symlink => {
return Err(AuthorError::UnsupportedSymlinkAuthoring(format!(
"{path}: symlink node modification is out of scope"
)));
}
}
if file.mode != base.mode {
planned.push(plan_change_perm(base, file.mode, path));
}
} else if baseline_symlinks.contains_key(path) {
return Err(AuthorError::UnsupportedSymlinkAuthoring(format!(
"{path}: symlink node modification is out of scope"
)));
} else {
create_candidates.push((path.clone(), bytes.clone(), file.mode));
}
}
for (path, base) in &baseline_files {
if !worktree.contains_key(path) {
planned.push(plan_delete(base, path));
}
}
for path in baseline_symlinks.keys() {
if !worktree.contains_key(path) {
return Err(AuthorError::UnsupportedSymlinkAuthoring(format!(
"{path}: symlink node deletion is out of scope"
)));
}
}
create_candidates.sort_by(|a, b| a.0.cmp(&b.0));
for (path, bytes, mode) in &create_candidates {
let repo_path = RepoPath::parse(path).map_err(AuthorError::Store)?;
let (blob_kind, node_kind) = classify_new(bytes);
let blob_id = write_content_blob(&object_store, blob_kind, bytes)?;
let node_id = generator
.mint_fresh(&working_state)
.map_err(AuthorError::Mint)?;
working_state
.create_node(
node_id,
LiveNode {
path: repo_path,
kind: node_kind,
content: NodeContent::File {
blob_id,
mode: *mode,
},
},
)
.map_err(AuthorError::Store)?;
planned.push(PlannedOp {
kind: OperationKind::CreateFile(CreateFile {
path: path.clone(),
node_id,
blob_id,
mode: *mode,
}),
path: path.clone(),
node_id,
summary_kind: WorktreePatchOperationKind::CreateFile,
blob_refs: 1,
});
}
if planned.is_empty() {
return Err(AuthorError::Store(PrikkError::InvalidName(
"worktree has no node-addressed changes to commit".to_string(),
)));
}
planned.sort_by(|a, b| {
kind_rank(&a.kind)
.cmp(&kind_rank(&b.kind))
.then_with(|| a.path.as_bytes().cmp(b.path.as_bytes()))
.then_with(|| a.node_id.as_bytes().cmp(b.node_id.as_bytes()))
});
let mut operations = Vec::with_capacity(planned.len());
let mut referenced_blob_count = 0_usize;
let mut text_edit_count = 0_usize;
let mut summaries = Vec::with_capacity(planned.len());
for (index, op) in planned.into_iter().enumerate() {
let op_seq = next_op_seq(index).map_err(AuthorError::Store)?;
referenced_blob_count += op.blob_refs;
if matches!(op.kind, OperationKind::EditText(_)) {
text_edit_count += 1;
}
summaries.push(WorktreePatchOperationSummary {
path: op.path,
operation: op.summary_kind,
});
operations.push(Operation {
op_seq,
op_id: None,
preconditions: Vec::new(),
kind: op.kind,
});
}
let operation_count = operations.len();
let patch_payload = PatchPayload {
operations,
parent_patch_ids: Vec::new(),
intent: None,
preconditions: Vec::new(),
purpose: PatchPurpose::Normal,
};
patch_payload.validate().map_err(AuthorError::Store)?;
let mut patch = ObjectEnvelope::unsigned(
ObjectType::Patch,
1,
patch_payload
.to_canonical_bytes()
.map_err(AuthorError::Store)?,
);
let patch_id = patch.object_id();
let signature =
crate::author_signing::author_signature(signer, patch_id).map_err(AuthorError::Store)?;
patch.add_signature(signature).map_err(AuthorError::Store)?;
let wal = Wal::new(layout.default_queue_wal_path());
let wal_sequence = wal.append_patch(&patch).map_err(AuthorError::Store)?;
Ok(WorktreePatchCommitReport {
ref_name: ref_name.to_string(),
patch_id,
wal_sequence,
operation_count,
referenced_blob_count,
text_edit_count,
changes: summaries,
})
}
fn baseline_block_has_snapshot_ref(
object_store: &FileObjectStore,
baseline_block: ObjectId,
) -> std::result::Result<bool, AuthorError> {
let envelope = object_store
.read_typed(baseline_block, ObjectType::Block)
.map_err(AuthorError::Store)?
.ok_or_else(|| {
AuthorError::Store(PrikkError::Integrity(format!(
"baseline Block {baseline_block} is missing"
)))
})?;
let block = prikk_object::BlockPayload::decode_canonical(&envelope.canonical_payload)
.map_err(AuthorError::Store)?;
Ok(block.snapshot_blob_ref.is_some())
}
fn classify_new(bytes: &[u8]) -> (BlobKind, NodeKind) {
if std::str::from_utf8(bytes).is_ok() {
(BlobKind::Text, NodeKind::TextFile)
} else {
(BlobKind::Binary, NodeKind::BinaryFile)
}
}
fn plan_edit_text(
object_store: &FileObjectStore,
base: &BaselineFile,
new_bytes: &[u8],
path: &str,
) -> std::result::Result<PlannedOp, AuthorError> {
let old_text = read_file_blob_bytes(object_store, base.blob_id)?;
let old_len = old_text.len();
let left = text_span::left_anchor(&old_text, 0);
let right = text_span::right_anchor(&old_text, old_len);
let old_span_hash = text_span_hash(&old_text);
let span_id = text_span::compute_span_id(base.node_id, &old_span_hash, &left, &right, 0);
Ok(PlannedOp {
kind: OperationKind::EditText(EditText {
node_id: base.node_id,
span_id,
old_span_hash,
left_anchor_hash: left,
right_anchor_hash: right,
replacement_text: new_bytes.to_vec(),
presentation_hint_line: None,
presentation_hint_column: None,
old_span_text: old_text,
}),
path: path.to_string(),
node_id: base.node_id,
summary_kind: WorktreePatchOperationKind::EditText,
blob_refs: 0,
})
}
fn plan_replace_binary(
object_store: &FileObjectStore,
base: &BaselineFile,
new_bytes: &[u8],
path: &str,
) -> std::result::Result<PlannedOp, AuthorError> {
let new_blob_id = write_content_blob(object_store, BlobKind::Binary, new_bytes)?;
Ok(PlannedOp {
kind: OperationKind::ReplaceBinary(ReplaceBinary {
node_id: base.node_id,
old_blob_id: base.blob_id,
new_blob_id,
}),
path: path.to_string(),
node_id: base.node_id,
summary_kind: WorktreePatchOperationKind::ReplaceBinary,
blob_refs: 2,
})
}
fn plan_change_perm(base: &BaselineFile, new_mode: u32, path: &str) -> PlannedOp {
PlannedOp {
kind: OperationKind::ChangePerm(ChangePerm {
node_id: base.node_id,
old_mode: base.mode,
new_mode,
}),
path: path.to_string(),
node_id: base.node_id,
summary_kind: WorktreePatchOperationKind::ChangePerm,
blob_refs: 0,
}
}
fn plan_delete(base: &BaselineFile, path: &str) -> PlannedOp {
PlannedOp {
kind: OperationKind::DeleteNode(DeleteNode {
path: path.to_string(),
node_id: base.node_id,
old_node_kind: base.kind,
preimage: DeleteNodePreimage::File {
old_blob_id: base.blob_id,
old_mode: base.mode,
},
}),
path: path.to_string(),
node_id: base.node_id,
summary_kind: WorktreePatchOperationKind::DeleteFile,
blob_refs: 0,
}
}
fn binary_blob_id(bytes: &[u8]) -> std::result::Result<ObjectId, AuthorError> {
let payload = BlobPayload::new(BlobKind::Binary, bytes.to_vec());
let canonical = payload.to_canonical_bytes().map_err(AuthorError::Store)?;
Ok(ObjectId::from_canonical_payload(
ObjectType::Blob,
1,
&canonical,
))
}
fn write_content_blob(
object_store: &FileObjectStore,
kind: BlobKind,
bytes: &[u8],
) -> std::result::Result<ObjectId, AuthorError> {
let payload = BlobPayload::new(kind, bytes.to_vec());
let canonical = payload.to_canonical_bytes().map_err(AuthorError::Store)?;
let envelope = ObjectEnvelope::unsigned(ObjectType::Blob, 1, canonical);
let mut store = object_store.clone();
store.write_object(&envelope).map_err(AuthorError::Store)
}
fn read_file_blob_bytes(
object_store: &FileObjectStore,
blob_id: ObjectId,
) -> std::result::Result<Vec<u8>, AuthorError> {
let envelope = object_store
.read_object(blob_id)
.map_err(AuthorError::Store)?
.ok_or_else(|| {
AuthorError::Store(PrikkError::Integrity(format!(
"baseline content Blob {blob_id} is missing"
)))
})?;
crate::blob_access::decode_file_content_blob(&envelope.canonical_payload)
.map_err(AuthorError::Store)
}
fn enumerate_worktree_files(
layout: &RepositoryLayout,
) -> std::result::Result<BTreeMap<String, WorktreeFile>, AuthorError> {
let mut out = BTreeMap::new();
let root = layout.root().to_path_buf();
walk_dir(&root, &root, &mut out)?;
Ok(out)
}
fn walk_dir(
root: &std::path::Path,
dir: &std::path::Path,
out: &mut BTreeMap<String, WorktreeFile>,
) -> std::result::Result<(), AuthorError> {
let entries =
fs::read_dir(dir).map_err(|e| AuthorError::Store(PrikkError::Io(e.to_string())))?;
for entry in entries {
let entry = entry.map_err(|e| AuthorError::Store(PrikkError::Io(e.to_string())))?;
let file_name = entry.file_name();
if file_name == ".prikk" {
continue;
}
let path = entry.path();
let metadata = fs::symlink_metadata(&path)
.map_err(|e| AuthorError::Store(PrikkError::Io(e.to_string())))?;
let file_type = metadata.file_type();
if file_type.is_symlink() {
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
return Err(AuthorError::UnsupportedSymlinkAuthoring(format!(
"{rel}: worktree symlink authoring is out of scope"
)));
}
if file_type.is_dir() {
walk_dir(root, &path, out)?;
continue;
}
if !file_type.is_file() {
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
return Err(AuthorError::Store(PrikkError::InvalidName(format!(
"{rel}: worktree entry is not a regular file"
))));
}
let relative = path.strip_prefix(root).map_err(|_| {
AuthorError::Store(PrikkError::Integrity(
"worktree path escaped repository root".to_string(),
))
})?;
let rel = relative.to_str().ok_or_else(|| {
AuthorError::Store(PrikkError::InvalidName(format!(
"worktree path is not valid UTF-8: {}",
relative.to_string_lossy()
)))
})?;
let repo_path = RepoPath::parse(rel).map_err(AuthorError::Store)?;
let mode = normalize_file_mode(&metadata);
let bytes =
fs::read(&path).map_err(|e| AuthorError::Store(PrikkError::Io(e.to_string())))?;
out.insert(repo_path.as_str().to_string(), WorktreeFile { bytes, mode });
}
Ok(())
}