Replace conflicts are resolved by keeping a candidate f7510b63cceb
A change that writes an opaque file with a replace conflict now settles it when the caller names the path: one Replace from each live blob to the chosen one, depending on every change that made a candidate live, so all are superseded and one blob remains (a resolution). Hosted edits (the hub's web edits and resolver) name the paths the user writes. A plain snapshot still never settles one, since a checkout writes a candidate at the path. Spec §9.3, Draft 0.8m. closes #21
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 05:22 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
f7510b63cceb5e4e237fe11b3a467a4583b1f0276fedfd18e2f7a25e27bf6356- Depends on
- 62794ecc7f74 6e723b81f5b9 75167672ebcd 84b9e9ac0ab4 8ab265dc4a51 982864068a59 b7f102fcbb17 cdb5beba7f05 eae595568832 f8c695764c95 ff0af1ee299d
- Depended on by
- 157d02e95586 f2f91ceb0e9f 23ff1288a5dd dfb23a3a8cfa 83d61e803ccd
- 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.1.0 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
- 12 atoms, 0 tree operations, 4 files
Browse the tree at this change
modified crates/oxy-core/src/repo/hosted.rs +14 −1
| … | 96 unchanged lines |
||
| 97 | 97 | } |
|
| 98 | 98 | } |
|
| 99 | 99 | } |
|
| 100 | - | let built = self.build_from_entries(&base, &base_snapshot, HashMap::new(), &entries)?; |
|
| 100 | + | // A path the user writes settles its replace conflict, if any: they |
|
| 101 | + | // chose that content (spec §9.3). |
|
| 102 | + | let written: std::collections::HashSet<String> = edits |
|
| 103 | + | .iter() |
|
| 104 | + | .filter(|e| e.content.is_some()) |
|
| 105 | + | .map(|e| e.path.trim_matches('/').to_string()) |
|
| 106 | + | .collect(); |
|
| 107 | + | let built = self.build_from_entries_resolving( |
|
| 108 | + | &base, |
|
| 109 | + | &base_snapshot, |
|
| 110 | + | HashMap::new(), |
|
| 111 | + | &entries, |
|
| 112 | + | &written, |
|
| 113 | + | )?; |
|
| 101 | 114 | if let Some(s) = built |
|
| 102 | 115 | .statuses |
|
| 103 | 116 | .iter() |
|
| … | 464 unchanged lines |
modified crates/oxy-core/src/repo/snapshot.rs +41 −0
| … | 109 unchanged lines |
||
| 110 | 110 | reuse: HashMap<String, (NodeId, NodeKind)>, |
|
| 111 | 111 | fs_entries: &BTreeMap<String, FsEntry>, |
|
| 112 | 112 | ) -> Result<BuiltChange> { |
|
| 113 | + | self.build_from_entries_resolving(base, base_snapshot, reuse, fs_entries, &HashSet::new()) |
|
| 114 | + | } |
|
| 115 | + | ||
| 116 | + | /// Like `build_from_entries`, settling the replace conflicts of the |
|
| 117 | + | /// opaque files in `resolve` to their content (spec §9.3, §9.6): a |
|
| 118 | + | /// caller that knows the user chose it, such as the hub's resolver. A |
|
| 119 | + | /// snapshot never settles one on its own, since a checkout writes one |
|
| 120 | + | /// candidate at the path. |
|
| 121 | + | pub(crate) fn build_from_entries_resolving( |
|
| 122 | + | &self, |
|
| 123 | + | base: &State, |
|
| 124 | + | base_snapshot: &Snapshot, |
|
| 125 | + | reuse: HashMap<String, (NodeId, NodeKind)>, |
|
| 126 | + | fs_entries: &BTreeMap<String, FsEntry>, |
|
| 127 | + | resolve: &HashSet<String>, |
|
| 128 | + | ) -> Result<BuiltChange> { |
|
| 113 | 129 | // Attributes come from the files being committed, so a new |
|
| 114 | 130 | // `.oxyattributes` applies to the same change that adds it. |
|
| 115 | 131 | let attrs = crate::attributes::Attributes::from_files( |
|
| … | 3 unchanged lines |
||
| 119 | 135 | .map(|(p, e)| (p.as_str(), e.content.as_slice())), |
|
| 120 | 136 | ); |
|
| 121 | 137 | let mut b = ChangeBuilder::new(base, base_snapshot, reuse).with_attributes(attrs); |
|
| 138 | + | b.resolve = resolve.clone(); |
|
| 122 | 139 | // Rename detection (spec §7.1, §23): a removed file and an added file |
|
| 123 | 140 | // of the same kind whose contents are at least half alike become one |
|
| 124 | 141 | // move of the same node, so identity survives across the rename. |
|
| … | 1289 unchanged lines |
||
| 1414 | 1431 | resolves: bool, |
|
| 1415 | 1432 | attrs: crate::attributes::Attributes, |
|
| 1416 | 1433 | blobs: HashMap<String, ObjectRef>, |
|
| 1434 | + | /// Opaque files whose replace conflict the content settles. |
|
| 1435 | + | resolve: HashSet<String>, |
|
| 1417 | 1436 | } |
|
| 1418 | 1437 | ||
| 1419 | 1438 | impl<'a> ChangeBuilder<'a> { |
|
| … | 21 unchanged lines |
||
| 1441 | 1460 | resolves: false, |
|
| 1442 | 1461 | attrs: crate::attributes::Attributes::default(), |
|
| 1443 | 1462 | blobs: HashMap::new(), |
|
| 1463 | + | resolve: HashSet::new(), |
|
| 1444 | 1464 | } |
|
| 1445 | 1465 | } |
|
| 1446 | 1466 | ||
| … | 319 unchanged lines |
||
| 1766 | 1786 | if e.kind == NodeKind::File && wants_opaque { |
|
| 1767 | 1787 | // Unchanged or replaced, the file stays opaque: remember its blob. |
|
| 1768 | 1788 | self.blobs.insert(path.to_string(), new_ref); |
|
| 1789 | + | } |
|
| 1790 | + | if blobs.len() > 1 && self.resolve.contains(path) && e.kind == expected.kind { |
|
| 1791 | + | // Settle the replace conflict (spec §9.3): replace every |
|
| 1792 | + | // live candidate with the chosen blob, depending on each |
|
| 1793 | + | // change that made one live, so all are superseded. |
|
| 1794 | + | let chosen = self.blob_of(repo, path, e, nocompress)?; |
|
| 1795 | + | for b in blobs { |
|
| 1796 | + | self.edits.push(Edit::Replace { |
|
| 1797 | + | node, |
|
| 1798 | + | old: Some(*b), |
|
| 1799 | + | new: Some(chosen), |
|
| 1800 | + | }); |
|
| 1801 | + | } |
|
| 1802 | + | self.contributors |
|
| 1803 | + | .extend(self.base.blob_contributors(&node, blobs)); |
|
| 1804 | + | self.resolves = true; |
|
| 1805 | + | self.statuses.push(PathStatus { |
|
| 1806 | + | path: path.to_string(), |
|
| 1807 | + | status: ChangeStatus::Resolved, |
|
| 1808 | + | }); |
|
| 1809 | + | return Ok(()); |
|
| 1769 | 1810 | } |
|
| 1770 | 1811 | if blobs.len() > 1 { |
|
| 1771 | 1812 | if !blobs.contains(&new_ref) { |
|
| … | 417 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +82 −0
| … | 5252 unchanged lines |
||
| 5253 | 5253 | assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\nTWO\nthree\n"); |
|
| 5254 | 5254 | fs::set_permissions(root.join("big.txt"), fs::Permissions::from_mode(0o644)).unwrap(); |
|
| 5255 | 5255 | } |
|
| 5256 | + | ||
| 5257 | + | /// A replace conflict (spec §9.3) is settled by a change that writes one |
|
| 5258 | + | /// candidate, or new bytes, at the path when the caller names it; a plain |
|
| 5259 | + | /// snapshot of a checkout never settles it on its own. |
|
| 5260 | + | #[test] |
|
| 5261 | + | fn replace_conflicts_are_settled_when_asked() { |
|
| 5262 | + | hermetic(); |
|
| 5263 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 5264 | + | let root = dir.path(); |
|
| 5265 | + | let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); |
|
| 5266 | + | let blob = |n: u8| { |
|
| 5267 | + | let mut v = vec![0u8; 64]; |
|
| 5268 | + | v[1] = n; |
|
| 5269 | + | v |
|
| 5270 | + | }; |
|
| 5271 | + | write(root, "img.bin", &blob(1)); |
|
| 5272 | + | repo.commit("add img", &[]).unwrap(); |
|
| 5273 | + | repo.bookmark_create("other", None).unwrap(); |
|
| 5274 | + | write(root, "img.bin", &blob(2)); |
|
| 5275 | + | repo.commit("img two", &[]).unwrap(); |
|
| 5276 | + | repo.bookmark_switch("other", false).unwrap(); |
|
| 5277 | + | write(root, "img.bin", &blob(3)); |
|
| 5278 | + | repo.commit("img three", &[]).unwrap(); |
|
| 5279 | + | repo.bookmark_switch("main", false).unwrap(); |
|
| 5280 | + | repo.merge("other").unwrap(); |
|
| 5281 | + | let heads = repo.bookmark_heads().unwrap(); |
|
| 5282 | + | let merged = repo.load_state(&heads).unwrap().materialize(); |
|
| 5283 | + | assert!( |
|
| 5284 | + | matches!(&merged.files["img.bin"].content, FileContent::Opaque { blobs } if blobs.len() == 2), |
|
| 5285 | + | "a replace conflict" |
|
| 5286 | + | ); |
|
| 5287 | + | // A snapshot of the checkout records nothing. |
|
| 5288 | + | assert!(repo |
|
| 5289 | + | .snapshot("status") |
|
| 5290 | + | .unwrap() |
|
| 5291 | + | .statuses |
|
| 5292 | + | .iter() |
|
| 5293 | + | .all(|s| s.path != "img.bin")); |
|
| 5294 | + | ||
| 5295 | + | // Keeping candidate 3 settles it. |
|
| 5296 | + | let author = repo.author(); |
|
| 5297 | + | let edits = [FileEdit { |
|
| 5298 | + | path: "img.bin".into(), |
|
| 5299 | + | content: Some(blob(3)), |
|
| 5300 | + | }]; |
|
| 5301 | + | let c = repo |
|
| 5302 | + | .build_change(&heads, &edits, "keep three", author) |
|
| 5303 | + | .unwrap(); |
|
| 5304 | + | assert_eq!(c.kind, ChangeKind::Resolution); |
|
| 5305 | + | let mut st = repo.load_state(&heads).unwrap(); |
|
| 5306 | + | for d in &c.deps { |
|
| 5307 | + | assert!(st.contains(d)); |
|
| 5308 | + | } |
|
| 5309 | + | assert!( |
|
| 5310 | + | heads.iter().all(|h| c.deps.contains(h)), |
|
| 5311 | + | "depends on both contributors" |
|
| 5312 | + | ); |
|
| 5313 | + | st.add(c).unwrap(); |
|
| 5314 | + | let after = st.materialize(); |
|
| 5315 | + | assert!(!after.is_conflicted()); |
|
| 5316 | + | assert_eq!( |
|
| 5317 | + | after.files["img.bin"].content, |
|
| 5318 | + | FileContent::Opaque { |
|
| 5319 | + | blobs: vec![Repo::blob_ref(&blob(3))] |
|
| 5320 | + | } |
|
| 5321 | + | ); |
|
| 5322 | + | ||
| 5323 | + | // New bytes settle it too. |
|
| 5324 | + | let edits = [FileEdit { |
|
| 5325 | + | path: "img.bin".into(), |
|
| 5326 | + | content: Some(blob(9)), |
|
| 5327 | + | }]; |
|
| 5328 | + | let c = repo.build_change(&heads, &edits, "new", author).unwrap(); |
|
| 5329 | + | let mut st = repo.load_state(&heads).unwrap(); |
|
| 5330 | + | st.add(c).unwrap(); |
|
| 5331 | + | assert_eq!( |
|
| 5332 | + | st.materialize().files["img.bin"].content, |
|
| 5333 | + | FileContent::Opaque { |
|
| 5334 | + | blobs: vec![Repo::blob_ref(&blob(9))] |
|
| 5335 | + | } |
|
| 5336 | + | ); |
|
| 5337 | + | } |
modified docs/spec.md +3 −3
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | | | | |
|
| 4 | 4 | |---|---| |
|
| 5 | - | | **Status** | Draft 0.8l | |
|
| 5 | + | | **Status** | Draft 0.8m | |
|
| 6 | 6 | | **Format version** | 1 | |
|
| 7 | 7 | | **Date** | 2026-09-26 | |
|
| 8 | 8 | | **Audience** | Implementers, security reviewers, and contributors | |
|
| … | 354 unchanged lines |
||
| 363 | 363 | ||
| 364 | 364 | Two live `Replace` edits with the same `old` and different `new` are a **replace conflict**. The state exposes both candidate blobs. |
|
| 365 | 365 | ||
| 366 | - | Which blobs are live: a replace record is superseded by a later replacement of its blob, a record whose `old` is its `new` and whose change depends on its change (§8.1 makes every replacement depend on the change that made its `old` current). Replacements that do not know each other both stay live, and a file reverted to an earlier blob keeps it. A replacement recorded without that dependency (before Draft 0.8i) supersedes every record with its `new` that does not come after it, the rule until then. |
|
| 366 | + | Which blobs are live: a replace record is superseded by a later replacement of its blob, a record whose `old` is its `new` and whose change depends on its change (§8.1 makes every replacement depend on the change that made its `old` current). Replacements that do not know each other both stay live, and a file reverted to an earlier blob keeps it. A replace conflict is **resolved** by a `kind = resolution` change with one `Replace` from each live blob to the chosen one, depending on every change that made a candidate live, so each is superseded and one blob remains. A working-copy snapshot never settles a replace conflict on its own (a checkout writes one candidate at the path); a tool that knows the user's choice, such as a forge's resolver, builds the resolution. A replacement recorded without that dependency (before Draft 0.8i) supersedes every record with its `new` that does not come after it, the rule until then. |
|
| 367 | 367 | ||
| 368 | 368 | ### 9.4 Properties |
|
| 369 | 369 | ||
| … | 574 unchanged lines |
||
| 944 | 944 | ||
| 945 | 945 | --- |
|
| 946 | 946 | ||
| 947 | - | *Changelog.* Draft 0.8l (2026-09-28): deleting a reference on a remote; first serials from the clock (§16.4). Draft 0.8k (2026-09-28): the manifest records graph keys, so unchanged files are neither read nor rendered (§12.4). Draft 0.8j (2026-09-28): checkpoint payload version 2, file graphs by reference (§11). |
|
| 947 | + | *Changelog.* Draft 0.8m (2026-09-29): resolving replace conflicts (§9.3). Draft 0.8l (2026-09-28): deleting a reference on a remote; first serials from the clock (§16.4). Draft 0.8k (2026-09-28): the manifest records graph keys, so unchanged files are neither read nor rendered (§12.4). Draft 0.8j (2026-09-28): checkpoint payload version 2, file graphs by reference (§11). |
|
| 948 | 948 | ||
| 949 | 949 | Draft 0.8i (2026-09-28): portability rules for exec bits and path names (§7.7); found by verifying a real Git import commit by commit: deletions depend on the inserters of the lines right after them, and replacements on the change that set their old blob (§8.1); replace records are superseded causally, so a reverted binary keeps its blob (§9.3); merges resolve opaque conflicts to the commit's blob, and `oxy git verify` (§23). Draft 0.8h (2026-09-28): `oxy fsck` (§21.1); durability rules and the operation head as commit point (§13.6); the operation head moves by compare-and-swap and concurrent operations are merged (§12.1); `oxy doctor` (§21.2). Draft 0.8g (2026-09-27): delegates of a rule's identities may sign reference attestations (§14.5); local-path pushes bind unbound repositories like HTTP ones. Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands. |
|
| 950 | 950 | ||
| … | 61 unchanged lines |