diff --git a/docs/retro/scripts/retro_leftovers.py b/docs/retro/scripts/retro_leftovers.py new file mode 100644 index 0000000..d9cfd60 --- /dev/null +++ b/docs/retro/scripts/retro_leftovers.py @@ -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// в 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)) diff --git a/openspec/changes/harness-retro/design.md b/openspec/changes/harness-retro/design.md index f444c5a..a160171 100644 --- a/openspec/changes/harness-retro/design.md +++ b/openspec/changes/harness-retro/design.md @@ -100,7 +100,7 @@ Bash-команда считается чтением кода, если хот - `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 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 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 | Ходы и токены — по уникальному `message.id`, а не по строкам транскрипта | Строки одного сообщения повторяют `usage`; подсчёт по строкам завышает токены в 2–3 раза | Ходы и токены 11.10 (по строкам) несравнимы; в `2026-10-11.metrics.json` они помечены | | 2026-10-24 | Записи ассистента с `isApiErrorMessage` или `model: ` — не ход модели: не считаются в ходах, токенах и стартовом контексте | Это сообщения 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 | Сторона Gitea в хвостах не проверяется | Хрупко (формат описаний PR и issue); агент видит это при чтении | Незакрытая issue после merge не попадёт в метрику | diff --git a/openspec/changes/harness-retro/specs/harness-retro/spec.md b/openspec/changes/harness-retro/specs/harness-retro/spec.md index 02e2085..50adbc5 100644 --- a/openspec/changes/harness-retro/specs/harness-retro/spec.md +++ b/openspec/changes/harness-retro/specs/harness-retro/spec.md @@ -137,7 +137,7 @@ - ветка `origin/<ветка>`, слитая в `origin/main`; - локальная ветка, слитая или с upstream `gone`; - worktree на такой ветке или `prunable`; -- `.superpowers/sdd/*`; +- `.superpowers/sdd/*` в основном checkout или в worktree такой ветки (в worktree активной задачи — не хвост); - неархивный `openspec/changes//` в `origin/main`; - отставший или грязный основной checkout. diff --git a/tests/test-retro-leftovers.sh b/tests/test-retro-leftovers.sh new file mode 100644 index 0000000..4c21b6e --- /dev/null +++ b/tests/test-retro-leftovers.sh @@ -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"