OXYGENHUB

Resolve name conflicts by keeping a candidate fd958fbc06fb

When several files want one name (shown as NAME.oxy-conflict-N), the
resolver lists the candidates with a preview and keeps the one chosen:
its content goes to the name and every candidate is removed, which the
snapshot records as a move of the kept file, so it keeps its identity.
The commit is a resolution change through the delegate, refused if the
bookmark moved meanwhile. Conflicts between directories or links are
left to clients. The conflicts page links name conflicts to it and now
gives their full path (it gave the name alone).

closes #31
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 04:55 UTC
Signature
✔ signed by Andy McCall
Hash
fd958fbc06fb6d650412b0d8ae3e418198830aadedb8cd462582bb958f9a73de
Depends on
145306044896 93be9ce4653e c5f3bde466d1 d23464c0ac60
Depended on by
ffb67a81188c a1b9869565aa 5e6d6484c28e 9e5ef6c23006 16cb2b3aa947
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
20 atoms, 1 tree operation, 6 files

Browse the tree at this change

added templates/resolve_name.html +24 −0
1 +
{% extends "base.html" %}
2 +
{% import "repo_tabs.html" as m %}
3 +
{% block title %}Resolve {{ path }} · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 +
{% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %}
5 +
{% block tabs %}{% call m::tabs(repo, "conflicts") %}{% endblock %}
6 +
{% block mainclass %} class="wide"{% endblock %}
7 +
{% block content %}
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>
10 +
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
11 +
{% if let Some(d) = delegation %}<p class="error">{{ d }}</p>{% endif %}
12 +
<form method="post" action="{{ repo.url }}/resolve/{{ bookmark }}/{{ path }}">
13 +
<input type="hidden" name="_csrf" value="{{ shell.csrf }}">
14 +
<input type="hidden" name="state" value="{{ state }}">
15 +
{% for c in candidates %}
16 +
<section class="file-diff" id="candidate-{{ loop.index }}">
17 +
  <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 +
  {% if let Some(p) = c.preview %}<pre class="marked">{{ p }}</pre>{% else %}<p class="small mute">binary, {{ c.content.len() }} bytes</p>{% endif %}
19 +
</section>
20 +
{% endfor %}
21 +
<label for="message">Message</label><input type="text" id="message" name="message" value="{{ message }}">
22 +
<button type="submit">COMMIT RESOLUTION</button>
23 +
</form>
24 +
{% endblock %}
modified crates/hub/src/repos/view.rs +4 −1
…
915 unchanged lines
916 916
    let by_node = snapshot.tree.by_node();
917 917
    let path_of = |n: &oxy_core::ids::NodeId| by_node.get(n).map(|e| e.path.clone()).unwrap_or_else(|| n.short(8));
