worktree-for-agents 1.0.1 → 1.1.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.
@@ -76,3 +76,26 @@ The dry run publishes the composed `commit_subject` and `commit_body`; execute p
76
76
  to the lifecycle. If missing, the manager uses the neutral fallback
77
77
  `Complete <work_id> (generic closure)` / `No delivery description was available
78
78
  at closure time.`.
79
+
80
+ ## Merge strategy
81
+
82
+ `worktree_complete` accepts the optional string `merge_strategy` with the enum
83
+ values `squash` (default) and `preserve-history`. Squash integrates the branch
84
+ as one commit; preserve-history records an explicit merge commit that keeps the
85
+ branch commits. Neither strategy uses a fast-forward.
86
+
87
+ The field is validated as a string and against the enum before any mutation; a
88
+ value outside the enum is rejected as `invalid_request` with exit code 3. It is
89
+ included in the plan hash only for `worktree_complete`, so a preflight for one
90
+ strategy cannot execute the other and reports `plan_mismatch` with exit code 5.
91
+
92
+ A dry run publishes `data.merge_strategy` and the planned `mutations`:
93
+ `squash_merge` for squash, or `merge_no_ff` for preserve-history, followed by
94
+ `remove_worktree` and `delete_branch` in both cases. Execute returns
95
+ `merge_strategy` and `integrated_commit`; `squash_commit` is present only for
96
+ squash and equals `integrated_commit`. The transaction records the strategy and
97
+ the integrated commit. Recovery validates the parent shape recorded by the
98
+ strategy, reads a transaction without the field as squash, and reports an
99
+ unknown value as `invalid_journal` with exit code 5 while retaining evidence.
100
+ With `preserve-history`, a branch with no commits ahead of the base fails as
101
+ `operation_failed` with exit code 5 without a transaction or mutation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktree-for-agents",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Standalone Git worktree manager with a stdio MCP service usable by any coding agent",
5
5
  "type": "module",
