OXYGENHUB

Resolve a binary file's conflicting versions by keeping one 9e5ef6c23006

A binary file replaced two ways (a replace conflict) now has the same
resolver page as a name conflict: its versions, each labelled with the
change that wrote it, and a choice of which to keep. Writing the kept
version at the path makes a resolution change that replaces every version
with it (oxygen#21). The conflicts page links replace conflicts to it.
Pinned to the oxygen that settles them.

refs andymccall/oxygen#21
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 05:27 UTC
Signature
✔ signed by Andy McCall
Hash
9e5ef6c2300693dafd5abe6026587fe4c46b9ca5d5134202cbaaa32d128d7396
Depends on
08057ce15e77 145306044896 16cb2b3aa947 380996db84e5 93be9ce4653e c5f3bde466d1 d23464c0ac60 fd958fbc06fb
Depended on by
a1b9869565aa 65be9f9c3074 5e6d6484c28e
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
22 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified crates/hub/src/web/resolve.rs +90 −6
…
213 unchanged lines
214 214
    Ok(None)
215 215
}
216 216
217 +
/// The versions of a binary file with a replace conflict at `path`, each
218 +
/// labelled with the change that wrote it.
219 +
fn replace_candidates(
220 +
    r: &oxy_core::repo::Repo,
221 +
    state: &oxy_core::state::State,
222 +
    snapshot: &oxy_core::state::Snapshot,
223 +
    path: &str,
224 +
) -> Option<Vec<Candidate>> {
225 +
    use oxy_core::state::FileContent;
226 +
    let f = snapshot.files.get(path)?;
227 +
    let FileContent::Opaque { blobs } = &f.content else { return None };
228 +
    if blobs.len() < 2 || f.kind != oxy_core::objects::NodeKind::File {
229 +
        return None;
230 +
    }
231 +
    let writers = state.live_replaces(&f.node);
232 +
    let mut out = Vec::new();
233 +
    for (i, b) in blobs.iter().enumerate() {
234 +
        let content = r.read_blob(b).ok()?;
235 +
        let by = writers.iter().find(|(_, n)| n == b).map(|(h, _)| state.label(h)).unwrap_or_default();
236 +
        let preview = std::str::from_utf8(&content).ok().map(|t| t.lines().take(40).collect::<Vec<_>>().join("\n"));
237 +
        out.push(Candidate { path: format!("version {} from change {by}", i + 1), content, preview });
238 +
    }
239 +
    Some(out)
240 +
}
241 +
217 242
/// What resolving `path` on a bookmark means.
218 243
enum Target {
219 244
    /// A conflicted text file, rendered with markers.
220 245
    Lines(String),
221 246
    /// A name conflict: keep one candidate at `path`.
222 247
    Name(Vec<Candidate>),
248 +
    /// A replace conflict of a binary file: keep one of its versions.
249 +
    Replace(Vec<Candidate>),
223 250
}
224 251
225 252
#[derive(Template)]
…
5 unchanged lines
231 258
    path: String,
232 259
    state: String,
233 260
    candidates: Vec<Candidate>,
261 +
    /// Whether the candidates are versions of one file (a replace
262 +
    /// conflict) rather than files wanting one name.
263 +
    versions: bool,
234 264
    vscode: String,
235 265
    delegation: Option<String>,
236 266
    error: Option<String>,
…
27 unchanged lines
264 294
            let heads = r.store().get_state(&st)?.heads;
265 295
            let loaded = cache.load(r, &heads)?;
266 296
            let names = name_candidates(r, &loaded.snapshot, &p);
297 +
            let versions = replace_candidates(r, &loaded.state, &loaded.snapshot, &p);
267 298
            let file = if names.as_ref().is_ok_and(|n| n.is_some()) {
268 299
                None
269 300
            } else {
270 301
                let target = view::resolve(r, cache, &b)?;
271 302
                Some(view::file(r, cache, target, &p, limit))
272 303
            };
273 -
            Ok(Some((st.to_hex(), names, file)))
304 +
            Ok(Some((st.to_hex(), names, file, versions)))
274 305
        })
275 306
        .await
276 307
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
277 -
    let Some((state, names, file)) = found else { return Err(Error::bad(shell.clone(), "Conflicts are resolved on a bookmark.")) };
