OXYGENHUB

Resolve conflicts region by region c5f3bde466d1

A conflicted text file of a bookmark has a resolver page for writers
(/{owner}/{name}/resolve/{bookmark}/{path}): 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, which makes it a
resolution change depending on every contributor. The page carries the
bookmark's state and refuses to commit if it moved meanwhile; malformed
markers fall back to the editor, which the conflicts page still offers.
The change page tags resolution changes, and the web edit's follow-ups
(push log, webhooks, Flow, closing keywords, mirror) are shared.

closes #30
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 04:41 UTC
Signature
✔ signed by Andy McCall
Hash
c5f3bde466d11d2f876de714c624f81cdd0aeb66c1e4472251b66212f076b112
Depends on
145306044896 19bb31ff1f5c 3f4b0ea6eabf 42fa95540468 93be9ce4653e d23464c0ac60
Depended on by
8501fd5815b9 dd327d76062d 33d98147511b c92a99490cba 62cdf34daec1 c8df33a6829f c96b32865000 129c23effb73 d3e75fb41e61 b6b7e4cf6b24 5e6d6484c28e 9e5ef6c23006 16cb2b3aa947 fd958fbc06fb
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, 2 tree operations, 8 files

Browse the tree at this change

added crates/hub/src/web/resolve.rs +329 −0
1 +
//! The conflict resolver (hub spec §6.10): a conflicted text file of a
2 +
//! bookmark, region by region. For each region the user keeps one side,
3 +
//! every side in order, none, or text of their own; the file is put back
4 +
//! together around the unconflicted lines and committed through the
5 +
//! web-edit path, which makes it a `resolution` change depending on every
6 +
//! change that contributed to the conflict.
7 +
8 +
use super::edit::{after_web_commit, signer_for};
9 +
use super::settings::{csrf, require};
10 +
use super::{lookup_role, Error, Page, RepoShell, Shell};
11 +
use crate::auth::{Access, Viewer};
12 +
use crate::repos::edits;
13 +
use crate::repos::view::{self, FileBody};
14 +
use crate::Shared;
15 +
use askama::Template;
16 +
use axum::extract::{Form, Path, State};
17 +
use axum::response::{IntoResponse, Redirect, Response};
18 +
use oxy_core::repo::FileEdit;
19 +
use std::collections::HashMap;
20 +
21 +
/// A piece of a file rendered with `oxy` conflict markers (spec §9.5).
22 +
#[derive(Clone, Debug, PartialEq, Eq)]
23 +
pub enum Piece {
24 +
    /// Lines outside any conflict, kept as they are.
25 +
    Plain(String),
26 +
    Region(Region),
27 +
}
28 +
29 +
#[derive(Clone, Debug, PartialEq, Eq)]
30 +
pub struct Region {
31 +
    /// 1-based, as the markers number it.
32 +
    pub n: usize,
33 +
    pub sides: Vec<Side>,
34 +
}
35 +
36 +
#[derive(Clone, Debug, PartialEq, Eq)]
37 +
pub struct Side {
38 +
    /// "change 3f2a…", or "deleted by change …".
39 +
    pub label: String,
40 +
    /// The side's lines, each ending in `\n`.
41 +
    pub text: String,
42 +
    /// A deletion side: `text` is what was deleted, shown for context;
43 +
    /// keeping this side keeps the deletion, so it contributes nothing.
44 +
    pub deletion: bool,
45 +
}
46 +
47 +
impl Side {
48 +
    /// What keeping this side puts in the file.
49 +
    pub fn kept(&self) -> &str {
50 +
        if self.deletion {
51 +
            ""
52 +
        } else {
53 +
            &self.text
54 +
        }
55 +
    }
56 +
}
57 +
58 +
impl Region {
59 +
    /// Every side in order: what keeping all of them puts in the file.
60 +
    pub fn all(&self) -> String {
61 +
        self.sides.iter().map(Side::kept).collect()
62 +
    }
63 +
}
64 +
65 +
/// Split a marked rendering into plain text and regions. `None` when the
66 +
/// markers are malformed (the page then offers the plain editor).
67 +
pub fn parse(marked: &str) -> Option<Vec<Piece>> {
68 +
    let mut out = Vec::new();
69 +
    let mut plain = String::new();
70 +
    let mut region: Option<Region> = None;
71 +
    let mut lines = marked.split_inclusive('\n').peekable();
72 +
    while let Some(line) = lines.next() {
73 +
        let bare = line.trim_end_matches('\n');
74 +
        if let Some(rest) = bare.strip_prefix("<<<<<<< oxy conflict ") {
75 +
            if region.is_some() {
76 +
                return None;
77 +
            }
78 +
            if !plain.is_empty() {
79 +
                out.push(Piece::Plain(std::mem::take(&mut plain)));
80 +
            }
81 +
            let (n, label) = header(rest)?;
82 +
            let mut side = Side { label, text: String::new(), deletion: false };
83 +
            if lines.peek().map(|l| l.trim_end_matches('\n')) == Some("-------") {
84 +
                lines.next();
85 +
                side.deletion = true;
86 +
            }
87 +
            region = Some(Region { n, sides: vec![side] });
88 +
        } else if let Some(rest) = bare.strip_prefix("======= oxy conflict ") {
89 +
            let r = region.as_mut()?;
90 +
            let (n, label) = header(rest)?;
91 +
            if n != r.n {
92 +
                return None;
93 +
            }
94 +
            r.sides.push(Side { label, text: String::new(), deletion: false });
95 +
        } else if bare.starts_with(">>>>>>> oxy conflict ") {
96 +
            out.push(Piece::Region(region.take()?));
97 +
        } else {
98 +
            match region.as_mut() {
99 +
                Some(r) => r.sides.last_mut()?.text.push_str(line),
100 +
                None => plain.push_str(line),
101 +
            }
102 +
        }
103 +
    }
104 +
    if region.is_some() {
105 +
        return None;
106 +
    }
107 +
    if !plain.is_empty() {
108 +
        out.push(Piece::Plain(plain));
109 +
    }
110 +
    Some(out)
111 +
}
112 +
113 +
/// `N of T, side I of K: LABEL` → (N, LABEL).
114 +
fn header(rest: &str) -> Option<(usize, String)> {
115 +
    let (n, tail) = rest.split_once(" of ")?;
116 +
    let label = tail.split_once(": ").map(|(_, l)| l.to_string()).unwrap_or_default();
117 +
    Some((n.trim().parse().ok()?, label))
118 +
}
119 +
120 +
/// A user's choice for one region.
121 +
#[derive(Clone, Debug, PartialEq, Eq)]
122 +
pub enum Choice {
123 +
    Side(usize),
124 +
    All,
125 +
    None,
126 +
    Text(String),
127 +
}
128 +
129 +
/// The file with each region replaced by what was chosen for it; every
130 +
/// region needs a choice.
131 +
pub fn assemble(pieces: &[Piece], choices: &HashMap<usize, Choice>) -> Result<String, String> {
132 +
    let mut out = String::new();
133 +
    for p in pieces {
134 +
        match p {
135 +
            Piece::Plain(t) => out.push_str(t),
136 +
            Piece::Region(r) => match choices.get(&r.n) {
137 +
                Some(Choice::Side(i)) => {
138 +
                    out.push_str(r.sides.get(i.wrapping_sub(1)).ok_or_else(|| format!("conflict {} has no side {i}", r.n))?.kept())
139 +
                }
140 +
                Some(Choice::All) => out.push_str(&r.all()),
141 +
                Some(Choice::None) => {}
142 +
                Some(Choice::Text(t)) => {
143 +
                    let t = t.replace("\r\n", "\n");
144 +
                    out.push_str(&t);
145 +
                    if !t.is_empty() && !t.ends_with('\n') {
146 +
                        out.push('\n');
147 +
                    }
148 +
                }
149 +
                None => return Err(format!("choose how to resolve conflict {}", r.n)),
150 +
            },
151 +
        }
152 +
    }
153 +
    Ok(out)
154 +
}
155 +
156 +
#[derive(Template)]
157 +
#[template(path = "resolve.html")]
158 +
pub struct ResolvePage {
159 +
    shell: Shell,
160 +
    repo: RepoShell,
161 +
    bookmark: String,
162 +
    path: String,
163 +
    state: String,
164 +
    pieces: Vec<Piece>,
165 +
    delegation: Option<String>,
166 +
    error: Option<String>,
167 +
    message: String,
168 +
}
169 +
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> {
173 +
    let limit = app.config.limits.render_limit.0;
174 +
    let (b, p) = (bookmark.to_string(), path.to_string());
175 +
    let (state, file) = app
176 +
        .repos
177 +
        .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)?))
