Files
gstack/bin/gstack-brain-sync-index.py
4e4f2959cd v1.91.126.0 fix(brain-sync): hold case-colliding artifact paths instead of reporting ok (#3220) (#3254)
* fix(brain-sync): verify requested index entries after add

Refuse missing or unrelated staged paths and stale indexed content without consuming the queue or resetting the index.

* fix(brain-sync): refuse new casefold path collisions

Check component prefixes in requested/index and outgoing trees, including retained commits and fetch/merge retries. Preserve historical aliases, local commits, index, and queue on refusal.

* fix(brain-sync): hold case-colliding paths instead of halting the sync (#3220)

Builds on Kkkkkuro's staged-index and casefold checks (#3245, from #3223).
A queued path whose spelling differs only by letter case from another path
in the index or the batch is held before staging: it stays queued, every
other file keeps syncing, and the status is "collision" rather than "ok".
An established spelling keeps syncing; only the new alias waits.

Skill start prints one ARTIFACTS_SYNC attention line naming the colliding
names and the repair anchor (docs/troubleshooting.md#artifact-case-collision);
gstack-doctor warns the same way. The outgoing check is cached by HEAD so
the empty-queue drain at every skill boundary stays near main's cost.
gstack-slug and skill start's slug path are unchanged.

Co-authored-by: Kkkkkuro <180382840+Kkkkkuro@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(brain-sync): close review gaps in the case-collision hold

- Windows: write the held-path list with LF and strip CR from helper output,
  so CRLF cannot let a held path through or blank case_names.
- --publish-removals runs the outgoing case check before it pushes.
- The cached outgoing verdict is keyed by every ref, not HEAD alone, so a
  moved remote baseline is re-checked.
- The repair snippet folds only JSONL logs and lists snapshots and other
  files for a manual decision.

Co-authored-by: Kkkkkuro <180382840+Kkkkkuro@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(brain-sync): second review pass on the case-collision hold

- The repair loop also folds the dropped spelling's untracked (held) files
  and says to check for leftovers before removing the folder on Linux.
- The cached outgoing verdict also keys on HEAD's symbolic target.
- The outgoing check no longer needs a temp file, so a failed mktemp can
  no longer skip it.

Co-authored-by: Kkkkkuro <180382840+Kkkkkuro@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(brain-sync): give the retry-merge fixture a git identity

The retry merge commits with the ambient identity. CI and Ubicloud runners
have none under the test HOME, so the merge failed before the outgoing case
check ran and the drain reported push_failed instead of error.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): unreleased entry for the #3220 case-collision hold

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.126.0 stamp

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: KuroChen <Kkkkkuro@users.noreply.github.com>
Co-authored-by: Kkkkkuro <180382840+Kkkkkuro@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-17 21:17:39 +00:00

159 lines
6.8 KiB
Python

#!/usr/bin/env python3
"""Read-only verification of brain-sync's post-add index; never repairs it."""
import json
import os
from pathlib import Path
import re
import stat
import subprocess
import sys
import unicodedata
class Refusal(Exception):
def __init__(self, message, spellings=()):
super().__init__(message)
self.spellings = spellings
def git(root, *args, data=None, allow_missing=False):
try:
result = subprocess.run(
['git', '-C', str(root), *args], input=data,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=30)
except (OSError, subprocess.TimeoutExpired) as error:
raise Refusal('index verification could not read Git') from error
if result.returncode == 1 and allow_missing:
return b''
if result.returncode:
raise Refusal('index verification Git command failed')
return result.stdout
def prefixes(paths):
groups = {}
for path in paths:
parts = path.split('/')
for i in range(1, len(parts) + 1):
spelling = '/'.join(parts[:i])
key = unicodedata.normalize('NFC', spelling).casefold()
groups.setdefault(key, set()).add(spelling)
return groups
def names(spellings):
# Only the colliding component names, clamped to a slug's charset: skill
# start prints them, and the artifacts repo's paths are not trusted text.
leaves = sorted({re.sub(r'[^A-Za-z0-9._-]', '?', s.rsplit('/', 1)[-1])[:64] for s in spellings})
return ','.join(leaves[:4])
def first_collision(groups):
found = sorted((key.count('/'), key) for key, spellings in groups.items() if len(spellings) > 1)
return groups[found[0][1]] if found else ()
def tree_paths(root, *args):
return {p.decode('utf-8') for p in git(root, *args).split(b'\0') if p}
def refuse_new_collisions(root, paths, baseline='HEAD'):
# Compare complete component prefixes: Foo/a and foo/b collide too.
# Existing aliases are historical data; this check does not migrate them
# or block unrelated writes, but it refuses any additional spelling.
head = git(root, 'ls-tree', '-rz', '--name-only', baseline) if baseline else b''
previous = prefixes(p.decode('utf-8') for p in head.split(b'\0') if p)
groups = prefixes(paths)
for key, spellings in sorted(groups.items(), key=lambda item: (item[0].count('/'), item[0])):
if len(spellings) > 1 and not spellings <= previous.get(key, set()):
raise Refusal('new casefold path collision; inspect path spellings before retrying', spellings)
return first_collision(groups)
def hold(root, requested_file, held_file):
# A requested path is held when one of its prefixes has another spelling
# in the index or in this batch, unless its own spelling is the only one
# the index already tracks (an established project keeps syncing; the new
# alias waits). Held paths are never staged, so git cannot record them
# under the other spelling, and their queue records stay.
requested = {p for p in Path(requested_file).read_text(encoding='utf-8').replace('\r', '').split('\n') if p}
tracked_paths = tree_paths(root, 'ls-files', '-z')
tracked = prefixes(tracked_paths)
groups = prefixes(tracked_paths | requested)
held, colliding = set(), []
for path in requested:
parts = path.split('/')
for i in range(1, len(parts) + 1):
spelling = '/'.join(parts[:i])
key = unicodedata.normalize('NFC', spelling).casefold()
if len(groups[key]) > 1 and tracked.get(key) != {spelling}:
held.add(path)
colliding.append((key.count('/'), key))
break
with open(held_file, 'w', encoding='utf-8', newline='\n') as f:
f.writelines(p + '\n' for p in sorted(held))
return groups[min(colliding)[1]] if colliding else ()
def verify(root, requested_file, names_file):
requested = set(Path(requested_file).read_text(encoding='utf-8').splitlines())
requested.discard('')
entries = {}
for record in git(root, 'ls-files', '--stage', '-z').split(b'\0'):
if not record:
continue
header, name = record.split(b'\t', 1)
mode, oid, stage = header.split()
name = name.decode('utf-8')
if stage != b'0':
raise Refusal('unmerged index entries; inspect the index')
entries[name] = (mode, oid)
changed = {p.decode('utf-8') for p in Path(names_file).read_bytes().split(b'\0') if p}
refuse_new_collisions(root, set(entries) | requested)
if requested - entries.keys() or changed - requested:
raise Refusal('requested/index path mismatch; inspect exact path spellings and unrelated staged entries')
regular = []
for name in sorted(requested):
mode, oid = entries[name]
file = root / name
actual = file.lstat().st_mode
if mode == b'120000' and stat.S_ISLNK(actual):
hashed = git(root, 'hash-object', '--stdin', data=os.fsencode(os.readlink(file))).strip()
if hashed != oid:
raise Refusal('requested/index content mismatch')
elif mode in (b'100644', b'100755') and stat.S_ISREG(actual):
regular.append(name)
else:
raise Refusal('requested/index file type mismatch')
if regular:
# --stdin-paths applies the same clean filters/EOL conversion as add.
# Quote each path as Git's C-style input (including quotes/backslashes).
paths = ''.join(json.dumps(p, ensure_ascii=False) + '\n' for p in regular).encode('utf-8')
hashes = git(root, 'hash-object', '--stdin-paths', data=paths).splitlines()
if len(hashes) != len(regular) or any(entries[p][1] != oid for p, oid in zip(regular, hashes)):
raise Refusal('requested/index content mismatch; queued content is not in the index')
def outgoing(root):
if not git(root, 'rev-parse', '--verify', '--quiet', 'HEAD', allow_missing=True):
return ()
branch = git(root, 'rev-parse', '--abbrev-ref', 'HEAD').decode('utf-8').strip()
remote = 'refs/remotes/origin/' + branch
baseline = remote if git(root, 'rev-parse', '--verify', '--quiet', remote, allow_missing=True) else None
return refuse_new_collisions(root, tree_paths(root, 'ls-tree', '-rz', '--name-only', 'HEAD'), baseline)
if __name__ == '__main__':
try:
if sys.argv[1] == '--outgoing':
print(names(outgoing(Path(sys.argv[2]))))
elif sys.argv[1] == '--hold':
print(names(hold(Path(sys.argv[2]), sys.argv[3], sys.argv[4])))
else:
verify(Path(sys.argv[1]), sys.argv[2], sys.argv[3])
except (Refusal, OSError, UnicodeError, ValueError, IndexError) as error:
print(names(getattr(error, 'spellings', ())))
print('BRAIN_SYNC: staged artifact validation refused: ' + str(error), file=sys.stderr)
sys.exit(1)