##// END OF EJS Templates
rhg: correctly relativize copy source path
Arun Kulshreshtha -
r51338:51041a1a stable
parent child Browse files
Show More
@@ -1,657 +1,662 b''
1 1 // status.rs
2 2 //
3 3 // Copyright 2020, Georges Racinet <georges.racinets@octobus.net>
4 4 //
5 5 // This software may be used and distributed according to the terms of the
6 6 // GNU General Public License version 2 or any later version.
7 7
8 8 use crate::error::CommandError;
9 9 use crate::ui::{
10 10 format_pattern_file_warning, print_narrow_sparse_warnings, Ui,
11 11 };
12 12 use crate::utils::path_utils::RelativizePaths;
13 13 use clap::Arg;
14 14 use format_bytes::format_bytes;
15 15 use hg::config::Config;
16 16 use hg::dirstate::has_exec_bit;
17 17 use hg::dirstate::status::StatusPath;
18 18 use hg::dirstate::TruncatedTimestamp;
19 19 use hg::errors::{HgError, IoResultExt};
20 20 use hg::lock::LockError;
21 21 use hg::manifest::Manifest;
22 22 use hg::matchers::{AlwaysMatcher, IntersectionMatcher};
23 23 use hg::repo::Repo;
24 24 use hg::utils::debug::debug_wait_for_file;
25 25 use hg::utils::files::get_bytes_from_os_string;
26 26 use hg::utils::files::get_path_from_bytes;
27 27 use hg::utils::hg_path::{hg_path_to_path_buf, HgPath};
28 28 use hg::DirstateStatus;
29 29 use hg::PatternFileWarning;
30 30 use hg::StatusError;
31 31 use hg::StatusOptions;
32 32 use hg::{self, narrow, sparse};
33 33 use log::info;
34 34 use rayon::prelude::*;
35 35 use std::io;
36 36 use std::path::PathBuf;
37 37
38 38 pub const HELP_TEXT: &str = "
39 39 Show changed files in the working directory
40 40
41 41 This is a pure Rust version of `hg status`.
42 42
43 43 Some options might be missing, check the list below.
44 44 ";
45 45
46 46 pub fn args() -> clap::Command {
47 47 clap::command!("status")
48 48 .alias("st")
49 49 .about(HELP_TEXT)
50 50 .arg(
51 51 Arg::new("all")
52 52 .help("show status of all files")
53 53 .short('A')
54 54 .action(clap::ArgAction::SetTrue)
55 55 .long("all"),
56 56 )
57 57 .arg(
58 58 Arg::new("modified")
59 59 .help("show only modified files")
60 60 .short('m')
61 61 .action(clap::ArgAction::SetTrue)
62 62 .long("modified"),
63 63 )
64 64 .arg(
65 65 Arg::new("added")
66 66 .help("show only added files")
67 67 .short('a')
68 68 .action(clap::ArgAction::SetTrue)
69 69 .long("added"),
70 70 )
71 71 .arg(
72 72 Arg::new("removed")
73 73 .help("show only removed files")
74 74 .short('r')
75 75 .action(clap::ArgAction::SetTrue)
76 76 .long("removed"),
77 77 )
78 78 .arg(
79 79 Arg::new("clean")
80 80 .help("show only clean files")
81 81 .short('c')
82 82 .action(clap::ArgAction::SetTrue)
83 83 .long("clean"),
84 84 )
85 85 .arg(
86 86 Arg::new("deleted")
87 87 .help("show only deleted files")
88 88 .short('d')
89 89 .action(clap::ArgAction::SetTrue)
90 90 .long("deleted"),
91 91 )
92 92 .arg(
93 93 Arg::new("unknown")
94 94 .help("show only unknown (not tracked) files")
95 95 .short('u')
96 96 .action(clap::ArgAction::SetTrue)
97 97 .long("unknown"),
98 98 )
99 99 .arg(
100 100 Arg::new("ignored")
101 101 .help("show only ignored files")
102 102 .short('i')
103 103 .action(clap::ArgAction::SetTrue)
104 104 .long("ignored"),
105 105 )
106 106 .arg(
107 107 Arg::new("copies")
108 108 .help("show source of copied files (DEFAULT: ui.statuscopies)")
109 109 .short('C')
110 110 .action(clap::ArgAction::SetTrue)
111 111 .long("copies"),
112 112 )
113 113 .arg(
114 114 Arg::new("no-status")
115 115 .help("hide status prefix")
116 116 .short('n')
117 117 .action(clap::ArgAction::SetTrue)
118 118 .long("no-status"),
119 119 )
120 120 .arg(
121 121 Arg::new("verbose")
122 122 .help("enable additional output")
123 123 .short('v')
124 124 .action(clap::ArgAction::SetTrue)
125 125 .long("verbose"),
126 126 )
127 127 }
128 128
129 129 /// Pure data type allowing the caller to specify file states to display
130 130 #[derive(Copy, Clone, Debug)]
131 131 pub struct DisplayStates {
132 132 pub modified: bool,
133 133 pub added: bool,
134 134 pub removed: bool,
135 135 pub clean: bool,
136 136 pub deleted: bool,
137 137 pub unknown: bool,
138 138 pub ignored: bool,
139 139 }
140 140
141 141 pub const DEFAULT_DISPLAY_STATES: DisplayStates = DisplayStates {
142 142 modified: true,
143 143 added: true,
144 144 removed: true,
145 145 clean: false,
146 146 deleted: true,
147 147 unknown: true,
148 148 ignored: false,
149 149 };
150 150
151 151 pub const ALL_DISPLAY_STATES: DisplayStates = DisplayStates {
152 152 modified: true,
153 153 added: true,
154 154 removed: true,
155 155 clean: true,
156 156 deleted: true,
157 157 unknown: true,
158 158 ignored: true,
159 159 };
160 160
161 161 impl DisplayStates {
162 162 pub fn is_empty(&self) -> bool {
163 163 !(self.modified
164 164 || self.added
165 165 || self.removed
166 166 || self.clean
167 167 || self.deleted
168 168 || self.unknown
169 169 || self.ignored)
170 170 }
171 171 }
172 172
173 173 fn has_unfinished_merge(repo: &Repo) -> Result<bool, CommandError> {
174 174 Ok(repo.dirstate_parents()?.is_merge())
175 175 }
176 176
177 177 fn has_unfinished_state(repo: &Repo) -> Result<bool, CommandError> {
178 178 // These are all the known values for the [fname] argument of
179 179 // [addunfinished] function in [state.py]
180 180 let known_state_files: &[&str] = &[
181 181 "bisect.state",
182 182 "graftstate",
183 183 "histedit-state",
184 184 "rebasestate",
185 185 "shelvedstate",
186 186 "transplant/journal",
187 187 "updatestate",
188 188 ];
189 189 if has_unfinished_merge(repo)? {
190 190 return Ok(true);
191 191 };
192 192 for f in known_state_files {
193 193 if repo.hg_vfs().join(f).exists() {
194 194 return Ok(true);
195 195 }
196 196 }
197 197 Ok(false)
198 198 }
199 199
200 200 pub fn run(invocation: &crate::CliInvocation) -> Result<(), CommandError> {
201 201 // TODO: lift these limitations
202 202 if invocation
203 203 .config
204 204 .get(b"commands", b"status.terse")
205 205 .is_some()
206 206 {
207 207 return Err(CommandError::unsupported(
208 208 "status.terse is not yet supported with rhg status",
209 209 ));
210 210 }
211 211
212 212 let ui = invocation.ui;
213 213 let config = invocation.config;
214 214 let args = invocation.subcommand_args;
215 215
216 216 // TODO add `!args.get_flag("print0") &&` when we support `print0`
217 217 let verbose = args.get_flag("verbose")
218 218 || config.get_bool(b"ui", b"verbose")?
219 219 || config.get_bool(b"commands", b"status.verbose")?;
220 220
221 221 let all = args.get_flag("all");
222 222 let display_states = if all {
223 223 // TODO when implementing `--quiet`: it excludes clean files
224 224 // from `--all`
225 225 ALL_DISPLAY_STATES
226 226 } else {
227 227 let requested = DisplayStates {
228 228 modified: args.get_flag("modified"),
229 229 added: args.get_flag("added"),
230 230 removed: args.get_flag("removed"),
231 231 clean: args.get_flag("clean"),
232 232 deleted: args.get_flag("deleted"),
233 233 unknown: args.get_flag("unknown"),
234 234 ignored: args.get_flag("ignored"),
235 235 };
236 236 if requested.is_empty() {
237 237 DEFAULT_DISPLAY_STATES
238 238 } else {
239 239 requested
240 240 }
241 241 };
242 242 let no_status = args.get_flag("no-status");
243 243 let list_copies = all
244 244 || args.get_flag("copies")
245 245 || config.get_bool(b"ui", b"statuscopies")?;
246 246
247 247 let repo = invocation.repo?;
248 248
249 249 if verbose && has_unfinished_state(repo)? {
250 250 return Err(CommandError::unsupported(
251 251 "verbose status output is not supported by rhg (and is needed because we're in an unfinished operation)",
252 252 ));
253 253 }
254 254
255 255 let mut dmap = repo.dirstate_map_mut()?;
256 256
257 257 let check_exec = hg::checkexec::check_exec(repo.working_directory_path());
258 258
259 259 let options = StatusOptions {
260 260 check_exec,
261 261 list_clean: display_states.clean,
262 262 list_unknown: display_states.unknown,
263 263 list_ignored: display_states.ignored,
264 264 list_copies,
265 265 collect_traversed_dirs: false,
266 266 };
267 267
268 268 type StatusResult<'a> =
269 269 Result<(DirstateStatus<'a>, Vec<PatternFileWarning>), StatusError>;
270 270
271 271 let after_status = |res: StatusResult| -> Result<_, CommandError> {
272 272 let (mut ds_status, pattern_warnings) = res?;
273 273 for warning in pattern_warnings {
274 274 ui.write_stderr(&format_pattern_file_warning(&warning, repo))?;
275 275 }
276 276
277 277 for (path, error) in ds_status.bad {
278 278 let error = match error {
279 279 hg::BadMatch::OsError(code) => {
280 280 std::io::Error::from_raw_os_error(code).to_string()
281 281 }
282 282 hg::BadMatch::BadType(ty) => {
283 283 format!("unsupported file type (type is {})", ty)
284 284 }
285 285 };
286 286 ui.write_stderr(&format_bytes!(
287 287 b"{}: {}\n",
288 288 path.as_bytes(),
289 289 error.as_bytes()
290 290 ))?
291 291 }
292 292 if !ds_status.unsure.is_empty() {
293 293 info!(
294 294 "Files to be rechecked by retrieval from filelog: {:?}",
295 295 ds_status.unsure.iter().map(|s| &s.path).collect::<Vec<_>>()
296 296 );
297 297 }
298 298 let mut fixup = Vec::new();
299 299 if !ds_status.unsure.is_empty()
300 300 && (display_states.modified || display_states.clean)
301 301 {
302 302 let p1 = repo.dirstate_parents()?.p1;
303 303 let manifest = repo.manifest_for_node(p1).map_err(|e| {
304 304 CommandError::from((e, &*format!("{:x}", p1.short())))
305 305 })?;
306 306 let working_directory_vfs = repo.working_directory_vfs();
307 307 let store_vfs = repo.store_vfs();
308 308 let res: Vec<_> = ds_status
309 309 .unsure
310 310 .into_par_iter()
311 311 .map(|to_check| {
312 312 // The compiler seems to get a bit confused with complex
313 313 // inference when using a parallel iterator + map
314 314 // + map_err + collect, so let's just inline some of the
315 315 // logic.
316 316 match unsure_is_modified(
317 317 working_directory_vfs,
318 318 store_vfs,
319 319 check_exec,
320 320 &manifest,
321 321 &to_check.path,
322 322 ) {
323 323 Err(HgError::IoError { .. }) => {
324 324 // IO errors most likely stem from the file being
325 325 // deleted even though we know it's in the
326 326 // dirstate.
327 327 Ok((to_check, UnsureOutcome::Deleted))
328 328 }
329 329 Ok(outcome) => Ok((to_check, outcome)),
330 330 Err(e) => Err(e),
331 331 }
332 332 })
333 333 .collect::<Result<_, _>>()?;
334 334 for (status_path, outcome) in res.into_iter() {
335 335 match outcome {
336 336 UnsureOutcome::Clean => {
337 337 if display_states.clean {
338 338 ds_status.clean.push(status_path.clone());
339 339 }
340 340 fixup.push(status_path.path.into_owned())
341 341 }
342 342 UnsureOutcome::Modified => {
343 343 if display_states.modified {
344 344 ds_status.modified.push(status_path);
345 345 }
346 346 }
347 347 UnsureOutcome::Deleted => {
348 348 if display_states.deleted {
349 349 ds_status.deleted.push(status_path);
350 350 }
351 351 }
352 352 }
353 353 }
354 354 }
355 355 let relative_paths = config
356 356 .get_option(b"commands", b"status.relative")?
357 357 .unwrap_or(config.get_bool(b"ui", b"relative-paths")?);
358 358 let output = DisplayStatusPaths {
359 359 ui,
360 360 no_status,
361 361 relativize: if relative_paths {
362 362 Some(RelativizePaths::new(repo)?)
363 363 } else {
364 364 None
365 365 },
366 366 };
367 367 if display_states.modified {
368 368 output.display(b"M ", "status.modified", ds_status.modified)?;
369 369 }
370 370 if display_states.added {
371 371 output.display(b"A ", "status.added", ds_status.added)?;
372 372 }
373 373 if display_states.removed {
374 374 output.display(b"R ", "status.removed", ds_status.removed)?;
375 375 }
376 376 if display_states.deleted {
377 377 output.display(b"! ", "status.deleted", ds_status.deleted)?;
378 378 }
379 379 if display_states.unknown {
380 380 output.display(b"? ", "status.unknown", ds_status.unknown)?;
381 381 }
382 382 if display_states.ignored {
383 383 output.display(b"I ", "status.ignored", ds_status.ignored)?;
384 384 }
385 385 if display_states.clean {
386 386 output.display(b"C ", "status.clean", ds_status.clean)?;
387 387 }
388 388
389 389 let dirstate_write_needed = ds_status.dirty;
390 390 let filesystem_time_at_status_start =
391 391 ds_status.filesystem_time_at_status_start;
392 392
393 393 Ok((
394 394 fixup,
395 395 dirstate_write_needed,
396 396 filesystem_time_at_status_start,
397 397 ))
398 398 };
399 399 let (narrow_matcher, narrow_warnings) = narrow::matcher(repo)?;
400 400 let (sparse_matcher, sparse_warnings) = sparse::matcher(repo)?;
401 401 let matcher = match (repo.has_narrow(), repo.has_sparse()) {
402 402 (true, true) => {
403 403 Box::new(IntersectionMatcher::new(narrow_matcher, sparse_matcher))
404 404 }
405 405 (true, false) => narrow_matcher,
406 406 (false, true) => sparse_matcher,
407 407 (false, false) => Box::new(AlwaysMatcher),
408 408 };
409 409
410 410 print_narrow_sparse_warnings(
411 411 &narrow_warnings,
412 412 &sparse_warnings,
413 413 ui,
414 414 repo,
415 415 )?;
416 416 let (fixup, mut dirstate_write_needed, filesystem_time_at_status_start) =
417 417 dmap.with_status(
418 418 matcher.as_ref(),
419 419 repo.working_directory_path().to_owned(),
420 420 ignore_files(repo, config),
421 421 options,
422 422 after_status,
423 423 )?;
424 424
425 425 // Development config option to test write races
426 426 if let Err(e) =
427 427 debug_wait_for_file(config, "status.pre-dirstate-write-file")
428 428 {
429 429 ui.write_stderr(e.as_bytes()).ok();
430 430 }
431 431
432 432 if (fixup.is_empty() || filesystem_time_at_status_start.is_none())
433 433 && !dirstate_write_needed
434 434 {
435 435 // Nothing to update
436 436 return Ok(());
437 437 }
438 438
439 439 // Update the dirstate on disk if we can
440 440 let with_lock_result =
441 441 repo.try_with_wlock_no_wait(|| -> Result<(), CommandError> {
442 442 if let Some(mtime_boundary) = filesystem_time_at_status_start {
443 443 for hg_path in fixup {
444 444 use std::os::unix::fs::MetadataExt;
445 445 let fs_path = hg_path_to_path_buf(&hg_path)
446 446 .expect("HgPath conversion");
447 447 // Specifically do not reuse `fs_metadata` from
448 448 // `unsure_is_clean` which was needed before reading
449 449 // contents. Here we access metadata again after reading
450 450 // content, in case it changed in the meantime.
451 451 let metadata_res = repo
452 452 .working_directory_vfs()
453 453 .symlink_metadata(&fs_path);
454 454 let fs_metadata = match metadata_res {
455 455 Ok(meta) => meta,
456 456 Err(err) => match err {
457 457 HgError::IoError { .. } => {
458 458 // The file has probably been deleted. In any
459 459 // case, it was in the dirstate before, so
460 460 // let's ignore the error.
461 461 continue;
462 462 }
463 463 _ => return Err(err.into()),
464 464 },
465 465 };
466 466 if let Some(mtime) =
467 467 TruncatedTimestamp::for_reliable_mtime_of(
468 468 &fs_metadata,
469 469 &mtime_boundary,
470 470 )
471 471 .when_reading_file(&fs_path)?
472 472 {
473 473 let mode = fs_metadata.mode();
474 474 let size = fs_metadata.len();
475 475 dmap.set_clean(&hg_path, mode, size as u32, mtime)?;
476 476 dirstate_write_needed = true
477 477 }
478 478 }
479 479 }
480 480 drop(dmap); // Avoid "already mutably borrowed" RefCell panics
481 481 if dirstate_write_needed {
482 482 repo.write_dirstate()?
483 483 }
484 484 Ok(())
485 485 });
486 486 match with_lock_result {
487 487 Ok(closure_result) => closure_result?,
488 488 Err(LockError::AlreadyHeld) => {
489 489 // Not updating the dirstate is not ideal but not critical:
490 490 // don’t keep our caller waiting until some other Mercurial
491 491 // process releases the lock.
492 492 log::info!("not writing dirstate from `status`: lock is held")
493 493 }
494 494 Err(LockError::Other(HgError::IoError { error, .. }))
495 495 if error.kind() == io::ErrorKind::PermissionDenied =>
496 496 {
497 497 // `hg status` on a read-only repository is fine
498 498 }
499 499 Err(LockError::Other(error)) => {
500 500 // Report other I/O errors
501 501 Err(error)?
502 502 }
503 503 }
504 504 Ok(())
505 505 }
506 506
507 507 fn ignore_files(repo: &Repo, config: &Config) -> Vec<PathBuf> {
508 508 let mut ignore_files = Vec::new();
509 509 let repo_ignore = repo.working_directory_vfs().join(".hgignore");
510 510 if repo_ignore.exists() {
511 511 ignore_files.push(repo_ignore)
512 512 }
513 513 for (key, value) in config.iter_section(b"ui") {
514 514 if key == b"ignore" || key.starts_with(b"ignore.") {
515 515 let path = get_path_from_bytes(value);
516 516 // TODO:Β expand "~/" and environment variable here, like Python
517 517 // does with `os.path.expanduser` and `os.path.expandvars`
518 518
519 519 let joined = repo.working_directory_path().join(path);
520 520 ignore_files.push(joined);
521 521 }
522 522 }
523 523 ignore_files
524 524 }
525 525
526 526 struct DisplayStatusPaths<'a> {
527 527 ui: &'a Ui,
528 528 no_status: bool,
529 529 relativize: Option<RelativizePaths>,
530 530 }
531 531
532 532 impl DisplayStatusPaths<'_> {
533 533 // Probably more elegant to use a Deref or Borrow trait rather than
534 534 // harcode HgPathBuf, but probably not really useful at this point
535 535 fn display(
536 536 &self,
537 537 status_prefix: &[u8],
538 538 label: &'static str,
539 539 mut paths: Vec<StatusPath<'_>>,
540 540 ) -> Result<(), CommandError> {
541 541 paths.sort_unstable();
542 542 // TODO: get the stdout lock once for the whole loop
543 543 // instead of in each write
544 544 for StatusPath { path, copy_source } in paths {
545 let relative;
546 let path = if let Some(relativize) = &self.relativize {
547 relative = relativize.relativize(&path);
548 &*relative
545 let relative_path;
546 let relative_source;
547 let (path, copy_source) = if let Some(relativize) =
548 &self.relativize
549 {
550 relative_path = relativize.relativize(&path);
551 relative_source =
552 copy_source.as_ref().map(|s| relativize.relativize(s));
553 (&*relative_path, relative_source.as_deref())
549 554 } else {
550 path.as_bytes()
555 (path.as_bytes(), copy_source.as_ref().map(|s| s.as_bytes()))
551 556 };
552 557 // TODO: Add a way to use `write_bytes!` instead of `format_bytes!`
553 558 // in order to stream to stdout instead of allocating an
554 559 // itermediate `Vec<u8>`.
555 560 if !self.no_status {
556 561 self.ui.write_stdout_labelled(status_prefix, label)?
557 562 }
558 563 self.ui
559 564 .write_stdout_labelled(&format_bytes!(b"{}\n", path), label)?;
560 565 if let Some(source) = copy_source.filter(|_| !self.no_status) {
561 566 let label = "status.copied";
562 567 self.ui.write_stdout_labelled(
563 &format_bytes!(b" {}\n", source.as_bytes()),
568 &format_bytes!(b" {}\n", source),
564 569 label,
565 570 )?
566 571 }
567 572 }
568 573 Ok(())
569 574 }
570 575 }
571 576
572 577 /// Outcome of the additional check for an ambiguous tracked file
573 578 enum UnsureOutcome {
574 579 /// The file is actually clean
575 580 Clean,
576 581 /// The file has been modified
577 582 Modified,
578 583 /// The file was deleted on disk (or became another type of fs entry)
579 584 Deleted,
580 585 }
581 586
582 587 /// Check if a file is modified by comparing actual repo store and file system.
583 588 ///
584 589 /// This meant to be used for those that the dirstate cannot resolve, due
585 590 /// to time resolution limits.
586 591 fn unsure_is_modified(
587 592 working_directory_vfs: hg::vfs::Vfs,
588 593 store_vfs: hg::vfs::Vfs,
589 594 check_exec: bool,
590 595 manifest: &Manifest,
591 596 hg_path: &HgPath,
592 597 ) -> Result<UnsureOutcome, HgError> {
593 598 let vfs = working_directory_vfs;
594 599 let fs_path = hg_path_to_path_buf(hg_path).expect("HgPath conversion");
595 600 let fs_metadata = vfs.symlink_metadata(&fs_path)?;
596 601 let is_symlink = fs_metadata.file_type().is_symlink();
597 602
598 603 let entry = manifest
599 604 .find_by_path(hg_path)?
600 605 .expect("ambgious file not in p1");
601 606
602 607 // TODO: Also account for `FALLBACK_SYMLINK` and `FALLBACK_EXEC` from the
603 608 // dirstate
604 609 let fs_flags = if is_symlink {
605 610 Some(b'l')
606 611 } else if check_exec && has_exec_bit(&fs_metadata) {
607 612 Some(b'x')
608 613 } else {
609 614 None
610 615 };
611 616
612 617 let entry_flags = if check_exec {
613 618 entry.flags
614 619 } else if entry.flags == Some(b'x') {
615 620 None
616 621 } else {
617 622 entry.flags
618 623 };
619 624
620 625 if entry_flags != fs_flags {
621 626 return Ok(UnsureOutcome::Modified);
622 627 }
623 628 let filelog = hg::filelog::Filelog::open_vfs(&store_vfs, hg_path)?;
624 629 let fs_len = fs_metadata.len();
625 630 let file_node = entry.node_id()?;
626 631 let filelog_entry = filelog.entry_for_node(file_node).map_err(|_| {
627 632 HgError::corrupted(format!(
628 633 "filelog {:?} missing node {:?} from manifest",
629 634 hg_path, file_node
630 635 ))
631 636 })?;
632 637 if filelog_entry.file_data_len_not_equal_to(fs_len) {
633 638 // No need to read file contents:
634 639 // it cannot be equal if it has a different length.
635 640 return Ok(UnsureOutcome::Modified);
636 641 }
637 642
638 643 let p1_filelog_data = filelog_entry.data()?;
639 644 let p1_contents = p1_filelog_data.file_data()?;
640 645 if p1_contents.len() as u64 != fs_len {
641 646 // No need to read file contents:
642 647 // it cannot be equal if it has a different length.
643 648 return Ok(UnsureOutcome::Modified);
644 649 }
645 650
646 651 let fs_contents = if is_symlink {
647 652 get_bytes_from_os_string(vfs.read_link(fs_path)?.into_os_string())
648 653 } else {
649 654 vfs.read(fs_path)?
650 655 };
651 656
652 657 Ok(if p1_contents != &*fs_contents {
653 658 UnsureOutcome::Modified
654 659 } else {
655 660 UnsureOutcome::Clean
656 661 })
657 662 }
General Comments 0
You need to be logged in to leave comments. Login now