worktree-for-agents 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GUIDE.md +25 -25
- package/README.md +113 -114
- package/docs/installation.md +111 -112
- package/docs/mcp-contract.md +29 -6
- package/package.json +1 -1
- package/worktree_manager/__init__.py +1 -1
- package/worktree_manager/cli.py +3 -3
- package/worktree_manager/mcp_contract.py +5 -2
- package/worktree_manager/mcp_operations.py +4 -3
- package/worktree_manager/mcp_server.py +3 -2
- package/worktree_manager/recovery_manager.py +10 -4
- package/worktree_manager/worktree_lifecycle.py +57 -20
package/GUIDE.md
CHANGED
|
@@ -1,45 +1,45 @@
|
|
|
1
|
-
|
|
1
|
+
You are going to implement a change, run a spec, or start a flow. Do you want to create a worktree to work in isolation?
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
This guide explains how to use the `worktree-for-agents` manager to work
|
|
4
|
+
isolated in a worktree before touching the main branch.
|
|
5
5
|
|
|
6
|
-
##
|
|
6
|
+
## The seven tools
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
The MCP service exposes exactly seven tools:
|
|
9
9
|
|
|
10
|
-
- `worktree_create`:
|
|
11
|
-
- `worktree_complete`:
|
|
12
|
-
- `worktree_cleanup`:
|
|
13
|
-
- `worktree_recover`:
|
|
14
|
-
- `worktree_list`:
|
|
15
|
-
- `worktree_prune`:
|
|
16
|
-
- `worktree_status`:
|
|
10
|
+
- `worktree_create`: creates a worktree and its branch for a work identifier.
|
|
11
|
+
- `worktree_complete`: merges the worktree into the base branch and removes the worktree.
|
|
12
|
+
- `worktree_cleanup`: removes the worktree and its branch without merging.
|
|
13
|
+
- `worktree_recover`: recovers a pending transaction by its identifier.
|
|
14
|
+
- `worktree_list`: lists the repository worktrees.
|
|
15
|
+
- `worktree_prune`: prunes worktree information that no longer exists.
|
|
16
|
+
- `worktree_status`: shows the status of a work item.
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
All of them require an absolute Git repository path in `repo`.
|
|
19
19
|
|
|
20
|
-
##
|
|
20
|
+
## When to offer the worktree
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
`worktree_create`.
|
|
24
|
-
`worktree_complete`.
|
|
22
|
+
Before working on the main branch, offer to create a worktree with
|
|
23
|
+
`worktree_create`. Work inside the worktree and then merge with
|
|
24
|
+
`worktree_complete`. If the work is discarded, use `worktree_cleanup`.
|
|
25
25
|
|
|
26
|
-
##
|
|
26
|
+
## Launch without bin
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
The package exposes no bin or `npx`. The MCP service is launched with Node and the
|
|
29
|
+
absolute package path:
|
|
30
30
|
|
|
31
31
|
```sh
|
|
32
|
-
node <
|
|
32
|
+
node <package>/mcp-server.mjs
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
The CLI is launched the same way:
|
|
36
36
|
|
|
37
37
|
```sh
|
|
38
|
-
node <
|
|
38
|
+
node <package>/worktree.mjs --version
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
The installation diagnostics are obtained with:
|
|
42
42
|
|
|
43
43
|
```sh
|
|
44
|
-
node <
|
|
44
|
+
node <package>/setup.mjs
|
|
45
45
|
```
|
package/README.md
CHANGED
|
@@ -1,154 +1,153 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Worktree manager
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Standalone manager to create, complete, clean up, and recover worktrees of a
|
|
4
|
+
Git repository. The project does not know FlowTask and does not depend on its paths, legacy
|
|
5
|
+
refs, or coordination mechanisms.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Requirements
|
|
8
8
|
|
|
9
|
-
- **Python 3.9
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
- **Git 2.17.0
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
- **Node 18
|
|
16
|
-
|
|
17
|
-
- **`bash`**
|
|
9
|
+
- **Python 3.9 or later.** The entrypoint only checks that `python3` is
|
|
10
|
+
available on the `PATH`; the 3.9 floor is validated by the package postinstall
|
|
11
|
+
and `node <package>/setup.mjs`. The package uses only the standard library.
|
|
12
|
+
- **Git 2.17.0 or later**, for `git worktree remove`, which is the most
|
|
13
|
+
recent command used by the manager. The test baseline is Git 2.54.0. The
|
|
14
|
+
manager does not check the Git version at runtime.
|
|
15
|
+
- **Node 18 or later** for the `mcp-server.mjs`, `worktree.mjs`
|
|
16
|
+
and `setup.mjs` launchers.
|
|
17
|
+
- **`bash`** to run the test suites.
|
|
18
18
|
|
|
19
|
-
##
|
|
19
|
+
## Installation
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
`1.0.0`,
|
|
21
|
+
The manager is distributed as the npm package `worktree-for-agents`, version
|
|
22
|
+
`1.0.0`, with no bin or `npx`:
|
|
23
23
|
|
|
24
24
|
```sh
|
|
25
25
|
npm install -g worktree-for-agents@latest
|
|
26
26
|
pnpm add -g worktree-for-agents@latest
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
Check the installation diagnostics:
|
|
30
30
|
|
|
31
31
|
```sh
|
|
32
|
-
node <
|
|
32
|
+
node <package>/setup.mjs
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
The repository clone is for development only. There is no `pip install` and no
|
|
36
|
+
dependencies to resolve: the manager uses only the Python standard
|
|
37
|
+
library. Once installed, the manager works without a network.
|
|
38
38
|
|
|
39
|
-
##
|
|
39
|
+
## Quick start
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
Global options go before the subcommand and `--repo` requires an absolute
|
|
42
|
+
path.
|
|
43
43
|
|
|
44
44
|
```text
|
|
45
45
|
worktree.sh [--repo ABS] [--config ABS] [--json] create <id> [--base BRANCH] [--init-submodules] [--prepare] [--verify]
|
|
46
|
-
worktree.sh [
|
|
47
|
-
worktree.sh [
|
|
48
|
-
worktree.sh [
|
|
49
|
-
worktree.sh [
|
|
50
|
-
worktree.sh [
|
|
46
|
+
worktree.sh [globals] complete <id> [--preserve-dirty] [--title TEXT] [--body TEXT]
|
|
47
|
+
worktree.sh [globals] cleanup <id> [--discard]
|
|
48
|
+
worktree.sh [globals] recover [<transaction-id>]
|
|
49
|
+
worktree.sh [globals] prune
|
|
50
|
+
worktree.sh [globals] list
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
Target-direct
|
|
54
|
-
worktree
|
|
55
|
-
|
|
53
|
+
Target-direct operates on the repository given in `--repo` and does not create a
|
|
54
|
+
parent worktree nor modify its `HEAD`. Its syntax places `--parent-repo`
|
|
55
|
+
after the action and requires `--submodule-path` in `create`:
|
|
56
56
|
|
|
57
57
|
```text
|
|
58
|
-
worktree.sh [--repo ABS] target-direct <
|
|
58
|
+
worktree.sh [--repo ABS] target-direct <action> <id> --parent-repo <parent> [--target submodule] [--submodule-path <REL>] [--base BRANCH] [--link-parent staged-only] [--descriptor PATH]
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
The actions are `create`, `link-parent`, `abort`, `complete` and `recover`, and the
|
|
62
|
+
only supported link mode is `--link-parent staged-only`.
|
|
63
63
|
|
|
64
|
-
`--json`
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
worktree
|
|
64
|
+
`--json` publishes a single JSON document. `--request-json <file|->` publishes
|
|
65
|
+
the same nine-key envelope used by the MCP transport, and optionally accepts `--json`.
|
|
66
|
+
A dry run of `create` publishes `planned: true` and creates no
|
|
67
|
+
worktree or branch.
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
The MCP service is published over stdio. From the installed package it is launched without
|
|
70
70
|
bin:
|
|
71
71
|
|
|
72
72
|
```sh
|
|
73
|
-
node <
|
|
73
|
+
node <package>/mcp-server.mjs
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
The CLI is used the same way:
|
|
77
77
|
|
|
78
78
|
```sh
|
|
79
|
-
node <
|
|
79
|
+
node <package>/worktree.mjs --version
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
The MCP `initialize` publishes the guide in `instructions`:
|
|
83
83
|
|
|
84
84
|
```text
|
|
85
|
-
|
|
85
|
+
You are going to implement a change, run a spec, or start a flow. Do you want to create a worktree to work in isolation?
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
For development, the transport also starts from the checkout:
|
|
89
89
|
|
|
90
90
|
```sh
|
|
91
91
|
PYTHONPATH=. python3 -m worktree_manager.mcp_transport
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
##
|
|
94
|
+
## Configuration
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
`--config`
|
|
98
|
-
|
|
96
|
+
The configuration is an optional `key=value` file, selected with
|
|
97
|
+
`--config` or taken from `.worktree-manager.conf` at the repository root.
|
|
98
|
+
The eleven keys and their default values are:
|
|
99
99
|
|
|
100
|
-
|
|
|
100
|
+
| Key | Default | Restriction |
|
|
101
101
|
|---|---|---|
|
|
102
|
-
| `worktrees_dir` | `.worktrees` |
|
|
102
|
+
| `worktrees_dir` | `.worktrees` | Must not start with `-` |
|
|
103
103
|
| `branch_prefix` | `worktree/` | — |
|
|
104
|
-
| `state_dir` | `worktree-manager` |
|
|
105
|
-
| `backup_dir` | `backups` |
|
|
106
|
-
| `backup_ref_prefix` | `worktree-manager/backups` |
|
|
107
|
-
| `lock_dir` | `locks` |
|
|
108
|
-
| `prepare_command` | `''` (
|
|
109
|
-
| `verify_command` | `''` (
|
|
110
|
-
| `prepare_enabled` | `false` | `true`
|
|
111
|
-
| `verify_enabled` | `false` | `true`
|
|
112
|
-
| `preserve_dirty` | `false` | `true`
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
`
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
`
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
| Módulo | Responsabilidad |
|
|
104
|
+
| `state_dir` | `worktree-manager` | Safe relative path |
|
|
105
|
+
| `backup_dir` | `backups` | Not an absolute path and contains no `..` |
|
|
106
|
+
| `backup_ref_prefix` | `worktree-manager/backups` | No `..` |
|
|
107
|
+
| `lock_dir` | `locks` | Safe relative path |
|
|
108
|
+
| `prepare_command` | `''` (empty) | Required when `prepare_enabled=true` |
|
|
109
|
+
| `verify_command` | `''` (empty) | Required when `verify_enabled=true` |
|
|
110
|
+
| `prepare_enabled` | `false` | `true` or `false` |
|
|
111
|
+
| `verify_enabled` | `false` | `true` or `false` |
|
|
112
|
+
| `preserve_dirty` | `false` | `true` or `false` |
|
|
113
|
+
|
|
114
|
+
Each key may appear only once. Unknown keys, malformed lines, invalid booleans, and unsafe paths are rejected as
|
|
115
|
+
`invalid_configuration` with exit code `3`. The environment variable
|
|
116
|
+
`WORKTREE_MANAGER_<KEY>`, with the key in uppercase, overrides the value
|
|
117
|
+
read from the file.
|
|
118
|
+
|
|
119
|
+
## Architecture
|
|
120
|
+
|
|
121
|
+
`worktree.sh` is the entrypoint and delegates the lifecycle to the Python
|
|
122
|
+
package `worktree_manager`, which runs with `python3 -m worktree_manager`. The package
|
|
123
|
+
is the only lifecycle implementation: there is no alternative shell engine.
|
|
124
|
+
|
|
125
|
+
The package is organized in modules with one responsibility each:
|
|
126
|
+
|
|
127
|
+
| Module | Responsibility |
|
|
129
128
|
| --- | --- |
|
|
130
|
-
| `worktree_manager.cli` |
|
|
131
|
-
| `worktree_manager.configuration` |
|
|
132
|
-
| `worktree_manager.models` |
|
|
133
|
-
| `worktree_manager.output` |
|
|
134
|
-
| `worktree_manager.git_repository` |
|
|
135
|
-
| `worktree_manager.worktree_lifecycle` |
|
|
136
|
-
| `worktree_manager.commit_message` |
|
|
137
|
-
| `worktree_manager.target_direct_adapter` |
|
|
138
|
-
| `worktree_manager.recovery_manager` |
|
|
139
|
-
| `worktree_manager.transaction_journal` |
|
|
140
|
-
| `worktree_manager.backup_store` |
|
|
141
|
-
| `worktree_manager.lock_manager` |
|
|
142
|
-
| `worktree_manager.state_digest` |
|
|
143
|
-
| `worktree_manager.mcp_server` |
|
|
144
|
-
| `worktree_manager.mcp_contract` |
|
|
145
|
-
| `worktree_manager.mcp_operations` |
|
|
146
|
-
| `worktree_manager.mcp_transport` |
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
##
|
|
129
|
+
| `worktree_manager.cli` | Command dispatcher, target-direct dispatch, and output contract. |
|
|
130
|
+
| `worktree_manager.configuration` | Reading and validating the `key=value` configuration. |
|
|
131
|
+
| `worktree_manager.models` | Shared domain values: results, errors, and identities. |
|
|
132
|
+
| `worktree_manager.output` | Publishing the result as readable text or as JSON. |
|
|
133
|
+
| `worktree_manager.git_repository` | Shared Git access for the lifecycle modules. |
|
|
134
|
+
| `worktree_manager.worktree_lifecycle` | `create`, `complete`, `cleanup`, and `prune` operations. |
|
|
135
|
+
| `worktree_manager.commit_message` | Deterministic closing message composition and neutral fallback. |
|
|
136
|
+
| `worktree_manager.target_direct_adapter` | Target-direct operations on a submodule. |
|
|
137
|
+
| `worktree_manager.recovery_manager` | Recovery of pending transactions. |
|
|
138
|
+
| `worktree_manager.transaction_journal` | Durable transaction and link journal. |
|
|
139
|
+
| `worktree_manager.backup_store` | Retained backup references. |
|
|
140
|
+
| `worktree_manager.lock_manager` | Mutual exclusion between processes. |
|
|
141
|
+
| `worktree_manager.state_digest` | Canonical digest of the repository state and of a plan. |
|
|
142
|
+
| `worktree_manager.mcp_server` | MCP service published over stdio. |
|
|
143
|
+
| `worktree_manager.mcp_contract` | Strict versioned request and envelope contracts. |
|
|
144
|
+
| `worktree_manager.mcp_operations` | Facade over the lifecycle and recovery services. |
|
|
145
|
+
| `worktree_manager.mcp_transport` | Stdio transport entrypoint. |
|
|
146
|
+
|
|
147
|
+
The package also contains the package marker `__init__.py` and the
|
|
148
|
+
entrypoint `__main__.py`, which is what `python3 -m worktree_manager` runs.
|
|
149
|
+
|
|
150
|
+
## Tests
|
|
152
151
|
|
|
153
152
|
```sh
|
|
154
153
|
bash tests/cli-usage.sh
|
|
@@ -160,22 +159,22 @@ PYTHONPATH=. python3 -m unittest discover -s tests -p 'test_*.py'
|
|
|
160
159
|
PYTHONPATH=. python3 -m unittest discover -s tests -p 'test_mcp*.py' -v
|
|
161
160
|
```
|
|
162
161
|
|
|
163
|
-
##
|
|
162
|
+
## Documentation
|
|
164
163
|
|
|
165
|
-
- [
|
|
166
|
-
- [
|
|
167
|
-
- [
|
|
168
|
-
- [
|
|
164
|
+
- [CLI output contract](docs/cli-contract-v1.md) — contract.
|
|
165
|
+
- [MCP service contract](docs/mcp-contract.md) — contract.
|
|
166
|
+
- [Installation and usage guide](docs/installation.md) — guide.
|
|
167
|
+
- [History bash → Python](docs/history-bash-to-python.md) — notes, not a contract.
|
|
169
168
|
|
|
170
|
-
|
|
171
|
-
|
|
169
|
+
Contracts pin the verifiable behavior. The guide explains how to use it.
|
|
170
|
+
The history records the transition and does not replace the contracts.
|
|
172
171
|
|
|
173
|
-
##
|
|
172
|
+
## Source and distribution
|
|
174
173
|
|
|
175
|
-
|
|
176
|
-
|
|
174
|
+
This repository is the source of truth for the implementation, the contracts,
|
|
175
|
+
the installation, and the manager history.
|
|
177
176
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
177
|
+
The consumer that integrates this repository documents the adapter, the bridge, and
|
|
178
|
+
the submodule boundary: **that integration documentation lives in the
|
|
179
|
+
consumer repository**. The manager does not keep a vendored copy of itself
|
|
180
|
+
and does not know the consumer.
|
package/docs/installation.md
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Installation and usage of the worktree manager
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
This guide explains how to install the manager as an npm package and how to use it
|
|
4
|
+
on a test repository. Integration with a specific consumer
|
|
5
|
+
is documented in the repository that integrates the manager.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
The examples use `$HOME` because `--repo` requires an absolute path. `<package>`
|
|
8
|
+
is the installed package path.
|
|
9
9
|
|
|
10
|
-
## 1.
|
|
10
|
+
## 1. Requirements
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Check the available versions:
|
|
13
13
|
|
|
14
14
|
```sh
|
|
15
15
|
python3 --version
|
|
@@ -17,39 +17,38 @@ git --version
|
|
|
17
17
|
node --version
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
- **Python 3.9
|
|
21
|
-
3.9
|
|
22
|
-
<
|
|
23
|
-
- **Git 2.17.0
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
- **`bash`** para ejecutar las suites de pruebas.
|
|
20
|
+
- **Python 3.9 or later.** The package postinstall checks the
|
|
21
|
+
3.9 floor and fails visibly if it is missing or older. `node
|
|
22
|
+
<package>/setup.mjs` reports the detected interpreter.
|
|
23
|
+
- **Git 2.17.0 or later**, for `git worktree remove`. The test baseline is
|
|
24
|
+
Git 2.54.0. The manager does not check the Git version at runtime.
|
|
25
|
+
- **Node 18 or later** for the `mcp-server.mjs`, `worktree.mjs`
|
|
26
|
+
and `setup.mjs` launchers.
|
|
27
|
+
- **`bash`** to run the test suites.
|
|
29
28
|
|
|
30
|
-
## 2.
|
|
29
|
+
## 2. Install the package
|
|
31
30
|
|
|
32
31
|
```sh
|
|
33
32
|
npm install -g worktree-for-agents@latest
|
|
34
33
|
pnpm add -g worktree-for-agents@latest
|
|
35
34
|
```
|
|
36
35
|
|
|
37
|
-
|
|
36
|
+
Check the diagnostics:
|
|
38
37
|
|
|
39
38
|
```sh
|
|
40
|
-
node <
|
|
39
|
+
node <package>/setup.mjs
|
|
41
40
|
```
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
The package exposes no bin or `npx`: the MCP is launched with `node
|
|
43
|
+
<package>/mcp-server.mjs` and the CLI with `node <package>/worktree.mjs`. The
|
|
44
|
+
repository clone is for development only. There is no `pip install` and no
|
|
45
|
+
dependencies to resolve: the manager uses only the Python standard
|
|
46
|
+
library. Once installed, the manager works without a network.
|
|
48
47
|
|
|
49
|
-
## 3.
|
|
48
|
+
## 3. A test repository
|
|
50
49
|
|
|
51
|
-
|
|
52
|
-
`.worktrees/`
|
|
50
|
+
The manager requires the worktree path to be ignored by Git, and
|
|
51
|
+
`.worktrees/` in `.gitignore` satisfies that condition.
|
|
53
52
|
|
|
54
53
|
```sh
|
|
55
54
|
mkdir "$HOME/demo" && cd "$HOME/demo"
|
|
@@ -62,113 +61,113 @@ git add file .gitignore
|
|
|
62
61
|
git commit -m "base"
|
|
63
62
|
```
|
|
64
63
|
|
|
65
|
-
|
|
64
|
+
Create the worktree:
|
|
66
65
|
|
|
67
66
|
```sh
|
|
68
67
|
"$HOME/worktree-manager/worktree.sh" --repo "$HOME/demo" create demo --base main
|
|
69
68
|
```
|
|
70
69
|
|
|
71
|
-
|
|
70
|
+
What each step leaves behind:
|
|
72
71
|
|
|
73
|
-
- `git init`
|
|
74
|
-
-
|
|
75
|
-
- `create`
|
|
76
|
-
|
|
72
|
+
- `git init` creates the repository and its `main` branch.
|
|
73
|
+
- The initial commit pins the base on top of which the worktree is built.
|
|
74
|
+
- `create` creates the `worktree/demo` branch, the worktree at `$HOME/demo/.worktrees/demo`
|
|
75
|
+
and the transaction journal in the repository state directory.
|
|
77
76
|
|
|
78
|
-
|
|
77
|
+
Check the status:
|
|
79
78
|
|
|
80
79
|
```sh
|
|
81
80
|
"$HOME/worktree-manager/worktree.sh" --repo "$HOME/demo" list
|
|
82
81
|
```
|
|
83
82
|
|
|
84
|
-
|
|
83
|
+
Work inside the worktree and then merge it:
|
|
85
84
|
|
|
86
85
|
```sh
|
|
87
86
|
cd "$HOME/demo/.worktrees/demo"
|
|
88
|
-
printf '
|
|
87
|
+
printf 'change\n' >> file
|
|
89
88
|
git add file
|
|
90
|
-
git commit -m "
|
|
89
|
+
git commit -m "change"
|
|
91
90
|
cd "$HOME/demo"
|
|
92
91
|
"$HOME/worktree-manager/worktree.sh" --repo "$HOME/demo" complete demo --base main
|
|
93
92
|
```
|
|
94
93
|
|
|
95
|
-
`complete`
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
`complete` requires the repository to be on the base branch, the worktree to have
|
|
95
|
+
no uncommitted changes, and the destination to have none either, unless
|
|
96
|
+
`--preserve-dirty` is used. When finished, the change is merged as a squash
|
|
97
|
+
commit on `main`, and the worktree and its branch are removed.
|
|
99
98
|
|
|
100
|
-
|
|
99
|
+
To leave an expressive closing commit, pass `--title` and optionally
|
|
101
100
|
`--body`:
|
|
102
101
|
|
|
103
102
|
```sh
|
|
104
103
|
"$HOME/worktree-manager/worktree.sh" --repo "$HOME/demo" complete demo --base main \
|
|
105
|
-
--title "
|
|
104
|
+
--title "Demo closing" --body "Delivery: merged change. Reason: verification."
|
|
106
105
|
```
|
|
107
106
|
|
|
108
|
-
|
|
109
|
-
`Complete <work_id> (generic closure)`
|
|
107
|
+
If `--title` is not passed, the manager uses the generic fallback:
|
|
108
|
+
`Complete <work_id> (generic closure)` with the body
|
|
110
109
|
`No delivery description was available at closure time.`.
|
|
111
110
|
|
|
112
|
-
## 4.
|
|
111
|
+
## 4. Configuration
|
|
113
112
|
|
|
114
|
-
|
|
115
|
-
`.worktree-manager.conf`
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
`WORKTREE_MANAGER_<
|
|
119
|
-
|
|
113
|
+
The configuration is a `key=value` file. The manager reads it from
|
|
114
|
+
`.worktree-manager.conf` at the repository root, or from the file passed
|
|
115
|
+
with `--config`, which must exist and be an absolute path. If both are
|
|
116
|
+
given, `--config` wins. Afterwards, the environment variable
|
|
117
|
+
`WORKTREE_MANAGER_<KEY>`, with the key in uppercase, overrides the value
|
|
118
|
+
read from the file.
|
|
120
119
|
|
|
121
|
-
|
|
120
|
+
The eleven keys and their default values are:
|
|
122
121
|
|
|
123
|
-
|
|
|
122
|
+
| Key | Default | Restriction |
|
|
124
123
|
|---|---|---|
|
|
125
|
-
| `worktrees_dir` | `.worktrees` |
|
|
124
|
+
| `worktrees_dir` | `.worktrees` | Must not start with `-` |
|
|
126
125
|
| `branch_prefix` | `worktree/` | — |
|
|
127
|
-
| `state_dir` | `worktree-manager` |
|
|
128
|
-
| `backup_dir` | `backups` |
|
|
129
|
-
| `backup_ref_prefix` | `worktree-manager/backups` |
|
|
130
|
-
| `lock_dir` | `locks` |
|
|
131
|
-
| `prepare_command` | `''` (
|
|
132
|
-
| `verify_command` | `''` (
|
|
133
|
-
| `prepare_enabled` | `false` | `true`
|
|
134
|
-
| `verify_enabled` | `false` | `true`
|
|
135
|
-
| `preserve_dirty` | `false` | `true`
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
`=`,
|
|
139
|
-
`invalid_configuration`
|
|
140
|
-
|
|
141
|
-
## 5.
|
|
142
|
-
|
|
143
|
-
- `--json`
|
|
144
|
-
`tool_version`, `operation`, `result`, `data`, `warnings`
|
|
145
|
-
- `--request-json <
|
|
146
|
-
|
|
147
|
-
`transaction_id`.
|
|
148
|
-
|
|
149
|
-
-
|
|
150
|
-
`init_submodules_result`
|
|
151
|
-
`verify`
|
|
152
|
-
-
|
|
153
|
-
|
|
126
|
+
| `state_dir` | `worktree-manager` | Safe relative path |
|
|
127
|
+
| `backup_dir` | `backups` | Not an absolute path and contains no `..` |
|
|
128
|
+
| `backup_ref_prefix` | `worktree-manager/backups` | No `..` |
|
|
129
|
+
| `lock_dir` | `locks` | Safe relative path |
|
|
130
|
+
| `prepare_command` | `''` (empty) | Required when `prepare_enabled=true` |
|
|
131
|
+
| `verify_command` | `''` (empty) | Required when `verify_enabled=true` |
|
|
132
|
+
| `prepare_enabled` | `false` | `true` or `false` |
|
|
133
|
+
| `verify_enabled` | `false` | `true` or `false` |
|
|
134
|
+
| `preserve_dirty` | `false` | `true` or `false` |
|
|
135
|
+
|
|
136
|
+
Each key may appear only once. An unknown key, a line without
|
|
137
|
+
`=`, an invalid boolean, or an unsafe path is rejected as
|
|
138
|
+
`invalid_configuration` with exit code `3`, before any effect.
|
|
139
|
+
|
|
140
|
+
## 5. Output contracts
|
|
141
|
+
|
|
142
|
+
- `--json` publishes a single JSON document with seven keys: `contract_version`,
|
|
143
|
+
`tool_version`, `operation`, `result`, `data`, `warnings` and `error`.
|
|
144
|
+
- `--request-json <file|->` publishes the same nine-key document used by
|
|
145
|
+
the MCP transport: the previous seven plus `repository` and
|
|
146
|
+
`transaction_id`. It optionally accepts `--json` and the published document
|
|
147
|
+
does not change.
|
|
148
|
+
- A dry run of `create` publishes `planned: true` and creates no worktree or branch.
|
|
149
|
+
`init_submodules_result` is `planned` or `not_requested`, and `prepare` and
|
|
150
|
+
`verify` are `planned` or `skipped`.
|
|
151
|
+
- The MCP service is published over stdio and exposes seven tools. From the
|
|
152
|
+
installed package it is launched without bin:
|
|
154
153
|
|
|
155
154
|
```sh
|
|
156
|
-
node <
|
|
155
|
+
node <package>/mcp-server.mjs
|
|
157
156
|
```
|
|
158
157
|
|
|
159
|
-
|
|
158
|
+
`initialize` publishes the guide in `instructions`:
|
|
160
159
|
|
|
161
160
|
```text
|
|
162
|
-
|
|
161
|
+
You are going to implement a change, run a spec, or start a flow. Do you want to create a worktree to work in isolation?
|
|
163
162
|
```
|
|
164
163
|
|
|
165
|
-
|
|
164
|
+
For development, the transport also starts from the checkout:
|
|
166
165
|
|
|
167
166
|
```sh
|
|
168
167
|
PYTHONPATH=. python3 -m worktree_manager.mcp_transport
|
|
169
168
|
```
|
|
170
169
|
|
|
171
|
-
## 6.
|
|
170
|
+
## 6. Tests
|
|
172
171
|
|
|
173
172
|
```sh
|
|
174
173
|
bash tests/cli-usage.sh
|
|
@@ -180,35 +179,35 @@ PYTHONPATH=. python3 -m unittest discover -s tests -p 'test_*.py'
|
|
|
180
179
|
PYTHONPATH=. python3 -m unittest discover -s tests -p 'test_mcp*.py' -v
|
|
181
180
|
```
|
|
182
181
|
|
|
183
|
-
## 7.
|
|
182
|
+
## 7. Consumption as a submodule and coexistence
|
|
184
183
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
184
|
+
A consumer repository may materialize the manager as a submodule. The
|
|
185
|
+
rule is unique: the materialized checkout and the registered gitlink must be the
|
|
186
|
+
same commit.
|
|
188
187
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
<
|
|
192
|
-
|
|
193
|
-
|
|
188
|
+
The npm package `worktree-for-agents` is independent of the submodule: it ships
|
|
189
|
+
the Python runtime and works without a network once installed. `node
|
|
190
|
+
<package>/setup.mjs` diagnoses which installation is active when the
|
|
191
|
+
npm package, the global FlowTask copy, and the local submodule coexist. The repository
|
|
192
|
+
clone is for development only and no other distribution is promised.
|
|
194
193
|
|
|
195
|
-
## 8.
|
|
194
|
+
## 8. Troubleshooting
|
|
196
195
|
|
|
197
|
-
- **`python3`
|
|
198
|
-
|
|
196
|
+
- **`python3` is not on the `PATH`.** The entrypoint exits with code `127`
|
|
197
|
+
and the message
|
|
199
198
|
`worktree manager requires Python 3 (python3) on PATH; install Python 3 and retry.`
|
|
200
|
-
- **
|
|
201
|
-
|
|
202
|
-
`worktree manager Python entry point is missing: <
|
|
203
|
-
- **
|
|
204
|
-
|
|
199
|
+
- **The package entrypoint is missing.** The entrypoint exits with
|
|
200
|
+
code `126` and the message
|
|
201
|
+
`worktree manager Python entry point is missing: <path>/worktree_manager/__main__.py`.
|
|
202
|
+
- **The repository path is relative.** The operation is rejected with
|
|
203
|
+
code `invalid_repository`, exit code `3`, and the message
|
|
205
204
|
`Repository path must be absolute`.
|
|
206
|
-
- **
|
|
207
|
-
|
|
208
|
-
|
|
205
|
+
- **The Python version is older than 3.9.** The consumer installer reports
|
|
206
|
+
it as `python <version> is older than the required 3.9`. The entrypoint
|
|
207
|
+
of this repository does not check the version.
|
|
209
208
|
|
|
210
|
-
## 9.
|
|
209
|
+
## 9. What it does not cover
|
|
211
210
|
|
|
212
|
-
|
|
213
|
-
bridge,
|
|
214
|
-
|
|
211
|
+
This guide does not cover integration with a consumer: neither the adapter, nor the
|
|
212
|
+
bridge, nor gitlink validation, nor the consumer installation procedure.
|
|
213
|
+
That is documented in the consumer repository.
|
package/docs/mcp-contract.md
CHANGED
|
@@ -18,7 +18,7 @@ The `initialize` result carries `serverInfo.name "worktree-for-agents"` and
|
|
|
18
18
|
an `instructions` field with the literal guide text:
|
|
19
19
|
|
|
20
20
|
```text
|
|
21
|
-
|
|
21
|
+
You are going to implement a change, run a spec, or start a flow. Do you want to create a worktree to work in isolation?
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
When installed from npm, the service is launched without a bin entry via:
|
|
@@ -68,11 +68,34 @@ on independent equivalent repositories. When `MCP_E2E_EVIDENCE_DIR` is set,
|
|
|
68
68
|
each test writes its request/response trace there as JSON. No evidence or Git
|
|
69
69
|
fixture is written into the checked-out repository by default.
|
|
70
70
|
|
|
71
|
-
##
|
|
71
|
+
## Closing message
|
|
72
72
|
|
|
73
|
-
`worktree_complete`
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
`worktree_complete` accepts `commit_title` and `commit_body` (optional strings).
|
|
74
|
+
They are validated as strings and included in the plan hash only for this operation.
|
|
75
|
+
The dry run publishes the composed `commit_subject` and `commit_body`; execute passes them
|
|
76
|
+
to the lifecycle. If missing, the manager uses the neutral fallback
|
|
77
77
|
`Complete <work_id> (generic closure)` / `No delivery description was available
|
|
78
78
|
at closure time.`.
|
|
79
|
+
|
|
80
|
+
## Merge strategy
|
|
81
|
+
|
|
82
|
+
`worktree_complete` accepts the optional string `merge_strategy` with the enum
|
|
83
|
+
values `squash` (default) and `preserve-history`. Squash integrates the branch
|
|
84
|
+
as one commit; preserve-history records an explicit merge commit that keeps the
|
|
85
|
+
branch commits. Neither strategy uses a fast-forward.
|
|
86
|
+
|
|
87
|
+
The field is validated as a string and against the enum before any mutation; a
|
|
88
|
+
value outside the enum is rejected as `invalid_request` with exit code 3. It is
|
|
89
|
+
included in the plan hash only for `worktree_complete`, so a preflight for one
|
|
90
|
+
strategy cannot execute the other and reports `plan_mismatch` with exit code 5.
|
|
91
|
+
|
|
92
|
+
A dry run publishes `data.merge_strategy` and the planned `mutations`:
|
|
93
|
+
`squash_merge` for squash, or `merge_no_ff` for preserve-history, followed by
|
|
94
|
+
`remove_worktree` and `delete_branch` in both cases. Execute returns
|
|
95
|
+
`merge_strategy` and `integrated_commit`; `squash_commit` is present only for
|
|
96
|
+
squash and equals `integrated_commit`. The transaction records the strategy and
|
|
97
|
+
the integrated commit. Recovery validates the parent shape recorded by the
|
|
98
|
+
strategy, reads a transaction without the field as squash, and reports an
|
|
99
|
+
unknown value as `invalid_journal` with exit code 5 while retaining evidence.
|
|
100
|
+
With `preserve-history`, a branch with no commits ahead of the base fails as
|
|
101
|
+
`operation_failed` with exit code 5 without a transaction or mutation.
|
package/package.json
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""Standalone Python worktree management backend."""
|
|
2
|
-
__version__ = '1.
|
|
2
|
+
__version__ = '1.1.0'
|
package/worktree_manager/cli.py
CHANGED
|
@@ -6,7 +6,7 @@ from .configuration import Configuration
|
|
|
6
6
|
from .git_repository import GitRepository
|
|
7
7
|
from .models import ManagerError,OperationResult,ResultKind
|
|
8
8
|
from .output import emit
|
|
9
|
-
from .worktree_lifecycle import WorktreeLifecycle
|
|
9
|
+
from .worktree_lifecycle import WorktreeLifecycle, MERGE_STRATEGIES, DEFAULT_MERGE_STRATEGY
|
|
10
10
|
from .recovery_manager import RecoveryManager
|
|
11
11
|
from .target_direct_adapter import TargetDirectAdapter
|
|
12
12
|
from . import __version__ as VERSION
|
|
@@ -18,7 +18,7 @@ def _parser():
|
|
|
18
18
|
for name in ('create','complete','cleanup'):
|
|
19
19
|
p=sub.add_parser(name); p.add_argument('work_id'); p.add_argument('--base');
|
|
20
20
|
if name=='cleanup': p.add_argument('--discard',action='store_true')
|
|
21
|
-
if name=='complete': p.add_argument('--preserve-dirty',action='store_true'); p.add_argument('--title'); p.add_argument('--body')
|
|
21
|
+
if name=='complete': p.add_argument('--preserve-dirty',action='store_true'); p.add_argument('--title'); p.add_argument('--body'); p.add_argument('--merge-strategy',choices=MERGE_STRATEGIES,default=DEFAULT_MERGE_STRATEGY)
|
|
22
22
|
if name=='create':
|
|
23
23
|
p.add_argument('--init-submodules',action='store_true'); p.add_argument('--prepare',action='store_true'); p.add_argument('--verify',action='store_true')
|
|
24
24
|
p=sub.add_parser('recover'); p.add_argument('transaction_id',nargs='?')
|
|
@@ -120,7 +120,7 @@ def main(argv=None)->int:
|
|
|
120
120
|
result=OperationResult(operation,data=data)
|
|
121
121
|
elif operation=='cleanup': result=OperationResult(operation,data=lifecycle.cleanup(args.work_id,args.discard,args.base or 'main'))
|
|
122
122
|
elif operation=='complete':
|
|
123
|
-
data=lifecycle.complete(args.work_id,args.base or 'main',args.preserve_dirty,commit_title=args.title,commit_body=args.body)
|
|
123
|
+
data=lifecycle.complete(args.work_id,args.base or 'main',args.preserve_dirty,commit_title=args.title,commit_body=args.body,merge_strategy=args.merge_strategy)
|
|
124
124
|
data['merge']='success'
|
|
125
125
|
result=OperationResult(operation,data=data)
|
|
126
126
|
if operation=='list' and not args.json:
|
|
@@ -5,13 +5,14 @@ import pathlib
|
|
|
5
5
|
|
|
6
6
|
CONTRACT_VERSION = 1
|
|
7
7
|
from . import __version__ as TOOL_VERSION
|
|
8
|
+
from .worktree_lifecycle import MERGE_STRATEGIES
|
|
8
9
|
TOOL_NAMES = (
|
|
9
10
|
"worktree_create", "worktree_complete", "worktree_cleanup",
|
|
10
11
|
"worktree_recover", "worktree_list", "worktree_prune", "worktree_status",
|
|
11
12
|
)
|
|
12
13
|
FIELDS = {
|
|
13
14
|
"worktree_create": {"repo", "work_id", "base", "dry_run", "parent_repo", "submodule_path", "confirm", "plan_hash", "init_submodules", "prepare", "verify"},
|
|
14
|
-
"worktree_complete": {"repo", "work_id", "base", "dry_run", "authorization", "confirm", "plan_hash", "transaction_id", "preserve_dirty", "target_direct", "parent_repo", "commit_title", "commit_body"},
|
|
15
|
+
"worktree_complete": {"repo", "work_id", "base", "dry_run", "authorization", "confirm", "plan_hash", "transaction_id", "preserve_dirty", "target_direct", "parent_repo", "commit_title", "commit_body", "merge_strategy"},
|
|
15
16
|
"worktree_cleanup": {"repo", "work_id", "base", "dry_run", "authorization", "confirm", "plan_hash", "discard", "discard_consent"},
|
|
16
17
|
"worktree_recover": {"repo", "transaction_id", "dry_run", "authorization", "confirm", "target_direct", "parent_repo"},
|
|
17
18
|
"worktree_list": {"repo"}, "worktree_prune": {"repo", "dry_run", "authorization", "confirm", "plan_hash"},
|
|
@@ -40,9 +41,11 @@ def validate_request(tool: str, request: dict) -> dict:
|
|
|
40
41
|
for key in ("dry_run", "discard", "preserve_dirty", "discard_consent", "target_direct", "confirm", "init_submodules", "prepare", "verify"):
|
|
41
42
|
if key in request and type(request[key]) is not bool:
|
|
42
43
|
raise ValueError(f"{key}_must_be_boolean")
|
|
43
|
-
for key in ("work_id", "base", "transaction_id", "authorization", "parent_repo", "submodule_path", "commit_title", "commit_body"):
|
|
44
|
+
for key in ("work_id", "base", "transaction_id", "authorization", "parent_repo", "submodule_path", "commit_title", "commit_body", "merge_strategy"):
|
|
44
45
|
if key in request and not isinstance(request[key], str):
|
|
45
46
|
raise ValueError(f"{key}_must_be_string")
|
|
47
|
+
if tool == "worktree_complete" and "merge_strategy" in request and request["merge_strategy"] not in MERGE_STRATEGIES:
|
|
48
|
+
raise ValueError("merge_strategy_invalid")
|
|
46
49
|
if tool in {"worktree_create", "worktree_complete", "worktree_cleanup", "worktree_status"} and not request.get("work_id"):
|
|
47
50
|
raise ValueError("work_id_required")
|
|
48
51
|
if tool == "worktree_create" and bool(request.get("parent_repo")) != bool(request.get("submodule_path")):
|
|
@@ -32,7 +32,7 @@ def call(tool, request):
|
|
|
32
32
|
req["transaction_id"] = __import__("uuid").uuid4().hex
|
|
33
33
|
fields = ["operation", "repo", "work_id", "confirm", "transaction_id", "base", "preserve_dirty", "discard", "discard_consent", "target_direct", "parent_repo", "submodule_path", "state_digest"]
|
|
34
34
|
if tool == "worktree_complete":
|
|
35
|
-
fields = fields + ["commit_title", "commit_body"]
|
|
35
|
+
fields = fields + ["commit_title", "commit_body", "merge_strategy"]
|
|
36
36
|
def plan_for(digest):
|
|
37
37
|
return {key: (tool if key == "operation" else digest if key == "state_digest" else bool(req.get("authorization")) if key == "confirm" else req.get(key)) for key in fields}
|
|
38
38
|
if dry:
|
|
@@ -117,8 +117,9 @@ def call(tool, request):
|
|
|
117
117
|
if lifecycle.git.run(("-C", str(path), "status", "--porcelain", "--untracked-files=all")).stdout: raise ManagerError("intervention_required", "Worktree has uncommitted changes; it was retained", 5)
|
|
118
118
|
from .commit_message import compose_commit_message
|
|
119
119
|
_subject, _body = compose_commit_message(req["work_id"], req.get("commit_title"), req.get("commit_body"))
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
strategy = req.get("merge_strategy", "squash")
|
|
121
|
+
data = {"planned": True, "work_id": req["work_id"], "transaction_id":req.get("transaction_id"), "preflight": "passed", "merge_strategy": strategy, "mutations": ["squash_merge" if strategy == "squash" else "merge_no_ff", "remove_worktree", "delete_branch"], "plan_hash":req.get("_computed_plan_hash"), "state_digest":digest, "commit_subject": _subject, "commit_body": _body}
|
|
122
|
+
else: data = lifecycle.complete(req["work_id"], req.get("base", "main"), req.get("preserve_dirty", False), req.get("transaction_id"), req.get("plan_hash"), req, merge_strategy=req.get("merge_strategy", "squash"))
|
|
122
123
|
elif tool == "worktree_cleanup":
|
|
123
124
|
if req.get("discard") and req.get("discard_consent") is not True:
|
|
124
125
|
raise ManagerError("discard_consent_required", "Separate explicit discard consent is required", 3)
|
|
@@ -6,7 +6,7 @@ from .mcp_contract import TOOL_NAMES, absolute_repository, error_envelope
|
|
|
6
6
|
from .mcp_operations import call
|
|
7
7
|
from .models import ManagerError
|
|
8
8
|
|
|
9
|
-
MCP_INSTRUCTIONS = "
|
|
9
|
+
MCP_INSTRUCTIONS = "You are going to implement a change, run a spec, or start a flow. Do you want to create a worktree to work in isolation?"
|
|
10
10
|
|
|
11
11
|
def schemas():
|
|
12
12
|
from .mcp_contract import FIELDS, REQUIRED, output_schema
|
|
@@ -17,7 +17,8 @@ def schemas():
|
|
|
17
17
|
"init_submodules":{"type":"boolean"}, "prepare":{"type":"boolean"}, "verify":{"type":"boolean"},
|
|
18
18
|
"dry_run": {"type": "boolean"}, "discard": {"type": "boolean"},
|
|
19
19
|
"discard_consent": {"type": "boolean"}, "preserve_dirty": {"type": "boolean"},
|
|
20
|
-
"commit_title": {"type": "string"}, "commit_body": {"type": "string"}
|
|
20
|
+
"commit_title": {"type": "string"}, "commit_body": {"type": "string"},
|
|
21
|
+
"merge_strategy": {"type": "string", "enum": ["squash", "preserve-history"]}}
|
|
21
22
|
props.update({"parent_repo": {"type": "string"}, "submodule_path": {"type": "string"},
|
|
22
23
|
"target_direct": {"type": "boolean"}})
|
|
23
24
|
return [{"name": name, "description": "Manage repository worktrees", "inputSchema": {
|
|
@@ -6,6 +6,7 @@ from .models import ManagerError, ResultKind
|
|
|
6
6
|
from .transaction_journal import TransactionJournal
|
|
7
7
|
from .git_repository import GitRepository
|
|
8
8
|
from .lock_manager import LockManager
|
|
9
|
+
from .worktree_lifecycle import MERGE_STRATEGIES
|
|
9
10
|
import hashlib
|
|
10
11
|
class RecoveryManager:
|
|
11
12
|
def __init__(self,state_root: str|Path,repo_root: str|Path|None=None): self.journal=TransactionJournal(state_root); self.repo=GitRepository(repo_root) if repo_root else None
|
|
@@ -61,6 +62,8 @@ class RecoveryManager:
|
|
|
61
62
|
try: meta=json.loads((self.journal.directory(transaction_id)/'meta.json').read_text(encoding='utf-8'))
|
|
62
63
|
except (OSError,ValueError) as exc: raise ManagerError('invalid_journal','Recovery metadata is corrupt; backups were retained',5) from exc
|
|
63
64
|
if meta.get('base') is None or meta.get('branch') is None or meta.get('path') is None: raise ManagerError('invalid_journal','Transaction identity is incomplete',5)
|
|
65
|
+
merge_strategy=meta.get('merge_strategy','squash')
|
|
66
|
+
if merge_strategy not in MERGE_STRATEGIES: raise ManagerError('invalid_journal','Transaction merge strategy is not recognised; resources retained',5)
|
|
64
67
|
# Preserve-dirty captures are a separate recovery contract from a
|
|
65
68
|
# durable squash. A crash can leave captured parent state that
|
|
66
69
|
# must not be silently classified as a completed close, regardless
|
|
@@ -78,13 +81,16 @@ class RecoveryManager:
|
|
|
78
81
|
branch_tip=self.repo.run(('rev-parse',f'refs/heads/{branch}'),check=False)
|
|
79
82
|
parents=self.repo.run(('rev-list','--parents','-n','1',head),check=False).stdout.split()
|
|
80
83
|
registered_paths={str(Path(item).resolve()) for item in registered}
|
|
84
|
+
expected_parents=3 if merge_strategy=='preserve-history' else 2
|
|
85
|
+
valid_parents=len(parents)==expected_parents and parents[0]==head and parents[1]==before
|
|
86
|
+
if merge_strategy=='preserve-history' and valid_parents: valid_parents=parents[2]==meta.get('origin')
|
|
81
87
|
if (base_tip.returncode or base_tip.stdout.strip()!=head or
|
|
82
88
|
str(path.resolve()) not in registered_paths or
|
|
83
89
|
worktree_head.returncode or worktree_head.stdout.strip()!=meta.get('origin') or
|
|
84
90
|
symbolic.returncode or symbolic.stdout.strip()!=f'refs/heads/{branch}' or
|
|
85
91
|
branch_tip.returncode or branch_tip.stdout.strip()!=meta.get('origin') or
|
|
86
|
-
|
|
87
|
-
raise ManagerError('intervention_required','Durable
|
|
92
|
+
not valid_parents):
|
|
93
|
+
raise ManagerError('intervention_required','Durable integration evidence is incomplete or ambiguous; resources retained',5)
|
|
88
94
|
return ResultKind.NO_PENDING_WORK, None
|
|
89
95
|
if phase in {'captured','merge_started'} and head==before:
|
|
90
96
|
# The squash commit did not happen. Restore captured destination state once.
|
|
@@ -94,8 +100,8 @@ class RecoveryManager:
|
|
|
94
100
|
lifecycle._restore_saved(transaction_id,meta)
|
|
95
101
|
self.journal.write_phase(transaction_id,'restored')
|
|
96
102
|
return ResultKind.SUCCESS,{'transaction_id':transaction_id,'phase':'restored','integrated':False}
|
|
97
|
-
|
|
98
|
-
if phase in {'merged','remove_started','removed'} and (not
|
|
103
|
+
integrated=meta.get('integrated') or meta.get('squash')
|
|
104
|
+
if phase in {'merged','remove_started','removed'} and (not integrated or head!=integrated): raise ManagerError('intervention_required','Repository HEAD drifted from the recorded integrated commit; resources retained',5)
|
|
99
105
|
if path.exists() or str(path) in registered:
|
|
100
106
|
if phase not in {'merged','remove_started'}: raise ManagerError('intervention_required','Worktree state is ambiguous; resources retained',5)
|
|
101
107
|
if self.repo.run(('-C',str(path),'status','--porcelain')).stdout: raise ManagerError('intervention_required','Worktree is dirty; resources retained',5)
|
|
@@ -21,6 +21,13 @@ def validate_work_id(value: str) -> str:
|
|
|
21
21
|
raise ManagerError('invalid_identity','Invalid work identifier',3)
|
|
22
22
|
return value
|
|
23
23
|
|
|
24
|
+
MERGE_STRATEGIES = ('squash', 'preserve-history')
|
|
25
|
+
DEFAULT_MERGE_STRATEGY = 'squash'
|
|
26
|
+
def validate_merge_strategy(value: str) -> str:
|
|
27
|
+
if value not in MERGE_STRATEGIES:
|
|
28
|
+
raise ManagerError('invalid_merge_strategy','Invalid merge strategy',3)
|
|
29
|
+
return value
|
|
30
|
+
|
|
24
31
|
class WorktreeLifecycle:
|
|
25
32
|
def __init__(self, config: Configuration):
|
|
26
33
|
self.config=config
|
|
@@ -132,15 +139,18 @@ class WorktreeLifecycle:
|
|
|
132
139
|
self.git.run(('worktree','remove',*(('--force',) if discard else ()),str(path)))
|
|
133
140
|
self.git.run(('branch','-D' if discard else '-d',branch))
|
|
134
141
|
return {'cleaned':work_id,'branch':branch,'worktree':str(path)}
|
|
135
|
-
def complete(self,work_id: str,base: str,preserve_dirty: bool=False,transaction_id: str|None=None,plan_hash: str|None=None,request:dict|None=None,commit_title=None,commit_body=None)->dict[str,str]:
|
|
136
|
-
"""
|
|
142
|
+
def complete(self,work_id: str,base: str,preserve_dirty: bool=False,transaction_id: str|None=None,plan_hash: str|None=None,request:dict|None=None,commit_title=None,commit_body=None,merge_strategy: str|None=None)->dict[str,str]:
|
|
143
|
+
"""Integrate a worktree branch and retire it only after verified success.
|
|
137
144
|
|
|
138
|
-
|
|
139
|
-
|
|
145
|
+
Squash produces one commit on the base branch. Preserve-history records
|
|
146
|
+
an explicit merge commit that keeps the branch commits. Dirty-destination
|
|
147
|
+
state is captured and restored around the integration in both strategies.
|
|
140
148
|
"""
|
|
141
149
|
branch=self._branch(work_id); base=self._base(base); path=self._path(work_id)
|
|
142
150
|
if commit_title is None and isinstance(request,dict): commit_title=request.get('commit_title')
|
|
143
151
|
if commit_body is None and isinstance(request,dict): commit_body=request.get('commit_body')
|
|
152
|
+
if merge_strategy is None and isinstance(request,dict): merge_strategy=request.get('merge_strategy')
|
|
153
|
+
merge_strategy=validate_merge_strategy(merge_strategy or DEFAULT_MERGE_STRATEGY)
|
|
144
154
|
registered={item['path'] for item in self.list()}
|
|
145
155
|
if str(path) not in registered or not path.is_dir():
|
|
146
156
|
raise ManagerError('invalid_identity','Worktree identity does not match registration',3)
|
|
@@ -153,6 +163,12 @@ class WorktreeLifecycle:
|
|
|
153
163
|
status=self.git.run(('status','--porcelain','--untracked-files=all')).stdout
|
|
154
164
|
if status and not preserve_dirty:
|
|
155
165
|
raise ManagerError('intervention_required','Destination has uncommitted changes; use --preserve-dirty to retain them',5)
|
|
166
|
+
if merge_strategy=='preserve-history':
|
|
167
|
+
ahead=self.git.run(('rev-list','--count',f'{base}..{branch}'),check=False)
|
|
168
|
+
if ahead.returncode:
|
|
169
|
+
raise ManagerError('operation_failed','Could not inspect the branch ahead of the base; nothing was merged',5)
|
|
170
|
+
if (ahead.stdout.strip() or '0')=='0':
|
|
171
|
+
raise ManagerError('operation_failed','Branch has no commits ahead of the base; nothing was merged',5)
|
|
156
172
|
state=self.state_root
|
|
157
173
|
locks=LockManager(state/self.config.lock_dir)
|
|
158
174
|
tx=transaction_id or uuid.uuid4().hex
|
|
@@ -161,7 +177,7 @@ class WorktreeLifecycle:
|
|
|
161
177
|
raise ManagerError('intervention_required','Transaction binding already exists; execute is blocked',5)
|
|
162
178
|
before=self.git.head(); origin=self.git.run(('rev-parse',f'refs/heads/{branch}')).stdout.strip()
|
|
163
179
|
meta={'work_id':work_id,'base':base,'branch':branch,'path':str(path),'before':before,'origin':origin,
|
|
164
|
-
'preserve_dirty':bool(status),'tracked_ref':None,'untracked_ref':None,
|
|
180
|
+
'preserve_dirty':bool(status),'merge_strategy':merge_strategy,'tracked_ref':None,'untracked_ref':None,
|
|
165
181
|
'backup_ref_prefix':self.config.backup_ref_prefix}
|
|
166
182
|
meta_path=journal.directory(tx)/'meta.json'
|
|
167
183
|
def save_meta(phase):
|
|
@@ -182,6 +198,7 @@ class WorktreeLifecycle:
|
|
|
182
198
|
try:
|
|
183
199
|
journal.write_binding(tx, {'transaction_id':tx,'repo':str(self.config.repo_root.resolve()),'work_id':work_id,
|
|
184
200
|
'operation':'worktree_complete','request':request or {},'plan_hash':plan_hash,
|
|
201
|
+
'merge_strategy':merge_strategy,
|
|
185
202
|
'state_digest':__import__('worktree_manager.state_digest',fromlist=['state_digest']).state_digest(self.config.repo_root,state),
|
|
186
203
|
'created_at':__import__('datetime').datetime.now(__import__('datetime').timezone.utc).isoformat(),
|
|
187
204
|
'phase':'prepared','base':base,'before':before,'origin':origin})
|
|
@@ -215,19 +232,33 @@ class WorktreeLifecycle:
|
|
|
215
232
|
meta['commit_subject']=subject
|
|
216
233
|
meta['commit_body']=body
|
|
217
234
|
save_meta('merge_started')
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
self.git.run(('
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
235
|
+
if merge_strategy=='preserve-history':
|
|
236
|
+
message=subject+('\n\n'+body if body else '')
|
|
237
|
+
merged=self.git.run(('merge','--no-ff','-m',message,branch),check=False)
|
|
238
|
+
if merged.returncode:
|
|
239
|
+
self.git.run(('merge','--abort'),check=False)
|
|
240
|
+
save_meta('pending_manual')
|
|
241
|
+
raise ManagerError('operation_failed','Preserve-history merge failed; worktree and branch retained',5)
|
|
242
|
+
after=self.git.head()
|
|
243
|
+
parents=self.git.run(('rev-list','--parents','-n','1',after)).stdout.split()
|
|
244
|
+
if after==before or len(parents)!=3 or parents[0]!=after or parents[1]!=before or parents[2]!=origin:
|
|
245
|
+
save_meta('pending_manual'); raise ManagerError('operation_failed','Merge commit verification failed; resources retained',5)
|
|
246
|
+
else:
|
|
247
|
+
merged=self.git.run(('merge','--squash',branch),check=False)
|
|
248
|
+
if merged.returncode or self.git.run(('diff','--cached','--quiet'),check=False).returncode==0:
|
|
249
|
+
self.git.run(('reset','--merge'),check=False)
|
|
250
|
+
save_meta('pending_manual')
|
|
251
|
+
raise ManagerError('operation_failed','Squash merge failed or produced no changes; worktree and branch retained',5)
|
|
252
|
+
commit_args=['commit','-m',subject]
|
|
253
|
+
if body:
|
|
254
|
+
commit_args += ['-m',body]
|
|
255
|
+
self.git.run(tuple(commit_args))
|
|
256
|
+
after=self.git.head()
|
|
257
|
+
if after==before or len(self.git.run(('rev-list','--parents','-n','1',after)).stdout.split())!=2:
|
|
258
|
+
save_meta('pending_manual'); raise ManagerError('operation_failed','Squash commit verification failed; resources retained',5)
|
|
259
|
+
meta['integrated']=after
|
|
260
|
+
if merge_strategy=='squash': meta['squash']=after
|
|
261
|
+
save_meta('merged')
|
|
231
262
|
self.git.run(('worktree','remove','--force','--',str(path)))
|
|
232
263
|
if path.exists() or str(path) in {item['path'] for item in self.list()}:
|
|
233
264
|
save_meta('pending_manual'); raise ManagerError('operation_failed','Worktree removal could not be verified',5)
|
|
@@ -236,10 +267,16 @@ class WorktreeLifecycle:
|
|
|
236
267
|
save_meta('pending_manual'); raise ManagerError('operation_failed','Branch changed; it was retained',5)
|
|
237
268
|
self.git.run(('update-ref','-d',f'refs/heads/{branch}',origin)); save_meta('closed')
|
|
238
269
|
if status:
|
|
239
|
-
meta['
|
|
270
|
+
meta['integrated']=after
|
|
271
|
+
if merge_strategy=='squash': meta['squash']=after
|
|
272
|
+
save_meta('merged')
|
|
240
273
|
self._restore_saved(tx,meta)
|
|
241
274
|
save_meta('restored')
|
|
242
|
-
|
|
275
|
+
result={'completed':work_id,'branch':branch,'base_branch':base,'worktree':str(path),
|
|
276
|
+
'integrated_commit':after,'merge_strategy':merge_strategy,
|
|
277
|
+
'transaction_id':tx,'commit_subject':subject,'commit_body':body}
|
|
278
|
+
if merge_strategy=='squash': result['squash_commit']=after
|
|
279
|
+
return result
|
|
243
280
|
finally: locks.release()
|
|
244
281
|
|
|
245
282
|
def _restore_saved(self,tx,meta):
|