Skip to main content

git_xcrypt/commands/
sync.rs

1//! `git-xcrypt sync` — regenerate the cosmetic lines in `.gitattributes`.
2//!
3//! Only the cosmetic lines. The catch-all line above them is static, written
4//! once by `init`, and carries the whole security guarantee; nothing here can
5//! make it stale, which is the point of the construction. Forgetting to run
6//! `sync` therefore costs a worse `git diff`, never a secret.
7//!
8//! `--check` exists so a CI job can say "the section is out of date" without
9//! writing to the working tree.
10
11use crate::git::attributes;
12use crate::git::repo::{CONFIG_FILE, Repo};
13use crate::rules::declaration::Config;
14use crate::{Error, Result};
15
16/// What `sync` found, and what it did about it.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Outcome {
19    /// The section already said what it should. Nothing was written.
20    UpToDate,
21    /// The section was regenerated.
22    Updated,
23    /// The section is out of date and `--check` forbade writing.
24    Stale,
25}
26
27/// What `sync` did, plus anything the configuration file is worth warning about.
28#[derive(Debug)]
29pub struct Report {
30    /// The verdict.
31    pub outcome: Outcome,
32    /// Lines of `.git-xcrypt` that declare something pointless.
33    ///
34    /// Carried out rather than printed here so the binary owns every message,
35    /// as it does for `init`.
36    pub warnings: Vec<String>,
37    /// How many lines outside the managed section touch `filter`, `text`,
38    /// `eol` or `crlf`.
39    ///
40    /// A count and nothing more, on purpose. Whether any of them actually
41    /// reaches a declared path is a question about git's whole attribute stack,
42    /// and `status` already answers it by running that stack — a second
43    /// spelling here would be one too many, and a `sync` that quoted an
44    /// ordinary `*.psd filter=lfs` at its user every run would teach them to
45    /// stop reading it. So this only says "something outside this section could
46    /// have an opinion" and points at the command that knows.
47    pub foreign: usize,
48}
49
50/// Runs `sync` in `repo`, writing unless `check` is set.
51///
52/// # Errors
53///
54/// [`Error::Config`] when `.git-xcrypt` is absent or cannot be understood, or
55/// when the managed section in `.gitattributes` has unbalanced markers;
56/// [`Error::Io`] on a read or write failure.
57pub fn run(repo: &Repo, check: bool, rendering: attributes::Rendering) -> Result<Report> {
58    let config = Config::load(&repo.xcrypt_config_path())?;
59    if config.missing {
60        return Err(Error::Config(format!(
61            "{CONFIG_FILE} is missing, so there is nothing to synchronise; \
62             run `git-xcrypt init` to create it"
63        )));
64    }
65
66    let lines = attributes::render_lines(&config, rendering);
67    let path = repo.attributes_path();
68
69    let outcome = if check {
70        // Any shape this build writes counts as current, not just the one asked
71        // for on this command line. `--check` is a CI gate, and its question is
72        // "does the section still describe the declaration" — a repository that
73        // still on the section `init` wrote has not gone stale by never having
74        // run `sync`, and failing it there would teach the gate's owner to
75        // ignore it.
76        // A section that matches *none* of them is what staleness looks like.
77        let current = attributes::ACCEPTED.into_iter().any(|rendering| {
78            let lines = attributes::render_lines(&config, rendering);
79            attributes::desired(&path, &lines).is_ok_and(|(existing, wanted)| existing == wanted)
80        });
81        if current {
82            Outcome::UpToDate
83        } else {
84            Outcome::Stale
85        }
86    } else if attributes::write_section(&path, &lines)? {
87        Outcome::Updated
88    } else {
89        Outcome::UpToDate
90    };
91
92    // Cheap: one read of a file already on disk, no attribute resolution. An
93    // unreadable `.gitattributes` answers zero rather than failing — `sync` has
94    // just written it, and the gate for that state is `status`.
95    let foreign = attributes::foreign_lines_touching(&path, &["filter", "text", "eol", "crlf"])
96        .map(|lines| lines.len())
97        .unwrap_or(0);
98
99    Ok(Report {
100        outcome,
101        foreign,
102        warnings: config.pointless_eol,
103    })
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use std::fs;
110    use std::process::Command;
111    use tempfile::TempDir;
112
113    fn init_repo() -> TempDir {
114        let dir = TempDir::new().expect("temporary directory");
115        let ok = Command::new("git")
116            .args(["init", "-q"])
117            .current_dir(dir.path())
118            .status()
119            .expect("git must be on PATH")
120            .success();
121        assert!(ok, "git init failed");
122        dir
123    }
124
125    /// A repository set up the way a user's would be, with `declarations` in
126    /// `.git-xcrypt`.
127    fn prepared(declarations: &str) -> (TempDir, Repo) {
128        let dir = init_repo();
129        let repo = Repo::discover(dir.path()).expect("discovery");
130        crate::commands::init::run(&repo).expect("init must succeed");
131        fs::write(repo.xcrypt_config_path(), declarations).expect("writing the declarations");
132        (dir, repo)
133    }
134
135    #[test]
136    fn only_the_per_pattern_section_can_go_stale() {
137        // The global section is one line that says nothing about the
138        // declaration, so no change to `.git-xcrypt` can make it wrong — which
139        // is exactly why it is the default: `sync` stops being part of the flow.
140        let (_dir, repo) = prepared("secrets/\n");
141        let before = fs::read_to_string(repo.attributes_path()).expect("attributes");
142        assert_eq!(
143            run(&repo, true, attributes::Rendering::Global)
144                .expect("check must succeed")
145                .outcome,
146            Outcome::UpToDate,
147            "a global section was called stale, which it cannot be"
148        );
149        fs::write(repo.xcrypt_config_path(), "secrets/\n*.env\nmore/\n")
150            .expect("changing the declarations");
151        assert_eq!(
152            run(&repo, true, attributes::Rendering::Global)
153                .expect("check must succeed")
154                .outcome,
155            Outcome::UpToDate,
156            "a global section went stale over a changed declaration"
157        );
158        assert_eq!(
159            before,
160            fs::read_to_string(repo.attributes_path()).expect("attributes"),
161            "--check must never touch the working tree"
162        );
163
164        // Split, and it can. That is the trade a plain `sync` buys into: the
165        // diff driver stops running for undeclared paths, and `sync` becomes
166        // something to run after every change to the declaration.
167        let per_pattern = attributes::Rendering::PerPattern { fold_case: false };
168        run(&repo, false, per_pattern).expect("sync");
169        assert_eq!(
170            run(&repo, true, per_pattern).expect("check").outcome,
171            Outcome::UpToDate,
172            "the check and the write must not disagree"
173        );
174
175        let before = fs::read_to_string(repo.attributes_path()).expect("attributes");
176        fs::write(
177            repo.xcrypt_config_path(),
178            "secrets/\n*.env\nmore/\nlater/\n",
179        )
180        .expect("changing the declarations again");
181        assert_eq!(
182            run(&repo, true, per_pattern).expect("check").outcome,
183            Outcome::Stale,
184            "a split section did not notice the declaration changing under it"
185        );
186        assert_eq!(
187            before,
188            fs::read_to_string(repo.attributes_path()).expect("attributes"),
189            "--check must never touch the working tree"
190        );
191    }
192}