worktree-for-agents 0.0.0-stage → 1.0.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 +45 -0
- package/README.md +180 -2
- package/docs/installation.md +214 -0
- package/docs/mcp-contract.md +78 -0
- package/mcp-server.mjs +54 -0
- package/package.json +37 -4
- package/scripts/postinstall.mjs +11 -0
- package/scripts/python.mjs +69 -0
- package/setup.mjs +208 -0
- package/worktree.mjs +47 -0
- package/worktree.sh +18 -0
- package/worktree_manager/__init__.py +2 -0
- package/worktree_manager/__main__.py +2 -0
- package/worktree_manager/backup_store.py +16 -0
- package/worktree_manager/cli.py +137 -0
- package/worktree_manager/commit_message.py +63 -0
- package/worktree_manager/configuration.py +61 -0
- package/worktree_manager/git_repository.py +72 -0
- package/worktree_manager/lock_manager.py +50 -0
- package/worktree_manager/mcp_contract.py +93 -0
- package/worktree_manager/mcp_operations.py +171 -0
- package/worktree_manager/mcp_server.py +61 -0
- package/worktree_manager/mcp_transport.py +5 -0
- package/worktree_manager/models.py +69 -0
- package/worktree_manager/output.py +11 -0
- package/worktree_manager/recovery_manager.py +114 -0
- package/worktree_manager/state_digest.py +40 -0
- package/worktree_manager/target_direct_adapter.py +197 -0
- package/worktree_manager/transaction_journal.py +36 -0
- package/worktree_manager/worktree_lifecycle.py +274 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the Python interpreter used by the packaged launchers.
|
|
5
|
+
* Order: WORKTREE_MANAGER_PYTHON override, then platform defaults
|
|
6
|
+
* (POSIX python3; win32 python then python3). Returns the binary name
|
|
7
|
+
* or absolute path, or null when no candidate exists.
|
|
8
|
+
*/
|
|
9
|
+
export function candidates() {
|
|
10
|
+
const override = process.env.WORKTREE_MANAGER_PYTHON;
|
|
11
|
+
if (override && override.trim() !== "") {
|
|
12
|
+
return [override];
|
|
13
|
+
}
|
|
14
|
+
if (process.platform === "win32") {
|
|
15
|
+
return ["python", "python3"];
|
|
16
|
+
}
|
|
17
|
+
return ["python3"];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Probe a candidate interpreter with `import sys` version output.
|
|
22
|
+
* Returns { bin, version } on Python >= 3.9, or { bin, version, cause }
|
|
23
|
+
* when the interpreter is missing or too old.
|
|
24
|
+
*/
|
|
25
|
+
export function probeCandidate(bin) {
|
|
26
|
+
const result = spawnSync(bin, ["-c", "import sys; print('%d.%d' % sys.version_info[:2])"], {
|
|
27
|
+
encoding: "utf8",
|
|
28
|
+
});
|
|
29
|
+
if (result.error || result.status !== 0) {
|
|
30
|
+
return { bin, version: null, cause: "not_found" };
|
|
31
|
+
}
|
|
32
|
+
const version = String(result.stdout || "").trim();
|
|
33
|
+
const match = /^(\d+)\.(\d+)$/.exec(version);
|
|
34
|
+
if (!match) {
|
|
35
|
+
return { bin, version: null, cause: "unparseable_version" };
|
|
36
|
+
}
|
|
37
|
+
const major = Number(match[1]);
|
|
38
|
+
const minor = Number(match[2]);
|
|
39
|
+
if (major < 3 || (major === 3 && minor < 9)) {
|
|
40
|
+
return { bin, version, cause: "too_old" };
|
|
41
|
+
}
|
|
42
|
+
return { bin, version };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve and probe in order. Returns the first usable interpreter
|
|
47
|
+
* as { bin, version }, or { bin: null, version: null, cause } when
|
|
48
|
+
* none is usable. Never throws for a missing interpreter.
|
|
49
|
+
*/
|
|
50
|
+
export function probePython() {
|
|
51
|
+
let last = { bin: null, version: null, cause: "not_found" };
|
|
52
|
+
for (const bin of candidates()) {
|
|
53
|
+
const probed = probeCandidate(bin);
|
|
54
|
+
if (probed.version && !probed.cause) {
|
|
55
|
+
return { bin: probed.bin, version: probed.version };
|
|
56
|
+
}
|
|
57
|
+
last = { bin: probed.bin, version: probed.version, cause: probed.cause || "not_found" };
|
|
58
|
+
}
|
|
59
|
+
return last;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the interpreter binary name without probing.
|
|
64
|
+
* Returns the first candidate, or null when none exists.
|
|
65
|
+
*/
|
|
66
|
+
export function resolvePython() {
|
|
67
|
+
const list = candidates();
|
|
68
|
+
return list.length > 0 ? list[0] : null;
|
|
69
|
+
}
|
package/setup.mjs
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, copyFileSync, renameSync, mkdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir, homedir } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { mkdtempSync } from "node:fs";
|
|
6
|
+
import { probePython } from "./scripts/python.mjs";
|
|
7
|
+
|
|
8
|
+
const packageDir = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
|
|
10
|
+
function readPackage() {
|
|
11
|
+
const raw = readFileSync(join(packageDir, "package.json"), "utf8");
|
|
12
|
+
return JSON.parse(raw);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function globalFlowtaskDir() {
|
|
16
|
+
if (process.platform === "win32") {
|
|
17
|
+
const base = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
|
|
18
|
+
return join(base, "FlowTask", "worktree-manager");
|
|
19
|
+
}
|
|
20
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
21
|
+
const base = xdg ? join(xdg, "flowtask") : join(homedir(), ".config", "flowtask");
|
|
22
|
+
return join(base, "worktree-manager");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function opencodeConfigCandidates() {
|
|
26
|
+
if (process.platform === "win32") {
|
|
27
|
+
const base = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
|
|
28
|
+
return [join(base, "opencode", "opencode.json")];
|
|
29
|
+
}
|
|
30
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
31
|
+
if (xdg) {
|
|
32
|
+
return [join(xdg, "opencode", "opencode.json")];
|
|
33
|
+
}
|
|
34
|
+
return [join(homedir(), ".config", "opencode", "opencode.json")];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function checkoutStatus(dir) {
|
|
38
|
+
if (!dir || !existsSync(join(dir, "worktree_manager", "__main__.py"))) {
|
|
39
|
+
return { present: false };
|
|
40
|
+
}
|
|
41
|
+
const transport = join(dir, "worktree_manager", "mcp_transport.py");
|
|
42
|
+
return { present: true, mcpTransport: existsSync(transport) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function collectStatus() {
|
|
46
|
+
const pkg = readPackage();
|
|
47
|
+
const probed = probePython();
|
|
48
|
+
const npmPackage = {
|
|
49
|
+
kind: "npm package",
|
|
50
|
+
path: packageDir,
|
|
51
|
+
version: pkg.version,
|
|
52
|
+
active: true,
|
|
53
|
+
};
|
|
54
|
+
const globalDir = globalFlowtaskDir();
|
|
55
|
+
const globalStatus = checkoutStatus(globalDir);
|
|
56
|
+
const globalCopy = {
|
|
57
|
+
kind: "FlowTask global copy",
|
|
58
|
+
path: globalDir,
|
|
59
|
+
present: globalStatus.present || false,
|
|
60
|
+
};
|
|
61
|
+
const submoduleDir = resolve(join(process.cwd(), ".flowtask", "scripts", "worktree-manager"));
|
|
62
|
+
const submoduleStatus = checkoutStatus(submoduleDir);
|
|
63
|
+
const submodule = {
|
|
64
|
+
kind: "local submodule",
|
|
65
|
+
path: submoduleDir,
|
|
66
|
+
present: submoduleStatus.present || false,
|
|
67
|
+
};
|
|
68
|
+
const configPaths = opencodeConfigCandidates();
|
|
69
|
+
const configPath = configPaths[0];
|
|
70
|
+
let opencode = { path: configPath, present: existsSync(configPath), entries: {} };
|
|
71
|
+
if (opencode.present) {
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(readFileSync(configPath, "utf8"));
|
|
74
|
+
const mcp = (parsed && parsed.mcp) || {};
|
|
75
|
+
opencode.entries = mcp;
|
|
76
|
+
} catch {
|
|
77
|
+
opencode.parseError = true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const expectedCommand = ["node", join(packageDir, "mcp-server.mjs")];
|
|
81
|
+
let activeEntry = "npm package";
|
|
82
|
+
const refsThisPackage = [];
|
|
83
|
+
for (const [name, value] of Object.entries(opencode.entries || {})) {
|
|
84
|
+
const cmd = value && value.command;
|
|
85
|
+
if (Array.isArray(cmd) && cmd.length === 2 && cmd[0] === "node" && typeof cmd[1] === "string") {
|
|
86
|
+
if (resolve(cmd[1]) === resolve(expectedCommand[1])) {
|
|
87
|
+
refsThisPackage.push(name);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
version: pkg.version,
|
|
93
|
+
packagePath: packageDir,
|
|
94
|
+
interpreter: probed,
|
|
95
|
+
installations: [npmPackage, globalCopy, submodule],
|
|
96
|
+
active: activeEntry,
|
|
97
|
+
opencode,
|
|
98
|
+
expectedCommand,
|
|
99
|
+
refsThisPackage,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function printHuman(status) {
|
|
104
|
+
const lines = [];
|
|
105
|
+
lines.push(`worktree-for-agents ${status.version}`);
|
|
106
|
+
lines.push(`package: ${status.packagePath}`);
|
|
107
|
+
if (status.interpreter.version) {
|
|
108
|
+
lines.push(`python: ${status.interpreter.bin} ${status.interpreter.version}`);
|
|
109
|
+
} else {
|
|
110
|
+
lines.push(`python: not found (requires 3.9+)`);
|
|
111
|
+
}
|
|
112
|
+
lines.push("installations:");
|
|
113
|
+
for (const inst of status.installations) {
|
|
114
|
+
if (inst.kind === "npm package") {
|
|
115
|
+
lines.push(` - npm package: ${inst.path} (version ${inst.version}, active)`);
|
|
116
|
+
} else {
|
|
117
|
+
lines.push(` - ${inst.kind}: ${inst.path} (${inst.present ? "present" : "absent"})`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
lines.push(`active: ${status.active}`);
|
|
121
|
+
lines.push(`opencode config: ${status.opencode.path} (${status.opencode.present ? "present" : "absent"})`);
|
|
122
|
+
const names = Object.keys(status.opencode.entries || {});
|
|
123
|
+
if (names.length === 0) {
|
|
124
|
+
lines.push("mcp entries: (none)");
|
|
125
|
+
} else {
|
|
126
|
+
lines.push("mcp entries:");
|
|
127
|
+
for (const name of names) {
|
|
128
|
+
const marker = status.refsThisPackage.includes(name) ? " (references this package)" : "";
|
|
129
|
+
lines.push(` - ${name}${marker}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function deepEqual(a, b) {
|
|
136
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function runWriteOpencode() {
|
|
140
|
+
const status = collectStatus();
|
|
141
|
+
const candidates = opencodeConfigCandidates();
|
|
142
|
+
const configPath = candidates[0];
|
|
143
|
+
const configDir = dirname(configPath);
|
|
144
|
+
if (!existsSync(configDir)) {
|
|
145
|
+
process.stderr.write(
|
|
146
|
+
`OpenCode config directory does not exist: ${configDir}. Create it manually or run OpenCode once, then rerun with --write-opencode.\n`
|
|
147
|
+
);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
let parsed = {};
|
|
151
|
+
let raw = null;
|
|
152
|
+
if (existsSync(configPath)) {
|
|
153
|
+
raw = readFileSync(configPath, "utf8");
|
|
154
|
+
try {
|
|
155
|
+
parsed = JSON.parse(raw);
|
|
156
|
+
} catch {
|
|
157
|
+
process.stderr.write(`OpenCode config is not valid JSON: ${configPath}. Fix it manually and rerun.\n`);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
162
|
+
parsed = {};
|
|
163
|
+
}
|
|
164
|
+
if (!parsed.mcp || typeof parsed.mcp !== "object" || Array.isArray(parsed.mcp)) {
|
|
165
|
+
parsed.mcp = {};
|
|
166
|
+
}
|
|
167
|
+
const expected = { type: "local", command: ["node", join(packageDir, "mcp-server.mjs")], enabled: true };
|
|
168
|
+
const existing = parsed.mcp["worktree-for-agents"];
|
|
169
|
+
if (existing !== undefined) {
|
|
170
|
+
if (deepEqual(existing, expected)) {
|
|
171
|
+
process.stdout.write("opencode.json already configured for worktree-for-agents.\n");
|
|
172
|
+
process.exit(0);
|
|
173
|
+
}
|
|
174
|
+
process.stderr.write(
|
|
175
|
+
`Refusing to overwrite mcp.worktree-for-agents: existing value differs from expected.\nExisting: ${JSON.stringify(existing)}\nExpected: ${JSON.stringify(expected)}\n`
|
|
176
|
+
);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
parsed.mcp["worktree-for-agents"] = expected;
|
|
180
|
+
const next = JSON.stringify(parsed, null, 2) + "\n";
|
|
181
|
+
if (existsSync(configPath)) {
|
|
182
|
+
copyFileSync(configPath, `${configPath}.bak`);
|
|
183
|
+
}
|
|
184
|
+
const stagingDir = mkdtempSync(join(tmpdir(), "worktree-for-agents-"));
|
|
185
|
+
try {
|
|
186
|
+
const staging = join(stagingDir, "opencode.json");
|
|
187
|
+
writeFileSync(staging, next, "utf8");
|
|
188
|
+
JSON.parse(readFileSync(staging, "utf8"));
|
|
189
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
190
|
+
renameSync(staging, configPath);
|
|
191
|
+
JSON.parse(readFileSync(configPath, "utf8"));
|
|
192
|
+
} finally {
|
|
193
|
+
try {
|
|
194
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
195
|
+
} catch {}
|
|
196
|
+
}
|
|
197
|
+
process.stdout.write(`Registered mcp.worktree-for-agents in ${configPath}.\n`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const args = new Set(process.argv.slice(2));
|
|
201
|
+
if (args.has("--write-opencode")) {
|
|
202
|
+
runWriteOpencode();
|
|
203
|
+
} else if (args.has("--json")) {
|
|
204
|
+
const status = collectStatus();
|
|
205
|
+
process.stdout.write(JSON.stringify(status, null, 2) + "\n");
|
|
206
|
+
} else {
|
|
207
|
+
printHuman(collectStatus());
|
|
208
|
+
}
|
package/worktree.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { probePython } from "./scripts/python.mjs";
|
|
6
|
+
|
|
7
|
+
const packageDir = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const entry = join(packageDir, "worktree_manager", "__main__.py");
|
|
9
|
+
|
|
10
|
+
if (!existsSync(entry)) {
|
|
11
|
+
process.stderr.write(
|
|
12
|
+
"worktree-for-agents is incomplete: missing worktree_manager/__main__.py. Reinstall the package.\n"
|
|
13
|
+
);
|
|
14
|
+
process.exit(126);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const probed = probePython();
|
|
18
|
+
if (!probed.version) {
|
|
19
|
+
process.stderr.write(
|
|
20
|
+
"worktree-for-agents requires Python 3.9 or later. Install Python 3.9+ and rerun the installation.\n"
|
|
21
|
+
);
|
|
22
|
+
process.exit(127);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const env = {
|
|
26
|
+
...process.env,
|
|
27
|
+
PYTHONPATH: packageDir + (process.env.PYTHONPATH ? `:${process.env.PYTHONPATH}` : ""),
|
|
28
|
+
};
|
|
29
|
+
const child = spawn(probed.bin, ["-m", "worktree_manager", ...process.argv.slice(2)], {
|
|
30
|
+
cwd: packageDir,
|
|
31
|
+
env,
|
|
32
|
+
stdio: "inherit",
|
|
33
|
+
});
|
|
34
|
+
const forward = (signal) => {
|
|
35
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
36
|
+
child.kill(signal);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
process.on("SIGINT", () => forward("SIGINT"));
|
|
40
|
+
process.on("SIGTERM", () => forward("SIGTERM"));
|
|
41
|
+
child.on("exit", (code, signal) => {
|
|
42
|
+
if (signal) {
|
|
43
|
+
process.kill(process.pid, signal);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
process.exit(code === null ? 1 : code);
|
|
47
|
+
});
|
package/worktree.sh
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
|
|
3
|
+
set -eu
|
|
4
|
+
|
|
5
|
+
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
6
|
+
if ! command -v python3 >/dev/null 2>&1; then
|
|
7
|
+
printf '%s\n' 'worktree manager requires Python 3 (python3) on PATH; install Python 3 and retry.' >&2
|
|
8
|
+
exit 127
|
|
9
|
+
fi
|
|
10
|
+
|
|
11
|
+
if [ ! -f "$script_dir/worktree_manager/__main__.py" ]; then
|
|
12
|
+
printf '%s\n' "worktree manager Python entry point is missing: $script_dir/worktree_manager/__main__.py" >&2
|
|
13
|
+
exit 126
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
PYTHONPATH="$script_dir${PYTHONPATH+:$PYTHONPATH}"
|
|
17
|
+
export PYTHONPATH
|
|
18
|
+
exec python3 -m worktree_manager "$@"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Retained Git refs; pending backups are never automatically pruned."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from .git_repository import GitRepository
|
|
4
|
+
from .models import ManagerError
|
|
5
|
+
class BackupStore:
|
|
6
|
+
def __init__(self,repository: GitRepository,backup_ref_prefix: str):
|
|
7
|
+
self.repository,self.prefix=repository,backup_ref_prefix.strip('/')
|
|
8
|
+
if not self.prefix or '..' in self.prefix.split('/'): raise ManagerError('invalid_configuration','Unsafe backup ref prefix',3)
|
|
9
|
+
def ref(self,tx: str,suffix: str='')->str:
|
|
10
|
+
if not tx or '/' in tx: raise ManagerError('invalid_identity','Invalid transaction identifier',3)
|
|
11
|
+
return f'refs/{self.prefix}/{tx}{suffix}'
|
|
12
|
+
def retain(self,tx: str,oid: str,suffix: str='')->None: self.repository.run(('update-ref',self.ref(tx,suffix),oid))
|
|
13
|
+
def resolve(self,tx: str,suffix: str='')->str|None:
|
|
14
|
+
result=self.repository.run(('rev-parse','--verify','--quiet',self.ref(tx,suffix)),check=False)
|
|
15
|
+
return result.stdout.strip() if result.returncode==0 else None
|
|
16
|
+
def delete(self,tx: str,suffix: str='')->None: self.repository.run(('update-ref','-d',self.ref(tx,suffix)),check=False)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Standalone Python command dispatcher."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import argparse,sys,subprocess,json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from .configuration import Configuration
|
|
6
|
+
from .git_repository import GitRepository
|
|
7
|
+
from .models import ManagerError,OperationResult,ResultKind
|
|
8
|
+
from .output import emit
|
|
9
|
+
from .worktree_lifecycle import WorktreeLifecycle
|
|
10
|
+
from .recovery_manager import RecoveryManager
|
|
11
|
+
from .target_direct_adapter import TargetDirectAdapter
|
|
12
|
+
from . import __version__ as VERSION
|
|
13
|
+
class _Parser(argparse.ArgumentParser):
|
|
14
|
+
def error(self, message):
|
|
15
|
+
raise ManagerError('usage_error',message,2)
|
|
16
|
+
def _parser():
|
|
17
|
+
parser=_Parser(prog='python3 -m worktree_manager'); parser.add_argument('--json',action='store_true'); parser.add_argument('--version',action='version',version=VERSION); parser.add_argument('--repo'); parser.add_argument('--config'); sub=parser.add_subparsers(dest='command',required=True,parser_class=_Parser)
|
|
18
|
+
for name in ('create','complete','cleanup'):
|
|
19
|
+
p=sub.add_parser(name); p.add_argument('work_id'); p.add_argument('--base');
|
|
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')
|
|
22
|
+
if name=='create':
|
|
23
|
+
p.add_argument('--init-submodules',action='store_true'); p.add_argument('--prepare',action='store_true'); p.add_argument('--verify',action='store_true')
|
|
24
|
+
p=sub.add_parser('recover'); p.add_argument('transaction_id',nargs='?')
|
|
25
|
+
sub.add_parser('list'); sub.add_parser('prune')
|
|
26
|
+
td=sub.add_parser('target-direct'); td.add_argument('action',choices=('create','link-parent','abort','complete','recover')); td.add_argument('work_id'); td.add_argument('--parent-repo',required=True); td.add_argument('--target',choices=('submodule',),default='submodule'); td.add_argument('--submodule-path'); td.add_argument('--base',default='main'); td.add_argument('--link-parent',choices=('staged-only',),default='staged-only'); td.add_argument('--descriptor')
|
|
27
|
+
return parser
|
|
28
|
+
|
|
29
|
+
def _request_json_mode(raw_argv):
|
|
30
|
+
"""Serve the canonical protocol envelope for `--request-json` invocations.
|
|
31
|
+
|
|
32
|
+
Success and failure publish the same nine-key envelope used by the MCP
|
|
33
|
+
transport, with or without `--json`, so a client never has to parse two
|
|
34
|
+
different document shapes.
|
|
35
|
+
"""
|
|
36
|
+
from .mcp_operations import call
|
|
37
|
+
from .mcp_contract import TOOL_NAMES,absolute_repository,error_envelope
|
|
38
|
+
pos=raw_argv.index('--request-json')
|
|
39
|
+
request=None; tool=None
|
|
40
|
+
def fail(code,message,exit_code):
|
|
41
|
+
operation=tool
|
|
42
|
+
if not operation and isinstance(request,dict) and isinstance(request.get('operation'),str):
|
|
43
|
+
operation=request['operation']
|
|
44
|
+
operation=operation or 'request-json'
|
|
45
|
+
repository=absolute_repository(request.get('repo')) if isinstance(request,dict) else ''
|
|
46
|
+
document=error_envelope(operation,repository,code,message,exit_code)
|
|
47
|
+
sys.stdout.write(json.dumps(document,sort_keys=True,separators=(',',':'))+'\n')
|
|
48
|
+
return exit_code
|
|
49
|
+
extra=[arg for index,arg in enumerate(raw_argv) if index not in (pos,pos+1) and arg!='--json']
|
|
50
|
+
if pos+1>=len(raw_argv) or extra:
|
|
51
|
+
return fail('usage_error','--request-json must be used alone (optionally with --json)',2)
|
|
52
|
+
source=raw_argv[pos+1]
|
|
53
|
+
try:
|
|
54
|
+
text=sys.stdin.read() if source=='-' else Path(source).read_text(encoding='utf-8')
|
|
55
|
+
request=json.loads(text)
|
|
56
|
+
if not isinstance(request,dict) or not isinstance(request.get('operation'),str): raise ValueError('operation_required')
|
|
57
|
+
# The protocol request is passed unchanged to the same strict
|
|
58
|
+
# validator and lifecycle façade used by MCP.
|
|
59
|
+
raw=request['operation']
|
|
60
|
+
tool=raw if raw in TOOL_NAMES else ('worktree_'+raw if 'worktree_'+raw in TOOL_NAMES else None)
|
|
61
|
+
if tool is None: raise ValueError('unknown_tool')
|
|
62
|
+
payload=dict(request); payload.pop('operation')
|
|
63
|
+
result=call(tool,payload)
|
|
64
|
+
sys.stdout.write(json.dumps(result,sort_keys=True,separators=(',',':'))+'\n')
|
|
65
|
+
error=result.get('error')
|
|
66
|
+
return 0 if not error else error.get('exit_code',5)
|
|
67
|
+
except ManagerError as exc:
|
|
68
|
+
return fail(exc.code,str(exc),exc.exit_code)
|
|
69
|
+
except (ValueError,OSError) as exc:
|
|
70
|
+
return fail('invalid_request',str(exc),3)
|
|
71
|
+
def main(argv=None)->int:
|
|
72
|
+
operation='usage'; json_mode='--json' in (argv if argv is not None else sys.argv[1:])
|
|
73
|
+
try:
|
|
74
|
+
raw_argv = list(argv if argv is not None else sys.argv[1:])
|
|
75
|
+
if '--request-json' in raw_argv:
|
|
76
|
+
return _request_json_mode(raw_argv)
|
|
77
|
+
if '--version' in raw_argv and any(arg not in {'--version', '--json'} for arg in raw_argv):
|
|
78
|
+
raise ManagerError('usage_error', "unrecognized arguments: extra arguments with --version", 2)
|
|
79
|
+
args=_parser().parse_args(argv); operation=args.command
|
|
80
|
+
if operation=='target-direct':
|
|
81
|
+
target=Path(args.repo).expanduser().resolve() if args.repo else Path.cwd().resolve()
|
|
82
|
+
if not args.descriptor:
|
|
83
|
+
common=Path(subprocess.run(['git','-C',str(target),'rev-parse','--git-common-dir'],check=True,text=True,stdout=subprocess.PIPE).stdout.strip())
|
|
84
|
+
if not common.is_absolute(): common=(target/common).resolve()
|
|
85
|
+
import hashlib
|
|
86
|
+
descriptor_id=args.work_id[3:] if args.action=='recover' and args.work_id.startswith('wt-') else args.work_id
|
|
87
|
+
args.descriptor=str(common/'worktree-manager'/'target-direct'/hashlib.sha256(descriptor_id.encode()).hexdigest()/'descriptor')
|
|
88
|
+
if args.action=='create':
|
|
89
|
+
if not args.submodule_path: raise ManagerError('invalid_identity','--submodule-path is required',2)
|
|
90
|
+
adapter=TargetDirectAdapter.create(args.descriptor,target,args.parent_repo,args.submodule_path,args.work_id,args.base)
|
|
91
|
+
data={'created':True,'worktree':adapter.values['worktree_path'],'branch':adapter.values['branch'],'descriptor':str(adapter.path)}
|
|
92
|
+
else:
|
|
93
|
+
adapter=TargetDirectAdapter(args.descriptor)
|
|
94
|
+
if adapter.values['target_repo']!=str(target) or adapter.values['parent_repo']!=str(Path(args.parent_repo).resolve()) or adapter.values['tx_id'] not in {args.work_id,'wt-'+args.work_id}:
|
|
95
|
+
raise ManagerError('invalid_descriptor','Target descriptor identity does not match command arguments',3)
|
|
96
|
+
data={'link-parent':adapter.link_parent,'abort':adapter.abort,'complete':adapter.complete,'recover':adapter.recover}[args.action]()
|
|
97
|
+
result=OperationResult('target-direct',data=data); emit(result,args.json)
|
|
98
|
+
# The established target-direct command reports abort through its
|
|
99
|
+
# dedicated public status (6), including a successfully restored
|
|
100
|
+
# index; the result envelope remains success in that case.
|
|
101
|
+
return 6 if args.action=='abort' and result.exit_code==0 else result.exit_code
|
|
102
|
+
root=Path(args.repo).expanduser() if args.repo else Path.cwd()
|
|
103
|
+
if not root.is_absolute(): raise ManagerError('invalid_repository','Repository path must be absolute',3)
|
|
104
|
+
repository=GitRepository(root)
|
|
105
|
+
if operation=='recover':
|
|
106
|
+
config=Configuration.load(repository.root,args.config)
|
|
107
|
+
git_dir=Path(repository.run(('rev-parse','--git-dir')).stdout.strip())
|
|
108
|
+
if not git_dir.is_absolute(): git_dir=(repository.root/git_dir).resolve()
|
|
109
|
+
kind,data=RecoveryManager(git_dir/config.state_dir,repository.root).recover(args.transaction_id)
|
|
110
|
+
result=OperationResult(operation,kind,data or {})
|
|
111
|
+
else:
|
|
112
|
+
config=Configuration.load(repository.root,args.config); lifecycle=WorktreeLifecycle(config)
|
|
113
|
+
if operation=='list': result=OperationResult(operation,data={'worktrees':lifecycle.list()})
|
|
114
|
+
elif operation=='prune':
|
|
115
|
+
data=lifecycle.prune()
|
|
116
|
+
result=OperationResult(operation,ResultKind.NO_PENDING_WORK if data.get('pruned',0)==0 else ResultKind.SUCCESS,data=data)
|
|
117
|
+
elif operation=='create':
|
|
118
|
+
base=args.base or next((b for b in ('development','main','trunk') if repository.run(('show-ref','--verify','--quiet',f'refs/heads/{b}'),check=False).returncode==0),'main')
|
|
119
|
+
data=lifecycle.create(args.work_id,base,init_submodules=args.init_submodules,prepare=args.prepare,verify=args.verify)
|
|
120
|
+
result=OperationResult(operation,data=data)
|
|
121
|
+
elif operation=='cleanup': result=OperationResult(operation,data=lifecycle.cleanup(args.work_id,args.discard,args.base or 'main'))
|
|
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)
|
|
124
|
+
data['merge']='success'
|
|
125
|
+
result=OperationResult(operation,data=data)
|
|
126
|
+
if operation=='list' and not args.json:
|
|
127
|
+
sys.stdout.write(repository.run(('worktree','list','--porcelain')).stdout)
|
|
128
|
+
return result.exit_code
|
|
129
|
+
emit(result,args.json); return result.exit_code
|
|
130
|
+
except ManagerError as exc:
|
|
131
|
+
if exc.code == 'resource_locked':
|
|
132
|
+
exc = ManagerError('operation_failed', exc.message, 5)
|
|
133
|
+
emit(OperationResult(operation,ResultKind.PENDING_INTERVENTION if exc.code=='intervention_required' else ResultKind.FAILURE,error=exc),getattr(locals().get('args',None),'json',json_mode)); return exc.exit_code
|
|
134
|
+
|
|
135
|
+
def usage(argv=None)->int:
|
|
136
|
+
try: return main(argv)
|
|
137
|
+
except SystemExit as exc: return int(exc.code)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Composition of worktree closure commit messages.
|
|
2
|
+
|
|
3
|
+
This module owns the deterministic transformation from an optional title and
|
|
4
|
+
body supplied by the caller into the subject and body written to the destination
|
|
5
|
+
repository. The neutral (English) fallback used when no title is supplied is
|
|
6
|
+
defined here so that a standalone closure never produces an empty commit message.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Neutral fallback used by the standalone manager. The consuming project may
|
|
14
|
+
# override the wording by supplying commit_title/commit_body.
|
|
15
|
+
GENERIC_SUBJECT = "Complete {work_id} (generic closure)"
|
|
16
|
+
GENERIC_BODY = "No delivery description was available at closure time."
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _normalize_title(value):
|
|
20
|
+
if value is None:
|
|
21
|
+
return ""
|
|
22
|
+
text = str(value)
|
|
23
|
+
# Replace line breaks and null bytes with spaces, then collapse whitespace.
|
|
24
|
+
text = text.replace("\r", " ").replace("\n", " ").replace("\x00", " ")
|
|
25
|
+
text = re.sub(r"\s+", " ", text)
|
|
26
|
+
return text.strip()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _normalize_body(value):
|
|
30
|
+
if value is None:
|
|
31
|
+
return ""
|
|
32
|
+
text = str(value)
|
|
33
|
+
# Normalize line endings and trim trailing whitespace per line.
|
|
34
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
35
|
+
lines = [line.rstrip(" \t") for line in text.split("\n")]
|
|
36
|
+
# Drop trailing blank lines.
|
|
37
|
+
while len(lines) > 1 and lines[-1] == "":
|
|
38
|
+
lines.pop()
|
|
39
|
+
return "\n".join(lines)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def compose_commit_message(work_id, title=None, body=None):
|
|
43
|
+
"""Return (subject, body) for the closure commit.
|
|
44
|
+
|
|
45
|
+
The subject always contains the work_id: when the supplied title does not
|
|
46
|
+
include it, it is appended as ' [work_id]'. An empty or whitespace-only
|
|
47
|
+
title is treated as absent and replaced by the neutral fallback subject.
|
|
48
|
+
"""
|
|
49
|
+
normalized_title = _normalize_title(title)
|
|
50
|
+
normalized_body = _normalize_body(body)
|
|
51
|
+
|
|
52
|
+
if not normalized_title:
|
|
53
|
+
subject = GENERIC_SUBJECT.format(work_id=work_id)
|
|
54
|
+
# No title means no delivery description: use the neutral body.
|
|
55
|
+
final_body = GENERIC_BODY
|
|
56
|
+
elif work_id in normalized_title:
|
|
57
|
+
subject = normalized_title
|
|
58
|
+
final_body = normalized_body
|
|
59
|
+
else:
|
|
60
|
+
subject = f"{normalized_title} [{work_id}]"
|
|
61
|
+
final_body = normalized_body
|
|
62
|
+
|
|
63
|
+
return subject, final_body
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Validated configuration matching the standalone key=value configuration."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from .models import ManagerError
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Configuration:
|
|
10
|
+
repo_root: Path
|
|
11
|
+
worktrees_dir: str = '.worktrees'
|
|
12
|
+
branch_prefix: str = 'worktree/'
|
|
13
|
+
state_dir: str = 'worktree-manager'
|
|
14
|
+
backup_dir: str = 'backups'
|
|
15
|
+
backup_ref_prefix: str = 'worktree-manager/backups'
|
|
16
|
+
lock_dir: str = 'locks'
|
|
17
|
+
prepare_command: str = ''
|
|
18
|
+
verify_command: str = ''
|
|
19
|
+
prepare_enabled: bool = False
|
|
20
|
+
verify_enabled: bool = False
|
|
21
|
+
preserve_dirty: bool = False
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def load(cls, repo_root: str|Path, config_path: str|Path|None=None, environ: dict[str,str]|None=None)->'Configuration':
|
|
25
|
+
root=Path(repo_root).expanduser()
|
|
26
|
+
if not root.is_absolute() or not root.is_dir(): raise ManagerError('invalid_repository','Repository root must be an existing absolute directory',3)
|
|
27
|
+
path=Path(config_path).expanduser() if config_path is not None else root/'.worktree-manager.conf'
|
|
28
|
+
values={}
|
|
29
|
+
if config_path is not None and (not path.is_absolute() or not path.is_file()): raise ManagerError('invalid_configuration','Configuration must be an existing absolute file',3)
|
|
30
|
+
if path.is_file():
|
|
31
|
+
try: lines=path.read_text(encoding='utf-8').splitlines()
|
|
32
|
+
except OSError as exc: raise ManagerError('invalid_configuration','Cannot read configuration',3) from exc
|
|
33
|
+
for line in lines:
|
|
34
|
+
if not line or line.startswith('#'): continue
|
|
35
|
+
if '=' not in line: raise ManagerError('invalid_configuration','Configuration lines must use key=value',3)
|
|
36
|
+
key,value=line.split('=',1)
|
|
37
|
+
if key in values: raise ManagerError('invalid_configuration',f'Duplicate configuration key: {key}',3)
|
|
38
|
+
values[key]=value
|
|
39
|
+
allowed={'worktrees_dir','branch_prefix','state_dir','backup_dir','backup_ref_prefix','lock_dir','prepare_command','verify_command','prepare_enabled','verify_enabled','preserve_dirty'}
|
|
40
|
+
if set(values)-allowed: raise ManagerError('invalid_configuration','Configuration contains unknown keys',3)
|
|
41
|
+
env=os.environ if environ is None else environ
|
|
42
|
+
for key in allowed:
|
|
43
|
+
name='WORKTREE_MANAGER_'+key.upper()
|
|
44
|
+
if name in env: values[key]=env[name]
|
|
45
|
+
for key,value in values.items():
|
|
46
|
+
if '\x00' in value or '\n' in value or '\r' in value: raise ManagerError('invalid_configuration',f'Invalid value for {key}',3)
|
|
47
|
+
for key in ('state_dir','lock_dir'):
|
|
48
|
+
value=values.get(key,'')
|
|
49
|
+
if value and (Path(value).is_absolute() or '..' in Path(value).parts or value in ('.','')): raise ManagerError('invalid_configuration',f'Unsafe relative path for {key}',3)
|
|
50
|
+
for key in ('worktrees_dir','backup_dir'):
|
|
51
|
+
value=values.get(key,'')
|
|
52
|
+
if value and (value.startswith('-') or (key=='backup_dir' and (Path(value).is_absolute() or '..' in Path(value).parts))): raise ManagerError('invalid_configuration',f'Invalid path for {key}',3)
|
|
53
|
+
if values.get('backup_ref_prefix','').find('..')>=0: raise ManagerError('invalid_configuration','Invalid backup ref prefix',3)
|
|
54
|
+
bools={'prepare_enabled','verify_enabled','preserve_dirty'}
|
|
55
|
+
for key in bools:
|
|
56
|
+
if key in values:
|
|
57
|
+
if values[key] not in ('true','false'): raise ManagerError('invalid_configuration',f'{key} must be true or false',3)
|
|
58
|
+
values[key]=values[key]=='true'
|
|
59
|
+
if values.get('prepare_enabled') and not values.get('prepare_command'): raise ManagerError('invalid_configuration','prepare_enabled requires prepare_command',3)
|
|
60
|
+
if values.get('verify_enabled') and not values.get('verify_command'): raise ManagerError('invalid_configuration','verify_enabled requires verify_command',3)
|
|
61
|
+
return cls(repo_root=root.resolve(),**values)
|