@@ -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
326374copied = 0
327375skipped_foreign = 0
@@ -331,76 +379,90 @@ marker_files = 0
331379preserved_list = []
332380created_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
406468with open(preserved_path, 'w') as f:
0 commit comments