warmtree 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- warmtree/__init__.py +3 -0
- warmtree/__main__.py +5 -0
- warmtree/cli.py +344 -0
- warmtree/config.py +142 -0
- warmtree/git.py +152 -0
- warmtree/lock.py +59 -0
- warmtree/pool.py +365 -0
- warmtree/skill.py +93 -0
- warmtree/skills/warmtree/SKILL.md +116 -0
- warmtree/warm.py +104 -0
- warmtree-0.1.0.dist-info/METADATA +279 -0
- warmtree-0.1.0.dist-info/RECORD +15 -0
- warmtree-0.1.0.dist-info/WHEEL +4 -0
- warmtree-0.1.0.dist-info/entry_points.txt +2 -0
- warmtree-0.1.0.dist-info/licenses/LICENSE +21 -0
warmtree/__init__.py
ADDED
warmtree/__main__.py
ADDED
warmtree/cli.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Command line entry point. One function per command.
|
|
2
|
+
|
|
3
|
+
`take` prints only the slot path on stdout so `cd "$(warmtree take x)"` works.
|
|
4
|
+
Everything a human reads goes to stderr.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import json
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from warmtree import __version__, config, git, skill
|
|
16
|
+
from warmtree.config import CONFIG_NAME, ConfigError
|
|
17
|
+
from warmtree.git import GitError
|
|
18
|
+
from warmtree.pool import Pool, PoolError, Slot
|
|
19
|
+
from warmtree.warm import WarmError
|
|
20
|
+
|
|
21
|
+
# Lockfiles `init` looks for to pre-fill `lockfiles`. It never guesses `run`.
|
|
22
|
+
KNOWN_LOCKFILES = (
|
|
23
|
+
"package-lock.json",
|
|
24
|
+
"pnpm-lock.yaml",
|
|
25
|
+
"yarn.lock",
|
|
26
|
+
"bun.lockb",
|
|
27
|
+
"bun.lock",
|
|
28
|
+
"uv.lock",
|
|
29
|
+
"poetry.lock",
|
|
30
|
+
"Pipfile.lock",
|
|
31
|
+
"requirements.txt",
|
|
32
|
+
"Cargo.lock",
|
|
33
|
+
"go.sum",
|
|
34
|
+
"packages.lock.json",
|
|
35
|
+
"Gemfile.lock",
|
|
36
|
+
"composer.lock",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main(argv: list[str] | None = None) -> int:
|
|
41
|
+
parser = build_parser()
|
|
42
|
+
args = parser.parse_args(argv)
|
|
43
|
+
try:
|
|
44
|
+
return args.func(args)
|
|
45
|
+
except (GitError, ConfigError, PoolError, WarmError) as exc:
|
|
46
|
+
fail(str(exc))
|
|
47
|
+
return 1
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def fail(message: str) -> None:
|
|
51
|
+
print(f"warmtree: {message}", file=sys.stderr)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def note(message: str) -> None:
|
|
55
|
+
print(message, file=sys.stderr)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
59
|
+
parser = argparse.ArgumentParser(
|
|
60
|
+
prog="warmtree", description="A pool of pre-warmed git worktrees."
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
63
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
64
|
+
|
|
65
|
+
init = commands.add_parser("init", help=f"write a starter {CONFIG_NAME}")
|
|
66
|
+
init.add_argument("--force", action="store_true", help="overwrite an existing file")
|
|
67
|
+
init.add_argument(
|
|
68
|
+
"--no-skill", action="store_true", help="do not install the agent skill"
|
|
69
|
+
)
|
|
70
|
+
init.set_defaults(func=cmd_init)
|
|
71
|
+
|
|
72
|
+
skill_cmd = commands.add_parser(
|
|
73
|
+
"skill", help="install the agent skill where this repo's coding agents look"
|
|
74
|
+
)
|
|
75
|
+
skill_cmd.add_argument(
|
|
76
|
+
"--tool",
|
|
77
|
+
action="append",
|
|
78
|
+
choices=skill.TOOL_KEYS,
|
|
79
|
+
help="install for this tool even if it was not detected; repeatable",
|
|
80
|
+
)
|
|
81
|
+
skill_cmd.add_argument(
|
|
82
|
+
"--force", action="store_true", help="overwrite a copy that was edited"
|
|
83
|
+
)
|
|
84
|
+
skill_cmd.set_defaults(func=cmd_skill)
|
|
85
|
+
|
|
86
|
+
fill = commands.add_parser("fill", help="create any missing slots")
|
|
87
|
+
fill.set_defaults(func=cmd_fill)
|
|
88
|
+
|
|
89
|
+
take = commands.add_parser("take", help="claim a ready slot for a branch")
|
|
90
|
+
take.add_argument("branch", help="branch to create or check out in the slot")
|
|
91
|
+
take.add_argument("--from", dest="from_ref", metavar="REF", help="start point")
|
|
92
|
+
refill = take.add_mutually_exclusive_group()
|
|
93
|
+
refill.add_argument("--no-refill", action="store_true", help="do not refill")
|
|
94
|
+
refill.add_argument(
|
|
95
|
+
"--refill-background",
|
|
96
|
+
action="store_true",
|
|
97
|
+
help="refill in a detached process and return at once",
|
|
98
|
+
)
|
|
99
|
+
take.set_defaults(func=cmd_take)
|
|
100
|
+
|
|
101
|
+
release = commands.add_parser("release", help="return a slot to the pool")
|
|
102
|
+
release.add_argument("branch", help="branch currently checked out in the slot")
|
|
103
|
+
release.add_argument("--keep-branch", action="store_true", help="keep the ref")
|
|
104
|
+
release.add_argument("--force", action="store_true", help="discard changes")
|
|
105
|
+
release.set_defaults(func=cmd_release)
|
|
106
|
+
|
|
107
|
+
remove = commands.add_parser("remove", help="delete slots")
|
|
108
|
+
remove.add_argument("names", nargs="*", metavar="SLOT", help="slots to delete")
|
|
109
|
+
remove.add_argument("--all", action="store_true", help="delete every slot")
|
|
110
|
+
remove.add_argument("--force", action="store_true", help="delete taken slots too")
|
|
111
|
+
remove.set_defaults(func=cmd_remove)
|
|
112
|
+
|
|
113
|
+
refresh = commands.add_parser(
|
|
114
|
+
"refresh", help="move waiting slots to base and re-warm if lockfiles changed"
|
|
115
|
+
)
|
|
116
|
+
refresh.set_defaults(func=cmd_refresh)
|
|
117
|
+
|
|
118
|
+
size = commands.add_parser("size", help="show or change how many slots to keep")
|
|
119
|
+
size.add_argument(
|
|
120
|
+
"size", nargs="?", type=int, help="new size; grows or shrinks the pool to match"
|
|
121
|
+
)
|
|
122
|
+
size.set_defaults(func=cmd_size)
|
|
123
|
+
|
|
124
|
+
which = commands.add_parser(
|
|
125
|
+
"which", help="name the slot the current directory is in"
|
|
126
|
+
)
|
|
127
|
+
which.set_defaults(func=cmd_which)
|
|
128
|
+
|
|
129
|
+
status = commands.add_parser("status", help="show every slot")
|
|
130
|
+
status.add_argument("--json", action="store_true", help="print JSON for agents")
|
|
131
|
+
status.set_defaults(func=cmd_status)
|
|
132
|
+
|
|
133
|
+
return parser
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
137
|
+
root = git.repo_root(Path.cwd())
|
|
138
|
+
target = root / CONFIG_NAME
|
|
139
|
+
if target.exists() and not args.force:
|
|
140
|
+
fail(f"{target} already exists (use --force)")
|
|
141
|
+
return 1
|
|
142
|
+
lockfiles = [name for name in KNOWN_LOCKFILES if (root / name).exists()]
|
|
143
|
+
target.write_text(config.starter_toml(lockfiles), encoding="utf-8")
|
|
144
|
+
print(f"wrote {target}")
|
|
145
|
+
if lockfiles:
|
|
146
|
+
print(f"lockfiles: {', '.join(lockfiles)}")
|
|
147
|
+
print("edit [warm] run to add your install command; warmtree never guesses it")
|
|
148
|
+
if not args.no_skill:
|
|
149
|
+
targets = skill.detect(root)
|
|
150
|
+
if targets:
|
|
151
|
+
_report_skill(root, skill.install(root, targets))
|
|
152
|
+
else:
|
|
153
|
+
print(
|
|
154
|
+
"no agent config detected; install the skill later with "
|
|
155
|
+
f"`warmtree skill --tool {'|'.join(skill.TOOL_KEYS)}`"
|
|
156
|
+
)
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def cmd_skill(args: argparse.Namespace) -> int:
|
|
161
|
+
root = git.repo_root(Path.cwd())
|
|
162
|
+
if args.tool:
|
|
163
|
+
targets = [skill.by_tool(key) for key in args.tool]
|
|
164
|
+
else:
|
|
165
|
+
targets = skill.detect(root)
|
|
166
|
+
if not targets:
|
|
167
|
+
fail(
|
|
168
|
+
"no agent config detected (CLAUDE.md, AGENTS.md, .cursor, "
|
|
169
|
+
"copilot-instructions.md); "
|
|
170
|
+
f"pick one with --tool {'|'.join(skill.TOOL_KEYS)}"
|
|
171
|
+
)
|
|
172
|
+
return 1
|
|
173
|
+
results = skill.install(root, targets, force=args.force)
|
|
174
|
+
_report_skill(root, results)
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _report_skill(root: Path, results: list[skill.Installed]) -> None:
|
|
179
|
+
for result in results:
|
|
180
|
+
where = result.path.relative_to(root).as_posix()
|
|
181
|
+
print(f"{result.action}: {result.target.tool} skill at {where}")
|
|
182
|
+
if any(result.action == "kept" for result in results):
|
|
183
|
+
note("a copy you edited was kept; use `warmtree skill --force` to replace it")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def cmd_fill(args: argparse.Namespace) -> int:
|
|
187
|
+
pool = _pool()
|
|
188
|
+
created = pool.fill()
|
|
189
|
+
for slot in created:
|
|
190
|
+
print(f"created {slot.name} at {slot.path}")
|
|
191
|
+
if not created:
|
|
192
|
+
ready = sum(1 for slot in pool.status() if slot.state == "ready")
|
|
193
|
+
print(f"pool is full ({ready} ready)")
|
|
194
|
+
return 0
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def cmd_take(args: argparse.Namespace) -> int:
|
|
198
|
+
pool = _pool()
|
|
199
|
+
slot, cold = pool.take(args.branch, from_ref=args.from_ref)
|
|
200
|
+
if cold:
|
|
201
|
+
note(f"no ready slot; created {slot.name} cold for {args.branch}")
|
|
202
|
+
else:
|
|
203
|
+
note(f"took {slot.name} for {args.branch}")
|
|
204
|
+
print(slot.path)
|
|
205
|
+
|
|
206
|
+
if args.refill_background:
|
|
207
|
+
_spawn_background_fill(pool.repo_root)
|
|
208
|
+
note("refilling in the background")
|
|
209
|
+
elif not args.no_refill:
|
|
210
|
+
for created in pool.fill():
|
|
211
|
+
note(f"refilled {created.name}")
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def cmd_release(args: argparse.Namespace) -> int:
|
|
216
|
+
slot, branch_deleted = _pool().release(
|
|
217
|
+
args.branch, keep_branch=args.keep_branch, force=args.force
|
|
218
|
+
)
|
|
219
|
+
print(f"released {slot.name}")
|
|
220
|
+
if branch_deleted:
|
|
221
|
+
print(f"deleted branch {args.branch}")
|
|
222
|
+
elif not args.keep_branch:
|
|
223
|
+
note(f"kept branch {args.branch}: it has commits that are not merged")
|
|
224
|
+
return 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def cmd_remove(args: argparse.Namespace) -> int:
|
|
228
|
+
if not args.names and not args.all:
|
|
229
|
+
fail("name a slot or pass --all")
|
|
230
|
+
return 1
|
|
231
|
+
removed = _pool().remove(names=args.names, all_slots=args.all, force=args.force)
|
|
232
|
+
for slot in removed:
|
|
233
|
+
print(f"removed {slot.name}")
|
|
234
|
+
return 0
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def cmd_refresh(args: argparse.Namespace) -> int:
|
|
238
|
+
results = _pool().refresh()
|
|
239
|
+
if not results:
|
|
240
|
+
print("nothing to refresh")
|
|
241
|
+
return 0
|
|
242
|
+
for result in results:
|
|
243
|
+
base = "moved to base" if result.moved else "at base"
|
|
244
|
+
warmth = "re-warmed" if result.rewarmed else "still warm"
|
|
245
|
+
print(f"{result.slot.name}: {base}, {warmth}")
|
|
246
|
+
return 0
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def cmd_size(args: argparse.Namespace) -> int:
|
|
250
|
+
root = git.repo_root(Path.cwd())
|
|
251
|
+
if args.size is not None:
|
|
252
|
+
config.write_size(root, args.size)
|
|
253
|
+
pool = Pool(root, config.load(root), log=note)
|
|
254
|
+
for slot in pool.trim():
|
|
255
|
+
print(f"removed {slot.name}")
|
|
256
|
+
for slot in pool.fill():
|
|
257
|
+
print(f"created {slot.name} at {slot.path}")
|
|
258
|
+
cfg = config.load(root)
|
|
259
|
+
counts = Counter(slot.state for slot in Pool(root, cfg).status())
|
|
260
|
+
print(f"size: {cfg.size}")
|
|
261
|
+
print(
|
|
262
|
+
f"ready {counts['ready']}, taken {counts['taken']}, "
|
|
263
|
+
f"warming {counts['warming']}, stale {counts['stale']}; "
|
|
264
|
+
f"{counts.total()} slots in total"
|
|
265
|
+
)
|
|
266
|
+
return 0
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def cmd_which(args: argparse.Namespace) -> int:
|
|
270
|
+
here = Path.cwd().resolve()
|
|
271
|
+
for slot in _pool().status():
|
|
272
|
+
path = Path(slot.path).resolve()
|
|
273
|
+
if here == path or path in here.parents:
|
|
274
|
+
print(f"{slot.name} {slot.state} {slot.branch or '-'}")
|
|
275
|
+
return 0
|
|
276
|
+
fail("not inside a warmtree slot")
|
|
277
|
+
return 1
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
281
|
+
slots = _pool().status()
|
|
282
|
+
if args.json:
|
|
283
|
+
print(json.dumps([slot.to_dict() for slot in slots], indent=2))
|
|
284
|
+
return 0
|
|
285
|
+
if not slots:
|
|
286
|
+
print("no slots yet; run `warmtree fill`")
|
|
287
|
+
return 0
|
|
288
|
+
print_table(slots)
|
|
289
|
+
return 0
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def print_table(slots: list[Slot]) -> None:
|
|
293
|
+
rows = [("SLOT", "STATE", "BRANCH", "AGE", "WARMED", "PATH")]
|
|
294
|
+
for slot in slots:
|
|
295
|
+
rows.append(
|
|
296
|
+
(
|
|
297
|
+
slot.name,
|
|
298
|
+
slot.state,
|
|
299
|
+
slot.branch or "-",
|
|
300
|
+
_age(slot.created),
|
|
301
|
+
_age(slot.warmed) + " ago" if slot.warmed else "-",
|
|
302
|
+
slot.path,
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
widths = [max(len(row[i]) for row in rows) for i in range(len(rows[0]) - 1)]
|
|
306
|
+
for row in rows:
|
|
307
|
+
cells = [cell.ljust(widths[i]) for i, cell in enumerate(row[:-1])]
|
|
308
|
+
print(" ".join([*cells, row[-1]]))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _pool() -> Pool:
|
|
312
|
+
root = git.repo_root(Path.cwd())
|
|
313
|
+
return Pool(root, config.load(root), log=note)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _spawn_background_fill(repo_root: Path) -> None:
|
|
317
|
+
"""Start `warmtree fill` detached from this process and terminal."""
|
|
318
|
+
command = [sys.executable, "-m", "warmtree", "fill"]
|
|
319
|
+
options: dict = {
|
|
320
|
+
"cwd": repo_root,
|
|
321
|
+
"stdin": subprocess.DEVNULL,
|
|
322
|
+
"stdout": subprocess.DEVNULL,
|
|
323
|
+
"stderr": subprocess.DEVNULL,
|
|
324
|
+
}
|
|
325
|
+
if sys.platform == "win32":
|
|
326
|
+
options["creationflags"] = (
|
|
327
|
+
subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
|
328
|
+
)
|
|
329
|
+
else:
|
|
330
|
+
options["start_new_session"] = True
|
|
331
|
+
subprocess.Popen(command, **options)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _age(timestamp: str) -> str:
|
|
335
|
+
"""Compact human age like `4m`, `3h`, `2d`."""
|
|
336
|
+
then = datetime.fromisoformat(timestamp)
|
|
337
|
+
seconds = int((datetime.now(UTC) - then).total_seconds())
|
|
338
|
+
if seconds < 60:
|
|
339
|
+
return f"{seconds}s"
|
|
340
|
+
if seconds < 3600:
|
|
341
|
+
return f"{seconds // 60}m"
|
|
342
|
+
if seconds < 86400:
|
|
343
|
+
return f"{seconds // 3600}h"
|
|
344
|
+
return f"{seconds // 86400}d"
|
warmtree/config.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Load and validate `.warmtree.toml`.
|
|
2
|
+
|
|
3
|
+
Every key has a default. An empty file, or no file at all, means a pool of
|
|
4
|
+
two slots parked on the repo's default branch with nothing to warm.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import tomllib
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
CONFIG_NAME = ".warmtree.toml"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigError(Exception):
|
|
17
|
+
"""The config file is malformed or holds a value we cannot use."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Config:
|
|
22
|
+
size: int = 2
|
|
23
|
+
base: str | None = None # None means "detect the repo's default branch"
|
|
24
|
+
dir: str | None = None # None means "../.warmtree/<repo name>"
|
|
25
|
+
lockfiles: tuple[str, ...] = ()
|
|
26
|
+
run: tuple[str, ...] = ()
|
|
27
|
+
copy: tuple[str, ...] = ()
|
|
28
|
+
env: bool = True
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Which keys live in which table, and the TOML type each must have.
|
|
32
|
+
# Anything not listed here is a typo, and typos are errors rather than silent
|
|
33
|
+
# defaults so a misspelled `run` never quietly skips warming.
|
|
34
|
+
_SCHEMA: dict[str, dict[str, type]] = {
|
|
35
|
+
"pool": {"size": int, "base": str, "dir": str, "lockfiles": list},
|
|
36
|
+
"warm": {"run": list, "copy": list, "env": bool},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse(text: str) -> Config:
|
|
41
|
+
"""Turn TOML text into a Config, or raise ConfigError."""
|
|
42
|
+
try:
|
|
43
|
+
data = tomllib.loads(text)
|
|
44
|
+
except tomllib.TOMLDecodeError as exc:
|
|
45
|
+
raise ConfigError(f"invalid TOML: {exc}") from exc
|
|
46
|
+
|
|
47
|
+
values: dict[str, object] = {}
|
|
48
|
+
for table_name, table in data.items():
|
|
49
|
+
if table_name not in _SCHEMA or not isinstance(table, dict):
|
|
50
|
+
raise ConfigError(f"unknown table or key {table_name!r}")
|
|
51
|
+
for key, value in table.items():
|
|
52
|
+
expected = _SCHEMA[table_name].get(key)
|
|
53
|
+
if expected is None:
|
|
54
|
+
raise ConfigError(f"unknown key {key!r} in [{table_name}]")
|
|
55
|
+
values[key] = _check(table_name, key, value, expected)
|
|
56
|
+
|
|
57
|
+
size = values.get("size", Config.size)
|
|
58
|
+
if isinstance(size, int) and size < 0:
|
|
59
|
+
raise ConfigError("[pool] size must be zero or more")
|
|
60
|
+
return Config(**values) # type: ignore[arg-type]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _check(table: str, key: str, value: object, expected: type) -> object:
|
|
64
|
+
"""Validate one value against its expected type. Lists become tuples."""
|
|
65
|
+
# bool is a subclass of int in Python, so `size = true` needs its own check.
|
|
66
|
+
if expected is int and isinstance(value, bool):
|
|
67
|
+
raise ConfigError(f"[{table}] {key} must be an integer")
|
|
68
|
+
if not isinstance(value, expected):
|
|
69
|
+
raise ConfigError(f"[{table}] {key} must be of type {expected.__name__}")
|
|
70
|
+
if expected is list:
|
|
71
|
+
if not all(isinstance(item, str) for item in value):
|
|
72
|
+
raise ConfigError(f"[{table}] {key} must be a list of strings")
|
|
73
|
+
return tuple(value)
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load(repo_root: Path) -> Config:
|
|
78
|
+
"""Read the config at the repo root. A missing file means defaults."""
|
|
79
|
+
path = repo_root / CONFIG_NAME
|
|
80
|
+
if not path.exists():
|
|
81
|
+
return Config()
|
|
82
|
+
return parse(path.read_text(encoding="utf-8"))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# The `size = N` line in [pool]. Only the digits are replaced so comments
|
|
86
|
+
# and spacing on that line survive.
|
|
87
|
+
_SIZE_LINE = re.compile(r"^(\s*size\s*=\s*)\d+", re.MULTILINE)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def write_size(repo_root: Path, size: int) -> Path:
|
|
91
|
+
"""Set [pool] size in the config file, creating the file if needed.
|
|
92
|
+
|
|
93
|
+
Edits one line in place rather than regenerating the file, so the user's
|
|
94
|
+
comments and other keys are untouched. Returns the config path.
|
|
95
|
+
"""
|
|
96
|
+
if size < 0:
|
|
97
|
+
raise ConfigError("size must be zero or more")
|
|
98
|
+
path = repo_root / CONFIG_NAME
|
|
99
|
+
text = path.read_text(encoding="utf-8") if path.exists() else starter_toml([])
|
|
100
|
+
if _SIZE_LINE.search(text):
|
|
101
|
+
text = _SIZE_LINE.sub(rf"\g<1>{size}", text, count=1)
|
|
102
|
+
elif "[pool]" in text:
|
|
103
|
+
text = text.replace("[pool]", f"[pool]\nsize = {size}", 1)
|
|
104
|
+
else:
|
|
105
|
+
text = f"[pool]\nsize = {size}\n\n" + text
|
|
106
|
+
parse(text) # never write a file we cannot read back
|
|
107
|
+
path.write_text(text, encoding="utf-8")
|
|
108
|
+
return path
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def pool_dir(repo_root: Path, config: Config) -> Path:
|
|
112
|
+
"""Absolute path of the directory that holds the slots.
|
|
113
|
+
|
|
114
|
+
The default is a sibling folder named after the repo, so two repos that
|
|
115
|
+
share a parent folder never share a pool or a state file.
|
|
116
|
+
"""
|
|
117
|
+
if config.dir is None:
|
|
118
|
+
return (repo_root.parent / ".warmtree" / repo_root.name).resolve()
|
|
119
|
+
return (repo_root / config.dir).resolve()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def starter_toml(lockfiles: list[str]) -> str:
|
|
123
|
+
"""The file `warmtree init` writes. Defaults for everything except
|
|
124
|
+
`lockfiles`, which is pre-filled with what init found in the repo.
|
|
125
|
+
`run` is never guessed: warmtree does not know what a project needs.
|
|
126
|
+
"""
|
|
127
|
+
# json.dumps of a list of strings is valid TOML for an array of strings.
|
|
128
|
+
lockfiles_toml = json.dumps(lockfiles)
|
|
129
|
+
return f"""\
|
|
130
|
+
# warmtree config. Every key has a default; delete a line to use it.
|
|
131
|
+
|
|
132
|
+
[pool]
|
|
133
|
+
size = 2 # slots to keep ready
|
|
134
|
+
# base = "main" # branch slots park on; default: repo default branch
|
|
135
|
+
# dir = "../.warmtree/app" # where slots live; default is ../.warmtree/<repo name>
|
|
136
|
+
lockfiles = {lockfiles_toml} # re-warm a slot only when one of these changes
|
|
137
|
+
|
|
138
|
+
[warm]
|
|
139
|
+
run = [] # run inside a slot at fill and refresh, e.g. ["npm ci"]
|
|
140
|
+
copy = [] # untracked files copied from the main repo, e.g. [".env"]
|
|
141
|
+
env = true # write WARMTREE_SLOT=<n> into the copied env files
|
|
142
|
+
"""
|
warmtree/git.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Thin wrapper around the git command line.
|
|
2
|
+
|
|
3
|
+
Each function runs one git command and returns the little we need. Nothing
|
|
4
|
+
here is mocked in tests; they run against real temporary repos.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GitError(Exception):
|
|
12
|
+
"""git exited non-zero. The message is git's own stderr."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run(args: list[str], cwd: Path) -> str:
|
|
16
|
+
"""Run `git <args>` in cwd and return stripped stdout."""
|
|
17
|
+
result = subprocess.run(
|
|
18
|
+
["git", *args],
|
|
19
|
+
cwd=cwd,
|
|
20
|
+
capture_output=True,
|
|
21
|
+
text=True,
|
|
22
|
+
encoding="utf-8",
|
|
23
|
+
errors="replace",
|
|
24
|
+
)
|
|
25
|
+
if result.returncode != 0:
|
|
26
|
+
message = result.stderr.strip()
|
|
27
|
+
if not message:
|
|
28
|
+
message = f"git {' '.join(args)} exited with code {result.returncode}"
|
|
29
|
+
raise GitError(message)
|
|
30
|
+
return result.stdout.strip()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def repo_root(start: Path) -> Path:
|
|
34
|
+
"""Root of the main worktree that contains `start`.
|
|
35
|
+
|
|
36
|
+
`--git-common-dir` points at the shared `.git` directory even when `start`
|
|
37
|
+
is inside a linked worktree, so the pool always belongs to the main repo.
|
|
38
|
+
"""
|
|
39
|
+
common_dir = run(["rev-parse", "--git-common-dir"], cwd=start)
|
|
40
|
+
return (start / common_dir).resolve().parent
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def default_branch(repo: Path) -> str:
|
|
44
|
+
"""The branch slots park on when the config does not name one.
|
|
45
|
+
|
|
46
|
+
Prefer what the remote calls its default. Without a remote, fall back to
|
|
47
|
+
whatever branch the main worktree has checked out.
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
remote_head = run(
|
|
51
|
+
["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], cwd=repo
|
|
52
|
+
)
|
|
53
|
+
return remote_head.removeprefix("origin/")
|
|
54
|
+
except GitError:
|
|
55
|
+
pass
|
|
56
|
+
try:
|
|
57
|
+
return run(["symbolic-ref", "--short", "HEAD"], cwd=repo)
|
|
58
|
+
except GitError as exc:
|
|
59
|
+
raise GitError(
|
|
60
|
+
"cannot detect the default branch (HEAD is detached and there is no "
|
|
61
|
+
"origin/HEAD); set base in .warmtree.toml"
|
|
62
|
+
) from exc
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def rev_parse(repo: Path, ref: str) -> str:
|
|
66
|
+
"""Full commit hash that `ref` points at."""
|
|
67
|
+
return run(["rev-parse", "--verify", f"{ref}^{{commit}}"], cwd=repo)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def worktree_add_detached(repo: Path, path: Path, ref: str) -> None:
|
|
71
|
+
"""Create a worktree at `path` with a detached HEAD at `ref`.
|
|
72
|
+
|
|
73
|
+
Detached means no branch is checked out, so the slot never collides with
|
|
74
|
+
git's one-branch-per-worktree rule until `take` puts a branch on it.
|
|
75
|
+
"""
|
|
76
|
+
run(["worktree", "add", "--detach", str(path), ref], cwd=repo)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def worktree_list(repo: Path) -> list[Path]:
|
|
80
|
+
"""Paths of every worktree git knows about, main worktree first."""
|
|
81
|
+
output = run(["worktree", "list", "--porcelain"], cwd=repo)
|
|
82
|
+
paths = []
|
|
83
|
+
for line in output.splitlines():
|
|
84
|
+
if line.startswith("worktree "):
|
|
85
|
+
paths.append(Path(line.removeprefix("worktree ")).resolve())
|
|
86
|
+
return paths
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def head_branch(path: Path) -> str | None:
|
|
90
|
+
"""Name of the checked-out branch, or None when HEAD is detached."""
|
|
91
|
+
try:
|
|
92
|
+
return run(["symbolic-ref", "--quiet", "--short", "HEAD"], cwd=path)
|
|
93
|
+
except GitError:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def is_dirty(path: Path) -> bool:
|
|
98
|
+
"""True when the worktree has modified, staged, or untracked files."""
|
|
99
|
+
return bool(run(["status", "--porcelain"], cwd=path))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def branch_exists(repo: Path, name: str) -> bool:
|
|
103
|
+
try:
|
|
104
|
+
run(["rev-parse", "--verify", "--quiet", f"refs/heads/{name}"], cwd=repo)
|
|
105
|
+
except GitError:
|
|
106
|
+
return False
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def checkout_branch(path: Path, branch: str) -> None:
|
|
111
|
+
run(["checkout", "--quiet", branch], cwd=path)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def checkout_new_branch(path: Path, branch: str, start: str) -> None:
|
|
115
|
+
run(["checkout", "--quiet", "-b", branch, start], cwd=path)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def reset_to_detached(path: Path, ref: str) -> None:
|
|
119
|
+
"""Park the worktree on `ref` with a detached HEAD and a clean tree.
|
|
120
|
+
|
|
121
|
+
`clean -fd` removes untracked files but not ignored ones, so installed
|
|
122
|
+
dependencies such as `node_modules` survive. That is the warm state.
|
|
123
|
+
"""
|
|
124
|
+
run(["checkout", "--quiet", "--force", "--detach", ref], cwd=path)
|
|
125
|
+
run(["clean", "-fd", "--quiet"], cwd=path)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def branch_delete(repo: Path, name: str) -> bool:
|
|
129
|
+
"""Delete a fully merged branch. Returns False if git refused, which is
|
|
130
|
+
what happens when the branch has commits not yet merged anywhere."""
|
|
131
|
+
try:
|
|
132
|
+
run(["branch", "--delete", name], cwd=repo)
|
|
133
|
+
except GitError:
|
|
134
|
+
return False
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def worktree_add_branch(repo: Path, path: Path, branch: str, start: str) -> None:
|
|
139
|
+
"""Create a worktree with `branch` checked out, creating the branch at
|
|
140
|
+
`start` if it does not exist yet. This is the cold path."""
|
|
141
|
+
if branch_exists(repo, branch):
|
|
142
|
+
run(["worktree", "add", str(path), branch], cwd=repo)
|
|
143
|
+
else:
|
|
144
|
+
run(["worktree", "add", "-b", branch, str(path), start], cwd=repo)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def worktree_remove(repo: Path, path: Path) -> None:
|
|
148
|
+
"""Delete a worktree and its registration, ignored files included."""
|
|
149
|
+
if path.exists():
|
|
150
|
+
run(["worktree", "remove", "--force", str(path)], cwd=repo)
|
|
151
|
+
else:
|
|
152
|
+
run(["worktree", "prune"], cwd=repo)
|