181 +
        })
182 +
        .await
183 +
        .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.")) };
185 +
    match file.body {
186 +
        FileBody::Text(t) if file.conflicted => Ok((state, t)),
187 +
        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.")),
189 +
    }
190 +
}
191 +
192 +
#[allow(clippy::too_many_arguments)]
193 +
async fn page(
194 +
    app: &Shared,
195 +
    viewer: &Viewer,
196 +
    owner: &str,
197 +
    name: &str,
198 +
    bookmark: &str,
199 +
    path: &str,
200 +
    error: Option<String>,
201 +
    message: Option<String>,
202 +
) -> Result<Page<ResolvePage>, Error> {
203 +
    let shell = Shell::new(app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), viewer);
204 +
    let me = require(viewer, &shell)?;
205 +
    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?;
207 +
    let Some(pieces) = parse(&text) else {
208 +
        return Err(Error::bad(shell, "The conflict markers could not be read; resolve the file in the plain editor."));
209 +
    };
210 +
    let delegation = signer_for(app, me.account.id).await.err();
211 +
    Ok(Page(ResolvePage {
212 +
        shell,
213 +
        repo: RepoShell::new(app, &row, "conflicts").with_role(role),
214 +
        bookmark: bookmark.to_string(),
215 +
        path: path.to_string(),
216 +
        state,
217 +
        pieces,
218 +
        delegation,
219 +
        error,
220 +
        message: message.unwrap_or_else(|| format!("Resolve the conflicts in {path}")),
221 +
    }))
