xvc_file/track/
mod.rs

1//! Home of the `xvc file track` command and related functionality.
2//!
3//! - [`cmd_track`] is the entry point for the `xvc file track` command.
4//! - [`TrackCLI`] is the command line interface
5//! - [`update_file_gitignores`] and [`update_dir_gitignores`] are functions to
6//!   update `.gitignore` files with the tracked paths.
7//! - [`carry_in`] is a specialized carry in function for `xvc file track`.
8
9use clap_complete::ArgValueCompleter;
10use derive_more::From;
11use xvc_core::util::completer::strum_variants_completer;
12
13use std::collections::HashSet;
14
15use xvc_core::util::git::build_gitignore;
16use xvc_core::{FromConfigKey, XvcConfigResult};
17use xvc_core::{UpdateFromXvcConfig, XvcConfig};
18
19use xvc_core::XvcOutputSender;
20use xvc_core::{
21    ContentDigest, HashAlgorithm, TextOrBinary, XvcCachePath, XvcFileType, XvcMetadata, XvcRoot,
22};
23
24use crate::carry_in::carry_in;
25use crate::common::compare::{
26    diff_content_digest, diff_recheck_method, diff_text_or_binary, diff_xvc_path_metadata,
27};
28use crate::common::gitignore::{update_dir_gitignores, update_file_gitignores};
29use crate::common::{targets_from_disk, update_store_records, FileTextOrBinary};
30use crate::error::Result;
31
32use clap::Parser;
33use std::path::PathBuf;
34
35use xvc_core::RecheckMethod;
36use xvc_core::XvcPath;
37use xvc_core::{HStore, XvcEntity};
38
39/// Add files for tracking with Xvc
40#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, From, Parser)]
41#[command(rename_all = "kebab-case")]
42pub struct TrackCLI {
43    /// How to track the file contents in cache: One of copy, symlink, hardlink, reflink.
44    ///
45    /// Note: Reflink uses copy if the underlying file system doesn't support it.
46    #[arg(long, alias = "as", add = ArgValueCompleter::new(strum_variants_completer::<RecheckMethod>))]
47    recheck_method: Option<RecheckMethod>,
48
49    /// Do not copy/link added files to the file cache
50    #[arg(long)]
51    no_commit: bool,
52    /// Calculate digests as text or binary file without checking contents, or by automatically. (Default:
53    /// auto)
54    #[arg(long, add = ArgValueCompleter::new(strum_variants_completer::<TextOrBinary>))]
55    text_or_binary: Option<FileTextOrBinary>,
56
57    /// Include git tracked files as well. (Default: false)
58    ///
59    /// Xvc doesn't track files that are already tracked by git by default.
60    /// You can set files.track.include-git to true in the configuration file to
61    /// change this behavior.
62    #[arg(long)]
63    include_git_files: bool,
64
65    /// Add targets even if they are already tracked
66    #[arg(long)]
67    force: bool,
68
69    /// Don't use parallelism
70    #[arg(long)]
71    no_parallel: bool,
72
73    /// Files/directories to track
74    #[arg(value_hint=clap::ValueHint::AnyPath)]
75    targets: Option<Vec<String>>,
76}
77
78impl UpdateFromXvcConfig for TrackCLI {
79    /// Updates `xvc file` configuration from the configuration files.
80    /// Command line options take precedence over other sources.
81    /// If options are not given, they are supplied from [XvcConfig]
82    fn update_from_conf(self, conf: &XvcConfig) -> XvcConfigResult<Box<Self>> {
83        let recheck_method = self
84            .recheck_method
85            .unwrap_or_else(|| RecheckMethod::from_conf(conf));
86        let no_commit = self.no_commit || conf.get_bool("file.track.no_commit")?.option;
87        let force = self.force || conf.get_bool("file.track.force")?.option;
88        let no_parallel = self.no_parallel || conf.get_bool("file.track.no_parallel")?.option;
89        let text_or_binary = self.text_or_binary.as_ref().map_or_else(
90            || Some(FileTextOrBinary::from_conf(conf)),
91            |v| Some(v.to_owned()),
92        );
93        let include_git_files =
94            self.include_git_files || conf.get_bool("file.track.include_git_files")?.option;
95
96        Ok(Box::new(Self {
97            targets: self.targets.clone(),
98            recheck_method: Some(recheck_method),
99            no_commit,
100            force,
101            no_parallel,
102            text_or_binary,
103            include_git_files,
104        }))
105    }
106}
107
108/// ## The pipeline
109///
110/// ```mermaid
111/// graph LR
112///     Target --> |File| Path
113///     Target -->|Directory| Dir
114///     Dir --> |File| Path
115///     Dir --> |Directory| Dir
116///     Path --> Ignored{Is this ignored?}
117///     Ignored --> |Yes| Ignore
118///     Ignored --> |No| XvcPath
119///     XvcPath --> |Force| XvcDigest
120///     XvcPath --> Filter{Is this changed?}
121///     Filter -->|Yes| XvcDigest
122///     Filter -->|No| Ignore
123///     XvcDigest --> CacheLocation
124///     CacheLocation --> RecheckMethod
125///     RecheckMethod --> |Copy| Copy
126///     RecheckMethod --> |Symlink| Symlink
127///     RecheckMethod --> |Hardlink| Hardlink
128///     RecheckMethod --> |Reflink| Reflink
129/// ```
130pub fn cmd_track(
131    output_snd: &XvcOutputSender,
132    xvc_root: &XvcRoot,
133    cli_opts: TrackCLI,
134) -> Result<()> {
135    let conf = xvc_root.config();
136    let opts = cli_opts.update_from_conf(conf)?;
137    let current_dir = conf.current_dir()?;
138    let filter_git_files = !opts.include_git_files;
139    let targets = targets_from_disk(
140        output_snd,
141        xvc_root,
142        current_dir,
143        &opts.targets,
144        filter_git_files,
145    )?;
146    let requested_recheck_method = opts.recheck_method;
147    let text_or_binary = opts.text_or_binary.unwrap_or_default();
148    let no_parallel = opts.no_parallel;
149
150    let stored_xvc_path_store = xvc_root.load_store::<XvcPath>()?;
151    let stored_xvc_metadata_store = xvc_root.load_store::<XvcMetadata>()?;
152
153    let xvc_path_metadata_diff = diff_xvc_path_metadata(
154        xvc_root,
155        &stored_xvc_path_store,
156        &stored_xvc_metadata_store,
157        &targets,
158    );
159
160    let xvc_path_diff = xvc_path_metadata_diff.0;
161    let xvc_metadata_diff = xvc_path_metadata_diff.1;
162
163    let changed_entities: HashSet<XvcEntity> =
164        xvc_path_diff
165            .iter()
166            .filter_map(|(xe, xpd)| if xpd.changed() { Some(*xe) } else { None })
167            .chain(xvc_metadata_diff.iter().filter_map(|(xe, xpd)| {
168                if xpd.changed() {
169                    Some(*xe)
170                } else {
171                    None
172                }
173            }))
174            .collect();
175
176    let stored_recheck_method_store = xvc_root.load_store::<RecheckMethod>()?;
177    let default_recheck_method = RecheckMethod::from_conf(conf);
178    let recheck_method_diff = diff_recheck_method(
179        default_recheck_method,
180        &stored_recheck_method_store,
181        requested_recheck_method,
182        &changed_entities,
183    );
184
185    let stored_text_or_binary_store = xvc_root.load_store::<FileTextOrBinary>()?;
186    let text_or_binary_diff = diff_text_or_binary(
187        &stored_text_or_binary_store,
188        text_or_binary,
189        &changed_entities,
190    );
191
192    let hash_algorithm = HashAlgorithm::from_conf(conf);
193
194    let stored_content_digest_store = xvc_root.load_store::<ContentDigest>()?;
195
196    let content_digest_diff = diff_content_digest(
197        output_snd,
198        xvc_root,
199        &stored_xvc_path_store,
200        &stored_xvc_metadata_store,
201        &stored_content_digest_store,
202        &stored_text_or_binary_store,
203        &xvc_path_diff,
204        &xvc_metadata_diff,
205        opts.text_or_binary,
206        Some(hash_algorithm),
207        !no_parallel,
208    );
209
210    update_store_records(xvc_root, &xvc_path_diff, true, false)?;
211    update_store_records(xvc_root, &xvc_metadata_diff, true, false)?;
212    update_store_records(xvc_root, &recheck_method_diff, true, false)?;
213    update_store_records(xvc_root, &text_or_binary_diff, true, false)?;
214    update_store_records(xvc_root, &content_digest_diff, true, false)?;
215
216    let file_targets: Vec<XvcPath> = targets
217        .iter()
218        .filter_map(|(xp, xmd)| {
219            if xmd.file_type == XvcFileType::File {
220                Some(xp.clone())
221            } else {
222                None
223            }
224        })
225        .collect();
226
227    // Warning: This one uses `opts.targets` instead of `targets` because
228    // `targets` has been filtered to only include files.
229    let dir_targets: Vec<XvcPath> = opts
230        .targets
231        .unwrap_or_else(|| vec![current_dir.to_string()])
232        .iter()
233        .filter_map(|t| {
234            let p = PathBuf::from(t);
235            if p.is_dir() {
236                XvcPath::new(xvc_root, current_dir, &p).ok()
237            } else {
238                None
239            }
240        })
241        .collect();
242
243    let current_gitignore = build_gitignore(xvc_root)?;
244
245    update_dir_gitignores(xvc_root, &current_gitignore, &dir_targets)?;
246    // We reload gitignores here to make sure we ignore the given dirs
247
248    let current_gitignore = build_gitignore(xvc_root)?;
249
250    update_file_gitignores(xvc_root, &current_gitignore, &file_targets)?;
251
252    if !opts.no_commit {
253        let current_xvc_path_store = xvc_root.load_store::<XvcPath>()?;
254
255        let updated_content_digest_store: HStore<ContentDigest> = content_digest_diff
256            .into_iter()
257            .filter_map(|(xe, cdd)| match cdd {
258                xvc_core::Diff::Identical => None,
259                xvc_core::Diff::RecordMissing { actual } => Some((xe, actual)),
260                xvc_core::Diff::ActualMissing { .. } => None,
261                xvc_core::Diff::Different { actual, .. } => Some((xe, actual)),
262                xvc_core::Diff::Skipped => None,
263            })
264            .collect();
265
266        // Only the updated paths are carried in
267        // TODO: Allow --force option to carry-in all paths
268        let xvc_paths_to_carry =
269            current_xvc_path_store.subset(updated_content_digest_store.keys().cloned())?;
270
271        // Filter directories from carry_in / recheck
272        let xvc_paths_to_carry: HStore<XvcPath> = xvc_paths_to_carry
273            .into_iter()
274            .filter_map(|(xe, xp)| {
275                targets.get(&xp).and_then(|xmd| {
276                    if xmd.file_type == XvcFileType::File {
277                        Some((xe, xp))
278                    } else {
279                        None
280                    }
281                })
282            })
283            .collect();
284
285        let cache_paths = updated_content_digest_store
286            .iter()
287            .filter_map(|(xe, cd)| {
288                xvc_paths_to_carry
289                    .get(xe)
290                    .and_then(|xp| XvcCachePath::new(xp, cd).ok())
291                    .map(|cp| (*xe, cp))
292            })
293            .collect();
294
295        let recheck_method_store = xvc_root.load_store::<RecheckMethod>()?;
296
297        carry_in(
298            output_snd,
299            xvc_root,
300            &xvc_paths_to_carry,
301            &cache_paths,
302            &recheck_method_store,
303            !no_parallel,
304            opts.force,
305        )?;
306    }
307
308    Ok(())
309}