ww-agentic-workflows 1.0.0.dev3__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.
Files changed (167) hide show
  1. ww/__init__.py +18 -0
  2. ww/_bundled_extensions/ww/git/extension.py +1728 -0
  3. ww/action_execution.py +887 -0
  4. ww/actions/__init__.py +94 -0
  5. ww/actions/command.py +444 -0
  6. ww/actions/contracts.py +699 -0
  7. ww/actions/extension.py +197 -0
  8. ww/actions/mcp.py +84 -0
  9. ww/actions/prompt.py +74 -0
  10. ww/actions/skill.py +62 -0
  11. ww/actions/slash_command.py +63 -0
  12. ww/agents.py +151 -0
  13. ww/amendments.py +54 -0
  14. ww/artifacts.py +93 -0
  15. ww/assessments.py +181 -0
  16. ww/assets/__init__.py +2 -0
  17. ww/assets/agent_instructions.md +49 -0
  18. ww/assets/docs/examples.md +879 -0
  19. ww/assets/docs/features.md +4639 -0
  20. ww/assets/docs/specification.md +1876 -0
  21. ww/assets/noww_skill.md +11 -0
  22. ww/assets/workflows/catchall.yaml +26 -0
  23. ww/assets/workflows/onboarding.yaml +586 -0
  24. ww/assets/workflows/scriptize.yaml +130 -0
  25. ww/assets/ww-automate_skill.md +23 -0
  26. ww/assets/ww-deduce-feedback_skill.md +38 -0
  27. ww/assets/ww-feedback-rules_skill.md +48 -0
  28. ww/assets/ww-learn-project_skill.md +22 -0
  29. ww/assets/ww-refresh_skill.md +26 -0
  30. ww/assets/ww-rule_skill.md +83 -0
  31. ww/assets/ww-rules-from-artifacts_skill.md +22 -0
  32. ww/assets/ww-scriptize_skill.md +33 -0
  33. ww/assets/ww-setup_skill.md +94 -0
  34. ww/assets/ww-solve_skill.md +23 -0
  35. ww/assets/ww-suggest_skill.md +32 -0
  36. ww/assets/ww-wizard_skill.md +105 -0
  37. ww/assets/ww_skill.md +59 -0
  38. ww/assignments.py +283 -0
  39. ww/bootstrap.py +405 -0
  40. ww/builtin_workflows.py +215 -0
  41. ww/changes.py +225 -0
  42. ww/child_coordination.py +482 -0
  43. ww/children.py +106 -0
  44. ww/claude_permissions.py +115 -0
  45. ww/cli/__init__.py +7 -0
  46. ww/cli/__main__.py +6 -0
  47. ww/cli/audit.py +129 -0
  48. ww/cli/catalogs.py +131 -0
  49. ww/cli/discover.py +607 -0
  50. ww/cli/initialization.py +898 -0
  51. ww/cli/lookup.py +287 -0
  52. ww/cli/main.py +1768 -0
  53. ww/cli/parser.py +1200 -0
  54. ww/cli/prompts.py +217 -0
  55. ww/cli/updates.py +117 -0
  56. ww/completion_artifacts.py +156 -0
  57. ww/completion_inputs.py +39 -0
  58. ww/config/__init__.py +582 -0
  59. ww/config/actions.py +591 -0
  60. ww/config/composition.py +571 -0
  61. ww/config/rules.py +511 -0
  62. ww/config/steps.py +1220 -0
  63. ww/config/values.py +223 -0
  64. ww/config_files.py +191 -0
  65. ww/config_writes.py +264 -0
  66. ww/contracts.py +155 -0
  67. ww/control.py +41 -0
  68. ww/defaults.py +130 -0
  69. ww/design_docs.py +32 -0
  70. ww/discovery.py +104 -0
  71. ww/documents.py +217 -0
  72. ww/errors.py +18 -0
  73. ww/executable.py +43 -0
  74. ww/execution_models/__init__.py +64 -0
  75. ww/execution_models/construction.py +148 -0
  76. ww/execution_models/decoding.py +38 -0
  77. ww/execution_models/plan_codec.py +565 -0
  78. ww/execution_models/records.py +1206 -0
  79. ww/execution_models/runs.py +266 -0
  80. ww/extensions/__init__.py +40 -0
  81. ww/extensions/api.py +559 -0
  82. ww/extensions/registry.py +864 -0
  83. ww/extensions/store.py +78 -0
  84. ww/feedback.py +342 -0
  85. ww/handler_repairs.py +57 -0
  86. ww/hooks/__init__.py +40 -0
  87. ww/hooks/agents.py +380 -0
  88. ww/hooks/install.py +168 -0
  89. ww/hooks/notices.py +206 -0
  90. ww/hooks/records.py +209 -0
  91. ww/hooks/runtime.py +266 -0
  92. ww/hooks/transcripts.py +183 -0
  93. ww/inspect.py +896 -0
  94. ww/instructions/__init__.py +17 -0
  95. ww/instructions/builder.py +1682 -0
  96. ww/instructions/commands.py +335 -0
  97. ww/instructions/handoff.py +149 -0
  98. ww/instructions/models.py +686 -0
  99. ww/instructions/policy.py +219 -0
  100. ww/instructions/text.py +168 -0
  101. ww/interactions.py +187 -0
  102. ww/interpolation.py +37 -0
  103. ww/item_passes.py +167 -0
  104. ww/items.py +99 -0
  105. ww/locking.py +207 -0
  106. ww/metadata_publication.py +230 -0
  107. ww/onboarding.py +229 -0
  108. ww/open_work.py +236 -0
  109. ww/operations.py +193 -0
  110. ww/operator_ui/__init__.py +16 -0
  111. ww/operator_ui/page.html +351 -0
  112. ww/operator_ui/server.py +215 -0
  113. ww/operator_ui/session.py +389 -0
  114. ww/operator_ui/sheet.py +104 -0
  115. ww/operator_ui/view.py +109 -0
  116. ww/output.py +339 -0
  117. ww/output_adapters/__init__.py +12 -0
  118. ww/output_adapters/base.py +25 -0
  119. ww/output_adapters/json_adapter.py +37 -0
  120. ww/output_adapters/markdown.py +2293 -0
  121. ww/output_adapters/rule_pages.py +337 -0
  122. ww/output_adapters/terminal.py +21 -0
  123. ww/package_updates.py +167 -0
  124. ww/plan/__init__.py +38 -0
  125. ww/plan/actions.py +207 -0
  126. ww/plan/compiler.py +1492 -0
  127. ww/plan/constructs.py +456 -0
  128. ww/plan/models.py +665 -0
  129. ww/project_config.py +752 -0
  130. ww/recovery.py +401 -0
  131. ww/replanning.py +367 -0
  132. ww/results.py +77 -0
  133. ww/rule_checks.py +230 -0
  134. ww/rule_conversion.py +331 -0
  135. ww/rule_disputes.py +148 -0
  136. ww/rule_store.py +456 -0
  137. ww/rule_verification.py +714 -0
  138. ww/rule_views.py +447 -0
  139. ww/rule_writes.py +920 -0
  140. ww/run_coordination.py +158 -0
  141. ww/runtimes.py +105 -0
  142. ww/service.py +4405 -0
  143. ww/setup_apply.py +428 -0
  144. ww/step_values.py +20 -0
  145. ww/storage.py +447 -0
  146. ww/storage_adapters/__init__.py +36 -0
  147. ww/storage_adapters/base.py +540 -0
  148. ww/storage_adapters/filesystem.py +370 -0
  149. ww/storage_adapters/memory.py +195 -0
  150. ww/storage_adapters/project_metadata.py +69 -0
  151. ww/storage_adapters/task_document.py +484 -0
  152. ww/task_ids.py +114 -0
  153. ww/task_references.py +124 -0
  154. ww/transitions.py +1619 -0
  155. ww/updates.py +399 -0
  156. ww/upgrade.py +95 -0
  157. ww/validation.py +168 -0
  158. ww/variables.py +275 -0
  159. ww/workflow_config.py +854 -0
  160. ww/workflow_update.py +239 -0
  161. ww/workflow_validation.py +1260 -0
  162. ww/workspace.py +50 -0
  163. ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
  164. ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
  165. ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
  166. ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
  167. ww_agentic_workflows-1.0.0.dev3.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,1728 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Git support for ww, as an extension.
