OXYGENHUB
# 16cb2b3aa947 oxygenhub / crates / hub / src / web / resolve.rs
20.9 KiB · Rust · last changed Resolve in VS Code; the name conflict test picks its candidate reliably 1 month ago source blame history raw
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
/// "Resolve in VS Code": the link oxygen-vscode handles (its README).
22
pub fn vscode_link(app: &Shared, owner: &str, name: &str, bookmark: &str, path: &str) -> String {
23
    use super::settings::urlencode;
24
    let url = format!("{}/{owner}/{name}", app.config.base_url().trim_end_matches('/'));
25
    format!("vscode://oxygen.oxygen-vscode/resolve?url={}&bookmark={}&path={}", urlencode(&url), urlencode(bookmark), urlencode(path))
26
}
27
28
/// A piece of a file rendered with `oxy` conflict markers (spec §9.5).
29
#[derive(Clone, Debug, PartialEq, Eq)]
30
pub enum Piece {
31
    /// Lines outside any conflict, kept as they are.
32
    Plain(String),
33
    Region(Region),
34
}
35
36
#[derive(Clone, Debug, PartialEq, Eq)]
37
pub struct Region {
38
    /// 1-based, as the markers number it.
39
    pub n: usize,
40
    pub sides: Vec<Side>,
41
}
42
43
#[derive(Clone, Debug, PartialEq, Eq)]
44
pub struct Side {
45
    /// "change 3f2a…", or "deleted by change …".
46
    pub label: String,
47
    /// The side's lines, each ending in `\n`.
48
    pub text: String,
49
    /// A deletion side: `text` is what was deleted, shown for context;
50
    /// keeping this side keeps the deletion, so it contributes nothing.
51
    pub deletion: bool,
52
}
53
54
impl Side {
55
    /// What keeping this side puts in the file.
56
    pub fn kept(&self) -> &str {
57
        if self.deletion {
58
            ""
59
        } else {
60
            &self.text
61
        }
62
    }
63
}
64
65
impl Region {
66
    /// Every side in order: what keeping all of them puts in the file.
67
    pub fn all(&self) -> String {
68
        self.sides.iter().map(Side::kept).collect()
69
    }
70
}
71
72
/// Split a marked rendering into plain text and regions. `None` when the
73
/// markers are malformed (the page then offers the plain editor).
74
pub fn parse(marked: &str) -> Option<Vec<Piece>> {
75
    let mut out = Vec::new();
76
    let mut plain = String::new();
77
    let mut region: Option<Region> = None;
78
    let mut lines = marked.split_inclusive('\n').peekable();
79
    while let Some(line) = lines.next() {
80
        let bare = line.trim_end_matches('\n');
81
        if let Some(rest) = bare.strip_prefix("<<<<<<< oxy conflict ") {
82
            if region.is_some() {
83
                return None;
84
            }
85
            if !plain.is_empty() {
86
                out.push(Piece::Plain(std::mem::take(&mut plain)));
87
            }
88
            let (n, label) = header(rest)?;
89
            let mut side = Side { label, text: String::new(), deletion: false };
90
            if lines.peek().map(|l| l.trim_end_matches('\n')) == Some("-------") {
91
                lines.next();
92
                side.deletion = true;
93
            }
94
            region = Some(Region { n, sides: vec![side] });
95
        } else if let Some(rest) = bare.strip_prefix("======= oxy conflict ") {
96
            let r = region.as_mut()?;
97
            let (n, label) = header(rest)?;
98
            if n != r.n {
99
                return None;
100
            }
101
            r.sides.push(Side { label, text: String::new(), deletion: false });
102
        } else if bare.starts_with(">>>>>>> oxy conflict ") {
103
            out.push(Piece::Region(region.take()?));
104
        } else {
105
            match region.as_mut() {
106
                Some(r) => r.sides.last_mut()?.text.push_str(line),
107
                None => plain.push_str(line),
108
            }
109
        }
110
    }
111
    if region.is_some() {
112
        return None;
113
    }
114
    if !plain.is_empty() {
115
        out.push(Piece::Plain(plain));
116
    }
117
    Some(out)
118
}
119
120
/// `N of T, side I of K: LABEL` → (N, LABEL).
121
fn header(rest: &str) -> Option<(usize, String)> {
122
    let (n, tail) = rest.split_once(" of ")?;
123
    let label = tail.split_once(": ").map(|(_, l)| l.to_string()).unwrap_or_default();
124
    Some((n.trim().parse().ok()?, label))
125
}
126
127
/// A user's choice for one region.
128
#[derive(Clone, Debug, PartialEq, Eq)]
129
pub enum Choice {
130
    Side(usize),
131
    All,
132
    None,
133
    Text(String),
134
}
135
136
/// The file with each region replaced by what was chosen for it; every
137
/// region needs a choice.
138
pub fn assemble(pieces: &[Piece], choices: &HashMap<usize, Choice>) -> Result<String, String> {
139
    let mut out = String::new();
140
    for p in pieces {
141
        match p {
142
            Piece::Plain(t) => out.push_str(t),
143
            Piece::Region(r) => match choices.get(&r.n) {
144
                Some(Choice::Side(i)) => {
145
                    out.push_str(r.sides.get(i.wrapping_sub(1)).ok_or_else(|| format!("conflict {} has no side {i}", r.n))?.kept())
146
                }
147
                Some(Choice::All) => out.push_str(&r.all()),
148
                Some(Choice::None) => {}
149
                Some(Choice::Text(t)) => {
150
                    let t = t.replace("\r\n", "\n");
151
                    out.push_str(&t);
152
                    if !t.is_empty() && !t.ends_with('\n') {
153
                        out.push('\n');
154
                    }
155
                }
156
                None => return Err(format!("choose how to resolve conflict {}", r.n)),
157
            },
158
        }
159
    }
160
    Ok(out)
161
}
162
163
/// One of the entries a name conflict shows at `NAME.oxy-conflict-N`.
164
#[derive(Clone, Debug)]
165
pub struct Candidate {
166
    pub path: String,
167
    pub content: Vec<u8>,
168
    /// The start of a text candidate, for the page.
169
    pub preview: Option<String>,
170
}
171
172
/// The candidates of a name conflict at `path` in a loaded state: the
173
/// files the tree shows as `path.oxy-conflict-N`. `None` when `path` has no
174
/// name conflict; an error for one between directories or links.
175
fn name_candidates(r: &oxy_core::repo::Repo, snapshot: &oxy_core::state::Snapshot, path: &str) -> Result<Option<Vec<Candidate>>, String> {
176
    use oxy_core::objects::NodeKind;
177
    use oxy_core::state::FileContent;
178
    use oxy_core::tree::TreeConflict;
179
    let by_node = snapshot.tree.by_node();
180
    for c in &snapshot.tree.conflicts {
181
        let TreeConflict::Name { parent, name, nodes } = c else { continue };
182
        let full = if parent.is_root() {
183
            name.clone()
184
        } else {
185
            match by_node.get(parent) {
186
                Some(e) => format!("{}/{name}", e.path),
187
                None => continue,
188
            }
189
        };
190
        if full != path {
191
            continue;
192
        }
193
        let mut out = Vec::new();
194
        for n in nodes {
195
            let Some(e) = by_node.get(n) else { continue };
196
            let f = snapshot.files.get(&e.path).ok_or("a candidate is missing")?;
197
            if f.kind != NodeKind::File {
198
                return Err("This conflict is between directories or links; resolve it from a client.".into());
199
            }
200
            let content = match &f.content {
201
                FileContent::Text { bytes, .. } => bytes.clone(),
202
                FileContent::Opaque { blobs } => match blobs.first() {
203
                    Some(b) => r.read_blob(b).map_err(|e| e.to_string())?,
204
                    None => vec![],
205
                },
206
                FileContent::Dir => continue,
207
            };
208
            let preview = std::str::from_utf8(&content).ok().map(|t| t.lines().take(40).collect::<Vec<_>>().join("\n"));
209
            out.push(Candidate { path: e.path.clone(), content, preview });
210
        }
211
        out.sort_by(|a, b| a.path.cmp(&b.path));
212
        return Ok(Some(out));
213
    }
214
    Ok(None)
215
}
216
217
/// What resolving `path` on a bookmark means.
218
enum Target {
219
    /// A conflicted text file, rendered with markers.
220
    Lines(String),
221
    /// A name conflict: keep one candidate at `path`.
222
    Name(Vec<Candidate>),
223
}
224
225
#[derive(Template)]
226
#[template(path = "resolve_name.html")]
227
pub struct NamePage {
228
    shell: Shell,
229
    repo: RepoShell,
230
    bookmark: String,
231
    path: String,
232
    state: String,
233
    candidates: Vec<Candidate>,
234
    vscode: String,
235
    delegation: Option<String>,
236
    error: Option<String>,
237
    message: String,
238
}
239
240
#[derive(Template)]
241
#[template(path = "resolve.html")]
242
pub struct ResolvePage {
243
    shell: Shell,
244
    repo: RepoShell,
245
    bookmark: String,
246
    path: String,
247
    state: String,
248
    pieces: Vec<Piece>,
249
    /// The link that hands this conflict to oxygen-vscode.
250
    vscode: String,
251
    delegation: Option<String>,
252
    error: Option<String>,
253
    message: String,
254
}
255
256
/// The bookmark's state and what resolving `path` there means.
257
async fn load(app: &Shared, shell: &Shell, repo: &crate::repos::RepoName, bookmark: &str, path: &str) -> Result<(String, Target), Error> {
258
    let limit = app.config.limits.render_limit.0;
259
    let (b, p) = (bookmark.to_string(), path.to_string());
260
    let found = app
261
        .repos
262
        .read(repo, move |r, cache| {
263
            let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
264
            let heads = r.store().get_state(&st)?.heads;
265
            let loaded = cache.load(r, &heads)?;
266
            let names = name_candidates(r, &loaded.snapshot, &p);
267
            let file = if names.as_ref().is_ok_and(|n| n.is_some()) {
268
                None
269
            } else {
270
                let target = view::resolve(r, cache, &b)?;
271
                Some(view::file(r, cache, target, &p, limit))
272
            };
273
            Ok(Some((st.to_hex(), names, file)))
274
        })
275
        .await
276
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
277
    let Some((state, names, file)) = found else { return Err(Error::bad(shell.clone(), "Conflicts are resolved on a bookmark.")) };
278
    match names {
279
        Ok(Some(c)) => return Ok((state, Target::Name(c))),
280
        Err(msg) => return Err(Error::bad(shell.clone(), &msg)),
281
        Ok(None) => {}
282
    }
283
    let file = match file {
284
        Some(Ok(f)) => f,
285
        Some(Err(e)) => return Err(Error::from_repo(shell.clone(), e)),
286
        None => return Err(Error::bad(shell.clone(), "Nothing to resolve there.")),
287
    };
288
    match file.body {
289
        FileBody::Text(t) if file.conflicted => Ok((state, Target::Lines(t))),
290
        FileBody::Text(_) => Err(Error::bad(shell.clone(), "That file has no conflicts on this bookmark.")),
291
        _ => Err(Error::bad(shell.clone(), "Only text files within the render limit, and name conflicts, are resolved in the browser.")),
292
    }
293
}
294
295
#[allow(clippy::too_many_arguments)]
296
async fn page(
297
    app: &Shared,
298
    viewer: &Viewer,
299
    owner: &str,
300
    name: &str,
301
    bookmark: &str,
302
    path: &str,
303
    error: Option<String>,
304
    message: Option<String>,
305
) -> Result<Page<ResolvePage>, Error> {
306
    let shell = Shell::new(app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), viewer);
