OXYGENHUB
# c5f3bde466d1 oxygenhub / crates / hub / src / web / resolve.rs
13.2 KiB · Rust · last changed Resolve conflicts region by region 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
/// 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
}