918 918
    match c {
919 -
        TreeConflict::Name { name, nodes, .. } => (name.clone(), format!("name: {} entries want this name", nodes.len())),
919 +
        TreeConflict::Name { name, nodes, parent } => {
920 +
            let path = if parent.is_root() { name.clone() } else { format!("{}/{name}", path_of(parent)) };
921 +
            (path, format!("name: {} entries want this name", nodes.len()))
922 +
        }
920 923
        TreeConflict::MultiPlacement { node, placements } => (path_of(node), format!("placement: {} places", placements.len())),
921 924
        TreeConflict::Orphan { node, .. } => (path_of(node), "orphan: parent directory was removed".into()),
922 925
        TreeConflict::Cycle { node } => (path_of(node), "cycle: directory moved into itself".into()),
…
195 unchanged lines
modified crates/hub/src/web/resolve.rs +187 −19
…
152 unchanged lines
153 153
    Ok(out)
154 154
}
155 155
156 +
/// One of the entries a name conflict shows at `NAME.oxy-conflict-N`.
157 +
#[derive(Clone, Debug)]
158 +
pub struct Candidate {
159 +
    pub path: String,
160 +
    pub content: Vec<u8>,
161 +
    /// The start of a text candidate, for the page.
162 +
    pub preview: Option<String>,
163 +
}
164 +
165 +
/// The candidates of a name conflict at `path` in a loaded state: the
166 +
/// files the tree shows as `path.oxy-conflict-N`. `None` when `path` has no
167 +
/// name conflict; an error for one between directories or links.
168 +
fn name_candidates(r: &oxy_core::repo::Repo, snapshot: &oxy_core::state::Snapshot, path: &str) -> Result<Option<Vec<Candidate>>, String> {
169 +
    use oxy_core::objects::NodeKind;
170 +
    use oxy_core::state::FileContent;
171 +
    use oxy_core::tree::TreeConflict;
172 +
    let by_node = snapshot.tree.by_node();
173 +
    for c in &snapshot.tree.conflicts {
174 +
        let TreeConflict::Name { parent, name, nodes } = c else { continue };
175 +
        let full = if parent.is_root() {
176 +
            name.clone()
177 +
        } else {
178 +
            match by_node.get(parent) {
179 +
                Some(e) => format!("{}/{name}", e.path),
180 +
                None => continue,
181 +
            }
182 +
        };
183 +
        if full != path {
184 +
            continue;
185 +
        }
186 +
        let mut out = Vec::new();
187 +
        for n in nodes {
188 +
            let Some(e) = by_node.get(n) else { continue };
189 +
            let f = snapshot.files.get(&e.path).ok_or("a candidate is missing")?;
190 +
            if f.kind != NodeKind::File {
191 +
                return Err("This conflict is between directories or links; resolve it from a client.".into());
192 +
            }
193 +
            let content = match &f.content {
194 +
                FileContent::Text { bytes, .. } => bytes.clone(),
195 +
                FileContent::Opaque { blobs } => match blobs.first() {
196 +
                    Some(b) => r.read_blob(b).map_err(|e| e.to_string())?,
197 +
                    None => vec![],
198 +
                },
199 +
                FileContent::Dir => continue,
200 +
            };
201 +
            let preview = std::str::from_utf8(&content).ok().map(|t| t.lines().take(40).collect::<Vec<_>>().join("\n"));
202 +
            out.push(Candidate { path: e.path.clone(), content, preview });
203 +
        }
204 +
        out.sort_by(|a, b| a.path.cmp(&b.path));
205 +
        return Ok(Some(out));
206 +
    }
207 +
    Ok(None)
208 +
}
209 +
210 +
/// What resolving `path` on a bookmark means.
211 +
enum Target {
212 +
    /// A conflicted text file, rendered with markers.
213 +
    Lines(String),
214 +
    /// A name conflict: keep one candidate at `path`.
215 +
    Name(Vec<Candidate>),
216 +
}
217 +
218 +
#[derive(Template)]
219 +
#[template(path = "resolve_name.html")]
220 +
pub struct NamePage {
221 +
    shell: Shell,
222 +
    repo: RepoShell,
223 +
    bookmark: String,
224 +
    path: String,
225 +
    state: String,
226 +
    candidates: Vec<Candidate>,
227 +
    delegation: Option<String>,
228 +
    error: Option<String>,
229 +
    message: String,
230 +
}
231 +
156 232
#[derive(Template)]
157 233
#[template(path = "resolve.html")]
158 234
pub struct ResolvePage {
…
8 unchanged lines
167 243
    message: String,
168 244
}
169 245
170 -
/// The bookmark's state, and the file rendered there when it is a
171 -
/// conflicted text file.
172 -
async fn load(app: &Shared, shell: &Shell, repo: &crate::repos::RepoName, bookmark: &str, path: &str) -> Result<(String, String), Error> {
246 +
/// The bookmark's state and what resolving `path` there means.
247 +
async fn load(app: &Shared, shell: &Shell, repo: &crate::repos::RepoName, bookmark: &str, path: &str) -> Result<(String, Target), Error> {
173 248
    let limit = app.config.limits.render_limit.0;
174 249
    let (b, p) = (bookmark.to_string(), path.to_string());
175 -
    let (state, file) = app
250 +
    let found = app
176 251
        .repos
177 252
        .read(repo, move |r, cache| {
178 -
            let state = r.view()?.bookmarks.get(&b).map(|s| s.to_hex());
179 -
            let target = view::resolve(r, cache, &b)?;
180 -
            Ok((state, view::file(r, cache, target, &p, limit)?))
253 +
            let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
254 +
            let heads = r.store().get_state(&st)?.heads;
255 +
            let loaded = cache.load(r, &heads)?;
256 +
            let names = name_candidates(r, &loaded.snapshot, &p);
257 +
            let file = if names.as_ref().is_ok_and(|n| n.is_some()) {
258 +
                None
259 +
            } else {
260 +
                let target = view::resolve(r, cache, &b)?;
261 +
                Some(view::file(r, cache, target, &p, limit))
262 +
            };
263 +
            Ok(Some((st.to_hex(), names, file)))
181 264
        })
182 265
        .await
183 266
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
184 -
    let Some(state) = state else { return Err(Error::bad(shell.clone(), "Conflicts are resolved on a bookmark.")) };
267 +
    let Some((state, names, file)) = found else { return Err(Error::bad(shell.clone(), "Conflicts are resolved on a bookmark.")) };
268 +
    match names {
269 +
        Ok(Some(c)) => return Ok((state, Target::Name(c))),
270 +
        Err(msg) => return Err(Error::bad(shell.clone(), &msg)),
271 +
        Ok(None) => {}
272 +
    }
273 +
    let file = match file {
274 +
        Some(Ok(f)) => f,
275 +
        Some(Err(e)) => return Err(Error::from_repo(shell.clone(), e)),
276 +
        None => return Err(Error::bad(shell.clone(), "Nothing to resolve there.")),
277 +
    };
185 278
    match file.body {
186 -
        FileBody::Text(t) if file.conflicted => Ok((state, t)),
279 +
        FileBody::Text(t) if file.conflicted => Ok((state, Target::Lines(t))),
187 280
        FileBody::Text(_) => Err(Error::bad(shell.clone(), "That file has no conflicts on this bookmark.")),
188 -
        _ => Err(Error::bad(shell.clone(), "Only text files within the render limit are resolved in the browser.")),
281 +
        _ => Err(Error::bad(shell.clone(), "Only text files within the render limit, and name conflicts, are resolved in the browser.")),
189 282
    }
190 283
}
191 284
…
11 unchanged lines
203 296
    let shell = Shell::new(app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), viewer);