307
    let me = require(viewer, &shell)?;
308
    let (repo, row, role) = lookup_role(app, &shell, viewer, owner, name, Access::Write).await?;
309
    let (state, target) = load(app, &shell, &repo, bookmark, path).await?;
310
    let text = match target {
311
        Target::Lines(t) => t,
312
        Target::Name(_) => return Err(Error::bad(shell, "That is a name conflict.")),
313
    };
314
    let Some(pieces) = parse(&text) else {
315
        return Err(Error::bad(shell, "The conflict markers could not be read; resolve the file in the plain editor."));
316
    };
317
    let delegation = signer_for(app, me.account.id).await.err();
318
    Ok(Page(ResolvePage {
319
        shell,
320
        repo: RepoShell::new(app, &row, "conflicts").with_role(role),
321
        bookmark: bookmark.to_string(),
322
        path: path.to_string(),
323
        state,
324
        pieces,
325
        vscode: vscode_link(app, owner, name, bookmark, path),
326
        delegation,
327
        error,
328
        message: message.unwrap_or_else(|| format!("Resolve the conflicts in {path}")),
329
    }))
330
}
331
332
pub async fn form(
333
    State(app): State<Shared>,
334
    viewer: Viewer,
335
    Path((owner, name, bookmark, path)): Path<(String, String, String, String)>,
336
) -> Result<Response, Error> {
337
    let shell = Shell::new(&app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), &viewer);
