repon 0.30.7

A terminal UI for the outer loop: seeing many git repos at once and acting on many in one gesture
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
//! `repon status`'s whole effect: build a Core exactly as [`super::App::new`] does, settle it
//! once, and hand back the settled document plus whether any probe genuinely failed.
//!
//! See `docs/spec/core-api.md`'s "The wire format" and "Exit codes", and
//! [ADR 0015](https://github.com/paulchiu/repon/blob/main/docs/adr/0015-the-core-owns-the-table.md).
//! The core itself never computes an exit code: [`entity_probe_failed`] is a pure predicate
//! over its public types, the same seam `docs/spec/core-api.md` already gives the Filter
//! predicate and the gutter glyph mapping.

use std::time::Duration;

use color_eyre::eyre::{Result, eyre};
use repon_core::{Cell, Core, EntityState, Settled, SettledDocument, Snapshot, Unknown};

use super::reload::{self, ActiveSet};
use crate::config::Config;

/// Slack added to [`reload::GENERATION_DEADLINE`] for this path's own `try_settle` call, so it
/// always outlasts the dedicated thread's own sweep rather than racing it: without slack, a
/// probe stuck right at the sweep's own deadline could still read outstanding here even
/// though the sweep is about to convert it to `Unknown::TimedOut` on its own.
const SETTLE_DEADLINE_SLACK: Duration = Duration::from_secs(5);

/// Builds a Core over the Set `flag_set` (or `REPON_SET`, or the first declared Set) resolves
/// to, whose own first walk is one Generation over every discovered Entity, blocks until it
/// settles or the deadline passes, then serialises the settled document to standard output. `flag_no_fetch`
/// is `--no-fetch`, forcing `fetch.enabled` off the same way it does for [`super::App::new`].
/// The process exits non-zero only when [`any_probe_failed`] finds one: a dirty tree, an
/// ahead/behind count, a stale value or a Not-applicable cell never does, whatever it reads
/// (`docs/spec/core-api.md`'s "Exit codes").
pub(crate) fn run(config: &Config, flag_set: Option<&str>, flag_no_fetch: bool) -> Result<()> {
    let (document, any_failed) = settle_document(config, flag_set, flag_no_fetch)?;

    // One document, printed once: `docs/spec/core-api.md`'s "The machine-readable consumer
    // emits one settled document rather than a stream". A plain `Write`, not `println!`,
    // since `to_writer` needs one to serialise into in the first place.
    let mut stdout = std::io::stdout();
    serde_json::to_writer(&mut stdout, &document)?;
    std::io::Write::write_all(&mut stdout, b"\n")?;

    if any_failed {
        return Err(eyre!(
            "at least one probe never got an answer; see the settled document's own Failed \
             cells and TimedOut reasons for which"
        ));
    }
    Ok(())
}

/// [`run`]'s own work, split out so a test can inspect the settled document and the failure
/// verdict directly rather than parsing what standard output printed.
fn settle_document(
    config: &Config,
    flag_set: Option<&str>,
    flag_no_fetch: bool,
) -> Result<(SettledDocument, bool)> {
    let env_set = std::env::var("REPON_SET").ok();
    // No remembered Set here, unlike the TUI's own startup: this subcommand is a scripted,
    // one-shot read, and what it covers must not turn on which Set an interactive session
    // happened to quit on.
    let active_set_config =
        reload::resolve_startup_set(&config.document.sets, flag_set, env_set.as_deref(), None)?;
    let active_set = ActiveSet::from_config(active_set_config);

    let core = Core::start(reload::core_spec(
        &config.document,
        &active_set,
        flag_no_fetch,
    ));
    // Either arm prints: an expiry here is not itself the verdict, because the dedicated
    // thread's own sweep has already converted anything still outstanding at that point to
    // `Unknown::TimedOut`, which is what `any_probe_failed` reads and reports.
    let snapshot = match core.try_settle(reload::GENERATION_DEADLINE + SETTLE_DEADLINE_SLACK) {
        Ok(settled) => settled,
        Err(unsettled) => unsettled,
    };

    let any_failed = any_probe_failed(&snapshot);
    Ok((SettledDocument::new(snapshot), any_failed))
}