6
6
  "engines": {
@@ -1,2 +1,2 @@
1
1
  """Standalone Python worktree management backend."""
2
- __version__ = '1.0.1'
2
+ __version__ = '1.1.0'
@@ -6,7 +6,7 @@ from .configuration import Configuration
6
6
  from .git_repository import GitRepository
7
7
  from .models import ManagerError,OperationResult,ResultKind
8
8
  from .output import emit
9
- from .worktree_lifecycle import WorktreeLifecycle
9
+ from .worktree_lifecycle import WorktreeLifecycle, MERGE_STRATEGIES, DEFAULT_MERGE_STRATEGY
10
10
  from .recovery_manager import RecoveryManager
11
11
  from .target_direct_adapter import TargetDirectAdapter
12
12
  from . import __version__ as VERSION
@@ -18,7 +18,7 @@ def _parser():
18
18
  for name in ('create','complete','cleanup'):
19
19
  p=sub.add_parser(name); p.add_argument('work_id'); p.add_argument('--base');
20
20
  if name=='cleanup': p.add_argument('--discard',action='store_true')
21
- if name=='complete': p.add_argument('--preserve-dirty',action='store_true'); p.add_argument('--title'); p.add_argument('--body')
21
+ if name=='complete': p.add_argument('--preserve-dirty',action='store_true'); p.add_argument('--title'); p.add_argument('--body'); p.add_argument('--merge-strategy',choices=MERGE_STRATEGIES,default=DEFAULT_MERGE_STRATEGY)
22
22
  if name=='create':
23
23
  p.add_argument('--init-submodules',action='store_true'); p.add_argument('--prepare',action='store_true'); p.add_argument('--verify',action='store_true')
24
24
  p=sub.add_parser('recover'); p.add_argument('transaction_id',nargs='?')
@@ -120,7 +120,7 @@ def main(argv=None)->int:
120
120
  result=OperationResult(operation,data=data)
121
121
  elif operation=='cleanup': result=OperationResult(operation,data=lifecycle.cleanup(args.work_id,args.discard,args.base or 'main'))
122
122
  elif operation=='complete':
123
- data=lifecycle.complete(args.work_id,args.base or 'main',args.preserve_dirty,commit_title=args.title,commit_body=args.body)
123
+ data=lifecycle.complete(args.work_id,args.base or 'main',args.preserve_dirty,commit_title=args.title,commit_body=args.body,merge_strategy=args.merge_strategy)
124
124
  data['merge']='success'
125
125
  result=OperationResult(operation,data=data)
126
126
  if operation=='list' and not args.json:
@@ -5,13 +5,14 @@ import pathlib
5
5
 
6
6
  CONTRACT_VERSION = 1
7
7
  from . import __version__ as TOOL_VERSION
8
+ from .worktree_lifecycle import MERGE_STRATEGIES
8
9
  TOOL_NAMES = (
9
10
  "worktree_create", "worktree_complete", "worktree_cleanup",
10
11
  "worktree_recover", "worktree_list", "worktree_prune", "worktree_status",
11
12
  )
12
13
  FIELDS = {
13
14
  "worktree_create": {"repo", "work_id", "base", "dry_run", "parent_repo", "submodule_path", "confirm", "plan_hash", "init_submodules", "prepare", "verify"},
14
- "worktree_complete": {"repo", "work_id", "base", "dry_run", "authorization", "confirm", "plan_hash", "transaction_id", "preserve_dirty", "target_direct", "parent_repo", "commit_title", "commit_body"},
15
+ "worktree_complete": {"repo", "work_id", "base", "dry_run", "authorization", "confirm", "plan_hash", "transaction_id", "preserve_dirty", "target_direct", "parent_repo", "commit_title", "commit_body", "merge_strategy"},
15
16
  "worktree_cleanup": {"repo", "work_id", "base", "dry_run", "authorization", "confirm", "plan_hash", "discard", "discard_consent"},
16
17
  "worktree_recover": {"repo", "transaction_id", "dry_run", "authorization", "confirm", "target_direct", "parent_repo"},
17
18
  "worktree_list": {"repo"}, "worktree_prune": {"repo", "dry_run", "authorization", "confirm", "plan_hash"},
@@ -40,9 +41,11 @@ def validate_request(tool: str, request: dict) -> dict:
40
41
  for key in ("dry_run", "discard", "preserve_dirty", "discard_consent", "target_direct", "confirm", "init_submodules", "prepare", "verify"):
41
42
  if key in request and type(request[key]) is not bool:
42
43
  raise ValueError(f"{key}_must_be_boolean")
43
- for key in ("work_id", "base", "transaction_id", "authorization", "parent_repo", "submodule_path", "commit_title", "commit_body"):
44
+ for key in ("work_id", "base", "transaction_id", "authorization", "parent_repo", "submodule_path", "commit_title", "commit_body", "merge_strategy"):
44
45
  if key in request and not isinstance(request[key], str):
45
46
  raise ValueError(f"{key}_must_be_string")
47
+ if tool == "worktree_complete" and "merge_strategy" in request and request["merge_strategy"] not in MERGE_STRATEGIES:
48
+ raise ValueError("merge_strategy_invalid")
46
49
  if tool in {"worktree_create", "worktree_complete", "worktree_cleanup", "worktree_status"} and not request.get("work_id"):
47
50
  raise ValueError("work_id_required")
48
51
  if tool == "worktree_create" and bool(request.get("parent_repo")) != bool(request.get("submodule_path")):
@@ -32,7 +32,7 @@ def call(tool, request):
32
32
  req["transaction_id"] = __import__("uuid").uuid4().hex
33
33
  fields = ["operation", "repo", "work_id", "confirm", "transaction_id", "base", "preserve_dirty", "discard", "discard_consent", "target_direct", "parent_repo", "submodule_path", "state_digest"]
34
34
  if tool == "worktree_complete":
35
- fields = fields + ["commit_title", "commit_body"]
35
+ fields = fields + ["commit_title", "commit_body", "merge_strategy"]
36
36
  def plan_for(digest):
37
37
  return {key: (tool if key == "operation" else digest if key == "state_digest" else bool(req.get("authorization")) if key == "confirm" else req.get(key)) for key in fields}
38
38
  if dry:
@@ -117,8 +117,9 @@ def call(tool, request):
117
117
  if lifecycle.git.run(("-C", str(path), "status", "--porcelain", "--untracked-files=all")).stdout: raise ManagerError("intervention_required", "Worktree has uncommitted changes; it was retained", 5)
118
118
  from .commit_message import compose_commit_message
119
119
  _subject, _body = compose_commit_message(req["work_id"], req.get("commit_title"), req.get("commit_body"))
120
- data = {"planned": True, "work_id": req["work_id"], "transaction_id":req.get("transaction_id"), "preflight": "passed", "mutations": ["squash_merge", "remove_worktree", "delete_branch"], "plan_hash":req.get("_computed_plan_hash"), "state_digest":digest, "commit_subject": _subject, "commit_body": _body}
121
- else: data = lifecycle.complete(req["work_id"], req.get("base", "main"), req.get("preserve_dirty", False), req.get("transaction_id"), req.get("plan_hash"), req)
120
+ strategy = req.get("merge_strategy", "squash")
121
+ data = {"planned": True, "work_id": req["work_id"], "transaction_id":req.get("transaction_id"), "preflight": "passed", "merge_strategy": strategy, "mutations": ["squash_merge" if strategy == "squash" else "merge_no_ff", "remove_worktree", "delete_branch"], "plan_hash":req.get("_computed_plan_hash"), "state_digest":digest, "commit_subject": _subject, "commit_body": _body}
122
+ else: data = lifecycle.complete(req["work_id"], req.get("base", "main"), req.get("preserve_dirty", False), req.get("transaction_id"), req.get("plan_hash"), req, merge_strategy=req.get("merge_strategy", "squash"))
122
123
  elif tool == "worktree_cleanup":
123
124
  if req.get("discard") and req.get("discard_consent") is not True:
124
125
  raise ManagerError("discard_consent_required", "Separate explicit discard consent is required", 3)
@@ -17,7 +17,8 @@ def schemas():
17
17
  "init_submodules":{"type":"boolean"}, "prepare":{"type":"boolean"}, "verify":{"type":"boolean"},
18
18
  "dry_run": {"type": "boolean"}, "discard": {"type": "boolean"},
19
19
  "discard_consent": {"type": "boolean"}, "preserve_dirty": {"type": "boolean"},
20
- "commit_title": {"type": "string"}, "commit_body": {"type": "string"}}
20
+ "commit_title": {"type": "string"}, "commit_body": {"type": "string"},
21
+ "merge_strategy": {"type": "string", "enum": ["squash", "preserve-history"]}}
21
22
  props.update({"parent_repo": {"type": "string"}, "submodule_path": {"type": "string"},
22
23
  "target_direct": {"type": "boolean"}})
