#!/usr/bin/env python3 """Review upstream or test selected commits in a separate checkout; never merge the active branch.""" import argparse from datetime import datetime, timezone import json from pathlib import Path import subprocess import re import sys ROOT=Path(__file__).resolve().parents[1] POLICY=ROOT/'docs/upstream-sync.json' def git(root,*args): return subprocess.check_output(['git','-C',str(root),*args],text=True).strip() def areas(files): groups=[] for label,prefixes in [('graphics',('dm/gfx/',)),('runtime/saves/input',('dm/host/','dm/ios/Dm','dm/ios/Game')), ('game profiles',('dm/games/',)),('build tooling',('scripts/','dm/mac/','dm/ios/tools/','dm/tools/'))]: if any(f.startswith(prefixes) for f in files):groups.append(label) return groups or ['documentation/tests/CI'] def commits(root,base,head,integrated): result=[] for line in git(root,'log','--reverse','--format=%H\t%s',base+'..'+head).splitlines(): sha,title=line.split('\t',1) files=git(root,'diff-tree','--root','--no-commit-id','--name-only','-r',sha).splitlines() result.append(dict(sha=sha,title=title,areas=areas(files),files=files, status='integrated' if sha in integrated else 'needs review')) return result def stage(root,destination,head,selected,integrated): # Resolve and validate every request before creating or cherry-picking anything. if git(root,'status','--porcelain'):raise ValueError('Commit or stash local edits before staging; nothing was changed.') resolved=[] for revision in selected: if revision.startswith('-'):raise ValueError('Expected an upstream commit SHA.') sha=git(root,'rev-parse','--verify',revision+'^{commit}') if sha in integrated:raise ValueError('This upstream commit is already recorded as integrated: '+sha) if subprocess.run(['git','-C',str(root),'merge-base','--is-ancestor',sha,head]).returncode: raise ValueError('Requested commit is not in the pinned upstream history: '+sha) if sha in resolved:raise ValueError('Duplicate commit: '+sha) resolved.append(sha) git(root,'worktree','add','--detach',str(destination),'HEAD') # On conflict leave the candidate and Git's conflict state for inspection. The active checkout is untouched. subprocess.run(['git','-C',str(destination),'cherry-pick','-x',*resolved],check=True) def merge_main(root,destination,head): if git(root,'status','--porcelain'):raise ValueError('Commit or stash local edits before staging; nothing was changed.') git(root,'worktree','add','--detach',str(destination),'HEAD') subprocess.run(['git','-C',str(destination),'merge','--no-edit',head],check=True) def profile_changes(root,head): def profiles(revision): found={} for row in git(root,'ls-tree','-r',revision,'--','dm/games').splitlines(): metadata,path=row.split('\t',1) match=re.fullmatch(r'dm/games/([0-9A-Fa-f]{16})/game.tsv',path) if match:found[match[1].upper()]=(metadata.split()[2],path) return found def summary(revision,title,path): name=title;versions=[] for line in git(root,'show',revision+':'+path).splitlines(): fields=line.split('\t') if fields[0]=='name' and len(fields)>1:name=fields[1] if fields[0]=='build' and len(fields)>2 and fields[2] not in versions:versions.append(fields[2]) return dict(id=title,name=name,versions=versions) old,new=profiles('HEAD'),profiles(head) return dict(added=[summary(head,i,new[i][1]) for i in sorted(new.keys()-old.keys())], updated=[summary(head,i,new[i][1]) for i in sorted(old.keys()&new.keys()) if old[i][0]!=new[i][0]], removed=[summary('HEAD',i,old[i][1]) for i in sorted(old.keys()-new.keys())]) def build_python(): candidates=[Path(sys.executable),ROOT.parent/'venv/bin/python', Path.home()/'Library/Application Support/nxnative Easy/Tools/nxnative-tools/venv/bin/python'] for python in candidates: if python.is_file() and subprocess.run([str(python),'-c','import capstone, lz4, fontTools'], stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode==0: return str(python) raise ValueError('Build-test packages are missing. Run START-HERE.command once, then retry this command.') def validate(candidate,output,python): env=__import__('os').environ.copy();env.setdefault('DM_GAME','010015100B514000') logs=output/'checks';logs.mkdir() subprocess.run([python,str(candidate/'scripts/sync_game_catalog.py'),'--check'],check=True) suites=['scripts/tests','dm/tools/tests','dm/ios/tests','dm/gfx/tests'] for suite in suites: for test in sorted((candidate/suite).glob('test_*.py')): relative=test.relative_to(candidate) with (logs/str(relative).replace('/','_')).with_suffix('.log').open('w') as log: subprocess.run([python,'-m','unittest',str(relative)],cwd=candidate,env=env, stdout=log,stderr=subprocess.STDOUT,check=True) # Passing synthetic checks is not gameplay validation or permission to publish a release. def main(argv=None): parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('--output',required=True,type=Path,help='new directory outside this checkout') mode=parser.add_mutually_exclusive_group() mode.add_argument('--stage',nargs='+',metavar='SHA',help='explicitly reviewed commits, in dependency order; stage and run synthetic checks') mode.add_argument('--merge-main',action='store_true',help='merge pinned upstream main into a detached candidate, synchronize game lists and run checks') args=parser.parse_args(argv) output=args.output.expanduser().resolve() if output==ROOT or ROOT in output.parents or output.exists():parser.error('Choose a new folder outside the source checkout.') policy=json.loads(POLICY.read_text()) remote=git(ROOT,'remote','get-url','upstream').removesuffix('.git') if remote not in ('https://github.com/seedds/nxnative','git@github.com:seedds/nxnative'): parser.error('The upstream remote does not point to seedds/nxnative; no files changed.') git(ROOT,'fetch','upstream','main') head=git(ROOT,'rev-parse','FETCH_HEAD') integrated={item['upstream_sha'] for item in policy['integrated']} base=policy.get('integrated_head',policy['baseline']) rows=commits(ROOT,base,head,integrated) games=profile_changes(ROOT,head) output.mkdir(parents=True) report=dict(reviewed_at=datetime.now(timezone.utc).isoformat(),upstream_head=head,baseline=base,game_changes=games, local_head=git(ROOT,'rev-parse','HEAD'),commits=rows,candidate_status='not staged') report_path=output/'report.json' def save():report_path.write_text(json.dumps(report,indent=2)+'\n') save() lines=['# Upstream review','','Pinned upstream: `'+head+'`. No active branch or game files changed.','', '| Commit | Status | Areas | Change |','|---|---|---|---|'] for row in rows: title=row['title'].replace('|','/').replace('\n',' ') lines.append(f"| `{row['sha'][:12]}` | {row['status']} | {', '.join(row['areas'])} | {title} |") lines+=['','## Game profile changes',''] for status,items in games.items(): lines += [f"- {status}: {g['name']} (`{g['id']}`), versions {', '.join(g['versions'])}" for g in items] (output/'report.md').write_text('\n'.join(lines)+'\n') print('Review:',output/'report.md',flush=True) if args.stage or args.merge_main: report['requested_commits']=args.stage;report['mode']='merge-main' if args.merge_main else 'selected commits';report['candidate_status']='staging';save() try: python=build_python() if args.merge_main:merge_main(ROOT,output/'candidate',head) else:stage(ROOT,output/'candidate',head,args.stage,integrated) subprocess.run([python,str(output/'candidate/scripts/sync_game_catalog.py'),'--write','--upstream-ref',head],check=True) report['candidate_status']='checking';save() validate(output/'candidate',output,python) report['candidate_status']='synthetic checks passed; gameplay and release review still required';save() except Exception: report['candidate_status']='failed; inspect candidate and checks, active checkout preserved';save();raise print('Candidate:',output/'candidate') print('Inspect changes, compile affected targets and test games before integration.') else: print('To evaluate selected commits, run again with a new --output folder and --merge-main, or --stage SHA [SHA ...].') return 0 if __name__=='__main__': try:sys.exit(main()) except (ValueError,OSError,subprocess.SubprocessError) as error: print('Error:',error,file=sys.stderr);sys.exit(1)