forest-cli 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.
forest/checkout.py ADDED
@@ -0,0 +1,992 @@
1
+ """Checkout workspace models and filesystem primitives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fcntl
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ from collections.abc import Iterator
10
+ from contextlib import contextmanager
11
+ from pathlib import Path
12
+ from typing import Any
13
+ from urllib.parse import urlsplit
14
+
15
+ import yaml
16
+ from pydantic import BaseModel, Field, model_validator
17
+
18
+ from forest.config import (
19
+ ConfigError,
20
+ LocalConfig,
21
+ ProjectConfig,
22
+ load_config,
23
+ load_local_config_file,
24
+ save_config,
25
+ save_local_config_file,
26
+ )
27
+ from forest.fsutil import atomic_write
28
+
29
+
30
+ class CheckoutError(Exception):
31
+ """Raised for checkout workspace loading or validation errors."""
32
+
33
+
34
+ TOKEN_RE = re.compile(r"^[A-Za-z0-9._-]+$")
35
+ RESERVED_CHECKOUT_NAMES = frozenset({"create", "adopt", "list", "current", "remove"})
36
+ RESERVED_REMOTE_NAMES = RESERVED_CHECKOUT_NAMES | frozenset({"add", "list", "show", "remove", "use", "set"})
37
+ EMPTY_SYNC_STATE_TEXT = "[]\n"
38
+ SECRET_REMOTE_URL_RE = re.compile(
39
+ r"(?i)(password|passwd|token|secret|credential|access[_-]?key|session[_-]?token|x-amz-signature)\s*="
40
+ )
41
+ WORKSPACE_GITIGNORE_PATTERNS = (
42
+ ".forest/HEAD",
43
+ ".forest/checkouts/*/local.yaml",
44
+ ".forest/checkouts/*/sync_state.json",
45
+ ".forest/checkouts/*/sync_state.json.lock",
46
+ )
47
+ LEGACY_GITIGNORE_PATTERNS = (
48
+ ".forest/local.yaml",
49
+ ".forest/sync_state.json",
50
+ )
51
+ FOREST_OWNED_GITIGNORE_LINES = frozenset(
52
+ {
53
+ ".forest/",
54
+ ".forest/*",
55
+ "!.forest/config.yaml",
56
+ "!.forest/checkouts/",
57
+ "!.forest/checkouts/*/",
58
+ "!.forest/checkouts/*/forest.yaml",
59
+ "forest.yaml",
60
+ }
61
+ | set(WORKSPACE_GITIGNORE_PATTERNS)
62
+ | set(LEGACY_GITIGNORE_PATTERNS)
63
+ )
64
+
65
+
66
+ def valid_token(name: str) -> bool:
67
+ """Return True when *name* is a strict path-safe token."""
68
+ return isinstance(name, str) and bool(name) and name not in {".", ".."} and TOKEN_RE.fullmatch(name) is not None
69
+
70
+
71
+ def validate_token(
72
+ name: str,
73
+ *,
74
+ kind: str = "name",
75
+ reserved_names: set[str] | frozenset[str] = frozenset(),
76
+ existing_names: list[str] | tuple[str, ...] | set[str] | frozenset[str] = frozenset(),
77
+ ) -> str:
78
+ """Validate a path-safe token and reject case-insensitive collisions."""
79
+ if not valid_token(name):
80
+ raise CheckoutError(f"Invalid {kind} '{name}': use non-empty [A-Za-z0-9._-]+, not '.' or '..'.")
81
+
82
+ if name.lower() in {reserved.lower() for reserved in reserved_names}:
83
+ raise CheckoutError(f"Invalid {kind} '{name}': reserved word.")
84
+
85
+ existing_by_lower = {existing.lower(): existing for existing in existing_names}
86
+ collision = existing_by_lower.get(name.lower())
87
+ if collision is not None:
88
+ raise CheckoutError(f"Invalid {kind} '{name}': collides with existing {kind} '{collision}'.")
89
+
90
+ return name
91
+
92
+
93
+ def validate_checkout_name(
94
+ name: str,
95
+ *,
96
+ existing_names: list[str] | tuple[str, ...] | set[str] | frozenset[str] = frozenset(),
97
+ ) -> str:
98
+ """Validate a checkout name using the workspace token rules."""
99
+ return validate_token(
100
+ name,
101
+ kind="checkout name",
102
+ reserved_names=RESERVED_CHECKOUT_NAMES,
103
+ existing_names=existing_names,
104
+ )
105
+
106
+
107
+ def validate_remote_path(remote_path: str | Path) -> Path:
108
+ """Validate a workspace remote_path as a safe relative POSIX prefix."""
109
+ text = str(remote_path)
110
+ if (
111
+ not text
112
+ or text != text.strip()
113
+ or text.startswith("/")
114
+ or text.endswith("/")
115
+ or "//" in text
116
+ or "\\" in text
117
+ or "://" in text
118
+ ):
119
+ raise CheckoutError(f"Invalid remote_path '{text}': use a relative POSIX path with safe segments.")
120
+
121
+ parts = text.split("/")
122
+ for part in parts:
123
+ if not valid_token(part):
124
+ raise CheckoutError(f"Invalid remote_path '{text}': unsafe path segment '{part}'.")
125
+ return Path(*parts)
126
+
127
+
128
+ def remote_url_has_secret_material(url: str) -> bool:
129
+ """Return True when a remote URL-like value contains credentials or token-like material."""
130
+ parsed = urlsplit(url)
131
+ if parsed.query:
132
+ return True
133
+ if parsed.scheme and (parsed.username is not None or parsed.password is not None):
134
+ return True
135
+ if re.match(r"^[^/@:]+:[^/@]+@[^:]+:.+", url):
136
+ return True
137
+ return SECRET_REMOTE_URL_RE.search(url) is not None
138
+
139
+
140
+ def _is_relative_to(path: Path, parent: Path) -> bool:
141
+ try:
142
+ path.relative_to(parent)
143
+ except ValueError:
144
+ return False
145
+ return True
146
+
147
+
148
+ def normalize_workspace_path(path: str | Path, workspace_root: str | Path, *, cwd: str | Path | None = None) -> Path:
149
+ """Resolve a path and store inside-workspace paths relative to the workspace root."""
150
+ workspace_root = Path(workspace_root).resolve(strict=False)
151
+ base = Path(cwd).resolve(strict=False) if cwd is not None else workspace_root
152
+ candidate = Path(path)
153
+ if not candidate.is_absolute():
154
+ candidate = base / candidate
155
+ candidate = candidate.resolve(strict=False)
156
+
157
+ if _is_relative_to(candidate, workspace_root):
158
+ return candidate.relative_to(workspace_root)
159
+ return candidate
160
+
161
+
162
+ def validate_workspace_data_path(path: str | Path, workspace_root: str | Path) -> Path:
163
+ """Return a resolved data path, rejecting paths under workspace .forest metadata."""
164
+ workspace_root = Path(workspace_root).resolve(strict=False)
165
+ candidate = Path(path)
166
+ if not candidate.is_absolute():
167
+ candidate = workspace_root / candidate
168
+ candidate = candidate.resolve(strict=False)
169
+ forest_dir = (workspace_root / ".forest").resolve(strict=False)
170
+ if _is_relative_to(candidate, forest_dir):
171
+ raise CheckoutError("Data paths must not point inside .forest metadata.")
172
+ return candidate
173
+
174
+
175
+ class CheckoutEntry(BaseModel):
176
+ """A registered checkout entry. Empty in workspace metadata v1."""
177
+
178
+ model_config = {"extra": "forbid"}
179
+
180
+
181
+ class WorkspaceConfig(BaseModel):
182
+ """Workspace registry stored in .forest/config.yaml."""
183
+
184
+ version: int = 1
185
+ checkouts: dict[str, CheckoutEntry] = Field(default_factory=dict)
186
+
187
+ @model_validator(mode="after")
188
+ def _validate_checkout_names(self) -> WorkspaceConfig:
189
+ seen: list[str] = []
190
+ for name in self.checkouts:
191
+ validate_checkout_name(name, existing_names=seen)
192
+ seen.append(name)
193
+ return self
194
+
195
+
196
+ def _workspace_dir(workspace_root: str | Path) -> Path:
197
+ return Path(workspace_root) / ".forest"
198
+
199
+
200
+ def workspace_config_path(workspace_root: str | Path) -> Path:
201
+ """Return the workspace registry path."""
202
+ return _workspace_dir(workspace_root) / "config.yaml"
203
+
204
+
205
+ def checkout_dir(workspace_root: str | Path, checkout_name: str) -> Path:
206
+ """Return a checkout metadata directory path."""
207
+ return _workspace_dir(workspace_root) / "checkouts" / checkout_name
208
+
209
+
210
+ def checkout_metadata_paths(workspace_root: str | Path, checkout_name: str) -> tuple[Path, Path, Path]:
211
+ """Return shared config, local config, and sync-state paths for a checkout."""
212
+ root = checkout_dir(workspace_root, checkout_name)
213
+ return root / "forest.yaml", root / "local.yaml", root / "sync_state.json"
214
+
215
+
216
+ def load_workspace_config(workspace_root: str | Path) -> WorkspaceConfig:
217
+ """Load .forest/config.yaml from a workspace root."""
218
+ path = workspace_config_path(workspace_root)
219
+ data = _load_yaml_mapping(path, "Workspace config")
220
+
221
+ try:
222
+ return WorkspaceConfig(**data)
223
+ except Exception as exc:
224
+ raise CheckoutError(f"Invalid workspace config in {path}: {exc}") from None
225
+
226
+
227
+ def _load_yaml_mapping(path: Path, label: str) -> dict[str, Any]:
228
+ if not path.exists():
229
+ raise CheckoutError(f"{label} not found: {path}")
230
+ try:
231
+ raw = path.read_text()
232
+ except OSError as exc:
233
+ raise CheckoutError(f"Cannot read {label.lower()} {path}: {exc}") from None
234
+
235
+ try:
236
+ data = yaml.safe_load(raw)
237
+ except yaml.YAMLError as exc:
238
+ raise CheckoutError(f"Invalid YAML in {path}: {exc}") from None
239
+
240
+ if data is None:
241
+ raise CheckoutError(f"{label} is empty: {path}")
242
+ if not isinstance(data, dict):
243
+ raise CheckoutError(f"Expected a dict in {path}, got {type(data).__name__}")
244
+ return data
245
+
246
+
247
+ def _validate_workspace_shared_metadata(data: dict[str, Any], path: Path) -> None:
248
+ remotes = data.get("remotes", {})
249
+ if isinstance(remotes, dict):
250
+ seen_remotes: list[str] = []
251
+ for remote_name, remote_data in remotes.items():
252
+ validate_token(
253
+ str(remote_name),
254
+ kind="remote name",
255
+ reserved_names=RESERVED_REMOTE_NAMES,
256
+ existing_names=seen_remotes,
257
+ )
258
+ seen_remotes.append(str(remote_name))
259
+ if isinstance(remote_data, dict):
260
+ url = remote_data.get("url")
261
+ if isinstance(url, str) and remote_url_has_secret_material(url):
262
+ raise CheckoutError(f"Invalid remote '{remote_name}' in {path}: URL contains credentials.")
263
+ endpoint = remote_data.get("endpoint")
264
+ if isinstance(endpoint, str) and remote_url_has_secret_material(endpoint):
265
+ raise CheckoutError(f"Invalid remote '{remote_name}' in {path}: endpoint contains credentials.")
266
+
267
+ stages = data.get("stages", {})
268
+ if isinstance(stages, dict):
269
+ seen_stages: list[str] = []
270
+ for stage_name, stage_data in stages.items():
271
+ validate_token(
272
+ str(stage_name),
273
+ kind="stage name",
274
+ reserved_names=RESERVED_CHECKOUT_NAMES,
275
+ existing_names=seen_stages,
276
+ )
277
+ seen_stages.append(str(stage_name))
278
+ if isinstance(stage_data, dict) and stage_data.get("remote_path") is not None:
279
+ validate_remote_path(stage_data["remote_path"])
280
+
281
+
282
+ def _load_workspace_shared_config(shared_path: Path) -> ProjectConfig:
283
+ data = _load_yaml_mapping(shared_path, "Configuration file")
284
+ _validate_workspace_shared_metadata(data, shared_path)
285
+ return _load_shared_config(shared_path)
286
+
287
+
288
+ def _validate_legacy_remote_paths(config_path: Path) -> None:
289
+ data = _load_yaml_mapping(config_path, "Configuration file")
290
+ stages = data.get("stages", {})
291
+ if not isinstance(stages, dict):
292
+ return
293
+
294
+ for stage_name, stage_data in stages.items():
295
+ if not isinstance(stage_data, dict) or stage_data.get("remote_path") is None:
296
+ continue
297
+ try:
298
+ validate_remote_path(stage_data["remote_path"])
299
+ except CheckoutError as exc:
300
+ raise CheckoutError(f"Invalid remote_path for stage '{stage_name}' in {config_path}: {exc}") from None
301
+
302
+
303
+ def _workspace_config_data(config: WorkspaceConfig) -> dict[str, object]:
304
+ return {
305
+ "version": config.version,
306
+ "checkouts": {name: {} for name in config.checkouts},
307
+ }
308
+
309
+
310
+ def save_workspace_config(workspace_root: str | Path, config: WorkspaceConfig) -> None:
311
+ """Atomically save .forest/config.yaml for a workspace root."""
312
+ atomic_write(
313
+ workspace_config_path(workspace_root),
314
+ yaml.dump(_workspace_config_data(config), default_flow_style=False, sort_keys=False),
315
+ )
316
+
317
+
318
+ def read_head(workspace_root: str | Path) -> str:
319
+ """Read the active checkout name from .forest/HEAD."""
320
+ path = _workspace_dir(workspace_root) / "HEAD"
321
+ if not path.exists():
322
+ raise CheckoutError(f"No active checkout: {path} does not exist.")
323
+ try:
324
+ raw = path.read_text()
325
+ except OSError as exc:
326
+ raise CheckoutError(f"Cannot read active checkout {path}: {exc}") from None
327
+
328
+ name = raw[:-1] if raw.endswith("\n") else raw
329
+ if "\n" in name:
330
+ raise CheckoutError(f"Invalid active checkout in {path}: expected exactly one checkout name line.")
331
+ return validate_checkout_name(name)
332
+
333
+
334
+ def write_head(workspace_root: str | Path, checkout_name: str) -> None:
335
+ """Atomically write the active checkout name to .forest/HEAD."""
336
+ atomic_write(_workspace_dir(workspace_root) / "HEAD", validate_checkout_name(checkout_name) + "\n")
337
+
338
+
339
+ def list_checkouts(workspace_root: str | Path) -> list[str]:
340
+ """Return registered checkout names after validating metadata directories exist."""
341
+ config = load_workspace_config(workspace_root)
342
+ names = sorted(config.checkouts)
343
+ for name in names:
344
+ if not checkout_dir(workspace_root, name).is_dir():
345
+ raise CheckoutError(f"Workspace checkout '{name}' is registered but its metadata directory is missing.")
346
+ return names
347
+
348
+
349
+ def _minimal_shared_checkout_yaml(checkout_name: str) -> str:
350
+ return yaml.dump(
351
+ {"project": checkout_name, "remotes": {}, "stages": {}},
352
+ default_flow_style=False,
353
+ sort_keys=False,
354
+ )
355
+
356
+
357
+ def ensure_checkout_local_files(workspace_root: str | Path, checkout_name: str) -> None:
358
+ """Create missing local-only checkout files without overwriting existing state."""
359
+ _shared_path, local_path, state_path = checkout_metadata_paths(workspace_root, checkout_name)
360
+ if not local_path.exists():
361
+ atomic_write(local_path, "")
362
+ if not state_path.exists():
363
+ atomic_write(state_path, EMPTY_SYNC_STATE_TEXT)
364
+
365
+
366
+ def create_checkout(workspace_root: str | Path, checkout_name: str) -> None:
367
+ """Create checkout metadata, register it, and make it active."""
368
+ workspace_root = Path(workspace_root)
369
+ config_path = workspace_config_path(workspace_root)
370
+ registry = load_workspace_config(workspace_root) if config_path.exists() else WorkspaceConfig()
371
+
372
+ if checkout_name in registry.checkouts:
373
+ raise CheckoutError(f"Checkout '{checkout_name}' already exists. Run: forest checkout {checkout_name}")
374
+
375
+ validate_checkout_name(checkout_name, existing_names=list(registry.checkouts))
376
+ target_dir = checkout_dir(workspace_root, checkout_name)
377
+ if target_dir.exists() and any(target_dir.iterdir()):
378
+ raise CheckoutError(
379
+ f"Checkout metadata directory already exists and is not empty: {target_dir}. "
380
+ "Inspect or remove it before retrying."
381
+ )
382
+
383
+ target_existed = target_dir.exists()
384
+ config_text = _optional_text(config_path)
385
+ head_path = _workspace_dir(workspace_root) / "HEAD"
386
+ head_text = _optional_text(head_path)
387
+ gitignore_path = workspace_root / ".gitignore"
388
+ gitignore_text = _optional_text(gitignore_path)
389
+
390
+ try:
391
+ target_dir.mkdir(parents=True, exist_ok=True)
392
+ shared_path, local_path, state_path = checkout_metadata_paths(workspace_root, checkout_name)
393
+ atomic_write(shared_path, _minimal_shared_checkout_yaml(checkout_name))
394
+ atomic_write(local_path, "")
395
+ atomic_write(state_path, EMPTY_SYNC_STATE_TEXT)
396
+
397
+ registry.checkouts[checkout_name] = CheckoutEntry()
398
+ save_workspace_config(workspace_root, registry)
399
+ write_head(workspace_root, checkout_name)
400
+ ensure_workspace_gitignore(workspace_root, checkout_name)
401
+ except Exception:
402
+ if target_dir.exists():
403
+ if target_existed:
404
+ _empty_directory(target_dir)
405
+ else:
406
+ shutil.rmtree(target_dir)
407
+ _restore_optional_text(config_path, config_text)
408
+ _restore_optional_text(head_path, head_text)
409
+ _restore_optional_text(gitignore_path, gitignore_text)
410
+ raise
411
+
412
+
413
+ def require_registered_checkout(workspace_root: Path, checkout_name: str) -> WorkspaceConfig:
414
+ """Return the registry after validating the checkout is registered and has metadata."""
415
+ registry = load_workspace_config(workspace_root)
416
+ if checkout_name not in registry.checkouts:
417
+ raise CheckoutError(f"Checkout '{checkout_name}' is not registered.")
418
+ if not checkout_dir(workspace_root, checkout_name).is_dir():
419
+ raise CheckoutError(f"Workspace checkout '{checkout_name}' metadata directory is missing.")
420
+ return registry
421
+
422
+
423
+ def switch_checkout(workspace_root: str | Path, checkout_name: str) -> None:
424
+ """Switch active HEAD to an existing registered checkout."""
425
+ workspace_root = Path(workspace_root)
426
+ require_registered_checkout(workspace_root, checkout_name)
427
+
428
+ shared_path, local_path, _state_path = checkout_metadata_paths(workspace_root, checkout_name)
429
+ shared_config = _load_workspace_shared_config(shared_path)
430
+ ensure_checkout_local_files(workspace_root, checkout_name)
431
+ local_config = _load_checkout_local_config(local_path)
432
+ _validate_workspace_local_config(local_config, shared_config, workspace_root=workspace_root)
433
+ write_head(workspace_root, checkout_name)
434
+
435
+
436
+ def remove_checkout(workspace_root: str | Path, checkout_name: str) -> bool:
437
+ """Remove registered checkout metadata only. Return True when HEAD was removed."""
438
+ workspace_root = Path(workspace_root)
439
+ registry = require_registered_checkout(workspace_root, checkout_name)
440
+ target_dir = checkout_dir(workspace_root, checkout_name)
441
+
442
+ head_path = _workspace_dir(workspace_root) / "HEAD"
443
+ active_name: str | None = None
444
+ if head_path.exists():
445
+ active_name = read_head(workspace_root)
446
+ if active_name not in registry.checkouts:
447
+ raise CheckoutError(f"Active checkout '{active_name}' is not registered in workspace config.")
448
+
449
+ del registry.checkouts[checkout_name]
450
+ save_workspace_config(workspace_root, registry)
451
+ shutil.rmtree(target_dir)
452
+ removed_head = active_name == checkout_name
453
+ if removed_head:
454
+ head_path.unlink(missing_ok=True)
455
+ return removed_head
456
+
457
+
458
+ def _optional_text(path: Path) -> str | None:
459
+ return path.read_text() if path.exists() else None
460
+
461
+
462
+ def _restore_optional_text(path: Path, text: str | None) -> None:
463
+ if text is None:
464
+ path.unlink(missing_ok=True)
465
+ return
466
+ atomic_write(path, text)
467
+
468
+
469
+ def _empty_directory(path: Path) -> None:
470
+ if not path.exists():
471
+ return
472
+ for child in path.iterdir():
473
+ if child.is_dir():
474
+ shutil.rmtree(child)
475
+ else:
476
+ child.unlink()
477
+
478
+
479
+ def _git_check_ignore(workspace_root: Path, rel_path: str) -> int:
480
+ try:
481
+ result = subprocess.run(
482
+ ["git", "check-ignore", "--no-index", "--quiet", "--", rel_path],
483
+ cwd=workspace_root,
484
+ check=False,
485
+ capture_output=True,
486
+ text=True,
487
+ )
488
+ except FileNotFoundError as exc:
489
+ raise CheckoutError("gitignore assertion failed: git command not found") from exc
490
+ if result.returncode in {0, 1}:
491
+ return result.returncode
492
+ detail = result.stderr.strip() or result.stdout.strip() or f"git check-ignore exited {result.returncode}"
493
+ raise CheckoutError(f"gitignore assertion failed: {detail}")
494
+
495
+
496
+ def _is_inside_git_worktree(workspace_root: Path) -> bool:
497
+ try:
498
+ result = subprocess.run(
499
+ ["git", "rev-parse", "--is-inside-work-tree"],
500
+ cwd=workspace_root,
501
+ check=False,
502
+ capture_output=True,
503
+ text=True,
504
+ )
505
+ except FileNotFoundError:
506
+ return False
507
+ return result.returncode == 0 and result.stdout.strip() == "true"
508
+
509
+
510
+ def _managed_gitignore_text(original_text: str, patterns: tuple[str, ...]) -> str:
511
+ lines = original_text.splitlines()
512
+ kept = [line for line in lines if line.strip() not in FOREST_OWNED_GITIGNORE_LINES]
513
+ kept.extend(patterns)
514
+ return "\n".join(kept).rstrip("\n") + "\n"
515
+
516
+
517
+ def _ensure_gitignore(
518
+ root: Path,
519
+ *,
520
+ patterns: tuple[str, ...],
521
+ ignored_paths: tuple[str, ...],
522
+ tracked_paths: tuple[str, ...],
523
+ ) -> None:
524
+ gitignore_path = root / ".gitignore"
525
+ original_text = gitignore_path.read_text() if gitignore_path.exists() else ""
526
+ new_text = _managed_gitignore_text(original_text, patterns)
527
+ if new_text != original_text:
528
+ atomic_write(gitignore_path, new_text)
529
+
530
+ if not _is_inside_git_worktree(root):
531
+ return
532
+
533
+ for rel_path in ignored_paths:
534
+ if _git_check_ignore(root, rel_path) != 0:
535
+ raise CheckoutError(f"gitignore assertion failed: expected ignored local metadata: {rel_path}")
536
+
537
+ for rel_path in tracked_paths:
538
+ if _git_check_ignore(root, rel_path) == 0:
539
+ raise CheckoutError(f"gitignore assertion failed: shared metadata is ignored: {rel_path}")
540
+
541
+
542
+ def ensure_workspace_gitignore(workspace_root: str | Path, checkout_name: str) -> None:
543
+ """Ensure workspace local-only metadata ignores and assert shared metadata is trackable."""
544
+ workspace_root = Path(workspace_root)
545
+ _ensure_gitignore(
546
+ workspace_root,
547
+ patterns=WORKSPACE_GITIGNORE_PATTERNS,
548
+ ignored_paths=(
549
+ ".forest/HEAD",
550
+ f".forest/checkouts/{checkout_name}/local.yaml",
551
+ f".forest/checkouts/{checkout_name}/sync_state.json",
552
+ ),
553
+ tracked_paths=(
554
+ ".forest/config.yaml",
555
+ f".forest/checkouts/{checkout_name}/forest.yaml",
556
+ ),
557
+ )
558
+
559
+
560
+ def ensure_registry_gitignore(workspace_root: str | Path) -> None:
561
+ """Ensure local-only workspace ignores for a checkout-less registry (fresh init)."""
562
+ _ensure_gitignore(
563
+ Path(workspace_root),
564
+ patterns=WORKSPACE_GITIGNORE_PATTERNS,
565
+ ignored_paths=(".forest/HEAD",),
566
+ tracked_paths=(".forest/config.yaml",),
567
+ )
568
+
569
+
570
+ def ensure_legacy_gitignore(project_root: str | Path) -> None:
571
+ """Ensure legacy local state ignores while keeping root forest.yaml trackable."""
572
+ project_root = Path(project_root)
573
+ _ensure_gitignore(
574
+ project_root,
575
+ patterns=LEGACY_GITIGNORE_PATTERNS,
576
+ ignored_paths=LEGACY_GITIGNORE_PATTERNS,
577
+ tracked_paths=("forest.yaml",),
578
+ )
579
+
580
+
581
+ def _start_directory(start: str | Path) -> Path:
582
+ start_path = Path(start).resolve(strict=False)
583
+ return start_path.parent if start_path.is_file() else start_path
584
+
585
+
586
+ def _find_boundary(start: str | Path) -> Path | None:
587
+ current = _start_directory(start)
588
+ while True:
589
+ if (current / ".forest").exists() or (current / ".git").exists():
590
+ return current
591
+ if current.parent == current:
592
+ return None
593
+ current = current.parent
594
+
595
+
596
+ def _find_legacy_config_without_boundary(start: str | Path) -> Path | None:
597
+ current = _start_directory(start)
598
+ while True:
599
+ config_path = current / "forest.yaml"
600
+ if config_path.exists():
601
+ return config_path
602
+ if current.parent == current:
603
+ return None
604
+ current = current.parent
605
+
606
+
607
+ def _find_legacy_root_for_adopt(start: str | Path) -> Path:
608
+ current = _start_directory(start)
609
+ boundary = _find_boundary(current)
610
+ while True:
611
+ if workspace_config_path(current).exists():
612
+ raise CheckoutError(f"Cannot adopt legacy config: checkout workspace already exists at {current}.")
613
+ if (current / "forest.yaml").exists():
614
+ return current
615
+ if current == boundary or current.parent == current:
616
+ break
617
+ current = current.parent
618
+
619
+ if boundary is None:
620
+ fallback = _find_legacy_config_without_boundary(start)
621
+ if fallback is not None:
622
+ return fallback.parent
623
+ elif workspace_config_path(boundary).exists():
624
+ raise CheckoutError(f"Cannot adopt legacy config: checkout workspace already exists at {boundary}.")
625
+
626
+ raise CheckoutError("No legacy forest.yaml to adopt.")
627
+
628
+
629
+ def _validate_legacy_adopt_inputs(
630
+ legacy_config: ProjectConfig,
631
+ legacy_local: LocalConfig,
632
+ ) -> None:
633
+ seen_stages: list[str] = []
634
+ missing_paths: list[str] = []
635
+ for stage_name, stage_cfg in legacy_config.stages.items():
636
+ validate_token(
637
+ stage_name,
638
+ kind="stage name",
639
+ reserved_names=RESERVED_CHECKOUT_NAMES,
640
+ existing_names=seen_stages,
641
+ )
642
+ seen_stages.append(stage_name)
643
+ if stage_cfg.path is None:
644
+ missing_paths.append(stage_name)
645
+ if missing_paths:
646
+ joined = ", ".join(sorted(missing_paths))
647
+ raise CheckoutError(f"incomplete legacy metadata: stage path missing for {joined}.")
648
+
649
+ seen_remotes: list[str] = []
650
+ for remote_name in legacy_config.remotes:
651
+ validate_token(
652
+ remote_name,
653
+ kind="remote name",
654
+ reserved_names=RESERVED_CHECKOUT_NAMES,
655
+ existing_names=seen_remotes,
656
+ )
657
+ seen_remotes.append(remote_name)
658
+
659
+ if legacy_local.active_remote is not None and legacy_local.active_remote not in legacy_config.remotes:
660
+ raise CheckoutError(
661
+ f"incomplete legacy metadata: active remote '{legacy_local.active_remote}' is not in remotes."
662
+ )
663
+
664
+
665
+ def _effective_local_stage_path(root: Path, path: Path | None) -> Path | None:
666
+ if path is None:
667
+ return None
668
+ candidate = path if path.is_absolute() else root / path
669
+ return candidate.resolve(strict=False)
670
+
671
+
672
+ def _verify_adopted_checkout(
673
+ workspace_root: Path,
674
+ checkout_name: str,
675
+ legacy_config: ProjectConfig,
676
+ legacy_local: LocalConfig,
677
+ ) -> None:
678
+ merged, data_root, state_path = _resolve_workspace(workspace_root)
679
+ expected_state = checkout_dir(workspace_root, checkout_name) / "sync_state.json"
680
+ if data_root != workspace_root:
681
+ raise CheckoutError("adopt verify failed: workspace root changed.")
682
+ if state_path != expected_state:
683
+ raise CheckoutError("adopt verify failed: sync_state path changed.")
684
+ if merged.project != legacy_config.project:
685
+ raise CheckoutError("adopt verify failed: project name changed.")
686
+ if set(merged.stages) != set(legacy_config.stages):
687
+ raise CheckoutError("adopt verify failed: stage set changed.")
688
+ if merged.remotes != legacy_config.remotes:
689
+ raise CheckoutError("adopt verify failed: remote config changed.")
690
+ if merged.manifest != legacy_config.manifest:
691
+ raise CheckoutError("adopt verify failed: manifest config changed.")
692
+
693
+ local_config = _load_checkout_local_config(checkout_dir(workspace_root, checkout_name) / "local.yaml")
694
+ if local_config.active_remote != legacy_local.active_remote:
695
+ raise CheckoutError("adopt verify failed: active remote changed.")
696
+
697
+ _verify_adopted_stages(workspace_root, merged, legacy_config)
698
+
699
+
700
+ def _verify_adopted_stages(
701
+ workspace_root: Path,
702
+ merged: ProjectConfig,
703
+ legacy_config: ProjectConfig,
704
+ ) -> None:
705
+ for stage_name, legacy_stage in legacy_config.stages.items():
706
+ merged_stage = merged.stages[stage_name]
707
+ if merged_stage.schema_ != legacy_stage.schema_:
708
+ raise CheckoutError(f"adopt verify failed: schema changed for stage '{stage_name}'.")
709
+ if merged_stage.sync_by != legacy_stage.sync_by:
710
+ raise CheckoutError(f"adopt verify failed: sync_by changed for stage '{stage_name}'.")
711
+ if legacy_stage.remote_path is not None and merged_stage.remote_path != legacy_stage.remote_path:
712
+ raise CheckoutError(f"adopt verify failed: remote_path changed for stage '{stage_name}'.")
713
+ expected_path = _effective_local_stage_path(workspace_root, legacy_stage.path)
714
+ if merged_stage.path != expected_path:
715
+ raise CheckoutError(f"adopt verify failed: local path changed for stage '{stage_name}'.")
716
+
717
+
718
+ def _restore_adopt_staging(
719
+ *,
720
+ workspace_root: Path,
721
+ checkout_name: str,
722
+ target_existed: bool,
723
+ workspace_config_text: str | None,
724
+ head_text: str | None,
725
+ gitignore_text: str | None,
726
+ legacy_texts: dict[Path, str | None],
727
+ ) -> None:
728
+ target_dir = checkout_dir(workspace_root, checkout_name)
729
+ if target_dir.exists():
730
+ if target_existed:
731
+ _empty_directory(target_dir)
732
+ else:
733
+ shutil.rmtree(target_dir)
734
+ _restore_optional_text(workspace_config_path(workspace_root), workspace_config_text)
735
+ _restore_optional_text(_workspace_dir(workspace_root) / "HEAD", head_text)
736
+ _restore_optional_text(workspace_root / ".gitignore", gitignore_text)
737
+ for path, text in legacy_texts.items():
738
+ current = _optional_text(path)
739
+ if text is not None and current is None:
740
+ _restore_optional_text(path, text)
741
+
742
+
743
+ def _assert_legacy_metadata_unchanged(legacy_texts: dict[Path, str | None]) -> None:
744
+ for path, expected in legacy_texts.items():
745
+ if _optional_text(path) != expected:
746
+ raise CheckoutError(f"legacy metadata changed during adopt, rerun: {path}")
747
+
748
+
749
+ def adopt_checkout(start: str | Path, checkout_name: str) -> Path:
750
+ """Adopt legacy root metadata into a checkout via copy, build, verify, then delete-old."""
751
+ validate_checkout_name(checkout_name)
752
+ legacy_root = _find_legacy_root_for_adopt(start)
753
+ if workspace_config_path(legacy_root).exists():
754
+ raise CheckoutError(f"Cannot adopt legacy config: checkout workspace already exists at {legacy_root}.")
755
+
756
+ target_dir = checkout_dir(legacy_root, checkout_name)
757
+ if target_dir.exists() and any(target_dir.iterdir()):
758
+ raise CheckoutError(
759
+ f"Checkout metadata directory already exists and is not empty: {target_dir}. "
760
+ "Inspect or remove it before retrying."
761
+ )
762
+ target_existed = target_dir.exists()
763
+
764
+ root_config_path = legacy_root / "forest.yaml"
765
+ local_path = legacy_root / ".forest" / "local.yaml"
766
+ state_path = legacy_root / ".forest" / "sync_state.json"
767
+ if not root_config_path.exists():
768
+ raise CheckoutError("No legacy forest.yaml to adopt.")
769
+ if not local_path.exists() or not state_path.exists():
770
+ raise CheckoutError("incomplete legacy metadata: expected .forest/local.yaml and .forest/sync_state.json.")
771
+
772
+ try:
773
+ legacy_config = _load_shared_config(root_config_path)
774
+ legacy_local = _load_checkout_local_config(local_path)
775
+ except CheckoutError:
776
+ raise
777
+
778
+ _validate_legacy_adopt_inputs(legacy_config, legacy_local)
779
+
780
+ workspace_config_text = _optional_text(workspace_config_path(legacy_root))
781
+ head_text = _optional_text(_workspace_dir(legacy_root) / "HEAD")
782
+ gitignore_text = _optional_text(legacy_root / ".gitignore")
783
+ legacy_texts = {
784
+ root_config_path: _optional_text(root_config_path),
785
+ local_path: _optional_text(local_path),
786
+ state_path: _optional_text(state_path),
787
+ }
788
+
789
+ try:
790
+ target_dir.mkdir(parents=True, exist_ok=True)
791
+ shared_path, adopted_local_path, adopted_state_path = checkout_metadata_paths(legacy_root, checkout_name)
792
+ shutil.copy2(root_config_path, shared_path)
793
+ shutil.copy2(local_path, adopted_local_path)
794
+ shutil.copy2(state_path, adopted_state_path)
795
+
796
+ stage_paths = dict(legacy_local.stage_paths)
797
+ split_stages = {}
798
+ for stage_name, stage_cfg in legacy_config.stages.items():
799
+ if stage_cfg.path is None:
800
+ raise CheckoutError(f"incomplete legacy metadata: stage path missing for {stage_name}.")
801
+ stage_paths[stage_name] = stage_cfg.path
802
+ split_stages[stage_name] = stage_cfg.model_copy(update={"path": None})
803
+
804
+ shared_config = legacy_config.model_copy(update={"stages": split_stages}, deep=True)
805
+ split_local = legacy_local.model_copy(update={"stage_paths": stage_paths}, deep=True)
806
+ save_config(shared_config, shared_path, include_stage_paths=False)
807
+ save_local_config_file(split_local, adopted_local_path)
808
+ atomic_write(adopted_state_path, state_path.read_text())
809
+
810
+ registry = WorkspaceConfig(checkouts={checkout_name: CheckoutEntry()})
811
+ save_workspace_config(legacy_root, registry)
812
+ write_head(legacy_root, checkout_name)
813
+ ensure_workspace_gitignore(legacy_root, checkout_name)
814
+ _verify_adopted_checkout(legacy_root, checkout_name, legacy_config, legacy_local)
815
+ _assert_legacy_metadata_unchanged(legacy_texts)
816
+
817
+ root_config_path.unlink()
818
+ local_path.unlink()
819
+ state_path.unlink()
820
+
821
+ resolved, data_root, final_state_path = resolve_active_config(legacy_root)
822
+ if (
823
+ data_root != legacy_root
824
+ or final_state_path != adopted_state_path
825
+ or resolved.project != legacy_config.project
826
+ ):
827
+ raise CheckoutError("adopt verify failed after deleting legacy originals.")
828
+ except Exception:
829
+ _restore_adopt_staging(
830
+ workspace_root=legacy_root,
831
+ checkout_name=checkout_name,
832
+ target_existed=target_existed,
833
+ workspace_config_text=workspace_config_text,
834
+ head_text=head_text,
835
+ gitignore_text=gitignore_text,
836
+ legacy_texts=legacy_texts,
837
+ )
838
+ raise
839
+
840
+ return legacy_root
841
+
842
+
843
+ def find_workspace_root(start: str | Path) -> Path | None:
844
+ """Find a workspace root without piercing a nearer .forest or .git boundary."""
845
+ boundary = _find_boundary(start)
846
+ if boundary is None:
847
+ return None
848
+
849
+ workspace_config = workspace_config_path(boundary)
850
+ legacy_config = boundary / "forest.yaml"
851
+ if workspace_config.exists() and legacy_config.exists():
852
+ raise CheckoutError(f"Ambiguous forest config: both {legacy_config} and {workspace_config} exist.")
853
+ if workspace_config.exists():
854
+ return boundary
855
+ return None
856
+
857
+
858
+ def find_project_boundary(start: str | Path) -> Path:
859
+ """Find the nearest .forest/.git boundary, or use the start directory."""
860
+ boundary = _find_boundary(start)
861
+ return boundary if boundary is not None else _start_directory(start)
862
+
863
+
864
+ def _load_checkout_local_config(local_path: Path) -> LocalConfig:
865
+ try:
866
+ return load_local_config_file(local_path)
867
+ except ConfigError as exc:
868
+ raise CheckoutError(str(exc)) from None
869
+
870
+
871
+ def _load_shared_config(shared_path: Path) -> ProjectConfig:
872
+ try:
873
+ return load_config(shared_path)
874
+ except ConfigError as exc:
875
+ raise CheckoutError(str(exc)) from None
876
+
877
+
878
+ def _validate_workspace_local_config(
879
+ local_config: LocalConfig,
880
+ shared_config: ProjectConfig,
881
+ *,
882
+ workspace_root: Path,
883
+ ) -> None:
884
+ if local_config.active_remote is not None:
885
+ validate_token(
886
+ local_config.active_remote,
887
+ kind="active_remote",
888
+ reserved_names=RESERVED_REMOTE_NAMES,
889
+ )
890
+
891
+ shared_stage_by_lower = {name.lower(): name for name in shared_config.stages}
892
+ seen_bindings: list[str] = []
893
+ for stage_name, stage_path in local_config.stage_paths.items():
894
+ validate_token(
895
+ stage_name,
896
+ kind="stage binding",
897
+ reserved_names=RESERVED_CHECKOUT_NAMES,
898
+ existing_names=seen_bindings,
899
+ )
900
+ seen_bindings.append(stage_name)
901
+ canonical_stage = shared_stage_by_lower.get(stage_name.lower())
902
+ if canonical_stage is not None and canonical_stage != stage_name:
903
+ raise CheckoutError(f"Invalid stage binding '{stage_name}': collides with stage name '{canonical_stage}'.")
904
+ validate_workspace_data_path(stage_path, workspace_root)
905
+
906
+
907
+ def _inject_workspace_stage_paths(
908
+ config: ProjectConfig,
909
+ local_config: LocalConfig,
910
+ *,
911
+ workspace_root: Path,
912
+ checkout_name: str,
913
+ ) -> ProjectConfig:
914
+ stages = {}
915
+ for stage_name, stage in config.stages.items():
916
+ validate_token(stage_name, kind="stage name", reserved_names=RESERVED_CHECKOUT_NAMES)
917
+ local_path = local_config.stage_paths.get(stage_name)
918
+ injected_path = None
919
+ if local_path is not None:
920
+ injected_path = validate_workspace_data_path(local_path, workspace_root)
921
+
922
+ default_remote_path = Path(checkout_name) / stage_name
923
+ remote_path = validate_remote_path(stage.remote_path) if stage.remote_path is not None else default_remote_path
924
+ stages[stage_name] = stage.model_copy(update={"path": injected_path, "remote_path": remote_path})
925
+ return config.model_copy(update={"stages": stages}, deep=True)
926
+
927
+
928
+ def _resolve_workspace(workspace_root: Path) -> tuple[ProjectConfig, Path, Path]:
929
+ registry = load_workspace_config(workspace_root)
930
+ checkout_name = read_head(workspace_root)
931
+ if checkout_name not in registry.checkouts:
932
+ raise CheckoutError(f"Active checkout '{checkout_name}' is not registered in workspace config.")
933
+
934
+ active_dir = checkout_dir(workspace_root, checkout_name)
935
+ if not active_dir.is_dir():
936
+ raise CheckoutError(f"Active checkout '{checkout_name}' metadata directory is missing.")
937
+
938
+ shared_config = _load_workspace_shared_config(active_dir / "forest.yaml")
939
+ local_config = _load_checkout_local_config(active_dir / "local.yaml")
940
+ _validate_workspace_local_config(local_config, shared_config, workspace_root=workspace_root)
941
+ merged = _inject_workspace_stage_paths(
942
+ shared_config,
943
+ local_config,
944
+ workspace_root=workspace_root,
945
+ checkout_name=checkout_name,
946
+ )
947
+ return merged, workspace_root, active_dir / "sync_state.json"
948
+
949
+
950
+ def _resolve_legacy(config_path: Path) -> tuple[ProjectConfig, Path, Path]:
951
+ _validate_legacy_remote_paths(config_path)
952
+ config = _load_shared_config(config_path)
953
+ data_root = config_path.parent
954
+ return config, data_root, data_root / ".forest" / "sync_state.json"
955
+
956
+
957
+ def resolve_active_config(start_cwd: str | Path) -> tuple[ProjectConfig, Path, Path]:
958
+ """Resolve active ProjectConfig, data root, and sync-state path.
959
+
960
+ Workspace mode merges local stage bindings on read. Legacy mode loads the
961
+ root forest.yaml unchanged when no workspace registry is present.
962
+ """
963
+ boundary = _find_boundary(start_cwd)
964
+ if boundary is not None:
965
+ workspace_config = workspace_config_path(boundary)
966
+ legacy_config = boundary / "forest.yaml"
967
+ if workspace_config.exists() and legacy_config.exists():
968
+ raise CheckoutError(f"Ambiguous forest config: both {legacy_config} and {workspace_config} exist.")
969
+ if workspace_config.exists():
970
+ return _resolve_workspace(boundary)
971
+ if legacy_config.exists():
972
+ return _resolve_legacy(legacy_config)
973
+ else:
974
+ fallback_legacy_config = _find_legacy_config_without_boundary(start_cwd)
975
+ if fallback_legacy_config is not None:
976
+ return _resolve_legacy(fallback_legacy_config)
977
+
978
+ raise CheckoutError("No forest project found. Run 'forest init' or create a checkout workspace.")
979
+
980
+
981
+ @contextmanager
982
+ def checkout_lock(state_path: str | Path) -> Iterator[None]:
983
+ """Acquire an exclusive per-checkout lock next to a sync_state.json file."""
984
+ state_path = Path(state_path)
985
+ lock_path = state_path.with_suffix(state_path.suffix + ".lock")
986
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
987
+ with lock_path.open("a+") as handle:
988
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
989
+ try:
990
+ yield
991
+ finally:
992
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)