claude-code-arcane 2.10.0 → 2.11.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/CHANGELOG.md +17 -0
- package/README.md +8 -8
- package/dist/cli.js +15 -0
- package/docs/SKILLS-CATALOG.md +4 -2
- package/docs/USER-GUIDE.md +1 -1
- package/docs/templates/skill-test-spec.md +0 -1
- package/package.json +1 -1
- package/profiles/backend-dotnet.yaml +2 -0
- package/profiles/backend-go.yaml +2 -0
- package/profiles/backend-nestjs.yaml +2 -0
- package/profiles/backend-nextjs.yaml +2 -0
- package/profiles/backend-ts.yaml +2 -0
- package/profiles/backend-typescript.yaml +2 -0
- package/profiles/blender.yaml +1 -0
- package/profiles/infra.yaml +2 -0
- package/profiles/meshy.yaml +1 -0
- package/rules/blender-files.md +72 -0
- package/rules/blender-pipeline.md +1 -63
- package/rules/gamedev/narrative.md +1 -1
- package/rules/meshy-assets.md +9 -78
- package/rules/meshy-files.md +83 -0
- package/skills/agent-protocol/SKILL.md +1 -1
- package/skills/agent-protocol/references/communication-standard.md +2 -2
- package/skills/ai-security/SKILL.md +4 -0
- package/skills/audit-dev/SKILL.md +1 -1
- package/skills/blender-animation/SKILL.md +1 -1
- package/skills/blender-export/SKILL.md +1 -1
- package/skills/blender-materials/SKILL.md +1 -1
- package/skills/blender-modeling/SKILL.md +1 -1
- package/skills/check/SKILL.md +1 -1
- package/skills/chief-of-staff/SKILL.md +1 -1
- package/skills/cloud-security/SKILL.md +4 -0
- package/skills/isms-audit-expert/SKILL.md +2 -0
- package/skills/meshy-derive/SKILL.md +1 -1
- package/skills/meshy-generate/SKILL.md +1 -1
- package/skills/meshy-setup/SKILL.md +1 -1
- package/skills/owasp-top10-check/SKILL.md +1 -1
- package/skills/qms-audit-expert/SKILL.md +2 -0
- package/skills/security-pen-testing/SKILL.md +1 -1
- package/skills/seo-audit/SKILL.md +1 -1
- package/skills/skill-auditor/SKILL.md +0 -1
- package/skills/skill-improve/SKILL.md +6 -7
- package/skills/skill-test/SKILL.md +3 -3
- package/skills/skill-test/references/static-checks.md +13 -8
- package/skills/standup-report/SKILL.md +1 -1
- package/skills/vn-gdd/SKILL.md +2 -0
- package/skills/web-security/SKILL.md +4 -0
- package/skills/worktree-isolation/README.md +6 -6
- package/skills/worktree-isolation/SKILL.md +5 -2
- package/skills/worktree-isolation-setup/README.md +7 -11
- package/skills/worktree-isolation-setup/SKILL.md +15 -6
- package/skills/worktree-isolation-setup/scripts/apply.py +0 -178
- package/skills/worktree-isolation-setup/scripts/install_hook.py +0 -118
- package/skills/worktree-isolation-setup/scripts/lib.py +0 -371
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Install the worktree-isolation SessionStart hook globally.
|
|
3
|
-
|
|
4
|
-
Idempotent. Safe to re-run.
|
|
5
|
-
|
|
6
|
-
What it does:
|
|
7
|
-
1. Copies `apply.py` + `lib.py` from this skill's `scripts/` dir into
|
|
8
|
-
`~/.claude/scripts/worktree-isolation/` (creating the dir if needed).
|
|
9
|
-
2. Ensures `~/.claude/settings.json` has a `SessionStart` hook entry that
|
|
10
|
-
invokes the copied `apply.py --quiet`.
|
|
11
|
-
|
|
12
|
-
Re-running is a no-op if the hook + scripts are already in place.
|
|
13
|
-
"""
|
|
14
|
-
|
|
15
|
-
from __future__ import annotations
|
|
16
|
-
|
|
17
|
-
import json
|
|
18
|
-
import os
|
|
19
|
-
import shutil
|
|
20
|
-
import stat
|
|
21
|
-
import sys
|
|
22
|
-
from pathlib import Path
|
|
23
|
-
|
|
24
|
-
HOME = Path.home()
|
|
25
|
-
TARGET_DIR = HOME / ".claude" / "scripts" / "worktree-isolation"
|
|
26
|
-
SETTINGS_PATH = HOME / ".claude" / "settings.json"
|
|
27
|
-
HOOK_COMMAND = f"{TARGET_DIR}/apply.py --quiet"
|
|
28
|
-
|
|
29
|
-
SCRIPTS_TO_COPY = ("apply.py", "lib.py")
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def copy_scripts(source_dir: Path) -> list[str]:
|
|
33
|
-
"""Copy hook scripts into ~/.claude/scripts/worktree-isolation/. Returns list of actions taken."""
|
|
34
|
-
TARGET_DIR.mkdir(parents=True, exist_ok=True)
|
|
35
|
-
actions: list[str] = []
|
|
36
|
-
for name in SCRIPTS_TO_COPY:
|
|
37
|
-
src = source_dir / name
|
|
38
|
-
dst = TARGET_DIR / name
|
|
39
|
-
if not src.exists():
|
|
40
|
-
raise FileNotFoundError(f"Source script missing: {src}")
|
|
41
|
-
if dst.exists() and dst.read_bytes() == src.read_bytes():
|
|
42
|
-
actions.append(f" • {name}: already up-to-date")
|
|
43
|
-
continue
|
|
44
|
-
shutil.copy2(src, dst)
|
|
45
|
-
# Ensure apply.py is executable.
|
|
46
|
-
if name == "apply.py":
|
|
47
|
-
mode = dst.stat().st_mode
|
|
48
|
-
dst.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
49
|
-
actions.append(f" • {name}: installed → {dst}")
|
|
50
|
-
return actions
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def hook_already_installed(settings: dict) -> bool:
|
|
54
|
-
for entry in settings.get("hooks", {}).get("SessionStart", []):
|
|
55
|
-
for hook in entry.get("hooks", []):
|
|
56
|
-
cmd = hook.get("command", "")
|
|
57
|
-
if "worktree-isolation/apply.py" in cmd and "--quiet" in cmd:
|
|
58
|
-
return True
|
|
59
|
-
return False
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def install_hook() -> str:
|
|
63
|
-
"""Add SessionStart hook to ~/.claude/settings.json. Returns a status string."""
|
|
64
|
-
if SETTINGS_PATH.exists():
|
|
65
|
-
try:
|
|
66
|
-
settings = json.loads(SETTINGS_PATH.read_text())
|
|
67
|
-
except json.JSONDecodeError as exc:
|
|
68
|
-
raise SystemExit(
|
|
69
|
-
f"Refusing to modify malformed {SETTINGS_PATH}: {exc}. Fix manually and re-run."
|
|
70
|
-
)
|
|
71
|
-
else:
|
|
72
|
-
settings = {}
|
|
73
|
-
|
|
74
|
-
if hook_already_installed(settings):
|
|
75
|
-
return " • SessionStart hook: already present (no change)"
|
|
76
|
-
|
|
77
|
-
hooks = settings.setdefault("hooks", {})
|
|
78
|
-
session_start = hooks.setdefault("SessionStart", [])
|
|
79
|
-
|
|
80
|
-
new_entry = {
|
|
81
|
-
"matcher": "",
|
|
82
|
-
"hooks": [{"type": "command", "command": HOOK_COMMAND}],
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
# Prefer to attach to an existing matcher="" entry rather than appending a new one.
|
|
86
|
-
for entry in session_start:
|
|
87
|
-
if entry.get("matcher", "") == "":
|
|
88
|
-
entry.setdefault("hooks", []).append(new_entry["hooks"][0])
|
|
89
|
-
break
|
|
90
|
-
else:
|
|
91
|
-
session_start.append(new_entry)
|
|
92
|
-
|
|
93
|
-
SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
-
SETTINGS_PATH.write_text(json.dumps(settings, indent=2) + "\n")
|
|
95
|
-
return f" • SessionStart hook: added to {SETTINGS_PATH}"
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def main() -> int:
|
|
99
|
-
here = Path(__file__).resolve().parent
|
|
100
|
-
print(f"Installing worktree-isolation hook from {here}")
|
|
101
|
-
print()
|
|
102
|
-
|
|
103
|
-
print("1) Copying hook scripts:")
|
|
104
|
-
for line in copy_scripts(here):
|
|
105
|
-
print(line)
|
|
106
|
-
|
|
107
|
-
print()
|
|
108
|
-
print("2) Wiring SessionStart hook:")
|
|
109
|
-
print(install_hook())
|
|
110
|
-
|
|
111
|
-
print()
|
|
112
|
-
print("Done. Open a new Claude Code session inside any git worktree to verify.")
|
|
113
|
-
print(f" .env.worktree should appear in the worktree root automatically.")
|
|
114
|
-
return 0
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if __name__ == "__main__":
|
|
118
|
-
sys.exit(main())
|
|
@@ -1,371 +0,0 @@
|
|
|
1
|
-
"""Helpers for worktree-isolation: parse compose files, allocate deterministic
|
|
2
|
-
host ports per worktree, render override yaml + .env.worktree.
|
|
3
|
-
|
|
4
|
-
Pure-Python except for PyYAML. Both apply.py and prepare.py import from here.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import hashlib
|
|
10
|
-
import re
|
|
11
|
-
from dataclasses import dataclass, replace
|
|
12
|
-
from pathlib import Path
|
|
13
|
-
from typing import Iterable
|
|
14
|
-
|
|
15
|
-
import yaml
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
PORT_RANGE_START = 20000
|
|
19
|
-
PORT_RANGE_SIZE = 10000 # → 20000-29999
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
@dataclass(frozen=True)
|
|
23
|
-
class PortMapping:
|
|
24
|
-
service: str
|
|
25
|
-
host_ip: str | None
|
|
26
|
-
original_host_port: int
|
|
27
|
-
new_host_port: int
|
|
28
|
-
container_port: int
|
|
29
|
-
protocol: str # "tcp" or "udp"
|
|
30
|
-
var_name: str | None # if compose used ${VAR:-default}, the captured VAR
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def worktree_id(cwd: Path) -> str:
|
|
34
|
-
"""8-char stable hash of the absolute path."""
|
|
35
|
-
return hashlib.sha256(str(cwd.resolve()).encode()).hexdigest()[:8]
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def allocate_port(worktree: str, service: str, original_host: int, container: int) -> int:
|
|
39
|
-
seed = f"{worktree}:{service}:{original_host}:{container}".encode()
|
|
40
|
-
h = hashlib.sha256(seed).digest()
|
|
41
|
-
offset = int.from_bytes(h[:4], "big") % PORT_RANGE_SIZE
|
|
42
|
-
return PORT_RANGE_START + offset
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
_VAR_PATTERN = re.compile(r"^\$\{(\w+)(?::-([^}]*))?\}$")
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def _split_port_spec(s: str) -> list[str]:
|
|
49
|
-
"""Split a docker-compose short-form port string on ':' while respecting ${...}.
|
|
50
|
-
|
|
51
|
-
Examples:
|
|
52
|
-
'54399:5432' → ['54399', '5432']
|
|
53
|
-
'127.0.0.1:54399:5432' → ['127.0.0.1', '54399', '5432']
|
|
54
|
-
'${TEST_DB_PORT:-5433}:5432' → ['${TEST_DB_PORT:-5433}', '5432']
|
|
55
|
-
"""
|
|
56
|
-
parts: list[str] = []
|
|
57
|
-
buf: list[str] = []
|
|
58
|
-
depth = 0
|
|
59
|
-
i = 0
|
|
60
|
-
while i < len(s):
|
|
61
|
-
c = s[i]
|
|
62
|
-
if c == "$" and i + 1 < len(s) and s[i + 1] == "{":
|
|
63
|
-
depth += 1
|
|
64
|
-
buf.append(c)
|
|
65
|
-
buf.append(s[i + 1])
|
|
66
|
-
i += 2
|
|
67
|
-
continue
|
|
68
|
-
if c == "}" and depth > 0:
|
|
69
|
-
depth -= 1
|
|
70
|
-
buf.append(c)
|
|
71
|
-
i += 1
|
|
72
|
-
continue
|
|
73
|
-
if c == ":" and depth == 0:
|
|
74
|
-
parts.append("".join(buf))
|
|
75
|
-
buf = []
|
|
76
|
-
i += 1
|
|
77
|
-
continue
|
|
78
|
-
buf.append(c)
|
|
79
|
-
i += 1
|
|
80
|
-
parts.append("".join(buf))
|
|
81
|
-
return parts
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def _parse_host_port_token(token: str) -> tuple[int | None, str | None]:
|
|
85
|
-
"""Return (default_port, var_name) for a host-port token.
|
|
86
|
-
Token may be a bare number ("54399") or a substitution ("${PORT:-54399}")."""
|
|
87
|
-
m = _VAR_PATTERN.match(token)
|
|
88
|
-
if m:
|
|
89
|
-
var = m.group(1)
|
|
90
|
-
default = m.group(2)
|
|
91
|
-
if default and default.isdigit():
|
|
92
|
-
return int(default), var
|
|
93
|
-
return None, var
|
|
94
|
-
if token.isdigit():
|
|
95
|
-
return int(token), None
|
|
96
|
-
return None, None
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def parse_port_entry(entry, service: str) -> PortMapping | None:
|
|
100
|
-
"""Normalize one ports: list entry into a PortMapping (new_host_port=0 placeholder).
|
|
101
|
-
|
|
102
|
-
Handles short-form strings, bare ints, and long-form dicts.
|
|
103
|
-
"""
|
|
104
|
-
if isinstance(entry, dict):
|
|
105
|
-
host_port_raw = entry.get("published")
|
|
106
|
-
container_port = int(entry["target"])
|
|
107
|
-
host_port: int | None
|
|
108
|
-
var_name: str | None = None
|
|
109
|
-
if host_port_raw is None:
|
|
110
|
-
host_port = container_port
|
|
111
|
-
elif isinstance(host_port_raw, int):
|
|
112
|
-
host_port = host_port_raw
|
|
113
|
-
else:
|
|
114
|
-
host_port, var_name = _parse_host_port_token(str(host_port_raw))
|
|
115
|
-
if host_port is None:
|
|
116
|
-
host_port = container_port
|
|
117
|
-
return PortMapping(
|
|
118
|
-
service=service,
|
|
119
|
-
host_ip=entry.get("host_ip"),
|
|
120
|
-
original_host_port=host_port,
|
|
121
|
-
new_host_port=0,
|
|
122
|
-
container_port=container_port,
|
|
123
|
-
protocol=entry.get("protocol", "tcp"),
|
|
124
|
-
var_name=var_name,
|
|
125
|
-
)
|
|
126
|
-
|
|
127
|
-
s = str(entry).strip()
|
|
128
|
-
proto = "tcp"
|
|
129
|
-
if "/" in s:
|
|
130
|
-
s, proto = s.rsplit("/", 1)
|
|
131
|
-
|
|
132
|
-
parts = _split_port_spec(s)
|
|
133
|
-
host_ip: str | None = None
|
|
134
|
-
var_name: str | None = None
|
|
135
|
-
|
|
136
|
-
if len(parts) == 1:
|
|
137
|
-
# Bare port — Docker treats as container port; we still bind a deterministic host port.
|
|
138
|
-
host_port_def, _ = _parse_host_port_token(parts[0])
|
|
139
|
-
container_port = host_port_def or 0
|
|
140
|
-
host_port: int | None = container_port
|
|
141
|
-
elif len(parts) == 2:
|
|
142
|
-
host_token, container_token = parts
|
|
143
|
-
host_port, var_name = _parse_host_port_token(host_token)
|
|
144
|
-
cp_def, _ = _parse_host_port_token(container_token)
|
|
145
|
-
container_port = cp_def or 0
|
|
146
|
-
if host_port is None:
|
|
147
|
-
host_port = container_port
|
|
148
|
-
elif len(parts) == 3:
|
|
149
|
-
host_ip, host_token, container_token = parts
|
|
150
|
-
host_port, var_name = _parse_host_port_token(host_token)
|
|
151
|
-
cp_def, _ = _parse_host_port_token(container_token)
|
|
152
|
-
container_port = cp_def or 0
|
|
153
|
-
if host_port is None:
|
|
154
|
-
host_port = container_port
|
|
155
|
-
else:
|
|
156
|
-
return None
|
|
157
|
-
|
|
158
|
-
return PortMapping(
|
|
159
|
-
service=service,
|
|
160
|
-
host_ip=host_ip,
|
|
161
|
-
original_host_port=host_port,
|
|
162
|
-
new_host_port=0,
|
|
163
|
-
container_port=container_port,
|
|
164
|
-
protocol=proto,
|
|
165
|
-
var_name=var_name,
|
|
166
|
-
)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def find_compose_files(cwd: Path) -> list[Path]:
|
|
170
|
-
"""All docker-compose*.yml/yaml in cwd, excluding worktree-generated overrides."""
|
|
171
|
-
out: list[Path] = []
|
|
172
|
-
for ext in ("yml", "yaml"):
|
|
173
|
-
out.extend(cwd.glob(f"docker-compose*.{ext}"))
|
|
174
|
-
out.extend(cwd.glob(f"compose*.{ext}"))
|
|
175
|
-
return sorted({p for p in out if not p.name.endswith((".worktree.yml", ".worktree.yaml"))})
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def collect_port_mappings(compose_path: Path, worktree: str) -> list[PortMapping]:
|
|
179
|
-
with open(compose_path) as f:
|
|
180
|
-
data = yaml.safe_load(f) or {}
|
|
181
|
-
services = data.get("services") or {}
|
|
182
|
-
out: list[PortMapping] = []
|
|
183
|
-
for service_name, service_def in services.items():
|
|
184
|
-
ports = (service_def or {}).get("ports") or []
|
|
185
|
-
for entry in ports:
|
|
186
|
-
pm = parse_port_entry(entry, service_name)
|
|
187
|
-
if pm is None or pm.original_host_port is None:
|
|
188
|
-
continue
|
|
189
|
-
new_port = allocate_port(worktree, service_name, pm.original_host_port, pm.container_port)
|
|
190
|
-
out.append(replace(pm, new_host_port=new_port))
|
|
191
|
-
return out
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def collect_container_names(compose_path: Path) -> dict[str, str]:
|
|
195
|
-
"""service → container_name (only for services that have a hardcoded one)."""
|
|
196
|
-
with open(compose_path) as f:
|
|
197
|
-
data = yaml.safe_load(f) or {}
|
|
198
|
-
services = data.get("services") or {}
|
|
199
|
-
out: dict[str, str] = {}
|
|
200
|
-
for service_name, service_def in services.items():
|
|
201
|
-
cn = (service_def or {}).get("container_name")
|
|
202
|
-
if isinstance(cn, str) and cn:
|
|
203
|
-
out[service_name] = cn
|
|
204
|
-
return out
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
class _OverrideList(list):
|
|
208
|
-
"""Wrapper so we can emit YAML with the `!override` tag — Compose then REPLACES the
|
|
209
|
-
base list instead of appending (merge default for sequences). Compose 2.24+."""
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
def _represent_override_list(dumper: yaml.SafeDumper, data: _OverrideList):
|
|
213
|
-
return dumper.represent_sequence("!override", list(data))
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
yaml.SafeDumper.add_representer(_OverrideList, _represent_override_list)
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
def render_override_yaml(
|
|
220
|
-
mappings: list[PortMapping],
|
|
221
|
-
container_names: dict[str, str],
|
|
222
|
-
project_name: str,
|
|
223
|
-
worktree: str,
|
|
224
|
-
) -> dict:
|
|
225
|
-
"""Build the override-yaml dict. Top-level `name:` sets COMPOSE_PROJECT_NAME for this stack.
|
|
226
|
-
Per-service: rewrite ports (with !override to replace base) + suffix container_name."""
|
|
227
|
-
by_service: dict[str, list[PortMapping]] = {}
|
|
228
|
-
for m in mappings:
|
|
229
|
-
by_service.setdefault(m.service, []).append(m)
|
|
230
|
-
|
|
231
|
-
services: dict[str, dict] = {}
|
|
232
|
-
all_services = set(by_service) | set(container_names)
|
|
233
|
-
for service in all_services:
|
|
234
|
-
svc: dict = {}
|
|
235
|
-
if service in by_service:
|
|
236
|
-
ports: list[str] = []
|
|
237
|
-
for m in by_service[service]:
|
|
238
|
-
spec = (
|
|
239
|
-
f"{m.host_ip}:{m.new_host_port}:{m.container_port}"
|
|
240
|
-
if m.host_ip
|
|
241
|
-
else f"{m.new_host_port}:{m.container_port}"
|
|
242
|
-
)
|
|
243
|
-
if m.protocol != "tcp":
|
|
244
|
-
spec = f"{spec}/{m.protocol}"
|
|
245
|
-
ports.append(spec)
|
|
246
|
-
svc["ports"] = _OverrideList(ports)
|
|
247
|
-
if service in container_names:
|
|
248
|
-
svc["container_name"] = f"{container_names[service]}-{worktree}"
|
|
249
|
-
services[service] = svc
|
|
250
|
-
|
|
251
|
-
return {"name": project_name, "services": services}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
def env_var_name(mapping: PortMapping) -> str:
|
|
255
|
-
"""Generate a stable upper-snake-case env var name for a port mapping.
|
|
256
|
-
|
|
257
|
-
Format: <SERVICE>_<CONTAINER_PORT>_HOST_PORT.
|
|
258
|
-
Example: service "postgres-integration", container 5432 → POSTGRES_INTEGRATION_5432_HOST_PORT.
|
|
259
|
-
"""
|
|
260
|
-
safe = re.sub(r"[^A-Za-z0-9]+", "_", mapping.service).strip("_").upper()
|
|
261
|
-
return f"{safe}_{mapping.container_port}_HOST_PORT"
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
def render_env_worktree(
|
|
265
|
-
project_name: str,
|
|
266
|
-
worktree: str,
|
|
267
|
-
compose_chain: list[tuple[Path, Path | None]],
|
|
268
|
-
mappings: list[PortMapping],
|
|
269
|
-
) -> str:
|
|
270
|
-
"""Render the .env.worktree contents.
|
|
271
|
-
|
|
272
|
-
compose_chain: [(base_compose_path, override_path_or_None)] in order.
|
|
273
|
-
"""
|
|
274
|
-
lines: list[str] = [
|
|
275
|
-
"# Auto-generated by ~/.claude/scripts/worktree-isolation/apply.py",
|
|
276
|
-
"# DO NOT COMMIT. Source before running docker compose:",
|
|
277
|
-
"# set -a; source .env.worktree; set +a",
|
|
278
|
-
f"WORKTREE_ID={worktree}",
|
|
279
|
-
f"COMPOSE_PROJECT_NAME={project_name}",
|
|
280
|
-
]
|
|
281
|
-
|
|
282
|
-
chain: list[str] = []
|
|
283
|
-
for base, override in compose_chain:
|
|
284
|
-
chain.append(base.name)
|
|
285
|
-
if override is not None:
|
|
286
|
-
chain.append(override.name)
|
|
287
|
-
if chain:
|
|
288
|
-
# Compose reads COMPOSE_FILE colon-separated on Unix, semicolon on Windows.
|
|
289
|
-
lines.append(f"COMPOSE_FILE={':'.join(chain)}")
|
|
290
|
-
|
|
291
|
-
seen_vars: set[str] = set()
|
|
292
|
-
for m in mappings:
|
|
293
|
-
var = env_var_name(m)
|
|
294
|
-
if var not in seen_vars:
|
|
295
|
-
lines.append(f"{var}={m.new_host_port}")
|
|
296
|
-
seen_vars.add(var)
|
|
297
|
-
# If the compose used ${VAR:-default}, also export VAR so substitution picks up the new port.
|
|
298
|
-
if m.var_name and m.var_name not in seen_vars:
|
|
299
|
-
lines.append(f"{m.var_name}={m.new_host_port}")
|
|
300
|
-
seen_vars.add(m.var_name)
|
|
301
|
-
|
|
302
|
-
return "\n".join(lines) + "\n"
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
def find_git_dir(cwd: Path) -> Path | None:
|
|
306
|
-
"""Locate the per-checkout git dir.
|
|
307
|
-
|
|
308
|
-
For regular checkouts: <cwd>/.git is a directory.
|
|
309
|
-
For git worktrees: <cwd>/.git is a file like 'gitdir: /abs/path/to/main/.git/worktrees/<name>'.
|
|
310
|
-
Returns the per-checkout dir (where info/exclude lives), or None if not in a repo.
|
|
311
|
-
"""
|
|
312
|
-
git_path = cwd / ".git"
|
|
313
|
-
if git_path.is_dir():
|
|
314
|
-
return git_path
|
|
315
|
-
if git_path.is_file():
|
|
316
|
-
try:
|
|
317
|
-
content = git_path.read_text().strip()
|
|
318
|
-
except OSError:
|
|
319
|
-
return None
|
|
320
|
-
if content.startswith("gitdir:"):
|
|
321
|
-
target = Path(content.split(":", 1)[1].strip())
|
|
322
|
-
if not target.is_absolute():
|
|
323
|
-
target = (cwd / target).resolve()
|
|
324
|
-
return target if target.exists() else None
|
|
325
|
-
# walk up
|
|
326
|
-
for parent in cwd.parents:
|
|
327
|
-
candidate = parent / ".git"
|
|
328
|
-
if candidate.is_dir():
|
|
329
|
-
return candidate
|
|
330
|
-
return None
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
def ensure_git_exclude(cwd: Path, patterns: Iterable[str]) -> bool:
|
|
334
|
-
"""Append patterns to <git_dir>/info/exclude (per-worktree, never committed).
|
|
335
|
-
Returns True if any pattern was added."""
|
|
336
|
-
git_dir = find_git_dir(cwd)
|
|
337
|
-
if git_dir is None:
|
|
338
|
-
return False
|
|
339
|
-
info_dir = git_dir / "info"
|
|
340
|
-
info_dir.mkdir(parents=True, exist_ok=True)
|
|
341
|
-
exclude_file = info_dir / "exclude"
|
|
342
|
-
existing_lines: set[str] = set()
|
|
343
|
-
if exclude_file.exists():
|
|
344
|
-
existing_lines = {l.strip() for l in exclude_file.read_text().splitlines() if l.strip()}
|
|
345
|
-
to_add = [p for p in patterns if p not in existing_lines]
|
|
346
|
-
if not to_add:
|
|
347
|
-
return False
|
|
348
|
-
with open(exclude_file, "a") as f:
|
|
349
|
-
if exclude_file.stat().st_size and not exclude_file.read_text().endswith("\n"):
|
|
350
|
-
f.write("\n")
|
|
351
|
-
f.write("# Worktree isolation (auto-added)\n")
|
|
352
|
-
for p in to_add:
|
|
353
|
-
f.write(f"{p}\n")
|
|
354
|
-
return True
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
def overrides_up_to_date(
|
|
358
|
-
base: Path, override: Path, env_file: Path
|
|
359
|
-
) -> bool:
|
|
360
|
-
"""True if override + env file exist and are newer than base. Skip regen."""
|
|
361
|
-
if not override.exists() or not env_file.exists():
|
|
362
|
-
return False
|
|
363
|
-
base_mtime = base.stat().st_mtime
|
|
364
|
-
return override.stat().st_mtime >= base_mtime and env_file.stat().st_mtime >= base_mtime
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
def override_path_for(base: Path) -> Path:
|
|
368
|
-
"""docker-compose.processing.yml → docker-compose.processing.worktree.yml.
|
|
369
|
-
docker-compose.yml → docker-compose.worktree.yml."""
|
|
370
|
-
stem = base.name[: -len(base.suffix)]
|
|
371
|
-
return base.parent / f"{stem}.worktree{base.suffix}"
|