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}