412 lines
23 KiB
Python
412 lines
23 KiB
Python
"""Initialize three ordinary resource copies from a frozen Git commit.
|
||||
|
|
|
|||
|
|
Run from the primary repository. This script creates no branches or worktrees,
|
|||
|
|
does not launch Unity/Blender, and never overwrites an existing resource copy.
|
|||
|
|
"""
|
|||
|
|
import argparse
|
|||
|
|
import ast
|
|||
|
|
from datetime import datetime, timedelta, timezone
|
|||
|
|
import hashlib
|
|||
|
|
import json
|
|||
|
|
from pathlib import Path, PurePosixPath
|
|||
|
|
import re
|
|||
|
|
import shutil
|
|||
|
|
import stat
|
|||
|
|
import subprocess
|
|||
|
|
import uuid
|
|||
|
|
import zipfile
|
|||
|
|
|
|||
|
|
|
|||
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|||
|
|
NAMES = ('tree1', 'tree2', 'tree3')
|
|||
|
|
ADAPTED = (
|
|||
|
|
'Tools/Unity/Commands/Build.json',
|
|||
|
|
'Tools/Unity/Commands/LocalizationBuild.json',
|
|||
|
|
'Tools/Unity/Commands/Restart.json',
|
|||
|
|
'Tools/Blender/CreatePrototypeMech.py',
|
|||
|
|
)
|
|||
|
|
GENERATED = ('WORKSPACE.md', 'WorkspaceInfo.json', 'InitializationManifest.json')
|
|||
|
|
FORBIDDEN = {'.git', 'library', 'temp', 'obj', 'logs', 'usersettings', 'builds', '.vs', '.idea', 'memorycaptures', 'recordings'}
|
|||
|
|
HK = timezone(timedelta(hours=8), 'Asia/Hong_Kong')
|
|||
|
|
|
|||
|
|
|
|||
|
|
def git(*args, check=True):
|
|||
|
|
return subprocess.run(['git', '-C', str(ROOT), *args], capture_output=True, check=check)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def git_text(*args):
|
|||
|
|
return git(*args).stdout.decode('utf-8').strip()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def sha(data):
|
|||
|
|
return hashlib.sha256(data).hexdigest()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def file_sha(path):
|
|||
|
|
with path.open('rb') as stream:
|
|||
|
|
return hashlib.file_digest(stream, 'sha256').hexdigest()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def safe_child(base, path):
|
|||
|
|
"""Resolve paths before any directory publication/move or file creation."""
|
|||
|
|
base = base.resolve()
|
|||
|
|
resolved = path.resolve()
|
|||
|
|
if resolved == base or not resolved.is_relative_to(base):
|
|||
|
|
raise RuntimeError(f'Path leaves the allowed directory: {path}')
|
|||
|
|
return resolved
|
|||
|
|
|
|||
|
|
|
|||
|
|
def is_link(path):
|
|||
|
|
attributes = getattr(path.lstat(), 'st_file_attributes', 0)
|
|||
|
|
return path.is_symlink() or bool(attributes & getattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0x400))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def write_json(path, data):
|
|||
|
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main_assets_state():
|
|||
|
|
return {p.relative_to(ROOT).as_posix(): file_sha(p) for p in sorted((ROOT / 'Assets').rglob('*')) if p.is_file()}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def git_state():
|
|||
|
|
return {
|
|||
|
|
'head': git_text('rev-parse', 'HEAD'),
|
|||
|
|
'branches': git_text('for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads'),
|
|||
|
|
'worktrees': git_text('worktree', 'list', '--porcelain'),
|
|||
|
|
'index': git_text('ls-files', '--stage'),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def baseline_files(commit, run):
|
|||
|
|
modes = {}
|
|||
|
|
for record in git('ls-tree', '-r', '-z', commit).stdout.split(b'\0'):
|
|||
|
|
if not record:
|
|||
|
|
continue
|
|||
|
|
meta, raw_path = record.split(b'\t', 1)
|
|||
|
|
mode, kind, _ = meta.decode('ascii').split()
|
|||
|
|
name = raw_path.decode('utf-8')
|
|||
|
|
path = PurePosixPath(name)
|
|||
|
|
if path.is_absolute() or '..' in path.parts or '\\' in name or ':' in name:
|
|||
|
|
raise RuntimeError(f'Invalid archive path: {name}')
|
|||
|
|
if path.parts[0].lower() in FORBIDDEN or path.parts[0] in NAMES:
|
|||
|
|
raise RuntimeError(f'Generated directory is tracked in baseline: {name}')
|
|||
|
|
if mode not in ('100644', '100755') or kind != 'blob':
|
|||
|
|
raise RuntimeError(f'Only ordinary files are supported: {mode} {name}')
|
|||
|
|
modes[name] = mode
|
|||
|
|
archive_path = run / 'baseline.zip'
|
|||
|
|
with archive_path.open('xb') as stream:
|
|||
|
|
subprocess.run(['git', '-C', str(ROOT), 'archive', '--format=zip', commit], stdout=stream, check=True)
|
|||
|
|
with zipfile.ZipFile(archive_path) as archive:
|
|||
|
|
files = {i.filename: archive.read(i) for i in archive.infolist() if not i.is_dir()}
|
|||
|
|
if set(files) != set(modes):
|
|||
|
|
raise RuntimeError('Archive does not match the frozen Git tree.')
|
|||
|
|
if set(GENERATED) & set(files):
|
|||
|
|
raise RuntimeError('Workspace metadata would overwrite baseline files.')
|
|||
|
|
for name in ADAPTED:
|
|||
|
|
if name not in files:
|
|||
|
|
raise RuntimeError(f'Expected tool is missing: {name}')
|
|||
|
|
return files
|
|||
|
|
|
|||
|
|
|
|||
|
|
def source_root(files):
|
|||
|
|
text = files[ADAPTED[3]].decode('utf-8')
|
|||
|
|
match = re.search(r"^root_path = Path\('([^']+)'\)$", text, re.MULTILINE)
|
|||
|
|
if not match:
|
|||
|
|
raise RuntimeError('The Blender root constant is not recognized.')
|
|||
|
|
return match.group(1)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def adapted_files(files, destination):
|
|||
|
|
before_root = source_root(files)
|
|||
|
|
after_root = destination.as_posix()
|
|||
|
|
output = dict(files)
|
|||
|
|
records = []
|
|||
|
|
for name in ADAPTED:
|
|||
|
|
if name.endswith('.json'):
|
|||
|
|
commands = json.loads(files[name].decode('utf-8'))
|
|||
|
|
if len(commands) != 1:
|
|||
|
|
raise RuntimeError(f'Expected one command in {name}')
|
|||
|
|
arguments = commands[0]['arguments']
|
|||
|
|
if name.endswith('/Restart.json'):
|
|||
|
|
previous = arguments['code']
|
|||
|
|
expected = f'UnityEditor.EditorApplication.OpenProject("{before_root}")'
|
|||
|
|
replacement = f'UnityEditor.EditorApplication.OpenProject("{after_root}")'
|
|||
|
|
if previous.count(expected) != 1:
|
|||
|
|
raise RuntimeError(f'Unexpected restart target in {name}')
|
|||
|
|
arguments['code'] = previous.replace(expected, replacement)
|
|||
|
|
clone = json.loads(json.dumps(commands))
|
|||
|
|
clone[0]['arguments']['code'] = clone[0]['arguments']['code'].replace(replacement, expected)
|
|||
|
|
else:
|
|||
|
|
previous = arguments['output_path']
|
|||
|
|
relative = PurePosixPath(previous).relative_to(PurePosixPath(before_root))
|
|||
|
|
if relative.parts[0] != 'Builds' or '..' in relative.parts:
|
|||
|
|
raise RuntimeError(f'Unexpected build output in {name}')
|
|||
|
|
arguments['output_path'] = destination.joinpath(*relative.parts).as_posix()
|
|||
|
|
safe_child(destination, Path(arguments['output_path']))
|
|||
|
|
clone = json.loads(json.dumps(commands))
|
|||
|
|
clone[0]['arguments']['output_path'] = previous
|
|||
|
|
if clone != json.loads(files[name].decode('utf-8')):
|
|||
|
|
raise RuntimeError(f'Unexpected semantic change to {name}')
|
|||
|
|
output[name] = (json.dumps(commands, ensure_ascii=False, indent=2) + '\n').encode('utf-8')
|
|||
|
|
else:
|
|||
|
|
previous = files[name].decode('utf-8')
|
|||
|
|
expected = f"root_path = Path('{before_root}')"
|
|||
|
|
replacement = f"root_path = Path('{after_root}')"
|
|||
|
|
if previous.count(expected) != 1:
|
|||
|
|
raise RuntimeError('Unexpected Blender root constant.')
|
|||
|
|
result = previous.replace(expected, replacement)
|
|||
|
|
ast.parse(result, filename=name)
|
|||
|
|
if result.replace(replacement, expected) != previous:
|
|||
|
|
raise RuntimeError('Unexpected Blender source edit.')
|
|||
|
|
output[name] = result.encode('utf-8')
|
|||
|
|
if output[name] == files[name]:
|
|||
|
|
raise RuntimeError(f'Expected a path adaptation: {name}')
|
|||
|
|
records.append({'path': name, 'fromRoot': before_root, 'toRoot': after_root,
|
|||
|
|
'sourceSha256': sha(files[name]), 'sha256': sha(output[name])})
|
|||
|
|
return output, records
|
|||
|
|
|
|||
|
|
|
|||
|
|
def workspace_info(name, destination, commit, initialized):
|
|||
|
|
return {
|
|||
|
|
'schemaVersion': 1, 'name': name, 'stage': 'resources-only', 'initializedAt': initialized,
|
|||
|
|
'sourceRepository': ROOT.as_posix(), 'sourceCommit': commit,
|
|||
|
|
'projectRoot': destination.as_posix(), 'unityVersion': '6000.6.8f1',
|
|||
|
|
'git': {'kind': 'ordinary-directory', 'branchCreated': False, 'worktreeRegistered': False},
|
|||
|
|
'mcp': {'expectedProjectRoot': destination.as_posix(), 'instanceId': None, 'connected': False},
|
|||
|
|
'runtimeDataIsolation': {'status': 'pending-before-play',
|
|||
|
|
'reason': 'The copied companyName/productName and PlayerPrefs namespace still match the baseline.'},
|
|||
|
|
'unityImport': 'not-run', 'unityCompilation': 'not-run', 'unityBuild': 'not-run',
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def create_copy(staging, destination, name, files, commit, initialized):
|
|||
|
|
staging.mkdir()
|
|||
|
|
output, adaptations = adapted_files(files, destination)
|
|||
|
|
for relative, data in output.items():
|
|||
|
|
target = safe_child(staging, staging / relative)
|
|||
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
with target.open('xb') as stream:
|
|||
|
|
stream.write(data)
|
|||
|
|
# Git cannot store empty folders; Unity folder metadata can describe one.
|
|||
|
|
for relative, data in files.items():
|
|||
|
|
if relative.startswith('Assets/') and relative.endswith('.meta') and re.search(rb'^folderAsset:\s*yes\s*$', data, re.MULTILINE):
|
|||
|
|
safe_child(staging, staging / relative[:-5]).mkdir(parents=True, exist_ok=True)
|
|||
|
|
write_json(staging / 'WorkspaceInfo.json', workspace_info(name, destination, commit, initialized))
|
|||
|
|
(staging / 'WORKSPACE.md').write_text(
|
|||
|
|
f'# {name} 资源目录\n\n'
|
|||
|
|
f'基线:`{commit}`。Unity:`6000.6.8f1`。目标:`{destination.as_posix()}`。\n\n'
|
|||
|
|
'本目录是普通资源副本,未建立独立 Git 仓库、分支或 worktree。'
|
|||
|
|
'目录内部的 Git 命令可能识别到父仓库,不应作为独立版本记录使用。\n\n'
|
|||
|
|
'资源、GUID、制作源文件与许可已复制;四份工具的输出或重开路径已指向本目录。'
|
|||
|
|
'路径适配及逐文件校验见 InitializationManifest.json。历史文档中的路径仍记录原来源。\n\n'
|
|||
|
|
'MCP 尚未连接,Unity 尚未导入、编译或构建。首次使用需要为本目录选择明确的 Unity 实例与完整路径。'
|
|||
|
|
'默认存档和 PlayerPrefs 命名空间尚未隔离,完成隔离前不要并行运行游戏。\n\n'
|
|||
|
|
'后续按主工程 Docs/Production/TreeInitializationPlan.md 迁移至正式开发工作区。\n',
|
|||
|
|
encoding='utf-8')
|
|||
|
|
generated = [{'path': relative, 'bytes': (staging / relative).stat().st_size,
|
|||
|
|
'sha256': file_sha(staging / relative)} for relative in GENERATED[:2]]
|
|||
|
|
manifest = {
|
|||
|
|
'schemaVersion': 1, 'sourceCommit': commit, 'projectRoot': destination.as_posix(),
|
|||
|
|
'sourceFileCount': len(files), 'sourceBytes': sum(map(len, files.values())),
|
|||
|
|
'adaptations': adaptations,
|
|||
|
|
'files': [{'path': path, 'bytes': len(data), 'sourceSha256': sha(files[path]),
|
|||
|
|
'sha256': sha(data), 'adapted': path in ADAPTED} for path, data in sorted(output.items())],
|
|||
|
|
'generatedFiles': generated,
|
|||
|
|
'selfHash': 'Recorded in the primary repository initialization report, not inside this manifest.',
|
|||
|
|
}
|
|||
|
|
write_json(staging / 'InitializationManifest.json', manifest)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def validate_copy(directory, destination, name, files, commit):
|
|||
|
|
output, adaptations = adapted_files(files, destination)
|
|||
|
|
metadata = json.loads((directory / 'WorkspaceInfo.json').read_text(encoding='utf-8'))
|
|||
|
|
if metadata['name'] != name or metadata['sourceCommit'] != commit or metadata['projectRoot'] != destination.as_posix():
|
|||
|
|
raise RuntimeError(f'Existing workspace has a different identity: {directory}')
|
|||
|
|
if metadata['stage'] != 'resources-only' or metadata['git'] != {'kind': 'ordinary-directory', 'branchCreated': False, 'worktreeRegistered': False}:
|
|||
|
|
raise RuntimeError(f'Workspace has advanced beyond resource initialization: {directory}')
|
|||
|
|
if metadata['mcp'] != {'expectedProjectRoot': destination.as_posix(), 'instanceId': None, 'connected': False}:
|
|||
|
|
raise RuntimeError(f'Workspace connection state changed: {directory}')
|
|||
|
|
manifest = json.loads((directory / 'InitializationManifest.json').read_text(encoding='utf-8'))
|
|||
|
|
if manifest['sourceCommit'] != commit or manifest['projectRoot'] != destination.as_posix() or manifest['adaptations'] != adaptations:
|
|||
|
|
raise RuntimeError(f'Manifest baseline or adaptations differ: {directory}')
|
|||
|
|
if manifest['sourceFileCount'] != len(files) or manifest['sourceBytes'] != sum(map(len, files.values())):
|
|||
|
|
raise RuntimeError(f'Manifest baseline count or size differs: {directory}')
|
|||
|
|
recorded = {entry['path']: entry for entry in manifest['files']}
|
|||
|
|
if len(recorded) != len(files) or set(recorded) != set(output):
|
|||
|
|
raise RuntimeError(f'Manifest file set differs: {directory}')
|
|||
|
|
found = []
|
|||
|
|
for path in directory.rglob('*'):
|
|||
|
|
safe_child(directory, path)
|
|||
|
|
if is_link(path):
|
|||
|
|
raise RuntimeError(f'Linked path is not allowed: {path}')
|
|||
|
|
if path.is_file():
|
|||
|
|
if path.stat().st_nlink != 1:
|
|||
|
|
raise RuntimeError(f'Hard-linked file is not allowed: {path}')
|
|||
|
|
found.append(path.relative_to(directory).as_posix())
|
|||
|
|
if set(found) != set(files) | set(GENERATED):
|
|||
|
|
raise RuntimeError(f'Unexpected or missing files in {directory}')
|
|||
|
|
if any((directory / item).exists() for item in ('.git', 'Library', 'Temp', 'Obj', 'Logs', 'UserSettings', 'Builds', '.vs', '.idea')):
|
|||
|
|
raise RuntimeError(f'Generated directory was copied: {directory}')
|
|||
|
|
for relative, expected in output.items():
|
|||
|
|
path = directory / relative
|
|||
|
|
entry = recorded[relative]
|
|||
|
|
if entry != {'path': relative, 'bytes': len(expected), 'sourceSha256': sha(files[relative]),
|
|||
|
|
'sha256': sha(expected), 'adapted': relative in ADAPTED}:
|
|||
|
|
raise RuntimeError(f'Manifest hash is not derived from the baseline: {relative}')
|
|||
|
|
if path.stat().st_size != len(expected) or file_sha(path) != sha(expected):
|
|||
|
|
raise RuntimeError(f'File does not match baseline/adaptation: {path}')
|
|||
|
|
if relative.endswith('.json'):
|
|||
|
|
json.loads(path.read_text(encoding='utf-8-sig'))
|
|||
|
|
if relative.startswith('Tools/') and relative.endswith('.py'):
|
|||
|
|
ast.parse(path.read_text(encoding='utf-8-sig'), filename=relative)
|
|||
|
|
if {entry['path'] for entry in manifest['generatedFiles']} != set(GENERATED[:2]) or len(manifest['generatedFiles']) != 2:
|
|||
|
|
raise RuntimeError('Generated metadata records differ.')
|
|||
|
|
for entry in manifest['generatedFiles']:
|
|||
|
|
path = directory / entry['path']
|
|||
|
|
if path.stat().st_size != entry['bytes'] or file_sha(path) != entry['sha256']:
|
|||
|
|
raise RuntimeError(f'Generated metadata was changed: {path}')
|
|||
|
|
assets = directory / 'Assets'
|
|||
|
|
meta_count = 0
|
|||
|
|
guids = set()
|
|||
|
|
for path in assets.rglob('*'):
|
|||
|
|
if path.name.endswith('.meta'):
|
|||
|
|
meta_count += 1
|
|||
|
|
if not path.with_suffix('').exists():
|
|||
|
|
raise RuntimeError(f'Orphan Unity metadata: {path}')
|
|||
|
|
match = re.search(r'^guid: ([a-f0-9]{32})$', path.read_text(encoding='utf-8'), re.MULTILINE)
|
|||
|
|
if not match or match.group(1) in guids:
|
|||
|
|
raise RuntimeError(f'Missing or duplicate Unity GUID: {path}')
|
|||
|
|
guids.add(match.group(1))
|
|||
|
|
elif not Path(str(path) + '.meta').is_file():
|
|||
|
|
raise RuntimeError(f'Unity asset has no metadata: {path}')
|
|||
|
|
required = (
|
|||
|
|
'Assets/Scenes/Bootstrap.unity', 'Assets/Scenes/MechTestRange.unity', 'Assets/Scenes/WarzonePrototype.unity',
|
|||
|
|
'Assets/Game/UI/Fonts/GameFont.asset', 'Assets/Game/UI/Fonts/GameTextSettings.asset',
|
|||
|
|
'Assets/Game/UI/GamePanelSettings.asset', 'Assets/Game/Localization/LocalizationCatalog.asset',
|
|||
|
|
'Assets/Game/Localization/Tables/en.json', 'Assets/Game/Localization/Tables/zh-Hans.json',
|
|||
|
|
'Assets/ThirdParty/Adobe/NotoSansSC/NotoSansSC-VF.ttf', 'Assets/ThirdParty/Adobe/NotoSansSC/OFL.txt',
|
|||
|
|
'SourceAssets/Blender/Mechs/Mech_Scout_A.blend', 'SourceAssets/ThirdParty/Adobe/NotoSansSC/NotoSansSC-VF.ttf',
|
|||
|
|
'Docs/Production/Licenses/Adobe/NotoSansSC/OFL.txt', 'Packages/manifest.json', 'Packages/packages-lock.json',
|
|||
|
|
)
|
|||
|
|
if any(not (directory / path).is_file() for path in required):
|
|||
|
|
raise RuntimeError('Required engine resource, source or license is missing.')
|
|||
|
|
if 'm_EditorVersion: 6000.6.8f1' not in (directory / 'ProjectSettings/ProjectVersion.txt').read_text():
|
|||
|
|
raise RuntimeError('Unexpected Unity version.')
|
|||
|
|
tables = [json.loads((directory / f'Assets/Game/Localization/Tables/{code}.json').read_text(encoding='utf-8'))['entries'] for code in ('en', 'zh-Hans')]
|
|||
|
|
keys = [{entry['key'] for entry in entries} for entries in tables]
|
|||
|
|
if keys[0] != keys[1] or any(len(entries) != len(key_set) for entries, key_set in zip(tables, keys)):
|
|||
|
|
raise RuntimeError('Localization table keys do not match.')
|
|||
|
|
return {'name': name, 'path': destination.as_posix(), 'status': 'passed', 'sourceCommit': commit,
|
|||
|
|
'sourceFiles': len(files), 'totalFiles': len(found), 'bytes': sum((directory / item).stat().st_size for item in found),
|
|||
|
|
'adaptedFiles': len(ADAPTED), 'unchangedFiles': len(files) - len(ADAPTED),
|
|||
|
|
'unityMetaFiles': meta_count, 'uniqueUnityGuids': len(guids), 'localizationKeysPerLocale': len(keys[0]),
|
|||
|
|
'independentOrdinaryFiles': True, 'cacheAndGitExcluded': True, 'hashValidation': 'passed',
|
|||
|
|
'manifestSha256': file_sha(directory / 'InitializationManifest.json')}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|||
|
|
parser.add_argument('--base', default='HEAD', help='HEAD or a hexadecimal Git commit ID.')
|
|||
|
|
args = parser.parse_args()
|
|||
|
|
if not re.fullmatch(r'HEAD|[a-fA-F0-9]{7,40}', args.base):
|
|||
|
|
raise RuntimeError('Use HEAD or a hexadecimal commit ID for --base.')
|
|||
|
|
if Path(git_text('rev-parse', '--show-toplevel')).resolve() != ROOT:
|
|||
|
|
raise RuntimeError('Run this tool from the primary repository, not a resource copy.')
|
|||
|
|
commit = git_text('rev-parse', '--verify', args.base + '^{commit}')
|
|||
|
|
state_before = git_state()
|
|||
|
|
assets_before = main_assets_state()
|
|||
|
|
targets = {}
|
|||
|
|
for name in NAMES:
|
|||
|
|
raw = ROOT / name
|
|||
|
|
if raw.exists() or raw.is_symlink():
|
|||
|
|
if is_link(raw) or not raw.is_dir():
|
|||
|
|
raise RuntimeError(f'Target is not an ordinary directory: {raw}')
|
|||
|
|
target = safe_child(ROOT, raw)
|
|||
|
|
if target.parent != ROOT or target.name != name:
|
|||
|
|
raise RuntimeError(f'Target must be a direct child of the primary repository: {target}')
|
|||
|
|
if git('check-ignore', '-q', '--', name + '/Assets').returncode != 0:
|
|||
|
|
raise RuntimeError(f'Add /{name}/ to the primary .gitignore before initialization.')
|
|||
|
|
if git_text('ls-files', '--', name):
|
|||
|
|
raise RuntimeError(f'Target already contains tracked files: {name}')
|
|||
|
|
targets[name] = target
|
|||
|
|
initialized = datetime.now(HK).isoformat(timespec='seconds')
|
|||
|
|
run_id = datetime.now(HK).strftime('%Y%m%dT%H%M%S') + '-' + uuid.uuid4().hex[:8]
|
|||
|
|
logs = safe_child(ROOT, ROOT / 'Logs/WorkspaceInitialization')
|
|||
|
|
run = safe_child(logs, logs / run_id)
|
|||
|
|
run.mkdir(parents=True)
|
|||
|
|
files = baseline_files(commit, run)
|
|||
|
|
required_bytes = sum(map(len, files.values())) * (sum(not p.exists() for p in targets.values()) + 1) + 64 * 1024 * 1024
|
|||
|
|
free_bytes = shutil.disk_usage(ROOT).free
|
|||
|
|
if free_bytes < required_bytes:
|
|||
|
|
raise RuntimeError(f'Not enough free disk space: {free_bytes} < {required_bytes}')
|
|||
|
|
prepared = {}
|
|||
|
|
outcomes = {}
|
|||
|
|
# All three copies are validated before publishing any new directory.
|
|||
|
|
for name, destination in targets.items():
|
|||
|
|
if destination.exists():
|
|||
|
|
outcomes[name] = 'verified-existing'
|
|||
|
|
validate_copy(destination, destination, name, files, commit)
|
|||
|
|
prepared[name] = destination
|
|||
|
|
else:
|
|||
|
|
staging = safe_child(run, run / name)
|
|||
|
|
create_copy(staging, destination, name, files, commit, initialized)
|
|||
|
|
validate_copy(staging, destination, name, files, commit)
|
|||
|
|
prepared[name] = staging
|
|||
|
|
outcomes[name] = 'created'
|
|||
|
|
results = []
|
|||
|
|
for name, destination in targets.items():
|
|||
|
|
source = prepared[name]
|
|||
|
|
if source != destination:
|
|||
|
|
# Recheck resolved absolute source and destination immediately before moving.
|
|||
|
|
if safe_child(run, source) != source or safe_child(ROOT, destination) != destination:
|
|||
|
|
raise RuntimeError('Publication paths changed.')
|
|||
|
|
if destination.exists() or destination.is_symlink():
|
|||
|
|
raise RuntimeError(f'Target appeared during preparation; refusing to overwrite: {destination}')
|
|||
|
|
source.rename(destination)
|
|||
|
|
result = validate_copy(destination, destination, name, files, commit)
|
|||
|
|
result['operation'] = outcomes[name]
|
|||
|
|
if git('check-ignore', '-q', '--', name + '/WorkspaceInfo.json').returncode != 0:
|
|||
|
|
raise RuntimeError('Published copy is not ignored by the primary repository.')
|
|||
|
|
results.append(result)
|
|||
|
|
if git_state() != state_before:
|
|||
|
|
raise RuntimeError('Primary HEAD, branches, worktrees or index changed during initialization.')
|
|||
|
|
if main_assets_state() != assets_before:
|
|||
|
|
raise RuntimeError('Primary Assets changed during initialization.')
|
|||
|
|
report = {
|
|||
|
|
'schemaVersion': 1, 'initializedAt': initialized, 'runId': run_id,
|
|||
|
|
'sourceRepository': ROOT.as_posix(), 'sourceCommit': commit,
|
|||
|
|
'baselineFileCount': len(files), 'baselineBytes': sum(map(len, files.values())),
|
|||
|
|
'freeDiskBytesAtStart': free_bytes, 'trees': results,
|
|||
|
|
'primaryAssetsUnchanged': True, 'gitHeadUnchanged': True, 'gitBranchesUnchanged': True,
|
|||
|
|
'gitWorktreeRegistrationsUnchanged': True, 'gitIndexUnchanged': True,
|
|||
|
|
'primaryGitState': state_before,
|
|||
|
|
'scope': 'Static resource initialization only; Unity/Blender/MCP were not started.',
|
|||
|
|
'pending': ['Unity package resolution/import/compilation', 'MCP explicit instance binding',
|
|||
|
|
'Separate test saves and PlayerPrefs before parallel play', 'Branches/worktrees only when requested'],
|
|||
|
|
}
|
|||
|
|
write_json(run / 'Result.json', report)
|
|||
|
|
qa = safe_child(ROOT, ROOT / 'Docs/QA/Workspaces')
|
|||
|
|
qa.mkdir(parents=True, exist_ok=True)
|
|||
|
|
write_json(qa / 'Initialization.json', report)
|
|||
|
|
rows = '\n'.join(f"| {r['name']} | {r['totalFiles']} | {r['bytes'] / 1048576:.2f} MiB | {r['unityMetaFiles']} | {r['operation']} / passed |" for r in results)
|
|||
|
|
(qa / 'Initialization.md').write_text(
|
|||
|
|
'# tree1–tree3 资源初始化验收\n\n'
|
|||
|
|
f'时间:{initialized}(Asia/Hong_Kong)。基线:`{commit}`。\n\n'
|
|||
|
|
'| 目录 | 文件数 | 大小 | Unity .meta | 结果 |\n| --- | --- | --- | --- | --- |\n' + rows + '\n\n'
|
|||
|
|
f'每份包含 {len(files)} 个基线文件、4 份工具路径适配及 3 份工作区说明/清单文件。'
|
|||
|
|
f'其中 {len(files) - 4} 个基线文件逐字节保留;适配文件与源、目标路径均按清单验证。\n\n'
|
|||
|
|
'检查通过:逐文件 SHA-256、资产与 .meta 配对、GUID 唯一且与基线一致、'
|
|||
|
|
'中英文表键一致、源模型/字体/许可/包锁完整、无共享可写链接、无缓存或构建包、'
|
|||
|
|
'父仓库忽略规则生效。主工程 Assets、HEAD、分支、worktree 注册及 Git 暂存区均保持原状。\n\n'
|
|||
|
|
'三个目录均为普通资源副本,没有独立 Git 历史。MCP 实例未连接;'
|
|||
|
|
'Unity 导入、编译、播放和构建未执行;并行运行前仍需隔离存档及 PlayerPrefs。\n\n'
|
|||
|
|
'完整记录见 [Initialization.json](Initialization.json),目录内的 InitializationManifest.json '
|
|||
|
|
'提供每个基线文件与适配项的哈希。执行计划见 [TreeInitializationPlan.md](../../Production/TreeInitializationPlan.md)。\n',
|
|||
|
|
encoding='utf-8')
|
|||
|
|
print(json.dumps({key: report[key] for key in ('runId', 'sourceCommit', 'baselineFileCount', 'trees', 'primaryAssetsUnchanged', 'gitBranchesUnchanged', 'gitWorktreeRegistrationsUnchanged')}, ensure_ascii=False, indent=2))
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == '__main__':
|
|||
|
|
main()
|