Oregami
Repositories/oxedyne/ore

oxedyne/ore/cli/tests/ignore.rs

19.7 KiB, 6 runs

created by r2848102244:29, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! What the ignore file keeps out of the history, and what it cannot touch.
2//!
3//! The glob matching itself is proven upstream in `oxedyne_fe2o3_file`; what is
4//! unproven until the binary is driven is that capture consults the rules at
5//! all, that a tracked file outlives a rule that later names it, and that the
6//! rules are read fresh at every verb rather than remembered from the first.
7
8mod support;
9
10use support::{
11 git,
12 git_init,
13 ore,
14 write,
15 write_raw,
16 Scratch,
17};
18
19use oxedyne_fe2o3_core::prelude::*;
20
21use std::fs;
22use std::os::unix::ffi::OsStrExt;
23
24
25/// Git's glob shapes decide what a first capture takes: an anchored directory,
26/// a suffix, a negation that wins by coming last, and a `**` across levels.
27#[test]
28fn glob_shapes_choose_what_is_captured() -> Outcome<()> {
29 let scratch = res!(Scratch::new("glob_shapes"));
30 let root = res!(scratch.sub("work"));
31 res!(res!(ore(&root, &["init"])).good("init"));
32 res!(write(&root, ".oreignore",
33 b"/target/\n*.tmp\n!keep.tmp\ndoc/**/*.bak\n"));
34 res!(write(&root, "target/debug/junk.bin", b"artefact\n"));
35 res!(write(&root, "src/target/x.txt", b"not at the root\n"));
36 res!(write(&root, "a.tmp", b"scratch\n"));
37 res!(write(&root, "keep.tmp", b"re-included\n"));
38 res!(write(&root, "doc/a/b/n.bak", b"deep backup\n"));
39 res!(write(&root, "root.bak", b"not under doc\n"));
40 let out = res!(ore(&root, &["mark", "one"]));
41 let text = fmt!("{}", res!(out.good("mark one")));
42 assert!(text.contains("4 created"),
43 "the three the rules let through, and the rules themselves: {}", text);
44 assert!(text.contains(".oreignore"), "the ignore file is tracked: {}", text);
45 assert!(text.contains("keep.tmp"), "the negation is later, so it wins: {}", text);
46 assert!(text.contains("src/target/x.txt"), "the leading slash anchors: {}", text);
47 assert!(text.contains("root.bak"), "doc/**/*.bak says nothing of the root: {}", text);
48 assert!(!text.contains("junk.bin"), "the anchored directory is out: {}", text);
49 assert!(!text.contains("a.tmp"), "the suffix is out: {}", text);
50 assert!(!text.contains("n.bak"), "the double star reached it: {}", text);
51 // The ignored files are still on disk, merely unrecorded.
52 assert!(root.join("target/debug/junk.bin").exists());
53 assert!(root.join("a.tmp").exists());
54 Ok(())
55}
56
57/// A file the history already tracks outlives a rule that later names it: its
58/// edits are captured, and its removal is a deletion, exactly as git treats a
59/// tracked file `.gitignore` mentions.
60#[test]
61fn a_tracked_file_outlives_a_new_ignore_rule() -> Outcome<()> {
62 let scratch = res!(Scratch::new("tracked_ignored"));
63 let root = res!(scratch.sub("work"));
64 res!(res!(ore(&root, &["init"])).good("init"));
65 res!(write(&root, "app.log", b"first line\n"));
66 res!(res!(ore(&root, &["mark", "one"])).good("mark one"));
67
68 // The rule arrives after the file is tracked, along with a new file the
69 // rule does shut out.
70 res!(write(&root, ".oreignore", b"*.log\n"));
71 res!(write(&root, "app.log", b"first line\nsecond line\n"));
72 res!(write(&root, "new.log", b"never captured\n"));
73 let out = res!(ore(&root, &["mark", "two"]));
74 let text = fmt!("{}", res!(out.good("mark two")));
75 assert!(text.contains("1 edited"), "the tracked file is still read: {}", text);
76 assert!(text.contains("app.log"), "and it is the tracked one: {}", text);
77 assert!(text.contains("1 created"), "the arriving ignore file is captured: {}", text);
78 assert!(!text.contains("new.log"), "the new file stays out: {}", text);
79
80 // Removing the tracked file is a deletion, not an ignored non-event.
81 res!(fs::remove_file(root.join("app.log")));
82 let out = res!(ore(&root, &["mark", "three"]));
83 let text = fmt!("{}", res!(out.good("mark three")));
84 assert!(text.contains("1 deleted"), "gone means deleted, rule or no rule: {}", text);
85 Ok(())
86}
87
88/// The ignore file is read at every verb, so a rule removed speaks no longer.
89#[test]
90fn the_ignore_file_speaks_fresh_at_every_verb() -> Outcome<()> {
91 let scratch = res!(Scratch::new("fresh_rules"));
92 let root = res!(scratch.sub("work"));
93 res!(res!(ore(&root, &["init"])).good("init"));
94 res!(write(&root, ".oreignore", b"secret.txt\n"));
95 res!(write(&root, "secret.txt", b"kept out\n"));
96 let out = res!(ore(&root, &["mark", "one"]));
97 let text = fmt!("{}", res!(out.good("mark one")));
98 assert!(text.contains("1 created"), "only the rules themselves are captured: {}", text);
99 assert!(text.contains(".oreignore"), "which is the ignore file: {}", text);
100 assert!(!text.contains("secret.txt"), "the rule holds: {}", text);
101
102 res!(write(&root, ".oreignore", b"# nothing any more\n"));
103 let out = res!(ore(&root, &["mark", "two"]));
104 let text = fmt!("{}", res!(out.good("mark two")));
105 assert!(text.contains("1 created"), "the rule is gone, so the file is in: {}", text);
106 assert!(text.contains("secret.txt"), "and it is that file: {}", text);
107 assert!(text.contains("1 edited"), "and the rewritten rules are an edit: {}", text);
108 Ok(())
109}
110
111/// Nothing inside an ignored directory can be re-included, because the scan
112/// never descends into it -- which is git's rule too.
113#[test]
114fn nothing_returns_from_inside_an_ignored_directory() -> Outcome<()> {
115 let scratch = res!(Scratch::new("swallowed"));
116 let root = res!(scratch.sub("work"));
117 res!(res!(ore(&root, &["init"])).good("init"));
118 res!(write(&root, ".oreignore", b"target/\n!target/keep.txt\n"));
119 res!(write(&root, "target/keep.txt", b"unreachable\n"));
120 let out = res!(ore(&root, &["mark", "one"]));
121 let text = fmt!("{}", res!(out.good("mark one")));
122 assert!(text.contains("1 created"), "only the rules themselves: {}", text);
123 assert!(!text.contains("keep.txt"),
124 "the negation cannot reach inside the ignored directory: {}", text);
125 Ok(())
126}
127
128/// A rule and a path that are not UTF-8 meet byte for byte.
129#[test]
130fn ignore_rules_apply_to_byte_paths() -> Outcome<()> {
131 let scratch = res!(Scratch::new("byte_paths"));
132 let root = res!(scratch.sub("work"));
133 res!(res!(ore(&root, &["init"])).good("init"));
134 // The pattern is the two bytes 0xff and `*`: not text, still a rule.
135 res!(write(&root, ".oreignore", b"\xff*\n"));
136 res!(write_raw(&root, b"\xff\xfe.bin", b"named in no encoding\n"));
137 res!(write_raw(&root, b"\xfe.bin", b"a different first byte\n"));
138 let out = res!(ore(&root, &["mark", "one"]));
139 let text = fmt!("{}", res!(out.good("mark one")));
140 assert!(text.contains("2 created"),
141 "the unmatched byte name and the rules are in: {}", text);
142 assert!(root.join(std::ffi::OsStr::from_bytes(b"\xff\xfe.bin")).exists());
143 Ok(())
144}
145
146/// The ignore file is history like any other file, so a sync carries it to a
147/// replica that never wrote it, rules and all.
148#[test]
149fn the_ignore_file_travels_with_a_sync() -> Outcome<()> {
150 let scratch = res!(Scratch::new("travelling_rules"));
151 let a = res!(scratch.sub("a"));
152 let b = res!(scratch.sub("b"));
153 res!(res!(ore(&a, &["init"])).good("init a"));
154 res!(write(&a, ".oreignore", b"*.tmp\n"));
155 res!(write(&a, "real.txt", b"content\n"));
156 res!(write(&a, "junk.tmp", b"never recorded\n"));
157 res!(res!(ore(&a, &["mark", "base"])).good("mark base"));
158
159 res!(res!(ore(&b, &["init"])).good("init b"));
160 res!(res!(ore(&b, &["sync", &fmt!("{}", a.display())])).good("sync"));
161 let rules = res!(fs::read(b.join(".oreignore")));
162 assert_eq!(rules.as_slice(), b"*.tmp\n", "the rules arrived byte for byte");
163 assert!(b.join("real.txt").exists(), "alongside the ordinary file");
164 assert!(!b.join("junk.tmp").exists(), "and what they ignored never travelled");
165 Ok(())
166}
167
168/// A dotfile is content: it is captured, it survives a round trip through the
169/// working copy, and it comes back when `ore back` puts a state on disk.
170///
171/// The rule this proves was the other way round until 12026-07-30, when the
172/// scan skipped everything whose name began with a full stop. That lost
173/// `.gitignore`, `.github/workflows`, `.cargo/config.toml` and every other
174/// configuration a repository is expected to carry -- silently, and then
175/// destructively, because `back` removes what the state does not describe.
176#[test]
177fn a_dotfile_is_captured_like_any_other_file() -> Outcome<()> {
178 let scratch = res!(Scratch::new("dotfiles"));
179 let root = res!(scratch.sub("work"));
180 res!(res!(ore(&root, &["init"])).good("init"));
181 res!(write(&root, ".gitignore", b"target/\n"));
182 res!(write(&root, ".github/workflows/ci.yml", b"on: push\n"));
183 res!(write(&root, ".cargo/config.toml", b"[build]\n"));
184 res!(write(&root, "plain.txt", b"ordinary\n"));
185 let out = res!(ore(&root, &["mark", "one"]));
186 let text = fmt!("{}", res!(out.good("mark one")));
187 assert!(text.contains("4 created"), "every one of them: {}", text);
188 for path in [".gitignore", ".github/workflows/ci.yml", ".cargo/config.toml"] {
189 assert!(text.contains(path), "{} is captured: {}", path, text);
190 }
191
192 // Editing one is an edit, not a file arriving anew.
193 res!(write(&root, ".gitignore", b"target/\n*.tmp\n"));
194 let out = res!(ore(&root, &["mark", "two"]));
195 let text = fmt!("{}", res!(out.good("mark two")));
196 assert!(text.contains("1 edited"), "the dotfile is tracked: {}", text);
197 assert!(text.contains(".gitignore"), "and it is that one: {}", text);
198
199 // And going back restores it rather than removing it, which is the failure
200 // the old rule would have produced.
201 res!(res!(ore(&root, &["back", "one"])).good("back one"));
202 let got = res!(fs::read(root.join(".gitignore")));
203 assert_eq!(got.as_slice(), b"target/\n", "the earlier state came back");
204 assert!(root.join(".github/workflows/ci.yml").exists(),
205 "a dotfile deep in a dot-named directory is still there");
206 Ok(())
207}
208
209/// The two stores stay out, at the root and nested, and nothing a rule says can
210/// bring either in.
211///
212/// These are the only two names the scan knows by name. `.ore` is this tool's
213/// own log, and capturing it would put the history inside itself; `.git` is
214/// another version control system's, and a repository vendored inside the
215/// working copy is no more this repository's content than the one at the top.
216#[test]
217fn the_two_stores_are_never_captured() -> Outcome<()> {
218 let scratch = res!(Scratch::new("stores"));
219 let root = res!(scratch.sub("work"));
220 res!(res!(ore(&root, &["init"])).good("init"));
221 // A rule that re-includes everything, so nothing below can be blamed on the
222 // ignore file.
223 res!(write(&root, ".oreignore", b"!*\n"));
224 res!(write(&root, ".git/config", b"[core]\n"));
225 res!(write(&root, ".git/objects/ab/cdef", b"\x00binary\n"));
226 res!(write(&root, "vendor/lib/.git/HEAD", b"ref: refs/heads/main\n"));
227 res!(write(&root, "vendor/lib/.ore/config", b"not ours either\n"));
228 res!(write(&root, "vendor/lib/src.rs", b"fn main() {}\n"));
229 res!(write(&root, "real.txt", b"content\n"));
230 let out = res!(ore(&root, &["mark", "one"]));
231 let text = fmt!("{}", res!(out.good("mark one")));
232 assert!(text.contains("3 created"),
233 "the rules, the vendored source and the ordinary file: {}", text);
234 for path in [".git/config", "abcdef", "HEAD", "vendor/lib/.ore/config"] {
235 assert!(!text.contains(path), "{} stayed out: {}", path, text);
236 }
237 assert!(text.contains("vendor/lib/src.rs"),
238 "a file beside a nested store is still content: {}", text);
239 // This repository's own log is where it was, and is not in its own history.
240 assert!(root.join(".ore").join("config").is_file());
241 let out = res!(ore(&root, &["log"]));
242 let text = fmt!("{}", res!(out.good("log")));
243 assert!(text.contains("captured nothing"),
244 "a second look finds neither store: {}", text);
245 Ok(())
246}
247
248/// An ignore rule governs a dotfile exactly as it governs anything else, by
249/// name, by suffix, and by directory.
250#[test]
251fn an_ignore_rule_governs_a_dotfile() -> Outcome<()> {
252 let scratch = res!(Scratch::new("dot_rules"));
253 let root = res!(scratch.sub("work"));
254 res!(res!(ore(&root, &["init"])).good("init"));
255 res!(write(&root, ".oreignore", b".env\n.cache/\n*.local\n!.env.example\n"));
256 res!(write(&root, ".env", b"SECRET=nobody\n"));
257 res!(write(&root, ".env.example", b"SECRET=\n"));
258 res!(write(&root, ".envrc", b"use flake\n"));
259 res!(write(&root, ".cache/blob.bin", b"artefact\n"));
260 res!(write(&root, ".config.local", b"machine only\n"));
261 let out = res!(ore(&root, &["mark", "one"]));
262 let text = fmt!("{}", res!(out.good("mark one")));
263 assert!(text.contains("3 created"),
264 "the rules, the example and the unmatched dotfile: {}", text);
265 assert!(text.contains(".env.example"), "an exact rule does not catch a prefix \
266 of a longer name, and the negation says so anyway: {}", text);
267 assert!(text.contains(".envrc"), "nor does it catch a different name: {}", text);
268 assert!(!text.contains("blob.bin"), "an ignored dot-named directory is not \
269 descended into: {}", text);
270 assert!(!text.contains(".config.local"), "a suffix rule reaches a dotfile: {}", text);
271 // And the secret is still on disk, merely unrecorded.
272 assert!(root.join(".env").exists());
273 Ok(())
274}
275
276/// A tracked dotfile outlives a rule that later names it, exactly as any other
277/// tracked file does.
278///
279/// This is the half of the policy that keeps a repository honest. A rule added
280/// after the fact must not quietly freeze a file the history is already
281/// carrying, or the working copy walks away from the history with nothing said.
282#[test]
283fn a_tracked_dotfile_outlives_a_later_rule() -> Outcome<()> {
284 let scratch = res!(Scratch::new("dot_tracked"));
285 let root = res!(scratch.sub("work"));
286 res!(res!(ore(&root, &["init"])).good("init"));
287 res!(write(&root, ".gitignore", b"target/\n"));
288 res!(res!(ore(&root, &["mark", "one"])).good("mark one"));
289
290 // The rule arrives afterwards, and names the tracked file and a new one.
291 res!(write(&root, ".oreignore", b".gitignore\n.npmrc\n"));
292 res!(write(&root, ".gitignore", b"target/\nbuild/\n"));
293 res!(write(&root, ".npmrc", b"never captured\n"));
294 let out = res!(ore(&root, &["mark", "two"]));
295 let text = fmt!("{}", res!(out.good("mark two")));
296 assert!(text.contains("1 edited"), "the tracked dotfile is still read: {}", text);
297 assert!(text.contains(".gitignore"), "and it is that one: {}", text);
298 assert!(!text.contains(".npmrc"), "the new dotfile stays out: {}", text);
299
300 // Removing it is a deletion, not an ignored non-event.
301 res!(fs::remove_file(root.join(".gitignore")));
302 let out = res!(ore(&root, &["mark", "three"]));
303 let text = fmt!("{}", res!(out.good("mark three")));
304 assert!(text.contains("1 deleted"), "gone means deleted, rule or no rule: {}", text);
305 Ok(())
306}
307
308/// The buried side of a collision is written into `.ore`, and never comes back
309/// as an authored edit.
310///
311/// The spill is derived data: a render that arbitrates an overlap writes each
312/// yielded operation's buried version under `.ore/collisions/`, and it is
313/// rewritten from scratch on every render. That is only safe because `.ore` is
314/// one of the two names the scan skips outright, so this checks the property the
315/// design rests on rather than assuming it -- a spill captured as content would
316/// put a losing version into the history as a third author's work, and would do
317/// it again on every verb.
318#[test]
319fn a_spilled_collision_never_enters_the_history() -> Outcome<()> {
320 let scratch = res!(Scratch::new("spill_skip"));
321 let root = res!(scratch.sub("work"));
322 res!(res!(ore(&root, &["init"])).good("init"));
323 // A rule that re-includes everything, so nothing below can be blamed on the
324 // ignore file.
325 res!(write(&root, ".oreignore", b"!*\n"));
326 // A collision is awkward to conjure in one repository, so the spill is placed
327 // by hand: what is being tested is the scan, not the renderer.
328 res!(write(&root, ".ore/collisions/r1.4", b"the version that yielded\n"));
329 res!(write(&root, "real.txt", b"content\n"));
330
331 let out = res!(ore(&root, &["mark", "one"]));
332 let text = fmt!("{}", res!(out.good("mark one")));
333 assert!(text.contains("real.txt"), "the ordinary file is captured: {}", text);
334 assert!(!text.contains("collisions"), "and the spill is not: {}", text);
335 assert!(!text.contains("r1.4"), "under any name: {}", text);
336
337 // It is still on disk, and a second verb still does not see it.
338 assert_eq!(res!(std::fs::read(root.join(".ore").join("collisions").join("r1.4"))),
339 b"the version that yielded\n".to_vec());
340 let out = res!(ore(&root, &["log"]));
341 let text = fmt!("{}", res!(out.good("log")));
342 assert!(text.contains("captured nothing"),
343 "a second look finds no spill: {}", text);
344 Ok(())
345}
346
347
348/// An import leaves the rules, and everything else it never recorded, on the disk.
349///
350/// The import ends by materialising the whole history over the working copy, and it is the
351/// one verb that captures nothing first -- so a file the history does not describe was never
352/// written down, and taking it off the disk loses it outright. The ignore file is the case
353/// that bites: written before the import, as every runbook says to, it is not in the git
354/// history, and a removal would hand the next verb the whole of `target/` to record for ever.
355#[test]
356fn an_import_leaves_what_it_never_recorded() -> Outcome<()> {
357 let scratch = res!(Scratch::new("import_keeps"));
358 let src = res!(scratch.sub("src"));
359 let into = res!(scratch.sub("ore"));
360 res!(git_init(&src));
361 res!(write(&src, "src/main.rs", b"fn main() {}\n"));
362 res!(git(&src, &["add", "."]));
363 res!(git(&src, &["commit", "--quiet", "--no-verify", "-m", "the first commit"]));
364
365 res!(res!(ore(&into, &["init"])).good("init"));
366 // The rules, and a file they exist to keep out, and one plain local file. None of the
367 // three is in the git history the import is about to read.
368 res!(write(&into, ".oreignore", b"*.log\n"));
369 res!(write(&into, "noise.log", b"an artefact\n"));
370 res!(write(&into, "notes.txt", b"mine, and not the history's\n"));
371 let ran = res!(ore(&into, &["import", &fmt!("{}", src.display())]));
372 assert!(ran.ok, "the import went through: {}{}", ran.out, ran.err);
373 assert!(ran.out.contains("0 removed"), "and removed nothing: {}", ran.out);
374
375 assert!(into.join(".oreignore").is_file(), "the rules survived the import");
376 assert!(into.join("noise.log").is_file(), "so did what they keep out");
377 assert!(into.join("notes.txt").is_file(), "so did a file nobody claimed");
378 assert!(into.join("src/main.rs").is_file(), "and the history did arrive");
379
380 // The point of keeping the rules: the next verb honours them.
381 let out = res!(ore(&into, &["mark", "after"]));
382 let text = fmt!("{}", res!(out.good("mark after")));
383 assert!(!text.contains("noise.log"),
384 "the rules bound the capture that followed: {}", text);
385 assert!(text.contains(".oreignore"),
386 "and the rules themselves were taken, since nothing excludes them: {}", text);
387 Ok(())
388}
389
390/// Going back to a state older than the rules does not withdraw them.
391///
392/// Every other file the tree does not describe goes, because the capture the verb began with
393/// recorded it and it can be had again. The ignore file is not that kind of file: it is the
394/// boundary of what the *next* capture reads, a history only grows, and one verb run without
395/// it is enough to put an entire build directory beyond recall.
396#[test]
397fn a_state_older_than_the_rules_keeps_them() -> Outcome<()> {
398 let scratch = res!(Scratch::new("back_keeps_rules"));
399 let root = res!(scratch.sub("work"));
400 res!(res!(ore(&root, &["init"])).good("init"));
401 res!(write(&root, "a.txt", b"before the rules\n"));
402 res!(res!(ore(&root, &["mark", "bare"])).good("mark bare"));
403 res!(write(&root, ".oreignore", b"*.log\n"));
404 res!(write(&root, "noise.log", b"an artefact\n"));
405 res!(res!(ore(&root, &["mark", "ruled"])).good("mark ruled"));
406
407 let out = res!(ore(&root, &["back", "bare"]));
408 let text = fmt!("{}", res!(out.good("back bare")));
409 assert!(text.contains("bare"), "the older state was rendered: {}", text);
410 assert!(!text.contains("removed"),
411 "and the rules were not counted among what moved: {}", text);
412 assert!(root.join(".oreignore").is_file(),
413 "the rules are still on the disk, though that state predates them");
414 assert!(root.join("noise.log").is_file(),
415 "and so is what they keep out, untouched either way");
416
417 // Which is the whole of why: the verb after the journey is still bounded.
418 let out = res!(ore(&root, &["mark", "after"]));
419 let text = fmt!("{}", res!(out.good("mark after")));
420 assert!(!text.contains("noise.log"),
421 "the rules still bound the capture: {}", text);
422 Ok(())
423}