diff --git a/.trinity/seals/tri_tool_tri_worktrees.json b/.trinity/seals/tri_tool_tri_worktrees.json index f0720f6fab..e384b7d094 100644 --- a/.trinity/seals/tri_tool_tri_worktrees.json +++ b/.trinity/seals/tri_tool_tri_worktrees.json @@ -1,27 +1,28 @@ { - "built_by": "t27c-bootstrap@0.5.2+5d5d2cdef", + "built_by": "t27c-bootstrap@0.5.2+ea35d9b72", "closure": [], "config": "gen=c,rust,verilog,zig;tests=zig", - "gen_hash_c": "sha256:b533e7d02cfcf8702f0816fc836513ef038e4cf8c2a947bb320433f20063ed57", - "gen_hash_rust": "sha256:96b37b3ce707b4f1437288c62e69caa8b3a0a387f11d1ff826e6e485f324d915", - "gen_hash_verilog": "sha256:95e02975fbe3862a319fca8098e35fb80e3d424042e82f83e0cae86430c6f8c4", - "gen_hash_zig": "sha256:a4e673ba1c8066518db133b13acbc7c0f8440262f7a5f540881435a56ef18ed1", + "gen_hash_c": "sha256:3730bc66d153d4e749ac0760d26c72e9dbd4ea8f5e0660f3efcecdc713c67869", + "gen_hash_rust": "sha256:5e6c679216c3e25f1065236b2dbdad7f610a520d04a8930ae59e697feeb6cb31", + "gen_hash_verilog": "sha256:cc44189cebda1719fc034c942a6fad895f65515b1dee20a33f219b8d338c1675", + "gen_hash_zig": "sha256:2bf37ef5bc6ec7f82534dd30e20dfc06d4001fffc022c1e5ad71dd4cb0737803", "module": "tool_tri_worktrees", "ring": 12, "seal_schema": 2, - "sealed_at": "2026-10-09T14:56:31Z", + "sealed_at": "2026-10-19T03:52:00Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:4427c5e349eccd291f547ab064220d6c52e5c4a954f74f6bc73b36cbb6fe722b", + "spec_hash": "sha256:a1192fdda1ff3eadeb5de28ae9d662ee7505545c70e62ca1d2d24d1d32b6d1cf", "spec_path": "specs/tools/tri/worktrees.t27", "tests": { "failed": 0, "failing": [], "forced": false, "passed": 4, - "total": 4 + "total": 4, + "vacuous": 0 }, "toolchain": { - "test_runner": "sha256:1982ae948e24d00c5d221ccec12dfce1eb01204b53e37b0f857f93b16d64629d", + "test_runner": "sha256:8512b80da533b79289110fca4a65f3336abd7a059ca4b72ea595d5e33ff8ad10", "zig": "0.16.0" } } \ No newline at end of file diff --git a/.trinity/seals/worktrees_TriWorktreeReap.json b/.trinity/seals/worktrees_TriWorktreeReap.json new file mode 100644 index 0000000000..d5560e48d7 --- /dev/null +++ b/.trinity/seals/worktrees_TriWorktreeReap.json @@ -0,0 +1,37 @@ +{ + "built_by": "t27c-bootstrap@0.5.2+ea35d9b72", + "closure": [ + { + "spec": "specs/tri/lab/receipt.t27", + "spec_hash": "sha256:00d9eec8d7be84dde596c9990b3a5574de24aff9f7a94579fc620eb585712441" + }, + { + "spec": "specs/tri/lanes/lanes.t27", + "spec_hash": "sha256:aac192b3e3250196262a9b8467d4a9cef8147438c11c0744c66c3f6b175c9342" + } + ], + "config": "gen=c,rust,verilog,zig;tests=zig", + "gen_hash_c": "sha256:8d09198aa53a8bdc23093d3cffb3512bc8380d7489f2246d662f60e96ab45a1d", + "gen_hash_rust": "sha256:a2b4fae679bfa41d9985a8c72810452a1748b7a490f224082d1d07b617ba9bd5", + "gen_hash_verilog": "sha256:716b12e948192e930b1e51c9f46cf0a674355d58e9e87d00137b27cbdadab708", + "gen_hash_zig": "sha256:624311ddde0ac3622b5eeb5ef74a1e74b6ec59bd944d2ff8157f828947de373e", + "module": "TriWorktreeReap", + "ring": 12, + "seal_schema": 2, + "sealed_at": "2026-10-19T03:51:32Z", + "sealed_by": "t27c-bootstrap@0.5.2", + "spec_hash": "sha256:b01a0e09684d6e3c6c055a8a6f9efb69215f2f0ac4ffeff1a5df33fa403748fd", + "spec_path": "specs/tri/worktrees/reap.t27", + "tests": { + "failed": 0, + "failing": [], + "forced": false, + "passed": 13, + "total": 13, + "vacuous": 0 + }, + "toolchain": { + "test_runner": "sha256:8512b80da533b79289110fca4a65f3336abd7a059ca4b72ea595d5e33ff8ad10", + "zig": "0.16.0" + } +} \ No newline at end of file diff --git a/bootstrap/src/main.rs b/bootstrap/src/main.rs index 6273276684..2de3d9ee79 100644 --- a/bootstrap/src/main.rs +++ b/bootstrap/src/main.rs @@ -176,8 +176,6 @@ mod host; mod tt_debug; mod tt_manifest; mod tt_profile; -// mod runtime_minimal; -// mod runtime_minimal_test; use anyhow::Context; use clap::{Parser, Subcommand}; diff --git a/bootstrap/src/runtime_minimal.rs b/bootstrap/src/runtime_minimal.rs deleted file mode 100644 index a2c7c306ba..0000000000 --- a/bootstrap/src/runtime_minimal.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! Minimal runtime test -use std::collections::HashMap; - -#[derive(Debug)] -pub struct TestRuntime { - cache: HashMap, -} - -impl TestRuntime { - pub fn new() -> Self { - Self { - cache: HashMap::new(), - } - } - - pub fn test(&self) -> f64 { - 1.0 - } -} diff --git a/bootstrap/src/runtime_minimal_test.rs b/bootstrap/src/runtime_minimal_test.rs deleted file mode 100644 index 70da39853c..0000000000 --- a/bootstrap/src/runtime_minimal_test.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Minimal test of runtime -use std::collections::HashMap; - -pub struct MinimalRuntime { - cache: HashMap, -} - -impl MinimalRuntime { - pub fn new() -> Self { - Self { - cache: HashMap::new(), - } - } - - pub fn eval(&mut self, x: f64) -> f64 { - x - } -} diff --git a/cli/tri/src/payout.rs b/cli/tri/src/payout.rs index ee535d5d82..a3599b7f36 100644 --- a/cli/tri/src/payout.rs +++ b/cli/tri/src/payout.rs @@ -3,7 +3,7 @@ pub mod pay; // t27c gen-rust of specs/tri/network/payout.t27 use {anyhow::Result, sha2::{Digest, Sha256}, std::collections::{BTreeMap, BTreeSet, HashMap}, std::io::Write, std::process::{Command, Stdio}}; /// Run `c` with `input` on its stdin; its stdout, leaked, since the generated rules read `&'static str`. -fn sh(c: &str, a: &[&str], input: &str) -> &'static str { let mut p = Command::new(c).args(a).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().expect(c); let _ = p.stdin.take().map(|mut i| i.write_all(input.as_bytes())); String::from_utf8_lossy(&p.wait_with_output().map(|o| o.stdout).unwrap_or_default()).into_owned().leak() } +pub(crate) fn sh(c: &str, a: &[&str], input: &str) -> &'static str { let mut p = Command::new(c).args(a).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().expect(c); let _ = p.stdin.take().map(|mut i| i.write_all(input.as_bytes())); String::from_utf8_lossy(&p.wait_with_output().map(|o| o.stdout).unwrap_or_default()).into_owned().leak() } /// GitHub's answer `name`: the memo, else the GH_CACHE file `ok` reads, else curl with `auth` on stdin (kept once `ok` reads it). `join` adds one author (pay::author_join). fn get(memo: &mut HashMap, dir: &std::path::Path, auth: &str, name: String, ok: &dyn Fn(&'static str) -> bool, args: &[&str]) -> &'static str { if let Some(t) = memo.get(&name) { return t; } let t = std::fs::read_to_string(dir.join(&name)).ok().map(|s| &*s.leak()).filter(|s| ok(s)).unwrap_or_else(|| { let t = sh("curl", &[&["-fsSL", "--max-time", "60", "-H", "@-"], args].concat(), auth); if ok(t) { let _ = std::fs::write(dir.join(&name), t); } t }); memo.insert(name, t); t } fn join(v: &mut Vec<(u64, u8, &'static str)>, id: u64, f: u8, own: bool, x: &'static str) { match v.iter().position(|a| a.0 == id) { Some(j) => v[j].1 = pay::author_join(v[j].1, f, own), None => v.push((id, f, x)) } } diff --git a/cli/tri/src/trees.rs b/cli/tri/src/trees.rs index 16b8e60262..afad6a06d5 100644 --- a/cli/tri/src/trees.rs +++ b/cli/tri/src/trees.rs @@ -23,6 +23,8 @@ use anyhow::{Context, Result}; use clap::Args; use std::process::Command; +#[path = "../../../gen/rust/tri/worktrees/reap.rs"] #[allow(dead_code, unused_parens)] +mod reap; // t27c gen-rust of specs/tri/worktrees/reap.t27: every rule of `--reap-plan` /// One checkout, as `git worktree list --porcelain` describes it. #[derive(Debug, PartialEq, Eq, Default)] @@ -32,7 +34,7 @@ pub struct Tree { pub branch: Option, pub detached: bool, pub bare: bool, - pub locked: bool, + pub locked: Option, pub prunable: bool, } @@ -137,7 +139,7 @@ pub fn parse_porcelain(text: &str) -> Vec { "branch" => cur.branch = Some(rest.trim_start_matches("refs/heads/").to_string()), "detached" => cur.detached = true, "bare" => cur.bare = true, - "locked" => cur.locked = true, + "locked" => cur.locked = Some(rest.to_string()), "prunable" => cur.prunable = true, _ => {} } @@ -248,6 +250,39 @@ pub struct Worktrees { /// Print every tree, not only those holding work or unreadable. #[arg(long)] all: bool, + /// Print the `git worktree remove` line of every checkout that is REMOVABLE (rules: specs/tri/worktrees/reap.t27): its PR is merged or closed (asked of GitHub once per branch), it holds nothing but the untracked target link, and no live pid holds its lock. Still deletes nothing. + #[arg(long)] + reap_plan: bool, +} + +/// The `--reap-plan` section: each checkout judged by `reap`, and the line that removes each REMOVABLE one. GitHub is asked only about a tree a merged PR would make removable. +fn reap_plan(trees: &[Tree], all: bool) { + let token = std::env::var("GITHUB_TOKEN").ok().or_else(|| Command::new("gh").args(["auth", "token"]).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())); + let auth = token.filter(|t| !t.is_empty()).map(|t| format!("Authorization: Bearer {t}\n")).unwrap_or_default(); + let (main, mut counts) = (trees.first().map_or("", |t| t.path.as_str()), [0usize; reap::VERDICTS as usize]); + println!(); + for (i, t) in trees.iter().enumerate() { + let (path, branch): (&'static str, &'static str) = (t.path.clone().leak(), t.branch.clone().unwrap_or_default().leak()); + let dirty = !git_in(path, &["status", "--porcelain"]).map_or(false, |s| reap::only_target(s.leak())); + let pid = reap::lock_pid(t.locked.clone().unwrap_or_default().leak()); + let gone = pid != 0 && Command::new("kill").args(["-0", &pid.to_string()]).output().map_or(false, |o| String::from_utf8_lossy(&o.stderr).contains("No such process")); + let lock = reap::lock_state(t.locked.is_some(), pid, !gone); + let judge = |pr| reap::verdict(i == 0, dirty, lock, pr, reap::plain(path)); + let mut v = judge(reap::PR_MERGED); + if v == reap::V_REMOVABLE { + let url = format!("{}{branch}", reap::PULLS_URL); + let pulls = if reap::plain(branch) { crate::payout::sh("curl", &["-fsSL", "--max-time", "30", "-H", "@-", &url], &auth) } else { "" }; + v = judge(reap::pr_state(serde_json::from_str::(pulls).ok().and_then(|j| serde_json::to_string_pretty(&j).ok()).unwrap_or_default().leak())); + } + counts[v as usize] += 1; + if v == reap::V_REMOVABLE || all { + println!(" {:14} {:28.28} {branch:34.34} {}", reap::verdict_name(v), path.rsplit('/').next().unwrap_or(path), reap::why(v)); + } + if v == reap::V_REMOVABLE { + println!(" {}{main}{}{} {path}", reap::REMOVE_HEAD, reap::REMOVE_MID, reap::remove_flags(lock)); + } + } + println!(" reap plan: {}", (0..reap::VERDICTS).map(|v| format!("{} {}", counts[v as usize], reap::verdict_name(v))).collect::>().join(", ")); } pub fn run(a: &Worktrees) -> Result<()> { @@ -355,6 +390,7 @@ pub fn run(a: &Worktrees) -> Result<()> { gib(keep_kb) ); } + if a.reap_plan { reap_plan(&trees, a.all); } println!(" Nothing was deleted. A clean tree is not a tree you own."); Ok(()) } @@ -398,7 +434,7 @@ mod porcelain_tests { fn locked_bare_and_prunable_are_read() { let t = parse_porcelain("worktree /r\nbare\n\nworktree /l\nHEAD a\nlocked\n\nworktree /p\nHEAD b\nprunable gitdir file points to non-existent location\n"); assert!(t[0].bare); - assert!(t[1].locked); + assert!(t[1].locked.is_some()); assert!(t[2].prunable); } diff --git a/docs/reports/orphan_modules.json b/docs/reports/orphan_modules.json index 0156bf30ac..2dec797f70 100644 --- a/docs/reports/orphan_modules.json +++ b/docs/reports/orphan_modules.json @@ -2,9 +2,9 @@ "_what": "Per-crate ceiling on .rs files under src/ that no `mod` declaration reaches from the crate root. Measured by `tri mods orphan --gate`.", "_why": "#2427, a Zig-lexer PR, removed `mod elab;` from cli/tri/src/main.rs in the same hunk as two other lines and left the 319-line file. cargo build cannot error on a file it does not compile, the suite fell 358 -> 354, and no gate reads the test count. Nothing printed a word for six days (#2900).", "_rule": "Exact match, not an upper bound. A count BELOW its ceiling fails too, so the next orphan cannot hide in the slack -- lower the number in the commit that connects a file.", - "_bootstrap_6": "Pre-existing, not repaired here: _enrich/mod.rs, neural/attention.rs, neural/hslm.rs, runtime_minimal.rs, runtime_minimal_test.rs. tooling.rs (7 unconnected gate bodies, #2905) was deleted by #10165 and math_pslq.rs by #10404, each to pay the foreign-line budget; the rest are unexamined. Connecting any of them is a decision about what goes red, not a repair.", + "_bootstrap_3": "Pre-existing, not repaired here: _enrich/mod.rs, neural/attention.rs, neural/hslm.rs. tooling.rs (7 unconnected gate bodies, #2905) was deleted by #10165, math_pslq.rs by #10404, and runtime_minimal.rs and runtime_minimal_test.rs (two scratch stubs, their `mod` lines already commented out of main.rs) by #10805, each to pay the foreign-line budget; the rest are unexamined. Connecting any of them is a decision about what goes red, not a repair.", "ceilings": { - "bootstrap": 5, + "bootstrap": 3, "cli/tri": 0, "cli/trios-bridge": 0, "cli/flash-spi": 0, diff --git a/gen/rust/tri/worktrees/reap.rs b/gen/rust/tri/worktrees/reap.rs new file mode 100644 index 0000000000..00a0844bf1 --- /dev/null +++ b/gen/rust/tri/worktrees/reap.rs @@ -0,0 +1,423 @@ +// Generated from .t27 spec +// DO NOT EDIT — generated by t27c +// NOT LOWERED BY THIS BACKEND: 13 test(s), 1 invariant(s), 0 function(s) with no body. +// This backend emits declarations only. The spec's checks live in +// the Zig and Verilog outputs; do not read this file as verified. + +pub const PULLS_URL: &'static str = "https://api.github.com/repos/gHashTag/t27/pulls?state=all&per_page=10&head=gHashTag:"; + +pub const REMOVE_HEAD: &'static str = "git -C "; + +pub const REMOVE_MID: &'static str = " worktree remove "; + +pub const PR_NONE: u8 = 0; + +pub const PR_OPEN: u8 = 1; + +pub const PR_MERGED: u8 = 2; + +pub const PR_CLOSED: u8 = 3; + +pub const LOCK_NONE: u8 = 0; + +pub const LOCK_LIVE: u8 = 1; + +pub const LOCK_DEAD: u8 = 2; + +pub const LOCK_UNKNOWN: u8 = 3; + +pub const V_REMOVABLE: u8 = 0; + +pub const V_KEEP_MAIN: u8 = 1; + +pub const V_KEEP_DIRTY: u8 = 2; + +pub const V_KEEP_LOCKED: u8 = 3; + +pub const V_KEEP_OPEN_PR: u8 = 4; + +pub const V_KEEP_NO_PR: u8 = 5; + +pub const V_KEEP_ODD_PATH: u8 = 6; + +pub const VERDICTS: u8 = 7; + +pub fn only_target(status: &'static str) -> bool { + let mut k: usize = 0; + while (k < status.len()) { + let end: usize = line_end(status, k); + if (((end > k) && (span_is(status, k, end, "?? target") == false)) && (span_is(status, k, end, "?? target/") == false)) { + return false; + } + k = (end + 1); + } + return true; +} + +pub fn lock_pid(reason: &'static str) -> u64 { + let mut k: usize = 0; + while (k < reason.len()) { + if starts(reason, k, "(pid ") { + let mut e: usize = (k + 5); + while (((e < reason.len()) && (reason.as_bytes()[(e) as usize] >= 48)) && (reason.as_bytes()[(e) as usize] <= 57)) { + e = (e + 1); + } + return digits(reason, (k + 5), e); + } + k = (k + 1); + } + return 0; +} + +pub fn lock_state(locked: bool, pid: u64, alive: bool) -> u8 { + if (locked == false) { + return LOCK_NONE; + } + if (pid == 0) { + return LOCK_UNKNOWN; + } + if alive { + return LOCK_LIVE; + } + return LOCK_DEAD; +} + +pub fn plain(s: &'static str) -> bool { + if (s.len() == 0) { + return false; + } + let mut i: usize = 0; + while (i < s.len()) { + let c: u8 = s.as_bytes()[(i) as usize]; + let ok: bool = ((((((((c >= 97) && (c <= 122)) || ((c >= 65) && (c <= 90))) || ((c >= 48) && (c <= 57))) || (c == 47)) || (c == 46)) || (c == 95)) || (c == 45)); + if (ok == false) { + return false; + } + i = (i + 1); + } + return true; +} + +pub fn pr_state(body: &'static str) -> u8 { + let mut seen: u8 = PR_NONE; + let mut o: usize = next_pr(body, 0); + while (o < body.len()) { + let e: usize = object_end(body, o); + let s: usize = key_at(body, o, e, 4, "state"); + let m: usize = key_at(body, o, e, 4, "merged_at"); + if span_is(body, value_from(body, s), value_to(body, s), "open") { + return PR_OPEN; + } + if span_is(body, value_from(body, s), value_to(body, s), "closed") { + if ((m < body.len()) && (body.as_bytes()[(m) as usize] == 34)) { + seen = PR_MERGED; + } else { + if (seen == PR_NONE) { + seen = PR_CLOSED; + } + } + } + o = next_pr(body, e); + } + return seen; +} + +pub fn verdict(main: bool, dirty: bool, lock: u8, pr: u8, path_ok: bool) -> u8 { + if main { + return V_KEEP_MAIN; + } + if dirty { + return V_KEEP_DIRTY; + } + if ((lock != LOCK_NONE) && (lock != LOCK_DEAD)) { + return V_KEEP_LOCKED; + } + if (pr == PR_OPEN) { + return V_KEEP_OPEN_PR; + } + if ((pr != PR_MERGED) && (pr != PR_CLOSED)) { + return V_KEEP_NO_PR; + } + if (path_ok == false) { + return V_KEEP_ODD_PATH; + } + return V_REMOVABLE; +} + +pub fn remove_flags(lock: u8) -> &'static str { + if (lock == LOCK_DEAD) { + return "-f -f"; + } + return "-f"; +} + +pub fn verdict_name(v: u8) -> &'static str { + if (v == V_REMOVABLE) { + return "REMOVABLE"; + } + if (v == V_KEEP_MAIN) { + return "KEEP_MAIN"; + } + if (v == V_KEEP_DIRTY) { + return "KEEP_DIRTY"; + } + if (v == V_KEEP_LOCKED) { + return "KEEP_LOCKED"; + } + if (v == V_KEEP_OPEN_PR) { + return "KEEP_OPEN_PR"; + } + if (v == V_KEEP_NO_PR) { + return "KEEP_NO_PR"; + } + return "KEEP_ODD_PATH"; +} + +pub fn why(v: u8) -> &'static str { + if (v == V_REMOVABLE) { + return "its PR is merged or closed, it holds nothing but the target link, no live process holds its lock"; + } + if (v == V_KEEP_MAIN) { + return "the main checkout is never removed"; + } + if (v == V_KEEP_DIRTY) { + return "it holds work, or git could not read it"; + } + if (v == V_KEEP_LOCKED) { + return "a live process, or a lock with no pid to check, holds it"; + } + if (v == V_KEEP_OPEN_PR) { + return "its pull request is still open"; + } + if (v == V_KEEP_NO_PR) { + return "no merged or closed pull request was found for its branch"; + } + return "its path has a character the printed line would need quoted for"; +} + +pub const AGENT_LOCK: &'static str = "claude agent agent-a1a8e2b61031e3d98 (pid 5082 start Sun Oct 18 15:42:53 2026)"; + +pub const MERGED: &'static str = "[\n {\n \"head\": {\n \"label\": \"gHashTag:claude/self-cleanup-skill-10804\",\n \"ref\": \"claude/self-cleanup-skill-10804\"\n },\n \"merged_at\": \"2026-10-19T03:33:38Z\",\n \"milestone\": {\n \"state\": \"open\"\n },\n \"number\": 10808,\n \"state\": \"closed\",\n \"title\": \"skills: self-cleanup\"\n }\n]"; + +pub const CLOSED: &'static str = "[\n {\n \"head\": {\n \"ref\": \"claude/dropped\"\n },\n \"merged_at\": null,\n \"number\": 10790,\n \"state\": \"closed\"\n }\n]"; + +pub const OPEN: &'static str = "[\n {\n \"body\": \"mentions \\\"state\\\": \\\"open\\\" in prose\",\n \"head\": {\n \"ref\": \"claude/open-one\"\n },\n \"merged_at\": null,\n \"number\": 10811,\n \"state\": \"open\"\n }\n]"; + +pub const CLOSED_THEN_OPEN: &'static str = "[\n {\n \"merged_at\": null,\n \"number\": 1,\n \"state\": \"closed\"\n },\n {\n \"merged_at\": null,\n \"number\": 2,\n \"state\": \"open\"\n }\n]"; + +pub const OPEN_THEN_MERGED: &'static str = "[\n {\n \"merged_at\": null,\n \"number\": 3,\n \"state\": \"open\"\n },\n {\n \"merged_at\": \"2026-10-01T00:00:00Z\",\n \"number\": 4,\n \"state\": \"closed\"\n }\n]"; + +pub const CLOSED_THEN_MERGED: &'static str = "[\n {\n \"merged_at\": null,\n \"number\": 5,\n \"state\": \"closed\"\n },\n {\n \"merged_at\": \"2026-10-01T00:00:00Z\",\n \"number\": 6,\n \"state\": \"closed\"\n }\n]"; + +pub fn key_at(text: &'static str, from: usize, stop: usize, ind: usize, key: &'static str) -> usize { + let mut at: usize = from; + while ((at < stop) && (at < text.len())) { + let end: usize = line_end(text, at); + let v: usize = key_value(text, at, end, ind, key); + if (v <= end) { + return v; + } + at = (end + 1); + } + return (text.len() + 1); +} + +pub fn key_value(text: &'static str, at: usize, end: usize, ind: usize, key: &'static str) -> usize { + let v: usize = (((at + ind) + key.len()) + 4); + if (v > end) { + return (end + 1); + } + let mut k: usize = 0; + while (k < ind) { + if (text.as_bytes()[((at + k)) as usize] != 32) { + return (end + 1); + } + k = (k + 1); + } + if (text.as_bytes()[((at + ind)) as usize] != 34) { + return (end + 1); + } + if (span_is(text, ((at + ind) + 1), (((at + ind) + 1) + key.len()), key) == false) { + return (end + 1); + } + if (((text.as_bytes()[((v - 3)) as usize] != 34) || (text.as_bytes()[((v - 2)) as usize] != 58)) || (text.as_bytes()[((v - 1)) as usize] != 32)) { + return (end + 1); + } + return v; +} + +pub fn line_end(text: &'static str, at: usize) -> usize { + let mut k: usize = at; + while ((k < text.len()) && (text.as_bytes()[(k) as usize] != 10)) { + k = (k + 1); + } + return k; +} + +pub fn line_is(text: &'static str, at: usize, end: usize, ind: usize, lit: &'static str) -> bool { + let mut e: usize = end; + if ((e > at) && (text.as_bytes()[((e - 1)) as usize] == 44)) { + e = (e - 1); + } + if (e < (at + ind)) { + return false; + } + let mut k: usize = 0; + while (k < ind) { + if (text.as_bytes()[((at + k)) as usize] != 32) { + return false; + } + k = (k + 1); + } + return span_is(text, (at + ind), e, lit); +} + +pub fn object_end(text: &'static str, at: usize) -> usize { + let mut k: usize = (line_end(text, at) + 1); + while (k < text.len()) { + let end: usize = line_end(text, k); + if line_is(text, k, end, 2, "}") { + return end; + } + k = (end + 1); + } + return text.len(); +} + +pub fn span_is(text: &'static str, from: usize, to: usize, lit: &'static str) -> bool { + if ((to < from) || ((to - from) != lit.len())) { + return false; + } + let mut k: usize = 0; + while (k < lit.len()) { + if (text.as_bytes()[((from + k)) as usize] != lit.as_bytes()[(k) as usize]) { + return false; + } + k = (k + 1); + } + return true; +} + +pub fn value_from(text: &'static str, v: usize) -> usize { + if (v >= text.len()) { + return text.len(); + } + if (text.as_bytes()[(v) as usize] == 34) { + return (v + 1); + } + return v; +} + +pub fn value_to(text: &'static str, v: usize) -> usize { + if (v >= text.len()) { + return text.len(); + } + let mut e: usize = line_end(text, v); + if ((e > v) && (text.as_bytes()[((e - 1)) as usize] == 44)) { + e = (e - 1); + } + if (((text.as_bytes()[(v) as usize] == 34) && (e > (v + 1))) && (text.as_bytes()[((e - 1)) as usize] == 34)) { + e = (e - 1); + } + if (e < value_from(text, v)) { + return value_from(text, v); + } + return e; +} + +pub fn branch_from(w: &'static str, at: usize) -> usize { + let stop: usize = record_end(w, at); + let mut k: usize = at; + while (k < stop) { + if starts(w, k, "branch refs/heads/") { + return (k + 18); + } + k = (line_end(w, k) + 1); + } + return w.len(); +} + +pub fn branch_to(w: &'static str, at: usize) -> usize { + let b: usize = branch_from(w, at); + if (b >= w.len()) { + return w.len(); + } + return line_end(w, b); +} + +pub fn digits(text: &'static str, from: usize, to: usize) -> u64 { + if ((to <= from) || ((to - from) > 18)) { + return 0; + } + let mut n: u64 = 0; + let mut i: usize = from; + while (i < to) { + if ((text.as_bytes()[(i) as usize] < 48) || (text.as_bytes()[(i) as usize] > 57)) { + return 0; + } + n = ((n * 10) + ((text.as_bytes()[(i) as usize] as u64) - 48)); + i = (i + 1); + } + return n; +} + +pub fn merged(m: &'static str, w: &'static str, at: usize) -> bool { + let b: usize = branch_from(w, at); + let be: usize = branch_to(w, at); + if (b >= w.len()) { + return false; + } + let mut k: usize = 0; + while ((((k + 15) + (be - b)) + 1) <= m.len()) { + if (span_is(m, k, (k + 15), "\"headRefName\":\"") && (m.as_bytes()[(((k + 15) + (be - b))) as usize] == 34)) { + let mut same: bool = true; + let mut i: usize = 0; + while (i < (be - b)) { + if (m.as_bytes()[(((k + 15) + i)) as usize] != w.as_bytes()[((b + i)) as usize]) { + same = false; + } + i = (i + 1); + } + if same { + return true; + } + } + k = (k + 1); + } + return false; +} + +pub fn next_pr(text: &'static str, at: usize) -> usize { + let mut k: usize = at; + while (k < text.len()) { + let end: usize = line_end(text, k); + if line_is(text, k, end, 2, "{") { + return k; + } + if line_is(text, k, end, 0, "]") { + return text.len(); + } + k = (end + 1); + } + return text.len(); +} + +pub fn record_end(w: &'static str, at: usize) -> usize { + let mut k: usize = at; + while (k < w.len()) { + let end: usize = line_end(w, k); + if (end == k) { + return k; + } + k = (end + 1); + } + return w.len(); +} + +pub fn starts(w: &'static str, at: usize, lit: &'static str) -> bool { + if ((at + lit.len()) > w.len()) { + return false; + } + return span_is(w, at, (at + lit.len()), lit); +} + diff --git a/specs/tools/tri/worktrees.t27 b/specs/tools/tri/worktrees.t27 index 9c1504cc52..1182c3fab1 100644 --- a/specs/tools/tri/worktrees.t27 +++ b/specs/tools/tri/worktrees.t27 @@ -2,7 +2,7 @@ ; specs/tools/tri/worktrees.t27 -- tool tri/worktrees, the `tri worktrees` command of the tri CLI ; Source of truth for the tool card on t27.ai (#/tools?tool=tri/worktrees). ; Extracted from the clap #[derive(Subcommand)] enums in cli/tri/src (witness source-parse: -; cargo was not run, the enum and its /// docs were read at commit 3cb7a4352492). +; cargo was not run, the enum and its /// docs were read at commit 3cb7a4352492; the --reap-plan arg was added from its /// doc in cli/tri/src/trees.rs with gHashTag/t27#10805). ; Layer 5 of the ladder Specs -> Skills -> Crons -> Agents -> Tools. ASCII only (L3). ; phi^2 + 1/phi^2 = 3 | TRINITY @@ -26,7 +26,7 @@ pub const ABOUT_SOURCE : str = "clap /// doc on Commands::Worktrees (cli/tri/src pub const ACTIONS : [0]str = []; pub const ACTIONS_ABOUT : [0]str = []; ; Top-level arguments/flags of the command itself, ": doc". -pub const ARGS : [2]str = ["--size: Also measure disk usage. Off by default: `du` over 120 checkouts is slow, and the holdings answer is the one that decides anything.", "--all: Print every tree, not only those holding work or unreadable."]; +pub const ARGS : [3]str = ["--size: Also measure disk usage. Off by default: `du` over 120 checkouts is slow, and the holdings answer is the one that decides anything.", "--all: Print every tree, not only those holding work or unreadable.", "--reap-plan: Print the `git worktree remove` line of every checkout that is REMOVABLE (rules: specs/tri/worktrees/reap.t27): its PR is merged or closed (asked of GitHub once per branch), it holds nothing but the untracked target link, and no live pid holds its lock. Still deletes nothing."]; ; Agent letters that own this command, only where a source line names it (AGENTS_NOTE cites it). pub const AGENTS : [2]str = ["T", "W"]; pub const AGENTS_NOTE : str = "docs/agents/AGENTS_ALPHABET.md \"Who holds a tool, and who holds a skill\" binds every tool to the Queen (T). Domain lead W: ABOUT is \"What the checkouts on this disk are holding. Deletes nothing\", and trees.rs explains \"a worktree is exactly where another session's uncommitted work lives ... 96 belong to one other session's scratchpad\" -- the per-session working state W's cell workflow runs in."; diff --git a/specs/tri/worktrees/reap.t27 b/specs/tri/worktrees/reap.t27 new file mode 100644 index 0000000000..93c7d1c64f --- /dev/null +++ b/specs/tri/worktrees/reap.t27 @@ -0,0 +1,393 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/tri/worktrees/reap.t27 -- which checkouts `tri worktrees --reap-plan` calls REMOVABLE, and the line that removes each +; A finished lane leaves its worktree behind: 300-650 MB each, and on the night of 2026-10-18 the bench +; Mac's disk filled twice. The self-cleanup skill (.claude/skills/self-cleanup/SKILL.md) removes the +; trees of merged lanes by hand, one rule at a time. This spec is that judgement as rules with tests +; (gHashTag/t27#10805), so `tri worktrees` states it and the session only runs what it prints. +; The command (cli/tri/src/trees.rs, through `t27c gen-rust` in gen/rust/tri/worktrees/reap.rs) is +; glue: it reads git (worktree list, status), asks `ps` whether a lock's pid lives, asks GitHub's REST +; API for the pull requests of a branch (one request per branch, and only for a tree that a merged +; pull request would make removable), calls the functions below and prints what they return. It never +; removes anything: it prints `git -C
worktree remove `, REMOVE_HEAD, +; the main checkout, REMOVE_MID, remove_flags(lock), the path. +; A checkout is judged by five facts, in this order, and the first that applies decides: +; main the first checkout of `git worktree list` is the clone itself: never removed. +; dirty `git status --porcelain` prints anything but the untracked `target` link (`only_target`), +; or git could not read the tree: it holds work, or it is unknown, and unknown is not clean. +; lock `locked ` of the porcelain. Claude's agent worktrees write `(pid N start ...)` in +; the reason; the glue asks `ps -p N`. A live pid, or a lock with no pid to check, keeps the +; tree. A dead pid is a lock a finished agent left: removable with `-f -f`. +; pr the pull requests whose head is the branch (`pr_state`): open keeps it, merged or closed +; lets it go. No pull request, a detached tree, an unreadable answer, a failed request: NONE, +; and NONE keeps it. A tree is never removable on missing data. +; path the printed line is typed into a terminal: a path with a character the shell reads +; (space, quote, `;`, `$`) is kept, not quoted by guesswork. +; The answer of GitHub is read as serde_json::to_string_pretty prints it: an array, a pull request +; ` {` ... ` }` at indent 2, its keys sorted at 4, the nested objects (head, user, milestone) deeper. +; Only the keys at indent 4 count: a milestone's own "state" is not the pull request's. The line +; readers are specs/tri/lab/receipt.t27's, imported, and the pull request walk is lanes.t27's. +; Fixtures are GitHub's answer for gHashTag/t27 PR 10808 (merged 2026-10-19), shortened; the nested +; milestone, the open and closed variants and the second pull request are made up to test the trap. +; ASCII only (L3) +; phi^2 + 1/phi^2 = 3 | TRINITY + +module TriWorktreeReap; + +use tri::lab::receipt::line_end; +use tri::lab::receipt::span_is; +use tri::lab::receipt::key_at; +use tri::lab::receipt::value_from; +use tri::lab::receipt::value_to; +use tri::lab::receipt::object_end; +use tri::lanes::lanes::next_pr; +use tri::lanes::lanes::starts; +use tri::lanes::lanes::digits; + +// The pull requests whose head is a branch of this repository; the glue appends the branch name. +pub const PULLS_URL : str = "https://api.github.com/repos/gHashTag/t27/pulls?state=all&per_page=10&head=gHashTag:"; +// The line to run, in pieces: REMOVE_HEAD, the main checkout, REMOVE_MID, the flags, a space, the path. +pub const REMOVE_HEAD : str = "git -C "; +pub const REMOVE_MID : str = " worktree remove "; + +// What GitHub says about a branch. NONE is also what a missing, failed or unreadable answer says. +pub const PR_NONE : u8 = 0; +pub const PR_OPEN : u8 = 1; +pub const PR_MERGED : u8 = 2; +pub const PR_CLOSED : u8 = 3; + +// What holds a tree's lock. +pub const LOCK_NONE : u8 = 0; +pub const LOCK_LIVE : u8 = 1; +pub const LOCK_DEAD : u8 = 2; +pub const LOCK_UNKNOWN : u8 = 3; + +// The verdict on one checkout. Only REMOVABLE gets a line; every other is a reason to keep it. +pub const V_REMOVABLE : u8 = 0; +pub const V_KEEP_MAIN : u8 = 1; +pub const V_KEEP_DIRTY : u8 = 2; +pub const V_KEEP_LOCKED : u8 = 3; +pub const V_KEEP_OPEN_PR : u8 = 4; +pub const V_KEEP_NO_PR : u8 = 5; +pub const V_KEEP_ODD_PATH : u8 = 6; +pub const VERDICTS : u8 = 7; + +invariant the_verdicts_are_numbered { assert V_REMOVABLE == 0 and V_KEEP_ODD_PATH == 6 and VERDICTS == 7 and PR_CLOSED == 3 and LOCK_UNKNOWN == 3; } + +/// Does `git status --porcelain` print nothing but the untracked `target` link (or directory)? +/// Empty is clean too. Every other entry, staged, modified, deleted or untracked, is work. +pub fn only_target(status: string) -> bool { + var k : usize = 0; + while (k < status.len) { + const end : usize = line_end(status, k); + if (end > k and span_is(status, k, end, "?? target") == false and span_is(status, k, end, "?? target/") == false) { return false; } + k = end + 1; + } + return true; +} + +/// The pid a lock's reason names: the digits after `(pid `, as Claude's agent worktrees write it. +/// 0 when the reason names none. +pub fn lock_pid(reason: string) -> u64 { + var k : usize = 0; + while (k < reason.len) { + if (starts(reason, k, "(pid ")) { + var e : usize = k + 5; + while (e < reason.len and reason[e] >= 48 and reason[e] <= 57) { + e = e + 1; + } + return digits(reason, k + 5, e); + } + k = k + 1; + } + return 0; +} + +/// Who holds the lock: nobody, a live pid, a dead one, or something that cannot be checked (a lock +/// with no pid in its reason). +pub fn lock_state(locked: bool, pid: u64, alive: bool) -> u8 { + if (locked == false) { return LOCK_NONE; } + if (pid == 0) { return LOCK_UNKNOWN; } + if (alive) { return LOCK_LIVE; } + return LOCK_DEAD; +} + +/// Is s safe to put in a URL and in a shell word as it stands: not empty, and only letters, digits +/// and / . _ -? A branch name or a path that is not plain is not asked about and not printed. +pub fn plain(s: string) -> bool { + if (s.len == 0) { return false; } + var i : usize = 0; + while (i < s.len) { + const c : u8 = s[i]; + const ok : bool = (c >= 97 and c <= 122) or (c >= 65 and c <= 90) or (c >= 48 and c <= 57) or c == 47 or c == 46 or c == 95 or c == 45; + if (ok == false) { return false; } + i = i + 1; + } + return true; +} + +/// What the answer of GitHub (the pull requests of one branch) says: OPEN when any is open, else +/// MERGED when any was merged (merged_at is a time, not null), else CLOSED when any is closed, else +/// NONE (no pull request, a state that is neither word, an error body, no text). +pub fn pr_state(body: string) -> u8 { + var seen : u8 = PR_NONE; + var o : usize = next_pr(body, 0); + while (o < body.len) { + const e : usize = object_end(body, o); + const s : usize = key_at(body, o, e, 4, "state"); + const m : usize = key_at(body, o, e, 4, "merged_at"); + if (span_is(body, value_from(body, s), value_to(body, s), "open")) { return PR_OPEN; } + if (span_is(body, value_from(body, s), value_to(body, s), "closed")) { + if (m < body.len and body[m] == 34) { + seen = PR_MERGED; + } else { + if (seen == PR_NONE) { seen = PR_CLOSED; } + } + } + o = next_pr(body, e); + } + return seen; +} + +/// The verdict on one checkout from its facts: is it the main checkout, does it hold work (or could +/// not be read), who holds its lock, what GitHub says of its branch, is its path plain. The first +/// fact that keeps it decides. A lock or a pull request state this code does not know keeps it too. +pub fn verdict(main: bool, dirty: bool, lock: u8, pr: u8, path_ok: bool) -> u8 { + if (main) { return V_KEEP_MAIN; } + if (dirty) { return V_KEEP_DIRTY; } + if (lock != LOCK_NONE and lock != LOCK_DEAD) { return V_KEEP_LOCKED; } + if (pr == PR_OPEN) { return V_KEEP_OPEN_PR; } + if (pr != PR_MERGED and pr != PR_CLOSED) { return V_KEEP_NO_PR; } + if (path_ok == false) { return V_KEEP_ODD_PATH; } + return V_REMOVABLE; +} + +/// The flags of `git worktree remove`: -f for the untracked target link, and -f -f for a tree whose +/// lock a dead process left. +pub fn remove_flags(lock: u8) -> string { + if (lock == LOCK_DEAD) { return "-f -f"; } + return "-f"; +} + +pub fn verdict_name(v: u8) -> string { + if (v == V_REMOVABLE) { return "REMOVABLE"; } + if (v == V_KEEP_MAIN) { return "KEEP_MAIN"; } + if (v == V_KEEP_DIRTY) { return "KEEP_DIRTY"; } + if (v == V_KEEP_LOCKED) { return "KEEP_LOCKED"; } + if (v == V_KEEP_OPEN_PR) { return "KEEP_OPEN_PR"; } + if (v == V_KEEP_NO_PR) { return "KEEP_NO_PR"; } + return "KEEP_ODD_PATH"; +} + +pub fn why(v: u8) -> string { + if (v == V_REMOVABLE) { return "its PR is merged or closed, it holds nothing but the target link, no live process holds its lock"; } + if (v == V_KEEP_MAIN) { return "the main checkout is never removed"; } + if (v == V_KEEP_DIRTY) { return "it holds work, or git could not read it"; } + if (v == V_KEEP_LOCKED) { return "a live process, or a lock with no pid to check, holds it"; } + if (v == V_KEEP_OPEN_PR) { return "its pull request is still open"; } + if (v == V_KEEP_NO_PR) { return "no merged or closed pull request was found for its branch"; } + return "its path has a character the printed line would need quoted for"; +} + +const AGENT_LOCK : string = "claude agent agent-a1a8e2b61031e3d98 (pid 5082 start Sun Oct 18 15:42:53 2026)"; + +const MERGED : string = "[\n {\n \"head\": {\n \"label\": \"gHashTag:claude/self-cleanup-skill-10804\",\n \"ref\": \"claude/self-cleanup-skill-10804\"\n },\n \"merged_at\": \"2026-10-19T03:33:38Z\",\n \"milestone\": {\n \"state\": \"open\"\n },\n \"number\": 10808,\n \"state\": \"closed\",\n \"title\": \"skills: self-cleanup\"\n }\n]"; + +const CLOSED : string = "[\n {\n \"head\": {\n \"ref\": \"claude/dropped\"\n },\n \"merged_at\": null,\n \"number\": 10790,\n \"state\": \"closed\"\n }\n]"; + +const OPEN : string = "[\n {\n \"body\": \"mentions \\\"state\\\": \\\"open\\\" in prose\",\n \"head\": {\n \"ref\": \"claude/open-one\"\n },\n \"merged_at\": null,\n \"number\": 10811,\n \"state\": \"open\"\n }\n]"; + +const CLOSED_THEN_OPEN : string = "[\n {\n \"merged_at\": null,\n \"number\": 1,\n \"state\": \"closed\"\n },\n {\n \"merged_at\": null,\n \"number\": 2,\n \"state\": \"open\"\n }\n]"; + +const OPEN_THEN_MERGED : string = "[\n {\n \"merged_at\": null,\n \"number\": 3,\n \"state\": \"open\"\n },\n {\n \"merged_at\": \"2026-10-01T00:00:00Z\",\n \"number\": 4,\n \"state\": \"closed\"\n }\n]"; + +const CLOSED_THEN_MERGED : string = "[\n {\n \"merged_at\": null,\n \"number\": 5,\n \"state\": \"closed\"\n },\n {\n \"merged_at\": \"2026-10-01T00:00:00Z\",\n \"number\": 6,\n \"state\": \"closed\"\n }\n]"; + +// Scenario: a tree holding nothing but the untracked target link is clean; anything else is work. +test only_the_target_link_is_clean { + assert only_target("") == true; + assert only_target("?? target") == true; + assert only_target("?? target/") == true; + assert only_target("?? target\n") == true; + assert only_target("?? target\n\n") == true; + assert only_target(" M cli/tri/src/trees.rs") == false; + assert only_target("?? target\n M a.rs") == false; + assert only_target(" M a.rs\n?? target") == false; + assert only_target("A new.t27") == false; + assert only_target(" D gone.rs") == false; + assert only_target("?? notes.md") == false; + assert only_target("?? targets") == false; + assert only_target("?? target2/") == false; + assert only_target("?? src/target") == false; + assert only_target("?? target/x") == false; + assert only_target("?? target ") == false; +} + +// Scenario: Claude's agent worktrees name the pid of their session in the lock's reason. +test a_lock_names_its_pid { + assert lock_pid(AGENT_LOCK) == 5082; + assert lock_pid("(pid 7)") == 7; + assert lock_pid("(pid 123456") == 123456; + assert lock_pid("") == 0; + assert lock_pid("locked by hand") == 0; + assert lock_pid("pid 5082") == 0; + assert lock_pid("(pid )") == 0; + assert lock_pid("(pid x5)") == 0; + assert lock_pid("(pid 99999999999999999999999)") == 0; + assert lock_pid("agent (pid") == 0; +} + +// Scenario: nobody, a live pid, a dead pid, and a lock that cannot be checked. +test a_lock_is_held_by_nobody_a_live_pid_a_dead_one_or_an_unknown { + assert lock_state(false, 0, false) == LOCK_NONE; + assert lock_state(false, 5082, true) == LOCK_NONE; + assert lock_state(true, 5082, true) == LOCK_LIVE; + assert lock_state(true, 5082, false) == LOCK_DEAD; + assert lock_state(true, 0, true) == LOCK_UNKNOWN; + assert lock_state(true, 0, false) == LOCK_UNKNOWN; +} + +// Scenario: only a plain name is asked about and printed. +test a_branch_and_a_path_are_plain_or_not { + assert plain("claude/tri-worktrees-reap-10805") == true; + assert plain("worktree-agent-a5476fea5f7b1db56") == true; + assert plain("/Users/playom/t27/.claude/worktrees/agent-a1a8e2b61031e3d98") == true; + assert plain("/private/tmp/claude-501/-Users-playom/04183dc5-60f9/scratchpad/epic/wt_4a") == true; + assert plain("") == false; + assert plain("/Users/play om/t27") == false; + assert plain("/tmp/a;rm") == false; + assert plain("/tmp/a$b") == false; + assert plain("/tmp/a'b") == false; + assert plain("feature#1") == false; + assert plain("a+b") == false; + assert plain("a&b") == false; +} + +// Scenario: the pull requests of a branch, as GitHub answers. +test the_pull_requests_of_a_branch_are_read { + assert pr_state(MERGED) == PR_MERGED; + assert pr_state(CLOSED) == PR_CLOSED; + assert pr_state(OPEN) == PR_OPEN; + assert pr_state(CLOSED_THEN_OPEN) == PR_OPEN; + assert pr_state(OPEN_THEN_MERGED) == PR_OPEN; + assert pr_state(CLOSED_THEN_MERGED) == PR_MERGED; +} + +// Scenario: no answer is NONE, never a pull request: an empty list, nothing, an error body (rate limit, +// bad credentials), a pull request with no state, a state that is neither word, one written at the +// wrong indent. +test missing_or_unreadable_data_is_none { + assert pr_state("[]") == PR_NONE; + assert pr_state("") == PR_NONE; + assert pr_state("{\n \"message\": \"Bad credentials\",\n \"status\": \"401\"\n}") == PR_NONE; + assert pr_state("[\n {\n \"number\": 9\n }\n]") == PR_NONE; + assert pr_state("[\n {\n \"merged_at\": null,\n \"state\": \"draft\"\n }\n]") == PR_NONE; + assert pr_state("[\n {\n \"number\": 9,\n \"state\": \"closed\"\n }\n]") == PR_NONE; + assert pr_state("[\n {\n \"number\": 9,\n \"state\": \"OPEN\"\n }\n]") == PR_NONE; +} + +// Scenario: the nested objects do not speak for the pull request: the milestone of the merged one says +// "open" and the body of the open one quotes "state": "closed" in prose. +test a_nested_state_is_not_the_pull_requests { + assert pr_state(MERGED) != PR_OPEN; + const prose : string = "[\n {\n \"body\": \"fixed; \\\"state\\\": \\\"closed\\\"\",\n \"merged_at\": null,\n \"state\": \"open\"\n }\n]"; + assert pr_state(prose) == PR_OPEN; + const nested_only : string = "[\n {\n \"milestone\": {\n \"state\": \"closed\"\n },\n \"number\": 5\n }\n]"; + assert pr_state(nested_only) == PR_NONE; +} + +// Scenario: each verdict, with the facts that give it. +test each_verdict_has_its_case { + assert verdict(false, false, LOCK_NONE, PR_MERGED, true) == V_REMOVABLE; + assert verdict(false, false, LOCK_NONE, PR_CLOSED, true) == V_REMOVABLE; + assert verdict(false, false, LOCK_DEAD, PR_MERGED, true) == V_REMOVABLE; + assert verdict(true, false, LOCK_NONE, PR_MERGED, true) == V_KEEP_MAIN; + assert verdict(false, true, LOCK_NONE, PR_MERGED, true) == V_KEEP_DIRTY; + assert verdict(false, false, LOCK_LIVE, PR_MERGED, true) == V_KEEP_LOCKED; + assert verdict(false, false, LOCK_UNKNOWN, PR_MERGED, true) == V_KEEP_LOCKED; + assert verdict(false, false, LOCK_NONE, PR_OPEN, true) == V_KEEP_OPEN_PR; + assert verdict(false, false, LOCK_NONE, PR_NONE, true) == V_KEEP_NO_PR; + assert verdict(false, false, LOCK_NONE, PR_MERGED, false) == V_KEEP_ODD_PATH; +} + +// Scenario: the first fact that keeps a tree decides, so the reason shown is the gravest one. +test the_first_keeping_fact_decides { + assert verdict(true, true, LOCK_LIVE, PR_OPEN, false) == V_KEEP_MAIN; + assert verdict(false, true, LOCK_LIVE, PR_OPEN, false) == V_KEEP_DIRTY; + assert verdict(false, false, LOCK_LIVE, PR_OPEN, false) == V_KEEP_LOCKED; + assert verdict(false, false, LOCK_NONE, PR_OPEN, false) == V_KEEP_OPEN_PR; + assert verdict(false, false, LOCK_NONE, PR_NONE, false) == V_KEEP_NO_PR; + assert verdict(false, false, LOCK_DEAD, PR_OPEN, true) == V_KEEP_OPEN_PR; +} + +// Scenario (negative control): a merged branch whose tree holds one stray file is kept, and a clean tree +// with no PR data is kept. The same facts with the stray file gone and the answer read are removable. +test a_tree_is_never_removable_on_missing_data { + assert verdict(false, only_target(" M a.rs") == false, LOCK_NONE, PR_MERGED, true) == V_KEEP_DIRTY; + assert verdict(false, only_target("?? target") == false, LOCK_NONE, pr_state(""), true) == V_KEEP_NO_PR; + assert verdict(false, only_target("?? target") == false, LOCK_NONE, pr_state("{\n \"message\": \"API rate limit exceeded\"\n}"), true) == V_KEEP_NO_PR; + assert verdict(false, only_target("?? target") == false, LOCK_NONE, pr_state(MERGED), true) == V_REMOVABLE; + assert verdict(false, only_target("?? target") == false, lock_state(true, lock_pid(AGENT_LOCK), true), pr_state(MERGED), true) == V_KEEP_LOCKED; + assert verdict(false, only_target("?? target") == false, lock_state(true, lock_pid(AGENT_LOCK), false), pr_state(MERGED), true) == V_REMOVABLE; + assert verdict(false, only_target("?? target") == false, lock_state(true, lock_pid("locked by hand"), false), pr_state(MERGED), true) == V_KEEP_LOCKED; +} + +// Scenario: over every combination of facts, including a lock state and a PR state nobody defined, a +// tree is REMOVABLE exactly when it is not main, not dirty, locked by nobody or a dead pid, its PR +// merged or closed, and its path plain. +test removable_exactly_when_every_fact_allows { + var m : u8 = 0; + while (m < 2) { + var d : u8 = 0; + while (d < 2) { + var l : u8 = 0; + while (l < 6) { + var p : u8 = 0; + while (p < 6) { + var o : u8 = 0; + while (o < 2) { + const v : u8 = verdict(m == 1, d == 1, l, p, o == 1); + const allowed : bool = m == 0 and d == 0 and (l == LOCK_NONE or l == LOCK_DEAD) and (p == PR_MERGED or p == PR_CLOSED) and o == 1; + if (allowed) { + assert v == V_REMOVABLE; + } else { + assert v != V_REMOVABLE; + assert v < VERDICTS; + } + o = o + 1; + } + p = p + 1; + } + l = l + 1; + } + d = d + 1; + } + m = m + 1; + } +} + +// Scenario: the line to run. A dead pid's lock needs -f twice; every other tree, once. +test the_line_that_removes_a_tree { + assert span_is(REMOVE_HEAD, 0, REMOVE_HEAD.len, "git -C "); + assert span_is(REMOVE_MID, 0, REMOVE_MID.len, " worktree remove "); + assert span_is(remove_flags(LOCK_NONE), 0, 2, "-f"); + assert remove_flags(LOCK_NONE).len == 2; + assert remove_flags(LOCK_DEAD).len == 5; + assert span_is(remove_flags(LOCK_DEAD), 0, 5, "-f -f"); + assert remove_flags(LOCK_LIVE).len == 2; + assert starts(PULLS_URL, 0, "https://api.github.com/repos/gHashTag/t27/pulls?"); + assert PULLS_URL[PULLS_URL.len - 1] == 58; +} + +// Scenario: every verdict has its name and its reason. +test the_words { + assert span_is(verdict_name(V_REMOVABLE), 0, 9, "REMOVABLE"); + assert span_is(verdict_name(V_KEEP_MAIN), 0, 9, "KEEP_MAIN"); + assert span_is(verdict_name(V_KEEP_DIRTY), 0, 10, "KEEP_DIRTY"); + assert span_is(verdict_name(V_KEEP_LOCKED), 0, 11, "KEEP_LOCKED"); + assert span_is(verdict_name(V_KEEP_OPEN_PR), 0, 12, "KEEP_OPEN_PR"); + assert span_is(verdict_name(V_KEEP_NO_PR), 0, 10, "KEEP_NO_PR"); + assert span_is(verdict_name(V_KEEP_ODD_PATH), 0, 13, "KEEP_ODD_PATH"); + assert why(V_REMOVABLE)[0] == 105 and why(V_KEEP_MAIN)[4] == 109 and why(V_KEEP_DIRTY)[0] == 105; + assert why(V_KEEP_LOCKED)[0] == 97 and why(V_KEEP_OPEN_PR)[0] == 105 and why(V_KEEP_NO_PR)[0] == 110; + assert why(V_KEEP_ODD_PATH)[0] == 105; +}