c5f3bde4 Andy McCall 1 month ago ↶
1
2
3
4
5
6
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
22
# [ derive ( Clone, Debug, PartialEq, Eq ) ]
23
pub enum Piece {
24
25
Plain( String ) ,
26
Region( Region) ,
27
}
28
29
# [ derive ( Clone, Debug, PartialEq, Eq ) ]
30
pub struct Region {
31
32
pub n : usize ,
33
pub sides : Vec< Side> ,
34
}
35
36
# [ derive ( Clone, Debug, PartialEq, Eq ) ]
37
pub struct Side {
38
39
pub label : String,
40
41
pub text : String,
42
43
44
pub deletion : bool ,
45
}
46
47
impl Side {
48
49
pub fn kept ( & self ) -> & str {
50
if self .deletion {
51
" "
52
} else {
53
& self .text
54
}
55
}
56
}
57
58
impl Region {
59
60
pub fn all ( & self ) -> String {
61
self .sides.iter ( ) .map ( Side:: kept) .collect ( )
62
}
63
}
64
65
66
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
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
121
# [ derive ( Clone, Debug, PartialEq, Eq ) ]
122
pub enum Choice {
123
Side( usize ) ,
124
All,
125
None ,
126
Text( String ) ,
127
}
128
129
130
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
171
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
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\n from-2\n ======= oxy conflict 1 of 2, side 2 of 2: change 0303\n from-3\n more-3\n >>>>>>> oxy conflict 1 of 2 end\n b\n <<<<<<< oxy conflict 2 of 2, side 1 of 2: deleted by change 0404\n -------\n gone\n ======= oxy conflict 2 of 2, side 2 of 2: change 0505\n added\n >>>>>>> oxy conflict 2 of 2 end\n c\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\n more-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\n no 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\n from-3\n more-3\n b\n c\n " , " the deletion wins: nothing" ) ;
324
assert_eq! ( pick ( Choice:: All, Choice:: Side( 2 ) ) .unwrap ( ) , " a\n from-2\n from-3\n more-3\n b\n added\n c\n " ) ;
325
assert_eq! ( pick ( Choice:: None, Choice:: Text( " mine" .into ( ) ) ) .unwrap ( ) , " a\n b\n mine\n c\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
}