1use std::io;
11use std::path::{Component, Path, PathBuf};
12use std::sync::Mutex;
13use std::time::{Duration, Instant};
14
15use crate::cache::{FileStat, HashCache, baseline_for, record_baseline, try_open_cache};
16use crate::config::{OnConflict, SettingSource};
17use crate::error::{Error, ErrorCode, backend_error_code};
18use crate::files::metadata::FileMetadata;
19use crate::globbing::GetSelection;
20use crate::operation::{
21 ByteTotals, FileRow, Hashing, OperationContext, OperationPlan, RunOutcome, millis,
22};
23use crate::paths::ProjectPath;
24use crate::utils::get_threadpool;
25use crate::{Backend, Compression, DvsPaths, RetrieveRequest, cache};
26use fs_err as fs;
27use rayon::prelude::*;
28use serde::Serialize;
29use uuid::Uuid;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
33#[serde(rename_all = "snake_case")]
34pub enum Before {
35 Absent,
37 Current,
39 Clean,
41 Modified,
43 Unknown,
45 NotTracked,
47 NotAFile,
49 UnsafePath,
51}
52
53impl Before {
54 pub fn as_str(&self) -> &'static str {
55 match self {
56 Before::Absent => "absent",
57 Before::Current => "current",
58 Before::Clean => "clean",
59 Before::Modified => "modified",
60 Before::Unknown => "unknown",
61 Before::NotTracked => "not_tracked",
62 Before::NotAFile => "not_a_file",
63 Before::UnsafePath => "unsafe_path",
64 }
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
70#[serde(rename_all = "snake_case")]
71pub enum Action {
72 Restored,
73 Replaced,
74 Unchanged,
75 Skipped,
76 NotRun,
77 Cancelled,
79 Failed,
80 WouldRestore,
81 WouldReplace,
82}
83
84impl Action {
85 pub fn as_str(&self) -> &'static str {
86 match self {
87 Action::Restored => "restored",
88 Action::Replaced => "replaced",
89 Action::Unchanged => "unchanged",
90 Action::Skipped => "skipped",
91 Action::NotRun => "not_run",
92 Action::Cancelled => "cancelled",
93 Action::Failed => "failed",
94 Action::WouldRestore => "would_restore",
95 Action::WouldReplace => "would_replace",
96 }
97 }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
103#[serde(rename_all = "snake_case")]
104pub enum StoredCopy {
105 Present,
106 Absent,
107 Unknown,
108}
109
110impl StoredCopy {
111 pub fn as_str(&self) -> &'static str {
112 match self {
113 StoredCopy::Present => "present",
114 StoredCopy::Absent => "absent",
115 StoredCopy::Unknown => "unknown",
116 }
117 }
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum GetCode {
124 LocalChanges,
125 UnknownLocal,
126 BatchAborted,
127 NotTracked,
128 NotAFile,
129 UnsafePath,
130 SourceMissing,
131 HashMismatch,
132 DestinationChanged,
133 PermissionDenied,
134 FileInUse,
135 Shared(ErrorCode),
136}
137
138impl From<ErrorCode> for GetCode {
139 fn from(code: ErrorCode) -> Self {
140 GetCode::Shared(code)
141 }
142}
143
144impl GetCode {
145 pub fn as_str(&self) -> &'static str {
146 match self {
147 GetCode::LocalChanges => "local_changes",
148 GetCode::UnknownLocal => "unknown_local",
149 GetCode::BatchAborted => "batch_aborted",
150 GetCode::NotTracked => "not_tracked",
151 GetCode::NotAFile => "not_a_file",
152 GetCode::UnsafePath => "unsafe_path",
153 GetCode::SourceMissing => "source_missing",
154 GetCode::HashMismatch => "hash_mismatch",
155 GetCode::DestinationChanged => "destination_changed",
156 GetCode::PermissionDenied => "permission_denied",
157 GetCode::FileInUse => "file_in_use",
158 GetCode::Shared(code) => code.as_str(),
159 }
160 }
161}
162
163impl Serialize for GetCode {
164 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
165 serializer.serialize_str(self.as_str())
166 }
167}
168
169#[derive(Debug, Clone, Serialize)]
172pub struct GetRow {
173 pub path: PathBuf,
175 pub local_path: PathBuf,
177 pub before: Before,
178 pub action: Action,
179 pub satisfied: bool,
181 pub size: Option<u64>,
183 pub target_hash: Option<String>,
184 pub local_hash: Option<String>,
185 pub stored_copy: Option<StoredCopy>,
187 pub code: Option<GetCode>,
188 pub message: Option<String>,
189 pub hint: Option<String>,
191 pub duration_ms: Option<u64>,
194}
195
196impl GetRow {
197 pub fn is_conflict(&self) -> bool {
199 matches!(
200 self.code,
201 Some(GetCode::LocalChanges | GetCode::UnknownLocal)
202 )
203 }
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
207#[serde(rename_all = "snake_case")]
208pub enum GetOutcome {
209 Complete,
211 Aborted,
213 Partial,
216 Failed,
218 Cancelled,
220}
221
222impl GetOutcome {
223 pub fn as_str(&self) -> &'static str {
224 match self {
225 GetOutcome::Complete => "complete",
226 GetOutcome::Aborted => "aborted",
227 GetOutcome::Partial => "partial",
228 GetOutcome::Failed => "failed",
229 GetOutcome::Cancelled => "cancelled",
230 }
231 }
232}
233
234#[derive(Debug, Clone, Default, Serialize)]
237pub struct GetCounts {
238 pub total: usize,
239 pub succeeded: usize,
240 pub restored: usize,
241 pub replaced: usize,
242 pub unchanged: usize,
243 pub skipped: usize,
244 pub not_run: usize,
245 pub cancelled: usize,
246 pub failed: usize,
247 pub would_restore: usize,
248 pub would_replace: usize,
249}
250
251#[derive(Debug, Clone, Serialize)]
253pub struct GetReport {
254 pub outcome: GetOutcome,
255 pub dry_run: bool,
256 pub on_conflict: OnConflict,
257 pub source: SettingSource,
258 pub counts: GetCounts,
259 pub bytes: ByteTotals,
260 pub problem: Option<Error>,
262 pub files: Vec<GetRow>,
263}
264
265impl GetReport {
266 pub fn problem(
268 code: ErrorCode,
269 message: impl Into<String>,
270 dry_run: bool,
271 on_conflict: OnConflict,
272 source: SettingSource,
273 ) -> Self {
274 GetReport {
275 outcome: GetOutcome::Failed,
276 dry_run,
277 on_conflict,
278 source,
279 counts: GetCounts::default(),
280 bytes: ByteTotals::default(),
281 problem: Some(Error::new(code, message)),
282 files: Vec::new(),
283 }
284 }
285
286 pub fn summary(&self) -> String {
293 let c = &self.counts;
294 let what = match self.outcome {
295 GetOutcome::Complete
296 if c.restored + c.replaced + c.would_restore + c.would_replace == 0 =>
297 {
298 format!("nothing to do, {} already current", c.unchanged)
299 }
300 GetOutcome::Complete => counts_phrase(c),
301 GetOutcome::Aborted => {
302 let mut reasons = conflict_reasons(&self.files);
303 if c.failed > 0 {
304 reasons.push(format!(
305 "{} cannot be retrieved",
306 plural(c.failed, "file", "files")
307 ));
308 }
309 let dry = if self.dry_run { "would be " } else { "" };
310 format!("aborted, nothing {dry}changed: {}", reasons.join("; "))
311 }
312 GetOutcome::Partial | GetOutcome::Failed | GetOutcome::Cancelled => {
313 format!("{}: {}", self.outcome.as_str(), counts_phrase(c))
314 }
315 };
316 let mut line = what;
317 if let Some(first) = line.get(..1) {
318 line.replace_range(..1, &first.to_uppercase());
319 }
320 let policy_acted = self
321 .files
322 .iter()
323 .any(|r| matches!(r.before, Before::Modified | Before::Unknown));
324 if policy_acted {
325 line.push_str(&format!(
326 "\non_conflict = {} ({})",
327 self.on_conflict, self.source
328 ));
329 }
330 line
331 }
332
333 pub fn verdict(&self) -> GetVerdict {
335 if self.outcome == GetOutcome::Cancelled {
336 GetVerdict::Cancelled
337 } else if self.problem.is_some() || self.counts.failed > 0 {
338 GetVerdict::Failed
339 } else if self.outcome == GetOutcome::Aborted {
340 GetVerdict::HeldBack
341 } else {
342 GetVerdict::Done
343 }
344 }
345}
346
347fn plural(n: usize, one: &str, many: &str) -> String {
348 format!("{n} {}", if n == 1 { one } else { many })
349}
350
351fn counts_phrase(c: &GetCounts) -> String {
353 let parts = [
354 (c.restored, "restored {}"),
355 (c.replaced, "replaced {}"),
356 (c.would_restore, "would restore {}"),
357 (c.would_replace, "would replace {}"),
358 (c.unchanged, "{} already current"),
359 (c.skipped, "skipped {}"),
360 (c.not_run, "{} held back"),
361 (c.cancelled, "cancelled {}"),
362 (c.failed, "{} failed"),
363 ];
364 let out: Vec<String> = parts
365 .iter()
366 .filter(|(n, _)| *n > 0)
367 .map(|(n, f)| f.replace("{}", &n.to_string()))
368 .collect();
369 if out.is_empty() {
370 "nothing to do".to_string()
371 } else {
372 out.join(", ")
373 }
374}
375
376fn conflict_reasons(files: &[GetRow]) -> Vec<String> {
378 let count = |b: Before| {
379 files
380 .iter()
381 .filter(|r| r.is_conflict() && r.before == b)
382 .count()
383 };
384 let (modified, unknown) = (count(Before::Modified), count(Before::Unknown));
385 let mut reasons = Vec::new();
386 if modified > 0 {
387 reasons.push(format!(
388 "{} local changes",
389 plural(modified, "file has", "files have")
390 ));
391 }
392 if unknown > 0 {
393 reasons.push(format!(
394 "{} from the tracked version and dvs has no record of writing {}",
395 plural(unknown, "file differs", "files differ"),
396 if unknown == 1 { "it" } else { "them" }
397 ));
398 }
399 reasons
400}
401
402#[derive(Debug, Clone, Copy, PartialEq, Eq)]
404pub enum GetVerdict {
405 Done,
407 Failed,
409 HeldBack,
411 Cancelled,
413}
414
415impl From<RunOutcome> for GetVerdict {
418 fn from(outcome: RunOutcome) -> Self {
419 match outcome {
420 RunOutcome::Complete => GetVerdict::Done,
421 RunOutcome::Partial | RunOutcome::Failed => GetVerdict::Failed,
422 RunOutcome::Cancelled => GetVerdict::Cancelled,
423 }
424 }
425}
426
427#[derive(Debug, Clone, Copy)]
428pub struct GetOptions {
429 pub dry_run: bool,
430 pub on_conflict: OnConflict,
431 pub source: SettingSource,
432}
433
434impl Default for GetOptions {
435 fn default() -> Self {
436 GetOptions {
437 dry_run: false,
438 on_conflict: OnConflict::Abort,
439 source: SettingSource::Default,
440 }
441 }
442}
443
444struct Planned {
446 row: GetRow,
447 metadata: Option<FileMetadata>,
448 stat: Option<FileStat>,
451 hash_time: Duration,
453 hashed: bool,
455 emitted: bool,
457}
458
459impl Planned {
460 fn new(row: GetRow) -> Self {
461 Planned {
462 row,
463 metadata: None,
464 stat: None,
465 hash_time: Duration::ZERO,
466 hashed: false,
467 emitted: false,
468 }
469 }
470
471 fn emit(&mut self, ctx: &OperationContext) {
473 if !self.emitted {
474 self.emitted = true;
475 self.fill_hash_duration();
476 self.row.hint = hint(&self.row);
477 ctx.observer().file_finished(FileRow::Get(&self.row));
478 }
479 }
480
481 fn fill_hash_duration(&mut self) {
484 if self.row.duration_ms.is_none() && self.hashed {
485 self.row.duration_ms = Some(millis(self.hash_time));
486 }
487 }
488
489 fn needs_transfer(&self, on_conflict: OnConflict) -> bool {
490 self.row.action == Action::NotRun
491 && (matches!(self.row.before, Before::Absent | Before::Clean)
492 || (self.row.is_conflict() && on_conflict == OnConflict::Overwrite))
493 }
494
495 fn is_known_problem(&self, on_conflict: OnConflict) -> bool {
500 self.row.action == Action::Failed
501 || (self.row.is_conflict() && on_conflict != OnConflict::Overwrite)
502 }
503
504 fn set(&mut self, action: Action, code: Option<GetCode>, message: Option<String>) {
505 set_row(&mut self.row, action, code, message);
506 }
507}
508
509fn set_row(row: &mut GetRow, action: Action, code: Option<GetCode>, message: Option<String>) {
510 row.action = action;
511 row.satisfied = matches!(
512 action,
513 Action::Restored
514 | Action::Replaced
515 | Action::Unchanged
516 | Action::WouldRestore
517 | Action::WouldReplace
518 );
519 row.code = code;
520 row.message = message;
521}
522
523fn cancel_row(row: &mut GetRow) {
525 let message = "cancelled before it started".to_string();
526 set_row(
527 row,
528 Action::Cancelled,
529 Some(GetCode::Shared(ErrorCode::Cancelled)),
530 Some(message),
531 );
532}
533
534fn project_relative(root: &Path, absolute: &Path) -> Option<PathBuf> {
537 let absolute = normalize(absolute);
538 if let Ok(rel) = absolute.strip_prefix(root) {
539 return Some(rel.to_path_buf());
540 }
541 let root = root.canonicalize().ok()?;
542 let parent = absolute.parent()?.canonicalize().ok()?;
543 let rel = parent.join(absolute.file_name()?);
544 rel.strip_prefix(&root).ok().map(Path::to_path_buf)
545}
546
547fn relative_to(base: &Path, target: &Path) -> PathBuf {
549 let base: Vec<_> = base.components().collect();
550 let target: Vec<_> = target.components().collect();
551 let common = base
552 .iter()
553 .zip(target.iter())
554 .take_while(|(a, b)| a == b)
555 .count();
556 let mut out = PathBuf::new();
557 for _ in common..base.len() {
558 out.push("..");
559 }
560 for c in &target[common..] {
561 out.push(c.as_os_str());
562 }
563 if out.as_os_str().is_empty() {
564 out.push(".");
565 }
566 out
567}
568
569fn normalize(path: &Path) -> PathBuf {
571 let mut out = PathBuf::new();
572 for c in path.components() {
573 match c {
574 Component::CurDir => {}
575 Component::ParentDir => {
576 if !out.pop() {
577 out.push("..");
578 }
579 }
580 other => out.push(other.as_os_str()),
581 }
582 }
583 out
584}
585
586fn destination_is_contained(root: &Path, target: &Path) -> bool {
589 let mut dir = target.parent();
590 while let Some(d) = dir {
591 match std::fs::symlink_metadata(d) {
592 Ok(_) => {
593 return std::fs::canonicalize(d)
594 .map(|real| real.starts_with(root))
595 .unwrap_or(false);
596 }
597 Err(_) => dir = d.parent(),
598 }
599 }
600 false
601}
602
603fn blank_row(paths: &DvsPaths, relative_path: &Path) -> GetRow {
604 GetRow {
605 path: relative_path.to_path_buf(),
606 local_path: relative_to(paths.cwd(), &paths.file_path(relative_path)),
607 before: Before::Absent,
608 action: Action::NotRun,
609 satisfied: false,
610 size: None,
611 target_hash: None,
612 local_hash: None,
613 stored_copy: None,
614 code: None,
615 message: None,
616 hint: None,
617 duration_ms: None,
618 }
619}
620
621fn plan_file(
624 paths: &DvsPaths,
625 relative_path: &Path,
626 cache: Option<&Mutex<HashCache>>,
627 hashing: &Hashing<'_>,
628) -> Planned {
629 let mut planned = Planned::new(blank_row(paths, relative_path));
630 let fail = |mut planned: Planned, before: Before, code: GetCode, message: String| {
631 planned.row.before = before;
632 planned.set(Action::Failed, Some(code), Some(message));
633 planned
634 };
635
636 let metadata_path = paths.metadata_path(relative_path);
637 let metadata: FileMetadata = match fs::File::open(&metadata_path)
638 .map_err(anyhow::Error::from)
639 .and_then(|f| serde_json::from_reader(f).map_err(anyhow::Error::from))
640 {
641 Ok(m) => m,
642 Err(e) => {
643 return fail(
644 planned,
645 Before::Unknown,
646 GetCode::Shared(ErrorCode::BadMetadata),
647 format!("cannot read {}: {e}", metadata_path.display()),
648 );
649 }
650 };
651 planned.row.size = Some(metadata.size);
652 planned.row.target_hash = Some(metadata.hashes.blake3.clone());
653
654 let target = paths.file_path(relative_path);
655 if !destination_is_contained(paths.repo_root(), &target) {
656 return fail(
657 planned,
658 Before::UnsafePath,
659 GetCode::UnsafePath,
660 "a parent directory leads outside the project (symlink?); dvs will not write there"
661 .to_string(),
662 );
663 }
664
665 match std::fs::symlink_metadata(&target) {
666 Err(e) if e.kind() == io::ErrorKind::NotFound => {
667 planned.row.before = Before::Absent;
668 planned.metadata = Some(metadata);
669 return planned;
670 }
671 Err(e) => {
672 return fail(planned, Before::Unknown, io_code(&e), e.to_string());
673 }
674 Ok(meta) if meta.file_type().is_symlink() => {
675 return fail(
676 planned,
677 Before::NotAFile,
678 GetCode::NotAFile,
679 "is a symbolic link; dvs will not replace it or write through it".to_string(),
680 );
681 }
682 Ok(meta) if !meta.is_file() => {
683 let what = if meta.is_dir() {
684 "is a directory"
685 } else {
686 "is not a regular file"
687 };
688 return fail(
689 planned,
690 Before::NotAFile,
691 GetCode::NotAFile,
692 format!("{what}; dvs will not replace it"),
693 );
694 }
695 Ok(_) => {}
696 }
697
698 let rel_str = relative_path.to_string_lossy();
699 let stat = FileStat::from_path(&target).ok();
700 hashing.begin();
701 planned.hashed = true;
702 let (hashes, size) = match cache::hashes_for_file(&target, &rel_str, cache) {
703 Ok(h) => h,
704 Err(e) => {
705 let code = e
706 .downcast_ref::<io::Error>()
707 .map(io_code)
708 .unwrap_or(GetCode::Shared(ErrorCode::IoError));
709 return fail(
710 planned,
711 Before::Unknown,
712 code,
713 format!("cannot read the working file: {e}"),
714 );
715 }
716 };
717 planned.row.local_hash = Some(hashes.blake3.clone());
718 planned.stat = stat;
719
720 planned.row.before = if hashes == metadata.hashes && size == metadata.size {
721 Before::Current
722 } else {
723 match baseline_for(cache, &rel_str) {
724 Some((blake3, base_size)) if blake3 == hashes.blake3 && base_size == size => {
725 Before::Clean
726 }
727 Some(_) => Before::Modified,
728 None => Before::Unknown,
729 }
730 };
731 let conflict = match planned.row.before {
732 Before::Modified => Some((
733 GetCode::LocalChanges,
734 "local changes since dvs last wrote this file",
735 )),
736 Before::Unknown => Some((
737 GetCode::UnknownLocal,
738 "differs from the tracked version, and dvs has no record of writing it",
739 )),
740 _ => None,
741 };
742 if let Some((code, message)) = conflict {
743 planned.set(Action::NotRun, Some(code), Some(message.to_string()));
744 }
745 planned.metadata = Some(metadata);
746 planned
747}
748
749fn io_code(e: &io::Error) -> GetCode {
750 if cfg!(windows) && matches!(e.raw_os_error(), Some(32) | Some(33)) {
752 return GetCode::FileInUse;
753 }
754 match e.kind() {
755 io::ErrorKind::PermissionDenied => GetCode::PermissionDenied,
756 _ => GetCode::Shared(ErrorCode::IoError),
757 }
758}
759
760fn code_for(e: &anyhow::Error) -> GetCode {
762 if let Some(code) = backend_error_code(e) {
763 return code.into();
764 }
765 e.chain()
766 .find_map(|cause| cause.downcast_ref::<io::Error>())
767 .map(io_code)
768 .unwrap_or(GetCode::Shared(ErrorCode::IoError))
769}
770
771fn destination_unchanged(target: &Path, planned: &Option<FileStat>) -> bool {
773 match planned {
774 None => std::fs::symlink_metadata(target).is_err(),
775 Some(stat) => FileStat::from_path(target).ok().as_ref() == Some(stat),
776 }
777}
778
779type FileError = (GetCode, String);
781
782fn install(
785 backend: &Backend,
786 paths: &DvsPaths,
787 relative_path: &Path,
788 metadata: &FileMetadata,
789 planned_stat: &Option<FileStat>,
790 operation_id: Uuid,
791 on_bytes: Option<&(dyn Fn(u64) + Send + Sync)>,
792) -> Result<(), FileError> {
793 let target = paths.file_path(relative_path);
794 if let Some(parent) = target.parent() {
795 fs::create_dir_all(parent)
796 .map_err(|e| (io_code(&e), format!("cannot create its directory: {e}")))?;
797 }
798 let tmp_path = target.with_extension(format!("dvs-tmp.{}", Uuid::new_v4()));
799 let path = ProjectPath::from_path(relative_path)
800 .map_err(|e| (GetCode::Shared(ErrorCode::IoError), e.to_string()))?;
801
802 let result = (|| {
803 let retrieved = backend
804 .retrieve(RetrieveRequest {
805 hashes: &metadata.hashes,
806 target: &tmp_path,
807 compression: metadata.compression,
808 path,
809 operation_id,
810 on_bytes,
811 })
812 .map_err(|e| (code_for(&e), format!("retrieval failed: {e:#}")))?;
813 if !retrieved {
814 return Err((
815 GetCode::SourceMissing,
816 "this version is not in storage".to_string(),
817 ));
818 }
819 let actual = FileMetadata::from_file(&tmp_path, Compression::None, None)
820 .map_err(|e| (code_for(&e), e.to_string()))?;
821 if actual.hashes != metadata.hashes {
822 return Err((
823 GetCode::HashMismatch,
824 "the stored content does not match the tracked hash".to_string(),
825 ));
826 }
827 if !destination_unchanged(&target, planned_stat) {
828 return Err((
829 GetCode::DestinationChanged,
830 "the file changed while dvs was downloading it; your version was kept".to_string(),
831 ));
832 }
833 fs::rename(&tmp_path, &target)
834 .map_err(|e| (io_code(&e), format!("cannot replace the file: {e}")))?;
835 Ok(())
836 })();
837 if result.is_err() {
838 let _ = fs::remove_file(&tmp_path);
839 }
840 result
841}
842
843pub fn get_files(
855 selection: GetSelection,
856 paths: &DvsPaths,
857 backend: &Backend,
858 options: GetOptions,
859 ctx: &OperationContext,
860) -> GetReport {
861 let GetOptions {
862 dry_run,
863 on_conflict,
864 source,
865 } = options;
866 let problem = |code: ErrorCode, message: String| {
868 let mut report = GetReport::problem(code, message, dry_run, on_conflict, source);
869 if ctx.is_cancelled() {
870 report.outcome = GetOutcome::Cancelled;
871 }
872 report
873 };
874 if selection.tracked.is_empty() && selection.unmatched.is_empty() {
875 return problem(
876 ErrorCode::NoFilesSelected,
877 "no tracked files matched".into(),
878 );
879 }
880
881 let pool = match get_threadpool(selection.tracked.len().max(1)) {
882 Ok(p) => p,
883 Err(e) => return problem(ErrorCode::OperationFailed, e.to_string()),
884 };
885 let cache = try_open_cache(paths);
886 let cache = cache.as_ref();
887 let observer = ctx.observer();
888 let ending = |planned, aborted| {
889 finish(
890 planned,
891 Ending {
892 dry_run,
893 on_conflict,
894 source,
895 aborted,
896 },
897 ctx,
898 )
899 };
900
901 let hashing = Hashing::new(observer);
905 let mut planned: Vec<Planned> = pool.install(|| {
906 selection
907 .tracked
908 .into_par_iter()
909 .map(|p| {
910 if ctx.is_cancelled() {
913 let mut planned = Planned::new(blank_row(paths, &p));
914 planned.row.before = Before::Unknown;
915 cancel_row(&mut planned.row);
916 return planned;
917 }
918 let started = Instant::now();
919 let mut planned = plan_file(paths, &p, cache, &hashing);
920 planned.hash_time = started.elapsed();
921 planned
922 })
923 .collect()
924 });
925 hashing.finish();
926 let cwd_rel = paths
927 .cwd_relative_to_root()
928 .map(Path::to_path_buf)
929 .unwrap_or_default();
930 for user_path in selection.unmatched {
931 let inside = if user_path.is_absolute() {
934 project_relative(paths.repo_root(), &user_path)
935 } else {
936 Some(normalize(&cwd_rel.join(&user_path)))
937 };
938 let mut p = Planned::new(blank_row(paths, inside.as_deref().unwrap_or(&user_path)));
939 let is_dir = paths.file_path(&p.row.path).is_dir();
940 if inside.is_none() {
941 p.row.local_path = user_path;
942 }
943 p.row.before = Before::NotTracked;
944 p.set(
945 Action::Failed,
946 Some(GetCode::NotTracked),
947 Some(if is_dir {
948 "no tracked files selected in this directory".to_string()
949 } else {
950 "not tracked by dvs".to_string()
951 }),
952 );
953 planned.push(p);
954 }
955 planned.sort_by(|a, b| a.row.path.cmp(&b.row.path));
956
957 for p in planned
958 .iter_mut()
959 .filter(|p| p.row.before == Before::Current)
960 {
961 p.set(Action::Unchanged, None, None);
962 p.row.duration_ms = Some(millis(p.hash_time));
964 if !dry_run {
965 if let Some(m) = &p.metadata {
966 record_baseline(
967 cache,
968 &p.row.path.to_string_lossy(),
969 &m.hashes.blake3,
970 m.size,
971 );
972 }
973 }
974 }
975
976 let cancelled_before_storage = ctx.is_cancelled();
981 let needs_remote = !cancelled_before_storage
982 && planned
983 .iter()
984 .any(|p| p.needs_transfer(on_conflict) || p.row.is_conflict());
985 let access = if needs_remote {
986 backend.check_access().map_err(access_failure)
987 } else {
988 Ok(())
989 };
990 for p in planned.iter_mut().filter(|p| p.row.is_conflict()) {
991 p.row.stored_copy = Some(match (&access, &p.row.local_hash) {
992 (Ok(()), Some(blake3)) if needs_remote => {
993 let hashes = crate::Hashes {
994 blake3: blake3.clone(),
995 md5: None,
996 };
997 match backend.exists(&hashes) {
998 Ok(true) => StoredCopy::Present,
999 Ok(false) => StoredCopy::Absent,
1000 Err(_) => StoredCopy::Unknown,
1001 }
1002 }
1003 _ => StoredCopy::Unknown,
1004 });
1005 }
1006
1007 let blocked =
1009 on_conflict != OnConflict::Skip && planned.iter().any(|p| p.is_known_problem(on_conflict));
1010 if blocked {
1011 for p in planned.iter_mut() {
1012 let kept = p.row.is_conflict() && on_conflict != OnConflict::Overwrite;
1014 if p.row.before == Before::Current
1015 || p.row.action == Action::Failed
1016 || kept
1017 || p.row.action == Action::Cancelled
1018 {
1019 continue;
1020 }
1021 p.set(
1022 Action::NotRun,
1023 Some(GetCode::BatchAborted),
1024 Some("held back: another requested file has a problem".to_string()),
1025 );
1026 }
1027 let plan = operation_plan(&planned, &[]);
1028 observer.planned(&plan);
1029 return ending(planned, true);
1030 }
1031 for p in planned
1032 .iter_mut()
1033 .filter(|p| p.row.is_conflict() && on_conflict == OnConflict::Skip)
1034 {
1035 p.row.action = Action::Skipped;
1036 }
1037
1038 if cancelled_before_storage {
1043 for p in planned.iter_mut().filter(|p| p.needs_transfer(on_conflict)) {
1044 cancel_row(&mut p.row);
1045 }
1046 }
1047 let transfer_idx: Vec<usize> = planned
1048 .iter()
1049 .enumerate()
1050 .filter(|(_, p)| p.needs_transfer(on_conflict))
1051 .map(|(i, _)| i)
1052 .collect();
1053 let plan = operation_plan(&planned, &transfer_idx);
1054 if let Err((code, message)) = &access {
1055 for &i in &transfer_idx {
1056 planned[i].set(Action::Failed, Some(*code), Some(message.clone()));
1057 }
1058 observer.planned(&plan);
1059 return ending(planned, false);
1060 }
1061 observer.planned(&plan);
1062 for p in planned.iter_mut() {
1064 if !p.needs_transfer(on_conflict) {
1065 p.emit(ctx);
1066 }
1067 }
1068
1069 let operation_id = Uuid::new_v4();
1070 let finished: Vec<(usize, GetRow)> = pool.install(|| {
1071 transfer_idx
1072 .par_iter()
1073 .map(|&i| {
1074 let p = &planned[i];
1075 let mut row = p.row.clone();
1076 if ctx.is_cancelled() {
1078 cancel_row(&mut row);
1079 if p.hashed {
1081 row.duration_ms = Some(millis(p.hash_time));
1082 }
1083 observer.file_finished(FileRow::Get(&row));
1084 return (i, row);
1085 }
1086 let started = Instant::now();
1087 let result = transfer(p, backend, paths, dry_run, operation_id, ctx);
1088 settle(&mut row, p, result, dry_run, paths, cache);
1089 row.duration_ms = Some(millis(p.hash_time + started.elapsed()));
1090 row.hint = hint(&row);
1091 observer.file_finished(FileRow::Get(&row));
1093 (i, row)
1094 })
1095 .collect()
1096 });
1097 for (i, row) in finished {
1098 planned[i].row = row;
1099 planned[i].emitted = true;
1100 }
1101
1102 ending(planned, false)
1103}
1104
1105fn transfer(
1107 p: &Planned,
1108 backend: &Backend,
1109 paths: &DvsPaths,
1110 dry_run: bool,
1111 operation_id: Uuid,
1112 ctx: &OperationContext,
1113) -> Result<(), FileError> {
1114 let metadata = p
1115 .metadata
1116 .as_ref()
1117 .expect("planned transfers have metadata");
1118 if dry_run {
1119 return match backend.exists(&metadata.hashes) {
1120 Ok(true) => Ok(()),
1121 Ok(false) => Err((
1122 GetCode::SourceMissing,
1123 "this version is not in storage".to_string(),
1124 )),
1125 Err(e) => Err((code_for(&e), format!("{e:#}"))),
1126 };
1127 }
1128 let file_progress = ctx.observer().transfer_started(&p.row.path, metadata.size);
1129 let on_bytes = file_progress.as_ref().map(|fp| &*fp.on_bytes);
1130 let result = install(
1131 backend,
1132 paths,
1133 &p.row.path,
1134 metadata,
1135 &p.stat,
1136 operation_id,
1137 on_bytes,
1138 );
1139 if let Some(fp) = &file_progress {
1140 (fp.on_done)(result.is_ok());
1141 }
1142 result
1143}
1144
1145fn settle(
1148 row: &mut GetRow,
1149 p: &Planned,
1150 result: Result<(), FileError>,
1151 dry_run: bool,
1152 paths: &DvsPaths,
1153 cache: Option<&Mutex<HashCache>>,
1154) {
1155 let was_absent = p.row.before == Before::Absent;
1156 let was_conflict = p.row.is_conflict();
1157 match result {
1158 Ok(()) => {
1159 let action = match (dry_run, was_absent) {
1160 (true, true) => Action::WouldRestore,
1161 (true, false) => Action::WouldReplace,
1162 (false, true) => Action::Restored,
1163 (false, false) => Action::Replaced,
1164 };
1165 let message = was_conflict.then(|| {
1166 match (dry_run, p.row.before == Before::Modified) {
1167 (true, true) => "local changes would be discarded",
1168 (false, true) => "local changes were discarded",
1169 (true, false) => "the local version would be replaced",
1170 (false, false) => "the local version was replaced",
1171 }
1172 .to_string()
1173 });
1174 set_row(row, action, None, message);
1175 if !dry_run {
1176 let metadata = p
1177 .metadata
1178 .as_ref()
1179 .expect("planned transfers have metadata");
1180 let rel_str = p.row.path.to_string_lossy().into_owned();
1181 record_baseline(cache, &rel_str, &metadata.hashes.blake3, metadata.size);
1182 if let Some(mtx) = cache {
1183 if let Ok(stat) = FileStat::from_path(&paths.file_path(&p.row.path)) {
1184 if let Err(e) =
1185 mtx.lock()
1186 .unwrap()
1187 .insert(&rel_str, &stat, &metadata.hashes)
1188 {
1189 log::warn!("Cache store failed after get for {rel_str}: {e}");
1190 }
1191 }
1192 }
1193 log::info!("Retrieved {} ({})", p.row.path.display(), action.as_str());
1194 }
1195 }
1196 Err((code, message)) => {
1197 log::warn!("Failed to get {}: {message}", p.row.path.display());
1198 set_row(row, Action::Failed, Some(code), Some(message));
1199 }
1200 }
1201}
1202
1203fn access_failure(e: anyhow::Error) -> FileError {
1206 let code = backend_error_code(&e)
1207 .map(GetCode::from)
1208 .unwrap_or(GetCode::Shared(ErrorCode::ServerUnavailable));
1209 (code, format!("{e:#}"))
1210}
1211
1212pub fn shell_path(path: &Path) -> String {
1214 let s = path.to_string_lossy();
1215 if s.chars()
1216 .all(|c| c.is_ascii_alphanumeric() || "/._-+".contains(c))
1217 {
1218 s.into_owned()
1219 } else {
1220 format!("'{}'", s.replace('\'', r"'\''"))
1221 }
1222}
1223
1224fn hint(row: &GetRow) -> Option<String> {
1226 let p = shell_path(&row.local_path);
1227 let choices = format!(
1228 "keep your version: dvs add {p} (makes it the tracked version); \
1229 take the tracked version: dvs get --force {p} (replaces yours)"
1230 );
1231 match row.code? {
1232 GetCode::LocalChanges => Some(choices),
1233 GetCode::UnknownLocal => Some(format!(
1234 "dvs has no record of writing this file (a clone from before dvs recorded this, \
1235 or a cleared cache); {choices}"
1236 )),
1237 GetCode::BatchAborted => {
1238 Some("held back by another file; to get the rest: --on-conflict skip".into())
1239 }
1240 GetCode::NotTracked if row.local_path.is_dir() => Some(
1241 "nothing tracked is selected here; add -r to include subdirectories, or check the path"
1242 .into(),
1243 ),
1244 GetCode::NotTracked => Some("check the path; list tracked files with: dvs status".into()),
1245 GetCode::SourceMissing => Some(
1246 "the stored content of this version is missing (probably an interrupted upload); \
1247 tell whoever added it"
1248 .into(),
1249 ),
1250 GetCode::Shared(ErrorCode::AccessDenied) => {
1251 Some("check you are logged in with access: dvs info, dvs login".into())
1252 }
1253 GetCode::Shared(ErrorCode::ServerUnavailable) => {
1254 Some("check your network or VPN and the server, then run dvs get again".into())
1255 }
1256 GetCode::DestinationChanged => Some(format!("run again once nothing is writing {p}")),
1257 GetCode::FileInUse => {
1258 Some("close the program using the file, then run dvs get again".into())
1259 }
1260 GetCode::Shared(ErrorCode::BadMetadata) => Some(format!(
1261 "fix the .dvs file for {p} (for example a git merge conflict), then run dvs get again"
1262 )),
1263 _ => None,
1264 }
1265}
1266
1267fn operation_plan(planned: &[Planned], transfer_idx: &[usize]) -> OperationPlan {
1270 let size = |p: &Planned| p.row.size.unwrap_or(0);
1271 OperationPlan {
1272 files_total: planned.len() as u64,
1273 bytes_total: planned.iter().map(size).sum(),
1274 files_to_transfer: transfer_idx.len() as u64,
1275 bytes_to_transfer: transfer_idx.iter().map(|&i| size(&planned[i])).sum(),
1276 }
1277}
1278
1279struct Ending {
1281 dry_run: bool,
1282 on_conflict: OnConflict,
1283 source: SettingSource,
1284 aborted: bool,
1285}
1286
1287fn finish(planned: Vec<Planned>, ending: Ending, ctx: &OperationContext) -> GetReport {
1288 let Ending {
1289 dry_run,
1290 on_conflict,
1291 source,
1292 aborted,
1293 } = ending;
1294 let files: Vec<GetRow> = planned
1295 .into_iter()
1296 .map(|mut p| {
1297 p.emit(ctx);
1298 p.row
1299 })
1300 .collect();
1301 let mut counts = GetCounts {
1302 total: files.len(),
1303 succeeded: files.iter().filter(|r| r.satisfied).count(),
1304 ..GetCounts::default()
1305 };
1306 for r in &files {
1307 let slot = match r.action {
1308 Action::Restored => &mut counts.restored,
1309 Action::Replaced => &mut counts.replaced,
1310 Action::Unchanged => &mut counts.unchanged,
1311 Action::Skipped => &mut counts.skipped,
1312 Action::NotRun => &mut counts.not_run,
1313 Action::Cancelled => &mut counts.cancelled,
1314 Action::Failed => &mut counts.failed,
1315 Action::WouldRestore => &mut counts.would_restore,
1316 Action::WouldReplace => &mut counts.would_replace,
1317 };
1318 *slot += 1;
1319 }
1320 let mut bytes = ByteTotals::default();
1323 for r in &files {
1324 if let Some(size) = r.size {
1325 let installed = matches!(r.action, Action::Restored | Action::Replaced);
1326 bytes.count(size, r.before == Before::Current, installed);
1327 }
1328 }
1329 let outcome = if ctx.is_cancelled() {
1332 GetOutcome::Cancelled
1333 } else if aborted {
1334 GetOutcome::Aborted
1335 } else if counts.succeeded == counts.total {
1336 GetOutcome::Complete
1337 } else if counts.succeeded + counts.skipped == 0 {
1338 GetOutcome::Failed
1339 } else {
1340 GetOutcome::Partial
1341 };
1342 GetReport {
1343 outcome,
1344 dry_run,
1345 on_conflict,
1346 source,
1347 counts,
1348 bytes,
1349 problem: None,
1350 files,
1351 }
1352}
1353
1354#[cfg(test)]
1355mod tests {
1356 use super::*;
1357 use crate::add_files;
1358 use crate::config::Config;
1359 use crate::testutil::{create_file, create_temp_git_repo, init_dvs_repo};
1360 use std::collections::HashSet;
1361 use tempfile::TempDir;
1362
1363 struct TestRepo {
1364 _tmp: TempDir,
1365 root: PathBuf,
1366 config: Config,
1367 paths: DvsPaths,
1368 }
1369
1370 impl TestRepo {
1371 fn new() -> Self {
1372 let (_tmp, root) = create_temp_git_repo();
1373 let (config, _dvs_dir) = init_dvs_repo(&root);
1374 let paths =
1375 DvsPaths::new(root.clone(), root.clone(), config.metadata_folder_name()).unwrap();
1376 Self {
1377 _tmp,
1378 root,
1379 config,
1380 paths,
1381 }
1382 }
1383
1384 fn backend(&self) -> &Backend {
1385 self.config.backend()
1386 }
1387
1388 fn add(&self, name: &str, content: &[u8]) {
1390 create_file(&self.root, name, content);
1391 let results = add_files(
1392 vec![name.into()],
1393 &self.paths,
1394 self.backend(),
1395 None,
1396 Compression::Zstd,
1397 false,
1398 &OperationContext::new(),
1399 )
1400 .unwrap()
1401 .files;
1402 assert!(matches!(
1403 results[0].detail,
1404 crate::AddDetail::Success { .. }
1405 ));
1406 }
1407
1408 fn metadata_bytes(&self, name: &str) -> Vec<u8> {
1409 fs::read(self.paths.metadata_path(Path::new(name))).unwrap()
1410 }
1411
1412 fn set_metadata_bytes(&self, name: &str, bytes: &[u8]) {
1414 fs::write(self.paths.metadata_path(Path::new(name)), bytes).unwrap();
1415 }
1416
1417 fn switched_branch(&self, name: &str, v1: &[u8], v2: &[u8]) {
1420 self.add(name, v1);
1421 let old = self.metadata_bytes(name);
1422 self.add(name, v2);
1423 self.set_metadata_bytes(name, &old);
1424 }
1425
1426 fn read(&self, name: &str) -> Vec<u8> {
1427 fs::read(self.root.join(name)).unwrap()
1428 }
1429
1430 fn get(&self, names: &[&str], on_conflict: OnConflict) -> GetReport {
1431 self.get_with(names, &[], on_conflict, false)
1432 }
1433
1434 fn get_with(
1435 &self,
1436 names: &[&str],
1437 unmatched: &[&str],
1438 on_conflict: OnConflict,
1439 dry_run: bool,
1440 ) -> GetReport {
1441 let selection = GetSelection {
1442 tracked: names.iter().map(PathBuf::from).collect::<HashSet<_>>(),
1443 unmatched: unmatched.iter().map(PathBuf::from).collect(),
1444 };
1445 get_files(
1446 selection,
1447 &self.paths,
1448 self.backend(),
1449 GetOptions {
1450 dry_run,
1451 on_conflict,
1452 source: SettingSource::Argument,
1453 },
1454 &OperationContext::new(),
1455 )
1456 }
1457
1458 fn corrupt_blob(&self, name: &str) {
1459 let meta: FileMetadata = serde_json::from_slice(&self.metadata_bytes(name)).unwrap();
1460 let hash = meta.hashes.blake3;
1461 let storage = match &self.config.backend {
1462 Backend::Local(b) => b.path.clone(),
1463 Backend::Server(_) => unreachable!(),
1464 };
1465 let blob = storage.join(&hash[..2]).join(&hash[2..]);
1466 let mut perms = fs::metadata(&blob).unwrap().permissions();
1467 #[allow(clippy::permissions_set_readonly_false)]
1468 perms.set_readonly(false);
1469 fs::set_permissions(&blob, perms).unwrap();
1470 fs::write(&blob, zstd::encode_all(&b"other bytes"[..], 0).unwrap()).unwrap();
1472 }
1473 }
1474
1475 fn row<'a>(report: &'a GetReport, name: &str) -> &'a GetRow {
1476 report
1477 .files
1478 .iter()
1479 .find(|r| r.path == Path::new(name))
1480 .unwrap_or_else(|| panic!("no row for {name}: {report:#?}"))
1481 }
1482
1483 #[test]
1485 fn absent_files_are_restored() {
1486 let repo = TestRepo::new();
1487 repo.add("a.csv", b"a");
1488 fs::remove_file(repo.root.join("a.csv")).unwrap();
1489
1490 let report = repo.get(&["a.csv"], OnConflict::Abort);
1491 assert_eq!(report.outcome, GetOutcome::Complete);
1492 let r = row(&report, "a.csv");
1493 assert_eq!(
1494 (r.before, r.action, r.satisfied),
1495 (Before::Absent, Action::Restored, true)
1496 );
1497 assert_eq!(repo.read("a.csv"), b"a");
1498 }
1499
1500 #[test]
1502 fn current_files_are_unchanged() {
1503 let repo = TestRepo::new();
1504 repo.add("a.csv", b"a");
1505 let report = repo.get(&["a.csv"], OnConflict::Abort);
1506 assert_eq!(report.outcome, GetOutcome::Complete);
1507 assert_eq!(row(&report, "a.csv").action, Action::Unchanged);
1508 }
1509
1510 #[test]
1512 fn clean_files_after_a_branch_switch_are_replaced() {
1513 let repo = TestRepo::new();
1514 repo.switched_branch("a.csv", b"version one", b"version two");
1515
1516 let report = repo.get(&["a.csv"], OnConflict::Abort);
1517 let r = row(&report, "a.csv");
1518 assert_eq!((r.before, r.action), (Before::Clean, Action::Replaced));
1519 assert_eq!(report.outcome, GetOutcome::Complete);
1520 assert_eq!(repo.read("a.csv"), b"version one");
1521 }
1522
1523 #[test]
1524 fn a_get_install_records_the_baseline() {
1525 let repo = TestRepo::new();
1526 repo.switched_branch("a.csv", b"one", b"two");
1527 repo.get(&["a.csv"], OnConflict::Abort);
1528 repo.add("tmp.csv", b"two");
1530 let two = repo.metadata_bytes("tmp.csv");
1531 repo.set_metadata_bytes("a.csv", &two);
1532 let report = repo.get(&["a.csv"], OnConflict::Abort);
1533 assert_eq!(row(&report, "a.csv").before, Before::Clean, "{report:#?}");
1534 assert_eq!(repo.read("a.csv"), b"two");
1535 }
1536
1537 #[test]
1539 fn local_changes_abort_the_whole_batch() {
1540 let repo = TestRepo::new();
1541 repo.switched_branch("a.csv", b"a1", b"a2");
1542 repo.switched_branch("b.csv", b"b1", b"b2");
1543 fs::write(repo.root.join("b.csv"), b"my edits").unwrap();
1544
1545 let report = repo.get(&["a.csv", "b.csv"], OnConflict::Abort);
1546 assert_eq!(report.outcome, GetOutcome::Aborted);
1547 let b = row(&report, "b.csv");
1548 assert_eq!(
1549 (b.before, b.action, b.code),
1550 (
1551 Before::Modified,
1552 Action::NotRun,
1553 Some(GetCode::LocalChanges)
1554 )
1555 );
1556 assert_eq!(b.stored_copy, Some(StoredCopy::Absent));
1557 let a = row(&report, "a.csv");
1558 assert_eq!(
1559 (a.action, a.code),
1560 (Action::NotRun, Some(GetCode::BatchAborted))
1561 );
1562 assert_eq!(repo.read("a.csv"), b"a2", "nothing may change on abort");
1563 assert_eq!(repo.read("b.csv"), b"my edits");
1564 assert_eq!(report.verdict(), GetVerdict::HeldBack);
1565 assert!(b.hint.as_deref().unwrap().contains("dvs add b.csv"));
1566 }
1567
1568 #[test]
1570 fn overwrite_replaces_local_changes() {
1571 let repo = TestRepo::new();
1572 repo.switched_branch("b.csv", b"b1", b"b2");
1573 fs::write(repo.root.join("b.csv"), b"my edits").unwrap();
1574
1575 let report = repo.get(&["b.csv"], OnConflict::Overwrite);
1576 assert_eq!(report.outcome, GetOutcome::Complete);
1577 let b = row(&report, "b.csv");
1578 assert_eq!((b.before, b.action), (Before::Modified, Action::Replaced));
1579 assert_eq!(repo.read("b.csv"), b"b1");
1580 }
1581
1582 #[test]
1584 fn a_differing_file_without_baseline_is_unknown() {
1585 let repo = TestRepo::new();
1586 repo.add("a.csv", b"tracked");
1587 fs::remove_dir_all(repo.paths.cache_folder()).unwrap();
1588 fs::write(repo.root.join("a.csv"), b"copied in by hand").unwrap();
1589
1590 let report = repo.get(&["a.csv"], OnConflict::Abort);
1591 let r = row(&report, "a.csv");
1592 assert_eq!(
1593 (r.before, r.action, r.code),
1594 (Before::Unknown, Action::NotRun, Some(GetCode::UnknownLocal))
1595 );
1596 assert_eq!(repo.read("a.csv"), b"copied in by hand");
1597 }
1598
1599 #[test]
1601 fn an_edit_undone_exactly_is_clean() {
1602 let repo = TestRepo::new();
1603 repo.switched_branch("a.csv", b"one", b"two");
1604 fs::write(repo.root.join("a.csv"), b"temporary edit").unwrap();
1605 fs::write(repo.root.join("a.csv"), b"two").unwrap();
1606 let report = repo.get(&["a.csv"], OnConflict::Abort);
1607 assert_eq!(row(&report, "a.csv").before, Before::Clean);
1608 }
1609
1610 #[test]
1612 fn untracked_requests_are_rows_and_block_unless_skip() {
1613 let repo = TestRepo::new();
1614 repo.add("a.csv", b"a");
1615 fs::remove_file(repo.root.join("a.csv")).unwrap();
1616
1617 let report = repo.get_with(&["a.csv"], &["typo.csv"], OnConflict::Abort, false);
1618 assert_eq!(report.outcome, GetOutcome::Aborted);
1619 let t = row(&report, "typo.csv");
1620 assert_eq!(
1621 (t.before, t.code),
1622 (Before::NotTracked, Some(GetCode::NotTracked))
1623 );
1624 assert_eq!(row(&report, "a.csv").code, Some(GetCode::BatchAborted));
1625 assert!(!repo.root.join("a.csv").exists());
1626 assert_eq!(report.verdict(), GetVerdict::Failed);
1627
1628 let report = repo.get_with(&["a.csv"], &["typo.csv"], OnConflict::Skip, false);
1629 assert_eq!(report.outcome, GetOutcome::Partial);
1630 assert_eq!(row(&report, "a.csv").action, Action::Restored);
1631 }
1632
1633 #[test]
1635 fn malformed_metadata_blocks_the_batch_unless_skip() {
1636 let repo = TestRepo::new();
1637 repo.add("a.csv", b"a");
1638 repo.add("b.csv", b"b");
1639 fs::remove_file(repo.root.join("a.csv")).unwrap();
1640 repo.set_metadata_bytes("b.csv", b"<<<<<<< HEAD\n{}\n=======\n{}\n>>>>>>> other\n");
1641
1642 let report = repo.get(&["a.csv", "b.csv"], OnConflict::Abort);
1643 assert_eq!(report.outcome, GetOutcome::Aborted);
1644 assert_eq!(
1645 row(&report, "b.csv").code,
1646 Some(GetCode::Shared(ErrorCode::BadMetadata))
1647 );
1648 assert_eq!(row(&report, "a.csv").code, Some(GetCode::BatchAborted));
1649 assert!(!repo.root.join("a.csv").exists());
1650
1651 let report = repo.get(&["a.csv", "b.csv"], OnConflict::Skip);
1652 assert_eq!(row(&report, "a.csv").action, Action::Restored);
1653 }
1654
1655 #[test]
1656 fn a_directory_without_selected_files_says_so() {
1657 let repo = TestRepo::new();
1658 repo.add("a.csv", b"a");
1659 fs::create_dir_all(repo.root.join("empty")).unwrap();
1660 let report = repo.get_with(&["a.csv"], &["empty"], OnConflict::Skip, false);
1661 let r = row(&report, "empty");
1662 assert_eq!(r.code, Some(GetCode::NotTracked));
1663 assert_eq!(
1664 r.message.as_deref(),
1665 Some("no tracked files selected in this directory")
1666 );
1667 }
1668
1669 #[test]
1671 fn an_empty_selection_is_an_operation_problem() {
1672 let repo = TestRepo::new();
1673 let report = repo.get(&[], OnConflict::Abort);
1674 assert_eq!(report.outcome, GetOutcome::Failed);
1675 assert_eq!(
1676 report.problem.map(|p| p.code),
1677 Some(ErrorCode::NoFilesSelected)
1678 );
1679 assert!(report.files.is_empty());
1680 }
1681
1682 #[test]
1684 fn missing_content_fails_only_that_file() {
1685 let repo = TestRepo::new();
1686 repo.add("a.csv", b"a");
1687 repo.add("b.csv", b"b");
1688 let storage = match &repo.config.backend {
1689 Backend::Local(b) => b.path.clone(),
1690 Backend::Server(_) => unreachable!(),
1691 };
1692 let meta: FileMetadata = serde_json::from_slice(&repo.metadata_bytes("b.csv")).unwrap();
1693 let h = meta.hashes.blake3;
1694 fs::remove_file(storage.join(&h[..2]).join(&h[2..])).unwrap();
1695 fs::remove_file(repo.root.join("a.csv")).unwrap();
1696 fs::remove_file(repo.root.join("b.csv")).unwrap();
1697
1698 let report = repo.get(&["a.csv", "b.csv"], OnConflict::Abort);
1699 assert_eq!(report.outcome, GetOutcome::Partial);
1700 assert_eq!(row(&report, "a.csv").action, Action::Restored);
1701 assert_eq!(row(&report, "b.csv").code, Some(GetCode::SourceMissing));
1702 let c = &report.counts;
1703 assert_eq!((c.total, c.succeeded, c.failed), (2, 1, 1));
1704 }
1705
1706 #[test]
1708 fn corrupt_content_keeps_the_existing_file() {
1709 let repo = TestRepo::new();
1710 repo.switched_branch("a.csv", b"original content", b"newer content");
1711 repo.corrupt_blob("a.csv");
1713 let report = repo.get(&["a.csv"], OnConflict::Abort);
1714 let r = row(&report, "a.csv");
1715 assert_eq!(
1716 (r.action, r.code),
1717 (Action::Failed, Some(GetCode::HashMismatch))
1718 );
1719 assert_eq!(repo.read("a.csv"), b"newer content");
1720 let tmp_left = fs::read_dir(&repo.root)
1721 .unwrap()
1722 .flatten()
1723 .any(|e| e.file_name().to_string_lossy().contains("dvs-tmp"));
1724 assert!(!tmp_left);
1725 }
1726
1727 #[test]
1729 fn a_directory_in_the_way_is_never_replaced() {
1730 let repo = TestRepo::new();
1731 repo.add("a.csv", b"a");
1732 fs::remove_file(repo.root.join("a.csv")).unwrap();
1733 fs::create_dir(repo.root.join("a.csv")).unwrap();
1734 fs::write(repo.root.join("a.csv/inside"), b"keep").unwrap();
1735
1736 let report = repo.get(&["a.csv"], OnConflict::Overwrite);
1737 assert_eq!(row(&report, "a.csv").code, Some(GetCode::NotAFile));
1738 assert!(repo.root.join("a.csv/inside").exists());
1739 }
1740
1741 #[cfg(unix)]
1743 #[test]
1744 fn a_symlinked_destination_is_never_written_through() {
1745 let repo = TestRepo::new();
1746 repo.add("a.csv", b"a");
1747 let elsewhere = repo.root.parent().unwrap().join("elsewhere.csv");
1748 fs::write(&elsewhere, b"precious").unwrap();
1749 fs::remove_file(repo.root.join("a.csv")).unwrap();
1750 std::os::unix::fs::symlink(&elsewhere, repo.root.join("a.csv")).unwrap();
1751
1752 let report = repo.get(&["a.csv"], OnConflict::Overwrite);
1753 assert_eq!(row(&report, "a.csv").code, Some(GetCode::NotAFile));
1754 assert_eq!(fs::read(&elsewhere).unwrap(), b"precious");
1755 }
1756
1757 #[cfg(unix)]
1759 #[test]
1760 fn a_symlinked_parent_leading_outside_is_refused() {
1761 let repo = TestRepo::new();
1762 repo.add("data/x.txt", b"tracked");
1763 let outside = repo.root.parent().unwrap().join("outside");
1764 fs::create_dir(&outside).unwrap();
1765 fs::write(outside.join("x.txt"), b"precious").unwrap();
1766 fs::remove_dir_all(repo.root.join("data")).unwrap();
1767 std::os::unix::fs::symlink(&outside, repo.root.join("data")).unwrap();
1768
1769 for policy in [OnConflict::Abort, OnConflict::Skip, OnConflict::Overwrite] {
1770 let report = repo.get(&["data/x.txt"], policy);
1771 let r = row(&report, "data/x.txt");
1772 assert_eq!(
1773 (r.before, r.code),
1774 (Before::UnsafePath, Some(GetCode::UnsafePath))
1775 );
1776 assert_eq!(fs::read(outside.join("x.txt")).unwrap(), b"precious");
1777 }
1778 }
1779
1780 #[test]
1782 fn a_destination_that_changed_after_planning_is_detected() {
1783 let repo = TestRepo::new();
1784 let path = create_file(&repo.root, "a.csv", b"before");
1785 let stat = Some(FileStat::from_path(&path).unwrap());
1786 assert!(destination_unchanged(&path, &stat));
1787 fs::write(&path, b"edited meanwhile").unwrap();
1788 assert!(!destination_unchanged(&path, &stat));
1789 let absent = repo.root.join("new.csv");
1790 assert!(destination_unchanged(&absent, &None));
1791 fs::write(&absent, b"appeared").unwrap();
1792 assert!(!destination_unchanged(&absent, &None));
1793 }
1794
1795 #[test]
1797 fn a_dry_run_writes_nothing() {
1798 let repo = TestRepo::new();
1799 repo.switched_branch("a.csv", b"a1", b"a2");
1800 repo.add("b.csv", b"b");
1801 fs::remove_file(repo.root.join("b.csv")).unwrap();
1802
1803 let report = repo.get_with(&["a.csv", "b.csv"], &[], OnConflict::Abort, true);
1804 assert_eq!(report.outcome, GetOutcome::Complete);
1805 assert_eq!(row(&report, "a.csv").action, Action::WouldReplace);
1806 assert_eq!(row(&report, "b.csv").action, Action::WouldRestore);
1807 assert_eq!(repo.read("a.csv"), b"a2");
1808 assert!(!repo.root.join("b.csv").exists());
1809 }
1810
1811 #[test]
1813 fn skip_keeps_local_changes_and_gets_the_rest() {
1814 let repo = TestRepo::new();
1815 repo.add("a.csv", b"a");
1816 fs::remove_file(repo.root.join("a.csv")).unwrap();
1817 repo.switched_branch("b.csv", b"b1", b"b2");
1818 fs::write(repo.root.join("b.csv"), b"edited").unwrap();
1819 repo.add("c.csv", b"c");
1820 repo.switched_branch("d.csv", b"d1", b"d2");
1821
1822 let report = repo.get(&["a.csv", "b.csv", "c.csv", "d.csv"], OnConflict::Skip);
1823 assert_eq!(row(&report, "a.csv").action, Action::Restored);
1824 assert_eq!(row(&report, "b.csv").action, Action::Skipped);
1825 assert_eq!(row(&report, "c.csv").action, Action::Unchanged);
1826 assert_eq!(row(&report, "d.csv").action, Action::Replaced);
1827 assert_eq!(report.outcome, GetOutcome::Partial);
1828 assert_eq!(report.verdict(), GetVerdict::Done);
1829 assert_eq!(repo.read("b.csv"), b"edited");
1830 assert_eq!(repo.read("d.csv"), b"d1");
1831 }
1832
1833 #[test]
1834 fn skipping_every_file_is_partial_and_done() {
1835 let repo = TestRepo::new();
1836 repo.switched_branch("b.csv", b"b1", b"b2");
1837 fs::write(repo.root.join("b.csv"), b"edited").unwrap();
1838
1839 let report = repo.get(&["b.csv"], OnConflict::Skip);
1840 assert_eq!(row(&report, "b.csv").action, Action::Skipped);
1841 assert_eq!(report.outcome, GetOutcome::Partial);
1842 assert_eq!(report.verdict(), GetVerdict::Done);
1843 }
1844
1845 #[test]
1848 fn paths_from_a_subdirectory() {
1849 let repo = TestRepo::new();
1850 repo.add("data/x.csv", b"x");
1851 fs::remove_file(repo.root.join("data/x.csv")).unwrap();
1852 let in_data = DvsPaths::new(
1853 repo.root.join("data"),
1854 repo.root.clone(),
1855 repo.config.metadata_folder_name(),
1856 )
1857 .unwrap();
1858 let selection = GetSelection {
1859 tracked: HashSet::from([PathBuf::from("data/x.csv")]),
1860 unmatched: vec!["typo.csv".into(), repo.root.join("data/abs-typo.csv")],
1861 };
1862 let options = GetOptions {
1863 dry_run: false,
1864 on_conflict: OnConflict::Skip,
1865 source: SettingSource::Argument,
1866 };
1867 let report = get_files(
1868 selection,
1869 &in_data,
1870 repo.backend(),
1871 options,
1872 &OperationContext::new(),
1873 );
1874 let local = |name: &str| row(&report, name).local_path.clone();
1875 assert_eq!(local("data/x.csv"), PathBuf::from("x.csv"));
1876 assert_eq!(local("data/typo.csv"), PathBuf::from("typo.csv"));
1877 assert_eq!(local("data/abs-typo.csv"), PathBuf::from("abs-typo.csv"));
1878 assert!(repo.root.join("data/x.csv").exists());
1879 }
1880
1881 #[cfg(feature = "cache")]
1884 #[test]
1885 fn an_upgraded_clone_learns_the_baseline_from_a_real_get() {
1886 let repo = TestRepo::new();
1887 repo.add("a.csv", b"one");
1888 let one = repo.metadata_bytes("a.csv");
1889 repo.add("a.csv", b"two");
1890 let two = repo.metadata_bytes("a.csv");
1891 rusqlite::Connection::open(repo.paths.cache_folder().join("dvs.db"))
1892 .unwrap()
1893 .execute_batch("DROP TABLE baseline;")
1894 .unwrap();
1895
1896 let report = repo.get_with(&["a.csv"], &[], OnConflict::Abort, true);
1897 assert_eq!(row(&report, "a.csv").action, Action::Unchanged);
1898 repo.set_metadata_bytes("a.csv", &one);
1899 let report = repo.get(&["a.csv"], OnConflict::Abort);
1900 assert_eq!(
1901 row(&report, "a.csv").before,
1902 Before::Unknown,
1903 "dry run recorded"
1904 );
1905
1906 repo.set_metadata_bytes("a.csv", &two);
1907 repo.get(&["a.csv"], OnConflict::Abort);
1908 repo.set_metadata_bytes("a.csv", &one);
1909 let report = repo.get(&["a.csv"], OnConflict::Abort);
1910 let r = row(&report, "a.csv");
1911 assert_eq!((r.before, r.action), (Before::Clean, Action::Replaced));
1912 assert_eq!(repo.read("a.csv"), b"one");
1913 }
1914
1915 #[cfg(unix)]
1918 #[test]
1919 fn an_unwritable_destination_fails_alone_and_a_rerun_finishes() {
1920 use std::os::unix::fs::PermissionsExt;
1921 let repo = TestRepo::new();
1922 repo.add("locked/a.csv", b"a");
1923 repo.add("b.csv", b"b");
1924 fs::remove_file(repo.root.join("locked/a.csv")).unwrap();
1925 fs::remove_file(repo.root.join("b.csv")).unwrap();
1926 let locked = repo.root.join("locked");
1927 fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o555)).unwrap();
1928 if fs::write(locked.join("probe"), b"").is_ok() {
1929 return; }
1931
1932 let report = repo.get(&["locked/a.csv", "b.csv"], OnConflict::Abort);
1933 fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
1934 assert_eq!(report.outcome, GetOutcome::Partial);
1935 assert_eq!(
1936 row(&report, "locked/a.csv").code,
1937 Some(GetCode::PermissionDenied)
1938 );
1939 assert_eq!(row(&report, "b.csv").action, Action::Restored);
1940
1941 let report = repo.get(&["locked/a.csv", "b.csv"], OnConflict::Abort);
1942 assert_eq!(report.outcome, GetOutcome::Complete);
1943 assert_eq!(repo.read("locked/a.csv"), b"a");
1944 }
1945
1946 impl TestRepo {
1947 fn get_observed(
1948 &self,
1949 names: &[&str],
1950 unmatched: &[&str],
1951 ) -> (GetReport, Vec<String>, Vec<serde_json::Value>) {
1952 let recorder = std::sync::Arc::new(crate::testutil::Recorder::default());
1953 let selection = GetSelection {
1954 tracked: names.iter().map(PathBuf::from).collect::<HashSet<_>>(),
1955 unmatched: unmatched.iter().map(PathBuf::from).collect(),
1956 };
1957 let options = GetOptions {
1958 dry_run: false,
1959 on_conflict: OnConflict::Skip,
1960 source: SettingSource::Argument,
1961 };
1962 let ctx = OperationContext::new().with_observer(recorder.clone());
1963 let report = get_files(selection, &self.paths, self.backend(), options, &ctx);
1964 let rows = recorder.rows.lock().unwrap().clone();
1965 (report, recorder.seen(), rows)
1966 }
1967
1968 fn temp_files_left(&self) -> bool {
1969 fs::read_dir(&self.root)
1970 .unwrap()
1971 .flatten()
1972 .any(|e| e.file_name().to_string_lossy().contains("dvs-tmp"))
1973 }
1974 }
1975
1976 #[test]
1977 fn observer_hears_the_plan_then_each_final_row_once_as_it_finishes() {
1978 let repo = TestRepo::new();
1979 repo.add("a.csv", b"aaa");
1981 fs::remove_file(repo.root.join("a.csv")).unwrap();
1982 repo.add("b.csv", b"bb");
1983 repo.switched_branch("c.csv", b"c1", b"c2");
1984 fs::write(repo.root.join("c.csv"), b"edited").unwrap();
1985 repo.add("d.csv", b"dddd");
1986 fs::remove_file(repo.root.join("d.csv")).unwrap();
1987 let names = ["a.csv", "b.csv", "c.csv", "d.csv"];
1988 let (report, seen, mut rows) = repo.get_observed(&names, &["typo.csv"]);
1989
1990 let at = |line: &str| seen.iter().position(|s| s == line).unwrap();
1991 let plan = at("plan 5 11 2 7");
1993 assert!(
1994 seen[..plan].iter().all(|s| !s.starts_with("row")),
1995 "{seen:?}"
1996 );
1997 rows.sort_by_key(|r| r["path"].as_str().unwrap().to_string());
1999 let expected: Vec<_> = report
2000 .files
2001 .iter()
2002 .map(|r| serde_json::to_value(r).unwrap())
2003 .collect();
2004 assert_eq!(rows, expected);
2005 assert!(row(&report, "c.csv").hint.is_some());
2006 assert!(row(&report, "a.csv").duration_ms.is_some());
2008 assert!(row(&report, "b.csv").duration_ms.is_some());
2009 assert_eq!(row(&report, "typo.csv").duration_ms, None);
2010 }
2011
2012 #[test]
2013 fn no_hash_events_when_nothing_is_hashed() {
2014 let repo = TestRepo::new();
2015 repo.add("a.csv", b"a");
2016 repo.add("b.csv", b"b");
2017 fs::remove_file(repo.root.join("b.csv")).unwrap();
2018 let hash_events = |name| {
2019 let (_, seen, _) = repo.get_observed(&[name], &[]);
2020 seen.into_iter()
2021 .filter(|s| s.starts_with("hash"))
2022 .collect::<Vec<_>>()
2023 };
2024 assert_eq!(hash_events("a.csv"), ["hash start", "hash end"]);
2026 assert!(hash_events("b.csv").is_empty());
2028 }
2029
2030 #[test]
2031 fn a_cancel_lets_started_files_finish_and_cancels_the_rest() {
2032 let repo = TestRepo::new();
2033 let names: Vec<String> = (0..40).map(|i| format!("f{i:02}.csv")).collect();
2035 for name in &names {
2036 repo.add(name, name.as_bytes());
2037 fs::remove_file(repo.root.join(name)).unwrap();
2038 }
2039 let token = crate::CancelToken::new();
2040 let recorder = std::sync::Arc::new(crate::testutil::Recorder::cancelling_on_transfer(
2041 token.clone(),
2042 1,
2043 ));
2044 let selection = GetSelection {
2045 tracked: names.iter().map(PathBuf::from).collect(),
2046 unmatched: Vec::new(),
2047 };
2048 let options = GetOptions {
2049 dry_run: false,
2050 on_conflict: OnConflict::Abort,
2051 source: SettingSource::Argument,
2052 };
2053 let ctx = OperationContext::new()
2054 .with_cancel(token)
2055 .with_observer(recorder.clone());
2056 let report = get_files(selection, &repo.paths, repo.backend(), options, &ctx);
2057
2058 assert_eq!(report.outcome, GetOutcome::Cancelled);
2059 let seen = recorder.seen();
2060 let started: HashSet<_> = seen
2061 .iter()
2062 .filter_map(|s| s.strip_prefix("transfer "))
2063 .map(PathBuf::from)
2064 .collect();
2065 assert!(!started.is_empty());
2066 let mut cancelled = 0;
2067 for r in &report.files {
2068 if started.contains(&r.path) {
2069 assert_eq!((r.action, r.code), (Action::Restored, None));
2070 assert!(repo.root.join(&r.path).exists());
2071 } else {
2072 assert_eq!(
2073 (r.action, r.code, r.satisfied, r.duration_ms),
2074 (
2075 Action::Cancelled,
2076 Some(GetCode::Shared(ErrorCode::Cancelled)),
2077 false,
2078 None
2079 )
2080 );
2081 assert!(!repo.root.join(&r.path).exists());
2082 cancelled += 1;
2083 }
2084 }
2085 assert!(cancelled > 0, "the cancel came too late to test anything");
2086 assert_eq!(report.counts.cancelled, cancelled);
2087 assert_eq!(report.counts.not_run, 0);
2088 assert_eq!(report.counts.restored, started.len());
2089 assert_eq!(report.counts.failed, 0, "cancelled files are not failures");
2090 assert!(!repo.temp_files_left());
2091 assert_eq!(recorder.rows.lock().unwrap().len(), names.len());
2093 }
2094
2095 #[test]
2096 fn access_failures_keep_their_shared_code() {
2097 for code in [ErrorCode::AuthExpired, ErrorCode::ProjectNotFound] {
2098 let e: anyhow::Error = Error::new(code, "nope").into();
2099 assert_eq!(access_failure(e).0.as_str(), code.as_str());
2100 }
2101 let plain = anyhow::anyhow!("something went wrong");
2102 assert_eq!(
2103 access_failure(plain).0,
2104 GetCode::Shared(ErrorCode::ServerUnavailable)
2105 );
2106 }
2107
2108 #[test]
2109 fn codes_serialize_as_flat_strings() {
2110 let json = |code: GetCode| serde_json::to_value(code).unwrap();
2111 assert_eq!(json(GetCode::LocalChanges), "local_changes");
2112 assert_eq!(json(GetCode::Shared(ErrorCode::IoError)), "io_error");
2113 }
2114
2115 fn summary_report(outcome: GetOutcome, counts: GetCounts) -> GetReport {
2117 let mut report = GetReport::problem(
2118 ErrorCode::OperationFailed,
2119 "",
2120 false,
2121 OnConflict::Abort,
2122 SettingSource::Default,
2123 );
2124 report.problem = None;
2125 report.outcome = outcome;
2126 report.counts = counts;
2127 report
2128 }
2129
2130 #[test]
2131 fn aborted_summary_names_files_that_cannot_be_retrieved() {
2132 let mut report = summary_report(
2133 GetOutcome::Aborted,
2134 GetCounts {
2135 failed: 1,
2136 ..GetCounts::default()
2137 },
2138 );
2139 assert!(report.summary().contains("1 file cannot be retrieved"));
2140 report.counts.failed = 2;
2141 assert!(report.summary().contains("2 files cannot be retrieved"));
2142 }
2143
2144 #[test]
2145 fn summary_lists_the_non_zero_counts_in_order() {
2146 let report = summary_report(
2147 GetOutcome::Cancelled,
2148 GetCounts {
2149 restored: 1,
2150 cancelled: 2,
2151 ..GetCounts::default()
2152 },
2153 );
2154 assert_eq!(report.summary(), "Cancelled: restored 1, cancelled 2");
2155 let report = summary_report(
2156 GetOutcome::Complete,
2157 GetCounts {
2158 unchanged: 3,
2159 ..GetCounts::default()
2160 },
2161 );
2162 assert_eq!(report.summary(), "Nothing to do, 3 already current");
2163 }
2164}