Skip to content

Commit 4a6489e

Browse files
committed
feat: bridge Codex v0.5–v0.6 transactional deploy + rollback (2b/2)
Complete Phase 2 (D2-c) of the Codex-Game-Studios v0.5–v0.6 bridge: make the cross-target deploy transactional so a mid-deploy failure restores the target to its pre-deploy state. - install.sh: wrap the deploy loop with a transaction — snapshot every to-be-overwritten file (standard-copy on-diff and marker-splice branches) into .opencode/backups/txn-<stamp>/modified/ before mutating, record newly-created paths, and guard the loop with try/except. On failure, _rollback() restores modified files from snapshots, removes created files, promotes txn-<stamp>/ to rollback-<stamp>/ for inspection, and exits 1 (bash set -e then halts before model injection). On success the ephemeral txn dir is deleted; the per-file backup-if-differs keeps persistent history (now sharing one stamp dir per deploy). - README.md / UPGRADING.md: document the transactional deploy and the rollback-<timestamp>/ record. Verified: bash -n OK; audit.sh all 0 errors; 2a regression matrix 13/13; forced-failure rollback matrix 8/8 (mid-deploy PermissionError -> .gitignore restored to pre-deploy content, re-created README.md removed, rollback-<stamp>/ promoted, no txn- lingering, exit 1). Known limitation: newly-created empty parent dirs may linger after rollback (best-effort, documented in the rollback message). Self-install (source==target) is unaffected.
1 parent 5c2fb50 commit 4a6489e

3 files changed

Lines changed: 135 additions & 64 deletions

File tree

.opencode/install.sh

