repon_core/lib.rs
1//! The rendering-agnostic core: it computes git state and knows nothing about terminals.
2//!
3//! Its public surface is flat, re-exported from the crate root rather than through
4//! `fanout`, `git`, `entity` or `snapshot`, which stay private: a generic scatter
5//! primitive and a single branch read are not vocabulary a second consumer needs,
6//! and neither is which file happens to define `EntityState` or `Snapshot`. The one
7//! exception is `liveness`, a `test-util`-gated module of test waits that names
8//! nothing git-shaped and so belongs under a namespace rather than at the root. The
9//! entry points on `Core` itself (`start`, `refresh`, `snapshot`, `try_settle`, ...)
10//! land in later work and get re-exported the same way once they exist.
11//!
12//! ## Reviewing an addition to this surface
13//!
14//! Every addition to what this crate exports should hold each of these, from
15//! `docs/spec/core-api.md`'s ownership table and
16//! [ADR 0015](https://github.com/paulchiu/repon/blob/main/docs/adr/0015-the-core-owns-the-table.md):
17//!
18//! - It does a git-shaped thing (discovery, a probe phase, the metadata poll, a Set
19//! boundary, an override, Generation supersession, the row fold, the display
20//! name, the default branch rung, the environment contract as data, Action
21//! fan-out), never a terminal-shaped one (rendering, the cursor, glyphs, theme,
22//! keybindings, the Launcher, config file discovery, `$HOME`, a user-specific
23//! environment variable).
24//! - An empty Selection carries no meaning here: this crate never defaults it to
25//! "the row under the cursor" or anything else that only makes sense on a screen.
26//! - `refresh` takes an already-ordered `&[EntityKey]`. This crate never computes
27//! or second-guesses that order; cursor-row-first is the consumer's ordering to
28//! make, not this crate's to infer.
29//! - A Filter is a pure predicate over these public types. Deciding when to apply
30//! one, if ever, stays with the consumer.
31//! - It has no notification channel, update stream or callback: a consumer reads a
32//! [`Snapshot`] when it decides to, it is never pushed one.
33//!
34//! Four refusals hold for every type this crate ever makes public, reasoned in
35//! [ADR 0015](https://github.com/paulchiu/repon/blob/main/docs/adr/0015-the-core-owns-the-table.md):
36//!
37//! - No `#[non_exhaustive]`: it forces a consumer to add a wildcard match arm even
38//! when every variant is already matched, reintroducing by attribute the default
39//! path ADR 0001 forbids. ADR 0015 argued this against an in-repo consumer; ADR
40//! 0021 observes that publishing widens the audience beyond that case, so the
41//! refusal stands but is not re-argued for the wider one.
42//! - No sealed trait: the enforcement ADR 0015 relies on is a real second consumer
43//! (`repon sets`), not defensive API ceremony guarding against a use case nothing
44//! presents.
45//! - No separate versioning scheme: repon-core is a path dependency with one
46//! in-workspace consumer, so a breaking change and its fix land in the same commit.
47//! - No git-backend trait abstraction: the crate's existing test drives a real
48//! disposable repository rather than a mock, so a trait would buy testability
49//! already paid for.
50
51// Repon is a Unix program, and the restriction lands here rather than on the
52// terminal crate because this one owns Action fan-out: a step goes into a new
53// session with setsid(2) and is read back over a PTY. See docs/spec/actions.md.
54#[cfg(not(unix))]
55compile_error!("repon-core requires a Unix target: see docs/spec/actions.md");
56
57mod auto_update;
58mod base;
59mod cell;
60mod core;
61mod default_branch;
62mod discovery;
63mod entity;
64mod environment;
65mod executor;
66mod fanout;
67mod fetch;
68mod filter;
69mod git;
70mod landing;
71/// The one module reachable by name rather than re-exported flat: it holds no vocabulary a
72/// consumer needs, only the wait a test in either crate uses for a liveness property, and a
73/// namespace is what keeps names that generic out of the crate root. Gated with it, and the
74/// gate is what
75/// `every_pub_item_documented_as_test_only_is_either_gated_or_has_a_production_use_site`
76/// reads this doc comment to require.
77#[cfg(any(test, feature = "test-util"))]
78pub mod liveness;
79mod patch_equivalence;
80mod poll;
81mod snapshot;
82#[cfg(test)]
83mod test_support;
84#[cfg(feature = "serde")]
85mod wire;
86
87pub use cell::{Cell, Generation, Settled, Timestamp, Unknown};
88pub use core::AutoUpdateAttempt;
89pub use core::FetchFailures;
90pub use core::ManagementHandle;
91pub use core::{ActionSpec, AutoUpdateSpec, Core, CoreSpec, FetchSpec, RepoOverride, Step};
92pub use discovery::{Discovery, SetSpec, count, discover};
93pub use entity::ActionReceipt;
94pub use entity::AheadBehind;
95pub use entity::CaptureElision;
96pub use entity::DefaultBranch;
97pub use entity::DefaultBranchStopped;
98pub use entity::DeleteRisk;
99pub use entity::Diagnostics;
100pub use entity::DirtyCounts;
101pub use entity::EntityKey;
102pub use entity::EntityState;
103pub use entity::Head;
104pub use entity::Kind;
105pub use entity::OwnWork;
106pub use entity::Presence;
107pub use entity::RunningStep;
108pub use entity::Skip;
109pub use entity::StepOutcome;
110pub use entity::StepResult;
111pub use entity::SyncState;
112pub use entity::WorktreeState;
113pub use environment::environment;
114pub use filter::{Applicability, Filter, KeyVocabulary, vocabulary};
115pub use git::{InProgressOperation, ProbeError, RecentCommit};
116pub use snapshot::{RowSummary, Snapshot, summary};
117#[cfg(feature = "serde")]
118pub use wire::SettledDocument;
119
120#[cfg(test)]
121mod tests {
122 /// The exported name from one `pub use` item: `Name`, or the alias in
123 /// `Name as Alias`. Panics naming `line` if `item` is neither, so a form this
124 /// cannot read fails the test rather than being silently dropped.
125 fn exported_name(item: &str, line: &str) -> String {
126 if let Some((_, alias)) = item.split_once(" as ") {
127 return alias.trim().to_string();
128 }
129 let name = item.trim();
130 assert!(
131 !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_'),
132 "cannot read an exported name from `{item}` in `pub use` line `{line}`"
133 );
134 name.to_string()
135 }
136
137 /// Everything in `source` ahead of the test module, which is the region where a
138 /// crate-root declaration's visibility is decided and the only region either scan
139 /// below reads: a `pub` item inside the test module is not part of the surface.
140 fn crate_root_declarations(source: &str) -> &str {
141 source.split("mod tests {").next().unwrap_or(source)
142 }
143
144 /// The crate's actual public surface: every name reachable at the crate root
145 /// of `source`, read from the crate's own `src/lib.rs` rather than a hand-kept
146 /// list, so a name added without a matching glossary entry has nowhere to hide.
147 ///
148 /// Three declaration forms carry a name onto that surface, and all three are
149 /// read: a `pub use` re-export, a `pub mod` namespace, and a `pub const`. A
150 /// line scan, not a parser, since this crate controls the formatting of its own
151 /// crate-root lines: it recognises `pub use path::Name;`,
152 /// `pub use path::Name as Alias;`, a single-line braced group
153 /// `pub use path::{Name, Other as Alias};`, `pub mod name;` and
154 /// `pub const NAME: Type = ...;`. Any other `pub ` line at the crate root is a
155 /// panic rather than a skip, since a scanner that quietly passes over an
156 /// unfamiliar form is the same drift this test exists to catch: `pub mod
157 /// liveness;` was invisible to the `pub use`-only version of this scan. What it
158 /// does *not* check is privacy: a name collected here is only ever asked whether
159 /// the glossary documents it, so a module made public whose name happens to be a
160 /// glossary term passes. `docs/spec/releasing.md`'s cleared-gate item 1 is the
161 /// privacy claim, and
162 /// [`modules_the_release_gate_names_are_private_at_the_crate_root`] is what
163 /// enforces it.
164 fn crate_root_public_surface(source: &str) -> Vec<String> {
165 let mut names = Vec::new();
166 for line in crate_root_declarations(source).lines() {
167 let line = line.trim();
168 if !line.starts_with("pub ") {
169 continue;
170 }
171 if let Some(rest) = line.strip_prefix("pub mod ") {
172 let name = rest
173 .strip_suffix(';')
174 .unwrap_or_else(|| panic!("`pub mod` line is not `;`-terminated: `{line}`"));
175 names.push(exported_name(name, line));
176 continue;
177 }
178 if let Some(rest) = line.strip_prefix("pub const ") {
179 let name = rest
180 .split(':')
181 .next()
182 .unwrap_or_else(|| panic!("`pub const` line names nothing: `{line}`"));
183 names.push(exported_name(name, line));
184 continue;
185 }
186 let body = line
187 .strip_prefix("pub use ")
188 .and_then(|s| s.strip_suffix(';'))
189 .unwrap_or_else(|| {
190 panic!("crate-root `pub` line is in no form this scan reads: `{line}`")
191 });
192 match body.split_once('{') {
193 Some((_path, rest)) => {
194 let group = rest.strip_suffix('}').unwrap_or_else(|| {
195 panic!("`pub use` group is not `}}`-terminated: `{line}`")
196 });
197 for item in group.split(',') {
198 let item = item.trim();
199 if !item.is_empty() {
200 names.push(exported_name(item, line));
201 }
202 }
203 }
204 None => {
205 let last = body.rsplit("::").next().unwrap_or(body);
206 names.push(exported_name(last, line));
207 }
208 }
209 }
210 names
211 }
212
213 /// True if `name`'s words, read together as one phrase, appear in `glossary`.
214 ///
215 /// A Rust identifier's casing never matches the glossary's Capitalised prose
216 /// terms directly, so `name` is split into words on `_` and on a
217 /// lowercase-to-uppercase boundary (never inside a run of capitals, or a
218 /// SCREAMING_CASE constant would come apart one letter at a time), rejoined
219 /// with single spaces, and searched
220 /// for case-insensitively. Matching is deliberately whole-phrase rather than
221 /// any-single-word: a name like `EntityState` must find "entity state" together,
222 /// not pass because "state" alone occurs in unrelated prose such as "Worktree
223 /// state". The false-negative risk this leaves: a name whose words are the
224 /// glossary's own terms but in a different order, plural, or hyphenated (a
225 /// `RepoSet` next to a glossary that only ever writes "a Set of Repos") reads as
226 /// undocumented even though a reader would find it.
227 fn glossary_covers(glossary: &str, name: &str) -> bool {
228 let mut words = Vec::new();
229 let mut word = String::new();
230 for ch in name.chars() {
231 if ch == '_' {
232 if !word.is_empty() {
233 words.push(std::mem::take(&mut word));
234 }
235 continue;
236 }
237 if ch.is_uppercase() && word.ends_with(|last: char| !last.is_uppercase()) {
238 words.push(std::mem::take(&mut word));
239 }
240 word.push(ch);
241 }
242 if !word.is_empty() {
243 words.push(word);
244 }
245 let phrase = words.join(" ").to_lowercase();
246 glossary.to_lowercase().contains(&phrase)
247 }
248
249 /// Every crate-root re-export must name something the project glossary already
250 /// names, so that reading the crate root and reading the glossary give the same
251 /// answer.
252 ///
253 /// Both files are read at test time from `CARGO_MANIFEST_DIR` rather than with
254 /// `include_str!`: `GLOSSARY.md` lives at the repository root, outside this
255 /// crate's own directory, so it is not among the files `cargo package` ships.
256 /// `include_str!` was tried first; it compiles fine in the workspace checkout
257 /// and does not itself break `cargo publish --dry-run` (packaging only builds,
258 /// it does not run tests), but `cargo test` against the extracted package
259 /// (`target/package/repon-core-*`) then fails to compile at all, a missing-file
260 /// error with no test to report it. Reading both paths at runtime instead
261 /// degrades that to one failing assertion in a context nothing here needs to
262 /// support, rather than a compile error in any context that later turns test
263 /// code on.
264 #[test]
265 fn public_surface_matches_glossary() {
266 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
267 let source = std::fs::read_to_string(manifest_dir.join("src/lib.rs"))
268 .expect("read this crate's own source");
269 let glossary = std::fs::read_to_string(manifest_dir.join("../../GLOSSARY.md"))
270 .expect("read the project glossary");
271
272 for name in crate_root_public_surface(&source) {
273 assert!(
274 glossary_covers(&glossary, &name),
275 "crate-root public name `{name}` has no matching entry in the project glossary"
276 );
277 }
278 }
279
280 /// The module names `docs/spec/releasing.md`'s cleared-gate item 1 requires to stay
281 /// private, read out of the document rather than restated beside it.
282 ///
283 /// The item spells each one as a backticked `mod name;` fragment, which is what this
284 /// picks out: a name the gate stops naming stops being enforced here, and one it adds
285 /// is enforced without an edit to this file. An item naming none panics, since a
286 /// reworded gate would otherwise leave the test below asserting nothing.
287 fn modules_the_release_gate_requires_to_be_private(releasing: &str) -> Vec<String> {
288 let gate = releasing
289 .split("## Before the first crates.io publish")
290 .nth(1)
291 .expect("releasing.md must carry the pre-publish gate section");
292 let item = gate
293 .lines()
294 .find(|line| line.trim_start().starts_with("1. "))
295 .expect("the pre-publish gate must carry a numbered item 1");
296 let names: Vec<String> = item
297 .split('`')
298 .skip(1)
299 .step_by(2)
300 .filter_map(|code| Some(code.strip_prefix("mod ")?.strip_suffix(';')?.to_string()))
301 .collect();
302 assert!(
303 !names.is_empty(),
304 "cleared-gate item 1 names no `mod name;` fragment any more, so the privacy it \
305 records has nothing left to check: {item}"
306 );
307 names
308 }
309
310 /// `docs/spec/releasing.md`'s cleared-gate item 1 is a privacy claim about this crate
311 /// root, so the names it makes that claim about are read from the document and asserted
312 /// against the source rather than restated here.
313 ///
314 /// Each named module must still be declared at the crate root, in either form, before
315 /// its declaration is required to be the private one: a gate naming a module this crate
316 /// no longer has is a stale gate, not a passing check.
317 #[test]
318 fn modules_the_release_gate_names_are_private_at_the_crate_root() {
319 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
320 let source = std::fs::read_to_string(manifest_dir.join("src/lib.rs"))
321 .expect("read this crate's own source");
322 let releasing = std::fs::read_to_string(manifest_dir.join("../../docs/spec/releasing.md"))
323 .expect("read the releasing spec");
324 let declarations: Vec<&str> = crate_root_declarations(&source)
325 .lines()
326 .map(str::trim)
327 .collect();
328
329 for name in modules_the_release_gate_requires_to_be_private(&releasing) {
330 let private = format!("mod {name};");
331 let public = format!("pub {private}");
332 let declaration = declarations
333 .iter()
334 .find(|line| **line == private || **line == public)
335 .unwrap_or_else(|| {
336 panic!(
337 "`docs/spec/releasing.md`'s cleared-gate item 1 names `{private}`, \
338 which this crate root no longer declares in any form"
339 )
340 });
341 assert_eq!(
342 **declaration, private,
343 "`{name}` is public at this crate root, which `docs/spec/releasing.md`'s \
344 cleared-gate item 1 requires to stay private"
345 );
346 }
347 }
348
349 /// Every `.rs` file under `dir`, recursively.
350 fn rust_source_files(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
351 let mut files = Vec::new();
352 for entry in std::fs::read_dir(dir).expect("read a source directory") {
353 let path = entry.expect("read a directory entry").path();
354 if path.is_dir() {
355 files.extend(rust_source_files(&path));
356 } else if path.extension().is_some_and(|extension| extension == "rs") {
357 files.push(path);
358 }
359 }
360 files
361 }
362
363 /// `gix::interrupt::IS_INTERRUPTED` is a process-global static wired to
364 /// SIGINT; using it would cancel every entity's probe at once, defeating the
365 /// one `Arc<AtomicBool>` per in-flight entity [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
366 /// "Cancellation" requires. Scans every source file under `src`, not just
367 /// `core.rs`, so a future module reaching for it is caught too. A line only
368 /// counts as real usage when it is not a comment, which is what lets doc
369 /// comments (this crate's own, explaining the ban) keep naming it.
370 #[test]
371 fn gix_interrupt_is_interrupted_is_never_used() {
372 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
373 // Built from two pieces rather than written as one path literal, so this
374 // check's own source line is never itself a match for what it scans for.
375 let banned = format!("interrupt::{}", "IS_INTERRUPTED");
376 let mut offending_locations = Vec::new();
377 for path in rust_source_files(&manifest_dir.join("src")) {
378 let source = std::fs::read_to_string(&path).expect("read a crate source file");
379 for (number, line) in source.lines().enumerate() {
380 if line.trim_start().starts_with("//") {
381 continue;
382 }
383 if line.contains(&banned) {
384 offending_locations.push(format!("{}:{}", path.display(), number + 1));
385 }
386 }
387 }
388 assert!(
389 offending_locations.is_empty(),
390 "gix's process-global interrupt static must never be used outside a comment, found at: {offending_locations:?}"
391 );
392 }
393
394 /// `docs/spec/actions.md`'s "The run on screen": "the parse cannot live in
395 /// repon-core, because ansi-to-tui produces ratatui types and the core has a CI
396 /// line asserting its tree contains no ratatui". `just check-core-isolation`
397 /// proves the dependency is absent; this proves the same claim at the source
398 /// level, so a hand-rolled parser producing ratatui types some other way (not
399 /// through the `ansi-to-tui` dependency at all) is caught too. Scans every
400 /// source file under `src`, not just `executor.rs`, since a future module is as
401 /// capable of reaching for either name as that one.
402 #[test]
403 fn no_source_file_in_this_crate_names_the_rendering_crates_that_parse_ansi() {
404 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
405 let banned = [
406 format!("{}{}", "rata", "tui"),
407 format!("{}_{}", "ansi", "to_tui"),
408 ];
409 let mut offending_locations = Vec::new();
410 for path in rust_source_files(&manifest_dir.join("src")) {
411 let source = std::fs::read_to_string(&path).expect("read a crate source file");
412 for (number, line) in source.lines().enumerate() {
413 if line.trim_start().starts_with("//") {
414 continue;
415 }
416 if banned.iter().any(|needle| line.contains(needle)) {
417 offending_locations.push(format!("{}:{}", path.display(), number + 1));
418 }
419 }
420 }
421 assert!(
422 offending_locations.is_empty(),
423 "found a rendering crate named in repon-core's own source, which must stay raw \
424 bytes with no interpretation: {offending_locations:?}"
425 );
426 }
427
428 /// [`RowSummary`](crate::RowSummary)'s mapping to a gutter glyph is
429 /// `docs/spec/core-api.md`'s explicit consumer-side job, never this crate's.
430 /// Scans every source file under `src` for the two shapes that mapping would
431 /// take here: a function returning a bare `char`, or a match arm whose
432 /// right-hand side is a character literal.
433 #[test]
434 fn no_state_is_mapped_to_a_character_anywhere_in_this_crate() {
435 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
436 let banned_return = format!("-{}", "> char");
437 let banned_arm = format!("={}", "> '");
438 let mut offending_locations = Vec::new();
439 for path in rust_source_files(&manifest_dir.join("src")) {
440 let source = std::fs::read_to_string(&path).expect("read a crate source file");
441 for (number, line) in source.lines().enumerate() {
442 if line.trim_start().starts_with("//") {
443 continue;
444 }
445 if line.contains(&banned_return) || line.contains(&banned_arm) {
446 offending_locations.push(format!("{}:{}", path.display(), number + 1));
447 }
448 }
449 }
450 assert!(
451 offending_locations.is_empty(),
452 "repon-core must never map a state to a character; the mapping belongs to the \
453 consumer, found at: {offending_locations:?}"
454 );
455 }
456
457 /// The manifest text a consumer actually resolves against, not a copy: `test-util` gates
458 /// `Timestamp::at` off the default published surface per
459 /// [ADR 0021](https://github.com/paulchiu/repon/blob/main/docs/adr/0021-a-release-is-what-the-tag-pipeline-publishes.md),
460 /// and a `default = [...]` naming it would silently turn every consumer's default build
461 /// back into the thing the gate exists to prevent.
462 #[test]
463 fn test_util_is_never_a_default_feature() {
464 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
465 let manifest = std::fs::read_to_string(manifest_dir.join("Cargo.toml"))
466 .expect("read this crate's own Cargo.toml");
467 let features_section = manifest
468 .split("[features]")
469 .nth(1)
470 .and_then(|rest| rest.split("\n[").next())
471 .unwrap_or("");
472 assert!(
473 features_section.contains("test-util"),
474 "expected a `test-util` feature declared in `[features]`; this test's own premise \
475 is stale if it moved: {manifest}"
476 );
477 let default_line = features_section
478 .lines()
479 .find(|line| line.trim_start().starts_with("default"));
480 assert!(
481 default_line.is_none_or(|line| !line.contains("test-util")),
482 "`test-util` must never be named in a default feature list, or it ships on every \
483 consumer's default build: {default_line:?}"
484 );
485 }
486}