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.
@@ -0,0 +1,274 @@
1
+ """Parent repository worktree lifecycle primitives."""
2
+ from __future__ import annotations
3
+ import re
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import tempfile
9
+ import uuid
10
+ from pathlib import Path
11
+ from .configuration import Configuration
12
+ from .git_repository import GitRepository
13
+ from .models import ManagerError
14
+ from .lock_manager import LockManager
15
+ from .transaction_journal import TransactionJournal
16
+ from .commit_message import compose_commit_message
17
+
18
+ _WORK_ID = re.compile(r'^[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*$')
19
+ def validate_work_id(value: str) -> str:
20
+ if not value or value.startswith('-') or not _WORK_ID.fullmatch(value) or any(p in ('.','..') or p.startswith('--') for p in value.split('/')):
21
+ raise ManagerError('invalid_identity','Invalid work identifier',3)
22
+ return value
23
+
24
+ class WorktreeLifecycle:
25
+ def __init__(self, config: Configuration):
26
+ self.config=config
27
+ self.git=GitRepository(config.repo_root)
28
+ self.state_root=self._state_root()
29
+ self.journal=TransactionJournal(self.state_root)
30
+ def _state_root(self)->Path:
31
+ value=Path(self.git.run(('rev-parse','--git-dir')).stdout.strip())
32
+ git_dir=(self.config.repo_root/value).resolve() if not value.is_absolute() else value.resolve()
33
+ return git_dir/self.config.state_dir
34
+ def _branch(self,work_id: str)->str: return self.config.branch_prefix+validate_work_id(work_id)
35
+ def _lock_work_id(self,work_id: str)->str: return validate_work_id(work_id).encode('utf-8').hex()
36
+ def _path(self,work_id: str)->Path:
37
+ part=Path(validate_work_id(work_id)); root=Path(self.config.worktrees_dir)
38
+ return (root/part if root.is_absolute() else self.config.repo_root/root/part).resolve()
39
+ def _base(self,base: str)->str:
40
+ if not base or base.startswith('-') or self.git.run(('check-ref-format','--branch',base),check=False).returncode:
41
+ raise ManagerError('invalid_identity','Invalid base branch',3)
42
+ if self.git.run(('show-ref','--verify','--quiet',f'refs/heads/{base}'),check=False).returncode:
43
+ raise ManagerError('invalid_identity',f"Base branch '{base}' does not exist",3)
44
+ return base
45
+ def list(self)->list[dict[str,str]]:
46
+ result=[]; path=None; branch=''
47
+ for line in self.git.worktrees().splitlines()+['']:
48
+ if line.startswith('worktree '): path=line[9:]; branch=''
49
+ elif line.startswith('branch '): branch=line[7:]
50
+ elif not line and path is not None: result.append({'path':path,'branch':branch}); path=None
51
+ return result
52
+ def create(self,work_id: str,base: str,init_submodules: bool=False,prepare: bool=False,verify: bool=False)->dict[str,str]:
53
+ branch=self._branch(work_id); base=self._base(base); path=self._path(work_id)
54
+ # The requested steps are validated before any mutation, so an
55
+ # unusable configuration never leaves a worktree or a branch behind.
56
+ self._validate_create_options(prepare,verify)
57
+ if self.git.run(('show-ref','--verify','--quiet',f'refs/heads/{branch}'),check=False).returncode==0 or path.exists():
58
+ raise ManagerError('operation_failed','Worktree branch or path already exists',5)
59
+ if not Path(self.config.worktrees_dir).is_absolute():
60
+ rel=path.relative_to(self.config.repo_root).as_posix()
61
+ if self.git.run(('check-ignore','-q','--',rel),check=False).returncode:
62
+ raise ManagerError('invalid_configuration',f"Worktree path '{rel}' is not ignored",3)
63
+ state=self.state_root
64
+ locks=LockManager(state/self.config.lock_dir)
65
+ with locks:
66
+ locks.acquire([f'work/{self._lock_work_id(work_id)}'],'create')
67
+ # Revalidate after acquiring the operation lock so concurrent create
68
+ # attempts cannot both pass the preflight checks.
69
+ if self.git.run(('show-ref','--verify','--quiet',f'refs/heads/{branch}'),check=False).returncode==0 or path.exists():
70
+ raise ManagerError('operation_failed','Worktree branch or path already exists',5)
71
+ path.parent.mkdir(parents=True,exist_ok=True)
72
+ self.git.run(('worktree','add','-b',branch,str(path),base))
73
+ init_result='not_requested'
74
+ if init_submodules:
75
+ if self.git.run(('-C',str(path),'submodule','update','--init','--recursive'),check=False).returncode:
76
+ # The worktree and its branch are kept so the operator can
77
+ # inspect the partially initialized state and decide.
78
+ raise ManagerError('operation_failed','Submodule initialization failed; worktree and branch retained',5)
79
+ init_result='initialized'
80
+ prepare_state='skipped'; verify_state='skipped'
81
+ if prepare or self.config.prepare_enabled:
82
+ prepare_state='failed'
83
+ if not self._run_configured_action(self.config.prepare_command,path):
84
+ raise ManagerError('operation_failed','Prepare command failed; worktree and branch retained',5)
85
+ prepare_state='passed'
86
+ if verify or self.config.verify_enabled:
87
+ verify_state='failed'
88
+ if not self._run_configured_action(self.config.verify_command,path):
89
+ raise ManagerError('operation_failed','Verify command failed; worktree and branch retained',5)
90
+ verify_state='passed'
91
+ return {'path':str(path),'branch':branch,'base_branch':base,
92
+ 'init_submodules':'true' if init_submodules else 'false',
93
+ 'init_submodules_result':init_result,
94
+ 'prepare':prepare_state,'verify':verify_state}
95
+ def _validate_create_options(self,prepare: bool,verify: bool)->None:
96
+ """Reject unusable prepare/verify configuration before mutating."""
97
+ if (prepare or self.config.prepare_enabled) and not self.config.prepare_command:
98
+ raise ManagerError('invalid_configuration','prepare requires prepare_command in the configuration',3)
99
+ if (verify or self.config.verify_enabled) and not self.config.verify_command:
100
+ raise ManagerError('invalid_configuration','verify requires verify_command in the configuration',3)
101
+ def _run_configured_action(self,command: str,path: Path)->bool:
102
+ """Run a configured repository action inside the new worktree."""
103
+ if not command: return False
104
+ for shell in ('bash','sh'):
105
+ try:
106
+ completed=subprocess.run([shell,'-c',command],cwd=str(path),text=True,
107
+ stdout=subprocess.PIPE,stderr=subprocess.PIPE)
108
+ except OSError:
109
+ continue
110
+ return completed.returncode==0
111
+ return False
112
+ def cleanup(self,work_id: str,discard: bool=False,base: str='main')->dict[str,str]:
113
+ branch=self._branch(work_id); path=self._path(work_id); self._base(base)
114
+ registered={item['path'] for item in self.list()}
115
+ if str(path) not in registered or not path.exists(): raise ManagerError('invalid_identity','Worktree identity does not match registration',3)
116
+ status=self.git.run(('-C',str(path),'status','--porcelain','--untracked-files=all')).stdout
117
+ commits=self.git.run(('rev-list','--count',f'{base}..{branch}'),check=False).stdout.strip() or '0'
118
+ if (status or int(commits)>0) and not discard: raise ManagerError('intervention_required','Worktree has changes or commits; explicit discard consent is required',5)
119
+ locks=LockManager(self.state_root/self.config.lock_dir)
120
+ with locks:
121
+ key=hashlib.sha256(str(self.config.repo_root.resolve()).encode()).hexdigest()
122
+ locks.acquire([f'repository-{key}',f'work/{self._lock_work_id(work_id)}'],'cleanup')
123
+ # The identity and mutable worktree state are checked again after
124
+ # lock acquisition; all expected rejection paths above precede the
125
+ # test-only barrier and any lock creation.
126
+ if str(path) not in {item['path'] for item in self.list()} or not path.exists():
127
+ raise ManagerError('invalid_identity','Worktree identity does not match registration',3)
128
+ status=self.git.run(('-C',str(path),'status','--porcelain','--untracked-files=all')).stdout
129
+ commits=self.git.run(('rev-list','--count',f'{base}..{branch}'),check=False).stdout.strip() or '0'
130
+ if (status or int(commits)>0) and not discard:
131
+ raise ManagerError('intervention_required','Worktree has changes or commits; explicit discard consent is required',5)
132
+ self.git.run(('worktree','remove',*(('--force',) if discard else ()),str(path)))
133
+ self.git.run(('branch','-D' if discard else '-d',branch))
134
+ return {'cleaned':work_id,'branch':branch,'worktree':str(path)}
135
+ def complete(self,work_id: str,base: str,preserve_dirty: bool=False,transaction_id: str|None=None,plan_hash: str|None=None,request:dict|None=None,commit_title=None,commit_body=None)->dict[str,str]:
136
+ """Squash a worktree branch and retire it only after verified success.
137
+
138
+ Dirty-destination preservation is intentionally rejected until its capture
139
+ and restoration protocol is available; it must never silently discard data.
140
+ """
141
+ branch=self._branch(work_id); base=self._base(base); path=self._path(work_id)
142
+ if commit_title is None and isinstance(request,dict): commit_title=request.get('commit_title')
143
+ if commit_body is None and isinstance(request,dict): commit_body=request.get('commit_body')
144
+ registered={item['path'] for item in self.list()}
145
+ if str(path) not in registered or not path.is_dir():
146
+ raise ManagerError('invalid_identity','Worktree identity does not match registration',3)
147
+ current=self.git.run(('symbolic-ref','--short','HEAD')).stdout.strip()
148
+ if current != base: raise ManagerError('invalid_identity',f"Current branch must be '{base}'",3)
149
+ if self.git.run(('show-ref','--verify','--quiet',f'refs/heads/{branch}'),check=False).returncode:
150
+ raise ManagerError('invalid_identity','Worktree branch does not exist',3)
151
+ if self.git.run(('-C',str(path),'status','--porcelain','--untracked-files=all')).stdout:
152
+ raise ManagerError('intervention_required','Worktree has uncommitted changes; it was retained',5)
153
+ status=self.git.run(('status','--porcelain','--untracked-files=all')).stdout
154
+ if status and not preserve_dirty:
155
+ raise ManagerError('intervention_required','Destination has uncommitted changes; use --preserve-dirty to retain them',5)
156
+ state=self.state_root
157
+ locks=LockManager(state/self.config.lock_dir)
158
+ tx=transaction_id or uuid.uuid4().hex
159
+ journal=TransactionJournal(state)
160
+ if (journal.directory(tx)/'binding.json').exists():
161
+ raise ManagerError('intervention_required','Transaction binding already exists; execute is blocked',5)
162
+ before=self.git.head(); origin=self.git.run(('rev-parse',f'refs/heads/{branch}')).stdout.strip()
163
+ meta={'work_id':work_id,'base':base,'branch':branch,'path':str(path),'before':before,'origin':origin,
164
+ 'preserve_dirty':bool(status),'tracked_ref':None,'untracked_ref':None,
165
+ 'backup_ref_prefix':self.config.backup_ref_prefix}
166
+ meta_path=journal.directory(tx)/'meta.json'
167
+ def save_meta(phase):
168
+ journal.write_phase(tx,phase)
169
+ meta['phase']=phase
170
+ meta_path.parent.mkdir(parents=True,exist_ok=True)
171
+ fd,tmp=tempfile.mkstemp(dir=meta_path.parent,prefix='.meta-')
172
+ try:
173
+ with os.fdopen(fd,'w',encoding='utf-8') as stream: json.dump(meta,stream,sort_keys=True); stream.flush(); os.fsync(stream.fileno())
174
+ os.chmod(tmp,0o600); os.replace(tmp,meta_path)
175
+ finally:
176
+ if os.path.exists(tmp): os.unlink(tmp)
177
+ if os.environ.get('WORKTREE_MANAGER_TEST_CRASH_AFTER_TRANSACTION_PHASE')==phase:
178
+ os._exit(86)
179
+ key=hashlib.sha256(str(self.config.repo_root).encode()).hexdigest()
180
+ with locks:
181
+ locks.acquire([f'repository-{key}',f'work/{self._lock_work_id(work_id)}'],'complete')
182
+ try:
183
+ journal.write_binding(tx, {'transaction_id':tx,'repo':str(self.config.repo_root.resolve()),'work_id':work_id,
184
+ 'operation':'worktree_complete','request':request or {},'plan_hash':plan_hash,
185
+ 'state_digest':__import__('worktree_manager.state_digest',fromlist=['state_digest']).state_digest(self.config.repo_root,state),
186
+ 'created_at':__import__('datetime').datetime.now(__import__('datetime').timezone.utc).isoformat(),
187
+ 'phase':'prepared','base':base,'before':before,'origin':origin})
188
+ if status:
189
+ # Keep the original index/worktree as a Git stash commit and
190
+ # separately retain untracked files in a private tree ref.
191
+ tracked=self.git.run(('stash','create',f'worktree-manager-backup {tx}'),check=False).stdout.strip()
192
+ if not tracked:
193
+ raise ManagerError('operation_failed','Could not capture destination changes; nothing was modified',5)
194
+ tracked_ref=f'refs/{self.config.backup_ref_prefix.strip("/")}/{tx}'
195
+ self.git.run(('update-ref',tracked_ref,tracked)); meta['tracked_ref']=tracked_ref
196
+ untracked=self.git.run(('ls-files','--others','--exclude-standard','-z')).stdout
197
+ names=[x for x in untracked.split('\0') if x]
198
+ if names:
199
+ with tempfile.TemporaryDirectory(prefix='wt-index-') as td:
200
+ index=Path(td)/'index'; env={'GIT_INDEX_FILE':str(index)}
201
+ import subprocess
202
+ subprocess.run(['git','-C',str(self.config.repo_root),'read-tree','--empty'],env={**os.environ,**env},check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
203
+ subprocess.run(['git','-C',str(self.config.repo_root),'add','-f','--',*names],env={**os.environ,**env},check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
204
+ tree=subprocess.run(['git','-C',str(self.config.repo_root),'write-tree'],env={**os.environ,**env},check=True,text=True,stdout=subprocess.PIPE).stdout.strip()
205
+ oid=self.git.run(('commit-tree',tree,'-m',f'worktree-manager-backup-untracked {tx}')).stdout.strip()
206
+ untracked_ref=f'refs/{self.config.backup_ref_prefix.strip("/")}/{tx}-untracked'
207
+ self.git.run(('update-ref',untracked_ref,oid)); meta['untracked_ref']=untracked_ref
208
+ save_meta('captured')
209
+ # Clear only captured state; backups remain until recovery succeeds.
210
+ self.git.run(('restore','--source=HEAD','--staged','--worktree','--','.'),check=False)
211
+ for name in names if 'names' in locals() else []:
212
+ candidate=self.config.repo_root/name
213
+ if candidate.is_file() or candidate.is_symlink(): candidate.unlink()
214
+ subject,body=compose_commit_message(work_id,commit_title,commit_body)
215
+ meta['commit_subject']=subject
216
+ meta['commit_body']=body
217
+ save_meta('merge_started')
218
+ merged=self.git.run(('merge','--squash',branch),check=False)
219
+ if merged.returncode or self.git.run(('diff','--cached','--quiet'),check=False).returncode==0:
220
+ self.git.run(('reset','--merge'),check=False)
221
+ save_meta('pending_manual')
222
+ raise ManagerError('operation_failed','Squash merge failed or produced no changes; worktree and branch retained',5)
223
+ commit_args=['commit','-m',subject]
224
+ if body:
225
+ commit_args += ['-m',body]
226
+ self.git.run(tuple(commit_args))
227
+ after=self.git.head()
228
+ if after==before or len(self.git.run(('rev-list','--parents','-n','1',after)).stdout.split())!=2:
229
+ save_meta('pending_manual'); raise ManagerError('operation_failed','Squash commit verification failed; resources retained',5)
230
+ meta['squash']=after; save_meta('merged')
231
+ self.git.run(('worktree','remove','--force','--',str(path)))
232
+ if path.exists() or str(path) in {item['path'] for item in self.list()}:
233
+ save_meta('pending_manual'); raise ManagerError('operation_failed','Worktree removal could not be verified',5)
234
+ save_meta('removed')
235
+ if self.git.run(('rev-parse',f'refs/heads/{branch}')).stdout.strip()!=origin:
236
+ save_meta('pending_manual'); raise ManagerError('operation_failed','Branch changed; it was retained',5)
237
+ self.git.run(('update-ref','-d',f'refs/heads/{branch}',origin)); save_meta('closed')
238
+ if status:
239
+ meta['squash']=after; save_meta('merged')
240
+ self._restore_saved(tx,meta)
241
+ save_meta('restored')
242
+ return {'completed':work_id,'branch':branch,'base_branch':base,'worktree':str(path),'squash_commit':after,'transaction_id':tx,'commit_subject':subject,'commit_body':body}
243
+ finally: locks.release()
244
+
245
+ def _restore_saved(self,tx,meta):
246
+ try:
247
+ if meta.get('tracked_ref'):
248
+ self.git.run(('stash','apply','--index',meta['tracked_ref']))
249
+ ref=meta.get('untracked_ref')
250
+ if ref:
251
+ entries=self.git.run(('ls-tree','-r','--name-only','-z',ref)).stdout.split('\0')
252
+ for name in filter(None,entries):
253
+ dest=self.config.repo_root/name
254
+ if dest.exists() or dest.is_symlink(): raise ManagerError('intervention_required','Untracked restore collides with a file created after completion; backup retained',5)
255
+ dest.parent.mkdir(parents=True,exist_ok=True)
256
+ self.git.run(('restore',f'--source={ref}','--worktree','--',f'./{name}'))
257
+ except ManagerError as exc:
258
+ self.journal.write_phase(tx,'pending_manual')
259
+ raise ManagerError('intervention_required',f"Restore requires manual intervention; transaction {tx} and backups are retained",5) from exc
260
+ def prune(self)->dict[str,int]:
261
+ common=Path(self.git.run(('rev-parse','--git-common-dir')).stdout.strip())
262
+ if not common.is_absolute(): common=(self.config.repo_root/common).resolve()
263
+ admin=common/'worktrees'
264
+ before_ids={entry.name for entry in admin.iterdir() if entry.is_dir()} if admin.is_dir() else set()
265
+ before=self.git.run(('worktree','prune','--dry-run','--verbose'))
266
+ eligible=[line for line in (before.stdout+before.stderr).splitlines() if line.strip()]
267
+ key=hashlib.sha256(str(self.config.repo_root).encode()).hexdigest()
268
+ locks=LockManager(self.state_root/self.config.lock_dir)
269
+ with locks:
270
+ locks.acquire([f'repository-{key}'],'prune')
271
+ self.git.run(('worktree','prune','--verbose'))
272
+ after_ids={entry.name for entry in admin.iterdir() if entry.is_dir()} if admin.is_dir() else set()
273
+ pruned=len(before_ids-after_ids)
274
+ return {'pruned':pruned,'eligible_orphans':eligible}