/// Whether `snapshot` holds at least one probe that genuinely failed to get an answer: a
/// `Failed` Cell, a Cell the Generation deadline reached while still Loading
/// (`Unknown::TimedOut`), or an Entity whose own `.gitmodules` would not read or parse. Never
/// influenced by a Cell's `Known` value or its staleness, whatever either reads
/// (`docs/spec/core-api.md`'s "Exit codes": "nonzero means the tool could not get an answer,
/// never that the news is bad").
fn any_probe_failed(snapshot: &Snapshot) -> bool {
    snapshot.entities.iter().any(entity_probe_failed)
}

/// Not an exhaustive destructure of `EntityState`, unlike this crate's other absence scans
/// over it (`app.rs`'s own `dispatch_order`, say): this crate's
/// `the_git_operation_field_is_read_only_by_the_detail_pane_component` test forbids naming
/// the in-progress-operation field anywhere outside `components/detail.rs`, and a full
/// destructure has no way to ignore one field without naming it. So this reaches for the six
/// Cells and the one Diagnostics field it needs directly instead; a Cell-typed field added to
/// `EntityState` later is not caught at compile time here the way it would be by a
/// destructure, which is the cost of that trade. `repon_core::summary`'s own exhaustive
/// destructure is the compile-time stop that catches it instead, and carries a note back
/// to this fold.
fn entity_probe_failed(entity: &EntityState) -> bool {
    let cells: [&dyn ProbeOutcome; 6] = [
        &entity.branch,
        &entity.sync,
        &entity.base,
        &entity.dirty,
        &entity.state,
        &entity.default_branch,
    ];
    cells.iter().any(|cell| cell.probe_failed()) || entity.diagnostics.gitmodules_failed.is_some()
}

/// One Cell's own contribution to [`entity_probe_failed`], read uniformly across every
/// payload type an Entity's Cells carry, without a shared payload trait on `repon-core`'s own
/// surface.
trait ProbeOutcome {
    /// Whether this Cell's own settled shape means a probe never got an answer: `Failed`, or
    /// `Unknown(TimedOut)`. Every other `Unknown` reason is a settled fact rather than a
    /// missing one, and a `Known` cell is matched on its discriminant alone, so neither its
    /// value nor its staleness can ever reach this check.
    fn probe_failed(&self) -> bool;
}

impl<T> ProbeOutcome for Cell<T> {
    fn probe_failed(&self) -> bool {
        matches!(
            self.settled(),
            Some(Settled::Failed(_)) | Some(Settled::Unknown(Unknown::TimedOut))
        )
    }
}

#[cfg(test)]
mod tests {
    use std::fs;
    use std::path::Path;
    use std::process::Command;

    use repon_core::SetSpec;

    use super::*;
    use crate::config::document::Document;

    /// Runs `git` against `path` with a fixed identity, so a commit never depends on the
    /// machine's own global git config: the same reason `repon-core`'s own `test_support::git`
    /// exists, unreachable from this crate.
    fn git(path: &Path, args: &[&str]) {
        let status = Command::new("git")
            .arg("-C")
            .arg(path)
            .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
            .args(args)
            .status()
            .expect("run git");
        assert!(status.success(), "git {args:?} failed");
    }

    fn head_sha(path: &Path) -> String {
        let output = Command::new("git")
            .arg("-C")
            .arg(path)
            .args(["rev-parse", "HEAD"])
            .output()
            .expect("run git rev-parse");
        assert!(output.status.success());
        String::from_utf8(output.stdout)
            .expect("utf8 sha")
            .trim()
            .to_string()
    }

    /// A real disposable repository on `main` with one empty commit.
    fn init_repo(path: &Path) {
        fs::create_dir_all(path).expect("create repo dir");
        let status = Command::new("git")
            .args(["init", "--quiet", "--initial-branch", "main"])
            .arg(path)
            .status()
            .expect("run git init");
        assert!(status.success());
        git(path, &["commit", "--allow-empty", "-m", "first"]);
    }

    /// A minimal `Config` wrapping `document`: `config_dir`/`data_dir` are never read by
    /// [`settle_document`], which never touches a file itself.
    fn config_with_document(document: Document) -> Config {
        Config {
            config_dir: std::path::PathBuf::new(),
            data_dir: std::path::PathBuf::new(),
            document,
            warnings: Vec::new(),
            zero_config: false,
        }
    }

    fn document_for_root(root: &Path) -> Document {
        let mut document = Document::default();
        document.sets = vec![crate::config::document::SetConfig {
            name: toml::Spanned::new(0..0, "test".to_string()),
            roots: vec![root.to_string_lossy().into_owned()],
            include: None,
            exclude: None,
            on_refresh: None,
            before_sync: None,
            after_sync: None,
        }];
        document
    }

