OXYGENHUB

Commit: cheaper bookkeeping around a change (refs #41) f9466021286a

- A state index a few changes behind is brought up to date by replaying
  what it lacks, and written again only when it falls 16 changes behind
  or the replay took long; test builds compare the result with a full
  load, by changes and rendered files.
- A commit works out graph keys only for the paths it changed (the rest
  still stand, and any that does not is not trusted by the next
  snapshot), with the attributes the snapshot read rather than another
  walk of the tree; disk_attributes uses the lean lister.
- The manifest carries a BLAKE3 sum and is written without fsync, like
  the state index: a torn or stale one reads as none.
- The snapshot a commit takes always writes the manifest: after a status
  had seen an earlier edit of a file, the commit kept that edit's stat,
  and every status after it took a full snapshot.
- 'status: not clean by the manifest' says why with OXY_TIMINGS=1.

One-file commit on the Linux tree: 0.70 s (from 0.96 s, and the status
after it no longer rebuilds the index: 0.17 s, from 1.47 s).
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-30 22:33 UTC
Signature
✔ signed by Andy McCall
Hash
f9466021286acf2f71279531153458910c82632bd1b8749c3788693a52fc89e2
Depends on
05059d434aca 0bdc3f91a39f 135212de5370 192fad1e46a2 1966f5db15ee 3f1b8fe25ec9 4cce912a0953 4e88e774777a 614541c3cebd 6caacfff1d4f 75167672ebcd 76740263089c 8457a6cc6984 86e1824f2d0b 8f10a07a1dbe 957fee30826e 982864068a59 992467d31d26 cc7a00565161 cdb5beba7f05 ce14f0d68b15 d3cd7ef95da6 da868d5d2646 f9575918477f
Depended on by
c2e1d09047db 5552e69c7325 79b5a90ffd43 236f35c0baba cd4eb55e3b3d e226a6cc2f71 1ec3f008e96a 3604179679eb d4dbab97e948 acadb5ed28a1 e266b7d4065c 638fb21420d7
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
49 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified crates/oxy-core/src/repo/mod.rs +82 −51
…
122 unchanged lines
123 123
    Ok(crate::durable::write_atomic(path, data)?)
124 124
}
125 125
126 +
/// Replace a cache file: whole or not at all, but without waiting for the
127 +
/// disk. A cache checks its own bytes when read, so one a crash leaves
128 +
/// short or stale is rebuilt rather than trusted.
129 +
/// How many changes a state index may fall behind its working copy before
130 +
/// it is written again; until then each load replays them.
131 +
const INDEX_REPLAY_LIMIT: usize = 16;
132 +
const INDEX_REPLAY_TIME: std::time::Duration = std::time::Duration::from_millis(50);
133 +
134 +
pub(crate) fn write_cache(path: &Path, data: &[u8]) -> Result<()> {
135 +
    let tmp = path.with_extension(format!("tmp-{}", std::process::id()));
136 +
    fs::write(&tmp, data)?;
137 +
    fs::rename(&tmp, path)?;
138 +
    Ok(())
139 +
}
140 +
126 141
pub struct LogEntry {
127 142
    pub hash: ChangeHash,
128 143
    /// Metadata from the state; the full change may be absent in a shallow repository.
…
806 unchanged lines
935 950
                std::time::Duration::ZERO,
936 951
            );
