Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .trinity/seals/tri_tool_tri_worktrees.json
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
{
"built_by": "t27c-bootstrap@0.5.2+ea35d9b72",
"built_by": "t27c-bootstrap@0.5.2+0fba674ef",
"closure": [],
"config": "gen=c,rust,verilog,zig;tests=zig",
"gen_hash_c": "sha256:3730bc66d153d4e749ac0760d26c72e9dbd4ea8f5e0660f3efcecdc713c67869",
"gen_hash_rust": "sha256:5e6c679216c3e25f1065236b2dbdad7f610a520d04a8930ae59e697feeb6cb31",
"gen_hash_verilog": "sha256:cc44189cebda1719fc034c942a6fad895f65515b1dee20a33f219b8d338c1675",
"gen_hash_zig": "sha256:2bf37ef5bc6ec7f82534dd30e20dfc06d4001fffc022c1e5ad71dd4cb0737803",
"gen_hash_c": "sha256:40dcc9b0a2438bf3bba47c959a7cdf4202738509fa37ce7fefb385fe20eef0be",
"gen_hash_rust": "sha256:0a4f5618702ce154c4594a26c97d9108806001bf5408d1c0cf2b3bfcedcf561d",
"gen_hash_verilog": "sha256:1f5bad229733be1569904c6440c5a3757ac3e0dffcb7dcd0e327a173f985ef15",
"gen_hash_zig": "sha256:e733373b45fdf78eacaf573e888085c07a712beaff59f463e0f7f726a29f58f7",
"module": "tool_tri_worktrees",
"ring": 12,
"seal_schema": 2,
"sealed_at": "2026-10-19T03:52:00Z",
"sealed_at": "2026-10-19T16:52:32Z",
"sealed_by": "t27c-bootstrap@0.5.2",
"spec_hash": "sha256:a1192fdda1ff3eadeb5de28ae9d662ee7505545c70e62ca1d2d24d1d32b6d1cf",
"spec_hash": "sha256:cf6ed19b9fb1becd56aed48c55fa05a974b8de1a8aa5daa072cd3e1058d6ba53",
"spec_path": "specs/tools/tri/worktrees.t27",
"tests": {
"failed": 0,
Expand Down
18 changes: 9 additions & 9 deletions .trinity/seals/worktrees_TriWorktreeReap.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"built_by": "t27c-bootstrap@0.5.2+ae19b93cd",
"built_by": "t27c-bootstrap@0.5.2+0fba674ef",
"closure": [
{
"spec": "specs/tri/lab/receipt.t27",
Expand All @@ -11,23 +11,23 @@
}
],
"config": "gen=c,rust,verilog,zig;tests=zig",
"gen_hash_c": "sha256:ef394961ea6804088b9a81da13baebb8f1cbfe6e1187e62e3f7122b17c234c4c",
"gen_hash_rust": "sha256:a2b4fae679bfa41d9985a8c72810452a1748b7a490f224082d1d07b617ba9bd5",
"gen_hash_verilog": "sha256:716b12e948192e930b1e51c9f46cf0a674355d58e9e87d00137b27cbdadab708",
"gen_hash_zig": "sha256:8096bea0fc9750a5617560324c12c28001d6204e2371f65177005bb33612153d",
"gen_hash_c": "sha256:75a06970b662574386d285cb20ca8f8b984f5fe4e7af197c2f5c89cec9aec6f5",
"gen_hash_rust": "sha256:0da0f61c8c7296e0ccbddc33b84b849416b1a69914be8183a497d8e62170c57f",
"gen_hash_verilog": "sha256:3d1d336fe1db38c17cac57ad121b77c6f3c95f767df55a08930677d08ab1d3e8",
"gen_hash_zig": "sha256:d645b923f86a3b8402558a82399ea7ac575be30653c9a39640c1fa5aca2d998c",
"module": "TriWorktreeReap",
"ring": 12,
"seal_schema": 2,
"sealed_at": "2026-10-19T06:24:35Z",
"sealed_at": "2026-10-19T16:52:26Z",
"sealed_by": "t27c-bootstrap@0.5.2",
"spec_hash": "sha256:e6181a331c212c15d5bd38a26ab84a2d0f8f5885e7fcf2ae686063d219311016",
"spec_hash": "sha256:1b0ccf0834b3f4133bf5185b3576149da39286b54bad1320318f991b70c05f41",
"spec_path": "specs/tri/worktrees/reap.t27",
"tests": {
"failed": 0,
"failing": [],
"forced": false,
"passed": 13,
"total": 13,
"passed": 18,
"total": 18,
"vacuous": 0
},
"toolchain": {
Expand Down
84 changes: 25 additions & 59 deletions cli/tri/src/trees.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,21 +150,6 @@ pub fn parse_porcelain(text: &str) -> Vec<Tree> {
out
}

/// The session a scratchpad path belongs to, if it is under one.
///
/// Scratch worktrees live under `.../claude-501/<project>/<session-uuid>/...`. Grouping by
/// that segment is what turns "120 trees" into "96 of them are somebody else's".
pub fn session_of(path: &str) -> Option<&str> {
let mut parts = path.split('/');
while let Some(p) = parts.next() {
if p.starts_with("claude-") {
parts.next()?; // the project segment
return parts.next().filter(|s| s.len() >= 32);
}
}
None
}

/// Kilobytes to gibibytes, for printing only.
pub fn gib(kb: u64) -> f64 {
kb as f64 / 1_048_576.0
Expand Down Expand Up @@ -241,6 +226,19 @@ fn du_kb(path: &str) -> Option<u64> {
.ok()
}

/// The newest commit of a tree as `git log -1 --format=%ct%x09%s` prints it, and its age in minutes (reap.t27): what a reader has to go on to tell whose work a tree is. A path says nothing of it, and the `mine` label that guessed from the path is gone.
fn newest(path: &str) -> (&'static str, u64) {
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
let log: &'static str = git_in(path, &["log", "-1", "--format=%ct%x09%s"]).unwrap_or_default().leak();
(log, reap::age_of(now, log))
}

/// That evidence as one phrase: the age, and the first 60 characters of the subject, quoted.
fn said((log, age): (&'static str, u64)) -> String {
let when = if age == reap::AGE_UNKNOWN { "time unreadable".to_string() } else { format!("{}{} ago", reap::age_count(age), reap::age_unit(age)) };
format!("newest commit {when}: {:?}", log[reap::subject_from(log)..].chars().take(60).collect::<String>())
}

#[derive(Args)]
pub struct Worktrees {
/// Also measure disk usage. Off by default: `du` over 120 checkouts is slow, and the
Expand All @@ -250,7 +248,7 @@ 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.
/// 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, no live pid holds its lock, and neither its newest commit nor a file in it is younger than 60 minutes (such a tree is KEEP_ACTIVE and is printed too). Each row shows the unpushed commits and the newest commit's age and subject. Still deletes nothing.
#[arg(long)]
reap_plan: bool,
}
Expand All @@ -267,16 +265,19 @@ fn reap_plan(trees: &[Tree], all: bool) {
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);
let last = newest(path);
let judge = |pr, busy| reap::verdict(i == 0, dirty, lock, pr, reap::plain(path), busy);
let mut v = judge(reap::PR_MERGED, false);
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::<serde_json::Value>(pulls).ok().and_then(|j| serde_json::to_string_pretty(&j).ok()).unwrap_or_default().leak()));
let touched = Command::new("find").args([path, "-path", &format!("{path}/target"), "-prune", "-o", "-type", "f", "-mmin", &format!("-{}", reap::ACTIVE_MINUTES), "-print", "-quit"]).output().map_or(true, |o| !o.stdout.is_empty());
v = judge(reap::pr_state(serde_json::from_str::<serde_json::Value>(pulls).ok().and_then(|j| serde_json::to_string_pretty(&j).ok()).unwrap_or_default().leak()), reap::active(last.1, touched));
}
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 || v == reap::V_KEEP_ACTIVE || all {
let ahead = match holding_of(t) { Holding::Work { commits, .. } => commits.to_string(), Holding::Clean { .. } => "0".into(), Holding::Unknown(_) => "?".into() };
println!(" {:14} {:28.28} {branch:34.34} {ahead} unpushed, {} {}", reap::verdict_name(v), path.rsplit('/').next().unwrap_or(path), said(last), reap::why(v));
}
if v == reap::V_REMOVABLE {
println!(" {}{main}{}{} {path}", reap::REMOVE_HEAD, reap::REMOVE_MID, reap::remove_flags(lock));
Expand All @@ -295,9 +296,6 @@ pub fn run(a: &Worktrees) -> Result<()> {
String::from_utf8_lossy(&out.stdout).to_string()
};
let trees = parse_porcelain(&porcelain);
let here = std::env::current_dir()
.ok()
.and_then(|p| session_of(&p.to_string_lossy()).map(str::to_string));

let mut work = 0usize;
let mut unknown = 0usize;
Expand All @@ -318,7 +316,6 @@ pub fn run(a: &Worktrees) -> Result<()> {
Holding::Unknown(_) => unknown += 1,
Holding::Work { .. } => work += 1,
}
let mine = session_of(&t.path) == here.as_deref();
let show = a.all || h.holds_work();
let (tk, ok) = if a.size {
let target = du_kb(&format!("{}/target", t.path)).unwrap_or(0);
Expand Down Expand Up @@ -366,15 +363,15 @@ pub fn run(a: &Worktrees) -> Result<()> {
}
Holding::Unknown(r) => format!("UNREADABLE -- {r}"),
};
let tag = if mine { "mine " } else { "other" };
let seen = said(newest(&t.path));
if a.size {
println!(
" {tag} {name:28.28} {branch:34.34} {state} [{:.1} GiB regenerable, {:.1} GiB not]",
" {name:28.28} {branch:34.34} {state} [{:.1} GiB regenerable, {:.1} GiB not] {seen}",
gib(tk),
gib(ok)
);
} else {
println!(" {tag} {name:28.28} {branch:34.34} {state}");
println!(" {name:28.28} {branch:34.34} {state} {seen}");
}
}

Expand Down Expand Up @@ -535,37 +532,6 @@ mod holding_tests {
}
}

#[cfg(test)]
mod session_tests {
use super::*;

#[test]
fn a_scratch_path_names_its_session() {
let p = "/private/tmp/claude-501/-Users-x-PROJECTS/9ba6bf38-825b-45d0-a349-e87b252815cd/wt-shell";
assert_eq!(session_of(p), Some("9ba6bf38-825b-45d0-a349-e87b252815cd"));
}

#[test]
fn a_path_outside_a_scratchpad_names_none() {
assert_eq!(session_of("/Users/x/Desktop/PROJECTS/CLAUDE/t27"), None);
assert_eq!(session_of("/private/tmp/t27-brkstack"), None);
}

#[test]
fn a_short_segment_is_not_a_session_id() {
// Guards against reading the next path component as an id whatever it is.
assert_eq!(session_of("/private/tmp/claude-501/proj/short/wt"), None);
}

#[test]
fn two_paths_in_one_session_group_together() {
let a = "/private/tmp/claude-501/p/6d5ee66e-7a77-440d-bb58-d33bbe1e1558/scratchpad/CP";
let b = "/private/tmp/claude-501/p/6d5ee66e-7a77-440d-bb58-d33bbe1e1558/scratchpad/hunt";
assert_eq!(session_of(a), session_of(b));
assert!(session_of(a).is_some());
}
}

#[cfg(test)]
mod gib_tests {
use super::*;
Expand Down
84 changes: 80 additions & 4 deletions gen/rust/tri/worktrees/reap.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// 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.
// NOT LOWERED BY THIS BACKEND: 18 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.

Expand Down Expand Up @@ -40,7 +40,11 @@ pub const V_KEEP_NO_PR: u8 = 5;

pub const V_KEEP_ODD_PATH: u8 = 6;

pub const VERDICTS: u8 = 7;
pub const V_KEEP_ACTIVE: u8 = 7;

pub const VERDICTS: u8 = 8;

pub const ACTIVE_MINUTES: u64 = 60;

pub fn only_target(status: &'static str) -> bool {
let mut k: usize = 0;
Expand Down Expand Up @@ -122,7 +126,57 @@ pub fn pr_state(body: &'static str) -> u8 {
return seen;
}

pub fn verdict(main: bool, dirty: bool, lock: u8, pr: u8, path_ok: bool) -> u8 {
pub fn log_time(log: &'static str) -> u64 {
let mut e: usize = 0;
while (((e < log.len()) && (log.as_bytes()[(e) as usize] >= 48)) && (log.as_bytes()[(e) as usize] <= 57)) {
e = (e + 1);
}
if ((e == 0) || ((e < log.len()) && (log.as_bytes()[(e) as usize] != 9))) {
return 0;
}
return digits(log, 0, e);
}

pub fn subject_from(log: &'static str) -> usize {
let mut k: usize = 0;
while ((k < log.len()) && (log.as_bytes()[(k) as usize] != 9)) {
k = (k + 1);
}
if (k < log.len()) {
return (k + 1);
}
return k;
}

pub fn age_of(now: u64, log: &'static str) -> u64 {
return age_minutes(now, log_time(log));
}

pub fn active(age: u64, touched: bool) -> bool {
return ((touched || (age == AGE_UNKNOWN)) || (age < ACTIVE_MINUTES));
}

pub fn age_count(age: u64) -> u64 {
if (age < 60) {
return age;
}
if (age < 1440) {
return (age / 60);
}
return (age / 1440);
}

pub fn age_unit(age: u64) -> &'static str {
if (age < 60) {
return "m";
}
if (age < 1440) {
return "h";
}
return "d";
}

pub fn verdict(main: bool, dirty: bool, lock: u8, pr: u8, path_ok: bool, busy: bool) -> u8 {
if main {
return V_KEEP_MAIN;
}
Expand All @@ -138,6 +192,9 @@ pub fn verdict(main: bool, dirty: bool, lock: u8, pr: u8, path_ok: bool) -> u8 {
if ((pr != PR_MERGED) && (pr != PR_CLOSED)) {
return V_KEEP_NO_PR;
}
if busy {
return V_KEEP_ACTIVE;
}
if (path_ok == false) {
return V_KEEP_ODD_PATH;
}
Expand Down Expand Up @@ -170,12 +227,15 @@ pub fn verdict_name(v: u8) -> &'static str {
if (v == V_KEEP_NO_PR) {
return "KEEP_NO_PR";
}
if (v == V_KEEP_ACTIVE) {
return "KEEP_ACTIVE";
}
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";
return "its PR is merged or closed, it holds nothing but the target link, no live process holds its lock, nothing in it is recent";
}
if (v == V_KEEP_MAIN) {
return "the main checkout is never removed";
Expand All @@ -192,6 +252,9 @@ pub fn why(v: u8) -> &'static str {
if (v == V_KEEP_NO_PR) {
return "no merged or closed pull request was found for its branch";
}
if (v == V_KEEP_ACTIVE) {
return "its newest commit or one of its files is younger than the active window: someone may be working in it";
}
return "its path has a character the printed line would need quoted for";
}

Expand All @@ -209,6 +272,10 @@ pub const OPEN_THEN_MERGED: &'static str = "[\n {\n \"merged_at\": null,\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 const LOG: &'static str = "1760000000\twidgets: the newest commit";

pub const NOW: u64 = 1760000011;

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())) {
Expand Down Expand Up @@ -325,6 +392,15 @@ pub fn value_to(text: &'static str, v: usize) -> usize {
return e;
}

pub const AGE_UNKNOWN: u64 = 4294967295;

pub fn age_minutes(now: u64, then: u64) -> u64 {
if ((then == 0) || (then > now)) {
return AGE_UNKNOWN;
}
return ((now - then) / 60);
}

pub fn branch_from(w: &'static str, at: usize) -> usize {
let stop: usize = record_end(w, at);
let mut k: usize = at;
Expand Down
4 changes: 2 additions & 2 deletions specs/tools/tri/worktrees.t27
Original file line number Diff line number Diff line change
Expand Up @@ -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; the --reap-plan arg was added from its /// doc in cli/tri/src/trees.rs with gHashTag/t27#10805).
; 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 and its doc re-read with gHashTag/t27#11081).
; Layer 5 of the ladder Specs -> Skills -> Crons -> Agents -> Tools. ASCII only (L3).
; phi^2 + 1/phi^2 = 3 | TRINITY

Expand All @@ -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, "<name>: doc".
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."];
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, no live pid holds its lock, and neither its newest commit nor a file in it is younger than 60 minutes (such a tree is KEEP_ACTIVE and is printed too). Each row shows the unpushed commits and the newest commit's age and subject. 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.";
Expand Down
Loading
Loading