    /// The discriminating pair's clean half: a repo that is filthy in every dimension the
    /// criterion names (staged, unstaged, untracked, ahead, behind, stale) still settles with
    /// no probe ever failing. `status_stale_after` is zero, so `dirty` reads Stale as soon as
    /// it settles at all, deterministically rather than by racing a sleep.
    #[test]
    fn a_dirty_ahead_behind_and_stale_tree_never_reads_as_a_failed_probe() {
        let dir = tempfile::tempdir().expect("temp dir");
        let root = dir.path().canonicalize().expect("canonicalize temp dir");
        init_repo(&root);

        // Diverge: a commit on a second branch stands in for "upstream progress" no fetch
        // ever needs to happen for, then `main` gains a commit of its own the upstream ref
        // never sees, so `sync` reads ahead *and* behind rather than only one of the two.
        git(&root, &["checkout", "-b", "upstream-line"]);
        fs::write(root.join("remote.txt"), "remote\n").expect("write file");
        git(&root, &["add", "."]);
        git(&root, &["commit", "-m", "remote work"]);
        let upstream_sha = head_sha(&root);
        git(&root, &["checkout", "main"]);
        git(
            &root,
            &[
                "remote",
                "add",
                "origin",
                "https://example.invalid/repo.git",
            ],
        );
        git(&root, &["config", "branch.main.remote", "origin"]);
        git(&root, &["config", "branch.main.merge", "refs/heads/main"]);
        git(
            &root,
            &["update-ref", "refs/remotes/origin/main", &upstream_sha],
        );
        fs::write(root.join("tracked.txt"), "v1\n").expect("write file");
        git(&root, &["add", "."]);
        git(&root, &["commit", "-m", "local work"]);

        // Dirty: staged, unstaged and untracked all at once.
        fs::write(root.join("tracked.txt"), "v2\n").expect("unstaged edit");
        fs::write(root.join("staged.txt"), "staged\n").expect("write file");
        git(&root, &["add", "staged.txt"]);
        fs::write(root.join("untracked.txt"), "untracked\n").expect("write file");

        let core = Core::start_discovered(repon_core::CoreSpec {
            set: SetSpec {
                name: "test".to_string(),
                roots: vec![root],
                include: Vec::new(),
                exclude: Vec::new(),
            },
            overrides: Vec::new(),
            poll_interval: Duration::from_secs(3600),
            status_stale_after: Duration::ZERO,
            generation_deadline: Duration::from_secs(3600),
            show_submodules: false,
            fetch: repon_core::FetchSpec {
                enabled: false,
                interval: std::time::Duration::from_secs(3600),
                concurrency: 4,
            },
            auto_update: repon_core::AutoUpdateSpec { enabled: false },
        });
        let keys: Vec<_> = core
            .snapshot()
            .entities
            .iter()
            .map(|entity| entity.key.clone())
            .collect();
        core.refresh(&keys);
        let snapshot = core.settle();

        assert_eq!(snapshot.entities.len(), 1, "expected exactly the one repo");
        let entity = &snapshot.entities[0];
        assert!(
            matches!(
                entity.dirty.settled(),
                Some(Settled::Known { value, stale: true, at: _ }) if value.modified + value.untracked > 0
            ),
            "sanity check: dirty must actually settle Known, dirty and Stale, got {:?}",
            entity.dirty.settled()
        );
        assert!(
            matches!(
                entity.sync.settled(),
                Some(Settled::Known {
                    value: repon_core::SyncState::Tracking(ahead_behind),
                    at: _,
                    stale: _,
                }) if ahead_behind.ahead > 0 && ahead_behind.behind > 0
            ),
            "sanity check: sync must actually settle both ahead and behind, got {:?}",
            entity.sync.settled()
        );

        assert!(
            !any_probe_failed(&snapshot),
            "a dirty, diverged, stale tree must never read as a failed probe, got {:?}",
            snapshot.entities[0].diagnostics
        );
    }