204 297
    let me = require(viewer, &shell)?;
205 298
    let (repo, row, role) = lookup_role(app, &shell, viewer, owner, name, Access::Write).await?;
206 -
    let (state, text) = load(app, &shell, &repo, bookmark, path).await?;
299 +
    let (state, target) = load(app, &shell, &repo, bookmark, path).await?;
300 +
    let text = match target {
301 +
        Target::Lines(t) => t,
302 +
        Target::Name(_) => return Err(Error::bad(shell, "That is a name conflict.")),
303 +
    };
207 304
    let Some(pieces) = parse(&text) else {
208 305
        return Err(Error::bad(shell, "The conflict markers could not be read; resolve the file in the plain editor."));
209 306
    };
…
16 unchanged lines
226 323
    viewer: Viewer,
227 324
    Path((owner, name, bookmark, path)): Path<(String, String, String, String)>,
228 325
) -> Result<Response, Error> {
229 -
    // Malformed markers: the plain editor still resolves the file.
230 -
    let shell = Shell::new(&app, "", &viewer);
326 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), &viewer);
231 327
    let (repo, _, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
232 -
    let (_, text) = load(&app, &shell, &repo, &bookmark, &path).await?;
233 -
    if parse(&text).is_none() {
234 -
        return Ok(Redirect::to(&format!("/{owner}/{name}/edit/{bookmark}/{path}")).into_response());
328 +
    match load(&app, &shell, &repo, &bookmark, &path).await?.1 {
329 +
        // Malformed markers: the plain editor still resolves the file.
330 +
        Target::Lines(text) if parse(&text).is_none() => {
331 +
            Ok(Redirect::to(&format!("/{owner}/{name}/edit/{bookmark}/{path}")).into_response())
332 +
        }
333 +
        Target::Lines(_) => Ok(page(&app, &viewer, &owner, &name, &bookmark, &path, None, None).await?.into_response()),
334 +
        Target::Name(_) => Ok(name_page(&app, &viewer, &owner, &name, &bookmark, &path, None, None).await?.into_response()),
235 335
    }
236 -
    Ok(page(&app, &viewer, &owner, &name, &bookmark, &path, None, None).await?.into_response())
237 336
}
238 337
338 +
#[allow(clippy::too_many_arguments)]
339 +
async fn name_page(
340 +
    app: &Shared,
341 +
    viewer: &Viewer,
342 +
    owner: &str,
343 +
    name: &str,
344 +
    bookmark: &str,
345 +
    path: &str,
346 +
    error: Option<String>,
347 +
    message: Option<String>,
348 +
) -> Result<Page<NamePage>, Error> {
349 +
    let shell = Shell::new(app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), viewer);
350 +
    let me = require(viewer, &shell)?;
351 +
    let (repo, row, role) = lookup_role(app, &shell, viewer, owner, name, Access::Write).await?;
352 +
    let (state, target) = load(app, &shell, &repo, bookmark, path).await?;
353 +
    let Target::Name(candidates) = target else { return Err(Error::bad(shell, "That is not a name conflict.")) };
354 +
    let delegation = signer_for(app, me.account.id).await.err();
355 +
    Ok(Page(NamePage {
356 +
        shell,
357 +
        repo: RepoShell::new(app, &row, "conflicts").with_role(role),
358 +
        bookmark: bookmark.to_string(),
359 +
        path: path.to_string(),
360 +
        state,
361 +
        candidates,
362 +
        delegation,
363 +
        error,
364 +
        message: message.unwrap_or_else(|| format!("Resolve the name conflict at {path}")),
365 +
    }))
366 +
}
367 +
239 368
pub async fn save(
240 369
    State(app): State<Shared>,
241 370
    viewer: Viewer,
…
10 unchanged lines
252 381
            (app.clone(), viewer.clone(), owner.clone(), name.clone(), bookmark.clone(), path.clone(), message.clone());
253 382
        async move { page(&app, &viewer, &owner, &name, &bookmark, &path, Some(msg), Some(message)).await.map(IntoResponse::into_response) }
254 383
    };