338
    let (repo, _, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
339
    match load(&app, &shell, &repo, &bookmark, &path).await?.1 {
340
        // Malformed markers: the plain editor still resolves the file.
341
        Target::Lines(text) if parse(&text).is_none() => {
342
            Ok(Redirect::to(&format!("/{owner}/{name}/edit/{bookmark}/{path}")).into_response())
343
        }
344
        Target::Lines(_) => Ok(page(&app, &viewer, &owner, &name, &bookmark, &path, None, None).await?.into_response()),
345
        Target::Name(_) => Ok(name_page(&app, &viewer, &owner, &name, &bookmark, &path, None, None).await?.into_response()),
346
    }
347
}
348
349
#[allow(clippy::too_many_arguments)]
350
async fn name_page(
351
    app: &Shared,
352
    viewer: &Viewer,
353
    owner: &str,
354
    name: &str,
355
    bookmark: &str,
356
    path: &str,
357
    error: Option<String>,
358
    message: Option<String>,
359
) -> Result<Page<NamePage>, Error> {
360
    let shell = Shell::new(app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), viewer);
361
    let me = require(viewer, &shell)?;
362
    let (repo, row, role) = lookup_role(app, &shell, viewer, owner, name, Access::Write).await?;
363
    let (state, target) = load(app, &shell, &repo, bookmark, path).await?;
