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/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)