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,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}
|