Lines changed: 126 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,55 @@ if _collisions or (_modified and not replace_modified):
321321
sys.exit(1)
322322
# end preflight
323323
324-
import filecmp
324+
import filecmp, datetime
325+
326+
# Transaction setup: snapshot to-be-modified files and record to-be-created
327+
# files so a mid-deploy failure can roll the target back to its pre-deploy state.
328+
_stamp = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ')
329+
_txn_dir = os.path.join(target, '.opencode', 'backups', 'txn-' + _stamp)
330+
_txn_modified = {} # rel -> snapshot path under _txn_dir
331+
_txn_created = [] # rels newly created (did not exist pre-deploy)
332+
333+
def _snapshot(rel, dst):
334+
if rel in _txn_modified:
335+
return
336+
snap = os.path.join(_txn_dir, 'modified', rel)
337+
os.makedirs(os.path.dirname(snap), exist_ok=True)
338+
shutil.copy2(dst, snap)
339+
_txn_modified[rel] = snap
340+
341+
def _rollback(reason):
342+
print(f'\n-- Deploy failed: {reason} -- rolling back --', file=sys.stderr)
343+
restored = removed = failed = 0
344+
for rel, snap in _txn_modified.items():
345+
try:
346+
shutil.copy2(snap, os.path.join(target, rel))
347+
restored += 1
348+
except Exception as ex:
349+
print(f' WARN: could not restore {rel}: {ex}', file=sys.stderr)
350+
failed += 1
351+
for rel in _txn_created:
352+
try:
353+
if os.path.lexists(os.path.join(target, rel)):
354+
os.remove(os.path.join(target, rel))
355+
removed += 1
356+
except Exception as ex:
357+
print(f' WARN: could not remove created {rel}: {ex}', file=sys.stderr)
358+
failed += 1
359+
rb_dir = os.path.join(target, '.opencode', 'backups', 'rollback-' + _stamp)
360+
try:
361+
if os.path.isdir(_txn_dir) and not os.path.exists(rb_dir):
362+
os.rename(_txn_dir, rb_dir)
363+
except Exception:
364+
pass
365+
print(f' Restored {restored} modified file(s), removed {removed} created file(s).', file=sys.stderr)
366+
print(f' Rollback record: {rb_dir}', file=sys.stderr)
367+
if failed:
368+
print(f' {failed} file(s) could not be reverted — inspect manually.', file=sys.stderr)
369+
print(' Newly-created empty parent directories may linger; remove them if desired.', file=sys.stderr)
370+
sys.exit(1)
371+
372+
os.makedirs(_txn_dir, exist_ok=True)
325373
326374
copied = 0
327375
skipped_foreign = 0
@@ -331,76 +379,90 @@ marker_files = 0
331379
preserved_list = []
332380
created_list = []
333381
334-
for entry in data.get('files', []):
335-
rel = entry['path']
336-
mode_flag = entry.get('mode', 'copy')
337-
src = os.path.join(source, rel)
338-
dst = os.path.join(target, rel)
382+
try:
383+
for entry in data.get('files', []):
384+
rel = entry['path']
385+
mode_flag = entry.get('mode', 'copy')
386+
src = os.path.join(source, rel)
387+
dst = os.path.join(target, rel)
339388
340-
if not os.path.exists(src):
341-
continue
342-
343-
# Hard guard: refuse foreign-runtime paths
344-
if is_foreign(rel):
345-
print(f' REFUSED (foreign): {rel}', file=sys.stderr)
346-
skipped_foreign += 1
347-
continue
389+
if not os.path.exists(src):
390+
continue
348391
349-
# Coexistence: preserve ALL existing non-.opencode files (not just shared paths)
350-
if is_coexist_mode(mode) and os.path.exists(dst) and not rel.startswith('.opencode/'):
351-
preserved_list.append(rel)
352-
preserved += 1
353-
continue
392+
# Hard guard: refuse foreign-runtime paths
393+
if is_foreign(rel):
394+
print(f' REFUSED (foreign): {rel}', file=sys.stderr)
395+
skipped_foreign += 1
396+
continue
354397
355-
os.makedirs(os.path.dirname(dst), exist_ok=True)
398+
# Coexistence: preserve ALL existing non-.opencode files (not just shared paths)
399+
if is_coexist_mode(mode) and os.path.exists(dst) and not rel.startswith('.opencode/'):
400+
preserved_list.append(rel)
401+
preserved += 1
402+
continue
356403
357-
if mode_flag == 'marker':
358-
# Marker-block splice
359-
if os.path.isfile(dst) and marker_start in open(dst).read():
360-
with open(src) as f:
361-
src_txt = f.read()
362-
with open(dst) as f:
363-
dst_txt = f.read()
364-
m = re.search(r'(' + re.escape(marker_start) + r'.*?' + re.escape(marker_end) + r')', src_txt, re.S)
365-
if m:
366-
dst_txt = re.sub(
367-
re.escape(marker_start) + r'.*?' + re.escape(marker_end),
368-
m.group(1), dst_txt, flags=re.S
369-
)
370-
with open(dst, 'w') as f:
371-
f.write(dst_txt)
372-
marker_files += 1
373-
elif os.path.isfile(dst):
374-
with open(src) as f:
375-
src_txt = f.read()
376-
m = re.search(r'(' + re.escape(marker_start) + r'.*?' + re.escape(marker_end) + r')', src_txt, re.S)
377-
if m:
378-
with open(dst, 'a') as f:
379-
f.write('\n' + m.group(1) + '\n')
380-
marker_files += 1
404+
os.makedirs(os.path.dirname(dst), exist_ok=True)
405+
406+
if mode_flag == 'marker':
407+
# Marker-block splice
408+
if os.path.isfile(dst):
409+
_snapshot(rel, dst) # splice/append modifies the existing file
410+
if marker_start in open(dst).read():
411+
with open(src) as f:
412+
src_txt = f.read()
413+
with open(dst) as f:
414+
dst_txt = f.read()
415+
m = re.search(r'(' + re.escape(marker_start) + r'.*?' + re.escape(marker_end) + r')', src_txt, re.S)
416+
if m:
417+
dst_txt = re.sub(
418+
re.escape(marker_start) + r'.*?' + re.escape(marker_end),
419+
m.group(1), dst_txt, flags=re.S
420+
)
421+
with open(dst, 'w') as f:
422+
f.write(dst_txt)
423+
marker_files += 1
424+
else:
425+
with open(src) as f:
426+
src_txt = f.read()
427+
m = re.search(r'(' + re.escape(marker_start) + r'.*?' + re.escape(marker_end) + r')', src_txt, re.S)
428+
if m:
429+
with open(dst, 'a') as f:
430+
f.write('\n' + m.group(1) + '\n')
431+
marker_files += 1
432+
else:
433+
shutil.copy2(src, dst)
434+
copied += 1
435+
_txn_created.append(rel)
436+
if is_shared(rel):
437+
created_list.append(rel)
438+
elif rel == 'opencode.json':
439+
if not os.path.exists(dst):
440+
shutil.copy2(src, dst)
441+
copied += 1
442+
_txn_created.append(rel)
381443
else:
444+
# Standard copy with backup-if-differs (snapshot for rollback on change)
445+
if os.path.isfile(dst):
446+
if not filecmp.cmp(src, dst, shallow=False):
447+
_snapshot(rel, dst)
448+
backup_path = os.path.join(target, '.opencode', 'backups', _stamp, rel)
449+
os.makedirs(os.path.dirname(backup_path), exist_ok=True)
450+
shutil.copy2(dst, backup_path)
451+
else:
452+
_txn_created.append(rel)
453+
if is_shared(rel):
454+
created_list.append(rel)
382455
shutil.copy2(src, dst)
383456
copied += 1
384-
if is_shared(rel):
385-
created_list.append(rel)
386-
elif rel == 'opencode.json':
387-
if not os.path.exists(dst):
388-
shutil.copy2(src, dst)
389-
copied += 1
390-
else:
391-
# Standard copy with backup-if-differs
392-
if os.path.isfile(dst) and not filecmp.cmp(src, dst, shallow=False):
393-
backup_path = os.path.join(
394-
target, '.opencode', 'backups',
395-
__import__('datetime').datetime.now(__import__('datetime').timezone.utc).strftime('%Y%m%dT%H%M%SZ'),
396-
rel
397-
)
398-
os.makedirs(os.path.dirname(backup_path), exist_ok=True)
399-
shutil.copy2(dst, backup_path)
400-
shutil.copy2(src, dst)
401-
copied += 1
402-
if is_shared(rel):
403-
created_list.append(rel)
457+
except Exception as _e:
458+
_rollback(str(_e))
459+
460+
# Success: remove the ephemeral transaction dir. The per-file backup-if-differs
461+
# already retained persistent history under .opencode/backups/<stamp>/.
462+
try:
463+
shutil.rmtree(_txn_dir)
464+
except Exception:
465+
pass
404466
405467
# Write preserved/created lists
406468
with open(preserved_path, 'w') as f:

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,11 @@ bash .opencode/install.sh --replace-modified /path/to/existing-game-project
280280

281281
`--replace-modified` never overwrites an unowned shared file.
282282

283+
A cross-target deploy is transactional: if the deploy fails partway, modified
284+
files are restored from the transaction snapshot and newly-created files are
285+
removed, leaving the target at its pre-deploy state. A `rollback-<timestamp>/`
286+
record is kept under `.opencode/backups/` for inspection.
287+
283288
### What the installer modifies
284289

285290
1. **`.opencode/agents/*.md`** — injects `model:` and `variant:` into each

UPGRADING.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,10 @@ bash .opencode/install.sh --replace-modified /path/to/project
144144
```
145145

146146
`--replace-modified` never adopts an unowned shared path; merge those manually.
147+
A cross-target deploy is transactional: a mid-deploy failure restores modified
148+
files from the transaction snapshot and removes newly-created files, leaving
149+
the target at its pre-deploy state (a `rollback-<timestamp>/` record is kept
150+
under `.opencode/backups/`).
147151
Uninstall is also fail-closed: it requires valid `.opencode/install-state.json`
148152
ownership data and aborts without removing files when state is missing, stale,
149153
malformed, path-traversing, or symlinked.

0 commit comments

Comments
 (0)