balls/tracker.rs
1//! The `tracker` plugin — balls' one remote-talker (§0/§12/§13).
2//!
3//! Base balls is local-only: it commits task-file changes to the balls branch
4//! and never touches a remote. Everything beyond that is a plugin, and the
5//! tracker is the plugin that owns the remote. It is a SEPARATE binary, invoked
6//! subprocess-uniform (`<bin> <op> <phase>`, §6) with the §7 wire on stdin and
7//! no return channel — in-repo only as a default capability + reference impl.
8//!
9//! Its whole job is git acts on the state branch:
10//! - [`remote_ops::sync`] — `sync/pre`: fetch + fast-forward-only import; a
11//! non-ff is the contention signal (§13).
12//! - [`remote_ops::push`] — `*/post`: publish the sealed balls branch (§12).
13//! - [`prime::prime`] — `prime/pre`: settle the store name, clone an established
14//! remote branch into a local ref, or warn the stealth W1 (§12).
15//! - [`prime::prime_post`] — `prime/post`: settle content — fetch-ff an
16//! established remote then push, or found an absent branch by pushing (§12).
17//!
18//! The wire's [`Binding`] is everything it needs — `remote` + `tasks_branch`
19//! name the store upstream DIRECTLY, with no trail to walk (§12). When the binding
20//! carries no explicit `remote` (core resolves only `--remote`/`--center`/XDG, the
21//! config tiers — §0 keeps it local-only), the tracker discovers the project-repo
22//! `origin` as its single fallback ([`effective_remote`], resolved once at the
23//! [`handle`] dispatch point). Each handler no-ops in a stealth repo — no explicit
24//! remote AND no discoverable origin, or a binding DECLARED stealth (the landing
25//! `task_remote` sentinel, written by `bl prime --stealth` and re-derived by core
26//! on every op, bl-9df0) — the structural opt-out (§12).
27
28mod git;
29mod payload;
30mod prime;
31mod remote_ops;
32
33#[cfg(test)]
34mod fixtures;
35
36pub use payload::Binding;
37
38use serde::Serialize;
39use std::io::{self, Read, Write};
40use std::path::Path;
41
42/// The host-resolved environment the binary edge hands the tracker: the XDG
43/// roots that locate this checkout's clone bundle (§1). No env reads in the lib
44/// — the edge resolves them once (the bl-bfa8 rule) and passes them in.
45pub struct Env {
46 pub xdg: crate::layout::Xdg,
47}
48
49/// The ops the tracker handles, for the §6 `protocol` self-description: the
50/// deliverable verbs plus `import` (it pushes on their `post` — imported
51/// records sync like any mutate, §16), `sync`/`prime`, and `install`
52/// (it fetches the center's config on `install/pre`, §13).
53const OPS: &[&str] = &[
54 "create", "claim", "unclaim", "update", "close", "import", "sync", "prime", "install",
55];
56
57/// The §6 self-description emitted by `tracker protocol`. balls never persists
58/// it; it is read at install time to validate a binding.
59#[derive(Serialize)]
60struct SelfDescription {
61 protocol: u32,
62 ops: &'static [&'static str],
63}
64
65/// The tracker entrypoint: dispatch `args` (`protocol`, or `<op> <phase>` with
66/// the §7 payload on `input`), returning the process exit code. A handler error
67/// is logged to stderr and becomes exit `1` — the §6 "non-zero aborts the op".
68pub fn run(args: &[String], input: &mut impl Read, out: &mut impl Write, env: &Env) -> i32 {
69 match dispatch(args, input, out, env) {
70 Ok(()) => 0,
71 Err(e) => {
72 eprintln!("tracker: {e}");
73 1
74 }
75 }
76}
77
78fn dispatch(args: &[String], input: &mut impl Read, out: &mut impl Write, env: &Env) -> io::Result<()> {
79 match args.iter().map(String::as_str).collect::<Vec<_>>().as_slice() {
80 ["protocol"] => protocol(out),
81 [op, phase] => handle(op, phase, input, env),
82 _ => Err(io::Error::other("usage: tracker protocol | tracker <op> <phase>")),
83 }
84}
85
86/// Emit the §6 `{ protocol, ops }` self-description as JSON.
87fn protocol(out: &mut impl Write) -> io::Result<()> {
88 let desc = SelfDescription { protocol: crate::message::PROTOCOL, ops: OPS };
89 serde_json::to_writer(&mut *out, &desc).map_err(io::Error::other)?;
90 out.write_all(b"\n")
91}
92
93/// Route one `<op> <phase>` to its handler. The tracker acts in five slots —
94/// `sync/pre`, `prime/pre` (settle name + clone-in), `prime/post` (settle content
95/// — fetch-ff + push, bl-0a23), `install/pre` (the §13 config fetch), and any
96/// deliverable verb's `post` (the push) — and no-ops everywhere else (reads, the
97/// other phases). `prime/post` is matched out explicitly BEFORE the
98/// `sync`/`prime`/`install` catch-all so it reaches its own content handler; that
99/// catch-all then keeps `sync`/`install` from triggering the generic `post` push:
100/// in particular `install` adopts config INTO the local landing (a fetch), and
101/// must NEVER push the landing back out (publishing is a separate direction,
102/// §6/§13).
103fn handle(op: &str, phase: &str, input: &mut impl Read, env: &Env) -> io::Result<()> {
104 let mut binding = payload::read_binding(input)?;
105 binding.remote = effective_remote(&binding);
106 match (op, phase) {
107 ("sync", "pre") => remote_ops::sync(&binding),
108 ("prime", "pre") => prime::prime(&binding, env),
109 ("prime", "post") => prime::prime_post(&binding),
110 ("install", "pre") => remote_ops::fetch_config(&binding),
111 ("sync" | "prime" | "install", _) => Ok(()),
112 (_, "post") => remote_ops::push(&binding),
113 _ => Ok(()),
114 }
115}
116
117/// The effective store remote for this op (§12): the EXPLICIT remote core already
118/// resolved (`--remote`/`--center`/XDG `remote`, on the binding), else the
119/// auto-discovered project-repo `origin`. Implicit `origin` discovery is the
120/// TRACKER's alone — core stays local-only (§0) and hands a `remote: None`
121/// binding when no explicit tier is set. Resolved ONCE here at the [`handle`]
122/// dispatch point and written back onto `binding.remote`, so every handler shares
123/// this one fallback and reads `binding.remote` as before — the stealth gate
124/// ("no remote ⇒ no-op") thus means "no explicit remote AND no discoverable
125/// origin", with no per-handler re-probe. A binding carrying `stealth` — the
126/// landing `task_remote` sentinel core derives on EVERY op (§12, bl-9df0) — is
127/// DECLARED stealth: resolve no
128/// remote at all, so even a discoverable `origin` is never founded or pushed.
129fn effective_remote(b: &Binding) -> Option<String> {
130 if b.stealth {
131 return None;
132 }
133 b.remote.clone().or_else(|| origin_of(Path::new(&b.invocation_path)))
134}
135
136/// The auto-discovered store remote — `git remote get-url origin` on the PROJECT
137/// repo (the `invocation_path`, the clone the user works in, whose `origin` is the
138/// real upstream the code rides and where `balls/tasks` sits alongside it). A
139/// LOCAL config read, no network. NEVER the landing: the landing is local-only
140/// (§2 install-transport, founded by a bare `git init`) and carries no origin.
141/// Absent origin (the stealth case, or a non-repo path) ⇒ `None`.
142fn origin_of(project: &Path) -> Option<String> {
143 git::git(project, &["remote", "get-url", "origin"]).ok()
144}
145
146#[cfg(test)]
147#[path = "tracker_tests.rs"]
148mod tests;