222 +
}
223 +
224 +
pub async fn form(
225 +
    State(app): State<Shared>,
226 +
    viewer: Viewer,
227 +
    Path((owner, name, bookmark, path)): Path<(String, String, String, String)>,
228 +
) -> Result<Response, Error> {
229 +
    // Malformed markers: the plain editor still resolves the file.
230 +
    let shell = Shell::new(&app, "", &viewer);
231 +
    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());
235 +
    }
236 +
    Ok(page(&app, &viewer, &owner, &name, &bookmark, &path, None, None).await?.into_response())
237 +
}
238 +
239 +
pub async fn save(
240 +
    State(app): State<Shared>,
241 +
    viewer: Viewer,
242 +
    Path((owner, name, bookmark, path)): Path<(String, String, String, String)>,
243 +
    Form(f): Form<HashMap<String, String>>,
244 +
) -> Result<Response, Error> {
245 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), &viewer);
246 +
    let me = require(&viewer, &shell)?;
247 +
    csrf(&viewer, f.get("_csrf").map(String::as_str).unwrap_or(""), &shell)?;
248 +
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
249 +
    let message = f.get("message").map(|m| m.trim().to_string()).unwrap_or_default();
250 +
    let again = |msg: String| {
251 +
        let (app, viewer, owner, name, bookmark, path, message) =
252 +
            (app.clone(), viewer.clone(), owner.clone(), name.clone(), bookmark.clone(), path.clone(), message.clone());
253 +
        async move { page(&app, &viewer, &owner, &name, &bookmark, &path, Some(msg), Some(message)).await.map(IntoResponse::into_response) }
254 +
    };
255 +
    let (state, text) = load(&app, &shell, &repo, &bookmark, &path).await?;
256 +
    if f.get("state") != Some(&state) {
257 +
        return again(format!("{bookmark} has moved since this page was loaded; the conflicts below are as they are now.")).await;
258 +
    }
259 +
    let Some(pieces) = parse(&text) else { return again("The conflict markers could not be read.".into()).await };
260 +
    let mut choices = HashMap::new();
261 +
    for p in &pieces {
262 +
        if let Piece::Region(r) = p {
263 +
            let choice = match f.get(&format!("r{}", r.n)).map(String::as_str) {
264 +
                Some("all") => Choice::All,
265 +
                Some("none") => Choice::None,
266 +
                Some("text") => Choice::Text(f.get(&format!("t{}", r.n)).cloned().unwrap_or_default()),
267 +
                Some(s) => match s.strip_prefix("side").and_then(|i| i.parse().ok()) {
268 +
                    Some(i) => Choice::Side(i),
269 +
                    None => continue,
270 +
                },
271 +
                None => continue,
272 +
            };
273 +
            choices.insert(r.n, choice);
274 +
        }
275 +
    }
