OXYGENHUB

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