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.
@@ -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,2 @@
1
+ """Standalone Python worktree management backend."""
2
+ __version__ = '1.0.0'
@@ -0,0 +1,2 @@
1
+ from .cli import main
2
+ raise SystemExit(main())
@@ -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)