3
+
4
+ This is the first ww extension, and it deliberately lives in ``<root>/ext/``
5
+ rather than inside the ww package: it reaches ww only through
6
+ ``ww.extensions.api``, so it exercises exactly the path a third-party extension
7
+ takes.
8
+
9
+ What it adds over the equivalent shell handlers is memory and configuration. It
10
+ records every commit it makes and every branch it opens, and it reads its
11
+ settings from the ``ww/git`` section of ``ww.json``:
12
+
13
+ ```json
14
+ "extensions": {
15
+ "ww/git": {
16
+ "commit_format": "{{ww.task.id}}: {{commit_message}}",
17
+ "base_branches": {
18
+ "default": "main",
19
+ "bugfix": "develop",
20
+ "task": {"argv": ["./scripts/base-branch", "{{ww.task.lane}}"]}
21
+ },
22
+ "separate_branch": true,
23
+ "branch_name_formats": {
24
+ "default": "feature/{{ww.task.id}}",
25
+ "bugfix": "hotfix/{{ww.task.id}}"
26
+ },
27
+ "worktrees": false
28
+ }
29
+ }
30
+ ```
31
+
32
+ Handlers adopt state that already satisfies their request where possible and
33
+ refuse destructive recovery: nothing here passes ``--force``, discards a
34
+ change, or moves work you did not ask it to move. This is not a general
35
+ exactly-once guarantee. ``git-commit`` and ``merge-branch`` use ww's stable
36
+ operation ID and their checkers to recognize a commit made by an interrupted
37
+ attempt before retrying. ``merge-branch`` runs ``git merge --abort`` only on a
38
+ merge it started itself from a clean workspace: after a conflict, after a
39
+ refused signature, or when an interrupted attempt of the same operation left
40
+ it in progress.
41
+ Rendered task branch names are normalized to lowercase before Git operations.
42
+
43
+ A note on worktrees
44
+ -------------------
45
+
46
+ Under ``worktrees: true``, ``start-task-branch`` creates the branch and the
47
+ separate ``create-worktree`` handler selects or creates its checkout. ww
48
+ persists that directory, shows it to the agent, and runs later automatic
49
+ handlers there. The primary checkout is the task workspace when its current
50
+ branch exactly matches the task branch rendered from ``branch_name_formats``;
51
+ otherwise the separate checkout is used. Before committing, ``git-commit``
52
+ verifies that the workspace is the selected Git checkout root and that every
53
+ changed and staged path resolves beneath it.
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ import json
59
+ import subprocess
60
+ import tempfile
61
+ from collections.abc import Mapping
62
+ from dataclasses import dataclass, field, replace
63
+ from datetime import datetime, timezone
64
+ from pathlib import Path
65
+ from typing import Any
66
+
67
+ from ww.errors import ConfigurationError
68
+ from ww.extensions.api import (
69
+ Extension,
70
+ ExtensionCheckResult,
71
+ ExtensionCommand,
72
+ ExtensionContext,
73
+ ExtensionHandler,
74
+ ExtensionNamespace,
75
+ ExtensionResult,
76
+ ExtensionVariable,
77
+ ModeDefinition,
78
+ ProvidedVariable,
79
+ )
80
+ from ww.interpolation import dependencies, interpolate
81
+ from ww.variables import BRANCH_NAMING_STRATEGY, TASK_ID, TASK_WORKSPACE_DIR
82
+
83
+ COMMITS_FILE = "commits.jsonl"
84
+ BRANCHES_FILE = "branches.jsonl"
85
+ # ``git status --porcelain`` prefixes each path with two status letters and a space.
86
+ _PORCELAIN_STATUS_WIDTH = 3
87
+
88
+ # The tokens a format reads: the task's ID, its workflow, and its run.
89
+ WORKFLOW_TOKEN = "ww.task.workflow"
90
+ # The workflow whose branch handling the task takes: its ``hooks_from``, else
91
+ # the workflow itself, the key ``branch_name_formats`` and ``base_branches``
92
+ # are looked up by.
93
+ LANE_TOKEN = "ww.task.lane"
94
+ RUN_TOKEN = "ww.task.run"
95
+ FORMAT_TOKENS = (TASK_ID, WORKFLOW_TOKEN, LANE_TOKEN, RUN_TOKEN)
96
+ DEFAULT_COMMIT_FORMAT = "{{ww.task.id}}: {{commit_message}}"
97
+ DEFAULT_BRANCH_FORMAT = "{{ww.task.id}}"
98
+ _SETTING_KEYS = {
99
+ "commit_format",
100
+ "base_branches",
101
+ "separate_branch",
102
+ "branch_name_formats",
103
+ "worktrees",
104
+ "worktree_dir",
105
+ "worktree_name_format",
106
+ "on_signing_failure",
107
+ }
108
+ # What the commit handler does when git cannot sign a commit: stop for the
109
+ # operator, or commit once more without a signature and say so.
110
+ SIGNING_FAILURE_POLICIES = ("operator", "unsigned")
111
+ # The fields ``git log`` prints per commit when looking for an operation.
112
+ _LOG_FIELDS = 3
113
+ # The positional ``args`` of merge-branch.
114
+ MERGE_ARGUMENTS = ("branch", "message")
115
+ # What git prints when it cannot sign: its own marker, and the signing
116
+ # programs' usual wording (gpg, ssh-keygen, ssh-agent signers).
117
+ _SIGNING_FAILURE_MARKERS = (
118
+ "failed to write commit object",
119
+ "failed to sign",
120
+ "gpg failed",
121
+ "signing failed",
122
+ )
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class Settings:
127
+ """The ``ww/git`` section of ``ww.json``, validated."""
128
+
129
+ commit_format: str = DEFAULT_COMMIT_FORMAT
130
+ # Per workflow name, with ``default`` for every other workflow. A
131
+ # repository with its own conventions states them in its own
132
+ # ``ww.json``, which ww applies for tasks working there.
133
+ base_branches: dict[str, str | tuple[str, ...]] = field(default_factory=dict)
134
+ separate_branch: bool = False
135
+ branch_name_formats: dict[str, str] = field(
136
+ default_factory=lambda: {"default": DEFAULT_BRANCH_FORMAT}
137
+ )
138
+ worktrees: bool = False
139
+ worktree_dir: str | None = None
140
+ worktree_name_format: str = DEFAULT_BRANCH_FORMAT
141
+ on_signing_failure: str = "operator"
142
+
143
+ def branch_format(
144
+ self, workflow: str | None, strategy: str | None = None
145
+ ) -> str | None:
146
+ """Return an explicit strategy, or use workflow/default selection."""
147
+ if strategy is not None:
148
+ return self.branch_name_formats.get(strategy)
149
+ if workflow and workflow in self.branch_name_formats:
150
+ return self.branch_name_formats[workflow]
151
+ return self.branch_name_formats.get("default", DEFAULT_BRANCH_FORMAT)
152
+
153
+ def base_branch_for(self, workflow: str | None) -> str | tuple[str, ...] | None:
154
+ """Return the workflow's entry, then the ``default`` entry."""
155
+ if workflow and workflow in self.base_branches:
156
+ return self.base_branches[workflow]
157
+ return self.base_branches.get("default")
158
+
159
+ def to_dict(self) -> dict[str, Any]:
160
+ return {
161
+ "commit_format": self.commit_format,
162
+ "base_branches": {
163
+ workflow: _base_branch_dict(definition)
164
+ for workflow, definition in self.base_branches.items()
165
+ },
166
+ "separate_branch": self.separate_branch,
167
+ "branch_name_formats": dict(self.branch_name_formats),
168
+ "worktrees": self.worktrees,
169
+ "worktree_dir": self.worktree_dir,
170
+ "worktree_name_format": self.worktree_name_format,
171
+ "on_signing_failure": self.on_signing_failure,
172
+ }
173
+
174
+
175
+ def settings_from(config: Any) -> Settings:
176
+ """Validate one ``ww/git`` settings mapping.
177
+
178
+ ww hands the section through untouched, so this is where a typo is caught.
179
+ Unknown keys are an error rather than ignored: a misspelled setting that is
180
+ silently dropped looks configured and changes nothing.
181
+ """
182
+ if not config:
183
+ return Settings()
184
+ if not isinstance(config, dict):
185
+ raise ConfigurationError("ww/git settings must be an object")
186
+ unknown = set(config) - _SETTING_KEYS
187
+ if unknown:
188
+ raise ConfigurationError(
189
+ "ww/git has unknown setting(s): "
190
+ + ", ".join(sorted(unknown))
191
+ + "; known settings: "
192
+ + ", ".join(sorted(_SETTING_KEYS))
193
+ )
194
+ formats = config.get("branch_name_formats", {"default": DEFAULT_BRANCH_FORMAT})
195
+ if not isinstance(formats, dict) or not all(
196
+ isinstance(key, str) and isinstance(value, str)
197
+ for key, value in formats.items()
198
+ ):
199
+ raise ConfigurationError(
200
+ "ww/git branch_name_formats must map workflow names to formats"
201
+ )
202
+ base_branches = config.get("base_branches", {})
203
+ if not isinstance(base_branches, dict) or not all(
204
+ isinstance(workflow, str) and workflow.strip() for workflow in base_branches
205
+ ):
206
+ raise ConfigurationError(
207
+ "ww/git base_branches must map workflow names, or default, to base branches"
208
+ )
209
+ settings = Settings(
210
+ commit_format=_string(config, "commit_format", DEFAULT_COMMIT_FORMAT),
211
+ base_branches={
212
+ workflow: _required_base_branch(definition, f"base_branches[{workflow!r}]")
213
+ for workflow, definition in base_branches.items()
214
+ },
215
+ separate_branch=_bool(config, "separate_branch"),
216
+ branch_name_formats=dict(formats),
217
+ worktrees=_bool(config, "worktrees"),
218
+ worktree_dir=_optional_string(config, "worktree_dir"),
219
+ worktree_name_format=_string(
220
+ config, "worktree_name_format", DEFAULT_BRANCH_FORMAT
221
+ ),
222
+ on_signing_failure=_string(config, "on_signing_failure", "operator"),
223
+ )
224
+ if settings.on_signing_failure not in SIGNING_FAILURE_POLICIES:
225
+ raise ConfigurationError(
226
+ "ww/git on_signing_failure must be one of: "
227
+ + ", ".join(SIGNING_FAILURE_POLICIES)
228
+ )
229
+ _validate_commit_format(settings.commit_format)
230
+ if settings.worktrees and not settings.worktree_dir:
231
+ # Every default is wrong somewhere: inside the repository a worktree
232
+ # dirties the tree is-git-clean guards, and outside it writes to a
233
+ # directory nobody named. Ask instead of guessing.
234
+ raise ConfigurationError(
235
+ "ww/git worktree_dir is required when worktrees is true"
236
+ )
237
+ return settings
238
+
239
+
240
+ def _validate_commit_format(value: str) -> None:
241
+ """Ensure the format can produce one complete commit subject."""
242
+ names = dependencies(value)
243
+ unknown = set(names) - {*FORMAT_TOKENS, "commit_message"}
244
+ if unknown:
245
+ raise ConfigurationError(
246
+ "ww/git commit_format has unknown placeholder(s): "
247
+ + ", ".join(sorted(unknown))
248
+ )
249
+ if names.count("commit_message") != 1:
250
+ raise ConfigurationError(
251
+ "ww/git commit_format must contain exactly one "
252
+ "{{commit_message}} placeholder"
253
+ )
254
+
255
+
256
+ def _string(config: dict[str, Any], key: str, default: str) -> str:
257
+ value = config.get(key, default)
258
+ if not isinstance(value, str) or not value.strip():
259
+ raise ConfigurationError(f"ww/git {key} must be a non-empty string")
260
+ return value
261
+
262
+
263
+ def _optional_string(config: dict[str, Any], key: str) -> str | None:
264
+ value = config.get(key)
265
+ if value is None:
266
+ return None
267
+ if not isinstance(value, str) or not value.strip():
268
+ raise ConfigurationError(f"ww/git {key} must be a non-empty string")
269
+ return value
270
+
271
+
272
+ def _required_base_branch(value: Any, path: str) -> str | tuple[str, ...]:
273
+ if isinstance(value, str):
274
+ if value.strip():
275
+ return value
276
+ raise ConfigurationError(f"ww/git {path} must be a non-empty string")
277
+ if not isinstance(value, dict) or set(value) != {"argv"}:
278
+ raise ConfigurationError(
279
+ f"ww/git {path} must be a branch string or an object containing argv"
280
+ )
281
+ argv = value["argv"]
282
+ if (
283
+ not isinstance(argv, list)
284
+ or not argv
285
+ or not all(isinstance(argument, str) and argument for argument in argv)
286
+ ):
287
+ raise ConfigurationError(
288
+ f"ww/git {path}.argv must be a non-empty array of non-empty strings"
289
+ )
290
+ return tuple(argv)
291
+
292
+
293
+ def _base_branch_dict(
294
+ definition: str | tuple[str, ...],
295
+ ) -> str | dict[str, list[str]]:
296
+ if isinstance(definition, tuple):
297
+ return {"argv": list(definition)}
298
+ return definition
299
+
300
+
301
+ def _bool(config: dict[str, Any], key: str) -> bool:
302
+ value = config.get(key, False)
303
+ if not isinstance(value, bool):
304
+ raise ConfigurationError(f"ww/git {key} must be true or false")
305
+ return value
306
+
307
+
308
+ def _git(
309
+ context: ExtensionContext, *arguments: str, cwd: Path | None = None
310
+ ) -> subprocess.CompletedProcess[str]:
311
+ return subprocess.run(
312
+ ["git", *arguments],
313
+ cwd=cwd or context.workspace or context.root,
314
+ capture_output=True,
315
+ text=True,
316
+ check=False,
317
+ )
318
+
319
+
320
+ def _failed(result: subprocess.CompletedProcess[str], fallback: str) -> str:
321
+ return (result.stderr or result.stdout).strip() or fallback
322
+
323
+
324
+ def _repository(context: ExtensionContext) -> Path:
325
+ """The primary checkout of the repository this task works in.
326
+
327
+ A task's working directory is its project directory, or a worktree of
328
+ it; the ww project root is only the fallback. Branch operations always
329
+ run in the primary checkout, so a worktree resolves back through its
330
+ shared git directory.
331
+ """
332
+ start = (context.workspace or context.root).resolve()
333
+ result = subprocess.run(
334
+ ["git", "rev-parse", "--git-common-dir"],
335
+ cwd=start,
336
+ capture_output=True,
337
+ text=True,
338
+ check=False,
339
+ )
340
+ if result.returncode == 0:
341
+ common = Path(result.stdout.strip())
342
+ if not common.is_absolute():
343
+ common = start / common
344
+ common = common.resolve()
345
+ if common.name == ".git":
346
+ return common.parent
347
+ return start
348
+
349
+
350
+ def _workspace_root(context: ExtensionContext) -> tuple[Path | None, str | None]:
351
+ """Resolve and verify the Git worktree this handler is allowed to touch."""
352
+ workspace = (context.workspace or context.root).resolve()
353
+ top_level = _git(context, "rev-parse", "--show-toplevel")
354
+ if top_level.returncode:
355
+ return None, _failed(top_level, "could not determine Git worktree root")
356
+ root = Path(top_level.stdout.strip()).resolve()
357
+ if root != workspace:
358
+ return (
359
+ None,
360
+ f"selected workspace {workspace} is not the Git worktree root {root}",
361
+ )
362
+ return root, None
363
+
364
+
365
+ def _paths_inside(root: Path, paths: tuple[str, ...]) -> str | None:
366
+ """Reject paths that do not resolve under the selected worktree root."""
367
+ for path in paths:
368
+ candidate = Path(path)
369
+ if not path or candidate.is_absolute():
370
+ return f"Git reported unsafe worktree path {path!r}"
371
+ try:
372
+ (root / candidate).resolve().relative_to(root)
373
+ except ValueError:
374
+ return f"Git reported path outside the selected worktree: {path!r}"
375
+ return None
376
+
377
+
378
+ def _status_paths(output: str) -> tuple[str, ...]:
379
+ """Read every pathname from Git's NUL-delimited porcelain v1 output."""
380
+ fields = output.split("\0")
381
+ paths: list[str] = []
382
+ index = 0
383
+ while index < len(fields):
384
+ field = fields[index]
385
+ if not field:
386
+ index += 1
387
+ continue
388
+ if len(field) <= _PORCELAIN_STATUS_WIDTH or field[2] != " ":
389
+ # Git produced malformed porcelain. Treat it as unsafe rather than
390
+ # guessing which part might be a pathname.
391
+ return ()
392
+ status, path = field[:2], field[3:]
393
+ paths.append(path)
394
+ if "R" in status or "C" in status:
395
+ index += 1
396
+ if index >= len(fields) or not fields[index]:
397
+ return ()
398
+ paths.append(fields[index])
399
+ index += 1
400
+ return tuple(paths)
401
+
402
+
403
+ def _validate_commit_workspace(context: ExtensionContext) -> str | None:
404
+ root, error = _workspace_root(context)
405
+ if error:
406
+ return error
407
+ assert root is not None
408
+ status = _git(context, "status", "--porcelain=v1", "-z", "--untracked-files=all")
409
+ if status.returncode:
410
+ return _failed(status, "git status failed")
411
+ paths = _status_paths(status.stdout)
412
+ if status.stdout and not paths:
413
+ return "Git reported malformed status output"
414
+ return _paths_inside(root, paths)
415
+
416
+
417
+ def _validate_staged_paths(context: ExtensionContext) -> str | None:
418
+ root, error = _workspace_root(context)
419
+ if error:
420
+ return error
421
+ assert root is not None
422
+ staged = _git(context, "diff", "--cached", "--name-only", "-z")
423
+ if staged.returncode:
424
+ return _failed(staged, "could not inspect staged paths")
425
+ paths = tuple(path for path in staged.stdout.split("\0") if path)
426
+ return _paths_inside(root, paths)
427
+
428
+
429
+ def _tokens(context: ExtensionContext) -> dict[str, str]:
430
+ return {
431
+ TASK_ID: context.task_id or "",
432
+ WORKFLOW_TOKEN: context.workflow or "",
433
+ LANE_TOKEN: _lane(context) or "",
434
+ RUN_TOKEN: context.run_id or "",
435
+ }
436
+
437
+
438
+ def _now() -> str:
439
+ return (
440
+ datetime.now(timezone.utc)
441
+ .replace(microsecond=0)
442
+ .isoformat()
443
+ .replace("+00:00", "Z")
444
+ )
445
+
446
+
447
+ def _current_branch(context: ExtensionContext, cwd: Path | None = None) -> str:
448
+ result = _git(context, "rev-parse", "--abbrev-ref", "HEAD", cwd=cwd)
449
+ return result.stdout.strip()
450
+
451
+
452
+ def _branch_exists(
453
+ context: ExtensionContext, branch: str, cwd: Path | None = None
454
+ ) -> bool:
455
+ return (
456
+ _git(
457
+ context,
458
+ "rev-parse",
459
+ "--verify",
460
+ "--quiet",
461
+ f"refs/heads/{branch}",
462
+ cwd=cwd,
463
+ )
464
+ ).returncode == 0
465
+
466
+
467
+ def _root_uses_task_branch(context: ExtensionContext, task_branch: str) -> bool:
468
+ """Whether the project root is already the configured task workspace.
469
+
470
+ ``task_branch`` is rendered by :func:`_task_branch` from the configured
471
+ workflow-specific branch format. No conventional base or development
472
+ branch names participate in workspace selection.
473
+ """
474
+ return _current_branch(context, cwd=_repository(context)) == task_branch
475
+
476
+
477
+ def _worktree_path(context: ExtensionContext, settings: Settings, name: str) -> Path:
478
+ directory = Path(settings.worktree_dir or "")
479
+ if not directory.is_absolute():
480
+ directory = _repository(context) / directory
481
+ return directory / name
482
+
483
+
484
+ def _reserved_paths(context: ExtensionContext) -> tuple[Path, ...]:
485
+ """The worktree this task would own, so ww never reuses its ID elsewhere."""
486
+ settings = _task_settings(context)
487
+ if not settings.worktrees or not settings.worktree_dir:
488
+ return ()
489
+ name = interpolate(settings.worktree_name_format, _tokens(context)).strip()
490
+ relative = Path(name)
491
+ if (
492
+ not name
493
+ or relative.is_absolute()
494
+ or any(part in {".", ".."} for part in relative.parts)
495
+ ):
496
+ return ()
497
+ return (_worktree_path(context, settings, name),)
498
+
499
+
500
+ def _record_branch(context: ExtensionContext, **fields: Any) -> None:
501
+ context.store.append_line(
502
+ BRANCHES_FILE,
503
+ json.dumps({**fields, "recorded_at": _now()}, sort_keys=True),
504
+ )
505
+
506
+
507
+ def _recorded_branch(context: ExtensionContext, task_id: str) -> dict[str, Any] | None:
508
+ for line in reversed(context.store.read_lines(BRANCHES_FILE)):
509
+ if not line:
510
+ continue
511
+ record = json.loads(line)
512
+ if record.get("task_id") == task_id:
513
+ return record
514
+ return None
515
+
516
+
517
+ def _record_commit(context: ExtensionContext, record: dict[str, Any]) -> None:
518
+ """Record one commit without racing another recovery of the operation."""
519
+ operation_id = record.get("operation_id")
520
+ if not operation_id:
521
+ context.store.append_line(COMMITS_FILE, json.dumps(record, sort_keys=True))
522
+ return
523
+
524
+ def update(current: str | None) -> str:
525
+ lines = current.splitlines() if current else []
526
+ if any(
527
+ json.loads(line).get("operation_id") == operation_id
528
+ for line in lines
529
+ if line
530
+ ):
531
+ return current or ""
532
+ lines.append(json.dumps(record, sort_keys=True))
533
+ return "\n".join(lines) + "\n"
534
+
535
+ context.store.update_text(COMMITS_FILE, update)
536
+
537
+
538
+ def _parent_branch(context: ExtensionContext) -> str | None:
539
+ """Return the branch recorded for a direct parent task, if any."""
540
+ if not context.task_id or "/" not in context.task_id:
541
+ return None
542
+ parent_task_id, _, _ = context.task_id.rpartition("/")
543
+ record = _recorded_branch(context, parent_task_id)
544
+ branch = record.get("branch") if record else None
545
+ return branch if isinstance(branch, str) and branch else None
546
+
547
+
548
+ def _resolve_base_branch(
549
+ context: ExtensionContext, definition: str | tuple[str, ...] | None
550
+ ) -> tuple[str | None, str | None]:
551
+ """Resolve a literal or argv-backed base branch definition."""
552
+ if definition is None:
553
+ return None, None
554
+ if isinstance(definition, str):
555
+ return definition, None
556
+ argv = tuple(interpolate(argument, _tokens(context)) for argument in definition)
557
+ try:
558
+ result = subprocess.run(
559
+ argv,
560
+ cwd=_repository(context),
561
+ capture_output=True,
562
+ text=True,
563
+ check=False,
564
+ )
565
+ except OSError as error:
566
+ return None, f"base branch command could not start: {error}"
567
+ if result.returncode:
568
+ return None, _failed(result, "base branch command failed")
569
+ output = result.stdout.strip()
570
+ if not output:
571
+ return None, "base branch command returned an empty branch name"
572
+ if len(output.splitlines()) != 1:
573
+ return None, "base branch command must return exactly one line"
574
+ return output, None
575
+
576
+
577
+ def _branch_name(
578
+ context: ExtensionContext, branch_format: str
579
+ ) -> tuple[str | None, str | None, str | None]:
580
+ """Render the task's branch from ``branch_format``: (branch, parent, error).
581
+
582
+ A child task's branch is its parent's recorded branch plus the child ID,
583
+ whatever the format says.
584
+ """
585
+ branch = interpolate(branch_format, _tokens(context)).strip()
586
+ if not branch:
587
+ return None, None, "branch name format rendered empty"
588
+ parent_branch = _parent_branch(context)
589
+ if parent_branch and context.task_id:
590
+ child_id = context.task_id.rpartition("/")[2]
591
+ branch = f"{parent_branch}-{child_id}"
592
+ return branch.lower(), parent_branch, None
593
+
594
+
595
+ def _trusted_base(context: ExtensionContext, branch: str) -> str | None:
596
+ """The base recorded for ``branch``, while that branch still exists.
597
+
598
+ A record outlives its task: after task state is cleaned up, a generated
599
+ ID can be handed out again, and the old task's record (perhaps a hotfix
600
+ from another base) must not choose the new task's base. Only a record
601
+ naming this very branch, which still exists, is the task's own.
602
+ """
603
+ record = _recorded_branch(context, context.task_id or "")
604
+ if record is None or record.get("branch") != branch:
605
+ return None
606
+ base = record.get("base")
607
+ if not isinstance(base, str) or not base:
608
+ return None
609
+ if not _branch_exists(context, branch, cwd=_repository(context)):
610
+ return None
611
+ return base
612
+
613
+
614
+ def _lane(context: ExtensionContext) -> str | None:
615
+ """The workflow whose ``branch_name_formats`` and ``base_branches`` apply.
616
+
617
+ A workflow that takes a lane's hooks (``hooks_from``) branches as that
618
+ lane; records and ``{{ww.task.workflow}}`` keep the workflow's own name,
619
+ and ``{{ww.task.lane}}`` gives formats and base-branch commands the lane.
620
+ """
621
+ return context.lane or context.workflow
622
+
623
+
624
+ def _task_branch(
625
+ context: ExtensionContext, settings: Settings
626
+ ) -> tuple[str | None, str | None, str | None]:
627
+ """Resolve the branch and base shared by branch/worktree handlers."""
628
+ if not context.task_id:
629
+ return None, None, "a task is required to name a branch"
630
+ if (
631
+ context.workflow == "ww-scriptize-rules"
632
+ and "default" not in settings.base_branches
633
+ ):
634
+ return (
635
+ None,
636
+ None,
637
+ (
638
+ "ww-scriptize-rules requires extensions.ww/git.base_branches.default "
639
+ "in ww.json"
640
+ ),
641
+ )
642
+ strategy = context.values.get(BRANCH_NAMING_STRATEGY)
643
+ branch_format = settings.branch_format(_lane(context), strategy)
644
+ if branch_format is None:
645
+ return None, None, f"branch naming strategy not found: {strategy}"
646
+ branch, parent_branch, error = _branch_name(context, branch_format)
647
+ if error or branch is None:
648
+ return None, None, error
649
+ if parent_branch:
650
+ return branch, parent_branch, None
651
+ recorded_base = _trusted_base(context, branch)
652
+ if recorded_base:
653
+ return branch, recorded_base, None
654
+ configured_base, base_error = _resolve_base_branch(
655
+ context, settings.base_branch_for(_lane(context))
656
+ )
657
+ if base_error:
658
+ return None, None, base_error
659
+ base = configured_base or _current_branch(context, cwd=_repository(context))
660
+ return branch, base, None
661
+
662
+
663
+ def _task_settings(context: ExtensionContext) -> Settings:
664
+ """Scriptizing always branches from the default, independent of lanes."""
665
+ settings = settings_from(context.config)
666
+ if context.workflow == "ww-scriptize-rules":
667
+ settings = replace(
668
+ settings,
669
+ separate_branch=True,
670
+ base_branches={
671
+ key: value
672
+ for key, value in settings.base_branches.items()
673
+ if key == "default"
674
+ },
675
+ )
676
+ return settings
677
+
678
+
679
+ def _claims_task(context: ExtensionContext) -> bool:
680
+ """Whether ww/git still holds this task ID: a record or a task branch.
681
+
682
+ Either outlives the task state, so a generated ID that has one is not
683
+ handed out again. Every configured branch format is tried, since the
684
+ branch strategy of a future start is not known yet.
685
+ """
686
+ task_id = context.task_id
687
+ if not task_id:
688
+ return False
689
+ if _recorded_branch(context, task_id) is not None:
690
+ return True
691
+ settings = _task_settings(context)
692
+ repository = _repository(context)
693
+ for branch_format in dict.fromkeys(
694
+ (DEFAULT_BRANCH_FORMAT, *settings.branch_name_formats.values())
695
+ ):
696
+ branch, _parent, error = _branch_name(context, branch_format)
697
+ if not error and branch and _branch_exists(context, branch, cwd=repository):
698
+ return True
699
+ return False
700
+
701
+
702
+ def _forget_task(context: ExtensionContext) -> None:
703
+ """Drop the branch and commit records of the task and its children."""
704
+ task_id = context.task_id
705
+ if not task_id:
706
+ return
707
+
708
+ def belongs(line: str) -> bool:
709
+ if not line:
710
+ return False
711
+ owner = json.loads(line).get("task_id")
712
+ return isinstance(owner, str) and (
713
+ owner == task_id or owner.startswith(f"{task_id}/")
714
+ )
715
+
716
+ def without_task(current: str | None) -> str:
717
+ lines = [
718
+ line for line in (current or "").splitlines() if line and not belongs(line)
719
+ ]
720
+ return "\n".join(lines) + "\n" if lines else ""
721
+
722
+ for name in (BRANCHES_FILE, COMMITS_FILE):
723
+ if context.store.read_text(name) is not None:
724
+ context.store.update_text(name, without_task)
725
+
726
+
727
+ # --------------------------------------------------------------------------- #
728
+ # Handlers
729
+ # --------------------------------------------------------------------------- #
730
+
731
+
732
+ def _without_rendered_prefix(
733
+ message: str, commit_format: str, context: ExtensionContext
734
+ ) -> str:
735
+ """Drop a prefix the agent already wrote that the format is about to add.
736
+
737
+ Agents tend to start a message with the task ID even though the format
738
+ supplies it; rendering both would repeat it in the subject.
739
+ """
740
+ prefix_template, _, _ = commit_format.partition("{{commit_message}}")
741
+ prefix = interpolate(prefix_template, _tokens(context)).strip()
742
+ if prefix and message.startswith(prefix):
743
+ stripped = message[len(prefix) :].strip()
744
+ if stripped:
745
+ return stripped
746
+ return message
747
+
748
+
749
+ def _subject(context: ExtensionContext, settings: Settings, message: str) -> str:
750
+ """A commit's subject: ``message`` rendered through ``commit_format``."""
751
+ message = _without_rendered_prefix(message.strip(), settings.commit_format, context)
752
+ return interpolate(
753
+ settings.commit_format, {**_tokens(context), "commit_message": message}
754
+ ).strip()
755
+
756
+
757
+ def _commit_message_error(values: Mapping[str, str]) -> str | None:
758
+ """The one shape a subject must have; checked when supplied and when run."""
759
+ message = values.get("commit_message", "").strip()
760
+ if not message:
761
+ return "commit_message is required"
762
+ if "\n" in message or "\r" in message:
763
+ return "commit_message must be a single line"
764
+ return None
765
+
766
+
767
+ def _commit(context: ExtensionContext) -> ExtensionResult:
768
+ settings = _task_settings(context)
769
+ error = _commit_message_error(context.values)
770
+ if error is not None:
771
+ return ExtensionResult(False, error=error)
772
+ subject = _subject(context, settings, context.values["commit_message"])
773
+ if not subject:
774
+ return ExtensionResult(False, error="commit_format rendered an empty message")
775
+ # A retry may arrive after git committed but before ww recorded the
776
+ # result. The operation trailer gives the checker a durable identity.
777
+ if context.operation_id:
778
+ existing = _check_commit(context)
779
+ if existing.status == "succeeded" and existing.result is not None:
780
+ return existing.result
781
+ if existing.status == "unknown":
782
+ return ExtensionResult(
783
+ False,
784
+ error=(
785
+ existing.error
786
+ or "Git could not establish the interrupted operation outcome"
787
+ ),
788
+ )
789
+ workspace_error = _validate_commit_workspace(context)
790
+ if workspace_error:
791
+ return ExtensionResult(False, error=workspace_error)
792
+ staged = _git(context, "add", ".")
793
+ if staged.returncode:
794
+ return ExtensionResult(False, error=_failed(staged, "git add failed"))
795
+ staged_error = _validate_staged_paths(context)
796
+ if staged_error:
797
+ return ExtensionResult(False, error=staged_error)
798
+ has_staged_changes = _git(context, "diff", "--cached", "--quiet")
799
+ if has_staged_changes.returncode == 0:
800
+ # A round that changed nothing is a normal outcome, not a failure:
801
+ # succeed without a commit and without running project commit hooks.
802
+ return ExtensionResult(
803
+ True, output="nothing to commit; the workspace has no changes"
804
+ )
805
+ if has_staged_changes.returncode != 1:
806
+ return ExtensionResult(
807
+ False,
808
+ error=_failed(has_staged_changes, "could not inspect staged changes"),
809
+ )
810
+ commit_args = ["commit", "-m", subject]
811
+ if context.operation_id:
812
+ commit_args.extend(["-m", _operation_marker(context)])
813
+ committed = _git(context, *commit_args)
814
+ signed = True
815
+ if (
816
+ committed.returncode
817
+ and settings.on_signing_failure == "unsigned"
818
+ and _signing_failed(context, committed)
819
+ ):
820
+ # The signing agent refused (a locked key agent, for instance): the
821
+ # operator allowed an unsigned commit rather than a stop.
822
+ committed = _git(context, "-c", "commit.gpgsign=false", *commit_args)
823
+ signed = False
824
+ if committed.returncode:
825
+ return ExtensionResult(False, error=_failed(committed, "git commit failed"))
826
+ revision = _git(context, "rev-parse", "HEAD")
827
+ record = {
828
+ "sha": revision.stdout.strip(),
829
+ "message": subject,
830
+ "branch": _current_branch(context),
831
+ "task_id": context.task_id,
832
+ "run_id": context.run_id,
833
+ "workflow": context.workflow,
834
+ "committed_at": _now(),
835
+ }
836
+ if context.operation_id:
837
+ record["operation_id"] = context.operation_id
838
+ if not signed:
839
+ record["signed"] = False
840
+ _record_commit(context, record)
841
+ output = f"{record['sha'][:12]} {subject}"
842
+ if not signed:
843
+ output += (
844
+ " (unsigned: git could not sign it, and on_signing_failure allows this)"
845
+ )
846
+ return ExtensionResult(True, output=output)
847
+
848
+
849
+ def _signing_failed(
850
+ context: ExtensionContext, result: subprocess.CompletedProcess[str]
851
+ ) -> bool:
852
+ """Whether a failed commit failed because git could not sign it."""
853
+ enabled = _git(context, "config", "--bool", "commit.gpgsign")
854
+ if enabled.stdout.strip() != "true":
855
+ return False
856
+ message = (result.stderr + result.stdout).lower()
857
+ return any(marker in message for marker in _SIGNING_FAILURE_MARKERS)
858
+
859
+
860
+ def _check_commit(context: ExtensionContext) -> ExtensionCheckResult:
861
+ """Find a commit made by a previous attempt of this operation.
862
+
863
+ Returns ``succeeded`` only for one matching operation marker,
864
+ ``not_succeeded`` when no marker exists, and ``unknown`` when Git or the
865
+ commit record cannot establish a safe outcome.
866
+ """
867
+ found = _operation_commit(context)
868
+ if isinstance(found, ExtensionCheckResult):
869
+ return found
870
+ sha, subject = found
871
+ try:
872
+ record = {
873
+ "sha": sha,
874
+ "message": subject,
875
+ "branch": _current_branch(context),
876
+ "task_id": context.task_id,
877
+ "run_id": context.run_id,
878
+ "workflow": context.workflow,
879
+ "committed_at": _now(),
880
+ "operation_id": context.operation_id,
881
+ }
882
+ # Recording is itself idempotent and the duplicate check shares one
883
+ # lock with the write, so concurrent recovery attempts cannot append
884
+ # the same operation twice.
885
+ _record_commit(context, record)
886
+ return ExtensionCheckResult.succeeded(
887
+ ExtensionResult(True, output=f"{sha[:12]} {subject}")
888
+ )
889
+ except Exception as error: # noqa: BLE001 - checker must remain tri-state
890
+ return ExtensionCheckResult.unknown(f"could not record Git result: {error}")
891
+
892
+
893
+ def _merge_arguments(context: ExtensionContext) -> tuple[str, str] | str:
894
+ """The branch to merge and the merge message, or why they are unusable."""
895
+ if len(context.arguments) != len(MERGE_ARGUMENTS):
896
+ return "merge-branch takes two args: the branch to merge and the message"
897
+ branch, message = (argument.strip() for argument in context.arguments)
898
+ if not branch:
899
+ return "the branch to merge rendered empty"
900
+ if branch.startswith("-") or any(character.isspace() for character in branch):
901
+ return f"not a branch name: {branch!r}"
902
+ if not message or "\n" in message or "\r" in message:
903
+ return "the merge message must be one non-empty line"
904
+ return branch, message
905
+
906
+
907
+ def _merge_in_progress(context: ExtensionContext) -> bool:
908
+ return _git(context, "rev-parse", "-q", "--verify", "MERGE_HEAD").returncode == 0
909
+
910
+
911
+ def _git_path(context: ExtensionContext, name: str) -> Path | None:
912
+ """Where git keeps ``name`` in the workspace's git directory."""
913
+ located = _git(context, "rev-parse", "--git-path", name)
914
+ if located.returncode:
915
+ return None
916
+ path = Path(located.stdout.strip())
917
+ if not path.is_absolute():
918
+ path = (context.workspace or context.root) / path
919
+ return path
920
+
921
+
922
+ def _own_merge_in_progress(context: ExtensionContext) -> bool:
923
+ """Whether the merge in progress was started by this very operation.
924
+
925
+ Its message, which git keeps in ``MERGE_MSG`` until the merge concludes,
926
+ then carries this operation's trailer.
927
+ """
928
+ if not context.operation_id:
929
+ return False
930
+ path = _git_path(context, "MERGE_MSG")
931
+ if path is None:
932
+ return False
933
+ try:
934
+ message = path.read_text(encoding="utf-8")
935
+ except OSError:
936
+ return False
937
+ return _operation_marker(context) in message.splitlines()
938
+
939
+
940
+ # What git keeps while a rebase, cherry-pick or revert stands in progress.
941
+ _SEQUENCER_STATES = {
942
+ "rebase-merge": "a rebase",
943
+ "rebase-apply": "a rebase or `git am`",
944
+ "CHERRY_PICK_HEAD": "a cherry-pick",
945
+ "REVERT_HEAD": "a revert",
946
+ }
947
+
948
+
949
+ def _merge_target_error(context: ExtensionContext) -> str | None:
950
+ """Why the workspace cannot receive a merge: no branch, or another operation."""
951
+ for name, operation in _SEQUENCER_STATES.items():
952
+ path = _git_path(context, name)
953
+ if path is not None and path.exists():
954
+ return (
955
+ f"{operation} is in progress in the workspace; conclude or "
956
+ "abort it, then retry"
957
+ )
958
+ if _git(context, "symbolic-ref", "-q", "HEAD").returncode:
959
+ return (
960
+ "HEAD is detached, so a merge would land on no branch; check out "
961
+ "the branch to merge into, then retry"
962
+ )
963
+ return None
964
+
965
+
966
+ def _lines(result: subprocess.CompletedProcess[str]) -> list[str]:
967
+ return [path for path in result.stdout.split("\0") if path]
968
+
969
+
970
+ def _blob(context: ExtensionContext, revision: str, path: str) -> str | None:
971
+ found = _git(context, "rev-parse", "-q", "--verify", f"{revision}:{path}")
972
+ return found.stdout.strip() if found.returncode == 0 else None
973
+
974
+
975
+ def _index_blob(context: ExtensionContext, path: str) -> str | None:
976
+ listed = _git(context, "ls-files", "-s", "-z", "--", path)
977
+ for entry in _lines(listed):
978
+ details, _, name = entry.partition("\t")
979
+ # ``<mode> <blob> <stage>``: stage 0 is a merged entry.
980
+ _, blob, stage = (details.split() + ["", "", ""])[:3]
981
+ if name == path and stage == "0":
982
+ return blob
983
+ return None
984
+
985
+
986
+ def _clean_merge_blob(
987
+ context: ExtensionContext, base: str, ours: str, theirs: str
988
+ ) -> str | None:
989
+ """The blob a clean three-way merge of three blobs gives, or ``None``."""
990
+ with tempfile.TemporaryDirectory(prefix="ww-merge-") as directory:
991
+ files = []
992
+ for name, blob in (("ours", ours), ("base", base), ("theirs", theirs)):
993
+ content = subprocess.run(
994
+ ["git", "cat-file", "blob", blob],
995
+ cwd=context.workspace or context.root,
996
+ capture_output=True,
997
+ check=False,
998
+ )
999
+ if content.returncode:
1000
+ return None
1001
+ file = Path(directory) / name
1002
+ file.write_bytes(content.stdout)
1003
+ files.append(str(file))
1004
+ merged = _git(context, "merge-file", "-q", *files)
1005
+ if merged.returncode:
1006
+ return None
1007
+ hashed = _git(context, "hash-object", "--", files[0])
1008
+ return hashed.stdout.strip() if hashed.returncode == 0 else None
1009
+
1010
+
1011
+ def _merge_untouched(context: ExtensionContext) -> bool:
1012
+ """Whether the merge in progress still holds only what git's merge left.
1013
+
1014
+ Every unmerged file still carries conflict markers, no other file changed
1015
+ in the work tree, and every staged change is one the merge itself made:
1016
+ the merged branch's version of a file only it changed, or the clean
1017
+ three-way merge of a file both sides changed. Anything else is the
1018
+ operator's work, which aborting would discard; a case this cannot tell
1019
+ apart, such as a rename, counts as touched.
1020
+ """
1021
+ base_found = _git(context, "merge-base", "HEAD", "MERGE_HEAD")
1022
+ if base_found.returncode:
1023
+ return False
1024
+ base = base_found.stdout.strip()
1025
+ unmerged = set(
1026
+ _lines(_git(context, "diff", "--name-only", "-z", "--diff-filter=U"))
1027
+ )
1028
+ root = context.workspace or context.root
1029
+ for path in unmerged:
1030
+ try:
1031
+ text = (root / path).read_text(encoding="utf-8", errors="replace")
1032
+ except OSError:
1033
+ return False
1034
+ if not any(line.startswith("<<<<<<< ") for line in text.splitlines()):
1035
+ return False
1036
+ if set(_lines(_git(context, "diff", "--name-only", "-z"))) - unmerged:
1037
+ return False
1038
+ staged = _git(context, "diff", "--cached", "--name-only", "-z", "HEAD")
1039
+ if staged.returncode:
1040
+ return False
1041
+ for path in set(_lines(staged)) - unmerged:
1042
+ original = _blob(context, base, path)
1043
+ ours = _blob(context, "HEAD", path)
1044
+ theirs = _blob(context, "MERGE_HEAD", path)
1045
+ index = _index_blob(context, path)
1046
+ if theirs == original:
1047
+ # The merged branch did not change it: only the operator could.
1048
+ return False
1049
+ if ours == original:
1050
+ expected = theirs
1051
+ elif original is None or ours is None or theirs is None:
1052
+ return False
1053
+ else:
1054
+ expected = _clean_merge_blob(context, original, ours, theirs)
1055
+ if expected is None or index != expected:
1056
+ return False
1057
+ return True
1058
+
1059
+
1060
+ def _abort_merge(context: ExtensionContext) -> str | None:
1061
+ """Abort the merge in progress; the error when git cannot."""
1062
+ aborted = _git(context, "merge", "--abort")
1063
+ if aborted.returncode:
1064
+ return _failed(aborted, "git merge --abort failed")
1065
+ return None
1066
+
1067
+
1068
+ def _merge(context: ExtensionContext) -> ExtensionResult:
1069
+ """Merge a branch into the workspace's branch with ``git merge --no-ff``.
1070
+
1071
+ A conflict is never resolved here: the merge is aborted and the handler
1072
+ fails naming the conflicting files, so the task stops for the operator.
1073
+ """
1074
+ settings = _task_settings(context)
1075
+ parsed = _merge_arguments(context)
1076
+ if isinstance(parsed, str):
1077
+ return ExtensionResult(False, error=parsed)
1078
+ branch, message = parsed
1079
+ subject = _subject(context, settings, message)
1080
+ if not subject:
1081
+ return ExtensionResult(False, error="commit_format rendered an empty message")
1082
+ # As for git-commit: the operation trailer lets a retry recognize the
1083
+ # merge commit an interrupted attempt already made.
1084
+ if context.operation_id:
1085
+ existing = _check_merge(context)
1086
+ if existing.status == "succeeded" and existing.result is not None:
1087
+ return existing.result
1088
+ if existing.status == "unknown":
1089
+ return ExtensionResult(
1090
+ False,
1091
+ error=(
1092
+ existing.error
1093
+ or "Git could not establish the interrupted operation outcome"
1094
+ ),
1095
+ )
1096
+ _, workspace_error = _workspace_root(context)
1097
+ if workspace_error:
1098
+ return ExtensionResult(False, error=workspace_error)
1099
+ target_error = _merge_target_error(context)
1100
+ if target_error:
1101
+ return ExtensionResult(False, error=target_error)
1102
+ if _merge_in_progress(context):
1103
+ if not _own_merge_in_progress(context):
1104
+ return ExtensionResult(
1105
+ False,
1106
+ error=(
1107
+ "a merge is already in progress in the workspace; conclude "
1108
+ "it or run `git merge --abort`, then retry"
1109
+ ),
1110
+ )
1111
+ if not _merge_untouched(context):
1112
+ # The operator has worked on it since: their resolution is kept.
1113
+ return ExtensionResult(
1114
+ False,
1115
+ error=(
1116
+ f"this step's merge of {branch} is in progress and was "
1117
+ "worked on since (conflicts resolved or changes staged); "
1118
+ "conclude it with `git commit`, or discard it with "
1119
+ "`git merge --abort`, then retry"
1120
+ ),
1121
+ )
1122
+ # An interrupted attempt of this operation stopped mid-merge on a
1123
+ # workspace it had found clean, and nothing was done to it since:
1124
+ # undo it and merge again.
1125
+ abort_error = _abort_merge(context)
1126
+ if abort_error:
1127
+ return ExtensionResult(False, error=abort_error)
1128
+ status = _git(context, "status", "--porcelain")
1129
+ if status.returncode:
1130
+ return ExtensionResult(False, error=_failed(status, "git status failed"))
1131
+ changed = [line for line in status.stdout.splitlines() if line.strip()]
1132
+ if changed:
1133
+ return ExtensionResult(
1134
+ False,
1135
+ error=(
1136
+ f"working tree has {len(changed)} uncommitted change(s); "
1137
+ f"commit or remove them before merging {branch}"
1138
+ ),
1139
+ )
1140
+ tip = _git(context, "rev-parse", "--verify", "--quiet", f"{branch}^{{commit}}")
1141
+ if tip.returncode:
1142
+ return ExtensionResult(False, error=f"branch {branch!r} does not exist")
1143
+ tip_sha = tip.stdout.strip()
1144
+ target = _current_branch(context)
1145
+ if _git(context, "merge-base", "--is-ancestor", tip_sha, "HEAD").returncode == 0:
1146
+ return ExtensionResult(
1147
+ True,
1148
+ output=f"{branch} is already merged into {target}; nothing to merge",
1149
+ values={"merge_commit": ""},
1150
+ )
1151
+ merge_args = ["merge", "--no-ff", "--no-edit", "-m", subject]
1152
+ if context.operation_id:
1153
+ merge_args.extend(["-m", _operation_marker(context)])
1154
+ merge_args.append(branch)
1155
+ merged = _git(context, *merge_args)
1156
+ signed = True
1157
+ if (
1158
+ merged.returncode
1159
+ and settings.on_signing_failure == "unsigned"
1160
+ and _signing_failed(context, merged)
1161
+ ):
1162
+ # git merged the tree but could not sign the commit, and left the
1163
+ # merge in progress: undo it and merge once more without signing.
1164
+ if _merge_in_progress(context):
1165
+ abort_error = _abort_merge(context)
1166
+ if abort_error:
1167
+ return ExtensionResult(False, error=abort_error)
1168
+ merged = _git(context, "-c", "commit.gpgsign=false", *merge_args)
1169
+ signed = False
1170
+ if merged.returncode:
1171
+ return ExtensionResult(False, error=_merge_failure(context, branch, merged))
1172
+ if _merge_in_progress(context):
1173
+ return ExtensionResult(
1174
+ False,
1175
+ error=(
1176
+ f"git reported merging {branch} as done, but a merge is still "
1177
+ "in progress (MERGE_HEAD exists); conclude or abort it"
1178
+ ),
1179
+ )
1180
+ head = _git(context, "rev-parse", "HEAD").stdout.strip()
1181
+ second_parent = _git(context, "rev-parse", "--verify", "--quiet", "HEAD^2")
1182
+ if second_parent.stdout.strip() != tip_sha:
1183
+ return ExtensionResult(
1184
+ False,
1185
+ error=f"HEAD {head[:12]} is not a merge commit of {branch}",
1186
+ )
1187
+ record: dict[str, Any] = {
1188
+ "sha": head,
1189
+ "message": subject,
1190
+ "branch": target,
1191
+ "merged": branch,
1192
+ "task_id": context.task_id,
1193
+ "run_id": context.run_id,
1194
+ "workflow": context.workflow,
1195
+ "committed_at": _now(),
1196
+ }
1197
+ if context.operation_id:
1198
+ record["operation_id"] = context.operation_id
1199
+ if not signed:
1200
+ record["signed"] = False
1201
+ _record_commit(context, record)
1202
+ output = f"{head[:12]} {subject}"
1203
+ if not signed:
1204
+ output += (
1205
+ " (unsigned: git could not sign it, and on_signing_failure allows this)"
1206
+ )
1207
+ return ExtensionResult(True, output=output, values={"merge_commit": head})
1208
+
1209
+
1210
+ def _merge_failure(
1211
+ context: ExtensionContext,
1212
+ branch: str,
1213
+ result: subprocess.CompletedProcess[str],
1214
+ ) -> str:
1215
+ """Why the merge failed, after aborting whatever it left in progress."""
1216
+ conflicted = _git(context, "diff", "--name-only", "--diff-filter=U", "-z")
1217
+ files = [path for path in conflicted.stdout.split("\0") if path]
1218
+ if not _merge_in_progress(context):
1219
+ return _failed(result, "git merge failed")
1220
+ abort_error = _abort_merge(context)
1221
+ state = (
1222
+ "the merge was aborted, so the workspace is as it was before"
1223
+ if abort_error is None
1224
+ else f"aborting the merge failed ({abort_error}); the workspace is mid-merge"
1225
+ )
1226
+ if files:
1227
+ return (
1228
+ f"merging {branch} conflicts in: {', '.join(files)}; {state}. "
1229
+ "Resolve the conflicts, then retry"
1230
+ )
1231
+ return f"{_failed(result, 'git merge failed')}; {state}"
1232
+
1233
+
1234
+ def _check_merge(context: ExtensionContext) -> ExtensionCheckResult:
1235
+ """Find the merge commit made by a previous attempt of this operation."""
1236
+ found = _operation_commit(context)
1237
+ if isinstance(found, ExtensionCheckResult):
1238
+ return found
1239
+ sha, subject = found
1240
+ parsed = _merge_arguments(context)
1241
+ try:
1242
+ record: dict[str, Any] = {
1243
+ "sha": sha,
1244
+ "message": subject,
1245
+ "branch": _current_branch(context),
1246
+ "merged": parsed[0] if isinstance(parsed, tuple) else None,
1247
+ "task_id": context.task_id,
1248
+ "run_id": context.run_id,
1249
+ "workflow": context.workflow,
1250
+ "committed_at": _now(),
1251
+ "operation_id": context.operation_id,
1252
+ }
1253
+ _record_commit(context, record)
1254
+ return ExtensionCheckResult.succeeded(
1255
+ ExtensionResult(
1256
+ True, output=f"{sha[:12]} {subject}", values={"merge_commit": sha}
1257
+ )
1258
+ )
1259
+ except Exception as error: # noqa: BLE001 - checker must remain tri-state
1260
+ return ExtensionCheckResult.unknown(f"could not record Git result: {error}")
1261
+
1262
+
1263
+ def _operation_commit(
1264
+ context: ExtensionContext,
1265
+ ) -> tuple[str, str] | ExtensionCheckResult:
1266
+ """The one commit whose ``WW-Operation`` trailer names this operation.
1267
+
1268
+ Returns its sha and subject, or the checker result when there is not
1269
+ exactly one: ``not_succeeded`` for none, ``unknown`` when Git or the
1270
+ operation identity cannot establish a safe outcome.
1271
+ """
1272
+ if not context.operation_id:
1273
+ return ExtensionCheckResult.unknown("operation ID is missing")
1274
+ # Each commit ends with a record separator: a body spans lines, so a
1275
+ # line break cannot tell one commit from the next.
1276
+ log = _git(context, "log", "--all", "--format=%H%x00%s%x00%B%x1e")
1277
+ if log.returncode:
1278
+ return ExtensionCheckResult.unknown(_failed(log, "git log failed"))
1279
+ marker = _operation_marker(context)
1280
+ matches = []
1281
+ for entry in log.stdout.split("\x1e"):
1282
+ fields = entry.strip("\n").split("\x00")
1283
+ if len(fields) != _LOG_FIELDS:
1284
+ continue
1285
+ sha, subject, body = fields
1286
+ if marker in body.splitlines():
1287
+ matches.append((sha.strip(), subject.strip()))
1288
+ if len(matches) > 1:
1289
+ return ExtensionCheckResult.unknown(
1290
+ f"multiple Git commits claim operation {context.operation_id!r}"
1291
+ )
1292
+ if not matches:
1293
+ return ExtensionCheckResult.not_succeeded()
1294
+ return matches[0]
1295
+
1296
+
1297
+ def _operation_marker(context: ExtensionContext) -> str:
1298
+ return f"WW-Operation: {context.operation_id}"
1299
+
1300
+
1301
+ def _is_clean(context: ExtensionContext) -> ExtensionResult:
1302
+ status = _git(context, "status", "--porcelain")
1303
+ if status.returncode:
1304
+ return ExtensionResult(False, error=_failed(status, "git status failed"))
1305
+ changed = [line for line in status.stdout.splitlines() if line.strip()]
1306
+ if changed:
1307
+ return ExtensionResult(
1308
+ False, error=f"working tree has {len(changed)} uncommitted change(s)"
1309
+ )
1310
+ return ExtensionResult(True, output="clean")
1311
+
1312
+
1313
+ def _start_branch(context: ExtensionContext) -> ExtensionResult:
1314
+ settings = _task_settings(context)
1315
+ repository = _repository(context)
1316
+ if not settings.separate_branch and not settings.worktrees:
1317
+ return ExtensionResult(True, output="configured to work on the current branch")
1318
+ branch, base, error = _task_branch(context, settings)
1319
+ if error:
1320
+ return ExtensionResult(False, error=error)
1321
+ assert branch is not None and base is not None
1322
+ if settings.worktrees:
1323
+ if not _branch_exists(context, branch, cwd=repository):
1324
+ created = _git(context, "branch", branch, base, cwd=repository)
1325
+ if created.returncode:
1326
+ return ExtensionResult(
1327
+ False, error=_failed(created, "git branch failed")
1328
+ )
1329
+ _record_branch(
1330
+ context,
1331
+ task_id=context.task_id,
1332
+ workflow=context.workflow,
1333
+ branch=branch,
1334
+ base=base,
1335
+ worktree=None,
1336
+ )
1337
+ return ExtensionResult(True, output=f"created {branch} from {base}")
1338
+ if _root_uses_task_branch(context, branch):
1339
+ return ExtensionResult(
1340
+ True,
1341
+ output=f"primary checkout already uses {branch}",
1342
+ working_directory=repository,
1343
+ )
1344
+ if _recorded_branch(context, context.task_id or "") is None:
1345
+ _record_branch(
1346
+ context,
1347
+ task_id=context.task_id,
1348
+ workflow=context.workflow,
1349
+ branch=branch,
1350
+ base=base,
1351
+ worktree=None,
1352
+ )
1353
+ return ExtensionResult(True, output=f"branch {branch} is ready")
1354
+
1355
+ if _branch_exists(context, branch, cwd=repository):
1356
+ # Retried, or resumed after a switch: adopt the branch instead of
1357
+ # failing, and never re-point it at base.
1358
+ if _current_branch(context, cwd=repository) == branch:
1359
+ return ExtensionResult(True, output=f"already on {branch}")
1360
+ switched = _git(context, "switch", branch, cwd=repository)
1361
+ if switched.returncode:
1362
+ return ExtensionResult(False, error=_failed(switched, "git switch failed"))
1363
+ return ExtensionResult(True, output=f"switched to existing {branch}")
1364
+ created = _git(context, "switch", "-c", branch, base, cwd=repository)
1365
+ if created.returncode:
1366
+ return ExtensionResult(False, error=_failed(created, "git switch -c failed"))
1367
+ _record_branch(
1368
+ context,
1369
+ task_id=context.task_id,
1370
+ workflow=context.workflow,
1371
+ branch=branch,
1372
+ base=base,
1373
+ worktree=None,
1374
+ )
1375
+ return ExtensionResult(True, output=f"created {branch} from {base}")
1376
+
1377
+
1378
+ def _worktree_holding(
1379
+ context: ExtensionContext, repository: Path, branch: str
1380
+ ) -> Path | None:
1381
+ """The worktree that already has ``branch`` checked out, if any.
1382
+
1383
+ A branch can be checked out in one worktree at a time, so an existing
1384
+ checkout of the task branch is this task's workspace, wherever it lives.
1385
+ """
1386
+ listed = _git(context, "worktree", "list", "--porcelain", cwd=repository)
1387
+ if listed.returncode:
1388
+ return None
1389
+ location: Path | None = None
1390
+ for line in listed.stdout.splitlines():
1391
+ if line.startswith("worktree "):
1392
+ location = Path(line.removeprefix("worktree ").strip())
1393
+ elif line == f"branch refs/heads/{branch}" and location is not None:
1394
+ return location.resolve()
1395
+ return None
1396
+
1397
+
1398
+ def _create_worktree(context: ExtensionContext) -> ExtensionResult:
1399
+ """Create a separate checkout only after ``start-task-branch`` prepared it."""
1400
+ settings = _task_settings(context)
1401
+ repository = _repository(context)
1402
+ if not settings.worktrees:
1403
+ return ExtensionResult(True, output="worktrees are not enabled")
1404
+ branch, base, error = _task_branch(context, settings)
1405
+ if error:
1406
+ return ExtensionResult(False, error=error)
1407
+ assert branch is not None and base is not None
1408
+ if _root_uses_task_branch(context, branch):
1409
+ return ExtensionResult(
1410
+ True,
1411
+ output=f"primary checkout already uses {branch}",
1412
+ working_directory=repository,
1413
+ )
1414
+ if not _branch_exists(context, branch, cwd=repository):
1415
+ return ExtensionResult(
1416
+ False, error="task branch does not exist; run start-task-branch first"
1417
+ )
1418
+ name = interpolate(settings.worktree_name_format, _tokens(context)).strip()
1419
+ path_parts = Path(name).parts
1420
+ if (
1421
+ not name
1422
+ or Path(name).is_absolute()
1423
+ or any(part in {".", ".."} for part in path_parts)
1424
+ ):
1425
+ return ExtensionResult(False, error=f"worktree name format rendered {name!r}")
1426
+ path = _worktree_path(context, settings, name)
1427
+ if path.exists():
1428
+ return ExtensionResult(
1429
+ True, output=f"worktree already at {path}", working_directory=path
1430
+ )
1431
+ existing = _worktree_holding(context, repository, branch)
1432
+ if existing is not None and existing != repository.resolve():
1433
+ # Left by an earlier round or moved by hand: adopt it rather than
1434
+ # failing on git's one-worktree-per-branch rule.
1435
+ _record_branch(
1436
+ context,
1437
+ task_id=context.task_id,
1438
+ workflow=context.workflow,
1439
+ branch=branch,
1440
+ base=base,
1441
+ worktree=str(existing),
1442
+ )
1443
+ return ExtensionResult(
1444
+ True,
1445
+ output=f"adopted the existing worktree for {branch} at {existing}",
1446
+ working_directory=existing,
1447
+ )
1448
+ path.parent.mkdir(parents=True, exist_ok=True)
1449
+ added = _git(context, "worktree", "add", str(path), branch, cwd=repository)
1450
+ if added.returncode:
1451
+ return ExtensionResult(False, error=_failed(added, "git worktree add failed"))
1452
+ _record_branch(
1453
+ context,
1454
+ task_id=context.task_id,
1455
+ workflow=context.workflow,
1456
+ branch=branch,
1457
+ base=base,
1458
+ worktree=str(path),
1459
+ )
1460
+ # The executor persists this path and uses it for all later task work.
1461
+ return ExtensionResult(
1462
+ True,
1463
+ output=f"worktree for {branch} at {path} — do this task's work there",
1464
+ working_directory=path,
1465
+ )
1466
+
1467
+
1468
+ def _task_workspace_dir(context: ExtensionContext) -> str | None:
1469
+ """Select the canonical checkout for this task when Git knows one."""
1470
+ settings = _task_settings(context)
1471
+ root = _repository(context)
1472
+ if not settings.worktrees or not context.task_id:
1473
+ return str(root)
1474
+ branch, _, error = _task_branch(context, settings)
1475
+ if error or branch is None:
1476
+ return None
1477
+ if _root_uses_task_branch(context, branch):
1478
+ return str(root)
1479
+ if context.workspace is not None and context.workspace.is_dir():
1480
+ return str(context.workspace.resolve())
1481
+ record = _recorded_branch(context, context.task_id)
1482
+ location = record.get("worktree") if record else None
1483
+ if isinstance(location, str) and Path(location).is_dir():
1484
+ return str(Path(location).resolve())
1485
+ return None
1486
+
1487
+
1488
+ def _recorded_field(context: ExtensionContext, key: str) -> str | None:
1489
+ """A field of the task's latest branch record, never a live git lookup.
1490
+
1491
+ The primary checkout and a task's worktree may sit on different branches,
1492
+ so what git reports depends on where it is asked; the record does not.
1493
+ """
1494
+ if not context.task_id:
1495
+ return None
1496
+ record = _recorded_branch(context, context.task_id)
1497
+ value = record.get(key) if record else None
1498
+ return value if isinstance(value, str) and value else None
1499
+
1500
+
1501
+ def _branch_variable(context: ExtensionContext) -> str | None:
1502
+ """``{{ww.git.branch}}``: the task's branch, once ww/git recorded it."""
1503
+ return _recorded_field(context, "branch")
1504
+
1505
+
1506
+ def _base_branch_variable(context: ExtensionContext) -> str | None:
1507
+ """``{{ww.git.base_branch}}``: the branch the task's branch was made from."""
1508
+ return _recorded_field(context, "base")
1509
+
1510
+
1511
+ def _branch_strategy_variable(context: ExtensionContext) -> str | None:
1512
+ """``{{ww.git.branch_strategy}}``: the branch format key the task uses.
1513
+
1514
+ The one ``start --branch-strategy`` chose, else the lane's own entry
1515
+ in ``branch_name_formats``, else ``default``.
1516
+ """
1517
+ strategy = context.values.get(BRANCH_NAMING_STRATEGY)
1518
+ if strategy:
1519
+ return strategy
1520
+ formats = settings_from(context.config).branch_name_formats
1521
+ lane = _lane(context)
1522
+ if lane and lane in formats:
1523
+ return lane
1524
+ return "default"
1525
+
1526
+
1527
+ def _remove_worktree(context: ExtensionContext) -> ExtensionResult:
1528
+ settings = _task_settings(context)
1529
+ if not settings.worktrees:
1530
+ return ExtensionResult(True, output="worktrees are not enabled")
1531
+ if not context.task_id:
1532
+ return ExtensionResult(False, error="a task is required to find its worktree")
1533
+ record = _recorded_branch(context, context.task_id)
1534
+ location = record.get("worktree") if record else None
1535
+ if not location:
1536
+ return ExtensionResult(
1537
+ True, output=f"no worktree recorded for {context.task_id}"
1538
+ )
1539
+ if not Path(location).exists():
1540
+ return ExtensionResult(True, output=f"worktree {location} is already gone")
1541
+ removed = _git(context, "worktree", "remove", location)
1542
+ if removed.returncode:
1543
+ # git refuses while the checkout holds changes, and so do we: --force
1544
+ # would throw away work nobody asked to discard.
1545
+ return ExtensionResult(
1546
+ False, error=_failed(removed, "git worktree remove failed")
1547
+ )
1548
+ return ExtensionResult(True, output=f"removed worktree {location}")
1549
+
1550
+
1551
+ def _return_to_base(context: ExtensionContext) -> ExtensionResult:
1552
+ settings = _task_settings(context)
1553
+ if settings.worktrees:
1554
+ return ExtensionResult(True, output="worktrees leave the main checkout alone")
1555
+ if not settings.separate_branch:
1556
+ return ExtensionResult(True, output="no task branch was created")
1557
+ record = _recorded_branch(context, context.task_id or "") or {}
1558
+ base = record.get("base")
1559
+ if not isinstance(base, str) or not base:
1560
+ base, error = _resolve_base_branch(
1561
+ context, settings.base_branch_for(_lane(context))
1562
+ )
1563
+ if error:
1564
+ return ExtensionResult(False, error=error)
1565
+ if not base:
1566
+ return ExtensionResult(True, output="no base branch is configured or recorded")
1567
+ if _current_branch(context) == base:
1568
+ return ExtensionResult(True, output=f"already on {base}")
1569
+ switched = _git(context, "switch", base)
1570
+ if switched.returncode:
1571
+ return ExtensionResult(False, error=_failed(switched, "git switch failed"))
1572
+ return ExtensionResult(True, output=f"switched back to {base}")
1573
+
1574
+
1575
+ # --------------------------------------------------------------------------- #
1576
+ # Commands
1577
+ # --------------------------------------------------------------------------- #
1578
+
1579
+
1580
+ def _commits(context: ExtensionContext) -> str:
1581
+ wanted = context.arguments[0] if context.arguments else None
1582
+ records = [
1583
+ json.loads(line) for line in context.store.read_lines(COMMITS_FILE) if line
1584
+ ]
1585
+ if wanted:
1586
+ records = [record for record in records if record.get("task_id") == wanted]
1587
+ if not records:
1588
+ return f"No commits recorded{f' for {wanted}' if wanted else ''}."
1589
+ return "\n".join(
1590
+ f"{record['committed_at']} {record['sha'][:12]} "
1591
+ f"{record.get('task_id') or '-'} {record.get('branch') or '-'} "
1592
+ f"{record['message']}"
1593
+ for record in records
1594
+ )
1595
+
1596
+
1597
+ def _branches(context: ExtensionContext) -> str:
1598
+ wanted = context.arguments[0] if context.arguments else None
1599
+ records = [
1600
+ json.loads(line) for line in context.store.read_lines(BRANCHES_FILE) if line
1601
+ ]
1602
+ if wanted:
1603
+ records = [record for record in records if record.get("task_id") == wanted]
1604
+ if not records:
1605
+ return f"No branches recorded{f' for {wanted}' if wanted else ''}."
1606
+ return "\n".join(
1607
+ f"{record['recorded_at']} {record.get('task_id') or '-'} "
1608
+ f"{record['branch']} (from {record.get('base') or '-'})"
1609
+ + (f" worktree: {record['worktree']}" if record.get("worktree") else "")
1610
+ for record in records
1611
+ )
1612
+
1613
+
1614
+ def _branch_strategies(config: Mapping[str, object]) -> tuple[str, ...]:
1615
+ """Every branch format key: ``default`` and workflow-specific names."""
1616
+ return tuple(settings_from(dict(config)).branch_name_formats)
1617
+
1618
+
1619
+ def _settings(context: ExtensionContext) -> str:
1620
+ """Print the resolved settings.
1621
+
1622
+ ww validates the file's shape but not an extension's schema, so a typo
1623
+ surfaces when a handler runs. This is the command to check first.
1624
+ """
1625
+ return json.dumps(settings_from(context.config).to_dict(), indent=2)
1626
+
1627
+
1628
+ EXTENSION = Extension(
1629
+ vendor="ww",
1630
+ name="git",
1631
+ version="0.2.0",
1632
+ description="Commit and branch through ww, and keep a record of both.",
1633
+ variables=(ExtensionVariable(TASK_WORKSPACE_DIR, _task_workspace_dir),),
1634
+ namespace=ExtensionNamespace(
1635
+ "git",
1636
+ (
1637
+ ExtensionVariable("branch", _branch_variable),
1638
+ ExtensionVariable("base_branch", _base_branch_variable),
1639
+ ExtensionVariable("branch_strategy", _branch_strategy_variable),
1640
+ ),
1641
+ ),
1642
+ reserved_paths=_reserved_paths,
1643
+ claims_task=_claims_task,
1644
+ forget_task=_forget_task,
1645
+ branch_strategies=_branch_strategies,
1646
+ handlers=(
1647
+ ExtensionHandler(
1648
+ "git-commit",
1649
+ _commit,
1650
+ "Stage everything, commit using commit_format, and record it.",
1651
+ provide=(
1652
+ ProvidedVariable(
1653
+ "commit_message",
1654
+ "One sentence naming what changed in this round of work, "
1655
+ "since ww's previous commit on this task branch; never "
1656
+ "repeat an earlier commit's message. Leave out the task ID, "
1657
+ "which ww adds, and anything about the agentic environment.",
1658
+ ),
1659
+ ),
1660
+ check=_check_commit,
1661
+ validate=_commit_message_error,
1662
+ ),
1663
+ ExtensionHandler(
1664
+ "merge-branch",
1665
+ _merge,
1666
+ "Merge a branch into the task's branch with git merge --no-ff, "
1667
+ "aborting on a conflict, and record the merge commit.",
1668
+ check=_check_merge,
1669
+ outputs=("merge_commit",),
1670
+ arguments=MERGE_ARGUMENTS,
1671
+ ),
1672
+ ExtensionHandler(
1673
+ "is-git-clean",
1674
+ _is_clean,
1675
+ "Fail unless the working tree has no uncommitted changes.",
1676
+ ),
1677
+ ExtensionHandler(
1678
+ "start-task-branch",
1679
+ _start_branch,
1680
+ "Create or select this task's branch from the base branch.",
1681
+ ),
1682
+ ExtensionHandler(
1683
+ "create-worktree",
1684
+ _create_worktree,
1685
+ "Create or select this task's worktree after its branch exists.",
1686
+ ),
1687
+ ExtensionHandler(
1688
+ "remove-task-worktree",
1689
+ _remove_worktree,
1690
+ "Remove this task's worktree, refusing while it holds changes.",
1691
+ ),
1692
+ ExtensionHandler(
1693
+ "return-to-base-branch",
1694
+ _return_to_base,
1695
+ "Switch the main checkout back to the base branch.",
1696
+ ),
1697
+ ),
1698
+ modes=(
1699
+ ModeDefinition(
1700
+ "conventional-commits",
1701
+ (
1702
+ "Write commit messages as Conventional Commits.",
1703
+ "Use the form <type>(<scope>): <subject>, for example "
1704
+ "'fix(plan): reject an unknown hook phase'.",
1705
+ ),
1706
+ ),
1707
+ ),
1708
+ commands=(
1709
+ ExtensionCommand(
1710
+ "commits",
1711
+ _commits,
1712
+ "List the commits ww recorded, newest last.",
1713
+ usage="commits [TASK-ID]",
1714
+ ),
1715
+ ExtensionCommand(
1716
+ "branches",
1717
+ _branches,
1718
+ "List the branches and worktrees ww opened.",
1719
+ usage="branches [TASK-ID]",
1720
+ ),
1721
+ ExtensionCommand(
1722
+ "settings",
1723
+ _settings,
1724
+ "Print the resolved ww/git settings.",
1725
+ usage="settings",
1726
+ ),
1727
+ ),
1728
+ )