276 +
    let content = match assemble(&pieces, &choices) {
277 +
        Ok(c) => c,
278 +
        Err(msg) => return again(msg).await,
279 +
    };
280 +
    if message.is_empty() {
281 +
        return again("Describe the resolution.".into()).await;
282 +
    }
283 +
    let signer = match signer_for(&app, me.account.id).await {
284 +
        Ok(s) => s,
285 +
        Err(msg) => return again(msg).await,
286 +
    };
287 +
    let edit = vec![FileEdit { path: path.clone(), content: Some(content.into_bytes()) }];
288 +
    let done = match edits::commit(&app.repos, &repo, bookmark.clone(), bookmark.clone(), edit, message.clone(), signer).await {
289 +
        Ok(d) => d,
290 +
        Err(e) => return again(e.to_string()).await,
291 +
    };
292 +
    after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await;
293 +
    Ok(Redirect::to(&format!("/{owner}/{name}/change/{}", done.change.to_hex())).into_response())
294 +
}
295 +
296 +
#[cfg(test)]
297 +
mod tests {
298 +
    use super::*;
299 +
300 +
    const MARKED: &str = "a\n<<<<<<< oxy conflict 1 of 2, side 1 of 2: change 0202\nfrom-2\n======= oxy conflict 1 of 2, side 2 of 2: change 0303\nfrom-3\nmore-3\n>>>>>>> oxy conflict 1 of 2 end\nb\n<<<<<<< oxy conflict 2 of 2, side 1 of 2: deleted by change 0404\n-------\ngone\n======= oxy conflict 2 of 2, side 2 of 2: change 0505\nadded\n>>>>>>> oxy conflict 2 of 2 end\nc\n";
301 +
302 +
    #[test]
303 +
    fn markers_parse_into_regions_and_sides() {
304 +
        let p = parse(MARKED).unwrap();
305 +
        assert_eq!(p.len(), 5);
306 +
        assert_eq!(p[0], Piece::Plain("a\n".into()));
307 +
        let Piece::Region(r1) = &p[1] else { panic!() };
308 +
        assert_eq!((r1.n, r1.sides.len()), (1, 2));
309 +
        assert_eq!((r1.sides[0].label.as_str(), r1.sides[0].text.as_str()), ("change 0202", "from-2\n"));
310 +
        assert_eq!(r1.sides[1].text, "from-3\nmore-3\n");
311 +
        let Piece::Region(r2) = &p[3] else { panic!() };
312 +
        assert!(r2.sides[0].deletion && r2.sides[0].text == "gone\n" && r2.sides[0].kept().is_empty());
313 +
        assert_eq!(r2.sides[0].label, "deleted by change 0404");
314 +
        assert_eq!(p[4], Piece::Plain("c\n".into()));
315 +
        assert!(parse("<<<<<<< oxy conflict 1 of 1, side 1 of 2: change x\nno end\n").is_none());
316 +
        assert_eq!(parse("plain\n").unwrap(), vec![Piece::Plain("plain\n".into())]);
317 +
    }
318 +
319 +
    #[test]
320 +
    fn choices_assemble_the_file() {
321 +
        let p = parse(MARKED).unwrap();
322 +
        let pick = |a: Choice, b: Choice| assemble(&p, &HashMap::from([(1, a), (2, b)]));
323 +
        assert_eq!(pick(Choice::Side(2), Choice::Side(1)).unwrap(), "a\nfrom-3\nmore-3\nb\nc\n", "the deletion wins: nothing");
324 +
        assert_eq!(pick(Choice::All, Choice::Side(2)).unwrap(), "a\nfrom-2\nfrom-3\nmore-3\nb\nadded\nc\n");
325 +
        assert_eq!(pick(Choice::None, Choice::Text("mine".into())).unwrap(), "a\nb\nmine\nc\n", "own text ends its line");
326 +
        assert!(pick(Choice::Side(3), Choice::None).unwrap_err().contains("no side 3"));
327 +
        assert!(assemble(&p, &HashMap::from([(1, Choice::None)])).unwrap_err().contains("conflict 2"));
328 +
    }
329 +
}
added templates/resolve.html +41 −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">Choose what each conflict keeps. The file is put back together around the lines no one disagrees on and committed to {{ bookmark }} as a resolution: it depends on every change that wrote a side, so no side is lost from history. Link to a conflict with its <code>#region-N</code>.</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 p in pieces %}
16 +
{% match p %}
17 +
{% when crate::web::resolve::Piece::Plain with (text) %}
18 +
<pre class="marked context">{{ text }}</pre>
19 +
{% when crate::web::resolve::Piece::Region with (r) %}
20 +
<section class="file-diff conflict-region" id="region-{{ r.n }}">
21 +
  <header><a href="#region-{{ r.n }}"><strong>Conflict {{ r.n }}</strong></a> <span class="small mute">{{ r.sides.len() }} sides</span></header>
