mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* 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>
159 lines
6.8 KiB
Python
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)
|