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.
- ww/__init__.py +18 -0
- ww/_bundled_extensions/ww/git/extension.py +1728 -0
- ww/action_execution.py +887 -0
- ww/actions/__init__.py +94 -0
- ww/actions/command.py +444 -0
- ww/actions/contracts.py +699 -0
- ww/actions/extension.py +197 -0
- ww/actions/mcp.py +84 -0
- ww/actions/prompt.py +74 -0
- ww/actions/skill.py +62 -0
- ww/actions/slash_command.py +63 -0
- ww/agents.py +151 -0
- ww/amendments.py +54 -0
- ww/artifacts.py +93 -0
- ww/assessments.py +181 -0
- ww/assets/__init__.py +2 -0
- ww/assets/agent_instructions.md +49 -0
- ww/assets/docs/examples.md +879 -0
- ww/assets/docs/features.md +4639 -0
- ww/assets/docs/specification.md +1876 -0
- ww/assets/noww_skill.md +11 -0
- ww/assets/workflows/catchall.yaml +26 -0
- ww/assets/workflows/onboarding.yaml +586 -0
- ww/assets/workflows/scriptize.yaml +130 -0
- ww/assets/ww-automate_skill.md +23 -0
- ww/assets/ww-deduce-feedback_skill.md +38 -0
- ww/assets/ww-feedback-rules_skill.md +48 -0
- ww/assets/ww-learn-project_skill.md +22 -0
- ww/assets/ww-refresh_skill.md +26 -0
- ww/assets/ww-rule_skill.md +83 -0
- ww/assets/ww-rules-from-artifacts_skill.md +22 -0
- ww/assets/ww-scriptize_skill.md +33 -0
- ww/assets/ww-setup_skill.md +94 -0
- ww/assets/ww-solve_skill.md +23 -0
- ww/assets/ww-suggest_skill.md +32 -0
- ww/assets/ww-wizard_skill.md +105 -0
- ww/assets/ww_skill.md +59 -0
- ww/assignments.py +283 -0
- ww/bootstrap.py +405 -0
- ww/builtin_workflows.py +215 -0
- ww/changes.py +225 -0
- ww/child_coordination.py +482 -0
- ww/children.py +106 -0
- ww/claude_permissions.py +115 -0
- ww/cli/__init__.py +7 -0
- ww/cli/__main__.py +6 -0
- ww/cli/audit.py +129 -0
- ww/cli/catalogs.py +131 -0
- ww/cli/discover.py +607 -0
- ww/cli/initialization.py +898 -0
- ww/cli/lookup.py +287 -0
- ww/cli/main.py +1768 -0
- ww/cli/parser.py +1200 -0
- ww/cli/prompts.py +217 -0
- ww/cli/updates.py +117 -0
- ww/completion_artifacts.py +156 -0
- ww/completion_inputs.py +39 -0
- ww/config/__init__.py +582 -0
- ww/config/actions.py +591 -0
- ww/config/composition.py +571 -0
- ww/config/rules.py +511 -0
- ww/config/steps.py +1220 -0
- ww/config/values.py +223 -0
- ww/config_files.py +191 -0
- ww/config_writes.py +264 -0
- ww/contracts.py +155 -0
- ww/control.py +41 -0
- ww/defaults.py +130 -0
- ww/design_docs.py +32 -0
- ww/discovery.py +104 -0
- ww/documents.py +217 -0
- ww/errors.py +18 -0
- ww/executable.py +43 -0
- ww/execution_models/__init__.py +64 -0
- ww/execution_models/construction.py +148 -0
- ww/execution_models/decoding.py +38 -0
- ww/execution_models/plan_codec.py +565 -0
- ww/execution_models/records.py +1206 -0
- ww/execution_models/runs.py +266 -0
- ww/extensions/__init__.py +40 -0
- ww/extensions/api.py +559 -0
- ww/extensions/registry.py +864 -0
- ww/extensions/store.py +78 -0
- ww/feedback.py +342 -0
- ww/handler_repairs.py +57 -0
- ww/hooks/__init__.py +40 -0
- ww/hooks/agents.py +380 -0
- ww/hooks/install.py +168 -0
- ww/hooks/notices.py +206 -0
- ww/hooks/records.py +209 -0
- ww/hooks/runtime.py +266 -0
- ww/hooks/transcripts.py +183 -0
- ww/inspect.py +896 -0
- ww/instructions/__init__.py +17 -0
- ww/instructions/builder.py +1682 -0
- ww/instructions/commands.py +335 -0
- ww/instructions/handoff.py +149 -0
- ww/instructions/models.py +686 -0
- ww/instructions/policy.py +219 -0
- ww/instructions/text.py +168 -0
- ww/interactions.py +187 -0
- ww/interpolation.py +37 -0
- ww/item_passes.py +167 -0
- ww/items.py +99 -0
- ww/locking.py +207 -0
- ww/metadata_publication.py +230 -0
- ww/onboarding.py +229 -0
- ww/open_work.py +236 -0
- ww/operations.py +193 -0
- ww/operator_ui/__init__.py +16 -0
- ww/operator_ui/page.html +351 -0
- ww/operator_ui/server.py +215 -0
- ww/operator_ui/session.py +389 -0
- ww/operator_ui/sheet.py +104 -0
- ww/operator_ui/view.py +109 -0
- ww/output.py +339 -0
- ww/output_adapters/__init__.py +12 -0
- ww/output_adapters/base.py +25 -0
- ww/output_adapters/json_adapter.py +37 -0
- ww/output_adapters/markdown.py +2293 -0
- ww/output_adapters/rule_pages.py +337 -0
- ww/output_adapters/terminal.py +21 -0
- ww/package_updates.py +167 -0
- ww/plan/__init__.py +38 -0
- ww/plan/actions.py +207 -0
- ww/plan/compiler.py +1492 -0
- ww/plan/constructs.py +456 -0
- ww/plan/models.py +665 -0
- ww/project_config.py +752 -0
- ww/recovery.py +401 -0
- ww/replanning.py +367 -0
- ww/results.py +77 -0
- ww/rule_checks.py +230 -0
- ww/rule_conversion.py +331 -0
- ww/rule_disputes.py +148 -0
- ww/rule_store.py +456 -0
- ww/rule_verification.py +714 -0
- ww/rule_views.py +447 -0
- ww/rule_writes.py +920 -0
- ww/run_coordination.py +158 -0
- ww/runtimes.py +105 -0
- ww/service.py +4405 -0
- ww/setup_apply.py +428 -0
- ww/step_values.py +20 -0
- ww/storage.py +447 -0
- ww/storage_adapters/__init__.py +36 -0
- ww/storage_adapters/base.py +540 -0
- ww/storage_adapters/filesystem.py +370 -0
- ww/storage_adapters/memory.py +195 -0
- ww/storage_adapters/project_metadata.py +69 -0
- ww/storage_adapters/task_document.py +484 -0
- ww/task_ids.py +114 -0
- ww/task_references.py +124 -0
- ww/transitions.py +1619 -0
- ww/updates.py +399 -0
- ww/upgrade.py +95 -0
- ww/validation.py +168 -0
- ww/variables.py +275 -0
- ww/workflow_config.py +854 -0
- ww/workflow_update.py +239 -0
- ww/workflow_validation.py +1260 -0
- ww/workspace.py +50 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|