937 952
            while let Some(h) = stack.pop() {
938 -
                if !seen.insert(h) {
953 +
                // A change the state holds brings its history with it.
954 +
                if !seen.insert(h) || state.contains(&h) {
939 955
                    continue;
940 956
                }
941 957
                let t0 = std::time::Instant::now();
…
123 unchanged lines
1065 1081
1066 1082
    /// Attributes from the `.oxyattributes` files in the working directory.
1067 1083
    pub fn disk_attributes(&self) -> crate::attributes::Attributes {
1068 -
        let mut files: Vec<(String, Vec<u8>)> = Vec::new();
1069 -
        if let Ok(b) = fs::read(self.root.join(".oxyattributes")) {
1070 -
            files.push((".oxyattributes".into(), b));
1071 -
        }
1072 -
        let mut wb = ignore::WalkBuilder::new(&self.root);
1073 -
        wb.hidden(false)
1074 -
            .git_ignore(false)
1075 -
            .git_global(false)
1076 -
            .git_exclude(false)
1077 -
            .require_git(false)
1078 -
            .follow_links(false)
1079 -
            .add_custom_ignore_filename(".oxyignore");
1080 -
        wb.filter_entry(|e| e.file_name() != OXY_DIR);
1081 -
        for entry in wb.build().flatten() {
1082 -
            if entry.file_name() == ".oxyattributes"
1083 -
                && entry.path() != self.root.join(".oxyattributes")
1084 -
            {
1085 -
                if let Ok(b) = fs::read(entry.path()) {
1086 -
                    let rel: String = entry
1087 -
                        .path()
1088 -
                        .strip_prefix(&self.root)
1089 -
                        .unwrap()
1090 -
                        .components()
1091 -
                        .map(|c| c.as_os_str().to_string_lossy().to_string())
1092 -
                        .collect::<Vec<_>>()
1093 -
                        .join("/");
1094 -
                    files.push((rel, b));
1095 -
                }
1096 -
            }
1097 -
        }
1084 +
        // Nested repositories and ignored paths are left out, as in a walk.
1085 +
        let listing = walk::list(&self.root, &|p: &str| is_attributes(p)).unwrap_or_default();
1086 +
        let files: Vec<(String, Vec<u8>)> = listing
1087 +
            .files
1088 +
            .into_iter()
1089 +
            .filter_map(|(rel, path, _)| Some((rel, fs::read(path).ok()?)))
1090 +
            .collect();
1098 1091
        crate::attributes::Attributes::from_files(
1099 1092
            files.iter().map(|(p, c)| (p.as_str(), c.as_slice())),
1100 1093
        )
…
39 unchanged lines
1140 1133
    /// one of `deps` is in it and their heads are its heads. Otherwise it
1141 1134
    /// is loaded from history and indexed for next time.
1142 1135
    pub fn load_state_indexed(&self, deps: &[ChangeHash]) -> Result<State> {
1143 -
        if let Some(st) = self.read_state_index(deps) {
1144 -
            #[cfg(test)]
1145 -
            assert_eq!(
1146 -
                st.write_index(),
1147 -
                self.load_state_history(deps)?.write_index(),
1148 -
                "a state index holds the state history gives"
1149 -
            );
1150 -
            return Ok(st);
1136 +
        if let Some((st, heads)) = self.read_state_index() {
1137 +
            let mut have = st.maximal(deps);
1138 +
            have.sort();
1139 +
            if deps.iter().all(|d| st.contains(d)) && have == heads {
1140 +
                #[cfg(test)]
1141 +
                assert_eq!(
1142 +
                    st.write_index(),
1143 +
                    self.load_state_history(deps)?.write_index(),
1144 +
                    "a state index holds the state history gives"
1145 +
                );
1146 +
                return Ok(st);
1147 +
            }
1148 +
            // An index a few changes behind (a commit, a pull since): add
1149 +
            // what it lacks, as long as all it holds is history of `deps`.
1150 +
            // Anything amiss (a change beyond a shallow boundary) takes the
1151 +
            // long way instead.
1152 +
            let (indexed, started) = (st.len(), std::time::Instant::now());
1153 +
            if let Ok(st) = self.load_state_replayed(deps, st) {
1154 +
                let mut all = deps.to_vec();
1155 +
                all.extend(heads.iter().copied());
1156 +
                let (mut within, mut want) = (st.maximal(&all), st.maximal(deps));
1157 +
                within.sort();
1158 +
                want.sort();
1159 +
                if within == want {
1160 +
                    #[cfg(test)]
1161 +
                    self.assert_same_state(&st, &self.load_state_history(deps)?);
1162 +
                    // Writing the index costs far more than replaying a
1163 +
                    // few small changes: it is rewritten once it falls
1164 +
                    // behind, or when replaying took long (large changes).
1165 +
                    if st.len() - indexed > INDEX_REPLAY_LIMIT
1166 +
                        || started.elapsed() > INDEX_REPLAY_TIME
1167 +
                    {
1168 +
                        self.write_state_index(deps, &st);
1169 +
                    }
1170 +
                    return Ok(st);
1171 +
                }
1172 +
            }
1151 1173
        }
1152 1174
        let st = self.load_state_history(deps)?;
1153 1175
        self.write_state_index(deps, &st);
1154 1176
        Ok(st)
1177 +
    }
1178 +
1179 +
    /// Two history-only states hold the same changes and render the same
1180 +
    /// files. Their indexes may differ: one loaded from a checkpoint keeps
1181 +
    /// the checkpoint's graphs as the bases of its files' histories.
1182 +
    #[cfg(test)]
1183 +
    fn assert_same_state(&self, a: &State, b: &State) {
1184 +
        let ids = |s: &State| s.changes().map(|(h, _)| *h).collect::<BTreeSet<_>>();
1185 +
        assert_eq!(ids(a), ids(b), "the same changes");
1186 +
        let files = |s: &State| {
1187 +
            let mut s = s.clone();
1188 +
            self.load_all_graphs(&mut s).unwrap();
1189 +
            s.materialize().files
1190 +
        };
1191 +
        assert_eq!(files(a), files(b), "the same files");
1155 1192
    }
1156 1193
1157 1194
    /// The state index file: its heads, a hash of its changes block and one
1158 1195
    /// of the rest, then the body [`State::write_index`] gives, whose first
1159 1196
    /// block is the changes. Hashed in two parts so the log can read and
1160 1197
    /// check the changes alone.
1161 -
    fn read_state_index(&self, deps: &[ChangeHash]) -> Option<State> {
1198 +
    fn read_state_index(&self) -> Option<(State, Vec<ChangeHash>)> {
1162 1199
        let _t = crate::timing::phase("load: state index");
1163 1200
        let bytes = fs::read(self.state_index_path()).ok()?;
1164 1201
        let mut r = crate::flat::Reader::new(&bytes);
…
12 unchanged lines
1177 1214
        if *blake3::hash(changes).as_bytes() != changes_sum.0
1178 1215
            || *h.finalize().as_bytes() != rest_sum.0
1179 1216
        {
1180 -
            return None;
1181 -
        }
1182 -
        let st = State::read_index(body).ok()?;
1183 -
        if !deps.iter().all(|d| st.contains(d)) {
1184 1217
            return None;
1185 1218
        }
1186 -
        let mut have = st.maximal(deps);
1187 -
        have.sort();
1188 -
        (have == heads).then_some(st)
1219 +
        Some((State::read_index(body).ok()?, heads))
1189 1220
    }
1190 1221
1191 1222
    /// The changes of this workspace's state index (the closure of its
…
25 unchanged lines
1217 1248
    /// state index; quietly nothing when it cannot be (it is only a cache).
1218 1249
    pub(crate) fn write_state_index(&self, deps: &[ChangeHash], st: &State) {
1219 1250
        let _t = crate::timing::phase("write: state index");
1251 +
        let encoding = crate::timing::phase("write:   of which encoding");
1220 1252
        let Some(body) = st.write_index() else {
1221 1253
            return;
1222 1254
        };
1255 +
        drop(encoding);
1223 1256
        #[cfg(test)]
1224 1257
        assert_eq!(
1225 1258
            State::read_index(&body).ok().and_then(|s| s.write_index()),
…
14 unchanged lines
1240 1273
        w.fixed(sum.finalize().as_bytes());
1241 1274
        w.fixed(&body);
1242 1275
        let path = self.state_index_path();
1243 -
        let tmp = path.with_extension("tmp");
1244 -
        let _ = fs::create_dir_all(self.oxy.join("index"))
1245 -
            .and_then(|_| fs::write(&tmp, &w.0))
1246 -
            .and_then(|_| fs::rename(&tmp, &path));
1276 +
        let _ = fs::create_dir_all(self.oxy.join("index"));
1277 +
        let _ = write_cache(&path, &w.0);
1247 1278
    }
1248 1279
1249 1280
    fn locks_dir(&self) -> PathBuf {
…
645 unchanged lines
modified crates/oxy-core/src/repo/prompt.rs +40 −14
…
168 unchanged lines
169 169
/// The manifest file (oxygen#41): a flat binary layout read in one pass.
170 170
/// A magic number; the conflicted and uncommitted flags; when it was
171 171
/// written, at a fixed offset so an otherwise equal manifest can be
172 -
/// recognised; then each entry in path order: path, size, mtime in
173 -
/// nanoseconds, BLAKE3 of the content, the stored blob of an opaque file,
174 -
/// the graph key it was found equal to, and whether it is executable. A
175 -
/// file in any other layout (an older text manifest) reads as no manifest,
176 -
/// and the next snapshot writes it again.
177 -
const MANIFEST_MAGIC: &[u8; 8] = b"oxyman1\0";
172 +
/// recognised; BLAKE3 of everything else; then each entry in path order:
173 +
/// path, size, mtime in nanoseconds, BLAKE3 of the content, the stored
174 +
/// blob of an opaque file, the graph key it was found equal to, and
175 +
/// whether it is executable. A file that does not check, or in any other
176 +
/// layout (an older manifest), reads as no manifest, and the next snapshot
177 +
/// writes it again: it is a cache, written without waiting for the disk.
178 +
const MANIFEST_MAGIC: &[u8; 8] = b"oxyman2\0";
178 179
/// Where the written time sits: after the magic and the two flags.
179 180
const MANIFEST_WRITTEN: std::ops::Range<usize> = 10..26;
181 +
/// Where the checksum sits, after the written time.
182 +
const MANIFEST_SUM: std::ops::Range<usize> = 26..58;
180 183
184 +
fn manifest_sum(buf: &[u8]) -> [u8; 32] {
185 +
    let mut h = blake3::Hasher::new();
186 +
    h.update(&buf[..MANIFEST_SUM.start]);
187 +
    h.update_rayon(&buf[MANIFEST_SUM.end..]);
188 +
    *h.finalize().as_bytes()
189 +
}
190 +
181 191
fn encode_manifest(
182 192
    conflicted: bool,
183 193
    uncommitted: bool,
…
5 unchanged lines
189 199
    w.u8(conflicted as u8);
190 200
    w.u8(uncommitted as u8);
191 201
    w.fixed(&written_ns.to_le_bytes());
202 +
    w.fixed(&[0; 32]);
192 203
    for (path, e) in entries {
193 204
        w.bytes(path.as_bytes());
194 205
        w.u64(e.size);
…
9 unchanged lines
204 215
        }
205 216
        w.u8(e.exec as u8);
206 217
    }
218 +
    let sum = manifest_sum(&w.0);
219 +
    w.0[MANIFEST_SUM].copy_from_slice(&sum);
207 220
    w.0
208 221
}
209 222
…
5 unchanged lines
215 228
    let conflicted = r.u8().ok()? == 1;
216 229
    let uncommitted = r.u8().ok()? == 1;
217 230
    let written_ns = u128::from_le_bytes(r.fixed().ok()?);
231 +
    if r.fixed::<32>().ok()? != manifest_sum(buf) {
232 +
        return None;
233 +
    }
218 234
    let mut entries = Vec::new();
219 235
    while !r.at_end() {
220 236
        let path = std::str::from_utf8(r.bytes().ok()?).ok()?.to_string();
…
83 unchanged lines
304 320
        // change nothing but the file, which editors watching the tree
305 321
        // (and subrepos inside it) take as a change.
306 322
        if let Ok(old) = fs::read(&p) {
307 -
            let w = MANIFEST_WRITTEN;
323 +
            let (w, sum) = (MANIFEST_WRITTEN, MANIFEST_SUM);
308 324
            if old.len() == new.len()
309 325
                && old[..w.start] == new[..w.start]
310 -
                && old[w.end..] == new[w.end..]
326 +
                && old[sum.end..] == new[sum.end..]
311 327
            {
312 328
                let old_written = u128::from_le_bytes(old[w.clone()].try_into().unwrap());
313 329
                if newest_ns + 2_000_000_000 < old_written {
…
2 unchanged lines
316 332
            }
317 333
        }
318 334
        fs::create_dir_all(p.parent().unwrap())?;
319 -
        write_atomic(&p, &new)
335 +
        write_cache(&p, &new)
320 336
    }
321 337
322 338
    /// Change the header flags, and set the graph key of each path in
…
18 unchanged lines
341 357
            }
342 358
            (path.as_str(), e)
343 359
        });
344 -
        write_atomic(
360 +
        write_cache(
345 361
            &p,
346 362
            &encode_manifest(conflicted, uncommitted, m.written_ns, &mut rows),
347 363
        )
…
54 unchanged lines
402 418
    /// by hashing the file. `false` means only that a snapshot must decide.
403 419
    pub fn clean_by_manifest(&self) -> Result<bool> {
404 420
        let _t = crate::timing::phase("status: check the manifest");
421 +
        // Why a snapshot must decide, with timings on.
422 +
        let not = |why: &str| {
423 +
            if crate::timing::enabled() {
424 +
                eprintln!("timing status: not clean by the manifest: {why}");
425 +
            }
426 +
            Ok(false)
427 +
        };
405 428
        let Some((conflicted, uncommitted, m)) = self.read_manifest()? else {
406 -
            return Ok(false);
429 +
            return not("no manifest");
407 430
        };
408 431
        if conflicted || uncommitted || self.root.join(".oxymodules").exists() {
409 -
            return Ok(false);
432 +
            return not("conflicted, uncommitted or subrepos");
410 433
        }
411 434
        for (rel, e) in &m.entries {
412 435
            if e.mtime_ns + 2_000_000_000 < m.written_ns {
…
11 unchanged lines
424 447
                _ => false,
425 448
            };
426 449
            if !same {
427 -
                return Ok(false);
450 +
                return not(&format!("{rel} changed within the racy window"));
428 451
            }
452 +
        }
453 +
        if self.quick_dirty_keeping(true)? != Some(false) {
454 +
            return not("a file differs");
429 455
        }
430 -
        Ok(self.quick_dirty_keeping(true)? == Some(false))
456 +
        Ok(true)
431 457
    }
432 458
433 459
    /// Whether the working copy differs from the last manifest. Without a
…
156 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +58 −18
…
56 unchanged lines
57 57
    pub resolves: bool,
58 58
    /// Changes that contributed to the conflicts being resolved.
59 59
    pub contributors: Vec<ChangeHash>,
60 +
    /// The `.oxyattributes` in force on disk, from the files the walk read.
61 +
    pub(crate) attributes: crate::attributes::Attributes,
60 62
}
61 63
62 64
pub fn is_binary(content: &[u8]) -> bool {
…
212 unchanged lines
275 277
    /// Snapshot the working copy into the working-copy change (spec §12.3).
276 278
    /// The result carries the full base state and its whole rendering.
277 279
    pub fn snapshot(&self, command: &str) -> Result<SnapshotResult> {
278 -
        self.snapshot_as(command, false)
280 +
        self.snapshot_as(command, false, false)
279 281
    }
280 282
281 283
    /// Snapshot the working copy as [`Repo::snapshot`] does, for commands
…
5 unchanged lines
287 289
    /// `unrendered` with empty bytes. They are never conflicted, and no
288 290
    /// change the working copy makes touches them.
289 291
    pub fn snapshot_status(&self, command: &str) -> Result<SnapshotResult> {
290 -
        self.snapshot_as(command, true)
292 +
        self.snapshot_as(command, true, false)
291 293
    }
292 294
293 -
    fn snapshot_as(&self, command: &str, partial: bool) -> Result<SnapshotResult> {
295 +
    /// `snapshot_as`; `force_manifest` writes the manifest even where a
296 +
    /// status would not bother (an edited file is read next time whatever
297 +
    /// it says), for a commit, after which that file is unchanged.
298 +
    fn snapshot_as(
299 +
        &self,
300 +
        command: &str,
301 +
        partial: bool,
302 +
        force_manifest: bool,
303 +
    ) -> Result<SnapshotResult> {
294 304
        let _lock = self.lock_snapshot()?;
295 305
        let batch = self.store.begin_batch()?;
296 -
        let result = self.snapshot_locked(command, partial);
306 +
        let result = self.snapshot_locked(command, partial, force_manifest);
297 307
        batch.finish()?;
298 308
        result
299 309
    }
300 310
301 -
    fn snapshot_locked(&self, command: &str, partial: bool) -> Result<SnapshotResult> {
311 +
    fn snapshot_locked(
312 +
        &self,
313 +
        command: &str,
314 +
        partial: bool,
315 +
        force_manifest: bool,
316 +
    ) -> Result<SnapshotResult> {
302 317
        let _all = crate::timing::phase("snapshot");
303 318
        let (op_ref, op) = self.head_op()?;
304 319
        let (old_hash, old_wc) = self.working_copy()?;
…
144 unchanged lines
449 464
        // (it need not be read again), a new blob, a path added or gone.
450 465
        // An edited file is read next time whatever it says.
451 466
        let worth = match self.read_manifest()? {
467 +
            _ if force_manifest => true,
452 468
            None => true,
453 469
            Some((c, u, m)) => {
454 470
                c != conflicted
…
11 unchanged lines
466 482
        if worth {
467 483
            self.write_manifest_entries(conflicted, uncommitted, &fs_entries, &built.blobs)?;
468 484
        }
485 +
        let attributes = crate::attributes::Attributes::from_files(
486 +
            fs_entries
487 +
                .iter()
488 +
                .filter(|(p, _)| is_attributes(p))
489 +
                .map(|(p, e)| (p.as_str(), e.content.as_slice())),
490 +
        );
469 491
        Ok(SnapshotResult {
470 492
            wc_hash: new_hash,
471 493
            wc,
…
3 unchanged lines
475 497
            changed,
476 498
            resolves,
477 499
            contributors,
500 +
            attributes,
478 501
        })
479 502
    }
480 503
…
116 unchanged lines
597 620
    /// stays on disk as uncommitted work: the next snapshot rebuilds it
598 621
    /// against the new base, so the working copy always describes the
599 622
    /// directory.
600 -
    /// The graph key of each text file of the state at `heads`, by path,
601 -
    /// from a history-only load that builds no graph. Files with an `eol`
602 -
    /// attribute are left out: their bytes on disk are not the rendering.
603 -
    fn committed_graph_keys(&self, st: &State) -> HashMap<String, [u8; 32]> {
623 +
    /// The graph key in `st` of each text file at `paths`, the paths a
624 +
    /// commit changed: only their graphs changed, so the keys the manifest
625 +
    /// holds for the others still stand (and any that does not is simply
626 +
    /// not trusted by the next snapshot, which compares keys). Files with
627 +
    /// an `eol` attribute are left out: their bytes on disk are not the
628 +
    /// rendering.
629 +
    fn committed_graph_keys(
630 +
        &self,
631 +
        st: &State,
632 +
        paths: &[String],
633 +
        attrs: &crate::attributes::Attributes,
634 +
    ) -> HashMap<String, [u8; 32]> {
604 635
        let _t = crate::timing::phase("commit: graph keys");
605 -
        let attrs = self.disk_attributes();
606 -
        st.tree()
607 -
            .view()
608 -
            .entries
636 +
        let view = st.tree().view();
637 +
        paths
609 638
            .iter()
610 -
            .filter(|(p, e)| {
611 -
                e.kind == NodeKind::File && !e.mode.opaque && attrs.lookup(p).eol.is_none()
639 +
            .filter_map(|p| {
640 +
                let e = view.entries.get(p)?;
641 +
                (e.kind == NodeKind::File && !e.mode.opaque && attrs.lookup(p).eol.is_none())
642 +
                    .then_some(())?;
643 +
                Some((p.clone(), st.graph_key(&e.node)?))
612 644
            })
613 -
            .filter_map(|(p, e)| Some((p.clone(), st.graph_key(&e.node)?)))
614 645
            .collect()
615 646
    }
616 647
…
4 unchanged lines
621 652
        selection: &Selection,
622 653
    ) -> Result<ChangeHash> {
623 654
        let _wc = self.lock_working_copy()?;
624 -
        let mut snap = self.snapshot_status("oxy commit")?;
655 +
        let mut snap = self.snapshot_as("oxy commit", true, true)?;
625 656
        if snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty() {
626 657
            return fail("nothing to commit: the working copy matches its base");
627 658
        }
…
11 unchanged lines
639 670
        // takes its operations over (they are moved, not copied: for a
640 671
        // large tree they are most of the memory).
641 672
        let wc_refs = referenced_changes(&snap.wc, &snap.base);
673 +
        let changed_paths: Vec<String> = snap.statuses.iter().map(|s| s.path.clone()).collect();
642 674
        let (tree_ops, edits, partial) = match selection {
643 675
            Selection::All => (
644 676
                std::mem::take(&mut snap.wc.tree_ops),
…
103 unchanged lines
748 780
        }
749 781
        change.deps = deps.into_iter().collect();
750 782
        change.normalize();
783 +
        let signing = crate::timing::phase("commit: sign and store");
751 784
        let hash = self.store_signed(&change)?;
785 +
        drop(signing);
786 +
        let extending = crate::timing::phase("commit: extend the state");
752 787
753 788
        // New bookmark heads: maximal elements of old heads plus the new change.
754 789
        // The working copy continues on its own base plus the new change, which
…
29 unchanged lines
784 819
        let mut wc_base = old_base.clone();
785 820
        wc_base.push(hash);
786 821
        let wc_base = st.maximal(&wc_base);
822 +
        drop(extending);
787 823
788 824
        // The graph keys of the files as they now are, from the state the
789 825
        // commit just extended: a file whose graph it built has none, and
790 826
        // is read by the next snapshot; a key the next base does not share
791 827
        // (the bookmark held other heads) is simply not trusted then.
792 -
        let committed_keys = (!partial).then(|| self.committed_graph_keys(&st));
828 +
        let committed_keys =
829 +
            (!partial).then(|| self.committed_graph_keys(&st, &changed_paths, &snap.attributes));
830 +
        let recording = crate::timing::phase("commit: record");
793 831
        let wc = self.new_working_copy(wc_base, "");
794 832
        let wc_hash = self.store.put_change(&wc)?;
795 833
        let mut view = op.view.clone();
…
3 unchanged lines
799 837
        );
800 838
        view.workspaces.insert(self.workspace, wc_hash);
801 839
        self.record_op(&[op_ref], &format!("oxy commit -m {message:?}"), view)?;
840 +
        drop(recording);
841 +
        let _rewrite = crate::timing::phase("commit: manifest and hooks");
802 842
        if partial {
803 843
            // The rest is rebuilt from disk by the next snapshot.
804 844
            self.drop_manifest();
…
1746 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +58 −0
…
6287 unchanged lines
6288 6288
    assert_eq!(again.store().get_change_meta(&h).unwrap(), meta);
6289 6289
    assert_eq!(fs::metadata(&cache).unwrap().len(), len);
6290 6290
}
6291 +
6292 +
#[test]
6293 +
fn a_manifest_that_does_not_check_reads_as_none() {
6294 +
    hermetic();
6295 +
    let dir = tempfile::tempdir().unwrap();
6296 +
    let root = dir.path();
6297 +
    let repo = Repo::init(root).unwrap();
6298 +
    write(root, "a.txt", b"1\n");
6299 +
    repo.commit("first", &[]).unwrap();
6300 +
    let path = repo.manifest_path();
6301 +
    let mut bytes = fs::read(&path).unwrap();
6302 +
    assert!(Repo::open(root).unwrap().read_manifest().unwrap().is_some());
6303 +
    let last = bytes.len() - 1;
6304 +
    bytes[last] ^= 1;
6305 +
    fs::write(&path, &bytes).unwrap();
6306 +
    assert!(Repo::open(root).unwrap().read_manifest().unwrap().is_none());
6307 +
    // The next status reads the files and writes it again.
6308 +
    let again = Repo::open(root).unwrap();
6309 +
    assert!(!again.snapshot("status").unwrap().changed);
6310 +
    assert!(again.read_manifest().unwrap().is_some());
6311 +
}
6312 +
6313 +
#[test]
6314 +
fn a_commit_leaves_a_manifest_that_proves_the_working_copy_clean() {
6315 +
    hermetic();
6316 +
    let dir = tempfile::tempdir().unwrap();
6317 +
    let root = dir.path();
6318 +
    let repo = Repo::init(root).unwrap();
6319 +
    write(root, "a.txt", b"1\n");
6320 +
    write(root, "b.txt", b"2\n");
6321 +
    // b.txt outside the racy window, so snapshots take it as unchanged.
6322 +
    let old = std::time::SystemTime::now() - std::time::Duration::from_secs(60);
6323 +
    fs::File::options()
6324 +
        .write(true)
6325 +
        .open(root.join("b.txt"))
6326 +
        .unwrap()
6327 +
        .set_modified(old)
6328 +
        .unwrap();
6329 +
    repo.commit("first", &[]).unwrap();
6330 +
    assert!(repo.clean_by_manifest().unwrap(), "after the first commit");
6331 +
    repo.snapshot_status("status").unwrap();
6332 +
    write(root, "a.txt", b"1\n2\n");
6333 +
    repo.commit("second", &[]).unwrap();
6334 +
    assert!(
6335 +
        repo.clean_by_manifest().unwrap(),
6336 +
        "after a commit of one file"
6337 +
    );
6338 +
    // Edited, seen by a status, edited again, then committed: the status
6339 +
    // left the first edit's stat, which the commit must not keep.
6340 +
    write(root, "a.txt", b"1\n2\n3\n");
6341 +
    repo.snapshot_status("status").unwrap();
6342 +
    write(root, "a.txt", b"1\n2\n3\n4\n5\n");
6343 +
    repo.commit("third", &[]).unwrap();
6344 +
    assert!(
6345 +
        repo.clean_by_manifest().unwrap(),
6346 +
        "after an edit a status saw"
6347 +
    );
6348 +
}