23
24
  return [{"name": name, "description": "Manage repository worktrees", "inputSchema": {
@@ -6,6 +6,7 @@ from .models import ManagerError, ResultKind
6
6
  from .transaction_journal import TransactionJournal
7
7
  from .git_repository import GitRepository
8
8
  from .lock_manager import LockManager
9
+ from .worktree_lifecycle import MERGE_STRATEGIES
9
10
  import hashlib
10
11
  class RecoveryManager:
11
12
  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
@@ -61,6 +62,8 @@ class RecoveryManager:
61
62
  try: meta=json.loads((self.journal.directory(transaction_id)/'meta.json').read_text(encoding='utf-8'))
62
63
  except (OSError,ValueError) as exc: raise ManagerError('invalid_journal','Recovery metadata is corrupt; backups were retained',5) from exc
63
64
  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)
65
+ merge_strategy=meta.get('merge_strategy','squash')
66
+ if merge_strategy not in MERGE_STRATEGIES: raise ManagerError('invalid_journal','Transaction merge strategy is not recognised; resources retained',5)
64
67
  # Preserve-dirty captures are a separate recovery contract from a
65
68
  # durable squash. A crash can leave captured parent state that
66
69
  # must not be silently classified as a completed close, regardless
@@ -78,13 +81,16 @@ class RecoveryManager:
78
81
  branch_tip=self.repo.run(('rev-parse',f'refs/heads/{branch}'),check=False)
79
82
  parents=self.repo.run(('rev-list','--parents','-n','1',head),check=False).stdout.split()
80
83
  registered_paths={str(Path(item).resolve()) for item in registered}
84
+ expected_parents=3 if merge_strategy=='preserve-history' else 2
85
+ valid_parents=len(parents)==expected_parents and parents[0]==head and parents[1]==before
86
+ if merge_strategy=='preserve-history' and valid_parents: valid_parents=parents[2]==meta.get('origin')
81
87
  if (base_tip.returncode or base_tip.stdout.strip()!=head or
82
88
  str(path.resolve()) not in registered_paths or
83
89
  worktree_head.returncode or worktree_head.stdout.strip()!=meta.get('origin') or
84
90
  symbolic.returncode or symbolic.stdout.strip()!=f'refs/heads/{branch}' or
85
91
  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)
92
+ not valid_parents):
93
+ raise ManagerError('intervention_required','Durable integration evidence is incomplete or ambiguous; resources retained',5)
88
94
  return ResultKind.NO_PENDING_WORK, None
89
95
  if phase in {'captured','merge_started'} and head==before:
90
96
  # The squash commit did not happen. Restore captured destination state once.
@@ -94,8 +100,8 @@ class RecoveryManager:
94
100
  lifecycle._restore_saved(transaction_id,meta)
95
101
  self.journal.write_phase(transaction_id,'restored')
96
102
  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)
