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("<<<<<<< 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"><{{ c.view.author.email }}></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 |