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;
const SETTLE_DEADLINE_SLACK: Duration = Duration::from_secs(5);
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)?;
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(())
}
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();
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,
));
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))
}
fn any_probe_failed(snapshot: &Snapshot) -> bool {
snapshot.entities.iter().any(entity_probe_failed)
}
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()
}
trait ProbeOutcome {
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;
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()
}
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"]);
}
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
}
#[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);
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"]);
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
);
}
#[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())
);
}
#[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);
}
#[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"
);
}
}