308 +
    let Some((state, names, file, versions)) = found else {
309 +
        return Err(Error::bad(shell.clone(), "Conflicts are resolved on a bookmark."));
310 +
    };
311 +
    if let Some(v) = versions {
312 +
        return Ok((state, Target::Replace(v)));
313 +
    }
278 314
    match names {
279 315
        Ok(Some(c)) => return Ok((state, Target::Name(c))),
280 316
        Err(msg) => return Err(Error::bad(shell.clone(), &msg)),
…
28 unchanged lines
309 345
    let (state, target) = load(app, &shell, &repo, bookmark, path).await?;
310 346
    let text = match target {
311 347
        Target::Lines(t) => t,
312 -
        Target::Name(_) => return Err(Error::bad(shell, "That is a name conflict.")),
348 +
        Target::Name(_) | Target::Replace(_) => return Err(Error::bad(shell, "That conflict is resolved by keeping a candidate.")),
313 349
    };
314 350
    let Some(pieces) = parse(&text) else {
315 351
        return Err(Error::bad(shell, "The conflict markers could not be read; resolve the file in the plain editor."));
…
26 unchanged lines
342 378
            Ok(Redirect::to(&format!("/{owner}/{name}/edit/{bookmark}/{path}")).into_response())
343 379
        }
344 380
        Target::Lines(_) => Ok(page(&app, &viewer, &owner, &name, &bookmark, &path, None, None).await?.into_response()),
345 -
        Target::Name(_) => Ok(name_page(&app, &viewer, &owner, &name, &bookmark, &path, None, None).await?.into_response()),
381 +
        Target::Name(_) | Target::Replace(_) => {
382 +
            Ok(name_page(&app, &viewer, &owner, &name, &bookmark, &path, None, None).await?.into_response())
383 +
        }
346 384
    }
347 385
}
348 386
…
12 unchanged lines
361 399
    let me = require(viewer, &shell)?;
362 400
    let (repo, row, role) = lookup_role(app, &shell, viewer, owner, name, Access::Write).await?;
363 401
    let (state, target) = load(app, &shell, &repo, bookmark, path).await?;
364 -
    let Target::Name(candidates) = target else { return Err(Error::bad(shell, "That is not a name conflict.")) };
402 +
    let (candidates, versions) = match target {
403 +
        Target::Name(c) => (c, false),
404 +
        Target::Replace(c) => (c, true),
405 +
        Target::Lines(_) => return Err(Error::bad(shell, "That conflict is resolved region by region.")),
406 +
    };
365 407
    let delegation = signer_for(app, me.account.id).await.err();
366 408
    Ok(Page(NamePage {
367 409
        shell,
…
2 unchanged lines
370 412
        path: path.to_string(),
371 413
        state,
372 414
        candidates,
415 +
        versions,
373 416
        vscode: vscode_link(app, owner, name, bookmark, path),
374 417
        delegation,
375 418
        error,
376 -
        message: message.unwrap_or_else(|| format!("Resolve the name conflict at {path}")),
419 +
        message: message.unwrap_or_else(|| {
420 +
            if versions {
421 +
                format!("Resolve the conflicting versions of {path}")
422 +
            } else {
423 +
                format!("Resolve the name conflict at {path}")
424 +
            }
425 +
        }),
377 426
    }))
378 427
}
379 428
…
17 unchanged lines
397 446
    let stale = f.get("state") != Some(&state);