22 +
  {% for s in r.sides %}
23 +
  <div class="side">
24 +
    <label><input type="radio" name="r{{ r.n }}" value="side{{ loop.index }}"{% if loop.first %} checked{% endif %}> keep side {{ loop.index }} <span class="small mute">{{ s.label }}</span></label>
25 +
    {% if s.deletion %}<p class="small mute">Keeping this side keeps the deletion of these lines:</p>{% endif %}
26 +
    <pre class="marked{% if s.deletion %} deleted{% endif %}">{{ s.text }}</pre>
27 +
  </div>
28 +
  {% endfor %}
29 +
  <div class="choice">
30 +
    <label><input type="radio" name="r{{ r.n }}" value="all"> keep every side, in order</label>
31 +
    <label><input type="radio" name="r{{ r.n }}" value="none"> keep none</label>
32 +
    <label><input type="radio" name="r{{ r.n }}" value="text"> write it myself:</label>
33 +
  </div>
34 +
  <textarea name="t{{ r.n }}" rows="{{ r.all().lines().count() + 1 }}" aria-label="Your text for conflict {{ r.n }}">{{ r.all() }}</textarea>
35 +
</section>
36 +
{% endmatch %}
37 +
{% endfor %}
38 +
<label for="message">Message</label><input type="text" id="message" name="message" value="{{ message }}">
39 +
<button type="submit">COMMIT RESOLUTION</button>
40 +
</form>
41 +
{% endblock %}
modified crates/hub/src/web/edit.rs +36 −22
…
210 unchanged lines
211 211
        Ok(d) => d,
212 212
        Err(e) => return again(e.to_string()).await,
213 213
    };
214 -
    // What a push would have caused: the push log, webhooks, closing keywords.
214 +
    after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await;
215 +
    if f.mode == "propose" {
216 +
        match super::proposals::open_proposal(&app, &repo, &row, &me.account, &message, "", &r, &done.bookmark, false).await {
217 +
            Ok(p) => return Ok(Redirect::to(&format!("/{owner}/{name}/proposal/{}", p.number)).into_response()),
218 +
            Err(msg) => {
219 +
                return Ok(Redirect::to(&format!("/{owner}/{name}/tree/{}?notice={}", done.bookmark, super::settings::urlencode(&msg)))
220 +
                    .into_response())
221 +
            }
222 +
        }
223 +
    }
224 +
    let to = if f.delete.is_some() {
225 +
        format!("/{owner}/{name}/tree/{}", done.bookmark)
226 +
    } else {
227 +
        format!("/{owner}/{name}/file/{}/{}", done.bookmark, path)
228 +
    };
229 +
    Ok(Redirect::to(&to).into_response())