364
    let Target::Name(candidates) = target else { return Err(Error::bad(shell, "That is not a name conflict.")) };
365
    let delegation = signer_for(app, me.account.id).await.err();
366
    Ok(Page(NamePage {
367
        shell,
368
        repo: RepoShell::new(app, &row, "conflicts").with_role(role),
369
        bookmark: bookmark.to_string(),
370
        path: path.to_string(),
371
        state,
372
        candidates,
373
        vscode: vscode_link(app, owner, name, bookmark, path),
374
        delegation,
375
        error,
376
        message: message.unwrap_or_else(|| format!("Resolve the name conflict at {path}")),
377
    }))
378
}
379
380
pub async fn save(
381
    State(app): State<Shared>,
382
    viewer: Viewer,
383
    Path((owner, name, bookmark, path)): Path<(String, String, String, String)>,
384
    Form(f): Form<HashMap<String, String>>,
385
) -> Result<Response, Error> {
386
    let shell = Shell::new(&app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), &viewer);
387
    let me = require(&viewer, &shell)?;
388
    csrf(&viewer, f.get("_csrf").map(String::as_str).unwrap_or(""), &shell)?;
389
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
390
    let message = f.get("message").map(|m| m.trim().to_string()).unwrap_or_default();
391
    let again = |msg: String| {
392
        let (app, viewer, owner, name, bookmark, path, message) =
393
            (app.clone(), viewer.clone(), owner.clone(), name.clone(), bookmark.clone(), path.clone(), message.clone());
394
        async move { page(&app, &viewer, &owner, &name, &bookmark, &path, Some(msg), Some(message)).await.map(IntoResponse::into_response) }
395
    };
396
    let (state, target) = load(&app, &shell, &repo, &bookmark, &path).await?;
397
    let stale = f.get("state") != Some(&state);
398
    let text = match target {
399
        Target::Lines(t) => t,
400
        Target::Name(candidates) => {
401
            let again = |msg: String| {
402
                let (app, viewer, owner, name, bookmark, path, message) =
403
                    (app.clone(), viewer.clone(), owner.clone(), name.clone(), bookmark.clone(), path.clone(), message.clone());
404
                async move {
405
                    name_page(&app, &viewer, &owner, &name, &bookmark, &path, Some(msg), Some(message))
406
                        .await
407
                        .map(IntoResponse::into_response)
408
                }
409
            };
410
            if stale {
411
                return again(format!("{bookmark} has moved since this page was loaded; the candidates below are as they are now.")).await;
412
            }
413
            let Some(keep) = f.get("keep").and_then(|k| k.parse::<usize>().ok()).and_then(|k| candidates.get(k)) else {
414
                return again("Choose the candidate to keep.".into()).await;
415
            };
416
            if message.is_empty() {
417
                return again("Describe the resolution.".into()).await;
418
            }
419
            let signer = match signer_for(&app, me.account.id).await {
420
                Ok(s) => s,
421
                Err(msg) => return again(msg).await,
422
            };
423
            // The kept candidate's content at the name, every candidate
424
            // gone: the snapshot pairs the kept one with the name as a
425
            // move, so it keeps its identity.
426
            let mut edit: Vec<FileEdit> = candidates.iter().map(|c| FileEdit { path: c.path.clone(), content: None }).collect();
427
            edit.push(FileEdit { path: path.clone(), content: Some(keep.content.clone()) });
428
            let done = match edits::commit(&app.repos, &repo, bookmark.clone(), bookmark.clone(), edit, message.clone(), signer).await {
429
                Ok(d) => d,
430
                Err(e) => return again(e.to_string()).await,
431
            };
432
            after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await;
433
            return Ok(Redirect::to(&format!("/{owner}/{name}/change/{}", done.change.to_hex())).into_response());
434
        }
435
    };