103
+ integrated=meta.get('integrated') or meta.get('squash')
104
+ if phase in {'merged','remove_started','removed'} and (not integrated or head!=integrated): raise ManagerError('intervention_required','Repository HEAD drifted from the recorded integrated commit; resources retained',5)
99
105
  if path.exists() or str(path) in registered:
100
106
  if phase not in {'merged','remove_started'}: raise ManagerError('intervention_required','Worktree state is ambiguous; resources retained',5)
101
107
  if self.repo.run(('-C',str(path),'status','--porcelain')).stdout: raise ManagerError('intervention_required','Worktree is dirty; resources retained',5)
@@ -21,6 +21,13 @@ def validate_work_id(value: str) -> str:
21
21
  raise ManagerError('invalid_identity','Invalid work identifier',3)
22
22
  return value
23
23
 
24
+ MERGE_STRATEGIES = ('squash', 'preserve-history')
25
+ DEFAULT_MERGE_STRATEGY = 'squash'
26
+ def validate_merge_strategy(value: str) -> str:
27
+ if value not in MERGE_STRATEGIES:
28
+ raise ManagerError('invalid_merge_strategy','Invalid merge strategy',3)
29
+ return value
30
+
24
31
  class WorktreeLifecycle:
25
32
  def __init__(self, config: Configuration):
26
33
  self.config=config
@@ -132,15 +139,18 @@ class WorktreeLifecycle:
132
139
  self.git.run(('worktree','remove',*(('--force',) if discard else ()),str(path)))
133
140
  self.git.run(('branch','-D' if discard else '-d',branch))
134
141
  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.
142
+ 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,merge_strategy: str|None=None)->dict[str,str]:
143
+ """Integrate a worktree branch and retire it only after verified success.
137
144
 
138
- Dirty-destination preservation is intentionally rejected until its capture
139
- and restoration protocol is available; it must never silently discard data.
145
+ Squash produces one commit on the base branch. Preserve-history records
146
+ an explicit merge commit that keeps the branch commits. Dirty-destination
147
+ state is captured and restored around the integration in both strategies.
140
148
  """
141
149
  branch=self._branch(work_id); base=self._base(base); path=self._path(work_id)
142
150
  if commit_title is None and isinstance(request,dict): commit_title=request.get('commit_title')
143
151
  if commit_body is None and isinstance(request,dict): commit_body=request.get('commit_body')
152
+ if merge_strategy is None and isinstance(request,dict): merge_strategy=request.get('merge_strategy')
153
+ merge_strategy=validate_merge_strategy(merge_strategy or DEFAULT_MERGE_STRATEGY)
144
154
  registered={item['path'] for item in self.list()}
145
155
  if str(path) not in registered or not path.is_dir():
146
156
  raise ManagerError('invalid_identity','Worktree identity does not match registration',3)
@@ -153,6 +163,12 @@ class WorktreeLifecycle:
153
163
  status=self.git.run(('status','--porcelain','--untracked-files=all')).stdout
154
164
  if status and not preserve_dirty:
155
165
  raise ManagerError('intervention_required','Destination has uncommitted changes; use --preserve-dirty to retain them',5)
166
+ if merge_strategy=='preserve-history':
167
+ ahead=self.git.run(('rev-list','--count',f'{base}..{branch}'),check=False)
168
+ if ahead.returncode:
169
+ raise ManagerError('operation_failed','Could not inspect the branch ahead of the base; nothing was merged',5)
170
+ if (ahead.stdout.strip() or '0')=='0':
171
+ raise ManagerError('operation_failed','Branch has no commits ahead of the base; nothing was merged',5)
156
172
  state=self.state_root
157
173
  locks=LockManager(state/self.config.lock_dir)
158
174
  tx=transaction_id or uuid.uuid4().hex
@@ -161,7 +177,7 @@ class WorktreeLifecycle:
161
177
  raise ManagerError('intervention_required','Transaction binding already exists; execute is blocked',5)