230 +
}
231 +
232 +
/// What a push would have caused, for a change the hub committed for a
233 +
/// user: the push log, webhooks, a Flow run, closing keywords, the Git
234 +
/// mirror, and the audit log.
235 +
pub(super) async fn after_web_commit(
236 +
    app: &Shared,
237 +
    account: &accounts::Account,
238 +
    row: &db::repos::RepoRow,
239 +
    owner: &str,
240 +
    name: &str,
241 +
    done: &edits::Committed,
242 +
    message: &str,
243 +
) {
215 244
    let outcome = db::repos::PushOutcome {
216 245
        reference: done.bookmark.clone(),
217 246
        ok: true,
…
3 unchanged lines
221 250
        heads: done.heads.len() as i64,
222 251
        changes: 1,
223 252
    };
224 -
    db::repos::record_push(&app.db, row.id, Some((me.account.id, &me.account.username)), &[outcome]).await.ok();
225 -
    crate::hooks::emit(&app, &row, "push", &me.account.username, serde_json::json!({ "updates": [{ "reference": done.bookmark, "old_state": done.old_state, "new_state": done.new_state, "heads": done.heads.len(), "changes": 1 }], "via": "web" })).await;
226 -
    crate::flow::on_push(&app, &row, &done.bookmark, &done.new_state, &done.heads, &me.account.username).await;
253 +
    db::repos::record_push(&app.db, row.id, Some((account.id, &account.username)), &[outcome]).await.ok();
254 +
    crate::hooks::emit(app, row, "push", &account.username, serde_json::json!({ "updates": [{ "reference": done.bookmark, "old_state": done.old_state, "new_state": done.new_state, "heads": done.heads.len(), "changes": 1 }], "via": "web" })).await;
255 +
    crate::flow::on_push(app, row, &done.bookmark, &done.new_state, &done.heads, &account.username).await;
227 256
    if done.bookmark == row.default_bookmark {
228 -
        super::issues::close_from_messages(&app, &row, &[(done.change.to_hex(), message.clone())]).await;
229 -
        crate::mirror::refresh(&app, &row).await;
257 +
        super::issues::close_from_messages(app, row, &[(done.change.to_hex(), message.to_string())]).await;
258 +
        crate::mirror::refresh(app, row).await;
230 259
    }
231 260
    accounts::audit(
232 261
        &app.db,
233 -
        Some(&me.account),
262 +
        Some(account),
234 263
        "web.edit",
235 264
        &format!("{owner}/{name}"),
236 265
        &format!("{} {}", done.bookmark, done.change.short(12)),
237 266
    )
238 267
    .await
239 268
    .ok();
240 -
    if f.mode == "propose" {
241 -
        match super::proposals::open_proposal(&app, &repo, &row, &me.account, &message, "", &r, &done.bookmark, false).await {
242 -
            Ok(p) => return Ok(Redirect::to(&format!("/{owner}/{name}/proposal/{}", p.number)).into_response()),
243 -
            Err(msg) => {
244 -
                return Ok(Redirect::to(&format!("/{owner}/{name}/tree/{}?notice={}", done.bookmark, super::settings::urlencode(&msg)))
245 -
                    .into_response())
246 -
            }
247 -
        }
248 -
    }
249 -
    let to = if f.delete.is_some() {
250 -
        format!("/{owner}/{name}/tree/{}", done.bookmark)
251 -
    } else {
252 -
        format!("/{owner}/{name}/file/{}/{}", done.bookmark, path)
253 -
    };
254 -
    Ok(Redirect::to(&to).into_response())
255 269
}
modified crates/hub/src/web/mod.rs +2 −0
…
18 unchanged lines
19 19
pub mod releases;
20 20
mod repo;
21 21
mod repo_settings;
22 +
mod resolve;
22 23
mod session;
23 24
mod settings;
24 25
mod statics;
…
92 unchanged lines
117 118
        .route("/{owner}/{name}/star", post(issues::star))
118 119
        .route("/{owner}/{name}/edit/{r}", post(edit::save))
119 120
        .route("/{owner}/{name}/edit/{r}/{*path}", get(edit::edit_form))
121 +
        .route("/{owner}/{name}/resolve/{bookmark}/{*path}", get(resolve::form).post(resolve::save))
120 122
        .route("/{owner}/{name}/newfile/{r}", get(edit::new_form))
121 123
        .route("/{owner}/{name}/flow", get(flow::runs))
122 124
        .route("/{owner}/{name}/flow/{id}", get(flow::run))
…
434 unchanged lines
modified crates/hub/tests/edits.rs +30 −7
…
241 unchanged lines
242 242
        assert!(r.body.contains("Landed."), "#{n}: {}", r.body);
243 243
    }
244 244
    let conflicts = send(app, "GET", "/andy/demo/conflicts", Some(&andy), None).await.body;
245 -
    assert!(conflicts.contains("resolve in the browser"), "{conflicts}");
245 +
    assert!(conflicts.contains("resolve region by region") && conflicts.contains("/andy/demo/resolve/main/a.txt"), "{conflicts}");
246 246
    let page = send(app, "GET", "/andy/demo/edit/main/a.txt", Some(&andy), None).await;
247 247
    assert!(page.body.contains("Resolve a.txt") && page.body.contains("&#60;&#60;&#60;&#60;&#60;&#60;&#60; oxy conflict"), "{}", page.body);
248 248
    // Leaving markers in is refused; removing them resolves.
…
9 unchanged lines
258 258
    )
259 259
    .await;
260 260
    assert_ne!(r.status, StatusCode::SEE_OTHER);
