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/__init__.py +16 -0
- forest/analytics.py +29 -0
- forest/checkout.py +992 -0
- forest/cli.py +2747 -0
- forest/config.py +286 -0
- forest/flags.py +20 -0
- forest/flow.py +157 -0
- forest/fsutil.py +36 -0
- forest/logger.py +197 -0
- forest/manifest.py +112 -0
- forest/metrics.py +67 -0
- forest/monitoring.py +138 -0
- forest/paths.py +192 -0
- forest/rclone.py +561 -0
- forest/resilience.py +122 -0
- forest/sync_state.py +182 -0
- forest_cli-0.1.0.dist-info/METADATA +233 -0
- forest_cli-0.1.0.dist-info/RECORD +22 -0
- forest_cli-0.1.0.dist-info/WHEEL +5 -0
- forest_cli-0.1.0.dist-info/entry_points.txt +2 -0
- forest_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- forest_cli-0.1.0.dist-info/top_level.txt +1 -0
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)
|