prikk_store/worktree_patch.rs
1//! Worktree-to-patch authoring.
2//!
3//! Turns worktree changes into a node-addressed, role-bound Ed25519 AUTHOR-signed Patch envelope
4//! against a replay-derived baseline and appends it to the active WAL. The change-detection,
5//! operation-mapping, minting, mode-normalization, and canonical-ordering logic lives in
6//! [`node_authoring`]; signing goes through the injected [`crate::AuthorSigner`] boundary (no
7//! placeholder signer). Existing paths resolve to their persisted `node_id`; fresh nodes are minted
8//! in canonical create order; text edits go through the shared `text_span` module. Rename inference
9//! and symlink authoring remain out of scope.
10
11use prikk_error::{PrikkError, Result};
12use prikk_object::ObjectId;
13
14use crate::author_signing::AuthorSigner;
15use crate::layout::RepositoryLayout;
16use crate::node_id_gen::NodeIdGenerator;
17
18mod node_authoring;
19
20/// Result of authoring and appending a node-addressed patch from worktree changes.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct WorktreePatchCommitReport {
23 /// Baseline ref used to classify changes.
24 pub ref_name: String,
25 /// Patch object ID appended to the active WAL.
26 pub patch_id: ObjectId,
27 /// WAL sequence assigned to the patch envelope.
28 pub wal_sequence: u64,
29 /// Number of patch operations emitted.
30 pub operation_count: usize,
31 /// Number of Blob object references written or reused for operation payloads.
32 pub referenced_blob_count: usize,
33 /// Number of `EditText` operations emitted (text-file modifications).
34 pub text_edit_count: usize,
35 /// Operation summaries in emitted order.
36 pub changes: Vec<WorktreePatchOperationSummary>,
37}
38
39/// Summary of one generated patch operation.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct WorktreePatchOperationSummary {
42 /// Repository-relative path.
43 pub path: String,
44 /// Generated operation kind.
45 pub operation: WorktreePatchOperationKind,
46}
47
48/// Generated operation kind for CLI/reporting.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum WorktreePatchOperationKind {
51 /// A new file will be represented as `CreateFile`.
52 CreateFile,
53 /// A missing tracked file will be represented as `DeleteFile`.
54 DeleteFile,
55 /// A modified tracked file will be represented as `ReplaceBinary`.
56 ReplaceBinary,
57 /// A modified UTF-8 text file is represented as an arbitrary-span `EditText`.
58 EditText,
59 /// A regular file whose normalized mode changed is represented as `ChangePerm`.
60 ChangePerm,
61}
62
63impl WorktreePatchOperationKind {
64 /// Stable CLI label.
65 #[must_use]
66 pub const fn as_str(self) -> &'static str {
67 match self {
68 Self::CreateFile => "create-file",
69 Self::DeleteFile => "delete-file",
70 Self::ReplaceBinary => "replace-binary",
71 Self::EditText => "edit-text",
72 Self::ChangePerm => "change-perm",
73 }
74 }
75}
76
77/// DC-57 default hard block on active (queued, unsealed) patches — NFR-PERF-02's default 1000,
78/// overridable per invocation via `PRIKK_ACTIVE_PATCH_LIMIT` at the CLI boundary, never persisted.
79pub const DEFAULT_ACTIVE_PATCH_LIMIT: usize = 1000;
80
81/// DC-57 (NFR-PERF-02): true when the active WAL already holds `active_patch_limit` or more queued
82/// patches, so appending one more must be refused. "Active patches" has exactly one definition — the
83/// active WAL's record count — and this is the one comparison every authoring path
84/// (`node_authoring.rs::author_inner`, `active.rs::ActiveSession::append_patch`) calls, rather than
85/// each reimplementing it. `>=`, not `>`: once the queue already holds the limit, no more may join it.
86#[must_use]
87pub(crate) const fn active_patch_limit_exceeded(
88 current_count: usize,
89 active_patch_limit: usize,
90) -> bool {
91 current_count >= active_patch_limit
92}
93
94#[cfg(test)]
95mod threshold_tests {
96 use super::active_patch_limit_exceeded;
97
98 /// DC-57 criterion 5: the literal boundary values named in the RFC, tested directly against the
99 /// one shared comparison — this is the pure-arithmetic half of the boundary proof; the
100 /// integration half (proving it is actually wired into `author_inner` before any write, with a
101 /// small scaled limit) lives in `worktree_patch/tests.rs`.
102 #[test]
103 fn boundary_values_match_the_rfc() {
104 assert!(!active_patch_limit_exceeded(799, 800));
105 assert!(active_patch_limit_exceeded(800, 800));
106 assert!(!active_patch_limit_exceeded(999, 1000));
107 assert!(active_patch_limit_exceeded(1000, 1000));
108 assert!(active_patch_limit_exceeded(1001, 1000));
109 }
110}
111
112/// Options for authoring a node-addressed patch from worktree changes.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub struct WorktreePatchCommitOptions {
115 /// Retained for API compatibility. Existing-node `NodeKind` is now authoritative for the
116 /// modified-file mapping (text files author `EditText`, binary files author `ReplaceBinary`),
117 /// so this flag no longer drives kind selection and is a no-op.
118 pub prefer_text_edits: bool,
119 /// DC-57 (NFR-PERF-02): the active-patch count — the active WAL's record count, the one
120 /// definition every authoring path uses — must be strictly less than this before `author_inner`
121 /// does anything else. Defaults to [`DEFAULT_ACTIVE_PATCH_LIMIT`]; the CLI overrides it from
122 /// `PRIKK_ACTIVE_PATCH_LIMIT`, failing closed on a malformed value rather than silently keeping
123 /// the default.
124 pub active_patch_limit: usize,
125}
126
127impl WorktreePatchCommitOptions {
128 /// Default options (kind-driven mapping; `prefer_text_edits` is a no-op, see the field).
129 #[must_use]
130 pub const fn file_level() -> Self {
131 Self {
132 prefer_text_edits: false,
133 active_patch_limit: DEFAULT_ACTIVE_PATCH_LIMIT,
134 }
135 }
136
137 /// Options with `prefer_text_edits` set; retained for API compatibility (no-op, see the field).
138 #[must_use]
139 pub const fn prefer_text_edits() -> Self {
140 Self {
141 prefer_text_edits: true,
142 active_patch_limit: DEFAULT_ACTIVE_PATCH_LIMIT,
143 }
144 }
145
146 /// Override the active-patch hard-block limit (DC-57). The CLI calls this with a value parsed
147 /// from `PRIKK_ACTIVE_PATCH_LIMIT`; everything else keeps the default.
148 #[must_use]
149 pub const fn with_active_patch_limit(mut self, limit: usize) -> Self {
150 self.active_patch_limit = limit;
151 self
152 }
153}
154
155impl Default for WorktreePatchCommitOptions {
156 fn default() -> Self {
157 Self::file_level()
158 }
159}
160
161/// Author a node-addressed patch from worktree changes against the replay-derived baseline, sign it
162/// with a real role-bound Ed25519 AUTHOR signature from the injected `signer`, and append it to the
163/// active WAL (DC-09 Phase 4.4a, R1). Existing paths resolve to their persisted `node_id`; fresh
164/// nodes are minted through the production [`NodeIdGenerator`]; text edits go through the shared
165/// `text_span` module. There is no placeholder signing path.
166pub fn commit_worktree_changes_signed(
167 layout: &RepositoryLayout,
168 ref_name: &str,
169 message: &str,
170 options: WorktreePatchCommitOptions,
171 signer: &impl AuthorSigner,
172) -> Result<WorktreePatchCommitReport> {
173 layout.require_current_format()?;
174 let mut generator = NodeIdGenerator::production();
175 node_authoring::author_worktree_patch(
176 layout,
177 ref_name,
178 message,
179 options,
180 &mut generator,
181 signer,
182 )
183}
184
185/// Test-only entry that injects a deterministic node-id generator and an explicit signer so authoring
186/// is reproducible.
187#[cfg(test)]
188pub(crate) fn commit_worktree_changes_with_generator<S, A>(
189 layout: &RepositoryLayout,
190 ref_name: &str,
191 message: &str,
192 options: WorktreePatchCommitOptions,
193 generator: &mut NodeIdGenerator<S>,
194 signer: &A,
195) -> Result<WorktreePatchCommitReport>
196where
197 S: crate::node_id_gen::NodeIdEntropySource,
198 A: AuthorSigner,
199{
200 node_authoring::author_worktree_patch(layout, ref_name, message, options, generator, signer)
201}
202
203/// Assign a contiguous `op_seq` (1-based) for the operation at `index`. Used by node-addressed
204/// worktree authoring.
205pub(crate) fn next_op_seq(index: usize) -> Result<u32> {
206 let next = index
207 .checked_add(1)
208 .ok_or_else(|| PrikkError::CanonicalEncoding("operation count overflow".to_string()))?;
209 u32::try_from(next)
210 .map_err(|_| PrikkError::CanonicalEncoding("operation count exceeds u32".to_string()))
211}
212
213#[cfg(test)]
214mod tests;