OXYGENHUB
# fd958fbc06fb oxygenhub / crates / hub / src / web / resolve.rs
20.2 KiB · Rust · last changed Resolve name conflicts by keeping a candidate 1 month ago source blame history raw
c5f3bde4 Andy McCall ↶ 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
fd958fbc Andy McCall ↶ 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
c5f3bde4 Andy McCall ↶ 232
#[derive(Template)]
233
#[template(path = "resolve.html")]
234
pub struct ResolvePage {
235
    shell: Shell,
236
    repo: RepoShell,
237
    bookmark: String,
238
    path: String,
239
    state: String,
240
    pieces: Vec<Piece>,
241
    delegation: Option<String>,
242
    error: Option<String>,
243
    message: String,
244
}
245
fd958fbc Andy McCall ↶ 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> {
c5f3bde4 Andy McCall ↶ 248
    let limit = app.config.limits.render_limit.0;
249
    let (b, p) = (bookmark.to_string(), path.to_string());
fd958fbc Andy McCall ↶ 250
    let found = app
c5f3bde4 Andy McCall ↶ 251
        .repos
252
        .read(repo, move |r, cache| {
fd958fbc Andy McCall ↶ 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)))
c5f3bde4 Andy McCall ↶ 264
        })
265
        .await
266
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
fd958fbc Andy McCall ↶ 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
    };
c5f3bde4 Andy McCall ↶ 278
    match file.body {
fd958fbc Andy McCall ↶ 279
        FileBody::Text(t) if file.conflicted => Ok((state, Target::Lines(t))),
c5f3bde4 Andy McCall ↶ 280
        FileBody::Text(_) => Err(Error::bad(shell.clone(), "That file has no conflicts on this bookmark.")),
fd958fbc Andy McCall ↶ 281
        _ => Err(Error::bad(shell.clone(), "Only text files within the render limit, and name conflicts, are resolved in the browser.")),
c5f3bde4 Andy McCall ↶ 282
    }
283
}
284
285
#[allow(clippy::too_many_arguments)]
286
async fn page(
287
    app: &Shared,
288
    viewer: &Viewer,
289
    owner: &str,
290
    name: &str,
291
    bookmark: &str,
292
    path: &str,
293
    error: Option<String>,
294
    message: Option<String>,
295
) -> Result<Page<ResolvePage>, Error> {
296
    let shell = Shell::new(app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), viewer);
297
    let me = require(viewer, &shell)?;
298
    let (repo, row, role) = lookup_role(app, &shell, viewer, owner, name, Access::Write).await?;
fd958fbc Andy McCall ↶ 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
    };
c5f3bde4 Andy McCall ↶ 304
    let Some(pieces) = parse(&text) else {
305
        return Err(Error::bad(shell, "The conflict markers could not be read; resolve the file in the plain editor."));
306
    };
307
    let delegation = signer_for(app, me.account.id).await.err();
308
    Ok(Page(ResolvePage {
309
        shell,
310
        repo: RepoShell::new(app, &row, "conflicts").with_role(role),
311
        bookmark: bookmark.to_string(),
312
        path: path.to_string(),
313
        state,
314
        pieces,
315
        delegation,
316
        error,
317
        message: message.unwrap_or_else(|| format!("Resolve the conflicts in {path}")),
318
    }))
319
}
320
321
pub async fn form(
322
    State(app): State<Shared>,
323
    viewer: Viewer,
324
    Path((owner, name, bookmark, path)): Path<(String, String, String, String)>,
325
) -> Result<Response, Error> {
fd958fbc Andy McCall ↶ 326
    let shell = Shell::new(&app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), &viewer);
c5f3bde4 Andy McCall ↶ 327
    let (repo, _, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
fd958fbc Andy McCall ↶ 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()),
c5f3bde4 Andy McCall ↶ 335
    }
336
}
337
fd958fbc Andy McCall ↶ 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
c5f3bde4 Andy McCall ↶ 368
pub async fn save(
369
    State(app): State<Shared>,
370
    viewer: Viewer,
371
    Path((owner, name, bookmark, path)): Path<(String, String, String, String)>,
372
    Form(f): Form<HashMap<String, String>>,
373
) -> Result<Response, Error> {
374
    let shell = Shell::new(&app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), &viewer);
375
    let me = require(&viewer, &shell)?;
376
    csrf(&viewer, f.get("_csrf").map(String::as_str).unwrap_or(""), &shell)?;
377
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
378
    let message = f.get("message").map(|m| m.trim().to_string()).unwrap_or_default();