255 -
    let (state, text) = load(&app, &shell, &repo, &bookmark, &path).await?;
256 -
    if f.get("state") != Some(&state) {
384 +
    let (state, target) = load(&app, &shell, &repo, &bookmark, &path).await?;
385 +
    let stale = f.get("state") != Some(&state);
386 +
    let text = match target {
387 +
        Target::Lines(t) => t,
388 +
        Target::Name(candidates) => {
389 +
            let again = |msg: String| {
390 +
                let (app, viewer, owner, name, bookmark, path, message) =
391 +
                    (app.clone(), viewer.clone(), owner.clone(), name.clone(), bookmark.clone(), path.clone(), message.clone());
392 +
                async move {
393 +
                    name_page(&app, &viewer, &owner, &name, &bookmark, &path, Some(msg), Some(message))
394 +
                        .await
395 +
                        .map(IntoResponse::into_response)
396 +
                }
397 +
            };
398 +
            if stale {
399 +
                return again(format!("{bookmark} has moved since this page was loaded; the candidates below are as they are now.")).await;
400 +
            }
401 +
            let Some(keep) = f.get("keep").and_then(|k| k.parse::<usize>().ok()).and_then(|k| candidates.get(k)) else {
402 +
                return again("Choose the candidate to keep.".into()).await;
403 +
            };
404 +
            if message.is_empty() {
405 +
                return again("Describe the resolution.".into()).await;
406 +
            }
407 +
            let signer = match signer_for(&app, me.account.id).await {
408 +
                Ok(s) => s,
409 +
                Err(msg) => return again(msg).await,
410 +
            };
411 +
            // The kept candidate's content at the name, every candidate
412 +
            // gone: the snapshot pairs the kept one with the name as a
413 +
            // move, so it keeps its identity.
414 +
            let mut edit: Vec<FileEdit> = candidates.iter().map(|c| FileEdit { path: c.path.clone(), content: None }).collect();
415 +
            edit.push(FileEdit { path: path.clone(), content: Some(keep.content.clone()) });
416 +
            let done = match edits::commit(&app.repos, &repo, bookmark.clone(), bookmark.clone(), edit, message.clone(), signer).await {
417 +
                Ok(d) => d,
418 +
                Err(e) => return again(e.to_string()).await,
419 +
            };
420 +
            after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await;
421 +
            return Ok(Redirect::to(&format!("/{owner}/{name}/change/{}", done.change.to_hex())).into_response());
422 +
        }
423 +
    };
424 +
    if stale {
257 425
        return again(format!("{bookmark} has moved since this page was loaded; the conflicts below are as they are now.")).await;
258 426
    }
259 427
    let Some(pieces) = parse(&text) else { return again("The conflict markers could not be read.".into()).await };
…
70 unchanged lines
modified crates/hub/tests/edits.rs +46 −0
…
296 unchanged lines
297 297
    let shown = send(app, "GET", &change, Some(&andy), None).await.body;
298 298
    assert!(shown.to_lowercase().contains("resolution"), "a resolution change");
299 299
300 +
    // A name conflict: two proposals add different files called new.txt.
301 +
    for (b, text) in [("first-new", "first\n"), ("second-new", "second\n")] {
302 +
        let r = send(
303 +
            app,
304 +
            "POST",
305 +
            "/andy/demo/edit/main",
306 +
            Some(&andy),
307 +
            Some(&format!("_csrf={csrf}&path=new.txt&content={}&message={b}&mode=propose&new_bookmark={b}", enc(text))),
308 +
        )
309 +
        .await;
310 +
        assert!(r.location.as_deref().is_some_and(|l| l.contains("/proposal/")), "{}", r.body);
311 +
    }
312 +
    for n in [5, 6] {
313 +
        let page = send(app, "GET", &format!("/andy/demo/proposal/{n}"), Some(&andy), None).await;
314 +
        let r =
315 +
            send(app, "POST", &format!("/andy/demo/proposal/{n}/land"), Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await;
316 +
        assert!(r.body.contains("Landed."), "#{n}: {}", r.body);
317 +
    }
318 +
    let conflicts = send(app, "GET", "/andy/demo/conflicts", Some(&andy), None).await.body;
319 +
    assert!(conflicts.contains("choose which to keep") && conflicts.contains("/andy/demo/resolve/main/new.txt"), "{conflicts}");
320 +
    let page = send(app, "GET", "/andy/demo/resolve/main/new.txt", Some(&andy), None).await;
321 +
    assert!(page.body.contains("new.txt.oxy-conflict-1") && page.body.contains("new.txt.oxy-conflict-2"), "{}", page.body);
322 +
    let state = {
323 +
        let i = page.body.find("name=\"state\" value=\"").unwrap() + "name=\"state\" value=\"".len();
324 +
        page.body[i..i + 64].to_string()
325 +
    };
326 +
    // Which suffix holds "second" depends on node order; keep that one.
327 +
    let second = if page.body.find("second").unwrap() < page.body.find("first").unwrap() { 0 } else { 1 };
328 +
    let r = send(
329 +
        app,
330 +
        "POST",
331 +
        "/andy/demo/resolve/main/new.txt",
332 +
        Some(&andy),
333 +
        Some(&format!("_csrf={csrf}&state={state}&keep={second}&message={}", enc("Keep the second new.txt"))),
334 +
    )
335 +
    .await;
336 +
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
337 +
    // The kept file moved to the name, keeping its identity.
338 +
    let hash = r.location.unwrap().rsplit('/').next().unwrap().to_string();
339 +
    let detail = send(app, "GET", &format!("/api/v1/repos/andy/demo/changes/{hash}"), Some(&andy), None).await.body;
340 +
    assert!(detail.contains("\"kind\":\"renamed\""), "{detail}");
341 +
    assert!(send(app, "GET", "/andy/demo/conflicts", None, None).await.body.contains("No bookmark carries a conflict"));
342 +
    assert_eq!(send(app, "GET", "/andy/demo/file/main/new.txt?plain=1", None, None).await.body, "second\n");
343 +
    let tree = send(app, "GET", "/andy/demo/tree/main", None, None).await.body;
344 +
    assert!(!tree.contains("oxy-conflict"), "the candidates are gone");
345 +
300 346
    // Delete a file.
301 347
    let r = send(
302 348
        app,
…
15 unchanged lines
modified docs/spec.md +1 −1
…
292 unchanged lines
293 293
294 294
### 6.10 Conflicts page
295 295
296 -
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. The change page tags resolution changes. Otherwise the page gives the two-line client recipe.
296 +
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. The change page tags resolution changes. Otherwise the page gives the two-line client recipe.
297 297
298 298
### 6.11 Trust page
299 299
…
255 unchanged lines
modified templates/conflicts.html +1 −0
…
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 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 %}
16 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>
17 18
</section>
…
3 unchanged lines