162
178
  before=self.git.head(); origin=self.git.run(('rev-parse',f'refs/heads/{branch}')).stdout.strip()
163
179
  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,
180
+ 'preserve_dirty':bool(status),'merge_strategy':merge_strategy,'tracked_ref':None,'untracked_ref':None,
165
181
  'backup_ref_prefix':self.config.backup_ref_prefix}
166
182
  meta_path=journal.directory(tx)/'meta.json'
167
183
  def save_meta(phase):
@@ -182,6 +198,7 @@ class WorktreeLifecycle:
182
198
  try:
183
199
  journal.write_binding(tx, {'transaction_id':tx,'repo':str(self.config.repo_root.resolve()),'work_id':work_id,
184
200
  'operation':'worktree_complete','request':request or {},'plan_hash':plan_hash,
201
+ 'merge_strategy':merge_strategy,
185
202
  'state_digest':__import__('worktree_manager.state_digest',fromlist=['state_digest']).state_digest(self.config.repo_root,state),
186
203
  'created_at':__import__('datetime').datetime.now(__import__('datetime').timezone.utc).isoformat(),
187
204
  'phase':'prepared','base':base,'before':before,'origin':origin})
@@ -215,19 +232,33 @@ class WorktreeLifecycle:
215
232
  meta['commit_subject']=subject
216
233
  meta['commit_body']=body
217
234
  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')
235
+ if merge_strategy=='preserve-history':
236
+ message=subject+('\n\n'+body if body else '')
237
+ merged=self.git.run(('merge','--no-ff','-m',message,branch),check=False)
238
+ if merged.returncode:
239
+ self.git.run(('merge','--abort'),check=False)
240
+ save_meta('pending_manual')
241
+ raise ManagerError('operation_failed','Preserve-history merge failed; worktree and branch retained',5)
242
+ after=self.git.head()
243
+ parents=self.git.run(('rev-list','--parents','-n','1',after)).stdout.split()
244
+ if after==before or len(parents)!=3 or parents[0]!=after or parents[1]!=before or parents[2]!=origin:
245
+ save_meta('pending_manual'); raise ManagerError('operation_failed','Merge commit verification failed; resources retained',5)
246
+ else:
247
+ merged=self.git.run(('merge','--squash',branch),check=False)
248
+ if merged.returncode or self.git.run(('diff','--cached','--quiet'),check=False).returncode==0:
249
+ self.git.run(('reset','--merge'),check=False)
250
+ save_meta('pending_manual')
251
+ raise ManagerError('operation_failed','Squash merge failed or produced no changes; worktree and branch retained',5)
252
+ commit_args=['commit','-m',subject]
253
+ if body:
254
+ commit_args += ['-m',body]
255
+ self.git.run(tuple(commit_args))
256
+ after=self.git.head()
257
+ if after==before or len(self.git.run(('rev-list','--parents','-n','1',after)).stdout.split())!=2:
258
+ save_meta('pending_manual'); raise ManagerError('operation_failed','Squash commit verification failed; resources retained',5)
259
+ meta['integrated']=after
260
+ if merge_strategy=='squash': meta['squash']=after
261
+ save_meta('merged')
231
262
  self.git.run(('worktree','remove','--force','--',str(path)))
232
263
  if path.exists() or str(path) in {item['path'] for item in self.list()}:
233
264
  save_meta('pending_manual'); raise ManagerError('operation_failed','Worktree removal could not be verified',5)
@@ -236,10 +267,16 @@ class WorktreeLifecycle:
236
267
  save_meta('pending_manual'); raise ManagerError('operation_failed','Branch changed; it was retained',5)
237
268
  self.git.run(('update-ref','-d',f'refs/heads/{branch}',origin)); save_meta('closed')
238
269
  if status:
239
- meta['squash']=after; save_meta('merged')
270
+ meta['integrated']=after
271
+ if merge_strategy=='squash': meta['squash']=after
272
+ save_meta('merged')
240
273
  self._restore_saved(tx,meta)
241
274
  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}
275
+ result={'completed':work_id,'branch':branch,'base_branch':base,'worktree':str(path),
276
+ 'integrated_commit':after,'merge_strategy':merge_strategy,
277
+ 'transaction_id':tx,'commit_subject':subject,'commit_body':body}
278
+ if merge_strategy=='squash': result['squash_commit']=after
279
+ return result
243
280
  finally: locks.release()
244
281
 
245
282
  def _restore_saved(self,tx,meta):