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 | + | } |