    /// The discriminating pair's failing half: a `HEAD` that will not parse is a genuine
    /// `ProbeError`, per `crates/repon-core/src/git.rs`'s own
    /// `a_head_file_that_will_not_parse_is_a_failure_not_a_shape`. Nothing about this repo is
    /// dirty, ahead, behind or stale; only the corrupted `HEAD` should flip the verdict.
    #[test]
    fn a_head_that_will_not_parse_reads_as_a_failed_probe() {
        let dir = tempfile::tempdir().expect("temp dir");
        let root = dir.path().canonicalize().expect("canonicalize temp dir");
        init_repo(&root);
        fs::write(
            root.join(".git").join("HEAD"),
            "not a ref or an object id\n",
        )
        .expect("corrupt HEAD");

        let core = Core::start_discovered(repon_core::CoreSpec {
            set: SetSpec {
                name: "test".to_string(),
                roots: vec![root],
                include: Vec::new(),
                exclude: Vec::new(),
            },
            overrides: Vec::new(),
            poll_interval: Duration::from_secs(3600),
            status_stale_after: Duration::from_secs(3600),
            generation_deadline: Duration::from_secs(3600),
            show_submodules: false,
            fetch: repon_core::FetchSpec {
                enabled: false,
                interval: std::time::Duration::from_secs(3600),
                concurrency: 4,
            },
            auto_update: repon_core::AutoUpdateSpec { enabled: false },
        });
        let keys: Vec<_> = core
            .snapshot()
            .entities
            .iter()
            .map(|entity| entity.key.clone())
            .collect();
        core.refresh(&keys);
        let snapshot = core.settle();

        assert!(
            any_probe_failed(&snapshot),
            "a HEAD that will not parse must read as a failed probe, got {:?}",
            snapshot
                .entities
                .first()
                .map(|entity| entity.branch.settled())
        );
    }

    /// [`run`]'s own end-to-end wiring, in process rather than through the built binary:
    /// `crates/repon/tests/status_command.rs` proves the CLI dispatch and the process exit
    /// code separately.
    #[test]
    fn settle_document_tags_a_clean_repo_with_the_current_schema_and_no_failure() {
        let dir = tempfile::tempdir().expect("temp dir");
        let root = dir.path().canonicalize().expect("canonicalize temp dir");
        init_repo(&root);
        let config = config_with_document(document_for_root(&root));

        let (document, any_failed) =
            settle_document(&config, None, false).expect("settle a real, healthy repo");

        assert!(!any_failed, "a clean repo must never report a failed probe");
        assert_eq!(document.snapshot.entities.len(), 1);
    }

    /// `docs/spec/actions.md`'s "Exit codes" open item: a headless run verb is absent in v1,
    /// so nothing prints this exit code today, but [`entity_probe_failed`] must still leave a
    /// `Failed` Action step alone (a receipt is Repon's own report of what it did, never a
    /// reading of the world) while [`StepOutcome::Failed`] keeps carrying the code that a
    /// future consumer would need. No real repository or `Core` is needed: this is
    /// [`entity_probe_failed`]'s own seam, a pure predicate over `EntityState`.
    #[test]
    fn a_failed_action_step_never_flips_the_probe_verdict_and_keeps_its_exit_code() {
        use std::sync::Arc;

        use repon_core::{ActionReceipt, Generation, Kind, StepOutcome, StepResult, Timestamp};

        let mut entity = EntityState::new(
            repon_core::EntityKey::new(Arc::from(std::path::Path::new("/repo"))),
            Arc::from("repo"),
            Arc::from(std::path::Path::new("/repo/.git")),
            Kind::Repo,
        );
        entity.last_action = Some(ActionReceipt {
            label: Arc::from("reinstall"),
            steps: Arc::from(vec![StepResult {
                label: Arc::from("pnpm install"),
                outcome: StepOutcome::Failed(37),
                output: Arc::from(&b"boom"[..]),
                elapsed: Duration::from_millis(1),
                elision: None,
                shell: false,
                interactive: false,
            }]),
            skip: None,
            finished_at: Timestamp::now(),
            running: None,
        });

        let snapshot = Snapshot {
            generation: Generation::default(),
            discovered_at: Timestamp::now(),
            entities: vec![entity],
        };

        assert!(
            !any_probe_failed(&snapshot),
            "a Failed Action step must never read as a probe failure"
        );
        let receipt = snapshot.entities[0]
            .last_action
            .as_ref()
            .expect("the receipt must survive untouched");
        assert_eq!(
            receipt.steps[0].outcome,
            StepOutcome::Failed(37),
            "the per-entity exit code must still be there for a future headless consumer"
        );
    }
}