worktree-for-agents 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GUIDE.md +45 -0
- package/README.md +180 -2
- package/docs/installation.md +214 -0
- package/docs/mcp-contract.md +78 -0
- package/mcp-server.mjs +54 -0
- package/package.json +37 -4
- package/scripts/postinstall.mjs +11 -0
- package/scripts/python.mjs +69 -0
- package/setup.mjs +208 -0
- package/worktree.mjs +47 -0
- package/worktree.sh +18 -0
- package/worktree_manager/__init__.py +2 -0
- package/worktree_manager/__main__.py +2 -0
- package/worktree_manager/backup_store.py +16 -0
- package/worktree_manager/cli.py +137 -0
- package/worktree_manager/commit_message.py +63 -0
- package/worktree_manager/configuration.py +61 -0
- package/worktree_manager/git_repository.py +72 -0
- package/worktree_manager/lock_manager.py +50 -0
- package/worktree_manager/mcp_contract.py +93 -0
- package/worktree_manager/mcp_operations.py +171 -0
- package/worktree_manager/mcp_server.py +61 -0
- package/worktree_manager/mcp_transport.py +5 -0
- package/worktree_manager/models.py +69 -0
- package/worktree_manager/output.py +11 -0
- package/worktree_manager/recovery_manager.py +114 -0
- package/worktree_manager/state_digest.py +40 -0
- package/worktree_manager/target_direct_adapter.py +197 -0
- package/worktree_manager/transaction_journal.py +36 -0
- package/worktree_manager/worktree_lifecycle.py +274 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Conservative transaction recovery: never interprets recovery as discard."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import json
|
|
5
|
+
from .models import ManagerError, ResultKind
|
|
6
|
+
from .transaction_journal import TransactionJournal
|
|
7
|
+
from .git_repository import GitRepository
|
|
8
|
+
from .lock_manager import LockManager
|
|
9
|
+
import hashlib
|
|
10
|
+
class RecoveryManager:
|
|
11
|
+
def __init__(self,state_root: str|Path,repo_root: str|Path|None=None): self.journal=TransactionJournal(state_root); self.repo=GitRepository(repo_root) if repo_root else None
|
|
12
|
+
def recover(self,transaction_id: str|None=None,work_id: str|None=None):
|
|
13
|
+
if transaction_id is None:
|
|
14
|
+
pending=[]
|
|
15
|
+
for path in self.journal.root.glob('*/journal'):
|
|
16
|
+
try:
|
|
17
|
+
if path.read_text(encoding='utf-8').strip() not in ('restored',): pending.append(path.parent.name)
|
|
18
|
+
except OSError: continue
|
|
19
|
+
if not pending: return ResultKind.NO_PENDING_WORK,None
|
|
20
|
+
transaction_id=sorted(pending)[-1]
|
|
21
|
+
# Distinguish an unknown transaction identity from a damaged journal.
|
|
22
|
+
# This check must precede both journal parsing and the lock barrier.
|
|
23
|
+
if not (self.journal.directory(transaction_id)/'journal').is_file():
|
|
24
|
+
raise ManagerError('invalid_identity','Transaction does not exist',3)
|
|
25
|
+
if self.repo is not None:
|
|
26
|
+
binding=self.journal.read_binding(transaction_id)
|
|
27
|
+
if (binding.get('transaction_id')!=transaction_id or
|
|
28
|
+
binding.get('repo')!=str(self.repo.root.resolve()) or
|
|
29
|
+
(work_id is not None and binding.get('work_id')!=work_id)):
|
|
30
|
+
raise ManagerError('invalid_identity','Transaction binding does not match the requested identity',3)
|
|
31
|
+
phase=self.journal.read_phase(transaction_id)
|
|
32
|
+
if phase in {'restored','closed','aborted'}: return ResultKind.NO_PENDING_WORK,{'transaction_id':transaction_id,'phase':phase}
|
|
33
|
+
if self.repo is not None:
|
|
34
|
+
# Validate transaction existence before marking/acquiring the lock.
|
|
35
|
+
key=hashlib.sha256(str(self.repo.root.resolve()).encode()).hexdigest()
|
|
36
|
+
locks=LockManager(self.journal.root.parent/'locks')
|
|
37
|
+
with locks:
|
|
38
|
+
locks.acquire([f'repository-{key}'],'recover')
|
|
39
|
+
phase=self.journal.read_phase(transaction_id)
|
|
40
|
+
current=self.journal.read_binding(transaction_id)
|
|
41
|
+
if (current.get('transaction_id')!=transaction_id or current.get('repo')!=str(self.repo.root.resolve()) or
|
|
42
|
+
(work_id is not None and current.get('work_id')!=work_id)):
|
|
43
|
+
raise ManagerError('invalid_identity','Transaction binding changed during recovery',3)
|
|
44
|
+
return self._recover_locked(transaction_id,phase)
|
|
45
|
+
return self._recover_locked(transaction_id,phase)
|
|
46
|
+
|
|
47
|
+
def _recover_locked(self,transaction_id,phase):
|
|
48
|
+
if phase=='pending_manual':
|
|
49
|
+
if self.repo is None: return ResultKind.PENDING_INTERVENTION,{'transaction_id':transaction_id,'phase':phase}
|
|
50
|
+
try: meta=json.loads((self.journal.directory(transaction_id)/'meta.json').read_text(encoding='utf-8'))
|
|
51
|
+
except (OSError,ValueError) as exc: raise ManagerError('invalid_journal','Recovery metadata is corrupt; backups were retained',5) from exc
|
|
52
|
+
lifecycle=__import__('worktree_manager.worktree_lifecycle',fromlist=['WorktreeLifecycle']).WorktreeLifecycle.__new__(__import__('worktree_manager.worktree_lifecycle',fromlist=['WorktreeLifecycle']).WorktreeLifecycle)
|
|
53
|
+
lifecycle.config=type('RecoveryConfig',(),{'repo_root':self.repo.root,'backup_ref_prefix':meta.get('backup_ref_prefix','worktree-manager/backups')})()
|
|
54
|
+
lifecycle.git=self.repo; lifecycle.journal=self.journal
|
|
55
|
+
lifecycle._restore_saved(transaction_id,meta)
|
|
56
|
+
self.journal.write_phase(transaction_id,'restored')
|
|
57
|
+
return ResultKind.SUCCESS,{'transaction_id':transaction_id,'phase':'restored'}
|
|
58
|
+
if phase=='restored': return ResultKind.NO_PENDING_WORK,{'transaction_id':transaction_id,'phase':phase}
|
|
59
|
+
if phase in {'captured','merge_started','merged','remove_started','removed'}:
|
|
60
|
+
if self.repo is None: raise ManagerError('recovery_requires_intervention','Repository context is required; no effects were changed',5)
|
|
61
|
+
try: meta=json.loads((self.journal.directory(transaction_id)/'meta.json').read_text(encoding='utf-8'))
|
|
62
|
+
except (OSError,ValueError) as exc: raise ManagerError('invalid_journal','Recovery metadata is corrupt; backups were retained',5) from exc
|
|
63
|
+
if meta.get('base') is None or meta.get('branch') is None or meta.get('path') is None: raise ManagerError('invalid_journal','Transaction identity is incomplete',5)
|
|
64
|
+
# Preserve-dirty captures are a separate recovery contract from a
|
|
65
|
+
# durable squash. A crash can leave captured parent state that
|
|
66
|
+
# must not be silently classified as a completed close, regardless
|
|
67
|
+
# of whether HEAD advanced. Keep every artifact for intervention.
|
|
68
|
+
if meta.get('preserve_dirty'):
|
|
69
|
+
raise ManagerError('intervention_required','Preserved changes require explicit validated recovery; resources retained',5)
|
|
70
|
+
head=self.repo.head(); before=meta.get('before'); branch=meta['branch']; path=Path(meta['path']); registered={line[9:] for line in self.repo.worktrees().splitlines() if line.startswith('worktree ')}
|
|
71
|
+
# A durable squash interrupted before the close journal advances is
|
|
72
|
+
# not a transaction recovery. Confirm the complete identity before
|
|
73
|
+
# reporting no pending work; ambiguous movement remains actionable.
|
|
74
|
+
if phase == 'merge_started' and head != before:
|
|
75
|
+
base_tip=self.repo.run(('rev-parse',f"refs/heads/{meta['base']}"),check=False)
|
|
76
|
+
worktree_head=self.repo.run(('-C',str(path),'rev-parse','HEAD'),check=False)
|
|
77
|
+
symbolic=self.repo.run(('-C',str(path),'symbolic-ref','-q','HEAD'),check=False)
|
|
78
|
+
branch_tip=self.repo.run(('rev-parse',f'refs/heads/{branch}'),check=False)
|
|
79
|
+
parents=self.repo.run(('rev-list','--parents','-n','1',head),check=False).stdout.split()
|
|
80
|
+
registered_paths={str(Path(item).resolve()) for item in registered}
|
|
81
|
+
if (base_tip.returncode or base_tip.stdout.strip()!=head or
|
|
82
|
+
str(path.resolve()) not in registered_paths or
|
|
83
|
+
worktree_head.returncode or worktree_head.stdout.strip()!=meta.get('origin') or
|
|
84
|
+
symbolic.returncode or symbolic.stdout.strip()!=f'refs/heads/{branch}' or
|
|
85
|
+
branch_tip.returncode or branch_tip.stdout.strip()!=meta.get('origin') or
|
|
86
|
+
len(parents)!=2 or parents[0]!=head or parents[1]!=before):
|
|
87
|
+
raise ManagerError('intervention_required','Durable squash evidence is incomplete or ambiguous; resources retained',5)
|
|
88
|
+
return ResultKind.NO_PENDING_WORK, None
|
|
89
|
+
if phase in {'captured','merge_started'} and head==before:
|
|
90
|
+
# The squash commit did not happen. Restore captured destination state once.
|
|
91
|
+
if meta.get('preserve_dirty'):
|
|
92
|
+
from .worktree_lifecycle import WorktreeLifecycle
|
|
93
|
+
lifecycle=WorktreeLifecycle.__new__(WorktreeLifecycle); lifecycle.config=type('RecoveryConfig',(),{'repo_root':self.repo.root,'backup_ref_prefix':meta.get('backup_ref_prefix','worktree-manager/backups')})(); lifecycle.git=self.repo; lifecycle.journal=self.journal
|
|
94
|
+
lifecycle._restore_saved(transaction_id,meta)
|
|
95
|
+
self.journal.write_phase(transaction_id,'restored')
|
|
96
|
+
return ResultKind.SUCCESS,{'transaction_id':transaction_id,'phase':'restored','integrated':False}
|
|
97
|
+
squash=meta.get('squash')
|
|
98
|
+
if phase in {'merged','remove_started','removed'} and (not squash or head!=squash): raise ManagerError('intervention_required','Repository HEAD drifted from the recorded squash commit; resources retained',5)
|
|
99
|
+
if path.exists() or str(path) in registered:
|
|
100
|
+
if phase not in {'merged','remove_started'}: raise ManagerError('intervention_required','Worktree state is ambiguous; resources retained',5)
|
|
101
|
+
if self.repo.run(('-C',str(path),'status','--porcelain')).stdout: raise ManagerError('intervention_required','Worktree is dirty; resources retained',5)
|
|
102
|
+
self.journal.write_phase(transaction_id,'remove_started'); self.repo.run(('worktree','remove','--force','--',str(path)))
|
|
103
|
+
self.journal.write_phase(transaction_id,'removed')
|
|
104
|
+
ref=f"refs/heads/{branch}"; current=self.repo.run(('rev-parse',ref),check=False)
|
|
105
|
+
if current.returncode==0:
|
|
106
|
+
if current.stdout.strip()!=meta.get('origin'): raise ManagerError('intervention_required','Branch drifted; it was retained',5)
|
|
107
|
+
self.repo.run(('update-ref','-d',ref,meta['origin']))
|
|
108
|
+
if meta.get('preserve_dirty'):
|
|
109
|
+
from .worktree_lifecycle import WorktreeLifecycle
|
|
110
|
+
lifecycle=WorktreeLifecycle.__new__(WorktreeLifecycle); lifecycle.config=type('RecoveryConfig',(),{'repo_root':self.repo.root,'backup_ref_prefix':meta.get('backup_ref_prefix','worktree-manager/backups')})(); lifecycle.git=self.repo; lifecycle.journal=self.journal
|
|
111
|
+
lifecycle._restore_saved(transaction_id,meta)
|
|
112
|
+
self.journal.write_phase(transaction_id,'restored' if meta.get('preserve_dirty') else 'closed')
|
|
113
|
+
return ResultKind.SUCCESS,{'transaction_id':transaction_id,'phase':self.journal.read_phase(transaction_id),'integrated':True}
|
|
114
|
+
raise ManagerError('recovery_requires_intervention','Transaction requires its validated operation context; no effects were changed',5)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Stable digest of repository and transaction state used by preflight plans."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def canonical_hash(value):
|
|
8
|
+
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def repository_state(repo_root, state_root):
|
|
12
|
+
import subprocess
|
|
13
|
+
root, state = Path(repo_root).resolve(), Path(state_root)
|
|
14
|
+
def git(*args):
|
|
15
|
+
return subprocess.run(["git", "-C", str(root), *args], check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout.strip()
|
|
16
|
+
refs = git("for-each-ref", "--format=%(refname) %(objectname)").splitlines()
|
|
17
|
+
worktrees = git("worktree", "list", "--porcelain").splitlines()
|
|
18
|
+
index = Path(git("rev-parse", "--git-path", "index"))
|
|
19
|
+
if not index.is_absolute(): index = root / index
|
|
20
|
+
journal = []
|
|
21
|
+
transactions = state / "transactions"
|
|
22
|
+
if transactions.exists():
|
|
23
|
+
for entry in sorted(transactions.iterdir()):
|
|
24
|
+
if not entry.is_dir(): continue
|
|
25
|
+
phase_path = entry / "journal"
|
|
26
|
+
phase = phase_path.read_text().strip() if phase_path.is_file() else None
|
|
27
|
+
meta = (entry / "meta.json").read_bytes() if (entry / "meta.json").is_file() else b""
|
|
28
|
+
binding = (entry / "binding.json").read_bytes() if (entry / "binding.json").is_file() else b""
|
|
29
|
+
journal.append([entry.name, phase, hashlib.sha256(meta).hexdigest(), hashlib.sha256(binding).hexdigest()])
|
|
30
|
+
descriptors=[]
|
|
31
|
+
common=Path(git("rev-parse", "--git-common-dir"))
|
|
32
|
+
if not common.is_absolute(): common=root/common
|
|
33
|
+
directory=common/"worktree-manager"/"target-direct"
|
|
34
|
+
if directory.exists(): descriptors=sorted((p.relative_to(directory).as_posix(), hashlib.sha256(p.read_bytes()).hexdigest()) for p in directory.rglob("descriptor") if p.is_file())
|
|
35
|
+
state_obj={"head":git("rev-parse", "HEAD"),"index_sha256":hashlib.sha256(index.read_bytes()).hexdigest() if index.exists() else None,"refs":refs,"worktrees":worktrees,"journal":journal,"descriptors":descriptors}
|
|
36
|
+
return state_obj
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def state_digest(repo_root, state_root):
|
|
40
|
+
return canonical_hash(repository_state(repo_root, state_root))
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Strict reader for persisted target-direct descriptors."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import hashlib, os, shutil, subprocess, tempfile
|
|
6
|
+
import signal
|
|
7
|
+
from .models import ManagerError, TargetDirectIdentity
|
|
8
|
+
|
|
9
|
+
_KEYS=frozenset({'descriptor_version','operation','target_kind','target_repo','target_common_dir','parent_repo','parent_common_dir','submodule_path','gitlink_sha','target_head','base','base_sha','branch','worktree_path','link_parent','owner','phase','tx_id','index_snapshot_ref'})
|
|
10
|
+
_SHA=re.compile(r'^[0-9a-fA-F]{40}$')
|
|
11
|
+
_PHASES=frozenset('initial descriptor_written validated worktree_created ready snapshot_captured staged_started staged verified complete_validated remove_started removed closed reconciled index_reconcile_started index_restored recovered aborted blocked'.split())
|
|
12
|
+
def load_descriptor(path: str|Path)->tuple[dict[str,str],TargetDirectIdentity]:
|
|
13
|
+
try: lines=Path(path).read_text(encoding='utf-8').splitlines()
|
|
14
|
+
except OSError as exc: raise ManagerError('invalid_descriptor','Target descriptor is unavailable',3) from exc
|
|
15
|
+
values={}
|
|
16
|
+
for line in lines:
|
|
17
|
+
if not line or '=' not in line or '\r' in line: raise ManagerError('invalid_descriptor','Malformed target descriptor',3)
|
|
18
|
+
key,value=line.split('=',1)
|
|
19
|
+
if key not in _KEYS or key in values or not value or '\n' in value: raise ManagerError('invalid_descriptor','Unknown, duplicate, or empty descriptor field',3)
|
|
20
|
+
values[key]=value
|
|
21
|
+
if values.keys()!=_KEYS: raise ManagerError('invalid_descriptor','Target descriptor is incomplete',3)
|
|
22
|
+
if values['descriptor_version']!='1' or values['target_kind']!='submodule' or values['link_parent']!='staged-only': raise ManagerError('invalid_descriptor','Unsupported target descriptor contract',3)
|
|
23
|
+
for key in ('target_repo','target_common_dir','parent_repo','parent_common_dir','worktree_path','index_snapshot_ref'):
|
|
24
|
+
if not Path(values[key]).is_absolute(): raise ManagerError('invalid_descriptor','Descriptor paths must be absolute',3)
|
|
25
|
+
if not re.fullmatch(r'[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*',values['submodule_path']) or any(p in ('.','..') for p in values['submodule_path'].split('/')): raise ManagerError('invalid_descriptor','Invalid submodule path',3)
|
|
26
|
+
for key in ('gitlink_sha','target_head','base_sha'):
|
|
27
|
+
if not _SHA.fullmatch(values[key]): raise ManagerError('invalid_descriptor','Invalid descriptor object ID',3)
|
|
28
|
+
if values['phase'] not in _PHASES: raise ManagerError('invalid_descriptor','Unknown target descriptor phase',3)
|
|
29
|
+
if values['operation'] not in {'create','link-parent','abort','complete'}: raise ManagerError('invalid_descriptor','Unknown target operation',3)
|
|
30
|
+
identity=TargetDirectIdentity(values['target_repo'],values['parent_repo'],values['submodule_path'],values['link_parent'])
|
|
31
|
+
return values,identity
|
|
32
|
+
|
|
33
|
+
class TargetDirectAdapter:
|
|
34
|
+
"""Operate on persisted submodule identity without deriving paths from cwd."""
|
|
35
|
+
def __init__(self, descriptor_path: str|Path):
|
|
36
|
+
self.path=Path(descriptor_path).resolve(); self.values,self.identity=load_descriptor(self.path)
|
|
37
|
+
self.parent=Path(self.values['parent_repo']); self.target=Path(self.values['target_repo'])
|
|
38
|
+
self.snapshot=Path(self.values['index_snapshot_ref'])
|
|
39
|
+
if self.values['link_parent']!='staged-only': raise ManagerError('invalid_descriptor','Only staged-only parent linking is supported',3)
|
|
40
|
+
@classmethod
|
|
41
|
+
def create(cls, descriptor_path: str|Path, target_repo: str|Path, parent_repo: str|Path,
|
|
42
|
+
submodule_path: str, work_id: str, base: str='main'):
|
|
43
|
+
"""Create a target worktree and persist its complete recovery identity."""
|
|
44
|
+
target=Path(target_repo).resolve(); parent=Path(parent_repo).resolve()
|
|
45
|
+
def git(root,*args):
|
|
46
|
+
r=subprocess.run(['git','-C',str(root),*args],text=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
|
|
47
|
+
if r.returncode: raise ManagerError('operation_failed',r.stderr.strip() or 'Git operation failed',5)
|
|
48
|
+
return r.stdout.strip()
|
|
49
|
+
if not re.fullmatch(r'[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*',submodule_path) or any(x in ('.','..') for x in submodule_path.split('/')):
|
|
50
|
+
raise ManagerError('invalid_identity','Invalid submodule path',3)
|
|
51
|
+
for root in (target,parent): git(root,'rev-parse','--show-toplevel')
|
|
52
|
+
base_sha=git(target,'rev-parse',f'refs/heads/{base}')
|
|
53
|
+
branch=f'worktree/{work_id}'; worktree=target/'.worktrees'/work_id
|
|
54
|
+
if worktree.exists() or subprocess.run(['git','-C',str(target),'show-ref','--verify','--quiet',f'refs/heads/{branch}']).returncode==0:
|
|
55
|
+
raise ManagerError('operation_failed','Target worktree or branch already exists',5)
|
|
56
|
+
git(target,'worktree','add','-b',branch,str(worktree),base)
|
|
57
|
+
head=git(worktree,'rev-parse','HEAD')
|
|
58
|
+
target_common=Path(git(target,'rev-parse','--git-common-dir')); target_common=(target/target_common).resolve() if not target_common.is_absolute() else target_common
|
|
59
|
+
parent_common=Path(git(parent,'rev-parse','--git-common-dir')); parent_common=(parent/parent_common).resolve() if not parent_common.is_absolute() else parent_common
|
|
60
|
+
parent_gitdir=Path(git(parent,'rev-parse','--git-dir')); parent_gitdir=(parent/parent_gitdir).resolve() if not parent_gitdir.is_absolute() else parent_gitdir
|
|
61
|
+
index=Path(git(parent,'rev-parse','--git-path','index')); index=index if index.is_absolute() else parent/index
|
|
62
|
+
tx='wt-'+work_id; snapshot=target_common/'worktree-manager'/'target-direct'/hashlib.sha256(work_id.encode()).hexdigest()/'snapshot'
|
|
63
|
+
values={'descriptor_version':'1','operation':'create','target_kind':'submodule','target_repo':str(target),'target_common_dir':str(target_common),'parent_repo':str(parent),'parent_common_dir':str(parent_common),'submodule_path':submodule_path,'gitlink_sha':git(parent,'rev-parse','HEAD'),'target_head':head,'base':base,'base_sha':base_sha,'branch':branch,'worktree_path':str(worktree),'link_parent':'staged-only','owner':'python','phase':'ready','tx_id':tx,'index_snapshot_ref':str(snapshot)}
|
|
64
|
+
path=Path(descriptor_path).resolve(); path.parent.mkdir(parents=True,exist_ok=True)
|
|
65
|
+
path.write_text(''.join(f'{k}={v}\n' for k,v in values.items()),encoding='utf-8'); path.chmod(0o600)
|
|
66
|
+
return cls(path)
|
|
67
|
+
def _git(self,root,*args,check=True):
|
|
68
|
+
result=subprocess.run(['git','-C',str(root),*args],text=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
|
|
69
|
+
if check and result.returncode: raise ManagerError('operation_failed',result.stderr.strip() or 'Git operation failed',5)
|
|
70
|
+
return result
|
|
71
|
+
def link_parent(self):
|
|
72
|
+
"""Stage the submodule commit as a gitlink and do not commit the parent."""
|
|
73
|
+
if self.values['phase'] not in {'ready','snapshot_captured','staged_started'}: raise ManagerError('invalid_descriptor','Descriptor phase does not permit parent linking',4)
|
|
74
|
+
worktree=Path(self.values['worktree_path'])
|
|
75
|
+
if worktree.is_dir():
|
|
76
|
+
self.values['target_head']=self._git(worktree,'rev-parse','HEAD').stdout.strip()
|
|
77
|
+
index=Path(self._git(self.parent,'rev-parse','--git-path','index').stdout.strip()); index=index if index.is_absolute() else self.parent/index
|
|
78
|
+
self.snapshot.mkdir(parents=True,exist_ok=True)
|
|
79
|
+
shutil.copy2(index,self.snapshot/'index.bytes')
|
|
80
|
+
(self.snapshot/'head.sha').write_text(self._git(self.parent,'rev-parse','HEAD').stdout.strip()+'\n')
|
|
81
|
+
(self.snapshot/'index.sha256').write_text(hashlib.sha256((self.snapshot/'index.bytes').read_bytes()).hexdigest()+'\n')
|
|
82
|
+
(self.snapshot/'entry').write_bytes(self._git(self.parent,'ls-files','--stage','--',self.identity.submodule_path).stdout.encode())
|
|
83
|
+
(self.snapshot/'entries').write_bytes(self._git(self.parent,'ls-files','--stage').stdout.encode())
|
|
84
|
+
self.values['phase']='snapshot_captured'; self._write_descriptor()
|
|
85
|
+
self.values['phase']='staged_started'; self._write_descriptor()
|
|
86
|
+
self._git(self.parent,'update-index','--add','--cacheinfo',f"160000,{self.values['target_head']},{self.identity.submodule_path}")
|
|
87
|
+
got=self._git(self.parent,'ls-files','--stage','--',self.identity.submodule_path).stdout.strip()
|
|
88
|
+
expected=f"160000 {self.values['target_head']} 0\t{self.identity.submodule_path}"
|
|
89
|
+
if got!=expected: self.abort(); raise ManagerError('operation_failed','Staged parent entry is not the expected gitlink',5)
|
|
90
|
+
self.values['gitlink_sha']=self.values['target_head']
|
|
91
|
+
index_path=Path(self._git(self.parent,'rev-parse','--git-path','index').stdout.strip()); index_path=index_path if index_path.is_absolute() else self.parent/index_path
|
|
92
|
+
(self.snapshot/'staged-index.sha256').write_text(hashlib.sha256(index_path.read_bytes()).hexdigest()+'\n')
|
|
93
|
+
self.values['phase']='staged'; self._write_descriptor()
|
|
94
|
+
return {'link_parent':'staged-only','gitlink':got,'parent_committed':False}
|
|
95
|
+
def abort(self):
|
|
96
|
+
if not (self.snapshot/'index.bytes').is_file(): raise ManagerError('invalid_descriptor','Index snapshot is unavailable',4)
|
|
97
|
+
expected=(self.snapshot/'index.sha256').read_text().strip()
|
|
98
|
+
index=Path(self._git(self.parent,'rev-parse','--git-path','index').stdout.strip()); index=index if index.is_absolute() else self.parent/index
|
|
99
|
+
actual=hashlib.sha256(index.read_bytes()).hexdigest()
|
|
100
|
+
head=self._git(self.parent,'rev-parse','HEAD').stdout.strip()
|
|
101
|
+
original=(self.snapshot/'head.sha').read_text().strip()
|
|
102
|
+
if head!=original or actual!=expected:
|
|
103
|
+
entry=(self.snapshot/'entry').read_text()
|
|
104
|
+
staged=self._git(self.parent,'ls-files','--stage','--',self.identity.submodule_path).stdout
|
|
105
|
+
staged_hash=(self.snapshot/'staged-index.sha256').read_text().strip() if (self.snapshot/'staged-index.sha256').exists() else ''
|
|
106
|
+
saved_entries=(self.snapshot/'entries').read_text().splitlines()
|
|
107
|
+
current_entries=self._git(self.parent,'ls-files','--stage').stdout.splitlines()
|
|
108
|
+
unchanged=sorted(current_entries)==sorted(saved_entries)
|
|
109
|
+
staged_gitlink=staged.strip()==f"160000 {self.values['target_head']} 0\t{self.identity.submodule_path}" and actual==staged_hash
|
|
110
|
+
if head!=original or not (unchanged or staged_gitlink):
|
|
111
|
+
raise ManagerError('intervention_required','Parent index drift is ambiguous; snapshot retained',5)
|
|
112
|
+
tmp=index.with_name(index.name+'.restore-'+os.urandom(4).hex())
|
|
113
|
+
shutil.copy2(self.snapshot/'index.bytes',tmp); os.replace(tmp,index)
|
|
114
|
+
self.values['phase']='aborted'; self._write_descriptor()
|
|
115
|
+
return {'aborted':True,'parent_commit_unchanged':True}
|
|
116
|
+
def complete(self):
|
|
117
|
+
if self.values['phase'] not in {'staged','verified','complete_validated','remove_started','removed','closed'}: raise ManagerError('invalid_descriptor','Descriptor phase does not permit complete',4)
|
|
118
|
+
staged=self._git(self.parent,'ls-files','--stage','--',self.identity.submodule_path).stdout.strip()
|
|
119
|
+
if staged!=f"160000 {self.values['target_head']} 0\t{self.identity.submodule_path}": raise ManagerError('intervention_required','Parent gitlink differs from the recorded target commit',5)
|
|
120
|
+
path=Path(self.values['worktree_path']); branch=self.values['branch']; head=self.values['target_head']
|
|
121
|
+
if path.exists():
|
|
122
|
+
if self._git(path,'status','--porcelain').stdout: raise ManagerError('intervention_required','Target worktree is dirty and was retained',5)
|
|
123
|
+
self._git(self.target,'worktree','remove',str(path))
|
|
124
|
+
if self._git(self.target,'show-ref','--verify','--quiet',f'refs/heads/{branch}',check=False).returncode==0:
|
|
125
|
+
self._git(self.target,'update-ref','-d',f'refs/heads/{branch}',head)
|
|
126
|
+
self.values['phase']='closed'; self._write_descriptor()
|
|
127
|
+
return {'completed':True,'parent_worktree':None}
|
|
128
|
+
def recover(self):
|
|
129
|
+
"""Reconcile observed resources after interruption; repeated calls are safe."""
|
|
130
|
+
phase=self.values['phase']
|
|
131
|
+
if phase in {'aborted','blocked','closed','recovered'}:
|
|
132
|
+
return {'recovered':True,'classification':'A','phase':phase}
|
|
133
|
+
if phase in {'verified','staged','staged_started','snapshot_captured','descriptor_written','reconciled','index_reconcile_started'}:
|
|
134
|
+
# An interrupted descriptor transition is not proof that parent
|
|
135
|
+
# reconciliation completed. Persist a terminal blocked marker once;
|
|
136
|
+
# subsequent calls are idempotent and retain all recovery evidence.
|
|
137
|
+
if phase in {'descriptor_written','reconciled','index_reconcile_started','snapshot_captured','staged_started'}:
|
|
138
|
+
self.values['phase']='blocked'; self._write_descriptor()
|
|
139
|
+
raise ManagerError('intervention_required','Target recovery evidence is incomplete or ambiguous; descriptor and snapshot retained',6)
|
|
140
|
+
if not (self.snapshot/'index.bytes').is_file() or not (self.snapshot/'entries').is_file() or not (self.snapshot/'head.sha').is_file():
|
|
141
|
+
self.values['phase']='blocked'; self._write_descriptor()
|
|
142
|
+
raise ManagerError('intervention_required','Target recovery snapshot is incomplete; evidence retained',6)
|
|
143
|
+
if (self.snapshot/'index.bytes').exists():
|
|
144
|
+
original=(self.snapshot/'entries').read_text().splitlines()
|
|
145
|
+
current=self._git(self.parent,'ls-files','--stage').stdout.splitlines()
|
|
146
|
+
wanted=f"160000 {self.values['target_head']} 0\t{self.identity.submodule_path}"
|
|
147
|
+
original_target=[line for line in original if line.split('\t',1)[-1]==self.identity.submodule_path]
|
|
148
|
+
current_target=[line for line in current if line.split('\t',1)[-1]==self.identity.submodule_path]
|
|
149
|
+
# A completed link-parent is our intentional index mutation, not
|
|
150
|
+
# proof of arbitrary drift. Compare the full logical index while
|
|
151
|
+
# allowing only that exact gitlink delta; raw index bytes include
|
|
152
|
+
# Git's refreshable stat cache and are not a semantic snapshot.
|
|
153
|
+
expected=[line for line in original if line.split('\t',1)[-1]!=self.identity.submodule_path]
|
|
154
|
+
expected.append(wanted)
|
|
155
|
+
unchanged = sorted(current) == sorted(original)
|
|
156
|
+
if phase == 'staged':
|
|
157
|
+
if current_target == [wanted] and sorted(current) == sorted(expected):
|
|
158
|
+
raise ManagerError('intervention_required','Parent gitlink is intentionally staged; snapshot retained',6)
|
|
159
|
+
raise ManagerError('intervention_required','Parent index drift is ambiguous; snapshot retained',6)
|
|
160
|
+
if unchanged:
|
|
161
|
+
self.abort()
|
|
162
|
+
elif current_target == [wanted] and sorted(current) == sorted(expected):
|
|
163
|
+
self.abort()
|
|
164
|
+
else:
|
|
165
|
+
raise ManagerError('intervention_required','Parent index drift is ambiguous; snapshot retained',6)
|
|
166
|
+
self.values['phase']='recovered'; self._write_descriptor()
|
|
167
|
+
return {'recovered':True,'classification':'A','phase':'recovered'}
|
|
168
|
+
if phase in {'complete_validated','remove_started','removed'}:
|
|
169
|
+
path=Path(self.values['worktree_path'])
|
|
170
|
+
registered=str(path) in [line[9:] for line in self._git(self.target,'worktree','list','--porcelain').stdout.splitlines() if line.startswith('worktree ')]
|
|
171
|
+
if registered:
|
|
172
|
+
if phase not in {'complete_validated','remove_started'} or not path.exists(): raise ManagerError('intervention_required','Target worktree registration drifted; retained',5)
|
|
173
|
+
if self._git(path,'status','--porcelain').stdout: raise ManagerError('intervention_required','Target worktree is dirty; retained',5)
|
|
174
|
+
if phase=='complete_validated': self.values['phase']='remove_started'; self._write_descriptor()
|
|
175
|
+
self._git(self.target,'worktree','remove',str(path))
|
|
176
|
+
if path.exists(): raise ManagerError('intervention_required','Target worktree removal is incomplete',5)
|
|
177
|
+
self.values['phase']='removed'; self._write_descriptor()
|
|
178
|
+
branch=f"refs/heads/{self.values['branch']}"; current=self._git(self.target,'rev-parse',branch,check=False)
|
|
179
|
+
if current.returncode==0:
|
|
180
|
+
if current.stdout.strip()!=self.values['target_head']: raise ManagerError('intervention_required','Target branch drifted; retained',5)
|
|
181
|
+
self._git(self.target,'update-ref','-d',branch,self.values['target_head'])
|
|
182
|
+
self.values['phase']='closed'; self._write_descriptor()
|
|
183
|
+
return {'recovered':True,'classification':'A','phase':'closed'}
|
|
184
|
+
raise ManagerError('recovery_requires_intervention','Descriptor phase requires manual intervention',5)
|
|
185
|
+
def _write_descriptor(self):
|
|
186
|
+
self.path.parent.mkdir(parents=True,exist_ok=True)
|
|
187
|
+
text=''.join(f'{k}={v}\n' for k,v in self.values.items())
|
|
188
|
+
fd,tmp=tempfile.mkstemp(dir=self.path.parent,prefix='.descriptor-')
|
|
189
|
+
try:
|
|
190
|
+
with os.fdopen(fd,'w') as out: out.write(text); out.flush(); os.fsync(out.fileno())
|
|
191
|
+
os.chmod(tmp,0o600); os.replace(tmp,self.path)
|
|
192
|
+
# Test-only barrier models process death after a durable descriptor
|
|
193
|
+
# transition and before the next Git side effect.
|
|
194
|
+
if os.environ.get('WORKTREE_MANAGER_TEST_CRASH_AFTER_DESCRIPTOR_PHASE') == self.values['phase']:
|
|
195
|
+
os.kill(os.getpid(), signal.SIGKILL)
|
|
196
|
+
finally:
|
|
197
|
+
if os.path.exists(tmp): os.unlink(tmp)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Atomic transaction journal storage."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import os, tempfile
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from .models import ManagerError
|
|
7
|
+
PHASES = frozenset({'captured','merge_started','merged','pending_manual','restored','created','removed','closed','remove_started','captured_dirty','restoring'})
|
|
8
|
+
class TransactionJournal:
|
|
9
|
+
def __init__(self, state_root: str | Path): self.root = Path(state_root).resolve() / 'transactions'
|
|
10
|
+
def directory(self, tx: str) -> Path:
|
|
11
|
+
if not tx or '/' in tx or tx.startswith(('.', '-')): raise ManagerError('invalid_identity','Invalid transaction identifier',3)
|
|
12
|
+
return self.root / tx
|
|
13
|
+
@staticmethod
|
|
14
|
+
def _atomic(path: Path, text: str) -> None:
|
|
15
|
+
path.parent.mkdir(parents=True,exist_ok=True); fd,name=tempfile.mkstemp(prefix='.tmp-',dir=path.parent)
|
|
16
|
+
try:
|
|
17
|
+
with os.fdopen(fd,'w',encoding='utf-8') as stream: stream.write(text); stream.flush(); os.fsync(stream.fileno())
|
|
18
|
+
os.chmod(name,0o600); os.replace(name,path)
|
|
19
|
+
finally:
|
|
20
|
+
if os.path.exists(name): os.unlink(name)
|
|
21
|
+
def write_phase(self,tx: str,phase: str) -> None:
|
|
22
|
+
if phase not in PHASES: raise ManagerError('invalid_journal','Unknown transaction phase',3)
|
|
23
|
+
self._atomic(self.directory(tx)/'journal',phase+'\n')
|
|
24
|
+
def read_phase(self,tx: str) -> str:
|
|
25
|
+
try: value=(self.directory(tx)/'journal').read_text(encoding='utf-8')
|
|
26
|
+
except OSError as exc: raise ManagerError('invalid_journal','Transaction journal is unavailable',3) from exc
|
|
27
|
+
phase=value.removesuffix('\n')
|
|
28
|
+
if phase not in PHASES or '\n' in phase: raise ManagerError('invalid_journal','Transaction journal is corrupt',3)
|
|
29
|
+
return phase
|
|
30
|
+
def write_binding(self, tx: str, binding: dict) -> None:
|
|
31
|
+
self._atomic(self.directory(tx)/'binding.json', json.dumps(binding,sort_keys=True,separators=(',',':'))+'\n')
|
|
32
|
+
def read_binding(self, tx: str) -> dict:
|
|
33
|
+
try: value=json.loads((self.directory(tx)/'binding.json').read_text(encoding='utf-8'))
|
|
34
|
+
except (OSError,ValueError) as exc: raise ManagerError('intervention_required','Transaction binding is unavailable or corrupt',5) from exc
|
|
35
|
+
if not isinstance(value,dict): raise ManagerError('intervention_required','Transaction binding is invalid',5)
|
|
36
|
+
return value
|