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 ADDED
@@ -0,0 +1,3 @@
1
+ """warmtree: a pool of pre-warmed git worktrees."""
2
+
3
+ __version__ = "0.1.0"
warmtree/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow `python -m warmtree`, which the background refill uses."""
2
+
3
+ from warmtree.cli import main
4
+
5
+ raise SystemExit(main())
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)