code-analysis-client-dev 1.6.341__py3-none-any.whl

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,533 @@
1
+ """
2
+ Worktree management on top of CodeAnalysisAsyncClient.
3
+
4
+ Wraps ``worktree_*`` MCP commands with typed client methods, queued and synchronous,
5
+ following the FileSessionClient pattern.
6
+
7
+ Author: Vasiliy Zdanovskiy
8
+ email: vasilyvz@gmail.com
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
14
+
15
+ from code_analysis_client.exceptions import ClientValidationError
16
+ from code_analysis_client.responses import unwrap_command_result, FacadeCaller
17
+
18
+ if TYPE_CHECKING:
19
+ from code_analysis_client.client import CodeAnalysisAsyncClient
20
+
21
+
22
+ class WorktreeConflictError(ClientValidationError):
23
+ """Raised when worktree_sync encounters WORKTREE_SYNC_CONFLICT."""
24
+
25
+ def __init__(
26
+ self,
27
+ message: str,
28
+ *,
29
+ conflicts: Optional[List[Dict[str, Any]]] = None,
30
+ preserved_locked_files: Optional[List[str]] = None,
31
+ ) -> None:
32
+ """Initialize with conflict details."""
33
+ super().__init__(message, field="worktree_sync")
34
+ self.conflicts = conflicts or []
35
+ self.preserved_locked_files = preserved_locked_files or []
36
+
37
+
38
+ def _unwrap(data: Dict[str, Any]) -> Dict[str, Any]:
39
+ """Unwrap command result payloads."""
40
+ return unwrap_command_result(data)
41
+
42
+
43
+ def _require_non_empty(value: Any, *, field: str) -> str:
44
+ """Return stripped text or raise ClientValidationError for an empty field."""
45
+ text = str(value or "").strip()
46
+ if not text:
47
+ raise ClientValidationError(f"{field} is required", field=field)
48
+ return text
49
+
50
+
51
+ class WorktreeClient:
52
+ """Worktree lifecycle and sync management commands."""
53
+
54
+ __slots__ = ("_client",)
55
+
56
+ def __init__(self, client: CodeAnalysisAsyncClient) -> None:
57
+ """Store the underlying async client used to call worktree commands."""
58
+ self._client = FacadeCaller(client) # type: ignore[assignment]
59
+
60
+ # === Lifecycle commands ===
61
+
62
+ async def ensure(
63
+ self,
64
+ project_id: str,
65
+ name: str,
66
+ *,
67
+ branch: Optional[str] = None,
68
+ start_point: Optional[str] = None,
69
+ create_branch: bool = True,
70
+ owner_session_id: Optional[str] = None,
71
+ ) -> Dict[str, Any]:
72
+ """Create a worktree if absent; idempotent if already present with same branch.
73
+
74
+ Calls ``worktree_ensure`` per candidate-WT-1.md section 5.1.
75
+ """
76
+ project_id = _require_non_empty(project_id, field="project_id")
77
+ name = _require_non_empty(name, field="name")
78
+
79
+ params: Dict[str, Any] = {
80
+ "project_id": project_id,
81
+ "name": name,
82
+ }
83
+ if branch is not None:
84
+ params["branch"] = branch
85
+ if start_point is not None:
86
+ params["start_point"] = start_point
87
+ if not create_branch:
88
+ params["create_branch"] = False
89
+ if owner_session_id is not None:
90
+ params["owner_session_id"] = owner_session_id
91
+
92
+ return _unwrap(
93
+ await self._client.call_validated("worktree_ensure", params)
94
+ )
95
+
96
+ async def list(
97
+ self,
98
+ project_id: str,
99
+ *,
100
+ owner_session_id: Optional[str] = None,
101
+ only_names: Optional[List[str]] = None,
102
+ ) -> Dict[str, Any]:
103
+ """List worktrees in a project.
104
+
105
+ Calls ``worktree_list`` per candidate-WT-1.md section 5.2.
106
+ """
107
+ project_id = _require_non_empty(project_id, field="project_id")
108
+
109
+ params: Dict[str, Any] = {"project_id": project_id}
110
+ if owner_session_id is not None:
111
+ params["owner_session_id"] = owner_session_id
112
+ if only_names is not None:
113
+ params["only_names"] = only_names
114
+
115
+ return _unwrap(
116
+ await self._client.call_validated("worktree_list", params)
117
+ )
118
+
119
+ async def status(
120
+ self,
121
+ project_id: str,
122
+ name: str,
123
+ ) -> Dict[str, Any]:
124
+ """Get git status of one worktree (working tree + index).
125
+
126
+ Calls ``worktree_status`` per candidate-WT-1.md section 5.3.
127
+ """
128
+ project_id = _require_non_empty(project_id, field="project_id")
129
+ name = _require_non_empty(name, field="name")
130
+
131
+ params: Dict[str, Any] = {
132
+ "project_id": project_id,
133
+ "name": name,
134
+ }
135
+
136
+ return _unwrap(
137
+ await self._client.call_validated("worktree_status", params)
138
+ )
139
+
140
+ async def diff(
141
+ self,
142
+ project_id: str,
143
+ name: str,
144
+ *,
145
+ base: Optional[str] = None,
146
+ path: Optional[str] = None,
147
+ include_patch: bool = False,
148
+ ) -> Dict[str, Any]:
149
+ """Diff worktree against base branch.
150
+
151
+ Calls ``worktree_diff`` per candidate-WT-1.md section 5.4.
152
+ """
153
+ project_id = _require_non_empty(project_id, field="project_id")
154
+ name = _require_non_empty(name, field="name")
155
+
156
+ params: Dict[str, Any] = {
157
+ "project_id": project_id,
158
+ "name": name,
159
+ }
160
+ if base is not None:
161
+ params["base"] = base
162
+ if path is not None:
163
+ params["path"] = path
164
+ if include_patch:
165
+ params["include_patch"] = True
166
+
167
+ return _unwrap(
168
+ await self._client.call_validated("worktree_diff", params)
169
+ )
170
+
171
+ # === Sync and conflict resolution ===
172
+
173
+ async def sync(
174
+ self,
175
+ project_id: str,
176
+ name: str,
177
+ *,
178
+ strategy: Optional[str] = None,
179
+ base: Optional[str] = None,
180
+ auto_poll: bool = True,
181
+ timeout: Optional[float] = None,
182
+ ) -> Dict[str, Any]:
183
+ """Sync worktree from main branch (merge or rebase).
184
+
185
+ Calls ``worktree_sync`` per candidate-WT-1.md section 5.5.
186
+ Raises WorktreeConflictError on WORKTREE_SYNC_CONFLICT.
187
+ """
188
+ project_id = _require_non_empty(project_id, field="project_id")
189
+ name = _require_non_empty(name, field="name")
190
+
191
+ params: Dict[str, Any] = {
192
+ "project_id": project_id,
193
+ "name": name,
194
+ }
195
+ if strategy is not None:
196
+ params["strategy"] = strategy
197
+ if base is not None:
198
+ params["base"] = base
199
+
200
+ result = await self._client.call_validated(
201
+ "worktree_sync",
202
+ params,
203
+ auto_poll=auto_poll,
204
+ timeout=timeout,
205
+ )
206
+
207
+ # Check for WORKTREE_SYNC_CONFLICT error
208
+ if isinstance(result, dict) and not result.get("success", True):
209
+ error = result.get("error", {})
210
+ if error.get("code") == "WORKTREE_SYNC_CONFLICT":
211
+ details = error.get("details", {})
212
+ raise WorktreeConflictError(
213
+ "Worktree sync conflict",
214
+ conflicts=details.get("conflicts"),
215
+ preserved_locked_files=details.get("preserved_locked_files"),
216
+ )
217
+
218
+ return _unwrap(result)
219
+
220
+ async def conflicts(
221
+ self,
222
+ project_id: str,
223
+ name: str,
224
+ ) -> Dict[str, Any]:
225
+ """List merge/rebase conflicts with per-file markers.
226
+
227
+ Calls ``worktree_conflicts`` per candidate-WT-1.md section 5.6.
228
+ """
229
+ project_id = _require_non_empty(project_id, field="project_id")
230
+ name = _require_non_empty(name, field="name")
231
+
232
+ params: Dict[str, Any] = {
233
+ "project_id": project_id,
234
+ "name": name,
235
+ }
236
+
237
+ return _unwrap(
238
+ await self._client.call_validated("worktree_conflicts", params)
239
+ )
240
+
241
+ async def continue_(
242
+ self,
243
+ project_id: str,
244
+ name: str,
245
+ resolved_paths: List[str],
246
+ *,
247
+ message: Optional[str] = None,
248
+ auto_poll: bool = True,
249
+ timeout: Optional[float] = None,
250
+ ) -> Dict[str, Any]:
251
+ """Finalize merge or rebase after resolving conflicts.
252
+
253
+ Calls ``worktree_continue`` per candidate-WT-1.md section 5.7.
254
+ """
255
+ project_id = _require_non_empty(project_id, field="project_id")
256
+ name = _require_non_empty(name, field="name")
257
+ if not resolved_paths:
258
+ raise ClientValidationError(
259
+ "resolved_paths must be non-empty",
260
+ field="resolved_paths",
261
+ )
262
+
263
+ params: Dict[str, Any] = {
264
+ "project_id": project_id,
265
+ "name": name,
266
+ "resolved_paths": resolved_paths,
267
+ }
268
+ if message is not None:
269
+ params["message"] = message
270
+
271
+ return _unwrap(
272
+ await self._client.call_validated(
273
+ "worktree_continue",
274
+ params,
275
+ auto_poll=auto_poll,
276
+ timeout=timeout,
277
+ )
278
+ )
279
+
280
+ async def abort(
281
+ self,
282
+ project_id: str,
283
+ name: str,
284
+ *,
285
+ auto_poll: bool = True,
286
+ timeout: Optional[float] = None,
287
+ ) -> Dict[str, Any]:
288
+ """Abort in-progress merge or rebase.
289
+
290
+ Calls ``worktree_abort`` per candidate-WT-1.md section 5.8.
291
+ """
292
+ project_id = _require_non_empty(project_id, field="project_id")
293
+ name = _require_non_empty(name, field="name")
294
+
295
+ params: Dict[str, Any] = {
296
+ "project_id": project_id,
297
+ "name": name,
298
+ }
299
+
300
+ return _unwrap(
301
+ await self._client.call_validated(
302
+ "worktree_abort",
303
+ params,
304
+ auto_poll=auto_poll,
305
+ timeout=timeout,
306
+ )
307
+ )
308
+
309
+ # === Commit and removal ===
310
+
311
+ async def commit(
312
+ self,
313
+ project_id: str,
314
+ name: str,
315
+ message: str,
316
+ *,
317
+ paths: Optional[List[str]] = None,
318
+ all: bool = False,
319
+ auto_poll: bool = True,
320
+ timeout: Optional[float] = None,
321
+ ) -> Dict[str, Any]:
322
+ """Commit staged or all changes in worktree.
323
+
324
+ Calls ``worktree_commit`` per candidate-WT-1.md section 5.9.
325
+ """
326
+ project_id = _require_non_empty(project_id, field="project_id")
327
+ name = _require_non_empty(name, field="name")
328
+ message = _require_non_empty(message, field="message")
329
+
330
+ if not paths and not all:
331
+ raise ClientValidationError(
332
+ "At least one of paths or all=true is required",
333
+ field="paths",
334
+ )
335
+
336
+ params: Dict[str, Any] = {
337
+ "project_id": project_id,
338
+ "name": name,
339
+ "message": message,
340
+ }
341
+ if paths is not None:
342
+ params["paths"] = paths
343
+ if all:
344
+ params["all"] = True
345
+
346
+ return _unwrap(
347
+ await self._client.call_validated(
348
+ "worktree_commit",
349
+ params,
350
+ auto_poll=auto_poll,
351
+ timeout=timeout,
352
+ )
353
+ )
354
+
355
+ async def remove(
356
+ self,
357
+ project_id: str,
358
+ name: str,
359
+ *,
360
+ force: bool = False,
361
+ delete_branch: bool = True,
362
+ auto_poll: bool = True,
363
+ timeout: Optional[float] = None,
364
+ ) -> Dict[str, Any]:
365
+ """Remove a worktree and optionally its branch.
366
+
367
+ Calls ``worktree_remove`` per candidate-WT-1.md section 5.10.
368
+ """
369
+ project_id = _require_non_empty(project_id, field="project_id")
370
+ name = _require_non_empty(name, field="name")
371
+
372
+ params: Dict[str, Any] = {
373
+ "project_id": project_id,
374
+ "name": name,
375
+ }
376
+ if force:
377
+ params["force"] = True
378
+ if not delete_branch:
379
+ params["delete_branch"] = False
380
+
381
+ return _unwrap(
382
+ await self._client.call_validated(
383
+ "worktree_remove",
384
+ params,
385
+ auto_poll=auto_poll,
386
+ timeout=timeout,
387
+ )
388
+ )
389
+
390
+ async def prune(
391
+ self,
392
+ project_id: str,
393
+ *,
394
+ name: Optional[str] = None,
395
+ ttl_seconds: Optional[int] = None,
396
+ only_names: Optional[List[str]] = None,
397
+ dry_run: bool = False,
398
+ auto_poll: bool = True,
399
+ timeout: Optional[float] = None,
400
+ ) -> Dict[str, Any]:
401
+ """TTL safety net: reap idle worktrees whose sessions are gone.
402
+
403
+ Calls ``worktree_prune`` per candidate-WT-1.md section 5.11.
404
+ """
405
+ project_id = _require_non_empty(project_id, field="project_id")
406
+
407
+ params: Dict[str, Any] = {"project_id": project_id}
408
+ if name is not None:
409
+ params["name"] = name
410
+ if ttl_seconds is not None:
411
+ params["ttl_seconds"] = ttl_seconds
412
+ if only_names is not None:
413
+ params["only_names"] = only_names
414
+ if dry_run:
415
+ params["dry_run"] = True
416
+
417
+ return _unwrap(
418
+ await self._client.call_validated(
419
+ "worktree_prune",
420
+ params,
421
+ auto_poll=auto_poll,
422
+ timeout=timeout,
423
+ )
424
+ )
425
+
426
+ # === Lock and maintenance ===
427
+
428
+ async def lock(
429
+ self,
430
+ project_id: str,
431
+ name: str,
432
+ *,
433
+ reason: Optional[str] = None,
434
+ ) -> Dict[str, Any]:
435
+ """Lock a worktree to protect from removal.
436
+
437
+ Calls ``worktree_lock`` per candidate-WT-1.md section 5.12.
438
+ """
439
+ project_id = _require_non_empty(project_id, field="project_id")
440
+ name = _require_non_empty(name, field="name")
441
+
442
+ params: Dict[str, Any] = {
443
+ "project_id": project_id,
444
+ "name": name,
445
+ }
446
+ if reason is not None:
447
+ params["reason"] = reason
448
+
449
+ return _unwrap(
450
+ await self._client.call_validated("worktree_lock", params)
451
+ )
452
+
453
+ async def unlock(
454
+ self,
455
+ project_id: str,
456
+ name: str,
457
+ ) -> Dict[str, Any]:
458
+ """Unlock a worktree.
459
+
460
+ Calls ``worktree_unlock`` per candidate-WT-1.md section 5.13.
461
+ """
462
+ project_id = _require_non_empty(project_id, field="project_id")
463
+ name = _require_non_empty(name, field="name")
464
+
465
+ params: Dict[str, Any] = {
466
+ "project_id": project_id,
467
+ "name": name,
468
+ }
469
+
470
+ return _unwrap(
471
+ await self._client.call_validated("worktree_unlock", params)
472
+ )
473
+
474
+ async def move(
475
+ self,
476
+ project_id: str,
477
+ name: str,
478
+ new_name: str,
479
+ *,
480
+ auto_poll: bool = True,
481
+ timeout: Optional[float] = None,
482
+ ) -> Dict[str, Any]:
483
+ """Move/rename a worktree.
484
+
485
+ Calls ``worktree_move`` per candidate-WT-1.md section 5.14.
486
+ """
487
+ project_id = _require_non_empty(project_id, field="project_id")
488
+ name = _require_non_empty(name, field="name")
489
+ new_name = _require_non_empty(new_name, field="new_name")
490
+
491
+ params: Dict[str, Any] = {
492
+ "project_id": project_id,
493
+ "name": name,
494
+ "new_name": new_name,
495
+ }
496
+
497
+ return _unwrap(
498
+ await self._client.call_validated(
499
+ "worktree_move",
500
+ params,
501
+ auto_poll=auto_poll,
502
+ timeout=timeout,
503
+ )
504
+ )
505
+
506
+ async def repair(
507
+ self,
508
+ project_id: str,
509
+ name: str,
510
+ *,
511
+ auto_poll: bool = True,
512
+ timeout: Optional[float] = None,
513
+ ) -> Dict[str, Any]:
514
+ """Repair worktree after manual filesystem move/restore.
515
+
516
+ Calls ``worktree_repair`` per candidate-WT-1.md section 5.15.
517
+ """
518
+ project_id = _require_non_empty(project_id, field="project_id")
519
+ name = _require_non_empty(name, field="name")
520
+
521
+ params: Dict[str, Any] = {
522
+ "project_id": project_id,
523
+ "name": name,
524
+ }
525
+
526
+ return _unwrap(
527
+ await self._client.call_validated(
528
+ "worktree_repair",
529
+ params,
530
+ auto_poll=auto_poll,
531
+ timeout=timeout,
532
+ )
533
+ )