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 |