261 +
    // The resolver: region by region, against the state it showed.
262 +
    let page = send(app, "GET", "/andy/demo/resolve/main/a.txt", Some(&andy), None).await;
263 +
    assert!(
264 +
        page.body.contains("id=\"region-1\"") && page.body.contains("keep side 2") && page.body.contains("keep every side"),
265 +
        "{}",
266 +
        page.body
267 +
    );
268 +
    let state = {
269 +
        let i = page.body.find("name=\"state\" value=\"").unwrap() + "name=\"state\" value=\"".len();
270 +
        page.body[i..i + 64].to_string()
271 +
    };
272 +
    let stale = send(
273 +
        app,
274 +
        "POST",
275 +
        "/andy/demo/resolve/main/a.txt",
276 +
        Some(&andy),
277 +
        Some(&format!("_csrf={csrf}&state={}&r1=side2&message=x", "0".repeat(64))),
278 +
    )
279 +
    .await;
280 +
    assert!(stale.body.contains("has moved since this page was loaded"), "{}", stale.body);
281 +
    let unchosen =
282 +
        send(app, "POST", "/andy/demo/resolve/main/a.txt", Some(&andy), Some(&format!("_csrf={csrf}&state={state}&message=x"))).await;
283 +
    assert!(unchosen.body.contains("choose how to resolve conflict 1"), "{}", unchosen.body);
261 284
    let r = send(
262 285
        app,
263 286
        "POST",
264 -
        "/andy/demo/edit/main",
287 +
        "/andy/demo/resolve/main/a.txt",
265 288
        Some(&andy),
266 -
        Some(&format!(
267 -
            "_csrf={csrf}&path=a.txt&original=a.txt&content={}&message={}&mode=commit",
268 -
            enc("Both\ntwo\nthree\n"),
269 -
            enc("Resolve a.txt")
270 -
        )),
289 +
        Some(&format!("_csrf={csrf}&state={state}&r1=text&t1={}&message={}", enc("Both"), enc("Resolve a.txt"))),
271 290
    )
272 291
    .await;
273 292
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
293 +
    let change = r.location.unwrap();
294 +
    assert!(change.contains("/andy/demo/change/"), "{change}");
274 295
    assert!(send(app, "GET", "/andy/demo/conflicts", None, None).await.body.contains("No bookmark carries a conflict"));
275 296
    assert_eq!(send(app, "GET", "/andy/demo/file/main/a.txt?plain=1", None, None).await.body, "Both\ntwo\nthree\n");
297 +
    let shown = send(app, "GET", &change, Some(&andy), None).await.body;
298 +
    assert!(shown.to_lowercase().contains("resolution"), "a resolution change");
276 299
277 300
    // Delete a file.
278 301
    let r = send(
…
16 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. A "resolve in browser" action opens the file's conflict markers in the editor (§6.9) and commits a resolution change through the delegate. 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. 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/change.html +1 −1
…
5 unchanged lines
6 6
{% block mainclass %} class="wide"{% endblock %}
7 7
{% block content %}
8 8
<article class="change">
9 -
  <div class="head"><h1>{{ c.view.title }} <small class="px">{{ c.view.short }}</small></h1></div>
9 +
  <div class="head"><h1>{{ c.view.title }} <small class="px">{{ c.view.short }}</small>{% if c.view.resolution %} <span class="tag" title="Resolves conflicts; depends on every change that wrote a side">resolution</span>{% endif %}</h1></div>
10 10
  {{ body_html|safe }}
11 11
  <dl class="facts">
12 12
    <dt>Author</dt><dd>{% if let Some(a) = c.view.author.account %}<a href="/{{ a }}">{{ c.view.author.name }}</a> <span class="tag">@{{ a }}</span>{% else %}{{ c.view.author.name }}{% endif %}{% if !c.view.author.email.is_empty() %} <span class="mute">&lt;{{ c.view.author.email }}&gt;</span>{% endif %}{% if c.view.author.unknown && !c.view.author.key.is_empty() %} <span class="tag">unlinked key</span>{% endif %}</dd>
…
14 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.marked.is_some() && repo.can(crate::auth::Access::Write) %}<p><a class="go small" href="{{ repo.url }}/edit/{{ c.bookmark }}/{{ c.path }}">resolve in the browser</a></p>{% endif %}
15 +
  {% 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 16
  <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 17
</section>
18 18
{% endfor %}
…
2 unchanged lines