feat(retro): retro_leftovers.py — хвосты чеклиста закрытия по состоянию репозиториев (#29)
SDD workspace в worktree активной задачи хвостом не считается (O-19, живой прогон). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
dde575673d
commit
4bbf6ebb3a
154
docs/retro/scripts/retro_leftovers.py
Normal file
154
docs/retro/scripts/retro_leftovers.py
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Хвосты чеклиста закрытия по текущему состоянию репозиториев проекта или workspace (B12, #29).
|
||||||
|
|
||||||
|
Запуск: python3 retro_leftovers.py <папка проекта или workspace> [--fetch] [--markdown]
|
||||||
|
Репозитории: сам git-проект или каталоги из таблицы «| Каталог | Репозиторий | Роль |» в CLAUDE.md workspace.
|
||||||
|
Хвост: слитая в origin/main ветка на сервере или локально, worktree на ней или prunable, .superpowers/sdd/*,
|
||||||
|
неархивный openspec/changes/<id>/ в origin/main, основной checkout позади origin/main или с изменениями.
|
||||||
|
Ничего не меняет; --fetch делает только git fetch --prune origin. Спека: openspec/specs/harness-retro/spec.md.
|
||||||
|
"""
|
||||||
|
import collections
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
TABLE = re.compile(r"^\|\s*Каталог\s*\|\s*Репозиторий\s*\|\s*Роль\s*\|")
|
||||||
|
PROTECTED = ("main", "master")
|
||||||
|
|
||||||
|
|
||||||
|
def git(repo, *args):
|
||||||
|
env = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")}
|
||||||
|
r = subprocess.run(["git", "-C", repo] + list(args), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||||
|
universal_newlines=True, env=env)
|
||||||
|
return r.stdout if r.returncode == 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def lines(s):
|
||||||
|
return [l.strip() for l in (s or "").splitlines() if l.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def repos_of(project):
|
||||||
|
project = os.path.abspath(project)
|
||||||
|
if os.path.exists(os.path.join(project, ".git")):
|
||||||
|
return [project]
|
||||||
|
md, out, on = os.path.join(project, "CLAUDE.md"), [], False
|
||||||
|
if not os.path.isfile(md):
|
||||||
|
return out
|
||||||
|
for line in open(md, encoding="utf-8", errors="replace"):
|
||||||
|
if TABLE.match(line):
|
||||||
|
on = True
|
||||||
|
continue
|
||||||
|
if not on:
|
||||||
|
continue
|
||||||
|
if not line.startswith("|"):
|
||||||
|
break
|
||||||
|
d = line.strip().strip("|").split("|")[0].strip()
|
||||||
|
if not d or set(d) <= set("-: "):
|
||||||
|
continue
|
||||||
|
p = os.path.join(project, d.rstrip("/"))
|
||||||
|
if os.path.exists(os.path.join(p, ".git")):
|
||||||
|
out.append(p)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def worktrees(repo):
|
||||||
|
out, cur = [], {}
|
||||||
|
for l in (git(repo, "worktree", "list", "--porcelain") or "").splitlines() + [""]:
|
||||||
|
if not l.strip():
|
||||||
|
if cur:
|
||||||
|
out.append(cur)
|
||||||
|
cur = {}
|
||||||
|
continue
|
||||||
|
k, _, v = l.partition(" ")
|
||||||
|
cur[k] = v or True
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def leftovers(repo, fetch=False):
|
||||||
|
items = []
|
||||||
|
|
||||||
|
def add(kind, item, hint):
|
||||||
|
items.append({"kind": kind, "repo": repo, "item": item, "hint": hint})
|
||||||
|
|
||||||
|
if fetch:
|
||||||
|
git(repo, "fetch", "--prune", "-q", "origin")
|
||||||
|
sha = lambda ref: (git(repo, "rev-parse", "-q", "--verify", ref) or "").strip()
|
||||||
|
tip = sha("origin/main")
|
||||||
|
if not tip:
|
||||||
|
add("no-origin-main", "origin/main", "нет origin/main: git fetch origin или проверить remote")
|
||||||
|
return items
|
||||||
|
for b in lines(git(repo, "branch", "-r", "--merged", "origin/main")):
|
||||||
|
if "->" in b or not b.startswith("origin/") or b[len("origin/"):] in PROTECTED + ("HEAD",):
|
||||||
|
continue
|
||||||
|
if sha(b) != tip:
|
||||||
|
add("remote-branch", b, "git push origin --delete " + b[len("origin/"):])
|
||||||
|
stale = set()
|
||||||
|
for b in lines(git(repo, "branch", "--merged", "origin/main", "--format=%(refname:short)")):
|
||||||
|
if b not in PROTECTED and sha("refs/heads/" + b) != tip:
|
||||||
|
stale.add(b)
|
||||||
|
for l in lines(git(repo, "for-each-ref", "--format=%(refname:short) %(upstream:track)", "refs/heads")):
|
||||||
|
name, _, track = l.partition(" ")
|
||||||
|
if "gone" in track and name not in PROTECTED:
|
||||||
|
stale.add(name)
|
||||||
|
for b in sorted(stale):
|
||||||
|
add("local-branch", b, "git branch -D " + b)
|
||||||
|
wts = worktrees(repo)
|
||||||
|
main_wt = wts[0]["worktree"] if wts else repo
|
||||||
|
done = [main_wt] # SDD workspace — хвост в основном checkout и в worktree закрытой задачи, не в активном
|
||||||
|
for w in wts[1:]:
|
||||||
|
br = str(w.get("branch") or "")
|
||||||
|
if br.startswith("refs/heads/"):
|
||||||
|
br = br[len("refs/heads/"):]
|
||||||
|
if w.get("prunable") or br in stale:
|
||||||
|
add("worktree", w["worktree"], "ExitWorktree, затем git worktree remove " + w["worktree"])
|
||||||
|
done.append(w["worktree"])
|
||||||
|
for w in done:
|
||||||
|
for d in sorted(glob.glob(os.path.join(w, ".superpowers", "sdd", "*"))):
|
||||||
|
if os.path.isdir(d):
|
||||||
|
add("sdd-workspace", d, "rm -rf " + d)
|
||||||
|
for c in lines(git(repo, "ls-tree", "-d", "--name-only", "origin/main", "openspec/changes/")):
|
||||||
|
name = c.rstrip("/").rsplit("/", 1)[-1]
|
||||||
|
if name != "archive":
|
||||||
|
add("openspec-change", c, "openspec archive %s — отдельным PR" % name)
|
||||||
|
behind = (git(main_wt, "rev-list", "--count", "HEAD..origin/main") or "0").strip()
|
||||||
|
if behind not in ("", "0"):
|
||||||
|
add("main-behind", main_wt, "git pull --ff-only (позади origin/main на %s)" % behind)
|
||||||
|
dirty = [l for l in (git(main_wt, "status", "--porcelain") or "").splitlines() if l.strip()]
|
||||||
|
if dirty:
|
||||||
|
add("main-dirty", main_wt, "не трогать, перечислить: " + ", ".join(l[3:] for l in dirty[:5]))
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def to_markdown(items):
|
||||||
|
if not items:
|
||||||
|
return "Хвостов закрытия нет."
|
||||||
|
out = ["| Вид | Репозиторий | Что | Чем убрать |", "|---|---|---|---|"]
|
||||||
|
for i in items:
|
||||||
|
out.append("| %s | %s | `%s` | `%s` |" % (i["kind"], os.path.basename(i["repo"]), i["item"], i["hint"]))
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
args = argv[1:]
|
||||||
|
pos = [a for a in args if not a.startswith("--")]
|
||||||
|
if not pos:
|
||||||
|
print(__doc__.strip().splitlines()[2])
|
||||||
|
return 1
|
||||||
|
repos = repos_of(pos[0])
|
||||||
|
if not repos:
|
||||||
|
sys.stderr.write("retro_leftovers: в %s нет git-репозитория и таблицы репозиториев workspace\n" % pos[0])
|
||||||
|
return 1
|
||||||
|
items = [i for r in repos for i in leftovers(r, "--fetch" in args)]
|
||||||
|
if "--markdown" in args:
|
||||||
|
print(to_markdown(items))
|
||||||
|
else:
|
||||||
|
print(json.dumps({"repos": repos, "items": items,
|
||||||
|
"totals": dict(collections.Counter(i["kind"] for i in items))}, ensure_ascii=False, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv))
|
||||||
@ -100,7 +100,7 @@ Bash-команда считается чтением кода, если хот
|
|||||||
- `git branch -r --merged origin/main` без `origin/main` и `origin/HEAD`;
|
- `git branch -r --merged origin/main` без `origin/main` и `origin/HEAD`;
|
||||||
- `git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads`, где слитые в `origin/main` или `[gone]`;
|
- `git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads`, где слитые в `origin/main` или `[gone]`;
|
||||||
- `git worktree list --porcelain` — ветка из слитых или `prunable`;
|
- `git worktree list --porcelain` — ветка из слитых или `prunable`;
|
||||||
- `ls .superpowers/sdd/*/` в основном checkout и в каждом worktree;
|
- `ls .superpowers/sdd/*/` в основном checkout и в worktree, которые сами хвост (слитая или `gone` ветка, `prunable`). SDD workspace в worktree активной задачи — работа в процессе, не хвост;
|
||||||
- `git ls-tree -d --name-only origin/main openspec/changes/` без `archive`;
|
- `git ls-tree -d --name-only origin/main openspec/changes/` без `archive`;
|
||||||
- `git rev-list --count HEAD..origin/main` и `git status --porcelain` основного checkout.
|
- `git rev-list --count HEAD..origin/main` и `git status --porcelain` основного checkout.
|
||||||
|
|
||||||
@ -175,5 +175,6 @@ Bash-команда считается чтением кода, если хот
|
|||||||
| 2026-10-24 | `retro_stats.py` и `retro_blocks.py` удаляются | Их заменяет `retro_metrics.py`; в ретро 11.10 они упомянуты как история | Чтобы воспроизвести 11.10 буква в букву, нужен git history |
|
| 2026-10-24 | `retro_stats.py` и `retro_blocks.py` удаляются | Их заменяет `retro_metrics.py`; в ретро 11.10 они упомянуты как история | Чтобы воспроизвести 11.10 буква в букву, нужен git history |
|
||||||
| 2026-10-24 | Ходы и токены — по уникальному `message.id`, а не по строкам транскрипта | Строки одного сообщения повторяют `usage`; подсчёт по строкам завышает токены в 2–3 раза | Ходы и токены 11.10 (по строкам) несравнимы; в `2026-10-11.metrics.json` они помечены |
|
| 2026-10-24 | Ходы и токены — по уникальному `message.id`, а не по строкам транскрипта | Строки одного сообщения повторяют `usage`; подсчёт по строкам завышает токены в 2–3 раза | Ходы и токены 11.10 (по строкам) несравнимы; в `2026-10-11.metrics.json` они помечены |
|
||||||
| 2026-10-24 | Записи ассистента с `isApiErrorMessage` или `model: <synthetic>` — не ход модели: не считаются в ходах, токенах и стартовом контексте | Это сообщения Claude Code об ошибке API с нулевым `usage`; на живых транскриптах давали стартовый контекст 0 (O-18) | Если Claude Code пометит так настоящий ход, он выпадет из подсчёта |
|
| 2026-10-24 | Записи ассистента с `isApiErrorMessage` или `model: <synthetic>` — не ход модели: не считаются в ходах, токенах и стартовом контексте | Это сообщения Claude Code об ошибке API с нулевым `usage`; на живых транскриптах давали стартовый контекст 0 (O-18) | Если Claude Code пометит так настоящий ход, он выпадет из подсчёта |
|
||||||
|
| 2026-10-24 | SDD workspace в worktree неслитой ветки — не хвост | Живой прогон показал рабочий каталог текущего плана как хвост (O-19); это работа в процессе | Брошенная неслитая задача со своим SDD workspace не попадёт в хвосты (её видно по worktree) |
|
||||||
| 2026-10-24 | Свежая ветка, совпадающая с `origin/main`, — не хвост | `--merged` считает слитой и ветку без коммитов, например только что созданный worktree | Брошенная пустая ветка не попадёт в хвосты |
|
| 2026-10-24 | Свежая ветка, совпадающая с `origin/main`, — не хвост | `--merged` считает слитой и ветку без коммитов, например только что созданный worktree | Брошенная пустая ветка не попадёт в хвосты |
|
||||||
| 2026-10-24 | Сторона Gitea в хвостах не проверяется | Хрупко (формат описаний PR и issue); агент видит это при чтении | Незакрытая issue после merge не попадёт в метрику |
|
| 2026-10-24 | Сторона Gitea в хвостах не проверяется | Хрупко (формат описаний PR и issue); агент видит это при чтении | Незакрытая issue после merge не попадёт в метрику |
|
||||||
|
|||||||
@ -137,7 +137,7 @@
|
|||||||
- ветка `origin/<ветка>`, слитая в `origin/main`;
|
- ветка `origin/<ветка>`, слитая в `origin/main`;
|
||||||
- локальная ветка, слитая или с upstream `gone`;
|
- локальная ветка, слитая или с upstream `gone`;
|
||||||
- worktree на такой ветке или `prunable`;
|
- worktree на такой ветке или `prunable`;
|
||||||
- `.superpowers/sdd/*`;
|
- `.superpowers/sdd/*` в основном checkout или в worktree такой ветки (в worktree активной задачи — не хвост);
|
||||||
- неархивный `openspec/changes/<id>/` в `origin/main`;
|
- неархивный `openspec/changes/<id>/` в `origin/main`;
|
||||||
- отставший или грязный основной checkout.
|
- отставший или грязный основной checkout.
|
||||||
|
|
||||||
|
|||||||
60
tests/test-retro-leftovers.sh
Normal file
60
tests/test-retro-leftovers.sh
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# retro_leftovers.py: хвосты чеклиста закрытия по состоянию репозиториев (спека harness-retro, #29).
|
||||||
|
L="$ROOT/docs/retro/scripts/retro_leftovers.py"
|
||||||
|
G="$TMP/lo"; mkdir -p "$G"
|
||||||
|
git init -q --bare -b main "$G/o.git"
|
||||||
|
git clone -q "$G/o.git" "$G/c" 2>/dev/null; git -C "$G/c" symbolic-ref HEAD refs/heads/main
|
||||||
|
printf '.superpowers/\n.claude/worktrees/\n' > "$G/c/.gitignore"
|
||||||
|
git -C "$G/c" add .gitignore && git -C "$G/c" commit -q -m init && git -C "$G/c" push -q origin main 2>/dev/null
|
||||||
|
git -C "$G/c" switch -q -c feat/1 && git -C "$G/c" commit -q --allow-empty -m f1 && git -C "$G/c" push -q -u origin feat/1 2>/dev/null
|
||||||
|
git -C "$G/c" switch -q main && git -C "$G/c" merge -q --no-ff feat/1 -m "merge feat/1"
|
||||||
|
mkdir -p "$G/c/openspec/changes/foo" "$G/c/openspec/changes/archive/2026-01-01-bar"
|
||||||
|
echo x > "$G/c/openspec/changes/foo/proposal.md"; echo y > "$G/c/openspec/changes/archive/2026-01-01-bar/p.md"
|
||||||
|
git -C "$G/c" add openspec && git -C "$G/c" commit -q -m spec && git -C "$G/c" push -q origin main 2>/dev/null
|
||||||
|
git -C "$G/c" worktree add -q "$G/c/.claude/worktrees/w1" feat/1
|
||||||
|
mkdir -p "$G/c/.superpowers/sdd/plan-x" && touch "$G/c/.superpowers/sdd/plan-x/progress.md"
|
||||||
|
# активная задача: worktree на неслитой ветке со своим SDD workspace — не хвост
|
||||||
|
git -C "$G/c" worktree add -q "$G/c/.claude/worktrees/w2" -b feat/2 && git -C "$G/c/.claude/worktrees/w2" commit -q --allow-empty -m wip
|
||||||
|
mkdir -p "$G/c/.claude/worktrees/w2/.superpowers/sdd/plan-y" && touch "$G/c/.claude/worktrees/w2/.superpowers/sdd/plan-y/progress.md"
|
||||||
|
git clone -q "$G/o.git" "$G/c2" 2>/dev/null && git -C "$G/c2" commit -q --allow-empty -m ahead && git -C "$G/c2" push -q origin main 2>/dev/null
|
||||||
|
git -C "$G/c" fetch -q origin
|
||||||
|
echo x > "$G/c/untracked.txt"
|
||||||
|
J="$TMP/lo.json"
|
||||||
|
python3 "$L" "$G/c" > "$J"; rc=$?
|
||||||
|
assert_eq "хвосты: exit 0" "$rc" "0"
|
||||||
|
k(){ python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(eval(sys.argv[2]))' "$J" "$1"; }
|
||||||
|
assert_eq "виды хвостов" "$(k 'sorted(d["totals"])')" "['local-branch', 'main-behind', 'main-dirty', 'openspec-change', 'remote-branch', 'sdd-workspace', 'worktree']"
|
||||||
|
assert_eq "ветка на сервере и подсказка" "$(k '[(i["item"], i["hint"]) for i in d["items"] if i["kind"] == "remote-branch"]')" "[('origin/feat/1', 'git push origin --delete feat/1')]"
|
||||||
|
assert_eq "локальная ветка" "$(k '[i["item"] for i in d["items"] if i["kind"] == "local-branch"]')" "['feat/1']"
|
||||||
|
assert_eq "worktree на слитой ветке" "$(k '[i["item"].endswith("/.claude/worktrees/w1") for i in d["items"] if i["kind"] == "worktree"]')" "[True]"
|
||||||
|
assert_eq "SDD workspace" "$(k '[i["item"].endswith("/sdd/plan-x") for i in d["items"] if i["kind"] == "sdd-workspace"]')" "[True]"
|
||||||
|
assert_eq "неархивный change — без archive" "$(k '[i["item"] for i in d["items"] if i["kind"] == "openspec-change"]')" "['openspec/changes/foo']"
|
||||||
|
assert_grep "отставание от origin/main" 'позади origin/main на 1' "$J"
|
||||||
|
assert_grep "грязное дерево с путём" 'untracked.txt' "$J"
|
||||||
|
# только чтение: без --fetch ссылки и дерево не меняются
|
||||||
|
git -C "$G/c2" commit -q --allow-empty -m ahead2 && git -C "$G/c2" push -q origin main 2>/dev/null
|
||||||
|
before="$(git -C "$G/c" for-each-ref)$(git -C "$G/c" status --porcelain)"
|
||||||
|
python3 "$L" "$G/c" > /dev/null
|
||||||
|
assert_eq "без --fetch ничего не меняется" "$(git -C "$G/c" for-each-ref)$(git -C "$G/c" status --porcelain)" "$before"
|
||||||
|
python3 "$L" "$G/c" --fetch > "$J"
|
||||||
|
assert_grep "--fetch видит новые коммиты" 'позади origin/main на 2' "$J"
|
||||||
|
md=$(python3 "$L" "$G/c" --markdown)
|
||||||
|
assert_grep "таблица для черновика" '^\| remote-branch \| c \| `origin/feat/1` \| `git push origin --delete feat/1` \|$' <(printf '%s' "$md")
|
||||||
|
# чистый репозиторий: свежая ветка на вершине origin/main — не хвост
|
||||||
|
git init -q --bare -b main "$G/k.git"; git clone -q "$G/k.git" "$G/k" 2>/dev/null; git -C "$G/k" symbolic-ref HEAD refs/heads/main
|
||||||
|
git -C "$G/k" commit -q --allow-empty -m init && git -C "$G/k" push -q origin main 2>/dev/null && git -C "$G/k" branch feat/new
|
||||||
|
python3 "$L" "$G/k" > "$J"
|
||||||
|
assert_eq "чистый репозиторий — хвостов нет" "$(k 'd["items"]')" "[]"
|
||||||
|
assert_eq "чистый репозиторий — markdown" "$(python3 "$L" "$G/k" --markdown)" "Хвостов закрытия нет."
|
||||||
|
# без origin
|
||||||
|
git init -q -b main "$G/n" && git -C "$G/n" commit -q --allow-empty -m init
|
||||||
|
python3 "$L" "$G/n" > "$J"; rc=$?
|
||||||
|
assert_eq "без origin: exit 0 и no-origin-main" "$rc $(k '[i["kind"] for i in d["items"]]')" "0 ['no-origin-main']"
|
||||||
|
# workspace: репозитории из таблицы CLAUDE.md
|
||||||
|
W="$TMP/ws"; mkdir -p "$W"; git clone -q "$G/o.git" "$W/r" 2>/dev/null
|
||||||
|
printf '# w\n\n| Каталог | Репозиторий | Роль |\n|---|---|---|\n| r/ | o/r | dev |\n| - | x | qa |\n' > "$W/CLAUDE.md"
|
||||||
|
python3 "$L" "$W" > "$J"
|
||||||
|
assert_eq "workspace: репозитории из таблицы" "$(k '[r.endswith("/ws/r") for r in d["repos"]]')" "[True]"
|
||||||
|
assert_eq "workspace: ветка на сервере видна" "$(k '[i["item"] for i in d["items"] if i["kind"] == "remote-branch"]')" "['origin/feat/1']"
|
||||||
|
# не проект и не workspace
|
||||||
|
mkdir -p "$TMP/empty"; python3 "$L" "$TMP/empty" > /dev/null 2>&1; rc=$?
|
||||||
|
assert_eq "не репозиторий и не workspace: exit 1" "$rc" "1"
|
||||||
Loading…
x
Reference in New Issue
Block a user