436
    if stale {
437
        return again(format!("{bookmark} has moved since this page was loaded; the conflicts below are as they are now.")).await;
438
    }
439
    let Some(pieces) = parse(&text) else { return again("The conflict markers could not be read.".into()).await };
440
    let mut choices = HashMap::new();
441
    for p in &pieces {
442
        if let Piece::Region(r) = p {
443
            let choice = match f.get(&format!("r{}", r.n)).map(String::as_str) {
444
                Some("all") => Choice::All,
445
                Some("none") => Choice::None,
446
                Some("text") => Choice::Text(f.get(&format!("t{}", r.n)).cloned().unwrap_or_default()),
447
                Some(s) => match s.strip_prefix("side").and_then(|i| i.parse().ok()) {
448
                    Some(i) => Choice::Side(i),
449
                    None => continue,
450
                },
451
                None => continue,
452
            };
453
            choices.insert(r.n, choice);
454
        }
455
    }
456
    let content = match assemble(&pieces, &choices) {
457
        Ok(c) => c,
458
        Err(msg) => return again(msg).await,
459
    };
460
    if message.is_empty() {
461
        return again("Describe the resolution.".into()).await;
462
    }
463
    let signer = match signer_for(&app, me.account.id).await {
464
        Ok(s) => s,
465
        Err(msg) => return again(msg).await,
466
    };
467
    let edit = vec![FileEdit { path: path.clone(), content: Some(content.into_bytes()) }];
468
    let done = match edits::commit(&app.repos, &repo, bookmark.clone(), bookmark.clone(), edit, message.clone(), signer).await {
469
        Ok(d) => d,
470
        Err(e) => return again(e.to_string()).await,
471
    };
472
    after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await;
473
    Ok(Redirect::to(&format!("/{owner}/{name}/change/{}", done.change.to_hex())).into_response())
474
}
475
476
#[cfg(test)]
477
mod tests {
478
    use super::*;
479
480
    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";
481
482
    #[test]
483
    fn markers_parse_into_regions_and_sides() {
484
        let p = parse(MARKED).unwrap();
485
        assert_eq!(p.len(), 5);
486
        assert_eq!(p[0], Piece::Plain("a\n".into()));
487
        let Piece::Region(r1) = &p[1] else { panic!() };
488
        assert_eq!((r1.n, r1.sides.len()), (1, 2));
489
        assert_eq!((r1.sides[0].label.as_str(), r1.sides[0].text.as_str()), ("change 0202", "from-2\n"));
490
        assert_eq!(r1.sides[1].text, "from-3\nmore-3\n");
491
        let Piece::Region(r2) = &p[3] else { panic!() };
492
        assert!(r2.sides[0].deletion && r2.sides[0].text == "gone\n" && r2.sides[0].kept().is_empty());
493
        assert_eq!(r2.sides[0].label, "deleted by change 0404");
494
        assert_eq!(p[4], Piece::Plain("c\n".into()));
495
        assert!(parse("<<<<<<< oxy conflict 1 of 1, side 1 of 2: change x\nno end\n").is_none());
496
        assert_eq!(parse("plain\n").unwrap(), vec![Piece::Plain("plain\n".into())]);
497
    }
498
499
    #[test]
500
    fn choices_assemble_the_file() {
501
        let p = parse(MARKED).unwrap();
502
        let pick = |a: Choice, b: Choice| assemble(&p, &HashMap::from([(1, a), (2, b)]));
503
        assert_eq!(pick(Choice::Side(2), Choice::Side(1)).unwrap(), "a\nfrom-3\nmore-3\nb\nc\n", "the deletion wins: nothing");
504
        assert_eq!(pick(Choice::All, Choice::Side(2)).unwrap(), "a\nfrom-2\nfrom-3\nmore-3\nb\nadded\nc\n");
505
        assert_eq!(pick(Choice::None, Choice::Text("mine".into())).unwrap(), "a\nb\nmine\nc\n", "own text ends its line");
506
        assert!(pick(Choice::Side(3), Choice::None).unwrap_err().contains("no side 3"));
507
        assert!(assemble(&p, &HashMap::from([(1, Choice::None)])).unwrap_err().contains("conflict 2"));
508
    }
509
}