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/cli.py
ADDED
|
@@ -0,0 +1,2747 @@
|
|
|
1
|
+
"""Click CLI for forest."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from functools import partial
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
from forest import __version__, analytics, metrics, monitoring
|
|
16
|
+
from forest import rclone as _rclone
|
|
17
|
+
from forest.checkout import (
|
|
18
|
+
RESERVED_CHECKOUT_NAMES,
|
|
19
|
+
RESERVED_REMOTE_NAMES,
|
|
20
|
+
CheckoutError,
|
|
21
|
+
WorkspaceConfig,
|
|
22
|
+
adopt_checkout,
|
|
23
|
+
checkout_dir,
|
|
24
|
+
checkout_lock,
|
|
25
|
+
checkout_metadata_paths,
|
|
26
|
+
create_checkout,
|
|
27
|
+
ensure_registry_gitignore,
|
|
28
|
+
ensure_workspace_gitignore,
|
|
29
|
+
find_project_boundary,
|
|
30
|
+
find_workspace_root,
|
|
31
|
+
list_checkouts,
|
|
32
|
+
load_workspace_config,
|
|
33
|
+
normalize_workspace_path,
|
|
34
|
+
read_head,
|
|
35
|
+
remote_url_has_secret_material,
|
|
36
|
+
require_registered_checkout,
|
|
37
|
+
resolve_active_config,
|
|
38
|
+
save_workspace_config,
|
|
39
|
+
switch_checkout,
|
|
40
|
+
validate_token,
|
|
41
|
+
validate_workspace_data_path,
|
|
42
|
+
)
|
|
43
|
+
from forest.checkout import (
|
|
44
|
+
remove_checkout as remove_checkout_metadata,
|
|
45
|
+
)
|
|
46
|
+
from forest.config import (
|
|
47
|
+
ConfigError,
|
|
48
|
+
LocalConfig,
|
|
49
|
+
ProjectConfig,
|
|
50
|
+
RemoteConfig,
|
|
51
|
+
StageConfig,
|
|
52
|
+
load_config,
|
|
53
|
+
load_local_config_file,
|
|
54
|
+
save_config,
|
|
55
|
+
save_local_config_file,
|
|
56
|
+
)
|
|
57
|
+
from forest.flow import build_graph, render_mermaid
|
|
58
|
+
from forest.fsutil import atomic_write
|
|
59
|
+
from forest.logger import configure_logging, get_logger, new_run_id
|
|
60
|
+
from forest.manifest import ManifestError, load_manifest
|
|
61
|
+
from forest.paths import (
|
|
62
|
+
_effective_remote_base,
|
|
63
|
+
discover_units,
|
|
64
|
+
list_sync_files,
|
|
65
|
+
resolve_manifest_pairs,
|
|
66
|
+
resolve_paths,
|
|
67
|
+
)
|
|
68
|
+
from forest.rclone import (
|
|
69
|
+
RCLONE_INSTALL_MSG,
|
|
70
|
+
RcloneError,
|
|
71
|
+
RcloneTransport,
|
|
72
|
+
RemoteFile,
|
|
73
|
+
TransferResult,
|
|
74
|
+
build_remote,
|
|
75
|
+
get_transport,
|
|
76
|
+
)
|
|
77
|
+
from forest.sync_state import SyncEntry, SyncState, compute_checksum, load_state, record_pull, record_push, save_state
|
|
78
|
+
|
|
79
|
+
_log = get_logger("forest.cli")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def is_available() -> bool:
|
|
83
|
+
"""Return live rclone availability, keeping CLI gates monkeypatch-safe."""
|
|
84
|
+
return _rclone.is_available()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ForestGroup(click.Group):
|
|
88
|
+
"""Custom group that catches unhandled exceptions and formats them."""
|
|
89
|
+
|
|
90
|
+
def invoke(self, ctx: click.Context) -> None:
|
|
91
|
+
# ctx.invoked_subcommand is only set once Click resolves the subcommand
|
|
92
|
+
# inside super().invoke(), so read it at use time, not up front.
|
|
93
|
+
start = time.monotonic()
|
|
94
|
+
status = "error"
|
|
95
|
+
try:
|
|
96
|
+
super().invoke(ctx)
|
|
97
|
+
status = "success"
|
|
98
|
+
except click.exceptions.Exit as exc:
|
|
99
|
+
if exc.exit_code == 0:
|
|
100
|
+
status = "success"
|
|
101
|
+
raise
|
|
102
|
+
except (click.Abort, click.ClickException):
|
|
103
|
+
raise
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
monitoring.report_failure(
|
|
106
|
+
"unhandled command error",
|
|
107
|
+
str(exc),
|
|
108
|
+
command=ctx.invoked_subcommand or "forest",
|
|
109
|
+
exc=exc,
|
|
110
|
+
)
|
|
111
|
+
click.echo(f"Error: {exc}", err=True)
|
|
112
|
+
ctx.exit(1)
|
|
113
|
+
finally:
|
|
114
|
+
analytics.record_command(
|
|
115
|
+
ctx.invoked_subcommand or "forest",
|
|
116
|
+
duration_seconds=time.monotonic() - start,
|
|
117
|
+
status=status,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _echo_info(ctx: click.Context, msg: str) -> None:
|
|
122
|
+
"""Echo to stdout. Suppressed by --quiet."""
|
|
123
|
+
if not ctx.obj.get("quiet"):
|
|
124
|
+
click.echo(msg)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _echo_verbose(ctx: click.Context, msg: str) -> None:
|
|
128
|
+
"""Echo verbose details to stdout. Only with --verbose, suppressed by --quiet."""
|
|
129
|
+
if ctx.obj.get("verbose") and not ctx.obj.get("quiet"):
|
|
130
|
+
click.echo(msg)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _echo_error(msg: str) -> None:
|
|
134
|
+
"""Echo to stderr. Never suppressed."""
|
|
135
|
+
click.echo(msg, err=True)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _transport_error_detail(exc: Exception) -> str:
|
|
139
|
+
"""Return user-facing transport failure detail without losing rclone stderr."""
|
|
140
|
+
if isinstance(exc, RcloneError):
|
|
141
|
+
return (exc.stderr or str(exc)).strip()
|
|
142
|
+
return str(exc)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _preflight_remote_config(ctx: click.Context, remote_cfg: RemoteConfig) -> bool:
|
|
146
|
+
"""Validate transport config before any rclone network operation."""
|
|
147
|
+
try:
|
|
148
|
+
build_remote(remote_cfg)
|
|
149
|
+
except ValueError as exc:
|
|
150
|
+
_echo_error(f"Error: {exc}")
|
|
151
|
+
ctx.exit(1)
|
|
152
|
+
return False
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@click.group(cls=ForestGroup, context_settings={"help_option_names": ["-h", "--help"]})
|
|
157
|
+
@click.version_option(version=__version__, prog_name="forest")
|
|
158
|
+
@click.option("-C", "chdir", default=None, type=click.Path(path_type=Path), help="Run as if started in PATH.")
|
|
159
|
+
@click.option("--verbose", is_flag=True, help="Enable detailed output.")
|
|
160
|
+
@click.option("--quiet", is_flag=True, help="Suppress all output except errors.")
|
|
161
|
+
@click.pass_context
|
|
162
|
+
def main(ctx: click.Context, chdir: Path | None, verbose: bool, quiet: bool) -> None:
|
|
163
|
+
"""git-like data management for arbitrary data trees."""
|
|
164
|
+
if verbose and quiet:
|
|
165
|
+
raise click.UsageError("--verbose and --quiet are mutually exclusive.")
|
|
166
|
+
ctx.ensure_object(dict)
|
|
167
|
+
discovery_cwd = Path.cwd()
|
|
168
|
+
if chdir is not None:
|
|
169
|
+
discovery_cwd = chdir if chdir.is_absolute() else discovery_cwd / chdir
|
|
170
|
+
if not discovery_cwd.exists():
|
|
171
|
+
raise click.BadParameter(f"path does not exist: {chdir}", param_hint="-C")
|
|
172
|
+
if not discovery_cwd.is_dir():
|
|
173
|
+
raise click.BadParameter(f"path is not a directory: {chdir}", param_hint="-C")
|
|
174
|
+
ctx.obj["cwd"] = discovery_cwd.resolve(strict=False)
|
|
175
|
+
ctx.obj["verbose"] = verbose
|
|
176
|
+
ctx.obj["quiet"] = quiet
|
|
177
|
+
|
|
178
|
+
# Observability: one trace ID per invocation, stamped on every log record,
|
|
179
|
+
# metric sample, and error/alert report (see forest.logger docs).
|
|
180
|
+
run_id = new_run_id()
|
|
181
|
+
configure_logging(verbose=verbose)
|
|
182
|
+
monitoring.init_error_tracking(command=ctx.invoked_subcommand or "forest")
|
|
183
|
+
_log.info(
|
|
184
|
+
"invocation",
|
|
185
|
+
extra={"command": ctx.invoked_subcommand or "", "argv": sys.argv[1:], "cwd": str(ctx.obj["cwd"])},
|
|
186
|
+
)
|
|
187
|
+
ctx.obj["run_id"] = run_id
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass
|
|
191
|
+
class ProjectContext:
|
|
192
|
+
"""Resolved active project context for CLI commands."""
|
|
193
|
+
|
|
194
|
+
config: ProjectConfig
|
|
195
|
+
data_root: Path
|
|
196
|
+
state_path: Path
|
|
197
|
+
config_path: Path
|
|
198
|
+
local_config_path: Path
|
|
199
|
+
mode: str
|
|
200
|
+
checkout_name: str | None = None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _handle_checkout_error(ctx: click.Context, exc: Exception) -> None:
|
|
204
|
+
msg = str(exc)
|
|
205
|
+
if "Ambiguous forest config" in msg:
|
|
206
|
+
_echo_error(f"Error: {msg}")
|
|
207
|
+
_echo_error("Remove one config, or run 'forest checkout adopt NAME' to migrate the legacy root config.")
|
|
208
|
+
elif msg.startswith("No active checkout"):
|
|
209
|
+
_echo_error("Error: no active checkout selected.")
|
|
210
|
+
_print_checkout_guidance(ctx, err=True)
|
|
211
|
+
else:
|
|
212
|
+
_echo_error(f"Error: {msg}")
|
|
213
|
+
ctx.exit(1)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _print_checkout_guidance(ctx: click.Context, *, err: bool = False) -> None:
|
|
217
|
+
start = Path(ctx.obj["cwd"])
|
|
218
|
+
try:
|
|
219
|
+
workspace_root = find_workspace_root(start)
|
|
220
|
+
names = sorted(load_workspace_config(workspace_root).checkouts) if workspace_root is not None else []
|
|
221
|
+
except CheckoutError:
|
|
222
|
+
names = []
|
|
223
|
+
|
|
224
|
+
echo = _echo_error if err else lambda message: _echo_info(ctx, message)
|
|
225
|
+
if names:
|
|
226
|
+
for name in names:
|
|
227
|
+
echo(f"Run: forest checkout {name}")
|
|
228
|
+
else:
|
|
229
|
+
echo("Run: forest checkout create NAME")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _get_project_context(ctx: click.Context) -> ProjectContext:
|
|
233
|
+
"""Resolve and cache active config, data root, state path, and writable paths."""
|
|
234
|
+
cached = ctx.obj.get("project_context")
|
|
235
|
+
if isinstance(cached, ProjectContext):
|
|
236
|
+
return cached
|
|
237
|
+
|
|
238
|
+
start = Path(ctx.obj["cwd"])
|
|
239
|
+
try:
|
|
240
|
+
config, data_root, state_path = resolve_active_config(start)
|
|
241
|
+
workspace_config = data_root / ".forest" / "config.yaml"
|
|
242
|
+
if workspace_config.exists():
|
|
243
|
+
checkout_name = read_head(data_root)
|
|
244
|
+
registry = load_workspace_config(data_root)
|
|
245
|
+
if checkout_name not in registry.checkouts:
|
|
246
|
+
raise CheckoutError(f"Active checkout '{checkout_name}' is not registered in workspace config.")
|
|
247
|
+
active_dir = checkout_dir(data_root, checkout_name)
|
|
248
|
+
if not active_dir.is_dir():
|
|
249
|
+
raise CheckoutError(f"Active checkout '{checkout_name}' metadata directory is missing.")
|
|
250
|
+
project_context = ProjectContext(
|
|
251
|
+
config=config,
|
|
252
|
+
data_root=data_root,
|
|
253
|
+
state_path=state_path,
|
|
254
|
+
config_path=active_dir / "forest.yaml",
|
|
255
|
+
local_config_path=active_dir / "local.yaml",
|
|
256
|
+
mode="workspace",
|
|
257
|
+
checkout_name=checkout_name,
|
|
258
|
+
)
|
|
259
|
+
else:
|
|
260
|
+
project_context = ProjectContext(
|
|
261
|
+
config=config,
|
|
262
|
+
data_root=data_root,
|
|
263
|
+
state_path=state_path,
|
|
264
|
+
config_path=data_root / "forest.yaml",
|
|
265
|
+
local_config_path=data_root / ".forest" / "local.yaml",
|
|
266
|
+
mode="legacy",
|
|
267
|
+
)
|
|
268
|
+
except (CheckoutError, ConfigError) as exc:
|
|
269
|
+
_handle_checkout_error(ctx, exc)
|
|
270
|
+
raise AssertionError("unreachable") from exc
|
|
271
|
+
|
|
272
|
+
ctx.obj["project_context"] = project_context
|
|
273
|
+
ctx.obj["config"] = project_context.config
|
|
274
|
+
ctx.obj["data_root"] = project_context.data_root
|
|
275
|
+
ctx.obj["state_path"] = project_context.state_path
|
|
276
|
+
ctx.obj["config_path"] = project_context.config_path
|
|
277
|
+
return project_context
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _load_project_config(ctx: click.Context) -> ProjectConfig:
|
|
281
|
+
"""Return the active project config resolved from cwd or -C."""
|
|
282
|
+
return _get_project_context(ctx).config
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _load_writable_project_config(ctx: click.Context) -> ProjectConfig:
|
|
286
|
+
"""Load the shared config file that writers may safely save."""
|
|
287
|
+
project_context = _get_project_context(ctx)
|
|
288
|
+
if project_context.mode == "workspace":
|
|
289
|
+
try:
|
|
290
|
+
return load_config(project_context.config_path)
|
|
291
|
+
except ConfigError as exc:
|
|
292
|
+
_handle_checkout_error(ctx, exc)
|
|
293
|
+
raise AssertionError("unreachable") from exc
|
|
294
|
+
return project_context.config
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _save_project_config(ctx: click.Context, config: ProjectConfig) -> None:
|
|
298
|
+
project_context = _get_project_context(ctx)
|
|
299
|
+
save_config(config, project_context.config_path, include_stage_paths=project_context.mode == "legacy")
|
|
300
|
+
project_context.config = config
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _load_active_local_config(ctx: click.Context) -> LocalConfig:
|
|
304
|
+
project_context = _get_project_context(ctx)
|
|
305
|
+
try:
|
|
306
|
+
return load_local_config_file(project_context.local_config_path)
|
|
307
|
+
except ConfigError as exc:
|
|
308
|
+
_handle_checkout_error(ctx, exc)
|
|
309
|
+
raise AssertionError("unreachable") from exc
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _save_active_local_config(ctx: click.Context, config: LocalConfig) -> None:
|
|
313
|
+
save_local_config_file(config, _get_project_context(ctx).local_config_path)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _project_root(ctx: click.Context) -> Path:
|
|
317
|
+
"""Return the resolved data root."""
|
|
318
|
+
return _get_project_context(ctx).data_root
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _state_path(ctx: click.Context) -> Path:
|
|
322
|
+
"""Return the active sync state path."""
|
|
323
|
+
return _get_project_context(ctx).state_path
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _ensure_stage_bound(ctx: click.Context, stage_name: str, stage_cfg: StageConfig) -> bool:
|
|
327
|
+
"""Require a local stage binding before local file access."""
|
|
328
|
+
if stage_cfg.path is not None:
|
|
329
|
+
return True
|
|
330
|
+
_echo_error(f"Error: stage '{stage_name}' is not bound. Run: forest bind {stage_name} <PATH>")
|
|
331
|
+
ctx.exit(1)
|
|
332
|
+
return False
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _required_stage_path(ctx: click.Context, stage_name: str, stage_cfg: StageConfig) -> Path:
|
|
336
|
+
"""Return a stage path or exit with bind guidance."""
|
|
337
|
+
if stage_cfg.path is None:
|
|
338
|
+
_echo_error(f"Error: stage '{stage_name}' is not bound. Run: forest bind {stage_name} <PATH>")
|
|
339
|
+
ctx.exit(1)
|
|
340
|
+
raise AssertionError("unreachable")
|
|
341
|
+
return stage_cfg.path
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _ensure_stages_bound(ctx: click.Context, config: ProjectConfig, stage_names: list[str]) -> bool:
|
|
345
|
+
return all(_ensure_stage_bound(ctx, stage_name, config.stages[stage_name]) for stage_name in stage_names)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _bound_stage_subset(ctx: click.Context, config: ProjectConfig) -> dict[str, StageConfig]:
|
|
349
|
+
"""Return bound stages for bare push/pull, warning per unbound stage.
|
|
350
|
+
|
|
351
|
+
Exits when no stage is configured or none is bound.
|
|
352
|
+
"""
|
|
353
|
+
if not config.stages:
|
|
354
|
+
_echo_error("Error: no stages configured. Run: forest add STAGE PATH or define stages in forest.yaml.")
|
|
355
|
+
ctx.exit(1)
|
|
356
|
+
return {}
|
|
357
|
+
bound = {name: cfg for name, cfg in config.stages.items() if cfg.path is not None}
|
|
358
|
+
for name in config.stages:
|
|
359
|
+
if name not in bound:
|
|
360
|
+
_echo_error(f"Warning: stage '{name}' is not bound; skipping. Run: forest bind {name} <PATH>")
|
|
361
|
+
if not bound:
|
|
362
|
+
_echo_error("Error: no stages are bound. Run: forest bind STAGE <PATH>")
|
|
363
|
+
ctx.exit(1)
|
|
364
|
+
return {}
|
|
365
|
+
return bound
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _remote_use_hints(config: ProjectConfig) -> list[str]:
|
|
369
|
+
return [f"Run: forest remote use {name}" for name in sorted(config.remotes)]
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _print_remote_use_hints(ctx: click.Context, config: ProjectConfig, *, err: bool = False) -> None:
|
|
373
|
+
echo = _echo_error if err else lambda message: _echo_info(ctx, message)
|
|
374
|
+
for hint in _remote_use_hints(config):
|
|
375
|
+
echo(hint)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _exit_stale_active_remote(ctx: click.Context, config: ProjectConfig, active_remote: str) -> None:
|
|
379
|
+
_echo_error(f"Error: active remote '{active_remote}' not found in config.")
|
|
380
|
+
_print_remote_use_hints(ctx, config, err=True)
|
|
381
|
+
ctx.exit(1)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _ensure_active_remote_known(ctx: click.Context, config: ProjectConfig, local_config: LocalConfig) -> None:
|
|
385
|
+
active_remote = local_config.active_remote
|
|
386
|
+
if active_remote is not None and active_remote not in config.remotes:
|
|
387
|
+
_exit_stale_active_remote(ctx, config, active_remote)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _resolve_remote(
|
|
391
|
+
ctx: click.Context,
|
|
392
|
+
config: ProjectConfig,
|
|
393
|
+
project_root: Path,
|
|
394
|
+
remote_name: str | None,
|
|
395
|
+
) -> str | None:
|
|
396
|
+
"""Resolve the target remote name.
|
|
397
|
+
|
|
398
|
+
Uses *remote_name* if given, otherwise falls back to the active remote
|
|
399
|
+
from ``.forest/local.yaml``. On error, prints to stderr and exits.
|
|
400
|
+
"""
|
|
401
|
+
if remote_name is not None:
|
|
402
|
+
if remote_name not in config.remotes:
|
|
403
|
+
_echo_error(f"Error: remote '{remote_name}' not found.")
|
|
404
|
+
ctx.exit(1)
|
|
405
|
+
return None # unreachable
|
|
406
|
+
return remote_name
|
|
407
|
+
|
|
408
|
+
local_config = _load_active_local_config(ctx)
|
|
409
|
+
if local_config.active_remote is None:
|
|
410
|
+
if len(config.remotes) == 1:
|
|
411
|
+
return next(iter(config.remotes))
|
|
412
|
+
_echo_error("Error: no active remote set.")
|
|
413
|
+
if config.remotes:
|
|
414
|
+
_print_remote_use_hints(ctx, config, err=True)
|
|
415
|
+
else:
|
|
416
|
+
_echo_error("Run: forest remote add NAME URL")
|
|
417
|
+
ctx.exit(1)
|
|
418
|
+
return None # unreachable
|
|
419
|
+
|
|
420
|
+
if local_config.active_remote not in config.remotes:
|
|
421
|
+
_exit_stale_active_remote(ctx, config, local_config.active_remote)
|
|
422
|
+
return None # unreachable
|
|
423
|
+
|
|
424
|
+
return local_config.active_remote
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# ---------------------------------------------------------------------------
|
|
428
|
+
# Init command
|
|
429
|
+
# ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@main.command()
|
|
433
|
+
@click.pass_context
|
|
434
|
+
def init(ctx: click.Context) -> None:
|
|
435
|
+
"""Initialize a forest workspace container.
|
|
436
|
+
|
|
437
|
+
Creates a nameless ``.forest/`` workspace registry only — no named
|
|
438
|
+
checkout and no root config. Names belong to
|
|
439
|
+
``forest checkout create <name>``, which registers, activates, and
|
|
440
|
+
generates that checkout's ``forest.yaml``. Re-running ``init`` inside an
|
|
441
|
+
existing workspace reports setup status instead.
|
|
442
|
+
"""
|
|
443
|
+
discovery_cwd = Path(ctx.obj["cwd"])
|
|
444
|
+
try:
|
|
445
|
+
workspace_root = find_workspace_root(discovery_cwd)
|
|
446
|
+
except CheckoutError as exc:
|
|
447
|
+
_handle_checkout_error(ctx, exc)
|
|
448
|
+
return
|
|
449
|
+
|
|
450
|
+
if workspace_root is not None:
|
|
451
|
+
_init_workspace_setup(ctx, workspace_root)
|
|
452
|
+
return
|
|
453
|
+
|
|
454
|
+
# No workspace registry yet. Refuse to shadow a legacy root config.
|
|
455
|
+
legacy_config = discovery_cwd / "forest.yaml"
|
|
456
|
+
if legacy_config.exists():
|
|
457
|
+
_echo_error(
|
|
458
|
+
f"Error: legacy {legacy_config.name} found. "
|
|
459
|
+
"Run 'forest checkout adopt <name>' to migrate it into a workspace."
|
|
460
|
+
)
|
|
461
|
+
ctx.exit(1)
|
|
462
|
+
return
|
|
463
|
+
|
|
464
|
+
try:
|
|
465
|
+
save_workspace_config(discovery_cwd, WorkspaceConfig())
|
|
466
|
+
ensure_registry_gitignore(discovery_cwd)
|
|
467
|
+
except CheckoutError as exc:
|
|
468
|
+
_handle_checkout_error(ctx, exc)
|
|
469
|
+
return
|
|
470
|
+
|
|
471
|
+
_echo_info(ctx, f"Initialized empty forest workspace in {discovery_cwd / '.forest'}")
|
|
472
|
+
_echo_info(ctx, "")
|
|
473
|
+
_echo_info(ctx, "Next steps:")
|
|
474
|
+
_echo_info(ctx, " 1. Run 'forest checkout create <name>' to create your first checkout")
|
|
475
|
+
_echo_info(ctx, " 2. Run 'forest remote add <name> <url>' to configure a remote")
|
|
476
|
+
_echo_info(ctx, " 3. Run 'forest bind <stage> <path>' once local data exists")
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _init_workspace_setup(ctx: click.Context, workspace_root: Path) -> None:
|
|
480
|
+
"""Run workspace setup checks instead of creating a legacy root config."""
|
|
481
|
+
gitignore_path = workspace_root / ".gitignore"
|
|
482
|
+
gitignore_text = gitignore_path.read_text() if gitignore_path.exists() else None
|
|
483
|
+
try:
|
|
484
|
+
registry = load_workspace_config(workspace_root)
|
|
485
|
+
checkout_name = read_head(workspace_root)
|
|
486
|
+
config, _data_root, _state_path = resolve_active_config(workspace_root)
|
|
487
|
+
ensure_workspace_gitignore(workspace_root, checkout_name)
|
|
488
|
+
except CheckoutError as exc:
|
|
489
|
+
if str(exc).startswith("No active checkout"):
|
|
490
|
+
names = sorted(registry.checkouts) if "registry" in locals() else []
|
|
491
|
+
_echo_info(ctx, "No active checkout selected.")
|
|
492
|
+
if names:
|
|
493
|
+
_echo_info(ctx, "Available checkouts:")
|
|
494
|
+
for name in names:
|
|
495
|
+
_echo_info(ctx, f" {name}")
|
|
496
|
+
_echo_info(ctx, f"Run: forest checkout {name}")
|
|
497
|
+
else:
|
|
498
|
+
_echo_info(ctx, "Run: forest checkout NAME")
|
|
499
|
+
return
|
|
500
|
+
_restore_text(gitignore_path, gitignore_text)
|
|
501
|
+
_handle_checkout_error(ctx, exc)
|
|
502
|
+
return
|
|
503
|
+
|
|
504
|
+
_echo_info(ctx, f"Workspace checkout '{checkout_name}' is active.")
|
|
505
|
+
missing = [stage_name for stage_name, stage_cfg in config.stages.items() if stage_cfg.path is None]
|
|
506
|
+
for stage_name in missing:
|
|
507
|
+
_echo_info(ctx, f"Run: forest bind {stage_name} <PATH>")
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _migrate_rename_layout(root: Path) -> list[str]:
|
|
511
|
+
"""Rename legacy biostore metadata to forest names; return human-readable moves."""
|
|
512
|
+
moved: list[str] = []
|
|
513
|
+
old_meta = root / ".biostore"
|
|
514
|
+
if old_meta.exists():
|
|
515
|
+
new_meta = root / ".forest"
|
|
516
|
+
old_meta.rename(new_meta)
|
|
517
|
+
moved.append(".biostore/ -> .forest/")
|
|
518
|
+
checkouts = new_meta / "checkouts"
|
|
519
|
+
if checkouts.is_dir():
|
|
520
|
+
for cdir in sorted(checkouts.iterdir()):
|
|
521
|
+
old_checkout_cfg = cdir / "biostore.yaml"
|
|
522
|
+
if old_checkout_cfg.is_file():
|
|
523
|
+
old_checkout_cfg.rename(cdir / "forest.yaml")
|
|
524
|
+
moved.append(f"checkouts/{cdir.name}/biostore.yaml -> forest.yaml")
|
|
525
|
+
old_root_cfg = root / "biostore.yaml"
|
|
526
|
+
if old_root_cfg.exists():
|
|
527
|
+
old_root_cfg.rename(root / "forest.yaml")
|
|
528
|
+
moved.append("biostore.yaml -> forest.yaml")
|
|
529
|
+
moved.extend(_migrate_rewrite_gitignore(root))
|
|
530
|
+
return moved
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _migrate_rewrite_gitignore(root: Path) -> list[str]:
|
|
534
|
+
gitignore_path = root / ".gitignore"
|
|
535
|
+
if not gitignore_path.exists():
|
|
536
|
+
return []
|
|
537
|
+
text = gitignore_path.read_text()
|
|
538
|
+
new_text = text.replace(".biostore", ".forest").replace("biostore.yaml", "forest.yaml")
|
|
539
|
+
if new_text == text:
|
|
540
|
+
return []
|
|
541
|
+
gitignore_path.write_text(new_text)
|
|
542
|
+
return [".gitignore patterns rewritten"]
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
@main.command()
|
|
546
|
+
@click.pass_context
|
|
547
|
+
def migrate(ctx: click.Context) -> None:
|
|
548
|
+
"""Migrate a legacy biostore layout in place to forest.
|
|
549
|
+
|
|
550
|
+
Renames ``.biostore/`` to ``.forest/`` and ``biostore.yaml`` to
|
|
551
|
+
``forest.yaml`` (including each checkout's shared config), rewrites the
|
|
552
|
+
managed .gitignore patterns, and verifies the registry still parses.
|
|
553
|
+
Schema names are unchanged, so ``schema:`` references need no edits.
|
|
554
|
+
Refuses to run if a ``.forest/`` or ``forest.yaml`` already exists.
|
|
555
|
+
"""
|
|
556
|
+
root = Path(ctx.obj["cwd"])
|
|
557
|
+
new_meta = root / ".forest"
|
|
558
|
+
new_root_cfg = root / "forest.yaml"
|
|
559
|
+
|
|
560
|
+
if not (root / ".biostore").exists() and not (root / "biostore.yaml").exists():
|
|
561
|
+
_echo_error("Error: no legacy biostore layout found (.biostore/ or biostore.yaml).")
|
|
562
|
+
ctx.exit(1)
|
|
563
|
+
return
|
|
564
|
+
if new_meta.exists() or new_root_cfg.exists():
|
|
565
|
+
_echo_error("Error: .forest/ or forest.yaml already exists; refusing to overwrite. Migrate manually.")
|
|
566
|
+
ctx.exit(1)
|
|
567
|
+
return
|
|
568
|
+
|
|
569
|
+
for line in _migrate_rename_layout(root):
|
|
570
|
+
_echo_info(ctx, f" {line}")
|
|
571
|
+
|
|
572
|
+
try:
|
|
573
|
+
if (new_meta / "config.yaml").exists():
|
|
574
|
+
load_workspace_config(root)
|
|
575
|
+
elif new_root_cfg.exists():
|
|
576
|
+
load_config(new_root_cfg)
|
|
577
|
+
except (CheckoutError, ConfigError) as exc:
|
|
578
|
+
_echo_error(f"Error: migration moved files but the config did not parse: {exc}")
|
|
579
|
+
ctx.exit(1)
|
|
580
|
+
return
|
|
581
|
+
|
|
582
|
+
_echo_info(ctx, "")
|
|
583
|
+
_echo_info(ctx, "Migration complete.")
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
class CheckoutGroup(click.Group):
|
|
587
|
+
"""Checkout group that treats unknown subcommands as checkout names."""
|
|
588
|
+
|
|
589
|
+
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
|
|
590
|
+
command = super().get_command(ctx, cmd_name)
|
|
591
|
+
if command is not None or cmd_name.startswith("-"):
|
|
592
|
+
return command
|
|
593
|
+
|
|
594
|
+
@click.command(name=cmd_name)
|
|
595
|
+
@click.pass_context
|
|
596
|
+
def switch_command(switch_ctx: click.Context) -> None:
|
|
597
|
+
_checkout_switch(switch_ctx, cmd_name)
|
|
598
|
+
|
|
599
|
+
return switch_command
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _require_checkout_workspace(ctx: click.Context) -> Path:
|
|
603
|
+
"""Return workspace root or exit with checkout-workspace error."""
|
|
604
|
+
try:
|
|
605
|
+
workspace_root = find_workspace_root(Path(ctx.obj["cwd"]))
|
|
606
|
+
except CheckoutError as exc:
|
|
607
|
+
_handle_checkout_error(ctx, exc)
|
|
608
|
+
raise AssertionError("unreachable") from exc
|
|
609
|
+
if workspace_root is None:
|
|
610
|
+
_echo_error("Error: no checkout workspace found.")
|
|
611
|
+
ctx.exit(1)
|
|
612
|
+
raise AssertionError("unreachable")
|
|
613
|
+
return workspace_root
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _require_binding_workspace_context(ctx: click.Context) -> ProjectContext:
|
|
617
|
+
"""Return active workspace context for binding-only commands."""
|
|
618
|
+
_require_checkout_workspace(ctx)
|
|
619
|
+
project_context = _get_project_context(ctx)
|
|
620
|
+
if project_context.mode != "workspace":
|
|
621
|
+
_echo_error("Error: stage binding commands require a checkout workspace.")
|
|
622
|
+
ctx.exit(1)
|
|
623
|
+
raise AssertionError("unreachable")
|
|
624
|
+
return project_context
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _resolve_cli_path(ctx: click.Context, path: str | Path) -> Path:
|
|
628
|
+
"""Resolve a user path against the CLI discovery cwd."""
|
|
629
|
+
candidate = Path(path)
|
|
630
|
+
if not candidate.is_absolute():
|
|
631
|
+
candidate = Path(ctx.obj["cwd"]) / candidate
|
|
632
|
+
return candidate.resolve(strict=False)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _display_path(path: Path) -> str:
|
|
636
|
+
return str(path) if path.is_absolute() else path.as_posix()
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _restore_text(path: Path, text: str | None) -> None:
|
|
640
|
+
if text is None:
|
|
641
|
+
path.unlink(missing_ok=True)
|
|
642
|
+
return
|
|
643
|
+
atomic_write(path, text)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _stored_data_path(ctx: click.Context, path: str | Path, workspace_root: Path) -> tuple[Path, Path]:
|
|
647
|
+
"""Resolve, safety-check, and normalize a local data path for local.yaml."""
|
|
648
|
+
resolved = _resolve_cli_path(ctx, path)
|
|
649
|
+
try:
|
|
650
|
+
safe_path = validate_workspace_data_path(resolved, workspace_root)
|
|
651
|
+
except CheckoutError as exc:
|
|
652
|
+
_echo_error(f"Error: {exc}")
|
|
653
|
+
ctx.exit(1)
|
|
654
|
+
raise AssertionError("unreachable") from exc
|
|
655
|
+
return safe_path, normalize_workspace_path(safe_path, workspace_root)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _registered_stage_names(config: ProjectConfig) -> str:
|
|
659
|
+
names = sorted(config.stages)
|
|
660
|
+
return ", ".join(names) if names else "(none)"
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _bind_list(ctx: click.Context, config: ProjectConfig, local_config: LocalConfig) -> None:
|
|
664
|
+
"""List shared stages and local bindings."""
|
|
665
|
+
if not config.stages:
|
|
666
|
+
_echo_info(ctx, "No stages configured.")
|
|
667
|
+
return
|
|
668
|
+
for stage_name in sorted(config.stages):
|
|
669
|
+
binding = local_config.stage_paths.get(stage_name)
|
|
670
|
+
marker = _display_path(binding) if binding is not None else "<unbound>"
|
|
671
|
+
_echo_info(ctx, f"{stage_name}\t{marker}")
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _require_shared_stage(ctx: click.Context, config: ProjectConfig, stage_name: str) -> None:
|
|
675
|
+
if stage_name not in config.stages:
|
|
676
|
+
_echo_error(f"Error: stage '{stage_name}' not found; registered stages: {_registered_stage_names(config)}")
|
|
677
|
+
ctx.exit(1)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _checkout_create_root(ctx: click.Context, checkout_name: str) -> Path:
|
|
681
|
+
"""Return root where checkout create may write, rejecting legacy/ambiguous state."""
|
|
682
|
+
start = Path(ctx.obj["cwd"])
|
|
683
|
+
try:
|
|
684
|
+
workspace_root = find_workspace_root(start)
|
|
685
|
+
except CheckoutError as exc:
|
|
686
|
+
_handle_checkout_error(ctx, exc)
|
|
687
|
+
raise AssertionError("unreachable") from exc
|
|
688
|
+
if workspace_root is not None:
|
|
689
|
+
return workspace_root
|
|
690
|
+
|
|
691
|
+
root = find_project_boundary(start)
|
|
692
|
+
current = start
|
|
693
|
+
while True:
|
|
694
|
+
if (current / "forest.yaml").exists():
|
|
695
|
+
_echo_error(f"Error: legacy root config found: {current / 'forest.yaml'}")
|
|
696
|
+
_echo_error(f"Run: forest checkout adopt {checkout_name}")
|
|
697
|
+
ctx.exit(1)
|
|
698
|
+
raise AssertionError("unreachable")
|
|
699
|
+
if current == root or current.parent == current:
|
|
700
|
+
break
|
|
701
|
+
current = current.parent
|
|
702
|
+
|
|
703
|
+
if not (root / ".forest").exists() and not (root / ".git").exists():
|
|
704
|
+
current = root
|
|
705
|
+
while current.parent != current:
|
|
706
|
+
current = current.parent
|
|
707
|
+
if (current / "forest.yaml").exists():
|
|
708
|
+
_echo_error(f"Error: legacy root config found: {current / 'forest.yaml'}")
|
|
709
|
+
_echo_error(f"Run: forest checkout adopt {checkout_name}")
|
|
710
|
+
ctx.exit(1)
|
|
711
|
+
raise AssertionError("unreachable")
|
|
712
|
+
return root
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _active_checkout_or_none(workspace_root: Path, names: list[str]) -> str | None:
|
|
716
|
+
head_path = workspace_root / ".forest" / "HEAD"
|
|
717
|
+
if not head_path.exists():
|
|
718
|
+
return None
|
|
719
|
+
active_name = read_head(workspace_root)
|
|
720
|
+
if active_name not in names:
|
|
721
|
+
raise CheckoutError(f"Active checkout '{active_name}' is not registered in workspace config.")
|
|
722
|
+
return active_name
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _checkout_list(ctx: click.Context) -> None:
|
|
726
|
+
"""List registered checkouts from registry and HEAD only."""
|
|
727
|
+
workspace_root = _require_checkout_workspace(ctx)
|
|
728
|
+
try:
|
|
729
|
+
names = list_checkouts(workspace_root)
|
|
730
|
+
active_name = _active_checkout_or_none(workspace_root, names)
|
|
731
|
+
except CheckoutError as exc:
|
|
732
|
+
_handle_checkout_error(ctx, exc)
|
|
733
|
+
return
|
|
734
|
+
|
|
735
|
+
for name in names:
|
|
736
|
+
marker = "* " if name == active_name else " "
|
|
737
|
+
_echo_info(ctx, f"{marker}{name}")
|
|
738
|
+
|
|
739
|
+
if active_name is None and names:
|
|
740
|
+
for name in names:
|
|
741
|
+
_echo_info(ctx, f"Run: forest checkout {name}")
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _checkout_setup_guidance(ctx: click.Context, workspace_root: Path, checkout_name: str) -> None:
|
|
745
|
+
"""Print next setup commands for a switched checkout without mutating local state."""
|
|
746
|
+
shared_path, local_path, _state_path = checkout_metadata_paths(workspace_root, checkout_name)
|
|
747
|
+
try:
|
|
748
|
+
config = load_config(shared_path)
|
|
749
|
+
local_config = load_local_config_file(local_path)
|
|
750
|
+
except ConfigError as exc:
|
|
751
|
+
_handle_checkout_error(ctx, exc)
|
|
752
|
+
return
|
|
753
|
+
|
|
754
|
+
for stage_name, stage_cfg in config.stages.items():
|
|
755
|
+
if stage_cfg.path is None and stage_name not in local_config.stage_paths:
|
|
756
|
+
_echo_info(ctx, f"Run: forest bind {stage_name} <PATH>")
|
|
757
|
+
|
|
758
|
+
if not config.remotes:
|
|
759
|
+
_echo_info(ctx, "Run: forest remote add NAME URL")
|
|
760
|
+
elif local_config.active_remote is None and len(config.remotes) > 1:
|
|
761
|
+
for remote_name in sorted(config.remotes):
|
|
762
|
+
_echo_info(ctx, f"Run: forest remote use {remote_name}")
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _checkout_create_flow(ctx: click.Context, checkout_name: str, *, message: str) -> Path | None:
|
|
766
|
+
"""Create, register, and activate a checkout; return the workspace root."""
|
|
767
|
+
workspace_root = _checkout_create_root(ctx, checkout_name)
|
|
768
|
+
try:
|
|
769
|
+
create_checkout(workspace_root, checkout_name)
|
|
770
|
+
except CheckoutError as exc:
|
|
771
|
+
_handle_checkout_error(ctx, exc)
|
|
772
|
+
return None
|
|
773
|
+
|
|
774
|
+
_echo_info(ctx, message)
|
|
775
|
+
return workspace_root
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _checkout_switch(ctx: click.Context, checkout_name: str) -> None:
|
|
779
|
+
"""Switch HEAD to a checkout, creating it first when it is not registered."""
|
|
780
|
+
try:
|
|
781
|
+
workspace_root = find_workspace_root(Path(ctx.obj["cwd"]))
|
|
782
|
+
registered = workspace_root is not None and checkout_name in load_workspace_config(workspace_root).checkouts
|
|
783
|
+
except CheckoutError as exc:
|
|
784
|
+
_handle_checkout_error(ctx, exc)
|
|
785
|
+
return
|
|
786
|
+
|
|
787
|
+
if not registered:
|
|
788
|
+
created_root = _checkout_create_flow(
|
|
789
|
+
ctx,
|
|
790
|
+
checkout_name,
|
|
791
|
+
message=f"Created and switched to new checkout '{checkout_name}'.",
|
|
792
|
+
)
|
|
793
|
+
if created_root is not None:
|
|
794
|
+
_checkout_setup_guidance(ctx, created_root, checkout_name)
|
|
795
|
+
return
|
|
796
|
+
|
|
797
|
+
assert workspace_root is not None
|
|
798
|
+
try:
|
|
799
|
+
switch_checkout(workspace_root, checkout_name)
|
|
800
|
+
except CheckoutError as exc:
|
|
801
|
+
_handle_checkout_error(ctx, exc)
|
|
802
|
+
return
|
|
803
|
+
|
|
804
|
+
_echo_info(ctx, f"Switched to checkout '{checkout_name}'.")
|
|
805
|
+
_checkout_setup_guidance(ctx, workspace_root, checkout_name)
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _stdin_is_tty() -> bool:
|
|
809
|
+
"""Return whether stdin is an interactive terminal."""
|
|
810
|
+
return sys.stdin.isatty()
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _validate_checkout_remove_target(workspace_root: Path, checkout_name: str) -> None:
|
|
814
|
+
registry = require_registered_checkout(workspace_root, checkout_name)
|
|
815
|
+
head_path = workspace_root / ".forest" / "HEAD"
|
|
816
|
+
if head_path.exists():
|
|
817
|
+
active_name = read_head(workspace_root)
|
|
818
|
+
if active_name not in registry.checkouts:
|
|
819
|
+
raise CheckoutError(f"Active checkout '{active_name}' is not registered in workspace config.")
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
@main.group(cls=CheckoutGroup, invoke_without_command=True)
|
|
823
|
+
@click.pass_context
|
|
824
|
+
def checkout(ctx: click.Context) -> None:
|
|
825
|
+
"""Manage checkout workspaces."""
|
|
826
|
+
if ctx.invoked_subcommand is None:
|
|
827
|
+
_checkout_list(ctx)
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
@checkout.command("list")
|
|
831
|
+
@click.pass_context
|
|
832
|
+
def checkout_list(ctx: click.Context) -> None:
|
|
833
|
+
"""List registered checkouts."""
|
|
834
|
+
_checkout_list(ctx)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
@checkout.command("create")
|
|
838
|
+
@click.argument("name")
|
|
839
|
+
@click.pass_context
|
|
840
|
+
def checkout_create(ctx: click.Context, name: str) -> None:
|
|
841
|
+
"""Create and activate a checkout."""
|
|
842
|
+
_checkout_create_flow(ctx, name, message=f"Created checkout '{name}'.")
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
@checkout.command("adopt")
|
|
846
|
+
@click.argument("name")
|
|
847
|
+
@click.pass_context
|
|
848
|
+
def checkout_adopt(ctx: click.Context, name: str) -> None:
|
|
849
|
+
"""Adopt a legacy root project into a checkout workspace."""
|
|
850
|
+
try:
|
|
851
|
+
adopt_checkout(Path(ctx.obj["cwd"]), name)
|
|
852
|
+
except CheckoutError as exc:
|
|
853
|
+
_handle_checkout_error(ctx, exc)
|
|
854
|
+
return
|
|
855
|
+
|
|
856
|
+
_echo_info(ctx, f"Adopted legacy project as checkout '{name}'.")
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
@checkout.command("current")
|
|
860
|
+
@click.pass_context
|
|
861
|
+
def checkout_current(ctx: click.Context) -> None:
|
|
862
|
+
"""Print the active checkout name."""
|
|
863
|
+
workspace_root = _require_checkout_workspace(ctx)
|
|
864
|
+
try:
|
|
865
|
+
checkout_name = read_head(workspace_root)
|
|
866
|
+
registry = load_workspace_config(workspace_root)
|
|
867
|
+
if checkout_name not in registry.checkouts:
|
|
868
|
+
_echo_error(f"Error: active checkout '{checkout_name}' is not registered in workspace config.")
|
|
869
|
+
ctx.exit(1)
|
|
870
|
+
return
|
|
871
|
+
if not checkout_dir(workspace_root, checkout_name).is_dir():
|
|
872
|
+
_echo_error(f"Error: active checkout '{checkout_name}' metadata directory is missing.")
|
|
873
|
+
ctx.exit(1)
|
|
874
|
+
return
|
|
875
|
+
except CheckoutError as exc:
|
|
876
|
+
_handle_checkout_error(ctx, exc)
|
|
877
|
+
return
|
|
878
|
+
|
|
879
|
+
_echo_info(ctx, checkout_name)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
@checkout.command("remove")
|
|
883
|
+
@click.argument("name")
|
|
884
|
+
@click.option("--yes", is_flag=True, help="Skip the confirmation prompt (for scripted cleanup).")
|
|
885
|
+
@click.pass_context
|
|
886
|
+
def checkout_remove(ctx: click.Context, name: str, yes: bool) -> None:
|
|
887
|
+
"""Remove checkout metadata after confirmation (or with --yes)."""
|
|
888
|
+
workspace_root = _require_checkout_workspace(ctx)
|
|
889
|
+
try:
|
|
890
|
+
_validate_checkout_remove_target(workspace_root, name)
|
|
891
|
+
except CheckoutError as exc:
|
|
892
|
+
_handle_checkout_error(ctx, exc)
|
|
893
|
+
return
|
|
894
|
+
|
|
895
|
+
if not yes:
|
|
896
|
+
if not _stdin_is_tty():
|
|
897
|
+
_echo_error("Error: checkout remove requires an interactive terminal.")
|
|
898
|
+
ctx.exit(1)
|
|
899
|
+
return
|
|
900
|
+
|
|
901
|
+
if not click.confirm(f"Remove checkout '{name}' metadata?", default=False):
|
|
902
|
+
_echo_info(ctx, "Aborted.")
|
|
903
|
+
ctx.exit(1)
|
|
904
|
+
return
|
|
905
|
+
|
|
906
|
+
try:
|
|
907
|
+
removed_head = remove_checkout_metadata(workspace_root, name)
|
|
908
|
+
except CheckoutError as exc:
|
|
909
|
+
_handle_checkout_error(ctx, exc)
|
|
910
|
+
return
|
|
911
|
+
|
|
912
|
+
_echo_info(ctx, f"Removed checkout '{name}'.")
|
|
913
|
+
if removed_head:
|
|
914
|
+
_echo_info(ctx, "No active checkout selected.")
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
@main.command("add")
|
|
918
|
+
@click.argument("stage")
|
|
919
|
+
@click.argument("path", type=click.Path(path_type=Path))
|
|
920
|
+
@click.option(
|
|
921
|
+
"--sync-by",
|
|
922
|
+
"sync_by",
|
|
923
|
+
type=click.Choice(["subdirectory", "directory", "file"]),
|
|
924
|
+
default="subdirectory",
|
|
925
|
+
show_default=True,
|
|
926
|
+
help="Unit discovery mode for STAGE.",
|
|
927
|
+
)
|
|
928
|
+
@click.pass_context
|
|
929
|
+
def add_stage(
|
|
930
|
+
ctx: click.Context, stage: str, path: Path, sync_by: Literal["directory", "subdirectory", "file"]
|
|
931
|
+
) -> None:
|
|
932
|
+
"""Register STAGE and bind it to local PATH in the active checkout."""
|
|
933
|
+
project_context = _require_binding_workspace_context(ctx)
|
|
934
|
+
assert project_context.checkout_name is not None
|
|
935
|
+
shared_config = _load_writable_project_config(ctx)
|
|
936
|
+
local_config = _load_active_local_config(ctx)
|
|
937
|
+
|
|
938
|
+
try:
|
|
939
|
+
validate_token(stage, kind="stage name", reserved_names=RESERVED_CHECKOUT_NAMES)
|
|
940
|
+
except CheckoutError as exc:
|
|
941
|
+
_echo_error(f"Error: {exc}")
|
|
942
|
+
ctx.exit(1)
|
|
943
|
+
return
|
|
944
|
+
|
|
945
|
+
if stage in shared_config.stages:
|
|
946
|
+
_echo_error(f"Error: stage '{stage}' already exists. Use 'forest bind {stage} PATH'.")
|
|
947
|
+
ctx.exit(1)
|
|
948
|
+
return
|
|
949
|
+
|
|
950
|
+
_resolved_path, stored_path = _stored_data_path(ctx, path, project_context.data_root)
|
|
951
|
+
|
|
952
|
+
stages = dict(shared_config.stages)
|
|
953
|
+
stages[stage] = StageConfig(remote_path=Path(project_context.checkout_name) / stage, sync_by=sync_by)
|
|
954
|
+
_save_project_config(ctx, shared_config.model_copy(update={"stages": stages}, deep=True))
|
|
955
|
+
|
|
956
|
+
stage_paths = dict(local_config.stage_paths)
|
|
957
|
+
stage_paths[stage] = stored_path
|
|
958
|
+
_save_active_local_config(ctx, local_config.model_copy(update={"stage_paths": stage_paths}, deep=True))
|
|
959
|
+
|
|
960
|
+
_echo_info(ctx, f"Added stage '{stage}' -> {_display_path(stored_path)}")
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
@main.command("bind")
|
|
964
|
+
@click.argument("stage", required=False)
|
|
965
|
+
@click.argument("path", required=False, type=click.Path(path_type=Path))
|
|
966
|
+
@click.pass_context
|
|
967
|
+
def bind_stage(ctx: click.Context, stage: str | None, path: Path | None) -> None:
|
|
968
|
+
"""List or update local stage path bindings for the active checkout."""
|
|
969
|
+
project_context = _require_binding_workspace_context(ctx)
|
|
970
|
+
shared_config = _load_writable_project_config(ctx)
|
|
971
|
+
local_config = _load_active_local_config(ctx)
|
|
972
|
+
|
|
973
|
+
if stage is None and path is None:
|
|
974
|
+
_bind_list(ctx, shared_config, local_config)
|
|
975
|
+
return
|
|
976
|
+
if stage is None or path is None:
|
|
977
|
+
raise click.UsageError("bind requires STAGE PATH, or no arguments to list bindings.")
|
|
978
|
+
|
|
979
|
+
_require_shared_stage(ctx, shared_config, stage)
|
|
980
|
+
_resolved_path, stored_path = _stored_data_path(ctx, path, project_context.data_root)
|
|
981
|
+
old_path = local_config.stage_paths.get(stage)
|
|
982
|
+
|
|
983
|
+
stage_paths = dict(local_config.stage_paths)
|
|
984
|
+
stage_paths[stage] = stored_path
|
|
985
|
+
_save_active_local_config(ctx, local_config.model_copy(update={"stage_paths": stage_paths}, deep=True))
|
|
986
|
+
|
|
987
|
+
if old_path is None:
|
|
988
|
+
_echo_info(ctx, f"Bound stage '{stage}' -> {_display_path(stored_path)}")
|
|
989
|
+
else:
|
|
990
|
+
_echo_info(ctx, f"Bound stage '{stage}': {_display_path(old_path)} -> {_display_path(stored_path)}")
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
@main.command("unbind")
|
|
994
|
+
@click.argument("stage")
|
|
995
|
+
@click.pass_context
|
|
996
|
+
def unbind_stage(ctx: click.Context, stage: str) -> None:
|
|
997
|
+
"""Remove a local stage path binding from the active checkout."""
|
|
998
|
+
_require_binding_workspace_context(ctx)
|
|
999
|
+
shared_config = _load_writable_project_config(ctx)
|
|
1000
|
+
local_config = _load_active_local_config(ctx)
|
|
1001
|
+
_require_shared_stage(ctx, shared_config, stage)
|
|
1002
|
+
|
|
1003
|
+
old_path = local_config.stage_paths.get(stage)
|
|
1004
|
+
if old_path is None:
|
|
1005
|
+
_echo_info(ctx, f"Stage '{stage}' already unbound.")
|
|
1006
|
+
return
|
|
1007
|
+
|
|
1008
|
+
stage_paths = dict(local_config.stage_paths)
|
|
1009
|
+
del stage_paths[stage]
|
|
1010
|
+
_save_active_local_config(ctx, local_config.model_copy(update={"stage_paths": stage_paths}, deep=True))
|
|
1011
|
+
_echo_info(ctx, f"Unbound stage '{stage}' from {_display_path(old_path)}.")
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
@main.group()
|
|
1015
|
+
def remote() -> None:
|
|
1016
|
+
"""Manage remote storage endpoints."""
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _validate_remote_add_inputs(
|
|
1020
|
+
ctx: click.Context,
|
|
1021
|
+
name: str,
|
|
1022
|
+
url: str,
|
|
1023
|
+
endpoint: str | None,
|
|
1024
|
+
config: ProjectConfig,
|
|
1025
|
+
) -> None:
|
|
1026
|
+
try:
|
|
1027
|
+
validate_token(
|
|
1028
|
+
name,
|
|
1029
|
+
kind="remote name",
|
|
1030
|
+
reserved_names=RESERVED_REMOTE_NAMES,
|
|
1031
|
+
existing_names=set(config.remotes),
|
|
1032
|
+
)
|
|
1033
|
+
except CheckoutError as exc:
|
|
1034
|
+
_echo_error(f"Error: {exc}")
|
|
1035
|
+
ctx.exit(1)
|
|
1036
|
+
|
|
1037
|
+
if remote_url_has_secret_material(url):
|
|
1038
|
+
_echo_error("Error: remote URL contains embedded credentials or token-like material.")
|
|
1039
|
+
_echo_error("Use --profile for S3 auth, or --key-file and --known-hosts for SFTP auth.")
|
|
1040
|
+
ctx.exit(1)
|
|
1041
|
+
if endpoint is not None and remote_url_has_secret_material(endpoint):
|
|
1042
|
+
_echo_error("Error: remote endpoint contains embedded credentials or token-like material.")
|
|
1043
|
+
ctx.exit(1)
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
@remote.command("add")
|
|
1047
|
+
@click.argument("name")
|
|
1048
|
+
@click.argument("url")
|
|
1049
|
+
@click.option("--region", default=None, help="S3 region (e.g. us-east-2).")
|
|
1050
|
+
@click.option("--endpoint", default=None, help="S3-compatible endpoint URL (non-AWS S3).")
|
|
1051
|
+
@click.option("--profile", default=None, help="S3 auth profile name (e.g. scripps).")
|
|
1052
|
+
@click.option("--key-file", "key_file", default=None, help="SFTP private key file path.")
|
|
1053
|
+
@click.option("--known-hosts", "known_hosts", default=None, help="SFTP known_hosts file path.")
|
|
1054
|
+
@click.pass_context
|
|
1055
|
+
def remote_add(
|
|
1056
|
+
ctx: click.Context,
|
|
1057
|
+
name: str,
|
|
1058
|
+
url: str,
|
|
1059
|
+
region: str | None,
|
|
1060
|
+
endpoint: str | None,
|
|
1061
|
+
profile: str | None,
|
|
1062
|
+
key_file: str | None,
|
|
1063
|
+
known_hosts: str | None,
|
|
1064
|
+
) -> None:
|
|
1065
|
+
"""Add a named remote (NAME) with the given URL."""
|
|
1066
|
+
project_context = _get_project_context(ctx)
|
|
1067
|
+
config = _load_writable_project_config(ctx)
|
|
1068
|
+
_validate_remote_add_inputs(ctx, name, url, endpoint, config)
|
|
1069
|
+
local_config = _load_active_local_config(ctx)
|
|
1070
|
+
_ensure_active_remote_known(ctx, config, local_config)
|
|
1071
|
+
|
|
1072
|
+
is_first = not config.remotes
|
|
1073
|
+
|
|
1074
|
+
config.remotes[name] = RemoteConfig(
|
|
1075
|
+
url=url,
|
|
1076
|
+
region=region,
|
|
1077
|
+
endpoint=endpoint,
|
|
1078
|
+
profile=profile,
|
|
1079
|
+
key_file=key_file,
|
|
1080
|
+
known_hosts=known_hosts,
|
|
1081
|
+
)
|
|
1082
|
+
_save_project_config(ctx, config)
|
|
1083
|
+
|
|
1084
|
+
if project_context.mode == "legacy" and is_first:
|
|
1085
|
+
local_config.active_remote = name
|
|
1086
|
+
_save_active_local_config(ctx, local_config)
|
|
1087
|
+
|
|
1088
|
+
_echo_info(ctx, f"Added remote '{name}' -> {url}")
|
|
1089
|
+
if project_context.mode == "legacy" and is_first:
|
|
1090
|
+
_echo_info(ctx, f"Set '{name}' as active remote.")
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
@remote.command("remove")
|
|
1094
|
+
@click.argument("name")
|
|
1095
|
+
@click.pass_context
|
|
1096
|
+
def remote_remove(ctx: click.Context, name: str) -> None:
|
|
1097
|
+
"""Remove a named remote."""
|
|
1098
|
+
config = _load_writable_project_config(ctx)
|
|
1099
|
+
|
|
1100
|
+
if name not in config.remotes:
|
|
1101
|
+
_echo_error(f"Error: remote '{name}' not found.")
|
|
1102
|
+
ctx.exit(1)
|
|
1103
|
+
return
|
|
1104
|
+
|
|
1105
|
+
local_config = _load_active_local_config(ctx)
|
|
1106
|
+
_ensure_active_remote_known(ctx, config, local_config)
|
|
1107
|
+
was_active = local_config.active_remote == name
|
|
1108
|
+
|
|
1109
|
+
del config.remotes[name]
|
|
1110
|
+
_save_project_config(ctx, config)
|
|
1111
|
+
if was_active:
|
|
1112
|
+
local_config.active_remote = None
|
|
1113
|
+
_save_active_local_config(ctx, local_config)
|
|
1114
|
+
_echo_info(ctx, f"Removed remote '{name}'.")
|
|
1115
|
+
if was_active:
|
|
1116
|
+
_print_remote_use_hints(ctx, config)
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
@remote.command("list")
|
|
1120
|
+
@click.pass_context
|
|
1121
|
+
def remote_list(ctx: click.Context) -> None:
|
|
1122
|
+
"""List all configured remotes."""
|
|
1123
|
+
config = _load_project_config(ctx)
|
|
1124
|
+
|
|
1125
|
+
if not config.remotes:
|
|
1126
|
+
_echo_info(ctx, "No remotes configured. Use 'forest remote add' to add one.")
|
|
1127
|
+
return
|
|
1128
|
+
|
|
1129
|
+
local_config = _load_active_local_config(ctx)
|
|
1130
|
+
active = local_config.active_remote
|
|
1131
|
+
if active is not None and active not in config.remotes:
|
|
1132
|
+
_exit_stale_active_remote(ctx, config, active)
|
|
1133
|
+
return
|
|
1134
|
+
|
|
1135
|
+
for name, remote_cfg in sorted(config.remotes.items()):
|
|
1136
|
+
marker = "* " if name == active else " "
|
|
1137
|
+
_echo_info(ctx, f"{marker}{name}\t{remote_cfg.url}\t[{remote_cfg.remote_type}]")
|
|
1138
|
+
|
|
1139
|
+
if active is None:
|
|
1140
|
+
_print_remote_use_hints(ctx, config)
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
@remote.command("use")
|
|
1144
|
+
@click.argument("name")
|
|
1145
|
+
@click.pass_context
|
|
1146
|
+
def remote_use(ctx: click.Context, name: str) -> None:
|
|
1147
|
+
"""Set the active remote by name."""
|
|
1148
|
+
config = _load_project_config(ctx)
|
|
1149
|
+
|
|
1150
|
+
if name not in config.remotes:
|
|
1151
|
+
_echo_error(f"Error: remote '{name}' not found.")
|
|
1152
|
+
ctx.exit(1)
|
|
1153
|
+
return
|
|
1154
|
+
|
|
1155
|
+
local_config = _load_active_local_config(ctx)
|
|
1156
|
+
local_config.active_remote = name
|
|
1157
|
+
_save_active_local_config(ctx, local_config)
|
|
1158
|
+
_echo_info(ctx, f"Active remote set to '{name}'.")
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
@remote.command("show")
|
|
1162
|
+
@click.argument("name")
|
|
1163
|
+
@click.pass_context
|
|
1164
|
+
def remote_show(ctx: click.Context, name: str) -> None:
|
|
1165
|
+
"""Show details of a named remote."""
|
|
1166
|
+
config = _load_project_config(ctx)
|
|
1167
|
+
|
|
1168
|
+
if name not in config.remotes:
|
|
1169
|
+
_echo_error(f"Error: remote '{name}' not found.")
|
|
1170
|
+
ctx.exit(1)
|
|
1171
|
+
return
|
|
1172
|
+
|
|
1173
|
+
remote_cfg = config.remotes[name]
|
|
1174
|
+
_echo_info(ctx, f"Name: {name}")
|
|
1175
|
+
_echo_info(ctx, f"URL: {remote_cfg.url}")
|
|
1176
|
+
_echo_info(ctx, f"Type: {remote_cfg.remote_type}")
|
|
1177
|
+
if remote_cfg.region:
|
|
1178
|
+
_echo_info(ctx, f"Region: {remote_cfg.region}")
|
|
1179
|
+
if remote_cfg.endpoint:
|
|
1180
|
+
_echo_info(ctx, f"Endpoint: {remote_cfg.endpoint}")
|
|
1181
|
+
if remote_cfg.profile:
|
|
1182
|
+
_echo_info(ctx, f"Profile: {remote_cfg.profile}")
|
|
1183
|
+
if remote_cfg.key_file:
|
|
1184
|
+
_echo_info(ctx, f"Key file: {remote_cfg.key_file}")
|
|
1185
|
+
if remote_cfg.known_hosts:
|
|
1186
|
+
_echo_info(ctx, f"Known hosts: {remote_cfg.known_hosts}")
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def _observed_transfer(op: str, label: str, remote_name: str, fn: Callable[[], TransferResult]) -> TransferResult:
|
|
1190
|
+
"""Run one unit transfer with timing metrics and structured result logs."""
|
|
1191
|
+
tags = {"unit": label, "remote": remote_name}
|
|
1192
|
+
with metrics.timed(f"{op}.duration_seconds", tags=tags):
|
|
1193
|
+
xfer = fn()
|
|
1194
|
+
if xfer.errors:
|
|
1195
|
+
_log.error(f"{op} unit failed", extra={"unit": label, "remote": remote_name, "errors": len(xfer.errors)})
|
|
1196
|
+
else:
|
|
1197
|
+
metrics.record_transfer(op, files=xfer.files_transferred, transferred_bytes=xfer.bytes_transferred, tags=tags)
|
|
1198
|
+
_log.info(
|
|
1199
|
+
f"{op} unit ok",
|
|
1200
|
+
extra={
|
|
1201
|
+
"unit": label,
|
|
1202
|
+
"remote": remote_name,
|
|
1203
|
+
"files": xfer.files_transferred,
|
|
1204
|
+
"bytes": xfer.bytes_transferred,
|
|
1205
|
+
},
|
|
1206
|
+
)
|
|
1207
|
+
return xfer
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
def _format_size(total_bytes: int) -> str:
|
|
1211
|
+
"""Format byte count as human-readable size string."""
|
|
1212
|
+
if total_bytes >= 1024 * 1024 * 1024:
|
|
1213
|
+
return f"{total_bytes / (1024**3):.1f} GB"
|
|
1214
|
+
if total_bytes >= 1024 * 1024:
|
|
1215
|
+
return f"{total_bytes / (1024**2):.1f} MB"
|
|
1216
|
+
if total_bytes >= 1024:
|
|
1217
|
+
return f"{total_bytes / 1024:.1f} KB"
|
|
1218
|
+
return f"{total_bytes} B"
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
def _resolve_unit_path(
|
|
1222
|
+
stage_cfg: StageConfig,
|
|
1223
|
+
project_root: Path,
|
|
1224
|
+
unit_id: str,
|
|
1225
|
+
) -> Path:
|
|
1226
|
+
"""Resolve the absolute path for a unit respecting sync_by mode."""
|
|
1227
|
+
if stage_cfg.path is None:
|
|
1228
|
+
raise ValueError("Stage path is not configured. Bind the stage before using local files.")
|
|
1229
|
+
stage_path = stage_cfg.path
|
|
1230
|
+
if stage_cfg.sync_by == "directory":
|
|
1231
|
+
return project_root / stage_path
|
|
1232
|
+
return project_root / stage_path / unit_id
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
def _remote_prefix_for_unit(stage_cfg: StageConfig, uid: str) -> str:
|
|
1236
|
+
"""Compute the remote prefix string for a data unit.
|
|
1237
|
+
|
|
1238
|
+
Uses ``remote_path`` when set on the stage; otherwise falls back to
|
|
1239
|
+
the stage ``path``.
|
|
1240
|
+
"""
|
|
1241
|
+
remote_base = _effective_remote_base(stage_cfg)
|
|
1242
|
+
unit_prefix = remote_base if stage_cfg.sync_by == "directory" else remote_base / uid
|
|
1243
|
+
return PurePosixPath(unit_prefix).as_posix()
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def _entry_remote_path_matches_current(
|
|
1247
|
+
config: ProjectConfig,
|
|
1248
|
+
stage_cfg: StageConfig,
|
|
1249
|
+
uid: str,
|
|
1250
|
+
entry: SyncEntry,
|
|
1251
|
+
) -> bool:
|
|
1252
|
+
if entry.remote_path is None:
|
|
1253
|
+
return True
|
|
1254
|
+
remote_cfg = config.remotes.get(entry.remote)
|
|
1255
|
+
if remote_cfg is None:
|
|
1256
|
+
return True
|
|
1257
|
+
current_remote_path = remote_cfg.url.rstrip("/") + "/" + _remote_prefix_for_unit(stage_cfg, uid)
|
|
1258
|
+
return entry.remote_path == current_remote_path
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def _remote_key_for_file(
|
|
1262
|
+
file_path: Path,
|
|
1263
|
+
stage_cfg: StageConfig,
|
|
1264
|
+
project_root: Path,
|
|
1265
|
+
) -> str:
|
|
1266
|
+
"""Compute the remote key for a local file within a stage.
|
|
1267
|
+
|
|
1268
|
+
When ``remote_path`` is set on the stage, the key is built by taking
|
|
1269
|
+
the file relative to the stage's absolute directory and prepending
|
|
1270
|
+
the ``remote_path``. Otherwise, uses the file relative to the
|
|
1271
|
+
project root (existing behaviour).
|
|
1272
|
+
"""
|
|
1273
|
+
if stage_cfg.remote_path is not None:
|
|
1274
|
+
if stage_cfg.path is None:
|
|
1275
|
+
raise ValueError("Stage path is not configured. Bind the stage before using local files.")
|
|
1276
|
+
stage_abs = (project_root / stage_cfg.path).resolve()
|
|
1277
|
+
file_rel = file_path.resolve().relative_to(stage_abs)
|
|
1278
|
+
return (PurePosixPath(str(stage_cfg.remote_path)) / file_rel.as_posix()).as_posix()
|
|
1279
|
+
return file_path.relative_to(project_root).as_posix()
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def _validate_stage_option(ctx: click.Context, config: ProjectConfig, stage: str | None) -> None:
|
|
1283
|
+
"""Exit when --stage names an unknown stage."""
|
|
1284
|
+
if stage and stage not in config.stages:
|
|
1285
|
+
_echo_error(f"Error: stage '{stage}' not found in config.")
|
|
1286
|
+
ctx.exit(1)
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def _resolve_manifest_pairs_or_exit(
|
|
1290
|
+
ctx: click.Context,
|
|
1291
|
+
config: ProjectConfig,
|
|
1292
|
+
project_root: Path,
|
|
1293
|
+
id_value: str,
|
|
1294
|
+
stage: str | None,
|
|
1295
|
+
*,
|
|
1296
|
+
require_stage_column: bool = False,
|
|
1297
|
+
) -> list[tuple[str, str]]:
|
|
1298
|
+
"""Resolve --id to (stage, unit_id) pairs, exiting with a message on failure."""
|
|
1299
|
+
if config.manifest is None:
|
|
1300
|
+
_echo_error("Error: --id requires a manifest section in forest.yaml.")
|
|
1301
|
+
ctx.exit(1)
|
|
1302
|
+
return []
|
|
1303
|
+
manifest_path = project_root / config.manifest.file
|
|
1304
|
+
try:
|
|
1305
|
+
manifest = load_manifest(manifest_path)
|
|
1306
|
+
except FileNotFoundError:
|
|
1307
|
+
_echo_error(f"Error: Manifest file not found: {manifest_path}")
|
|
1308
|
+
ctx.exit(1)
|
|
1309
|
+
return []
|
|
1310
|
+
if require_stage_column and stage and stage not in config.manifest.stage_columns:
|
|
1311
|
+
_echo_error(
|
|
1312
|
+
f"Error: stage '{stage}' is not in manifest.stage_columns. "
|
|
1313
|
+
f"Available stages: {list(config.manifest.stage_columns.keys())}"
|
|
1314
|
+
)
|
|
1315
|
+
ctx.exit(1)
|
|
1316
|
+
return []
|
|
1317
|
+
try:
|
|
1318
|
+
return resolve_manifest_pairs(config, manifest, id_value, stage)
|
|
1319
|
+
except ManifestError as exc:
|
|
1320
|
+
_echo_error(f"Error: {exc}")
|
|
1321
|
+
ctx.exit(1)
|
|
1322
|
+
return []
|
|
1323
|
+
|
|
1324
|
+
|
|
1325
|
+
def _validate_targets_all(
|
|
1326
|
+
ctx: click.Context,
|
|
1327
|
+
config: ProjectConfig,
|
|
1328
|
+
project_root: Path,
|
|
1329
|
+
stage: str | None,
|
|
1330
|
+
) -> list[tuple[str, str, Path]]:
|
|
1331
|
+
stages = {stage: config.stages[stage]} if stage else config.stages
|
|
1332
|
+
if not _ensure_stages_bound(ctx, config, list(stages)):
|
|
1333
|
+
return []
|
|
1334
|
+
targets: list[tuple[str, str, Path]] = []
|
|
1335
|
+
for stage_name, stage_cfg in stages.items():
|
|
1336
|
+
for uid, abs_path in discover_units(stage_cfg, project_root):
|
|
1337
|
+
targets.append((stage_name, uid, abs_path))
|
|
1338
|
+
return targets
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
def _validate_targets_by_id(
|
|
1342
|
+
ctx: click.Context,
|
|
1343
|
+
config: ProjectConfig,
|
|
1344
|
+
project_root: Path,
|
|
1345
|
+
id_value: str,
|
|
1346
|
+
stage: str | None,
|
|
1347
|
+
) -> list[tuple[str, str, Path]]:
|
|
1348
|
+
pairs = _resolve_manifest_pairs_or_exit(ctx, config, project_root, id_value, stage, require_stage_column=True)
|
|
1349
|
+
if not _ensure_stages_bound(ctx, config, sorted({stage_name for stage_name, _uid in pairs})):
|
|
1350
|
+
return []
|
|
1351
|
+
targets: list[tuple[str, str, Path]] = []
|
|
1352
|
+
for stage_name, uid in pairs:
|
|
1353
|
+
stage_cfg = config.stages[stage_name]
|
|
1354
|
+
abs_path = _resolve_unit_path(stage_cfg, project_root, uid)
|
|
1355
|
+
targets.append((stage_name, uid, abs_path))
|
|
1356
|
+
if not targets:
|
|
1357
|
+
assert config.manifest is not None
|
|
1358
|
+
_echo_error(f"Error: ID '{id_value}' not found in manifest column '{config.manifest.id_column}'.")
|
|
1359
|
+
ctx.exit(1)
|
|
1360
|
+
return []
|
|
1361
|
+
return targets
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def _validate_targets_explicit(
|
|
1365
|
+
ctx: click.Context,
|
|
1366
|
+
config: ProjectConfig,
|
|
1367
|
+
project_root: Path,
|
|
1368
|
+
unit_ids: tuple[str, ...],
|
|
1369
|
+
stage: str | None,
|
|
1370
|
+
) -> list[tuple[str, str, Path]]:
|
|
1371
|
+
if not stage:
|
|
1372
|
+
_echo_error("Error: --stage is required when specifying unit IDs.")
|
|
1373
|
+
ctx.exit(1)
|
|
1374
|
+
return []
|
|
1375
|
+
if stage not in config.stages:
|
|
1376
|
+
_echo_error(f"Error: stage '{stage}' not found in config.")
|
|
1377
|
+
ctx.exit(1)
|
|
1378
|
+
return []
|
|
1379
|
+
stage_cfg = config.stages[stage]
|
|
1380
|
+
if not _ensure_stage_bound(ctx, stage, stage_cfg):
|
|
1381
|
+
return []
|
|
1382
|
+
return [(stage, uid, _resolve_unit_path(stage_cfg, project_root, uid)) for uid in unit_ids]
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
def _resolve_validate_targets(
|
|
1386
|
+
ctx: click.Context,
|
|
1387
|
+
config: ProjectConfig,
|
|
1388
|
+
project_root: Path,
|
|
1389
|
+
unit_ids: tuple[str, ...],
|
|
1390
|
+
stage: str | None,
|
|
1391
|
+
id_value: str | None,
|
|
1392
|
+
all_flag: bool,
|
|
1393
|
+
) -> list[tuple[str, str, Path]]:
|
|
1394
|
+
"""Resolve validate targets to (stage_name, unit_id, unit_abs_path) triples.
|
|
1395
|
+
|
|
1396
|
+
Returns an empty list and exits with error on problems.
|
|
1397
|
+
"""
|
|
1398
|
+
if all_flag:
|
|
1399
|
+
return _validate_targets_all(ctx, config, project_root, stage)
|
|
1400
|
+
if id_value:
|
|
1401
|
+
return _validate_targets_by_id(ctx, config, project_root, id_value, stage)
|
|
1402
|
+
if unit_ids:
|
|
1403
|
+
return _validate_targets_explicit(ctx, config, project_root, unit_ids, stage)
|
|
1404
|
+
_echo_error("Error: provide UNIT_IDs with --stage, use --id, or use --all.")
|
|
1405
|
+
ctx.exit(1)
|
|
1406
|
+
return []
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
@dataclass
|
|
1410
|
+
class _TransferRequest:
|
|
1411
|
+
"""Resolved per-command context shared by unit transfer helpers."""
|
|
1412
|
+
|
|
1413
|
+
transport: RcloneTransport
|
|
1414
|
+
remote_name: str
|
|
1415
|
+
remote_cfg: RemoteConfig
|
|
1416
|
+
config: ProjectConfig
|
|
1417
|
+
project_root: Path
|
|
1418
|
+
state_path: Path
|
|
1419
|
+
dry_run: bool
|
|
1420
|
+
exclude: tuple[str, ...] = ()
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
def _excluded_paths(abs_path: Path, exclude: tuple[str, ...]) -> set[Path]:
|
|
1424
|
+
"""Expand ad-hoc --exclude globs to the set of files they cover."""
|
|
1425
|
+
excluded: set[Path] = set()
|
|
1426
|
+
for pattern in exclude:
|
|
1427
|
+
for match in abs_path.glob(pattern):
|
|
1428
|
+
if match.is_file():
|
|
1429
|
+
excluded.add(match)
|
|
1430
|
+
elif match.is_dir():
|
|
1431
|
+
for f in match.rglob("*"):
|
|
1432
|
+
if f.is_file():
|
|
1433
|
+
excluded.add(f)
|
|
1434
|
+
return excluded
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
def _strip_forest_paths(sync_files: list[Path], project_root: Path) -> list[Path]:
|
|
1438
|
+
"""Drop files that live under any .forest/ metadata directory."""
|
|
1439
|
+
kept: list[Path] = []
|
|
1440
|
+
for f in sync_files:
|
|
1441
|
+
try:
|
|
1442
|
+
parts = f.relative_to(project_root).parts
|
|
1443
|
+
except ValueError:
|
|
1444
|
+
parts = f.parts
|
|
1445
|
+
if ".forest" not in parts:
|
|
1446
|
+
kept.append(f)
|
|
1447
|
+
return kept
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def _unit_sync_files(abs_path: Path, project_root: Path, exclude: tuple[str, ...]) -> list[Path]:
|
|
1451
|
+
"""Collect a unit's pushable files; OS junk, --exclude matches, and .forest/ are dropped."""
|
|
1452
|
+
sync_files = [abs_path] if abs_path.is_file() else list_sync_files(abs_path)
|
|
1453
|
+
if exclude:
|
|
1454
|
+
excluded_set = _excluded_paths(abs_path, exclude)
|
|
1455
|
+
sync_files = [f for f in sync_files if f not in excluded_set]
|
|
1456
|
+
return _strip_forest_paths(sync_files, project_root)
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
def _last_synced_entry(state_path: Path, remote_name: str, stage_name: str, uid: str) -> SyncEntry | None:
|
|
1460
|
+
"""Return the newest sync entry for (remote, stage, unit) from the locked state file."""
|
|
1461
|
+
with checkout_lock(state_path):
|
|
1462
|
+
current_state = load_state(state_path)
|
|
1463
|
+
for entry in reversed(current_state.entries):
|
|
1464
|
+
if entry.remote == remote_name and entry.stage == stage_name and entry.unit_id == uid:
|
|
1465
|
+
return entry
|
|
1466
|
+
return None
|
|
1467
|
+
|
|
1468
|
+
|
|
1469
|
+
def _matches_existing_push(existing: SyncEntry | None, checksum: str, remote_path: str) -> bool:
|
|
1470
|
+
return existing is not None and existing.checksum == checksum and existing.remote_path == remote_path
|
|
1471
|
+
|
|
1472
|
+
|
|
1473
|
+
def _sync_entry_payload(
|
|
1474
|
+
request: _TransferRequest,
|
|
1475
|
+
stage_name: str,
|
|
1476
|
+
uid: str,
|
|
1477
|
+
stage_cfg: StageConfig,
|
|
1478
|
+
local_path: Path,
|
|
1479
|
+
remote_path: str,
|
|
1480
|
+
checksum: str,
|
|
1481
|
+
xfer: TransferResult,
|
|
1482
|
+
) -> dict[str, object]:
|
|
1483
|
+
return {
|
|
1484
|
+
"remote": request.remote_name,
|
|
1485
|
+
"stage": stage_name,
|
|
1486
|
+
"unit_id": uid,
|
|
1487
|
+
"schema": stage_cfg.schema_,
|
|
1488
|
+
"local_path": str(local_path),
|
|
1489
|
+
"remote_path": remote_path,
|
|
1490
|
+
"file_count": xfer.files_transferred,
|
|
1491
|
+
"total_bytes": xfer.bytes_transferred,
|
|
1492
|
+
"checksum": checksum,
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
def _record_transfer(request: _TransferRequest, direction: Literal["push", "pull"], payload: dict[str, object]) -> None:
|
|
1497
|
+
record = record_push if direction == "push" else record_pull
|
|
1498
|
+
with checkout_lock(request.state_path):
|
|
1499
|
+
save_state(record(load_state(request.state_path), payload), request.state_path)
|
|
1500
|
+
|
|
1501
|
+
|
|
1502
|
+
def _echo_push_file_details(ctx: click.Context, file_tuples: list[tuple[Path, str]]) -> None:
|
|
1503
|
+
for f, remote_key in file_tuples:
|
|
1504
|
+
_echo_verbose(ctx, f" {remote_key} ({_format_size(f.stat().st_size)})")
|
|
1505
|
+
|
|
1506
|
+
|
|
1507
|
+
def _push_unit(ctx: click.Context, request: _TransferRequest, stage_name: str, uid: str, abs_path: Path) -> bool:
|
|
1508
|
+
"""Push one unit via transport; return False when the unit failed."""
|
|
1509
|
+
stage_cfg = request.config.stages[stage_name]
|
|
1510
|
+
stage_path = _required_stage_path(ctx, stage_name, stage_cfg)
|
|
1511
|
+
label = f"{stage_name}/{uid}"
|
|
1512
|
+
|
|
1513
|
+
# Reject nonexistent explicit targets
|
|
1514
|
+
if not abs_path.exists():
|
|
1515
|
+
_echo_error(f"FAIL {label} target path does not exist: {abs_path}")
|
|
1516
|
+
return False
|
|
1517
|
+
|
|
1518
|
+
sync_files = _unit_sync_files(abs_path, request.project_root, request.exclude)
|
|
1519
|
+
|
|
1520
|
+
# Nothing left to transfer (filters removed everything): report a graceful
|
|
1521
|
+
# no-op instead of a misleading "PUSH ... 0 files" and never invoke rclone
|
|
1522
|
+
# on an empty list or record a transferred-bytes entry.
|
|
1523
|
+
if not sync_files:
|
|
1524
|
+
_echo_info(ctx, f"SKIP {label} nothing to push")
|
|
1525
|
+
return True
|
|
1526
|
+
|
|
1527
|
+
# For file mode, use stage dir as checksum base; otherwise use unit path
|
|
1528
|
+
checksum_base = request.project_root / stage_path if stage_cfg.sync_by == "file" else abs_path
|
|
1529
|
+
checksum = compute_checksum(checksum_base, sync_files)
|
|
1530
|
+
current_remote_path = request.remote_cfg.url.rstrip("/") + "/" + _remote_prefix_for_unit(stage_cfg, uid)
|
|
1531
|
+
|
|
1532
|
+
# Skip when already synced under the active remote.
|
|
1533
|
+
existing = _last_synced_entry(request.state_path, request.remote_name, stage_name, uid)
|
|
1534
|
+
if _matches_existing_push(existing, checksum, current_remote_path):
|
|
1535
|
+
_echo_info(ctx, f"SKIP {label} already synced to {request.remote_name}")
|
|
1536
|
+
return True
|
|
1537
|
+
|
|
1538
|
+
file_tuples = [(f, _remote_key_for_file(f, stage_cfg, request.project_root)) for f in sync_files]
|
|
1539
|
+
size_str = _format_size(sum(f.stat().st_size for f in sync_files))
|
|
1540
|
+
|
|
1541
|
+
if request.dry_run:
|
|
1542
|
+
_echo_info(ctx, f"PLAN {label} {len(sync_files)} files, {size_str} -> {request.remote_name}")
|
|
1543
|
+
_echo_push_file_details(ctx, file_tuples)
|
|
1544
|
+
return True
|
|
1545
|
+
|
|
1546
|
+
# Push via transport (rclone streams --stats to stderr unless --quiet)
|
|
1547
|
+
xfer = _observed_transfer(
|
|
1548
|
+
"push",
|
|
1549
|
+
label,
|
|
1550
|
+
request.remote_name,
|
|
1551
|
+
partial(request.transport.push, file_tuples, request.remote_cfg.url, quiet=ctx.obj.get("quiet", False)),
|
|
1552
|
+
)
|
|
1553
|
+
if _echo_transfer_errors(label, xfer):
|
|
1554
|
+
return False
|
|
1555
|
+
|
|
1556
|
+
payload = _sync_entry_payload(request, stage_name, uid, stage_cfg, abs_path, current_remote_path, checksum, xfer)
|
|
1557
|
+
# File snapshot enables detailed diff support.
|
|
1558
|
+
payload["file_snapshot"] = {str(f.relative_to(checksum_base)): f.stat().st_size for f in sync_files}
|
|
1559
|
+
_record_transfer(request, "push", payload)
|
|
1560
|
+
|
|
1561
|
+
_echo_info(ctx, f"PUSH {label} {xfer.files_transferred} files, {size_str} -> {request.remote_name}")
|
|
1562
|
+
_echo_push_file_details(ctx, file_tuples)
|
|
1563
|
+
return True
|
|
1564
|
+
|
|
1565
|
+
|
|
1566
|
+
def _discover_bare_push_targets(
|
|
1567
|
+
ctx: click.Context, config: ProjectConfig, project_root: Path
|
|
1568
|
+
) -> list[tuple[str, str, Path]]:
|
|
1569
|
+
"""Discover units across bound stages for bare push; report when nothing is found."""
|
|
1570
|
+
bound_stages = _bound_stage_subset(ctx, config)
|
|
1571
|
+
targets: list[tuple[str, str, Path]] = []
|
|
1572
|
+
for stage_name, stage_cfg in bound_stages.items():
|
|
1573
|
+
for uid, abs_path in discover_units(stage_cfg, project_root):
|
|
1574
|
+
targets.append((stage_name, uid, abs_path))
|
|
1575
|
+
if not targets:
|
|
1576
|
+
_echo_info(ctx, "No units found in bound stages.")
|
|
1577
|
+
_echo_sync_by_hints(ctx, project_root, bound_stages)
|
|
1578
|
+
return targets
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
def _echo_sync_by_hints(ctx: click.Context, project_root: Path, bound_stages: dict[str, StageConfig]) -> None:
|
|
1582
|
+
for stage_name, stage_cfg in bound_stages.items():
|
|
1583
|
+
stage_dir = project_root / stage_cfg.path if stage_cfg.path is not None else None
|
|
1584
|
+
if stage_dir is not None and stage_dir.is_dir() and any(stage_dir.iterdir()):
|
|
1585
|
+
_echo_info(
|
|
1586
|
+
ctx,
|
|
1587
|
+
f"Hint: stage '{stage_name}' has files but no {stage_cfg.sync_by} units; "
|
|
1588
|
+
"check its sync_by mode in forest.yaml.",
|
|
1589
|
+
)
|
|
1590
|
+
|
|
1591
|
+
|
|
1592
|
+
def _resolve_push_targets_before_remote(
|
|
1593
|
+
ctx: click.Context,
|
|
1594
|
+
config: ProjectConfig,
|
|
1595
|
+
project_root: Path,
|
|
1596
|
+
unit_ids: tuple[str, ...],
|
|
1597
|
+
stage: str | None,
|
|
1598
|
+
id_value: str | None,
|
|
1599
|
+
all_flag: bool,
|
|
1600
|
+
) -> list[tuple[str, str, Path]] | None:
|
|
1601
|
+
"""Resolve push targets that are known before the remote resolves.
|
|
1602
|
+
|
|
1603
|
+
With --all only stage configuration and bindings are validated here; unit
|
|
1604
|
+
discovery happens after the remote resolves (preserving historical output
|
|
1605
|
+
order), signalled by returning None. Returns [] when there is nothing to
|
|
1606
|
+
do and the situation was already reported.
|
|
1607
|
+
"""
|
|
1608
|
+
if all_flag:
|
|
1609
|
+
if not config.stages:
|
|
1610
|
+
_echo_error("Error: no stages configured. Run: forest add STAGE PATH or define stages in forest.yaml.")
|
|
1611
|
+
ctx.exit(1)
|
|
1612
|
+
stages = {stage: config.stages[stage]} if stage else config.stages
|
|
1613
|
+
_ensure_stages_bound(ctx, config, list(stages))
|
|
1614
|
+
return None
|
|
1615
|
+
if unit_ids or stage or id_value:
|
|
1616
|
+
return _resolve_validate_targets(ctx, config, project_root, unit_ids, stage, id_value, False)
|
|
1617
|
+
return _discover_bare_push_targets(ctx, config, project_root)
|
|
1618
|
+
|
|
1619
|
+
|
|
1620
|
+
def _acquire_transport(ctx: click.Context, remote_name: str, remote_cfg: RemoteConfig) -> RcloneTransport:
|
|
1621
|
+
"""Gate on rclone availability, build the transport backend, and echo the remote banner."""
|
|
1622
|
+
# rclone is required for every transport operation (G3 baseline gating).
|
|
1623
|
+
if not is_available():
|
|
1624
|
+
_echo_error(f"Error: {RCLONE_INSTALL_MSG}")
|
|
1625
|
+
ctx.exit(1)
|
|
1626
|
+
try:
|
|
1627
|
+
transport = get_transport(remote_cfg)
|
|
1628
|
+
except ValueError as exc:
|
|
1629
|
+
_echo_error(f"Error: {exc}")
|
|
1630
|
+
ctx.exit(1)
|
|
1631
|
+
raise AssertionError("unreachable") from exc
|
|
1632
|
+
_preflight_remote_config(ctx, remote_cfg)
|
|
1633
|
+
_echo_verbose(ctx, f"Using remote '{remote_name}' [{remote_cfg.remote_type}] at {remote_cfg.url}")
|
|
1634
|
+
if remote_cfg.remote_type == "s3":
|
|
1635
|
+
_echo_verbose(ctx, f" backend={transport.backend_name}")
|
|
1636
|
+
return transport
|
|
1637
|
+
|
|
1638
|
+
|
|
1639
|
+
@main.command()
|
|
1640
|
+
@click.argument("unit_ids", nargs=-1)
|
|
1641
|
+
@click.option("--stage", default=None, help="Stage to push from.")
|
|
1642
|
+
@click.option("--id", "id_value", default=None, help="Resolve units via manifest ID.")
|
|
1643
|
+
@click.option("--all", "all_flag", is_flag=True, help="Push all units in all stages.")
|
|
1644
|
+
@click.option("--force", "_force", is_flag=True, help="Retained for compatibility; no effect.")
|
|
1645
|
+
@click.option("--dry-run", is_flag=True, help="Show plan without transferring.")
|
|
1646
|
+
@click.option("--exclude", multiple=True, help="Ad-hoc exclude glob patterns.")
|
|
1647
|
+
@click.pass_context
|
|
1648
|
+
def push(
|
|
1649
|
+
ctx: click.Context,
|
|
1650
|
+
unit_ids: tuple[str, ...],
|
|
1651
|
+
stage: str | None,
|
|
1652
|
+
id_value: str | None,
|
|
1653
|
+
all_flag: bool,
|
|
1654
|
+
_force: bool,
|
|
1655
|
+
dry_run: bool,
|
|
1656
|
+
exclude: tuple[str, ...],
|
|
1657
|
+
) -> None:
|
|
1658
|
+
"""Push data units to a remote.
|
|
1659
|
+
|
|
1660
|
+
Resolves targets, transfers all files in each unit (minus OS junk and
|
|
1661
|
+
ad-hoc --exclude matches) via transport, and records sync state on success.
|
|
1662
|
+
"""
|
|
1663
|
+
config = _load_project_config(ctx)
|
|
1664
|
+
project_root = _project_root(ctx)
|
|
1665
|
+
_validate_stage_option(ctx, config, stage)
|
|
1666
|
+
|
|
1667
|
+
targets = _resolve_push_targets_before_remote(ctx, config, project_root, unit_ids, stage, id_value, all_flag)
|
|
1668
|
+
if targets is not None and not targets:
|
|
1669
|
+
return
|
|
1670
|
+
|
|
1671
|
+
remote_name = _resolve_remote(ctx, config, project_root, None)
|
|
1672
|
+
if remote_name is None:
|
|
1673
|
+
return # unreachable — _resolve_remote exits on error
|
|
1674
|
+
remote_cfg = config.remotes[remote_name]
|
|
1675
|
+
|
|
1676
|
+
# Resolve --all targets after the remote (reuse validate logic)
|
|
1677
|
+
if all_flag:
|
|
1678
|
+
targets = _resolve_validate_targets(ctx, config, project_root, unit_ids, stage, id_value, all_flag)
|
|
1679
|
+
if not targets:
|
|
1680
|
+
return
|
|
1681
|
+
assert targets is not None
|
|
1682
|
+
|
|
1683
|
+
transport = _acquire_transport(ctx, remote_name, remote_cfg)
|
|
1684
|
+
request = _TransferRequest(
|
|
1685
|
+
transport=transport,
|
|
1686
|
+
remote_name=remote_name,
|
|
1687
|
+
remote_cfg=remote_cfg,
|
|
1688
|
+
config=config,
|
|
1689
|
+
project_root=project_root,
|
|
1690
|
+
state_path=_state_path(ctx),
|
|
1691
|
+
dry_run=dry_run,
|
|
1692
|
+
exclude=exclude,
|
|
1693
|
+
)
|
|
1694
|
+
|
|
1695
|
+
had_failure = False
|
|
1696
|
+
for stage_name, uid, abs_path in targets:
|
|
1697
|
+
if not _push_unit(ctx, request, stage_name, uid, abs_path):
|
|
1698
|
+
had_failure = True
|
|
1699
|
+
|
|
1700
|
+
if had_failure:
|
|
1701
|
+
monitoring.report_failure("push completed with errors", f"remote '{remote_name}'", command="push")
|
|
1702
|
+
ctx.exit(1)
|
|
1703
|
+
|
|
1704
|
+
|
|
1705
|
+
# ---------------------------------------------------------------------------
|
|
1706
|
+
# Pull helpers
|
|
1707
|
+
# ---------------------------------------------------------------------------
|
|
1708
|
+
|
|
1709
|
+
|
|
1710
|
+
def _is_metadata_remote_key(key: str) -> bool:
|
|
1711
|
+
"""Return True for forest/macOS metadata keys hidden from data views."""
|
|
1712
|
+
parts = [part for part in key.split("/") if part]
|
|
1713
|
+
return any(part == ".forest" or part == ".DS_Store" or part.startswith("._") for part in parts)
|
|
1714
|
+
|
|
1715
|
+
|
|
1716
|
+
def _safe_remote_file_key(key: str) -> PurePosixPath:
|
|
1717
|
+
rel = PurePosixPath(key)
|
|
1718
|
+
if key == "" or rel.is_absolute() or any(part in {"", ".", ".."} for part in rel.parts):
|
|
1719
|
+
raise ValueError(f"unsafe remote key: {key}")
|
|
1720
|
+
return rel
|
|
1721
|
+
|
|
1722
|
+
|
|
1723
|
+
def _non_metadata_remote_files(remote_files: list[RemoteFile]) -> list[RemoteFile]:
|
|
1724
|
+
"""Drop metadata keys before ls/status/pull grouping decisions."""
|
|
1725
|
+
return [rf for rf in remote_files if not _is_metadata_remote_key(rf.key)]
|
|
1726
|
+
|
|
1727
|
+
|
|
1728
|
+
def _listed_remote_files(transport_obj: RcloneTransport, remote_url: str, prefix: str = "") -> list[RemoteFile]:
|
|
1729
|
+
"""List remote files and centralize metadata filtering."""
|
|
1730
|
+
return _non_metadata_remote_files(transport_obj.ls(remote_url, prefix=prefix))
|
|
1731
|
+
|
|
1732
|
+
|
|
1733
|
+
def _subdirectory_remote_targets(
|
|
1734
|
+
project_root: Path, stage_name: str, stage_path: Path, remote_files: list[RemoteFile]
|
|
1735
|
+
) -> list[tuple[str, str, Path]]:
|
|
1736
|
+
unit_ids: set[str] = set()
|
|
1737
|
+
for rf in remote_files:
|
|
1738
|
+
parts = rf.key.split("/")
|
|
1739
|
+
if parts:
|
|
1740
|
+
unit_ids.add(parts[0])
|
|
1741
|
+
return [(stage_name, uid, project_root / stage_path / uid) for uid in sorted(unit_ids)]
|
|
1742
|
+
|
|
1743
|
+
|
|
1744
|
+
def _file_remote_targets(
|
|
1745
|
+
project_root: Path, stage_name: str, stage_path: Path, remote_files: list[RemoteFile]
|
|
1746
|
+
) -> list[tuple[str, str, Path]]:
|
|
1747
|
+
targets: list[tuple[str, str, Path]] = []
|
|
1748
|
+
seen: set[str] = set()
|
|
1749
|
+
for rf in remote_files:
|
|
1750
|
+
parts = rf.key.split("/")
|
|
1751
|
+
if len(parts) == 1 and parts[0] not in seen:
|
|
1752
|
+
seen.add(parts[0])
|
|
1753
|
+
targets.append((stage_name, parts[0], project_root / stage_path / parts[0]))
|
|
1754
|
+
return targets
|
|
1755
|
+
|
|
1756
|
+
|
|
1757
|
+
def _stage_remote_targets(
|
|
1758
|
+
project_root: Path, stage_name: str, stage_cfg: StageConfig, stage_path: Path, remote_files: list[RemoteFile]
|
|
1759
|
+
) -> list[tuple[str, str, Path]]:
|
|
1760
|
+
"""Group a stage's remote listing into (stage, unit_id, local_path) targets."""
|
|
1761
|
+
if stage_cfg.sync_by == "directory":
|
|
1762
|
+
if remote_files:
|
|
1763
|
+
return [(stage_name, stage_path.name, project_root / stage_path)]
|
|
1764
|
+
return []
|
|
1765
|
+
if stage_cfg.sync_by == "subdirectory":
|
|
1766
|
+
return _subdirectory_remote_targets(project_root, stage_name, stage_path, remote_files)
|
|
1767
|
+
if stage_cfg.sync_by == "file":
|
|
1768
|
+
return _file_remote_targets(project_root, stage_name, stage_path, remote_files)
|
|
1769
|
+
return []
|
|
1770
|
+
|
|
1771
|
+
|
|
1772
|
+
def _resolve_pull_all_targets(
|
|
1773
|
+
ctx: click.Context,
|
|
1774
|
+
project_root: Path,
|
|
1775
|
+
transport_obj: RcloneTransport,
|
|
1776
|
+
remote_url: str,
|
|
1777
|
+
stages: dict[str, StageConfig],
|
|
1778
|
+
) -> list[tuple[str, str, Path]]:
|
|
1779
|
+
"""Discover units on the remote for the given stages (pull --all / bare pull)."""
|
|
1780
|
+
targets: list[tuple[str, str, Path]] = []
|
|
1781
|
+
for stage_name, stage_cfg in stages.items():
|
|
1782
|
+
stage_path = _required_stage_path(ctx, stage_name, stage_cfg)
|
|
1783
|
+
prefix = _effective_remote_base(stage_cfg).as_posix()
|
|
1784
|
+
remote_files = _listed_remote_files(transport_obj, remote_url, prefix=prefix)
|
|
1785
|
+
targets.extend(_stage_remote_targets(project_root, stage_name, stage_cfg, stage_path, remote_files))
|
|
1786
|
+
return targets
|
|
1787
|
+
|
|
1788
|
+
|
|
1789
|
+
# ---------------------------------------------------------------------------
|
|
1790
|
+
# Pull command
|
|
1791
|
+
# ---------------------------------------------------------------------------
|
|
1792
|
+
|
|
1793
|
+
|
|
1794
|
+
def _resolve_pull_scope(
|
|
1795
|
+
ctx: click.Context,
|
|
1796
|
+
config: ProjectConfig,
|
|
1797
|
+
project_root: Path,
|
|
1798
|
+
unit_ids: tuple[str, ...],
|
|
1799
|
+
stage: str | None,
|
|
1800
|
+
id_value: str | None,
|
|
1801
|
+
all_flag: bool,
|
|
1802
|
+
) -> tuple[list[tuple[str, str, Path]] | None, dict[str, StageConfig] | None]:
|
|
1803
|
+
"""Split pull arguments into explicit targets or stages to discover remotely.
|
|
1804
|
+
|
|
1805
|
+
Returns ``(targets, None)`` for explicit selection and ``(None, stages)``
|
|
1806
|
+
when units must be discovered on the remote (bare pull / --all).
|
|
1807
|
+
"""
|
|
1808
|
+
if not unit_ids and not stage and not id_value and not all_flag:
|
|
1809
|
+
return None, _bound_stage_subset(ctx, config)
|
|
1810
|
+
if all_flag:
|
|
1811
|
+
if not config.stages:
|
|
1812
|
+
_echo_error("Error: no stages configured. Run: forest add STAGE PATH or define stages in forest.yaml.")
|
|
1813
|
+
ctx.exit(1)
|
|
1814
|
+
pull_stages = {stage: config.stages[stage]} if stage else dict(config.stages)
|
|
1815
|
+
_ensure_stages_bound(ctx, config, list(pull_stages))
|
|
1816
|
+
return None, pull_stages
|
|
1817
|
+
return _resolve_validate_targets(ctx, config, project_root, unit_ids, stage, id_value, False), None
|
|
1818
|
+
|
|
1819
|
+
|
|
1820
|
+
def _list_pull_all_targets(
|
|
1821
|
+
ctx: click.Context,
|
|
1822
|
+
request: _TransferRequest,
|
|
1823
|
+
pull_stages: dict[str, StageConfig],
|
|
1824
|
+
) -> list[tuple[str, str, Path]]:
|
|
1825
|
+
"""Discover remote units for bare pull / --all; report when none exist."""
|
|
1826
|
+
try:
|
|
1827
|
+
targets = _resolve_pull_all_targets(
|
|
1828
|
+
ctx, request.project_root, request.transport, request.remote_cfg.url, pull_stages
|
|
1829
|
+
)
|
|
1830
|
+
except RcloneError as exc:
|
|
1831
|
+
_echo_error(f"Error: failed to list remote '{request.remote_name}': {_transport_error_detail(exc)}")
|
|
1832
|
+
ctx.exit(1)
|
|
1833
|
+
return []
|
|
1834
|
+
if not targets:
|
|
1835
|
+
_echo_info(ctx, "No units found on remote.")
|
|
1836
|
+
return targets
|
|
1837
|
+
|
|
1838
|
+
|
|
1839
|
+
def _echo_pull_file_details(ctx: click.Context, remote_files: list[RemoteFile]) -> None:
|
|
1840
|
+
for rf in remote_files:
|
|
1841
|
+
_echo_verbose(ctx, f" {rf.key} ({_format_size(rf.size)})")
|
|
1842
|
+
|
|
1843
|
+
|
|
1844
|
+
def _echo_transfer_errors(label: str, xfer: TransferResult) -> bool:
|
|
1845
|
+
"""Echo per-unit transport errors; return True when any occurred."""
|
|
1846
|
+
for err in xfer.errors:
|
|
1847
|
+
_echo_error(f"ERROR {label} {err}")
|
|
1848
|
+
return bool(xfer.errors)
|
|
1849
|
+
|
|
1850
|
+
|
|
1851
|
+
def _observed_pull(
|
|
1852
|
+
ctx: click.Context, request: _TransferRequest, label: str, pull_tuples: list[tuple[str, Path]]
|
|
1853
|
+
) -> TransferResult:
|
|
1854
|
+
# rclone streams --stats to stderr unless --quiet
|
|
1855
|
+
return _observed_transfer(
|
|
1856
|
+
"pull",
|
|
1857
|
+
label,
|
|
1858
|
+
request.remote_name,
|
|
1859
|
+
partial(request.transport.pull, pull_tuples, request.remote_cfg.url, quiet=ctx.obj.get("quiet", False)),
|
|
1860
|
+
)
|
|
1861
|
+
|
|
1862
|
+
|
|
1863
|
+
def _pull_file_unit(ctx: click.Context, request: _TransferRequest, stage_name: str, uid: str) -> bool:
|
|
1864
|
+
"""Pull a sync_by=file unit as a direct file transfer; return False on failure."""
|
|
1865
|
+
stage_cfg = request.config.stages[stage_name]
|
|
1866
|
+
stage_path = _required_stage_path(ctx, stage_name, stage_cfg)
|
|
1867
|
+
label = f"{stage_name}/{uid}"
|
|
1868
|
+
# The remote key is the file path directly, not a prefix
|
|
1869
|
+
remote_prefix = _remote_prefix_for_unit(stage_cfg, uid)
|
|
1870
|
+
local_path = request.project_root / stage_path / uid
|
|
1871
|
+
pull_tuples = [(remote_prefix, local_path)]
|
|
1872
|
+
|
|
1873
|
+
# Get file size from a stage-level ls
|
|
1874
|
+
try:
|
|
1875
|
+
stage_remote_files = _listed_remote_files(
|
|
1876
|
+
request.transport, request.remote_cfg.url, prefix=_effective_remote_base(stage_cfg).as_posix()
|
|
1877
|
+
)
|
|
1878
|
+
except RcloneError as exc:
|
|
1879
|
+
_echo_error(f"ERROR {label} {_transport_error_detail(exc)}")
|
|
1880
|
+
return False
|
|
1881
|
+
|
|
1882
|
+
file_size = next((rf.size for rf in stage_remote_files if rf.key == uid), None)
|
|
1883
|
+
if file_size is None:
|
|
1884
|
+
_echo_error(f"ERROR {label} missing remote target: {remote_prefix}")
|
|
1885
|
+
return False
|
|
1886
|
+
|
|
1887
|
+
size_str = _format_size(file_size)
|
|
1888
|
+
if request.dry_run:
|
|
1889
|
+
_echo_info(ctx, f"PLAN {label} 1 files, {size_str} <- {request.remote_name}")
|
|
1890
|
+
_echo_verbose(ctx, f" {uid} ({size_str})")
|
|
1891
|
+
return True
|
|
1892
|
+
|
|
1893
|
+
xfer = _observed_pull(ctx, request, label, pull_tuples)
|
|
1894
|
+
if _echo_transfer_errors(label, xfer):
|
|
1895
|
+
return False
|
|
1896
|
+
|
|
1897
|
+
checksum = compute_checksum(request.project_root / stage_path, [local_path])
|
|
1898
|
+
remote_path = request.remote_cfg.url.rstrip("/") + "/" + remote_prefix
|
|
1899
|
+
payload = _sync_entry_payload(request, stage_name, uid, stage_cfg, local_path, remote_path, checksum, xfer)
|
|
1900
|
+
_record_transfer(request, "pull", payload)
|
|
1901
|
+
_echo_info(ctx, f"PULL {label} {xfer.files_transferred} files, {size_str} <- {request.remote_name}")
|
|
1902
|
+
return True
|
|
1903
|
+
|
|
1904
|
+
|
|
1905
|
+
def _build_pull_tuples(
|
|
1906
|
+
label: str, remote_files: list[RemoteFile], remote_prefix: str, local_root: Path
|
|
1907
|
+
) -> list[tuple[str, Path]] | None:
|
|
1908
|
+
"""Map remote keys to local paths, rejecting unsafe keys before any local write."""
|
|
1909
|
+
pull_tuples: list[tuple[str, Path]] = []
|
|
1910
|
+
for rf in remote_files:
|
|
1911
|
+
try:
|
|
1912
|
+
safe_key = _safe_remote_file_key(rf.key)
|
|
1913
|
+
except ValueError as exc:
|
|
1914
|
+
_echo_error(f"ERROR {label} {exc}")
|
|
1915
|
+
return None
|
|
1916
|
+
pull_tuples.append((f"{remote_prefix}/{safe_key.as_posix()}", local_root / safe_key.as_posix()))
|
|
1917
|
+
return pull_tuples
|
|
1918
|
+
|
|
1919
|
+
|
|
1920
|
+
def _pull_tree_unit(ctx: click.Context, request: _TransferRequest, stage_name: str, uid: str, abs_path: Path) -> bool:
|
|
1921
|
+
"""Pull a directory/subdirectory unit from its remote prefix; return False on failure."""
|
|
1922
|
+
stage_cfg = request.config.stages[stage_name]
|
|
1923
|
+
stage_path = _required_stage_path(ctx, stage_name, stage_cfg)
|
|
1924
|
+
label = f"{stage_name}/{uid}"
|
|
1925
|
+
unit_prefix = stage_path if stage_cfg.sync_by == "directory" else stage_path / uid
|
|
1926
|
+
remote_prefix = _remote_prefix_for_unit(stage_cfg, uid)
|
|
1927
|
+
|
|
1928
|
+
# List remote files under the unit prefix (metadata keys already dropped).
|
|
1929
|
+
try:
|
|
1930
|
+
remote_files = _listed_remote_files(request.transport, request.remote_cfg.url, prefix=remote_prefix)
|
|
1931
|
+
except RcloneError as exc:
|
|
1932
|
+
_echo_error(f"ERROR {label} {_transport_error_detail(exc)}")
|
|
1933
|
+
return False
|
|
1934
|
+
if not remote_files:
|
|
1935
|
+
_echo_error(f"ERROR {label} missing remote target: {remote_prefix}")
|
|
1936
|
+
return False
|
|
1937
|
+
|
|
1938
|
+
pull_tuples = _build_pull_tuples(label, remote_files, remote_prefix, request.project_root / unit_prefix)
|
|
1939
|
+
if pull_tuples is None:
|
|
1940
|
+
return False
|
|
1941
|
+
|
|
1942
|
+
size_str = _format_size(sum(rf.size for rf in remote_files))
|
|
1943
|
+
if request.dry_run:
|
|
1944
|
+
_echo_info(ctx, f"PLAN {label} {len(remote_files)} files, {size_str} <- {request.remote_name}")
|
|
1945
|
+
_echo_pull_file_details(ctx, remote_files)
|
|
1946
|
+
return True
|
|
1947
|
+
|
|
1948
|
+
xfer = _observed_pull(ctx, request, label, pull_tuples)
|
|
1949
|
+
if _echo_transfer_errors(label, xfer):
|
|
1950
|
+
return False
|
|
1951
|
+
|
|
1952
|
+
# Compute checksum from pulled files
|
|
1953
|
+
checksum = compute_checksum(abs_path, sorted(lp for _, lp in pull_tuples))
|
|
1954
|
+
remote_path = request.remote_cfg.url.rstrip("/") + "/" + remote_prefix
|
|
1955
|
+
payload = _sync_entry_payload(request, stage_name, uid, stage_cfg, abs_path, remote_path, checksum, xfer)
|
|
1956
|
+
_record_transfer(request, "pull", payload)
|
|
1957
|
+
_echo_info(ctx, f"PULL {label} {xfer.files_transferred} files, {size_str} <- {request.remote_name}")
|
|
1958
|
+
_echo_pull_file_details(ctx, remote_files)
|
|
1959
|
+
return True
|
|
1960
|
+
|
|
1961
|
+
|
|
1962
|
+
def _pull_unit(ctx: click.Context, request: _TransferRequest, stage_name: str, uid: str, abs_path: Path) -> bool:
|
|
1963
|
+
"""Pull one unit; sync_by=file goes through the direct file transfer path."""
|
|
1964
|
+
if request.config.stages[stage_name].sync_by == "file":
|
|
1965
|
+
return _pull_file_unit(ctx, request, stage_name, uid)
|
|
1966
|
+
return _pull_tree_unit(ctx, request, stage_name, uid, abs_path)
|
|
1967
|
+
|
|
1968
|
+
|
|
1969
|
+
@main.command()
|
|
1970
|
+
@click.argument("unit_ids", nargs=-1)
|
|
1971
|
+
@click.option("--stage", default=None, help="Stage to pull from.")
|
|
1972
|
+
@click.option("--id", "id_value", default=None, help="Resolve units via manifest ID.")
|
|
1973
|
+
@click.option("--all", "all_flag", is_flag=True, help="Pull all units in all stages.")
|
|
1974
|
+
@click.option("--dry-run", is_flag=True, help="Show plan without downloading.")
|
|
1975
|
+
@click.pass_context
|
|
1976
|
+
def pull(
|
|
1977
|
+
ctx: click.Context,
|
|
1978
|
+
unit_ids: tuple[str, ...],
|
|
1979
|
+
stage: str | None,
|
|
1980
|
+
id_value: str | None,
|
|
1981
|
+
all_flag: bool,
|
|
1982
|
+
dry_run: bool,
|
|
1983
|
+
) -> None:
|
|
1984
|
+
"""Pull data units from a remote.
|
|
1985
|
+
|
|
1986
|
+
Resolves targets, lists remote files, downloads all of them via
|
|
1987
|
+
transport, and records sync state.
|
|
1988
|
+
"""
|
|
1989
|
+
config = _load_project_config(ctx)
|
|
1990
|
+
project_root = _project_root(ctx)
|
|
1991
|
+
_validate_stage_option(ctx, config, stage)
|
|
1992
|
+
|
|
1993
|
+
targets, pull_stages = _resolve_pull_scope(ctx, config, project_root, unit_ids, stage, id_value, all_flag)
|
|
1994
|
+
if pull_stages is None and not targets:
|
|
1995
|
+
return
|
|
1996
|
+
|
|
1997
|
+
remote_name = _resolve_remote(ctx, config, project_root, None)
|
|
1998
|
+
if remote_name is None:
|
|
1999
|
+
return
|
|
2000
|
+
remote_cfg = config.remotes[remote_name]
|
|
2001
|
+
|
|
2002
|
+
transport = _acquire_transport(ctx, remote_name, remote_cfg)
|
|
2003
|
+
request = _TransferRequest(
|
|
2004
|
+
transport=transport,
|
|
2005
|
+
remote_name=remote_name,
|
|
2006
|
+
remote_cfg=remote_cfg,
|
|
2007
|
+
config=config,
|
|
2008
|
+
project_root=project_root,
|
|
2009
|
+
state_path=_state_path(ctx),
|
|
2010
|
+
dry_run=dry_run,
|
|
2011
|
+
)
|
|
2012
|
+
|
|
2013
|
+
if pull_stages is not None:
|
|
2014
|
+
targets = _list_pull_all_targets(ctx, request, pull_stages)
|
|
2015
|
+
if not targets:
|
|
2016
|
+
return
|
|
2017
|
+
assert targets is not None
|
|
2018
|
+
|
|
2019
|
+
had_failure = False
|
|
2020
|
+
for stage_name, uid, abs_path in targets:
|
|
2021
|
+
if not _pull_unit(ctx, request, stage_name, uid, abs_path):
|
|
2022
|
+
had_failure = True
|
|
2023
|
+
|
|
2024
|
+
if had_failure:
|
|
2025
|
+
monitoring.report_failure("pull completed with errors", f"remote '{remote_name}'", command="pull")
|
|
2026
|
+
ctx.exit(1)
|
|
2027
|
+
|
|
2028
|
+
|
|
2029
|
+
# ---------------------------------------------------------------------------
|
|
2030
|
+
# Status command
|
|
2031
|
+
# ---------------------------------------------------------------------------
|
|
2032
|
+
|
|
2033
|
+
|
|
2034
|
+
def _format_timestamp(iso_ts: str) -> str:
|
|
2035
|
+
"""Format an ISO-8601 timestamp to a short display string."""
|
|
2036
|
+
try:
|
|
2037
|
+
from datetime import datetime
|
|
2038
|
+
|
|
2039
|
+
dt = datetime.fromisoformat(iso_ts)
|
|
2040
|
+
if dt.tzinfo is not None:
|
|
2041
|
+
dt = dt.astimezone(tz=None)
|
|
2042
|
+
return dt.strftime("%Y-%m-%d %H:%M")
|
|
2043
|
+
except (ValueError, TypeError):
|
|
2044
|
+
return iso_ts
|
|
2045
|
+
|
|
2046
|
+
|
|
2047
|
+
def _stage_scope(ctx: click.Context, config: ProjectConfig, stage: str | None) -> dict[str, StageConfig]:
|
|
2048
|
+
"""Stages to examine (ADR 0016): bare use covers bound stages and warns per
|
|
2049
|
+
unbound stage; explicit --stage stays strict."""
|
|
2050
|
+
if stage:
|
|
2051
|
+
stages = {stage: config.stages[stage]}
|
|
2052
|
+
_ensure_stages_bound(ctx, config, list(stages))
|
|
2053
|
+
return stages
|
|
2054
|
+
return _bound_stage_subset(ctx, config)
|
|
2055
|
+
|
|
2056
|
+
|
|
2057
|
+
def _local_units_in_scope(
|
|
2058
|
+
stages: dict[str, StageConfig], project_root: Path, id_targets: set[tuple[str, str]] | None
|
|
2059
|
+
) -> list[tuple[str, str, Path]]:
|
|
2060
|
+
"""Discover local units from stage paths, honoring the --id filter."""
|
|
2061
|
+
local_units: list[tuple[str, str, Path]] = []
|
|
2062
|
+
for stage_name, stage_cfg in stages.items():
|
|
2063
|
+
for uid, abs_path in discover_units(stage_cfg, project_root):
|
|
2064
|
+
if id_targets is None or (stage_name, uid) in id_targets:
|
|
2065
|
+
local_units.append((stage_name, uid, abs_path))
|
|
2066
|
+
return local_units
|
|
2067
|
+
|
|
2068
|
+
|
|
2069
|
+
def _status_entry_in_scope(
|
|
2070
|
+
entry: SyncEntry,
|
|
2071
|
+
config: ProjectConfig,
|
|
2072
|
+
stages: dict[str, StageConfig],
|
|
2073
|
+
stage: str | None,
|
|
2074
|
+
remote_name: str | None,
|
|
2075
|
+
id_targets: set[tuple[str, str]] | None,
|
|
2076
|
+
) -> bool:
|
|
2077
|
+
"""Apply stage / bound-stage / remote / --id filters to a sync entry."""
|
|
2078
|
+
if stage and entry.stage != stage:
|
|
2079
|
+
return False
|
|
2080
|
+
# A skipped (unbound) stage contributes no rows at all (ADR 0016);
|
|
2081
|
+
# stages no longer in config still surface as remote-only.
|
|
2082
|
+
if entry.stage in config.stages and entry.stage not in stages:
|
|
2083
|
+
return False
|
|
2084
|
+
# Scope sync-state entries to the active remote.
|
|
2085
|
+
if remote_name and entry.remote != remote_name:
|
|
2086
|
+
return False
|
|
2087
|
+
return id_targets is None or (entry.stage, entry.unit_id) in id_targets
|
|
2088
|
+
|
|
2089
|
+
|
|
2090
|
+
def _remote_only_status_entries(
|
|
2091
|
+
state: SyncState,
|
|
2092
|
+
config: ProjectConfig,
|
|
2093
|
+
stages: dict[str, StageConfig],
|
|
2094
|
+
stage: str | None,
|
|
2095
|
+
remote_name: str | None,
|
|
2096
|
+
id_targets: set[tuple[str, str]] | None,
|
|
2097
|
+
local_keys: set[tuple[str, str]],
|
|
2098
|
+
) -> list[tuple[str, str, str, SyncEntry]]:
|
|
2099
|
+
"""Sync-state entries whose local unit no longer exists."""
|
|
2100
|
+
remote_only: list[tuple[str, str, str, SyncEntry]] = []
|
|
2101
|
+
for entry in state.entries:
|
|
2102
|
+
if not _status_entry_in_scope(entry, config, stages, stage, remote_name, id_targets):
|
|
2103
|
+
continue
|
|
2104
|
+
if (entry.stage, entry.unit_id) not in local_keys:
|
|
2105
|
+
remote_only.append((entry.stage, entry.unit_id, entry.remote, entry))
|
|
2106
|
+
return remote_only
|
|
2107
|
+
|
|
2108
|
+
|
|
2109
|
+
def _unit_files_and_size(abs_path: Path) -> tuple[list[Path], str]:
|
|
2110
|
+
"""Collect a unit's files (OS junk skipped) and format their total size."""
|
|
2111
|
+
sync_files = [abs_path] if abs_path.is_file() else list_sync_files(abs_path)
|
|
2112
|
+
size_str = _format_size(sum(f.stat().st_size for f in sync_files) if sync_files else 0)
|
|
2113
|
+
return sync_files, size_str
|
|
2114
|
+
|
|
2115
|
+
|
|
2116
|
+
def _matching_state_entries(state: SyncState, stage_name: str, uid: str, remote_name: str | None) -> list[SyncEntry]:
|
|
2117
|
+
"""Sync entries for a unit, scoped to the active remote when known."""
|
|
2118
|
+
matching = [e for e in state.entries if e.stage == stage_name and e.unit_id == uid]
|
|
2119
|
+
if remote_name:
|
|
2120
|
+
matching = [e for e in matching if e.remote == remote_name]
|
|
2121
|
+
return matching
|
|
2122
|
+
|
|
2123
|
+
|
|
2124
|
+
def _echo_entry_status_rows(
|
|
2125
|
+
ctx: click.Context,
|
|
2126
|
+
config: ProjectConfig,
|
|
2127
|
+
stage_cfg: StageConfig,
|
|
2128
|
+
uid: str,
|
|
2129
|
+
label: str,
|
|
2130
|
+
size_str: str,
|
|
2131
|
+
current_checksum: str,
|
|
2132
|
+
entries: list[SyncEntry],
|
|
2133
|
+
) -> None:
|
|
2134
|
+
"""Emit a synced/stale row per sync entry for a local unit."""
|
|
2135
|
+
for entry in entries:
|
|
2136
|
+
direction_label = "pushed" if entry.direction == "push" else "pulled"
|
|
2137
|
+
ts = _format_timestamp(entry.timestamp)
|
|
2138
|
+
in_sync = current_checksum == entry.checksum and _entry_remote_path_matches_current(
|
|
2139
|
+
config, stage_cfg, uid, entry
|
|
2140
|
+
)
|
|
2141
|
+
status_label = f"synced:{entry.remote}" if in_sync else f"stale:{entry.remote}"
|
|
2142
|
+
_echo_info(ctx, f"{status_label} {label} {size_str} {direction_label} {ts}")
|
|
2143
|
+
|
|
2144
|
+
|
|
2145
|
+
def _echo_local_unit_status(
|
|
2146
|
+
ctx: click.Context,
|
|
2147
|
+
config: ProjectConfig,
|
|
2148
|
+
project_root: Path,
|
|
2149
|
+
state: SyncState,
|
|
2150
|
+
remote_name: str | None,
|
|
2151
|
+
stage_name: str,
|
|
2152
|
+
uid: str,
|
|
2153
|
+
abs_path: Path,
|
|
2154
|
+
) -> None:
|
|
2155
|
+
"""Classify one local unit against sync state and emit its status rows."""
|
|
2156
|
+
stage_cfg = config.stages[stage_name]
|
|
2157
|
+
stage_path = _required_stage_path(ctx, stage_name, stage_cfg)
|
|
2158
|
+
label = f"{stage_name}/{uid}"
|
|
2159
|
+
sync_files, size_str = _unit_files_and_size(abs_path)
|
|
2160
|
+
|
|
2161
|
+
matching_entries = _matching_state_entries(state, stage_name, uid, remote_name)
|
|
2162
|
+
if not matching_entries:
|
|
2163
|
+
# No sync entry for the active remote means this local unit has not
|
|
2164
|
+
# been synchronized to that remote.
|
|
2165
|
+
_echo_info(ctx, f"local-only {label} {size_str} never pushed")
|
|
2166
|
+
return
|
|
2167
|
+
|
|
2168
|
+
# For file mode, use stage dir as checksum base
|
|
2169
|
+
checksum_base = project_root / stage_path if stage_cfg.sync_by == "file" else abs_path
|
|
2170
|
+
current_checksum = compute_checksum(checksum_base, sync_files) if sync_files else ""
|
|
2171
|
+
_echo_entry_status_rows(ctx, config, stage_cfg, uid, label, size_str, current_checksum, matching_entries)
|
|
2172
|
+
|
|
2173
|
+
|
|
2174
|
+
@main.command()
|
|
2175
|
+
@click.option("--stage", default=None, help="Filter by stage.")
|
|
2176
|
+
@click.option("--id", "id_value", default=None, help="Resolve units via manifest ID.")
|
|
2177
|
+
@click.pass_context
|
|
2178
|
+
def status(
|
|
2179
|
+
ctx: click.Context,
|
|
2180
|
+
stage: str | None,
|
|
2181
|
+
id_value: str | None,
|
|
2182
|
+
) -> None:
|
|
2183
|
+
"""Show sync status of data units.
|
|
2184
|
+
|
|
2185
|
+
Compares local state against sync_state.json to classify each unit
|
|
2186
|
+
as synced, local-only, remote-only, or stale. Read-only — no files
|
|
2187
|
+
are modified and no network calls are made by default.
|
|
2188
|
+
"""
|
|
2189
|
+
config = _load_project_config(ctx)
|
|
2190
|
+
project_root = _project_root(ctx)
|
|
2191
|
+
_validate_stage_option(ctx, config, stage)
|
|
2192
|
+
|
|
2193
|
+
remote_name = _resolve_remote(ctx, config, project_root, None)
|
|
2194
|
+
if remote_name is None:
|
|
2195
|
+
return
|
|
2196
|
+
|
|
2197
|
+
state = load_state(_state_path(ctx))
|
|
2198
|
+
stages = _stage_scope(ctx, config, stage)
|
|
2199
|
+
|
|
2200
|
+
id_targets: set[tuple[str, str]] | None = None
|
|
2201
|
+
if id_value:
|
|
2202
|
+
id_targets = set(_resolve_manifest_pairs_or_exit(ctx, config, project_root, id_value, stage))
|
|
2203
|
+
|
|
2204
|
+
local_units = _local_units_in_scope(stages, project_root, id_targets)
|
|
2205
|
+
local_keys = {(sn, uid) for sn, uid, _ in local_units}
|
|
2206
|
+
remote_only_entries = _remote_only_status_entries(state, config, stages, stage, remote_name, id_targets, local_keys)
|
|
2207
|
+
|
|
2208
|
+
for stage_name, uid, abs_path in local_units:
|
|
2209
|
+
_echo_local_unit_status(ctx, config, project_root, state, remote_name, stage_name, uid, abs_path)
|
|
2210
|
+
|
|
2211
|
+
for stage_name, uid, rname, entry in remote_only_entries:
|
|
2212
|
+
label = f"{stage_name}/{uid}"
|
|
2213
|
+
direction_label = "pushed" if entry.direction == "push" else "pulled"
|
|
2214
|
+
ts = _format_timestamp(entry.timestamp)
|
|
2215
|
+
size_str = _format_size(entry.total_bytes) if entry.total_bytes else "--"
|
|
2216
|
+
_echo_info(ctx, f"remote-only:{rname} {label} {size_str} {direction_label} {ts}")
|
|
2217
|
+
|
|
2218
|
+
|
|
2219
|
+
# ---------------------------------------------------------------------------
|
|
2220
|
+
# Diff command
|
|
2221
|
+
# ---------------------------------------------------------------------------
|
|
2222
|
+
|
|
2223
|
+
|
|
2224
|
+
def _deleted_push_targets(state: SyncState, stages: set[str], seen: set[tuple[str, str]]) -> list[tuple[str, str]]:
|
|
2225
|
+
"""Sync-state push entries whose unit no longer exists locally."""
|
|
2226
|
+
extra: list[tuple[str, str]] = []
|
|
2227
|
+
for entry in state.entries:
|
|
2228
|
+
if entry.direction != "push" or entry.stage not in stages:
|
|
2229
|
+
continue
|
|
2230
|
+
key = (entry.stage, entry.unit_id)
|
|
2231
|
+
if key not in seen:
|
|
2232
|
+
seen.add(key)
|
|
2233
|
+
extra.append(key)
|
|
2234
|
+
return extra
|
|
2235
|
+
|
|
2236
|
+
|
|
2237
|
+
def _diff_stage_targets(
|
|
2238
|
+
ctx: click.Context, config: ProjectConfig, project_root: Path, state: SyncState, stage: str
|
|
2239
|
+
) -> list[tuple[str, str]]:
|
|
2240
|
+
stage_cfg = config.stages[stage]
|
|
2241
|
+
_ensure_stage_bound(ctx, stage, stage_cfg)
|
|
2242
|
+
targets = [(stage, uid) for uid, _ in discover_units(stage_cfg, project_root)]
|
|
2243
|
+
targets.extend(_deleted_push_targets(state, {stage}, set(targets)))
|
|
2244
|
+
return targets
|
|
2245
|
+
|
|
2246
|
+
|
|
2247
|
+
def _diff_bare_targets(
|
|
2248
|
+
ctx: click.Context, config: ProjectConfig, project_root: Path, state: SyncState
|
|
2249
|
+
) -> list[tuple[str, str]]:
|
|
2250
|
+
# ADR 0016: bare diff covers bound stages and warns per unbound stage.
|
|
2251
|
+
bound = _bound_stage_subset(ctx, config)
|
|
2252
|
+
targets: list[tuple[str, str]] = []
|
|
2253
|
+
for stage_name, stage_cfg in bound.items():
|
|
2254
|
+
for uid, _ in discover_units(stage_cfg, project_root):
|
|
2255
|
+
targets.append((stage_name, uid))
|
|
2256
|
+
targets.extend(_deleted_push_targets(state, set(bound), set(targets)))
|
|
2257
|
+
return targets
|
|
2258
|
+
|
|
2259
|
+
|
|
2260
|
+
def _resolve_diff_targets(
|
|
2261
|
+
ctx: click.Context,
|
|
2262
|
+
config: ProjectConfig,
|
|
2263
|
+
project_root: Path,
|
|
2264
|
+
state: SyncState,
|
|
2265
|
+
unit_id: str | None,
|
|
2266
|
+
stage: str | None,
|
|
2267
|
+
) -> list[tuple[str, str]]:
|
|
2268
|
+
"""Resolve diff targets; deleted-but-pushed units stay visible."""
|
|
2269
|
+
if unit_id:
|
|
2270
|
+
if not stage:
|
|
2271
|
+
_echo_error("Error: --stage is required when specifying UNIT_ID.")
|
|
2272
|
+
ctx.exit(1)
|
|
2273
|
+
return []
|
|
2274
|
+
_ensure_stage_bound(ctx, stage, config.stages[stage])
|
|
2275
|
+
return [(stage, unit_id)]
|
|
2276
|
+
if stage:
|
|
2277
|
+
return _diff_stage_targets(ctx, config, project_root, state, stage)
|
|
2278
|
+
return _diff_bare_targets(ctx, config, project_root, state)
|
|
2279
|
+
|
|
2280
|
+
|
|
2281
|
+
def _last_push_entry(state: SyncState, stage_name: str, uid: str) -> SyncEntry | None:
|
|
2282
|
+
for entry in reversed(state.entries):
|
|
2283
|
+
if entry.stage == stage_name and entry.unit_id == uid and entry.direction == "push":
|
|
2284
|
+
return entry
|
|
2285
|
+
return None
|
|
2286
|
+
|
|
2287
|
+
|
|
2288
|
+
def _echo_snapshot_diff(
|
|
2289
|
+
ctx: click.Context, label: str, current_files: dict[str, int], prev_files: dict[str, int]
|
|
2290
|
+
) -> None:
|
|
2291
|
+
"""Detailed per-file diff between the local tree and the last push snapshot."""
|
|
2292
|
+
for path in sorted(set(current_files) | set(prev_files)):
|
|
2293
|
+
if path in current_files and path in prev_files:
|
|
2294
|
+
if current_files[path] != prev_files[path]:
|
|
2295
|
+
_echo_info(ctx, f"modified {label}/{path}")
|
|
2296
|
+
elif path in current_files:
|
|
2297
|
+
_echo_info(ctx, f"added {label}/{path}")
|
|
2298
|
+
else:
|
|
2299
|
+
_echo_info(ctx, f"deleted {label}/{path}")
|
|
2300
|
+
|
|
2301
|
+
|
|
2302
|
+
def _echo_unit_diff(
|
|
2303
|
+
ctx: click.Context,
|
|
2304
|
+
config: ProjectConfig,
|
|
2305
|
+
project_root: Path,
|
|
2306
|
+
state: SyncState,
|
|
2307
|
+
stage_name: str,
|
|
2308
|
+
uid: str,
|
|
2309
|
+
) -> None:
|
|
2310
|
+
"""Diff one unit against its last push entry and emit changed rows."""
|
|
2311
|
+
stage_cfg = config.stages[stage_name]
|
|
2312
|
+
stage_path = _required_stage_path(ctx, stage_name, stage_cfg)
|
|
2313
|
+
label = f"{stage_name}/{uid}"
|
|
2314
|
+
abs_path = _resolve_unit_path(stage_cfg, project_root, uid)
|
|
2315
|
+
|
|
2316
|
+
# Collect all files under the unit (schema-free); OS junk is skipped.
|
|
2317
|
+
sync_files = [abs_path] if abs_path.is_file() else list_sync_files(abs_path)
|
|
2318
|
+
checksum_base = project_root / stage_path if stage_cfg.sync_by == "file" else abs_path
|
|
2319
|
+
current_files = {str(f.relative_to(checksum_base)): f.stat().st_size for f in sync_files}
|
|
2320
|
+
|
|
2321
|
+
last_push = _last_push_entry(state, stage_name, uid)
|
|
2322
|
+
if last_push is None:
|
|
2323
|
+
# Never pushed — all files shown as added
|
|
2324
|
+
for path in sorted(current_files):
|
|
2325
|
+
_echo_info(ctx, f"added {label}/{path}")
|
|
2326
|
+
return
|
|
2327
|
+
|
|
2328
|
+
if last_push.file_snapshot is None:
|
|
2329
|
+
# Old sync entry without file_snapshot — overall comparison only
|
|
2330
|
+
if compute_checksum(checksum_base, sync_files) != last_push.checksum:
|
|
2331
|
+
_echo_info(ctx, f"modified {label} (re-push to enable detailed diff)")
|
|
2332
|
+
return
|
|
2333
|
+
|
|
2334
|
+
_echo_snapshot_diff(ctx, label, current_files, last_push.file_snapshot)
|
|
2335
|
+
|
|
2336
|
+
|
|
2337
|
+
@main.command("diff")
|
|
2338
|
+
@click.argument("unit_id", required=False, default=None)
|
|
2339
|
+
@click.option("--stage", default=None, help="Filter by stage.")
|
|
2340
|
+
@click.pass_context
|
|
2341
|
+
def diff(
|
|
2342
|
+
ctx: click.Context,
|
|
2343
|
+
unit_id: str | None,
|
|
2344
|
+
stage: str | None,
|
|
2345
|
+
) -> None:
|
|
2346
|
+
"""Show what changed locally since last push.
|
|
2347
|
+
|
|
2348
|
+
Compares the current local filesystem state against the file snapshot
|
|
2349
|
+
recorded in sync_state.json during the last push. Shows modified
|
|
2350
|
+
(size changed), added (new file), and deleted (removed file) entries.
|
|
2351
|
+
Schema include/exclude filters are applied — excluded files never
|
|
2352
|
+
appear. A never-pushed unit lists every file as added. When
|
|
2353
|
+
nothing changed, output is empty.
|
|
2354
|
+
"""
|
|
2355
|
+
config = _load_project_config(ctx)
|
|
2356
|
+
project_root = _project_root(ctx)
|
|
2357
|
+
_validate_stage_option(ctx, config, stage)
|
|
2358
|
+
|
|
2359
|
+
# Sync state feeds both target resolution and per-unit diff logic.
|
|
2360
|
+
state = load_state(_state_path(ctx))
|
|
2361
|
+
targets = _resolve_diff_targets(ctx, config, project_root, state, unit_id, stage)
|
|
2362
|
+
|
|
2363
|
+
for stage_name, uid in targets:
|
|
2364
|
+
_echo_unit_diff(ctx, config, project_root, state, stage_name, uid)
|
|
2365
|
+
|
|
2366
|
+
|
|
2367
|
+
# ---------------------------------------------------------------------------
|
|
2368
|
+
# Ls helpers
|
|
2369
|
+
# ---------------------------------------------------------------------------
|
|
2370
|
+
|
|
2371
|
+
|
|
2372
|
+
def _format_size_long(size_bytes: int) -> str:
|
|
2373
|
+
"""Format byte count as a right-aligned human-readable size for --long."""
|
|
2374
|
+
if size_bytes >= 1024 * 1024 * 1024:
|
|
2375
|
+
return f"{size_bytes / (1024**3):.1f} GB"
|
|
2376
|
+
if size_bytes >= 1024 * 1024:
|
|
2377
|
+
return f"{size_bytes / (1024**2):.1f} MB"
|
|
2378
|
+
if size_bytes >= 1024:
|
|
2379
|
+
return f"{size_bytes / 1024:.1f} KB"
|
|
2380
|
+
return f"{size_bytes} B"
|
|
2381
|
+
|
|
2382
|
+
|
|
2383
|
+
def _build_tree(entries: list[str]) -> list[str]:
|
|
2384
|
+
"""Build a tree-view of path entries using branch characters.
|
|
2385
|
+
|
|
2386
|
+
*entries* is a list of ``/``-separated relative paths. Returns lines
|
|
2387
|
+
ready for printing, using ``├──``, ``└──``, and ``│`` characters.
|
|
2388
|
+
"""
|
|
2389
|
+
# Build nested dict
|
|
2390
|
+
tree: dict[str, Any] = {}
|
|
2391
|
+
for entry in sorted(entries):
|
|
2392
|
+
parts = entry.split("/")
|
|
2393
|
+
node = tree
|
|
2394
|
+
for p in parts:
|
|
2395
|
+
node = node.setdefault(p, {})
|
|
2396
|
+
|
|
2397
|
+
lines: list[str] = []
|
|
2398
|
+
_render_tree(tree, lines, prefix="")
|
|
2399
|
+
return lines
|
|
2400
|
+
|
|
2401
|
+
|
|
2402
|
+
def _render_tree(
|
|
2403
|
+
node: dict[str, Any],
|
|
2404
|
+
lines: list[str],
|
|
2405
|
+
prefix: str,
|
|
2406
|
+
) -> None:
|
|
2407
|
+
"""Recursively render tree dict into output lines."""
|
|
2408
|
+
items = list(node.items())
|
|
2409
|
+
for i, (name, children) in enumerate(items):
|
|
2410
|
+
is_last = i == len(items) - 1
|
|
2411
|
+
connector = "└── " if is_last else "├── "
|
|
2412
|
+
lines.append(f"{prefix}{connector}{name}")
|
|
2413
|
+
extension = " " if is_last else "│ "
|
|
2414
|
+
_render_tree(children, lines, prefix + extension)
|
|
2415
|
+
|
|
2416
|
+
|
|
2417
|
+
def _resolve_ls_targets(
|
|
2418
|
+
ctx: click.Context,
|
|
2419
|
+
config: ProjectConfig,
|
|
2420
|
+
project_root: Path,
|
|
2421
|
+
unit_id: str | None,
|
|
2422
|
+
stage: str | None,
|
|
2423
|
+
id_value: str | None,
|
|
2424
|
+
) -> list[tuple[str, str | None]]:
|
|
2425
|
+
"""Resolve ls targets to (stage_name, unit_id_or_None) pairs.
|
|
2426
|
+
|
|
2427
|
+
When *unit_id* is given, returns a single target with that unit.
|
|
2428
|
+
When *id_value* is given, resolves via manifest.
|
|
2429
|
+
Otherwise returns all stages with unit_id=None.
|
|
2430
|
+
"""
|
|
2431
|
+
targets: list[tuple[str, str | None]] = []
|
|
2432
|
+
|
|
2433
|
+
if unit_id:
|
|
2434
|
+
if not stage:
|
|
2435
|
+
_echo_error("Error: --stage is required when specifying a UNIT_ID.")
|
|
2436
|
+
ctx.exit(1)
|
|
2437
|
+
return []
|
|
2438
|
+
if stage not in config.stages:
|
|
2439
|
+
_echo_error(f"Error: stage '{stage}' not found in config.")
|
|
2440
|
+
ctx.exit(1)
|
|
2441
|
+
return []
|
|
2442
|
+
targets.append((stage, unit_id))
|
|
2443
|
+
return targets
|
|
2444
|
+
|
|
2445
|
+
if id_value:
|
|
2446
|
+
targets.extend(_resolve_manifest_pairs_or_exit(ctx, config, project_root, id_value, stage))
|
|
2447
|
+
return targets
|
|
2448
|
+
|
|
2449
|
+
# Default: all stages
|
|
2450
|
+
stages = {stage: config.stages[stage]} if stage else config.stages
|
|
2451
|
+
for stage_name in stages:
|
|
2452
|
+
targets.append((stage_name, None))
|
|
2453
|
+
return targets
|
|
2454
|
+
|
|
2455
|
+
|
|
2456
|
+
def _ls_remote_files_or_exit(
|
|
2457
|
+
ctx: click.Context,
|
|
2458
|
+
transport: RcloneTransport,
|
|
2459
|
+
remote_cfg: RemoteConfig,
|
|
2460
|
+
remote_name: str,
|
|
2461
|
+
prefix: str,
|
|
2462
|
+
) -> list[RemoteFile]:
|
|
2463
|
+
"""List remote files under *prefix*, exiting with an error on failure."""
|
|
2464
|
+
try:
|
|
2465
|
+
return _listed_remote_files(transport, remote_cfg.url, prefix=prefix)
|
|
2466
|
+
except RcloneError as exc:
|
|
2467
|
+
_echo_error(f"Error: failed to list remote '{remote_name}': {_transport_error_detail(exc)}")
|
|
2468
|
+
ctx.exit(1)
|
|
2469
|
+
raise AssertionError("unreachable") from exc
|
|
2470
|
+
|
|
2471
|
+
|
|
2472
|
+
def _ls_unit_prefix(stage_cfg: StageConfig, stage_name: str, target_uid: str) -> str | None:
|
|
2473
|
+
"""Remote prefix for one unit, or None when the unit cannot exist."""
|
|
2474
|
+
remote_base = _effective_remote_base(stage_cfg)
|
|
2475
|
+
if stage_cfg.sync_by == "directory":
|
|
2476
|
+
expected_uid = stage_cfg.path.name if stage_cfg.path is not None else stage_name
|
|
2477
|
+
return remote_base.as_posix() if target_uid == expected_uid else None
|
|
2478
|
+
return (remote_base / target_uid).as_posix()
|
|
2479
|
+
|
|
2480
|
+
|
|
2481
|
+
def _echo_unit_files(
|
|
2482
|
+
ctx: click.Context,
|
|
2483
|
+
label: str,
|
|
2484
|
+
remote_files: list[RemoteFile],
|
|
2485
|
+
*,
|
|
2486
|
+
tree_flag: bool,
|
|
2487
|
+
long_flag: bool,
|
|
2488
|
+
) -> None:
|
|
2489
|
+
"""Print the files of one unit in plain, --tree, or --long form."""
|
|
2490
|
+
_echo_info(ctx, f"{label}/")
|
|
2491
|
+
if tree_flag:
|
|
2492
|
+
for line in _build_tree([rf.key for rf in remote_files]):
|
|
2493
|
+
_echo_info(ctx, line)
|
|
2494
|
+
elif long_flag:
|
|
2495
|
+
for rf in remote_files:
|
|
2496
|
+
size_str = _format_size_long(rf.size)
|
|
2497
|
+
ts = rf.last_modified or ""
|
|
2498
|
+
_echo_info(ctx, f" {size_str:>10} {ts:>16} {rf.key}")
|
|
2499
|
+
else:
|
|
2500
|
+
for rf in remote_files:
|
|
2501
|
+
_echo_info(ctx, f" {rf.key}")
|
|
2502
|
+
_echo_verbose(ctx, f" size: {_format_size_long(rf.size)}")
|
|
2503
|
+
|
|
2504
|
+
|
|
2505
|
+
def _echo_directory_stage(
|
|
2506
|
+
ctx: click.Context,
|
|
2507
|
+
stage_name: str,
|
|
2508
|
+
remote_files: list[RemoteFile],
|
|
2509
|
+
*,
|
|
2510
|
+
tree_flag: bool,
|
|
2511
|
+
long_flag: bool,
|
|
2512
|
+
) -> None:
|
|
2513
|
+
"""Print a sync_by=directory stage: the whole stage is one unit."""
|
|
2514
|
+
if tree_flag:
|
|
2515
|
+
_echo_info(ctx, f"{stage_name}/")
|
|
2516
|
+
for line in _build_tree([rf.key for rf in remote_files]):
|
|
2517
|
+
_echo_info(ctx, line)
|
|
2518
|
+
elif long_flag:
|
|
2519
|
+
size_str = _format_size_long(sum(rf.size for rf in remote_files))
|
|
2520
|
+
_echo_info(ctx, f" {stage_name}/ {len(remote_files)} files {size_str}")
|
|
2521
|
+
else:
|
|
2522
|
+
_echo_info(ctx, f" {stage_name}/ ({len(remote_files)} files)")
|
|
2523
|
+
for rf in remote_files:
|
|
2524
|
+
_echo_verbose(ctx, f" {rf.key} {_format_size_long(rf.size)}")
|
|
2525
|
+
|
|
2526
|
+
|
|
2527
|
+
def _echo_subdirectory_tree(ctx: click.Context, stage_name: str, units: dict[str, list[RemoteFile]]) -> None:
|
|
2528
|
+
"""Print units of a sync_by=subdirectory stage as a tree."""
|
|
2529
|
+
_echo_info(ctx, f"{stage_name}/")
|
|
2530
|
+
unit_names = sorted(units)
|
|
2531
|
+
for i, uid in enumerate(unit_names):
|
|
2532
|
+
is_last = i == len(unit_names) - 1
|
|
2533
|
+
_echo_info(ctx, f"{'└── ' if is_last else '├── '}{uid}/")
|
|
2534
|
+
# Rebase keys to be relative to the unit
|
|
2535
|
+
rel_paths = [rf.key.split("/", 1)[1] for rf in units[uid] if "/" in rf.key]
|
|
2536
|
+
extension = " " if is_last else "│ "
|
|
2537
|
+
for line in _build_tree(rel_paths):
|
|
2538
|
+
_echo_info(ctx, f"{extension}{line}")
|
|
2539
|
+
|
|
2540
|
+
|
|
2541
|
+
def _echo_subdirectory_stage(
|
|
2542
|
+
ctx: click.Context,
|
|
2543
|
+
stage_name: str,
|
|
2544
|
+
remote_files: list[RemoteFile],
|
|
2545
|
+
*,
|
|
2546
|
+
tree_flag: bool,
|
|
2547
|
+
long_flag: bool,
|
|
2548
|
+
) -> None:
|
|
2549
|
+
"""Print a sync_by=subdirectory stage grouped into units by first path part."""
|
|
2550
|
+
units: dict[str, list[RemoteFile]] = {}
|
|
2551
|
+
for rf in remote_files:
|
|
2552
|
+
units.setdefault(rf.key.split("/")[0], []).append(rf)
|
|
2553
|
+
|
|
2554
|
+
if tree_flag:
|
|
2555
|
+
_echo_subdirectory_tree(ctx, stage_name, units)
|
|
2556
|
+
elif long_flag:
|
|
2557
|
+
for uid in sorted(units):
|
|
2558
|
+
size_str = _format_size_long(sum(rf.size for rf in units[uid]))
|
|
2559
|
+
_echo_info(ctx, f" {stage_name}/{uid} {len(units[uid])} files {size_str}")
|
|
2560
|
+
else:
|
|
2561
|
+
for uid in sorted(units):
|
|
2562
|
+
_echo_info(ctx, f" {stage_name}/{uid} ({len(units[uid])} files)")
|
|
2563
|
+
for rf in units[uid]:
|
|
2564
|
+
_echo_verbose(ctx, f" {rf.key} {_format_size_long(rf.size)}")
|
|
2565
|
+
|
|
2566
|
+
|
|
2567
|
+
def _echo_file_stage(
|
|
2568
|
+
ctx: click.Context,
|
|
2569
|
+
stage_name: str,
|
|
2570
|
+
remote_files: list[RemoteFile],
|
|
2571
|
+
*,
|
|
2572
|
+
tree_flag: bool,
|
|
2573
|
+
long_flag: bool,
|
|
2574
|
+
) -> None:
|
|
2575
|
+
"""Print a sync_by=file stage: each top-level file is one unit."""
|
|
2576
|
+
file_units = [rf for rf in remote_files if len([part for part in rf.key.split("/") if part]) == 1]
|
|
2577
|
+
if tree_flag:
|
|
2578
|
+
_echo_info(ctx, f"{stage_name}/")
|
|
2579
|
+
for line in _build_tree([rf.key for rf in file_units]):
|
|
2580
|
+
_echo_info(ctx, line)
|
|
2581
|
+
elif long_flag:
|
|
2582
|
+
for rf in file_units:
|
|
2583
|
+
_echo_info(ctx, f" {stage_name}/{rf.key} {_format_size_long(rf.size)} {rf.last_modified or ''}")
|
|
2584
|
+
else:
|
|
2585
|
+
for rf in file_units:
|
|
2586
|
+
_echo_info(ctx, f" {stage_name}/{rf.key}")
|
|
2587
|
+
_echo_verbose(ctx, f" size: {_format_size_long(rf.size)}")
|
|
2588
|
+
|
|
2589
|
+
|
|
2590
|
+
def _ls_transport_or_exit(
|
|
2591
|
+
ctx: click.Context, config: ProjectConfig, project_root: Path
|
|
2592
|
+
) -> tuple[str, RemoteConfig, RcloneTransport] | None:
|
|
2593
|
+
"""Resolve the active remote and its transport for ls, exiting on hard errors."""
|
|
2594
|
+
remote_name = _resolve_remote(ctx, config, project_root, None)
|
|
2595
|
+
if remote_name is None:
|
|
2596
|
+
return None
|
|
2597
|
+
remote_cfg = config.remotes[remote_name]
|
|
2598
|
+
|
|
2599
|
+
# rclone is required for every transport operation (G3 baseline gating).
|
|
2600
|
+
if not is_available():
|
|
2601
|
+
_echo_error(f"Error: {RCLONE_INSTALL_MSG}")
|
|
2602
|
+
ctx.exit(1)
|
|
2603
|
+
return None
|
|
2604
|
+
|
|
2605
|
+
try:
|
|
2606
|
+
transport = get_transport(remote_cfg)
|
|
2607
|
+
except ValueError as exc:
|
|
2608
|
+
_echo_error(f"Error: {exc}")
|
|
2609
|
+
ctx.exit(1)
|
|
2610
|
+
return None
|
|
2611
|
+
if not _preflight_remote_config(ctx, remote_cfg):
|
|
2612
|
+
return None
|
|
2613
|
+
|
|
2614
|
+
_echo_verbose(ctx, f"Using remote '{remote_name}' [{remote_cfg.remote_type}] at {remote_cfg.url}")
|
|
2615
|
+
if remote_cfg.remote_type == "s3":
|
|
2616
|
+
_echo_verbose(ctx, f" backend={transport.backend_name}")
|
|
2617
|
+
return remote_name, remote_cfg, transport
|
|
2618
|
+
|
|
2619
|
+
|
|
2620
|
+
# ---------------------------------------------------------------------------
|
|
2621
|
+
# Ls command
|
|
2622
|
+
# ---------------------------------------------------------------------------
|
|
2623
|
+
|
|
2624
|
+
|
|
2625
|
+
@main.command("ls")
|
|
2626
|
+
@click.argument("unit_id", required=False, default=None)
|
|
2627
|
+
@click.option("--stage", default=None, help="Filter by stage.")
|
|
2628
|
+
@click.option("--id", "id_value", default=None, help="Resolve units via manifest ID.")
|
|
2629
|
+
@click.option("--tree", "tree_flag", is_flag=True, help="Show directory tree view.")
|
|
2630
|
+
@click.option("--long", "long_flag", is_flag=True, help="Show sizes and timestamps.")
|
|
2631
|
+
@click.pass_context
|
|
2632
|
+
def ls(
|
|
2633
|
+
ctx: click.Context,
|
|
2634
|
+
unit_id: str | None,
|
|
2635
|
+
stage: str | None,
|
|
2636
|
+
id_value: str | None,
|
|
2637
|
+
tree_flag: bool,
|
|
2638
|
+
long_flag: bool,
|
|
2639
|
+
) -> None:
|
|
2640
|
+
"""List data units or files on a remote.
|
|
2641
|
+
|
|
2642
|
+
Queries the active remote via the transport backend. When UNIT_ID is
|
|
2643
|
+
given with --stage, lists files within that specific unit. Otherwise
|
|
2644
|
+
lists data units per stage.
|
|
2645
|
+
"""
|
|
2646
|
+
config = _load_project_config(ctx)
|
|
2647
|
+
if config is None:
|
|
2648
|
+
return
|
|
2649
|
+
project_root = _project_root(ctx)
|
|
2650
|
+
_validate_stage_option(ctx, config, stage)
|
|
2651
|
+
|
|
2652
|
+
resolved = _ls_transport_or_exit(ctx, config, project_root)
|
|
2653
|
+
if resolved is None:
|
|
2654
|
+
return
|
|
2655
|
+
remote_name, remote_cfg, transport = resolved
|
|
2656
|
+
|
|
2657
|
+
targets = _resolve_ls_targets(ctx, config, project_root, unit_id, stage, id_value)
|
|
2658
|
+
for stage_name, target_uid in targets:
|
|
2659
|
+
stage_cfg = config.stages[stage_name]
|
|
2660
|
+
|
|
2661
|
+
if target_uid is not None:
|
|
2662
|
+
# List files within a specific unit
|
|
2663
|
+
prefix = _ls_unit_prefix(stage_cfg, stage_name, target_uid)
|
|
2664
|
+
remote_files = (
|
|
2665
|
+
[] if prefix is None else _ls_remote_files_or_exit(ctx, transport, remote_cfg, remote_name, prefix)
|
|
2666
|
+
)
|
|
2667
|
+
if not remote_files:
|
|
2668
|
+
_echo_info(ctx, f"{stage_name}/{target_uid}: no files on remote")
|
|
2669
|
+
continue
|
|
2670
|
+
_echo_unit_files(ctx, f"{stage_name}/{target_uid}", remote_files, tree_flag=tree_flag, long_flag=long_flag)
|
|
2671
|
+
continue
|
|
2672
|
+
|
|
2673
|
+
# List units within a stage (discover from remote)
|
|
2674
|
+
prefix = _effective_remote_base(stage_cfg).as_posix()
|
|
2675
|
+
remote_files = _ls_remote_files_or_exit(ctx, transport, remote_cfg, remote_name, prefix)
|
|
2676
|
+
if remote_files:
|
|
2677
|
+
echo_stage = {
|
|
2678
|
+
"directory": _echo_directory_stage,
|
|
2679
|
+
"subdirectory": _echo_subdirectory_stage,
|
|
2680
|
+
"file": _echo_file_stage,
|
|
2681
|
+
}[stage_cfg.sync_by]
|
|
2682
|
+
echo_stage(ctx, stage_name, remote_files, tree_flag=tree_flag, long_flag=long_flag)
|
|
2683
|
+
|
|
2684
|
+
|
|
2685
|
+
@main.command()
|
|
2686
|
+
@click.option("--id", "id_value", default=None, help="Expand a manifest ID to its units + resolved paths.")
|
|
2687
|
+
@click.option("--stage", "stage_name", default=None, help="Expand a single stage to its discovered units.")
|
|
2688
|
+
@click.option(
|
|
2689
|
+
"--direction",
|
|
2690
|
+
type=click.Choice(["LR", "TD"]),
|
|
2691
|
+
default="LR",
|
|
2692
|
+
help="Mermaid flow direction (default: LR).",
|
|
2693
|
+
)
|
|
2694
|
+
@click.option(
|
|
2695
|
+
"--output", "output_path", default=None, type=click.Path(), help="Write diagram to PATH instead of stdout."
|
|
2696
|
+
)
|
|
2697
|
+
@click.pass_context
|
|
2698
|
+
def flow(
|
|
2699
|
+
ctx: click.Context,
|
|
2700
|
+
id_value: str | None,
|
|
2701
|
+
stage_name: str | None,
|
|
2702
|
+
direction: str,
|
|
2703
|
+
output_path: str | None,
|
|
2704
|
+
) -> None:
|
|
2705
|
+
"""Emit a Mermaid data-flow diagram derived from the live project config.
|
|
2706
|
+
|
|
2707
|
+
Dry-run only: reads config/manifest/sync-state existence and never
|
|
2708
|
+
touches rclone or mutates state. Default view is stage-level; pass --id or
|
|
2709
|
+
--stage to expand a slice to its units and resolved paths.
|
|
2710
|
+
"""
|
|
2711
|
+
config = _load_project_config(ctx)
|
|
2712
|
+
project_root = _project_root(ctx)
|
|
2713
|
+
|
|
2714
|
+
local_config = _load_active_local_config(ctx)
|
|
2715
|
+
active = local_config.active_remote if local_config.active_remote in config.remotes else None
|
|
2716
|
+
has_state = _state_path(ctx).exists()
|
|
2717
|
+
|
|
2718
|
+
expanded_units: dict[str, list[tuple[str, str | None, str | None]]] = {}
|
|
2719
|
+
|
|
2720
|
+
if id_value is not None:
|
|
2721
|
+
if config.manifest is None:
|
|
2722
|
+
_echo_error("Error: --id requires a manifest section in forest.yaml.")
|
|
2723
|
+
ctx.exit(1)
|
|
2724
|
+
manifest = load_manifest(project_root / config.manifest.file)
|
|
2725
|
+
for stage, uid, local_path, remote_path in resolve_paths(config, manifest, id_value, stage_name):
|
|
2726
|
+
expanded_units.setdefault(stage, []).append((uid, str(local_path), remote_path))
|
|
2727
|
+
elif stage_name is not None:
|
|
2728
|
+
if stage_name not in config.stages:
|
|
2729
|
+
_echo_error(f"Error: unknown stage '{stage_name}'.")
|
|
2730
|
+
ctx.exit(1)
|
|
2731
|
+
for uid, abs_path in discover_units(config.stages[stage_name], project_root):
|
|
2732
|
+
expanded_units.setdefault(stage_name, []).append((uid, str(abs_path), None))
|
|
2733
|
+
|
|
2734
|
+
nodes, edges = build_graph(
|
|
2735
|
+
config,
|
|
2736
|
+
active_remote=active,
|
|
2737
|
+
has_state=has_state,
|
|
2738
|
+
expanded_units=expanded_units or None,
|
|
2739
|
+
key_outputs=None,
|
|
2740
|
+
)
|
|
2741
|
+
text = render_mermaid(nodes, edges, direction=direction)
|
|
2742
|
+
|
|
2743
|
+
if output_path:
|
|
2744
|
+
Path(output_path).write_text(text + "\n")
|
|
2745
|
+
_echo_info(ctx, f"Wrote {output_path}")
|
|
2746
|
+
else:
|
|
2747
|
+
click.echo(text)
|