1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
//! Changed-path commit index (DC-56).
//!
//! A rebuildable, non-authoritative cache under `cache_dir()` that lets `commit` skip reading a
//! worktree file's content when its size and mtime match what was last recorded for it —
//! [`CommitIndexEntry::matches_stat`] deliberately excludes mode from the trust condition, since a
//! permission change does not change content; `mode` is carried on the entry as plain bookkeeping,
//! not as a cache-validity input (DC-87 §3.3/§4.3 — it is also `None`-able at the source on a
//! platform with no observable POSIX mode, which the trust condition must stay indifferent to). Per
//! NFR-PERF-04 (`specs/prikk-non-functional-requirements-v1.1.md` §3 traceability row, "Caches are
//! rebuildable and never roots of trust"), this index is never authoritative for object identity: a
//! corrupt or absent file is always treated as an empty index, never a hard error, and any commit's
//! result is identical whether the index is warm, cold, or missing entirely — only the amount of
//! worktree content re-read differs.
//!
//! The validity rule this module implements — when an entry is trusted, what invalidates it, and
//! what bounds how often a rebuild occurs — is specified as a first-class document at
//! `rfcs/handoffs/DC-56-commit-full-tree-scan-compliance/cache-validity-specification-v1.md`. Read
//! that before changing the trust condition in [`CommitIndexEntry::matches_stat`].
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use prikk_error::Result;
use prikk_object::{BlobKind, CanonicalEncode, ObjectId};
use crate::fsutil::{
RootFileStat, read_file_if_exists, stat_file_state_if_exists, write_file_atomically,
};
use crate::layout::RepositoryLayout;
const INDEX_FILE_NAME: &str = "commit-index.v1";
const INDEX_MAGIC: &str = "PRIKK-COMMIT-INDEX-V1";
const FIELD_COUNT: usize = 7;
/// One path's cached content state: the stat triple it was last computed against, and the resulting
/// content-addressed hash under the node kind that stat was read for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CommitIndexEntry {
pub(crate) size: u64,
pub(crate) mtime_secs: i64,
pub(crate) mtime_nanos: u32,
pub(crate) mode: u32,
pub(crate) kind: BlobKind,
pub(crate) content_hash: ObjectId,
}
impl CommitIndexEntry {
/// True when a freshly observed stat exactly matches the state this entry's content hash was
/// computed against — the sole condition under which the hash may be trusted without a read.
/// This is the cache-validity specification's trust condition; see the module docs.
pub(crate) fn matches_stat(&self, stat: &RootFileStat) -> bool {
self.size == stat.size
&& self.mtime_secs == stat.mtime_secs
&& self.mtime_nanos == stat.mtime_nanos
}
}
/// The full changed-path index: repository path -> last-known content state.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct CommitIndex {
entries: BTreeMap<String, CommitIndexEntry>,
}
impl CommitIndex {
/// Load the persisted index, or an empty index if absent or unreadable. A corrupt or
/// version-mismatched file is never a hard error: the index is rebuildable and never
/// authoritative, so falling open to empty (forcing every path to be re-read once) is the
/// correct failure mode, not a repository integrity issue.
pub(crate) fn load(layout: &RepositoryLayout) -> Result<Self> {
let relative = layout.repository_relative(&index_path(layout))?;
let Some(bytes) = read_file_if_exists(layout.repository_mutation_root(), &relative)? else {
return Ok(Self::default());
};
Ok(parse(&bytes).unwrap_or_default())
}
/// Persist the index through a durable atomic replace.
pub(crate) fn save(&self, layout: &RepositoryLayout) -> Result<()> {
let relative = layout.repository_relative(&index_path(layout))?;
write_file_atomically(
layout.repository_mutation_root(),
&relative,
&serialize(self),
)
}
/// Look up a path's cached content state.
pub(crate) fn get(&self, path: &str) -> Option<&CommitIndexEntry> {
self.entries.get(path)
}
/// Record or replace a path's content state.
pub(crate) fn record(&mut self, path: String, entry: CommitIndexEntry) {
self.entries.insert(path, entry);
}
/// Drop every entry whose path is no longer present in the worktree. Deleted or renamed-away
/// paths must not linger: an unbounded index would grow forever, and a stale entry left behind
/// risks a coincidental future stat match if a new, unrelated file is later created at the same
/// path with the same size and mtime.
pub(crate) fn retain_paths(&mut self, live_paths: &BTreeSet<String>) {
self.entries.retain(|path, _| live_paths.contains(path));
}
/// Number of tracked paths. Used by tests and the deletion/rebuild evidence check.
#[cfg(test)]
pub(crate) fn len(&self) -> usize {
self.entries.len()
}
pub(crate) fn entries(&self) -> &BTreeMap<String, CommitIndexEntry> {
&self.entries
}
}
/// One path where the commit-index's recorded content hash disagrees with the worktree's actual
/// current content, despite the entry's stat still matching — the case the cache-validity
/// specification's §5/§6 exists to catch: a stat heuristic that was trusted but wrong (mtime
/// granularity, clock skew, a misbehaving filesystem).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommitIndexDivergence {
/// The repository-relative path with a disagreeing entry.
pub path: String,
/// The content hash the index recorded.
pub recorded_hash: ObjectId,
/// The content hash the file's actual current bytes produce.
pub actual_hash: ObjectId,
}
/// Check every commit-index entry whose recorded stat still matches the worktree against the file's
/// actual current content. An entry whose stat no longer matches is not re-read here: that is the
/// ordinary, expected "edited but not yet committed" case, not divergence — the next `commit` will
/// already re-read it. See the cache-validity specification §6 before changing this check's scope.
pub(crate) fn verify_divergence(layout: &RepositoryLayout) -> Result<Vec<CommitIndexDivergence>> {
let index = CommitIndex::load(layout)?;
let mut divergences = Vec::new();
for (path, entry) in index.entries() {
let relative = Path::new(path);
let Some(stat) = stat_file_state_if_exists(layout.worktree_mutation_root(), relative)?
else {
continue;
};
if !entry.matches_stat(&stat) {
continue;
}
let Some(bytes) = read_file_if_exists(layout.worktree_mutation_root(), relative)? else {
continue;
};
let actual_hash = content_hash(entry.kind, &bytes)?;
if actual_hash != entry.content_hash {
divergences.push(CommitIndexDivergence {
path: path.clone(),
recorded_hash: entry.content_hash,
actual_hash,
});
}
}
Ok(divergences)
}
fn index_path(layout: &RepositoryLayout) -> std::path::PathBuf {
layout.cache_dir().join(INDEX_FILE_NAME)
}
fn serialize(index: &CommitIndex) -> Vec<u8> {
let mut out = String::new();
out.push_str(INDEX_MAGIC);
out.push('\n');
for (path, entry) in &index.entries {
out.push_str(path);
out.push('\t');
out.push_str(&entry.size.to_string());
out.push('\t');
out.push_str(&entry.mtime_secs.to_string());
out.push('\t');
out.push_str(&entry.mtime_nanos.to_string());
out.push('\t');
out.push_str(&entry.mode.to_string());
out.push('\t');
out.push_str(&entry.kind.code().to_string());
out.push('\t');
out.push_str(&entry.content_hash.to_hex());
out.push('\n');
}
out.into_bytes()
}
/// Parse a persisted index. `None` on any structural problem — malformed magic, a line with the
/// wrong field count, or a field that fails to parse — so the caller can fail open to an empty
/// index rather than propagate a corrupt cache as a repository error. Repository paths are
/// ASCII-only with no control characters (`prikk_object::validate_repo_path`), so a plain tab/
/// newline-delimited line format never needs escaping.
fn parse(bytes: &[u8]) -> Option<CommitIndex> {
let text = std::str::from_utf8(bytes).ok()?;
let mut lines = text.lines();
if lines.next()? != INDEX_MAGIC {
return None;
}
let mut entries = BTreeMap::new();
for line in lines {
if line.is_empty() {
continue;
}
let fields: Vec<&str> = line.split('\t').collect();
if fields.len() != FIELD_COUNT {
return None;
}
let path = fields.first()?.to_string();
let size: u64 = fields.get(1)?.parse().ok()?;
let mtime_secs: i64 = fields.get(2)?.parse().ok()?;
let mtime_nanos: u32 = fields.get(3)?.parse().ok()?;
let mode: u32 = fields.get(4)?.parse().ok()?;
let kind_code: u16 = fields.get(5)?.parse().ok()?;
let kind = BlobKind::from_code(kind_code).ok()?;
let content_hash: ObjectId = fields.get(6)?.parse().ok()?;
entries.insert(
path,
CommitIndexEntry {
size,
mtime_secs,
mtime_nanos,
mode,
kind,
content_hash,
},
);
}
Some(CommitIndex { entries })
}
/// Compute the content hash a worktree file's bytes would produce as a blob of the given kind — the
/// same formula `node_authoring` uses for `EditText`/`ReplaceBinary`/`CreateFile` comparison, shared
/// here so the index and the authoring path can never compute two different hashes for the same
/// bytes.
pub(crate) fn content_hash(kind: BlobKind, bytes: &[u8]) -> Result<ObjectId> {
let payload = prikk_object::BlobPayload::new(kind, bytes.to_vec());
let canonical = payload.to_canonical_bytes()?;
Ok(ObjectId::from_canonical_payload(
prikk_object::ObjectType::Blob,
1,
&canonical,
))
}
#[cfg(test)]
mod tests;