From b046da126cd3748ed2cafb0445bdefd14fc29281 Mon Sep 17 00:00:00 2001 From: dietrich ayala Date: Tue, 18 Aug 2026 22:01:04 +0200 Subject: [PATCH] Replace improvised worktree probing in /track and /tracks with a status script Add bin/tracks-status.sh, a read-only bash+git script that reports repo location, the tracks.json declaration with defaults applied, base-branch existence, primary checkout cleanliness, and one block per worktree (branch, ahead/behind, clean/dirty, handoff presence, last commit time). track.md, tracks.md, and land.md now call it instead of describing a multi-command probe for a model to reinvent each run. --- .gitignore | 1 + bin/tracks-status.sh | 210 +++++++++++++++++++++++++++++++++++++++++++ commands/land.md | 21 +++-- commands/track.md | 22 +++-- commands/tracks.md | 14 ++- 5 files changed, 243 insertions(+), 25 deletions(-) create mode 100755 bin/tracks-status.sh diff --git a/.gitignore b/.gitignore index ef70aea..624ce9f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ !/statusline.sh !/agents/ +!/bin/ !/commands/ !/docs/ !/githooks/ diff --git a/bin/tracks-status.sh b/bin/tracks-status.sh new file mode 100755 index 0000000..58e1b7d --- /dev/null +++ b/bin/tracks-status.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# Read-only status report for /track and /tracks. Plain bash and git only, no +# dependencies. Never writes anything. Run with no arguments from anywhere +# inside a git repo; exits non-zero with a message on stderr if run outside +# one. +set -u + +fail() { + echo "tracks-status: $1" >&2 + exit 1 +} + +git rev-parse --is-inside-work-tree >/dev/null 2>&1 || fail "not inside a git repository" + +toplevel=$(git rev-parse --show-toplevel) +git_dir=$(git rev-parse --path-format=absolute --git-dir) +common_dir=$(git rev-parse --path-format=absolute --git-common-dir) +primary_root=$(dirname "$common_dir") + +if [ "$git_dir" = "$common_dir" ]; then + location="primary" + repo_root="$toplevel" +else + location="worktree" + track_name=$(basename "$toplevel") + repo_root="$primary_root" +fi + +echo "repo: $repo_root" +if [ "$location" = "primary" ]; then + echo "location: primary" +else + echo "location: worktree $track_name" +fi +echo + +# --- tracks.json, defaults applied --------------------------------------- + +tracks_json="$repo_root/tracks.json" + +json_get_string() { + # $1: raw json text, $2: key. Prints the string value of the first + # top-level "key": "value" match, or nothing if absent. + printf '%s' "$1" | grep -oE "\"$2\"[[:space:]]*:[[:space:]]*\"([^\"\\\\]|\\\\.)*\"" | head -n1 | + sed -E "s/^\"$2\"[[:space:]]*:[[:space:]]*\"//; s/\"\$//" +} + +json_get_namespace_block() { + # $1: raw json text. Prints the raw contents between the braces of the + # top-level "namespace" object, or nothing if absent. + printf '%s' "$1" | awk ' + { text = text $0 "\n" } + END { + idx = index(text, "\"namespace\"") + if (idx == 0) { exit } + rest = substr(text, idx) + bidx = index(rest, "{") + if (bidx == 0) { exit } + rest = substr(rest, bidx + 1) + depth = 1 + out = "" + for (i = 1; i <= length(rest); i++) { + c = substr(rest, i, 1) + if (c == "{") depth++ + if (c == "}") { depth--; if (depth == 0) break } + out = out c + } + print out + } + ' +} + +if [ -f "$tracks_json" ]; then + echo "tracks.json: present" + raw=$(cat "$tracks_json") +else + echo "tracks.json: absent (defaults used)" + raw="" +fi + +worktrees_dir=$(json_get_string "$raw" "worktrees") +[ -n "$worktrees_dir" ] || worktrees_dir=".worktrees" +base=$(json_get_string "$raw" "base") +[ -n "$base" ] || base="main" +install=$(json_get_string "$raw" "install") +verify=$(json_get_string "$raw" "verify") + +base_found=1 +git -C "$repo_root" rev-parse --verify "$base" >/dev/null 2>&1 || base_found=0 + +echo "worktrees_dir: $worktrees_dir" +if [ "$base_found" = "1" ]; then + echo "base: $base" +else + echo "base: $base (not found)" +fi +echo "install: ${install:-(none)}" +echo "verify: ${verify:-(none)}" + +ns_block=$(json_get_namespace_block "$raw") +ns_pairs=$(printf '%s' "$ns_block" | grep -oE '"[^"]+"[[:space:]]*:[[:space:]]*"([^"\\]|\\.)*"') +if [ -z "$ns_pairs" ]; then + echo "namespace: (none)" +else + echo "namespace:" + printf '%s\n' "$ns_pairs" | while IFS= read -r pair; do + key=$(printf '%s' "$pair" | sed -E 's/^"([^"]+)".*/\1/') + val=$(printf '%s' "$pair" | sed -E 's/^"[^"]+"[[:space:]]*:[[:space:]]*"(.*)"$/\1/') + echo " $key=$val" + done +fi +echo + +# --- primary checkout state ----------------------------------------------- + +if [ -n "$(git -C "$repo_root" status --porcelain 2>/dev/null)" ]; then + echo "primary: dirty" +else + echo "primary: clean" +fi +echo + +# --- every other worktree -------------------------------------------------- + +wt_list=$(git -C "$repo_root" worktree list --porcelain) + +wt_path="" +wt_branch="" +wt_detached=0 +printed_any=0 + +emit_worktree() { + [ -n "$wt_path" ] || return 0 + [ "$wt_path" = "$repo_root" ] && return 0 + + printed_any=1 + name=$(basename "$wt_path") + echo "worktree: $name" + echo "path: $wt_path" + + if [ ! -d "$wt_path" ]; then + echo "branch: (missing worktree directory)" + echo + return 0 + fi + + if [ "$wt_detached" = "1" ]; then + short=$(git -C "$wt_path" rev-parse --short HEAD 2>/dev/null) + echo "branch: detached at ${short:-unknown}" + else + echo "branch: $wt_branch" + fi + + if [ "$base_found" = "1" ]; then + counts=$(git -C "$wt_path" rev-list --left-right --count "${base}...HEAD" 2>/dev/null) + if [ -n "$counts" ]; then + behind=$(printf '%s' "$counts" | cut -f1) + ahead=$(printf '%s' "$counts" | cut -f2) + else + behind="?" + ahead="?" + fi + else + behind="? (base '$base' not found)" + ahead="? (base '$base' not found)" + fi + echo "ahead: $ahead" + echo "behind: $behind" + + if [ -n "$(git -C "$wt_path" status --porcelain 2>/dev/null)" ]; then + echo "status: dirty" + else + echo "status: clean" + fi + + if [ -f "$wt_path/handoff.md" ]; then + echo "handoff: yes" + else + echo "handoff: no" + fi + + last=$(git -C "$wt_path" log -1 --format=%cI 2>/dev/null) + echo "last_commit: ${last:-unknown}" + echo +} + +while IFS= read -r line; do + case "$line" in + "worktree "*) + emit_worktree + wt_path="${line#worktree }" + wt_branch="" + wt_detached=0 + ;; + "branch "*) + wt_branch="${line#branch refs/heads/}" + ;; + "detached") + wt_detached=1 + ;; + "") + : # blank separator between porcelain records; emit happens on next "worktree " line or at EOF + ;; + esac +done <<<"$wt_list" +emit_worktree + +if [ "$printed_any" = "0" ]; then + echo "worktrees: (none)" +fi diff --git a/commands/land.md b/commands/land.md index c99cc82..285ffc5 100644 --- a/commands/land.md +++ b/commands/land.md @@ -8,17 +8,20 @@ Background in `~/.claude/docs/tracks.md`. ## Check before touching anything -Read `tracks.json` at the repo root for `base` and `verify`. Then stop and report, -without merging, if any of these hold: - -- The track's worktree is dirty. Uncommitted work is not landable; say what is - uncommitted. -- The branch has no commits ahead of base. Nothing to land. +Run `$HOME/.claude/bin/tracks-status.sh` from the repo root. It reports `base` +and `verify` from `tracks.json` with defaults applied, whether the primary +checkout is dirty, and — in the block for this track's worktree — its branch, +commits ahead of and behind base, and whether it is clean or dirty. Then stop +and report, without merging, if any of these hold: + +- The track's worktree is dirty, per the script. Uncommitted work is not + landable; say what is uncommitted. +- The script reports zero commits ahead of base. Nothing to land. - An agent is still running against that worktree, per `ListAgents`. Landing underneath a live writer loses work. -- The primary checkout is dirty. A merge needs the base branch in a working - tree, and merging into someone else's uncommitted changes is how their work - gets lost. Say what is uncommitted and stop. +- The primary checkout is dirty, per the script. A merge needs the base branch + in a working tree, and merging into someone else's uncommitted changes is how + their work gets lost. Say what is uncommitted and stop. ## Verify diff --git a/commands/track.md b/commands/track.md index 07a9606..b7217ed 100644 --- a/commands/track.md +++ b/commands/track.md @@ -11,10 +11,13 @@ here can be defaulted, that cannot. ## Read the project's declaration -`tracks.json` at the repo root. Absent or partial, fall back per key: -`worktrees` → `.worktrees`, `base` → `main`, `install` → nothing, `verify` → -nothing, `namespace` → nothing. Substitute `{track}` and `{worktree}` in -namespace values, where `{worktree}` is the absolute worktree path. +Run `$HOME/.claude/bin/tracks-status.sh` from the repo root. It reports the +`tracks.json` declaration with defaults already applied — `worktrees`, `base`, +`install`, `verify`, `namespace` — whether the file existed at all, whether +`base` actually exists as a branch, whether the primary checkout is dirty, and +one block per existing worktree with its name and branch. Substitute `{track}` +and `{worktree}` in namespace values, where `{worktree}` is the absolute +worktree path. A project with no declaration still works. Say once, in the closing message, that defaults were used and what a declaration would add — do not stop to ask. @@ -23,10 +26,13 @@ that defaults were used and what a declaration would add — do not stop to ask. - A worktree or branch already using this name → stop and report it. Do not append a suffix; a second tree for the same work is the thing this prevents. -- No `base` branch in the repo → stop and say which branch was expected. -- Uncommitted changes in the primary checkout → warn in the closing message but - continue. The worktree is created from the committed base, so the work is - unaffected; the risk is at landing, and that is `/land`'s problem. + Check the script's worktree blocks for the name, and `git branch --list` for + the branch, since a branch can outlive its worktree. +- The script reports `base` as not found → stop and say which branch was + expected. +- The script reports the primary checkout as dirty → warn in the closing + message but continue. The worktree is created from the committed base, so the + work is unaffected; the risk is at landing, and that is `/land`'s problem. ## Create it diff --git a/commands/tracks.md b/commands/tracks.md index ac02f9a..f43890c 100644 --- a/commands/tracks.md +++ b/commands/tracks.md @@ -10,14 +10,12 @@ disagree, this one is right. ## Gather -Read `tracks.json` at the repo root for the worktree directory and base branch, falling -back to `.worktrees` and `main`. - -For every worktree `git worktree list` reports, other than the primary checkout, -collect: the branch or detached commit, commits ahead of and behind base, -whether the working tree is dirty, whether `handoff.md` exists, and the time of -the last commit on the branch. Then check `ListAgents` for an agent still -running against that worktree. +Run `$HOME/.claude/bin/tracks-status.sh` from the repo root. It reports the +`tracks.json` declaration with defaults applied, whether the primary checkout is +dirty, and one block per other worktree — branch or detached commit, commits +ahead of and behind base, clean or dirty, whether `handoff.md` exists, and the +last commit time. Then check `ListAgents` for an agent still running against +each worktree. ## Report -- 2.51.2