379
    let again = |msg: String| {
380
        let (app, viewer, owner, name, bookmark, path, message) =
381
            (app.clone(), viewer.clone(), owner.clone(), name.clone(), bookmark.clone(), path.clone(), message.clone());
382
        async move { page(&app, &viewer, &owner, &name, &bookmark, &path, Some(msg), Some(message)).await.map(IntoResponse::into_response) }
383
    };
fd958fbc Andy McCall ↶ 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 {
c5f3bde4 Andy McCall ↶ 425
        return again(format!("{bookmark} has moved since this page was loaded; the conflicts below are as they are now.")).await;
426
    }
427
    let Some(pieces) = parse(&text) else { return again("The conflict markers could not be read.".into()).await };
428
    let mut choices = HashMap::new();
429
    for p in &pieces {
430
        if let Piece::Region(r) = p {
431
            let choice = match f.get(&format!("r{}", r.n)).map(String::as_str) {
432
                Some("all") => Choice::All,
433
                Some("none") => Choice::None,
434
                Some("text") => Choice::Text(f.get(&format!("t{}", r.n)).cloned().unwrap_or_default()),
435
                Some(s) => match s.strip_prefix("side").and_then(|i| i.parse().ok()) {
436
                    Some(i) => Choice::Side(i),
437
                    None => continue,
438
                },
439
                None => continue,
440
            };
441
            choices.insert(r.n, choice);
442
        }
443
    }
444
    let content = match assemble(&pieces, &choices) {
445
        Ok(c) => c,
446
        Err(msg) => return again(msg).await,
447
    };
448
    if message.is_empty() {
449
        return again("Describe the resolution.".into()).await;
450
    }
451
    let signer = match signer_for(&app, me.account.id).await {
452
        Ok(s) => s,
453
        Err(msg) => return again(msg).await,
454
    };
455
    let edit = vec![FileEdit { path: path.clone(), content: Some(content.into_bytes()) }];
456
    let done = match edits::commit(&app.repos, &repo, bookmark.clone(), bookmark.clone(), edit, message.clone(), signer).await {
457
        Ok(d) => d,
458
        Err(e) => return again(e.to_string()).await,
459
    };
460
    after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await;
461
    Ok(Redirect::to(&format!("/{owner}/{name}/change/{}", done.change.to_hex())).into_response())
462
}
463
464
#[cfg(test)]
465
mod tests {
466
    use super::*;
467
468
    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";
469
470
    #[test]
471
    fn markers_parse_into_regions_and_sides() {
472
        let p = parse(MARKED).unwrap();
473
        assert_eq!(p.len(), 5);
474
        assert_eq!(p[0], Piece::Plain("a\n".into()));
475
        let Piece::Region(r1) = &p[1] else { panic!() };
476
        assert_eq!((r1.n, r1.sides.len()), (1, 2));
477
        assert_eq!((r1.sides[0].label.as_str(), r1.sides[0].text.as_str()), ("change 0202", "from-2\n"));
478
        assert_eq!(r1.sides[1].text, "from-3\nmore-3\n");
479
        let Piece::Region(r2) = &p[3] else { panic!() };
480
        assert!(r2.sides[0].deletion && r2.sides[0].text == "gone\n" && r2.sides[0].kept().is_empty());
481
        assert_eq!(r2.sides[0].label, "deleted by change 0404");
482
        assert_eq!(p[4], Piece::Plain("c\n".into()));
483
        assert!(parse("<<<<<<< oxy conflict 1 of 1, side 1 of 2: change x\nno end\n").is_none());
484
        assert_eq!(parse("plain\n").unwrap(), vec![Piece::Plain("plain\n".into())]);
485
    }
486
487
    #[test]
488
    fn choices_assemble_the_file() {
489
        let p = parse(MARKED).unwrap();
490
        let pick = |a: Choice, b: Choice| assemble(&p, &HashMap::from([(1, a), (2, b)]));
491
        assert_eq!(pick(Choice::Side(2), Choice::Side(1)).unwrap(), "a\nfrom-3\nmore-3\nb\nc\n", "the deletion wins: nothing");
492
        assert_eq!(pick(Choice::All, Choice::Side(2)).unwrap(), "a\nfrom-2\nfrom-3\nmore-3\nb\nadded\nc\n");
493
        assert_eq!(pick(Choice::None, Choice::Text("mine".into())).unwrap(), "a\nb\nmine\nc\n", "own text ends its line");
494
        assert!(pick(Choice::Side(3), Choice::None).unwrap_err().contains("no side 3"));
495
        assert!(assemble(&p, &HashMap::from([(1, Choice::None)])).unwrap_err().contains("conflict 2"));
496
    }
497
}