diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 8c37eec..49b29f6 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "found-issues", - "version": "3.10.9", + "version": "3.10.10", "description": "Markdown-based issue tracker for AI agents. Claude logs out-of-scope issues it spots while working; entries auto-flip to fixed on PR merge or commit-fix.", "author": { "name": "AltDoug", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 5bb65d1..806f8d7 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "found-issues", - "version": "3.10.9", + "version": "3.10.10", "description": "Markdown-based issue tracker for AI agents. The agent logs out-of-scope issues it spots while working; entries auto-flip to fixed on PR merge or commit-fix.", "author": { "name": "AltDoug", diff --git a/CHANGELOG.md b/CHANGELOG.md index dd82044..f5b70e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.10.10] - 2026-10-29 + +A clone that never fetched a commit no longer marks its `(commit:)` annotation stale, so sync stops dirtying pull-only clones. + +### Fixed +- **A commit missing from this clone is no longer taken as gone** (agent-config ledger `docs/found-issues.md:1`). sync demoted `(commit: )` to `(commit-stale: )` whenever the SHA did not resolve locally. A pull-only clone that never fetched the branch a commit was made on (measured on a Windows box's agent-config clone: 4 entries citing `a46fa6b`, `e672651`, `d7e9245`) demoted refs the author's clone still resolved, so two clones of one ledger disagreed, and the uncommitted ledger edit made the box's next `git pull --ff-only` abort ("local changes would be overwritten"), again after every SessionStart sync. A SHA missing locally is now demoted only when the absence is evidence: the repo has no remote (unchanged), or GitHub answers that it has no such commit (`gh api repos///commits/` → HTTP 422, one call per SHA per run). A 404 (a repo the signed-in gh account cannot see), a failed lookup, or a non-GitHub remote leaves the annotation alone, as a shallow clone already did. + ## [3.10.9] - 2026-10-27 A fix PR whose landing branch was merged but kept is moved to the default branch, instead of merging into a branch nothing reads any more. diff --git a/README.md b/README.md index 590e57d..5b3b1dd 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ [![Claude Code plugin](https://img.shields.io/badge/Claude%20Code-plugin-orange.svg)](https://docs.claude.com/en/docs/claude-code/plugins) [![Codex plugin](https://img.shields.io/badge/Codex-plugin-black.svg)](AGENTS.md#installing-for-codex) -**7 lifecycle hooks · 14 slash commands · 2044 tests on Linux/macOS · zero manual bookkeeping** +**7 lifecycle hooks · 14 slash commands · 2048 tests on Linux/macOS · zero manual bookkeeping** ![demo](hero.gif) diff --git a/bin/found-issues b/bin/found-issues index db82cb5..1777384 100755 --- a/bin/found-issues +++ b/bin/found-issues @@ -28,7 +28,7 @@ set -euo pipefail -readonly FI_VERSION="3.10.9" +readonly FI_VERSION="3.10.10" # Statusline fast path (lib/segment-cache.sh): `status --format=segment` runs # every few seconds per session, so it is answered from a content-checked diff --git a/docs/format-spec.md b/docs/format-spec.md index 2558a98..9835774 100644 --- a/docs/format-spec.md +++ b/docs/format-spec.md @@ -237,6 +237,7 @@ However, if a linked PR is closed without merge or a commit SHA becomes unreacha - `(PR: ...)` → `(PR-closed: ...)` — entry stays `[open]`, eligible for AI re-verification since the closure signal is stale - `(commit: ...)` → `(commit-stale: ...)` — entry stays `[open]`, eligible for AI re-verification since the SHA no longer resolves on default branch + - A SHA this clone cannot resolve is demoted only when that absence is evidence: the repo has no remote, or GitHub answers that it has no such commit (HTTP 422). A clone with a remote may simply never have fetched the commit, so a GitHub repo gh cannot see (404), a failed lookup, or a non-GitHub remote leaves `(commit: ...)` alone (3.10.10). These demoted forms are written **only** by `found-issues sync` during its audit phase; they are never produced by user commands or `/found-issues:annotate-*` calls. They serve as an audit trail of link staleness. diff --git a/docs/found-issues.md b/docs/found-issues.md index b46ae8c..71e3c0b 100644 --- a/docs/found-issues.md +++ b/docs/found-issues.md @@ -58,3 +58,4 @@ Maintained automatically by Claude. See /dev/null || true)" == "true" ]] && shallow_repo=1 + # The same holds for a clone with a remote: a SHA missing here may sit on a + # branch this clone never fetched (a pull-only clone, measured 2026-10-29: + # it demoted refs the author's clone still resolved, and the uncommitted + # ledger edit then blocked its own `git pull --ff-only`). A missing local + # object is not evidence. With no remote, this repo is the whole history. + local has_remote=0 + [[ -n "$(git remote 2>/dev/null || true)" ]] && has_remote=1 + + # Is gone upstream too? Asked only for a SHA missing locally. Yes + # only on GitHub's answer for a commit the repo does not have (HTTP 422 + # "No commit found"); a 404 is a repo gh cannot see (wrong account), and + # any other failure is "cannot tell" -- both leave the annotation alone, + # as does a non-GitHub remote, which has no cheap lookup. + # Memo lines: "\n\x1e<0|1>". + local _fi_gone_memo=$'\n' + _fi_commit_gone_upstream() { + local sha="$1" key=$'\n'"$1"$'\x1e' err ans=1 + (( has_remote )) || return 0 + [[ -n "$repo_id" ]] || return 1 + if [[ "$_fi_gone_memo" == *"$key"* ]]; then + ans="${_fi_gone_memo#*"$key"}" + return "${ans%%$'\n'*}" + fi + if ! err="$( { gh api "repos/$repo_id/commits/$sha" --jq .sha >/dev/null; } 2>&1 )" \ + && [[ "$err" == *"No commit found for SHA"* && "$err" == *"(HTTP 422)"* ]]; then + ans=0 + fi + _fi_gone_memo+="$sha"$'\x1e'"$ans"$'\n' + return "$ans" + } + # Loop-invariant; was one `git rev-parse` per path entry (audit ledger-5). local repo_root repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" @@ -355,7 +386,7 @@ cmd_sync() { || [[ "$(git cat-file -t "$sha" 2>&1 || true)" == *ambiguous* ]]; then : # Git cannot tell here (shallow history / ambiguous short SHA) — # leave the annotation for a full clone to judge. - else + elif _fi_commit_gone_upstream "$sha"; then # B1/B2: SHA doesn't resolve (squash-merge dropped it, force-push removed it) → demote demote_commit_refs+=("$sha") fi diff --git a/tests/bin-shims/gh b/tests/bin-shims/gh index 7bcc083..7b185dc 100755 --- a/tests/bin-shims/gh +++ b/tests/bin-shims/gh @@ -72,6 +72,23 @@ case "$cmd $sub" in fi done <<<"$GH_MOCK_PR_FILES" fi + # repos///commits/: GH_MOCK_COMMITS lists the full SHAs that + # exist upstream (newline-separated); a listed prefix answers its full SHA + # (sync asks --jq .sha). Unlisted = GitHub's real answer for a commit the + # repo does not have: HTTP 422. GH_MOCK_COMMITS_API=fail stands in for a + # repo gh cannot see (HTTP 404) or a network failure. + if [[ "$endpoint" =~ /commits/([0-9a-fA-F]+)$ ]] && [[ "${GH_MOCK_COMMITS_API:-}" != "fail" ]]; then + api_sha="${BASH_REMATCH[1]}" + while IFS= read -r mock_sha; do + if [[ -n "$mock_sha" && "$mock_sha" == "$api_sha"* ]]; then + printf '%s\n' "$mock_sha" + exit 0 + fi + done <<<"${GH_MOCK_COMMITS:-}" + printf '{"message":"No commit found for SHA: %s","status":"422"}' "$api_sha" + printf 'gh: No commit found for SHA: %s (HTTP 422)\n' "$api_sha" >&2 + exit 1 + fi printf 'gh: Not Found (HTTP 404)\n' >&2 exit 1 ;; diff --git a/tests/cli-sync-commit-stale.bats b/tests/cli-sync-commit-stale.bats index 10c58cd..76e36bc 100644 --- a/tests/cli-sync-commit-stale.bats +++ b/tests/cli-sync-commit-stale.bats @@ -49,3 +49,91 @@ teardown() { fi_run sync grep -q '\[fixed\].*\(commit: '"$sha"'\)' docs/found-issues.md } + +# --- consumer clones (2026-10-29) --- +# A clone that never fetched a commit cannot tell "gone" from "on a branch I +# do not have". Measured on a pull-only clone: sync demoted (commit:) refs to +# (commit-stale:) because the objects were missing locally, the uncommitted +# ledger edit then made `git pull --ff-only` abort, and it recurred every +# session. A missing local object is not evidence; only the remote is. + +@test "sync: clone missing a commit that exists upstream leaves (commit:) alone" { + # Upstream (a plain git remote) holds the commit on a side branch; the + # clone fetched main only, so the object is absent locally. + local up="$TMP/up.git" + git init -q --bare -b main "$up" + git remote add origin "$up" + mkdir -p src + echo "x" > src/foo.py + git add src/foo.py + git commit -q -m "init" + git push -q origin main + git checkout -q -b side + echo "y" >> src/foo.py + git commit -q -am "fix on a side branch" + local sha + sha="$(git rev-parse --short=7 HEAD)" + git push -q origin side + git checkout -q main + local clone="$TMP/clone" + git clone -q --no-local --single-branch -b main "file://$up" "$clone" + cd "$clone" + run git cat-file -e "$sha" + [ "$status" -ne 0 ] + + fi_seed_entry "src/foo.py:1 — bug (commit: $sha)" + local before + before="$(cat docs/found-issues.md)" + fi_run sync + [ "$status" -eq 0 ] + [ "$(cat docs/found-issues.md)" = "$before" ] +} + +@test "sync: GitHub says the commit exists -> (commit:) left alone" { + fi_use_gh_shim + fi_init_github_repo foo/bar main + mkdir -p src + echo "x" > src/foo.py + git add src/foo.py + git commit -q -m "init" + export GH_MOCK_COMMITS="a46fa6bb6ce8c0b71d2691c97964fc02cc77717e" + + fi_seed_entry "src/foo.py:1 — bug (commit: a46fa6b)" + local before + before="$(cat docs/found-issues.md)" + fi_run sync + [ "$status" -eq 0 ] + [ "$(cat docs/found-issues.md)" = "$before" ] +} + +@test "sync: GitHub says no such commit (HTTP 422) -> demotes to (commit-stale:)" { + fi_use_gh_shim + fi_init_github_repo foo/bar main + mkdir -p src + echo "x" > src/foo.py + git add src/foo.py + git commit -q -m "init" + export GH_MOCK_COMMITS="" + + fi_seed_entry "src/foo.py:1 — bug (commit: deadbeef)" + fi_run sync + [ "$status" -eq 0 ] + grep -q '^- \[open\].*(commit-stale: deadbeef)' docs/found-issues.md +} + +@test "sync: GitHub lookup fails (repo not visible / offline) -> (commit:) left alone" { + fi_use_gh_shim + fi_init_github_repo foo/bar main + mkdir -p src + echo "x" > src/foo.py + git add src/foo.py + git commit -q -m "init" + export GH_MOCK_COMMITS_API=fail + + fi_seed_entry "src/foo.py:1 — bug (commit: deadbeef)" + local before + before="$(cat docs/found-issues.md)" + fi_run sync + [ "$status" -eq 0 ] + [ "$(cat docs/found-issues.md)" = "$before" ] +}