398 447
    let text = match target {
399 448
        Target::Lines(t) => t,
449 +
        Target::Name(candidates) | Target::Replace(candidates) if candidates.is_empty() => {
450 +
            return again("There are no candidates to keep.".into()).await;
451 +
        }
452 +
        Target::Replace(candidates) => {
453 +
            let again = |msg: String| {
454 +
                let (app, viewer, owner, name, bookmark, path, message) =
455 +
                    (app.clone(), viewer.clone(), owner.clone(), name.clone(), bookmark.clone(), path.clone(), message.clone());
456 +
                async move {
457 +
                    name_page(&app, &viewer, &owner, &name, &bookmark, &path, Some(msg), Some(message))
458 +
                        .await
459 +
                        .map(IntoResponse::into_response)
460 +
                }
461 +
            };
462 +
            if stale {
463 +
                return again(format!("{bookmark} has moved since this page was loaded; the versions below are as they are now.")).await;
464 +
            }
465 +
            let Some(keep) = f.get("keep").and_then(|k| k.parse::<usize>().ok()).and_then(|k| candidates.get(k)) else {
466 +
                return again("Choose the version to keep.".into()).await;
467 +
            };
468 +
            if message.is_empty() {
469 +
                return again("Describe the resolution.".into()).await;
470 +
            }
471 +
            let signer = match signer_for(&app, me.account.id).await {
472 +
                Ok(s) => s,
473 +
                Err(msg) => return again(msg).await,
474 +
            };
475 +
            // Writing the kept version at the path settles the conflict.
476 +
            let edit = vec![FileEdit { path: path.clone(), content: Some(keep.content.clone()) }];
477 +
            let done = match edits::commit(&app.repos, &repo, bookmark.clone(), bookmark.clone(), edit, message.clone(), signer).await {
478 +
                Ok(d) => d,
479 +
                Err(e) => return again(e.to_string()).await,
480 +
            };
481 +
            after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await;
482 +
            return Ok(Redirect::to(&format!("/{owner}/{name}/change/{}", done.change.to_hex())).into_response());
483 +
        }
400 484
        Target::Name(candidates) => {
401 485
            let again = |msg: String| {
402 486
                let (app, viewer, owner, name, bookmark, path, message) =
…
107 unchanged lines
modified crates/hub/tests/edits.rs +52 −0
…
348 unchanged lines
349 349
    let tree = send(app, "GET", "/andy/demo/tree/main", None, None).await.body;
350 350
    assert!(!tree.contains("oxy-conflict"), "the candidates are gone");
351 351
352 +
    // A replace conflict: a binary file (a NUL makes it opaque) replaced
353 +
    // two ways by proposals that do not know each other.
354 +
    let r = send(
355 +
        app,
356 +
        "POST",
357 +
        "/andy/demo/edit/main",
358 +
        Some(&andy),
359 +
        Some(&format!("_csrf={csrf}&path=img.bin&content=base%00&message=img&mode=commit")),
360 +
    )
361 +
    .await;
362 +
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
363 +
    for (b, text) in [("img-a", "alpha%00"), ("img-b", "beta%00")] {
364 +
        let r = send(
365 +
            app,
366 +
            "POST",
367 +
            "/andy/demo/edit/main",
368 +
            Some(&andy),
369 +
            Some(&format!("_csrf={csrf}&path=img.bin&original=img.bin&content={text}&message={b}&mode=propose&new_bookmark={b}")),
370 +
        )
371 +
        .await;
372 +
        assert!(r.location.as_deref().is_some_and(|l| l.contains("/proposal/")), "{}", r.body);
373 +
    }
374 +
    for n in [7, 8] {
375 +
        let page = send(app, "GET", &format!("/andy/demo/proposal/{n}"), Some(&andy), None).await;
376 +
        let r =
377 +
            send(app, "POST", &format!("/andy/demo/proposal/{n}/land"), Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await;
378 +
        assert!(r.body.contains("Landed."), "#{n}: {}", r.body);
379 +
    }
380 +
    let conflicts = send(app, "GET", "/andy/demo/conflicts", Some(&andy), None).await.body;
381 +
    assert!(conflicts.contains("replace (2 candidates)") && conflicts.contains("/andy/demo/resolve/main/img.bin"), "{conflicts}");
382 +
    let page = send(app, "GET", "/andy/demo/resolve/main/img.bin", Some(&andy), None).await;
383 +
    assert!(page.body.contains("has 2 versions") && page.body.contains("version 2 from change"), "{}", page.body);
384 +
    let state = {
385 +
        let i = page.body.find("name=\"state\" value=\"").unwrap() + "name=\"state\" value=\"".len();
386 +
        page.body[i..i + 64].to_string()
387 +
    };
388 +
    let version = |text: &str| page.body.find(&format!("<pre class=\"marked\">{text}")).expect("a version's preview");
389 +
    let beta = if version("beta") < version("alpha") { 0 } else { 1 };
390 +
    let r = send(
391 +
        app,
392 +
        "POST",
393 +
        "/andy/demo/resolve/main/img.bin",
394 +
        Some(&andy),
395 +
        Some(&format!("_csrf={csrf}&state={state}&keep={beta}&message={}", enc("Keep beta"))),
396 +
    )
397 +
    .await;
398 +
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
399 +
    assert!(send(app, "GET", "/andy/demo/conflicts", None, None).await.body.contains("No bookmark carries a conflict"));
400 +
    assert_eq!(send(app, "GET", "/andy/demo/raw/main/img.bin", None, None).await.body, "beta\u{0}");
401 +
    let change = send(app, "GET", r.location.as_deref().unwrap(), Some(&andy), None).await.body;
402 +
    assert!(change.contains(">resolution<"), "a resolution change");
403 +
352 404
    // Delete a file.
353 405
    let r = send(
354 406
        app,
…
15 unchanged lines
replaced deps/oxygenflow +0 −0

No line changes.

modified docs/spec.md +1 −1
…
304 unchanged lines
305 305
306 306
### 6.10 Conflicts page
307 307
308 -
For each bookmark whose state has conflicts: the file (or tree entry), the conflict kind, and each side with the change that produced it and its content, rendered with the same marker structure `oxy checkout` writes. For a text file, **resolve region by region** (`/{owner}/{name}/resolve/{bookmark}/{path}`, writers) shows the lines no one disagrees on and each conflict region with its sides and the change that wrote each; per region the user keeps one side, every side in order, none, or their own text (a side that records a deletion keeps the deletion). Regions are linkable (`#region-N`). The file is put back together and committed through the delegate as a web edit (§6.9), which makes it a `kind = resolution` change depending on every contributor; the page carries the bookmark's state and refuses to commit if the bookmark moved meanwhile. Malformed markers fall back to the editor, which also stays available: "edit the marked file" opens the markers in it. A name conflict (several files want one name, shown as `NAME.oxy-conflict-N`) resolves on the same URL by keeping one candidate: it takes the name as a move, keeping its identity, and the other candidates are removed. Conflicts between directories or links, and replace conflicts of binary files, are resolved from a client. Both resolver pages offer **Resolve in VS Code**, a `vscode://oxygen.oxygen-vscode/resolve?url=…&bookmark=…&path=…` link that oxygen-vscode handles: it finds the open checkout of that repository (or offers to clone it), switches to the bookmark after asking, pulls it, and opens the file. The change page tags resolution changes. Otherwise the page gives the two-line client recipe.
308 +
For each bookmark whose state has conflicts: the file (or tree entry), the conflict kind, and each side with the change that produced it and its content, rendered with the same marker structure `oxy checkout` writes. For a text file, **resolve region by region** (`/{owner}/{name}/resolve/{bookmark}/{path}`, writers) shows the lines no one disagrees on and each conflict region with its sides and the change that wrote each; per region the user keeps one side, every side in order, none, or their own text (a side that records a deletion keeps the deletion). Regions are linkable (`#region-N`). The file is put back together and committed through the delegate as a web edit (§6.9), which makes it a `kind = resolution` change depending on every contributor; the page carries the bookmark's state and refuses to commit if the bookmark moved meanwhile. Malformed markers fall back to the editor, which also stays available: "edit the marked file" opens the markers in it. A name conflict (several files want one name, shown as `NAME.oxy-conflict-N`) resolves on the same URL by keeping one candidate: it takes the name as a move, keeping its identity, and the other candidates are removed. A replace conflict (a binary file replaced two ways) resolves the same way by keeping one version, each labelled with the change that wrote it; the commit replaces every version with it (Oxygen spec §9.3). Conflicts between directories or links are resolved from a client. Both resolver pages offer **Resolve in VS Code**, a `vscode://oxygen.oxygen-vscode/resolve?url=…&bookmark=…&path=…` link that oxygen-vscode handles: it finds the open checkout of that repository (or offers to clone it), switches to the bookmark after asking, pulls it, and opens the file. The change page tags resolution changes. Otherwise the page gives the two-line client recipe.
309 309
310 310
### 6.11 Trust page
311 311
…
266 unchanged lines
modified templates/conflicts.html +1 −1
…
11 unchanged lines
12 12
  <header><span class="lbl lbl-bm">{{ c.bookmark }}</span> <strong>{{ c.path }}</strong> <span class="tag bad">{{ c.kind }}</span></header>
13 13
  {% if !c.contributors.is_empty() %}<p class="small">Sides from: {% for x in c.contributors %}<a class="chip" href="{{ repo.url }}/change/{{ x.hash }}" title="{{ x.title }}">{{ x.short }}</a> {% endfor %}</p>{% endif %}
14 14
  {% if let Some(text) = c.marked %}<pre class="marked">{{ text }}</pre>{% endif %}
15 -
  {% if c.kind.starts_with("name:") && repo.can(crate::auth::Access::Write) %}<p><a class="go small" href="{{ repo.url }}/resolve/{{ c.bookmark }}/{{ c.path }}">choose which to keep</a></p>{% endif %}
15 +
  {% if (c.kind.starts_with("name:") || c.kind.starts_with("replace")) && repo.can(crate::auth::Access::Write) %}<p><a class="go small" href="{{ repo.url }}/resolve/{{ c.bookmark }}/{{ c.path }}">choose which to keep</a></p>{% endif %}
16 16
  {% if c.marked.is_some() && repo.can(crate::auth::Access::Write) %}<p><a class="go small" href="{{ repo.url }}/resolve/{{ c.bookmark }}/{{ c.path }}">resolve region by region</a> <span class="small mute">or <a href="{{ repo.url }}/edit/{{ c.bookmark }}/{{ c.path }}">edit the marked file</a></span></p>{% endif %}
17 17
  <p class="small mute">Or resolve from a client: <code>oxy bookmark switch {{ c.bookmark }}</code>, edit the file, <code>oxy resolve</code>, <code>oxy commit</code>, <code>oxy push</code>.</p>
18 18
</section>
…
3 unchanged lines
modified templates/resolve_name.html +2 −2
…
5 unchanged lines
6 6
{% block mainclass %} class="wide"{% endblock %}
7 7
{% block content %}
8 8
<div class="head"><h1>Resolve <code>{{ path }}</code> <small>on <span class="lbl lbl-bm">{{ bookmark }}</span></small></h1>
9 -
<p class="small mute">{{ candidates.len() }} files want this name, so the tree shows them as <code>{{ path }}.oxy-conflict-N</code>. Keep one: it takes the name, keeping its history, and the others are removed. The commit is a resolution change on {{ bookmark }}.</p></div>
9 +
{% if versions %}<p class="small mute">Changes that did not know of each other each replaced this file, so it has {{ candidates.len() }} versions. Keep one: the commit is a resolution change on {{ bookmark }} that replaces every version with it.</p>{% else %}<p class="small mute">{{ candidates.len() }} files want this name, so the tree shows them as <code>{{ path }}.oxy-conflict-N</code>. Keep one: it takes the name, keeping its history, and the others are removed. The commit is a resolution change on {{ bookmark }}.</p>{% endif %}</div>
10 10
<p class="small"><a href="{{ vscode }}">Resolve in VS Code</a> <span class="mute">(with the Oxygen extension: it opens your checkout on {{ bookmark }}, or offers to clone it)</span></p>
11 11
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
12 12
{% if let Some(d) = delegation %}<p class="error">{{ d }}</p>{% endif %}
…
2 unchanged lines
15 15
<input type="hidden" name="state" value="{{ state }}">
16 16
{% for c in candidates %}
17 17
<section class="file-diff" id="candidate-{{ loop.index }}">
18 -
  <header><label><input type="radio" name="keep" value="{{ loop.index0 }}"{% if loop.first %} checked{% endif %}> keep <strong>{{ c.path }}</strong></label> <a class="small" href="{{ repo.url }}/file/{{ bookmark }}/{{ c.path }}">view</a></header>
18 +
  <header><label><input type="radio" name="keep" value="{{ loop.index0 }}"{% if loop.first %} checked{% endif %}> keep <strong>{{ c.path }}</strong></label>{% if !versions %} <a class="small" href="{{ repo.url }}/file/{{ bookmark }}/{{ c.path }}">view</a>{% endif %}</header>
19 19
  {% if let Some(p) = c.preview %}<pre class="marked">{{ p }}</pre>{% else %}<p class="small mute">binary, {{ c.content.len() }} bytes</p>{% endif %}
20 20
</section>
21 21
{% endfor %}
…
4 unchanged lines