forest-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forest/__init__.py +16 -0
- forest/analytics.py +29 -0
- forest/checkout.py +992 -0
- forest/cli.py +2747 -0
- forest/config.py +286 -0
- forest/flags.py +20 -0
- forest/flow.py +157 -0
- forest/fsutil.py +36 -0
- forest/logger.py +197 -0
- forest/manifest.py +112 -0
- forest/metrics.py +67 -0
- forest/monitoring.py +138 -0
- forest/paths.py +192 -0
- forest/rclone.py +561 -0
- forest/resilience.py +122 -0
- forest/sync_state.py +182 -0
- forest_cli-0.1.0.dist-info/METADATA +233 -0
- forest_cli-0.1.0.dist-info/RECORD +22 -0
- forest_cli-0.1.0.dist-info/WHEEL +5 -0
- forest_cli-0.1.0.dist-info/entry_points.txt +2 -0
- forest_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- forest_cli-0.1.0.dist-info/top_level.txt +1 -0
forest/sync_state.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Sync state models and persistence for tracking push/pull operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
from forest.fsutil import atomic_write
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SyncEntry(BaseModel):
|
|
17
|
+
"""A single sync operation record for a data unit."""
|
|
18
|
+
|
|
19
|
+
remote: str
|
|
20
|
+
stage: str
|
|
21
|
+
unit_id: str
|
|
22
|
+
schema_: str | None = Field(default=None, alias="schema")
|
|
23
|
+
local_path: str | None = None
|
|
24
|
+
remote_path: str | None = None
|
|
25
|
+
direction: Literal["push", "pull"]
|
|
26
|
+
timestamp: str
|
|
27
|
+
file_count: int
|
|
28
|
+
total_bytes: int
|
|
29
|
+
checksum: str
|
|
30
|
+
schema_version: str | None = None
|
|
31
|
+
forest_version: str | None = None
|
|
32
|
+
file_snapshot: dict[str, int] | None = None
|
|
33
|
+
|
|
34
|
+
model_config = {"populate_by_name": True}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SyncState(BaseModel):
|
|
38
|
+
"""Top-level sync state tracking all push/pull operations."""
|
|
39
|
+
|
|
40
|
+
version: int = 1
|
|
41
|
+
project: str = ""
|
|
42
|
+
entries: list[SyncEntry] = Field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
model_config = {"populate_by_name": True}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_state(path: str | Path) -> SyncState:
|
|
48
|
+
"""Load sync state from a JSON file.
|
|
49
|
+
|
|
50
|
+
Returns an empty SyncState if the file does not exist.
|
|
51
|
+
"""
|
|
52
|
+
path = Path(path)
|
|
53
|
+
if not path.exists():
|
|
54
|
+
return SyncState()
|
|
55
|
+
|
|
56
|
+
data = json.loads(path.read_text())
|
|
57
|
+
if data == []:
|
|
58
|
+
return SyncState()
|
|
59
|
+
return SyncState.model_validate(data)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def save_state(state: SyncState, path: str | Path) -> None:
|
|
63
|
+
"""Write sync state to a JSON file with pretty-printed formatting.
|
|
64
|
+
|
|
65
|
+
Creates parent directories if they do not exist.
|
|
66
|
+
"""
|
|
67
|
+
path = Path(path)
|
|
68
|
+
atomic_write(path, json.dumps(state.model_dump(by_alias=True), indent=2) + "\n")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def record_push(state: SyncState, entry_data: dict[str, Any]) -> SyncState:
|
|
72
|
+
"""Record a push operation, returning a new SyncState.
|
|
73
|
+
|
|
74
|
+
If an entry with the same (remote, stage, unit_id) already exists,
|
|
75
|
+
it is replaced. The original state is not mutated.
|
|
76
|
+
"""
|
|
77
|
+
return _record(state, entry_data, direction="push")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def record_pull(state: SyncState, entry_data: dict[str, Any]) -> SyncState:
|
|
81
|
+
"""Record a pull operation, returning a new SyncState.
|
|
82
|
+
|
|
83
|
+
If an entry with the same (remote, stage, unit_id) already exists,
|
|
84
|
+
it is replaced. The original state is not mutated.
|
|
85
|
+
"""
|
|
86
|
+
return _record(state, entry_data, direction="pull")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _record(state: SyncState, entry_data: dict[str, Any], *, direction: str) -> SyncState:
|
|
90
|
+
"""Internal helper to record a push or pull entry."""
|
|
91
|
+
remote = entry_data["remote"]
|
|
92
|
+
stage = entry_data["stage"]
|
|
93
|
+
unit_id = entry_data["unit_id"]
|
|
94
|
+
|
|
95
|
+
entry_data = {
|
|
96
|
+
**entry_data,
|
|
97
|
+
"direction": direction,
|
|
98
|
+
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
|
|
99
|
+
}
|
|
100
|
+
new_entry = SyncEntry(**entry_data)
|
|
101
|
+
|
|
102
|
+
kept = [
|
|
103
|
+
e
|
|
104
|
+
for e in state.entries
|
|
105
|
+
if not (e.remote == remote and e.stage == stage and e.unit_id == unit_id and e.direction == direction)
|
|
106
|
+
]
|
|
107
|
+
kept.append(new_entry)
|
|
108
|
+
|
|
109
|
+
return SyncState(version=state.version, project=state.project, entries=kept)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_status(
|
|
113
|
+
state: SyncState,
|
|
114
|
+
stage: str,
|
|
115
|
+
unit_id: str,
|
|
116
|
+
*,
|
|
117
|
+
unit_path: str | Path | None = None,
|
|
118
|
+
remote: str | None = None,
|
|
119
|
+
) -> str:
|
|
120
|
+
"""Determine the sync status of a data unit.
|
|
121
|
+
|
|
122
|
+
When *unit_path* is not provided, it is derived from the matching
|
|
123
|
+
``SyncEntry.local_path`` (if one exists).
|
|
124
|
+
|
|
125
|
+
Returns one of:
|
|
126
|
+
- ``"synced"`` — local checksum matches stored entry
|
|
127
|
+
- ``"stale"`` — local exists and entry exists but checksums differ
|
|
128
|
+
- ``"local-only"`` — local dir exists but no sync entry
|
|
129
|
+
- ``"remote-only"`` — sync entry exists but no local dir
|
|
130
|
+
"""
|
|
131
|
+
# Find matching entry
|
|
132
|
+
entry = _find_entry(state, stage, unit_id, remote=remote)
|
|
133
|
+
|
|
134
|
+
# Derive unit_path from entry if not explicitly provided
|
|
135
|
+
if unit_path is None and entry is not None and entry.local_path is not None:
|
|
136
|
+
unit_path = Path(entry.local_path)
|
|
137
|
+
elif unit_path is not None:
|
|
138
|
+
unit_path = Path(unit_path)
|
|
139
|
+
|
|
140
|
+
if entry is None:
|
|
141
|
+
return "local-only"
|
|
142
|
+
|
|
143
|
+
if unit_path is None or not unit_path.is_dir():
|
|
144
|
+
return "remote-only"
|
|
145
|
+
|
|
146
|
+
# Both exist — compare checksums
|
|
147
|
+
local_files = sorted(f for f in unit_path.rglob("*") if f.is_file())
|
|
148
|
+
current_checksum = compute_checksum(unit_path, local_files)
|
|
149
|
+
|
|
150
|
+
if current_checksum == entry.checksum:
|
|
151
|
+
return "synced"
|
|
152
|
+
|
|
153
|
+
return "stale"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _find_entry(
|
|
157
|
+
state: SyncState,
|
|
158
|
+
stage: str,
|
|
159
|
+
unit_id: str,
|
|
160
|
+
*,
|
|
161
|
+
remote: str | None = None,
|
|
162
|
+
) -> SyncEntry | None:
|
|
163
|
+
"""Find the most recent matching sync entry."""
|
|
164
|
+
for entry in reversed(state.entries):
|
|
165
|
+
if entry.stage == stage and entry.unit_id == unit_id and (remote is None or entry.remote == remote):
|
|
166
|
+
return entry
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def compute_checksum(unit_path: str | Path, file_list: list[Path]) -> str:
|
|
171
|
+
"""Compute a deterministic SHA-256 checksum from (relative_path, file_size) tuples.
|
|
172
|
+
|
|
173
|
+
The checksum is independent of file modification times and content —
|
|
174
|
+
only the relative path and file size in bytes are used. The list is
|
|
175
|
+
sorted before hashing to ensure determinism regardless of input order.
|
|
176
|
+
"""
|
|
177
|
+
unit_path = Path(unit_path)
|
|
178
|
+
tuples = sorted((str(f.relative_to(unit_path)), f.stat().st_size) for f in file_list)
|
|
179
|
+
h = hashlib.sha256()
|
|
180
|
+
for rel_path, size in tuples:
|
|
181
|
+
h.update(f"{rel_path}:{size}\n".encode())
|
|
182
|
+
return h.hexdigest()
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: forest-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: git-like data management for arbitrary data trees: workspaces, checkouts, remotes, and sync
|
|
5
|
+
Author-email: Troy Sincomb <troysincomb@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: data-management,sync,cli
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: click>=8.0
|
|
15
|
+
Requires-Dist: polars>=1.0
|
|
16
|
+
Requires-Dist: pydantic>=2.0
|
|
17
|
+
Requires-Dist: pyyaml>=6.0
|
|
18
|
+
Provides-Extra: observability
|
|
19
|
+
Requires-Dist: sentry-sdk>=2.0; extra == "observability"
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: deptry>=0.23; extra == "dev"
|
|
22
|
+
Requires-Dist: mypy>=1.14; extra == "dev"
|
|
23
|
+
Requires-Dist: pre-commit>=4.0; extra == "dev"
|
|
24
|
+
Requires-Dist: pylint>=3.3; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-cov>=6.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-randomly>=3.15; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-rerunfailures>=15.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-xdist>=3.6; extra == "dev"
|
|
30
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
31
|
+
Requires-Dist: types-PyYAML; extra == "dev"
|
|
32
|
+
Requires-Dist: vulture>=2.14; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# 🌲 forest <a href="#-dogfood-this-repo-runs-forest"><img align="right" width="42%" src="docs/assets/hero.svg" alt="Animated isometric forest: data trees on a workspace platform, packets syncing up a git branch to a remote cloud"></a>
|
|
36
|
+
|
|
37
|
+
**git-like data management for arbitrary data trees.**
|
|
38
|
+
|
|
39
|
+
[](https://github.com/tmsincomb/forest/actions/workflows/ci.yml)
|
|
40
|
+
[](https://github.com/tmsincomb/forest/actions/workflows/docs.yml)
|
|
41
|
+
[](https://github.com/tmsincomb/forest/actions/workflows/release.yml)
|
|
42
|
+
[](https://www.python.org/downloads/)
|
|
43
|
+
[](LICENSE)
|
|
44
|
+
[](https://github.com/astral-sh/ruff)
|
|
45
|
+
[](https://mypy-lang.org/)
|
|
46
|
+
|
|
47
|
+
Forest is the data-side parallel to git's version control. Git tracks code in
|
|
48
|
+
`.git/`; forest tracks large data — local layout plus remote sync — in
|
|
49
|
+
`.forest/`. It borrows git's mental model and verbs (`checkout`, `status`,
|
|
50
|
+
`push`, `pull`, `remote`, a HEAD-style pointer) so your git intuition carries
|
|
51
|
+
over, but the two domains never overlap and neither requires the other.
|
|
52
|
+
|
|
53
|
+
Forest is **domain-agnostic and self-contained**: it manages *any* data trees,
|
|
54
|
+
knows nothing about what the data means, and depends on no other project. It
|
|
55
|
+
moves files and tracks their sync state; it does not validate or interpret their
|
|
56
|
+
contents.
|
|
57
|
+
|
|
58
|
+
## Model
|
|
59
|
+
|
|
60
|
+
Fixed-depth, no arbitrary nesting:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
workspace → checkout → stage → unit → files
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- **Workspace** — a per-repo `.forest/` control area (registry + active pointer).
|
|
67
|
+
- **Checkout** — a named data view/focus registered in the workspace (like a git
|
|
68
|
+
branch you stay rooted in). Switching is an O(1) pointer rewrite; data never
|
|
69
|
+
moves.
|
|
70
|
+
- **Stage** — a named data category inside a checkout, with a remote layout.
|
|
71
|
+
- **Unit** — one addressable item within a stage (a subdirectory, a directory,
|
|
72
|
+
or a file, per the stage's `sync_by`).
|
|
73
|
+
|
|
74
|
+
## Install
|
|
75
|
+
|
|
76
|
+
Requires [`rclone`](https://rclone.org/) on `$PATH` for transfers:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install -e ./forest
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Quick start
|
|
83
|
+
|
|
84
|
+
No config files are hand-written. Onboarding is a few commands:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
forest init # create the nameless .forest/ workspace container
|
|
88
|
+
forest checkout demo # create if absent, register + activate 'demo'
|
|
89
|
+
forest remote add origin s3://my-bucket/prefix # allowed before any local data exists
|
|
90
|
+
forest add raw ./data/raw # register stage 'raw' and bind it to a local path
|
|
91
|
+
forest push # sync every bound stage to the active remote
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
- `forest init` creates **only** `.forest/config.yaml` (`version: 1`,
|
|
95
|
+
`checkouts: {}`) and a managed `.gitignore`. No root config, no checkout, no
|
|
96
|
+
active pointer.
|
|
97
|
+
- `forest checkout <name>` switches to the checkout, creating, registering, and
|
|
98
|
+
activating it first (with `.forest/checkouts/<name>/forest.yaml`) when the
|
|
99
|
+
name is not registered. `forest checkout create <name>` is the explicit form.
|
|
100
|
+
- Remotes can be added before any local binding — useful when your data is
|
|
101
|
+
remote-only at first.
|
|
102
|
+
- `forest add STAGE PATH` registers a new stage and binds it to a local path in
|
|
103
|
+
one step (use `forest bind` to rebind an existing stage).
|
|
104
|
+
|
|
105
|
+
## Metadata layout
|
|
106
|
+
|
|
107
|
+
Everything forest owns lives under `.forest/`; your data does not.
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
.forest/
|
|
111
|
+
config.yaml # workspace registry: version, checkouts{}
|
|
112
|
+
HEAD # active checkout name (gitignored)
|
|
113
|
+
checkouts/
|
|
114
|
+
demo/
|
|
115
|
+
forest.yaml # shared: stages, remotes, manifest
|
|
116
|
+
local.yaml # user-local: active_remote, stage_paths (gitignored)
|
|
117
|
+
sync_state.json # user-local push/pull state (gitignored)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Shared metadata (`config.yaml`, each `forest.yaml`) is committed so a fresh
|
|
121
|
+
clone bootstraps with `bind` + `remote use` + `pull`. User-local files
|
|
122
|
+
(`HEAD`, `local.yaml`, `sync_state.json`) are gitignored.
|
|
123
|
+
|
|
124
|
+
## Commands
|
|
125
|
+
|
|
126
|
+
| Command | Purpose |
|
|
127
|
+
|---|---|
|
|
128
|
+
| `forest init` | Create the workspace container, or report setup status if it exists. |
|
|
129
|
+
| `forest checkout create/adopt/list/current/remove <name>` | Manage checkouts; bare `forest checkout <name>` switches, creating first if needed. `remove --yes` skips the prompt for scripts. |
|
|
130
|
+
| `forest add STAGE PATH [--sync-by MODE]` | Register a new stage and bind it to a local path; `--sync-by` picks unit discovery (`subdirectory`/`directory`/`file`). |
|
|
131
|
+
| `forest bind [STAGE PATH]` / `forest unbind STAGE` | Manage local stage↔path bindings. |
|
|
132
|
+
| `forest remote add/remove/list/use/show` | Manage remotes; `use` selects the active remote (optional while only one remote exists). |
|
|
133
|
+
| `forest push / pull / status / diff / ls` | Sync and inspect against the active remote. Bare `push`/`pull`/`status`/`diff` cover every bound stage (unbound stages warn and skip); `--all` requires all stages bound. |
|
|
134
|
+
| `forest flow` | Emit a Mermaid data-flow diagram of the active checkout. |
|
|
135
|
+
| `forest migrate` | Migrate a legacy `biostore` layout in place (see below). |
|
|
136
|
+
|
|
137
|
+
Run any command with `-C <path>` to operate on another repo without `cd`.
|
|
138
|
+
|
|
139
|
+
Forest syncs **all** files in a data unit, skipping OS junk (`.DS_Store`,
|
|
140
|
+
AppleDouble `._*`, `*.tmp`). It applies no content-based include/exclude rules.
|
|
141
|
+
|
|
142
|
+
## Config reference
|
|
143
|
+
|
|
144
|
+
Checkout `forest.yaml` (shared, committed):
|
|
145
|
+
|
|
146
|
+
```yaml
|
|
147
|
+
project: demo
|
|
148
|
+
remotes:
|
|
149
|
+
origin:
|
|
150
|
+
url: s3://my-bucket/prefix
|
|
151
|
+
region: us-east-2 # optional; also endpoint, profile, key_file, known_hosts
|
|
152
|
+
stages:
|
|
153
|
+
raw:
|
|
154
|
+
remote_path: demo/raw # optional; defaults to <checkout>/<stage>
|
|
155
|
+
sync_by: subdirectory # subdirectory | directory | file
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Checkout `local.yaml` (per-machine, gitignored):
|
|
159
|
+
|
|
160
|
+
```yaml
|
|
161
|
+
active_remote: origin
|
|
162
|
+
stage_paths:
|
|
163
|
+
raw: ../data/raw # relative resolves from the workspace root
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Environment variables
|
|
167
|
+
|
|
168
|
+
All optional, all off by default — forest is silent and sends nothing anywhere
|
|
169
|
+
unless configured. Copy `.env.example` for a commented template; operational
|
|
170
|
+
guides live in `docs/runbooks/`.
|
|
171
|
+
|
|
172
|
+
| Variable | Default | Effect |
|
|
173
|
+
|---|---|---|
|
|
174
|
+
| `FOREST_LOG_FILE` | unset | Append structured logs (JSON lines) to this file. |
|
|
175
|
+
| `FOREST_LOG_FORMAT` | `json` | `json` or `text`; set without `FOREST_LOG_FILE` to log to stderr. |
|
|
176
|
+
| `FOREST_LOG_LEVEL` | `INFO` | Standard logging level name. |
|
|
177
|
+
| `FOREST_METRICS_FILE` | unset | Append metric samples as JSON lines for external collectors. |
|
|
178
|
+
| `FOREST_ANALYTICS_FILE` | unset | Opt-in local usage analytics (JSON lines); nothing leaves the machine. |
|
|
179
|
+
| `FOREST_SENTRY_DSN` | unset | Sentry error tracking; needs `pip install "forest-cli[observability]"`. |
|
|
180
|
+
| `FOREST_ALERT_WEBHOOK` | unset | POST failure alerts to this HTTPS endpoint (Slack/Mattermost compatible). |
|
|
181
|
+
| `FOREST_TRANSFER_RETRIES` | `2` | Extra attempts for transient rclone failures; `0` disables. |
|
|
182
|
+
| `FOREST_RETRY_BASE_DELAY` | `0.5` | Initial retry backoff in seconds; doubles per attempt. |
|
|
183
|
+
| `FOREST_BREAKER_THRESHOLD` | `5` | Consecutive transfer failures before the circuit opens; `0` disables. |
|
|
184
|
+
| `FOREST_BREAKER_RESET_SECONDS` | `60` | Cool-down before an open circuit allows a probe operation. |
|
|
185
|
+
| `FOREST_FLAGS` | unset | Comma-separated feature flags; `raw-logs` disables log secret-scrubbing. |
|
|
186
|
+
|
|
187
|
+
## Dogfood: this repo runs forest
|
|
188
|
+
|
|
189
|
+
This repository manages its own `examples/` tree with forest — a live
|
|
190
|
+
demonstration that `.forest/` and `.git/` coexist without overlapping. It was
|
|
191
|
+
set up with exactly the quick-start commands:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
forest init
|
|
195
|
+
forest checkout demo
|
|
196
|
+
forest remote add origin s3://forest-test-542222635421-us-east-2-an --region us-east-2
|
|
197
|
+
forest add examples ./examples
|
|
198
|
+
forest push
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Inspect the result:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
git ls-files .forest # what a clone gets: config.yaml + checkouts/demo/forest.yaml
|
|
205
|
+
cat .gitignore # forest-managed: HEAD, local.yaml, sync_state.json stay local
|
|
206
|
+
forest status # sync state of the examples stage
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
A fresh clone bootstraps the local half with `forest bind examples ./examples`
|
|
210
|
+
followed by `forest pull` (the single configured remote is used automatically).
|
|
211
|
+
Pulling needs AWS credentials for the bucket; the layout is the demonstration.
|
|
212
|
+
|
|
213
|
+
## Migrating from biostore
|
|
214
|
+
|
|
215
|
+
Existing biostore repos use `.biostore/` and `biostore.yaml`. Migrate in place:
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
forest migrate
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
This renames `.biostore/` → `.forest/`, each `biostore.yaml` → `forest.yaml`,
|
|
222
|
+
rewrites the managed `.gitignore` patterns, and verifies the registry parses. It
|
|
223
|
+
refuses to run if a `.forest/` already exists.
|
|
224
|
+
|
|
225
|
+
## Notes
|
|
226
|
+
|
|
227
|
+
- **Single active machine (v1).** `HEAD`/`local.yaml`/`sync_state.json` are
|
|
228
|
+
git-invisible but may be synced by a file-syncing tool; forest assumes one
|
|
229
|
+
active machine and uses atomic writes plus a per-checkout `flock` for
|
|
230
|
+
intra-machine write races.
|
|
231
|
+
- **Real filenames.** Forest stores data under real paths, not a
|
|
232
|
+
content-addressed blob store.
|
|
233
|
+
- See `docs/adr/` for the design decisions behind the workspace/checkout model.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
forest/__init__.py,sha256=GXkEqeYHPZirXscjOYs5XrAERnyduI-aAuu_gvKbRmA,540
|
|
2
|
+
forest/analytics.py,sha256=RA1GzGAyaETLH1aVxVuILHP56EokzrhVYE8DnA_WKqM,961
|
|
3
|
+
forest/checkout.py,sha256=xwTit-EdSfZxe27LPaEVqGTBE-DJSD3xiChlWOYER-k,38657
|
|
4
|
+
forest/cli.py,sha256=ohDAiYd0OCwh9Y9GdFj1B2Q7XaxcH4EjrFX_jqtUV5c,103533
|
|
5
|
+
forest/config.py,sha256=Dy-pDOGN8F0QnJhH558zB-Xk_PnOzfY8ZyDuDOeSpS8,9271
|
|
6
|
+
forest/flags.py,sha256=4UCA8aSqQT5my9mzqltlWw2kPZ45Ch0PzUii2jw4GWg,641
|
|
7
|
+
forest/flow.py,sha256=ewb1jqw_kI4gyzBpWogSuIdsdhBfufS7N_7dPVlWWS8,5349
|
|
8
|
+
forest/fsutil.py,sha256=BQGOqx5Sa_wBNoOymIcG3bqEhfLsOF5U1mWZ_YXKOGw,1210
|
|
9
|
+
forest/logger.py,sha256=nIYMV2DWjPRYsl7DTvlb1ggQL3Y0EwegZ-d1wsmYeUo,7042
|
|
10
|
+
forest/manifest.py,sha256=hklrMvYU3WolJNTmq42YOvm_uMmhMyS0HBASstdubhg,3260
|
|
11
|
+
forest/metrics.py,sha256=dBz9LNolPi2d4xeSayJMQT7uHd2e8SgMG29rikCJY3w,2494
|
|
12
|
+
forest/monitoring.py,sha256=jaNfdIPfjOV6t3anVIHswf4zc0YLnuOpQl28lHFYcS0,4862
|
|
13
|
+
forest/paths.py,sha256=oc0aYMz9HX4kgaKnRXe2CTk0Hq9BWrrs7UOeA1sYgYE,6818
|
|
14
|
+
forest/rclone.py,sha256=cVsU0NvLTDGH5Rz9M-5dcOCuybbsDU4tXE-a86KL6nA,21359
|
|
15
|
+
forest/resilience.py,sha256=oEHiPOQjCP_voskkE8WZz5zQ0CfN2v8nmthoAYptXL4,4321
|
|
16
|
+
forest/sync_state.py,sha256=h8EmIN3uE3pQOj-20Tlhg6NFq2b5AmHOeHVNfIkvQwI,5542
|
|
17
|
+
forest_cli-0.1.0.dist-info/licenses/LICENSE,sha256=PRvBVRcqmEyg2k5TekU4LBP_BQTdVALzv5F12ofRXbE,1069
|
|
18
|
+
forest_cli-0.1.0.dist-info/METADATA,sha256=QQubDaJYFRltotTWORcGgby76UGZIPMkuQPpGKPxZio,10695
|
|
19
|
+
forest_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
20
|
+
forest_cli-0.1.0.dist-info/entry_points.txt,sha256=ogQn8m-0npXNFnEu1RW14gBIy6Hzttrhp2zcOLcttsM,43
|
|
21
|
+
forest_cli-0.1.0.dist-info/top_level.txt,sha256=Qh1kq9Xt0-pDXxBKCtInwGCWt8k7IEBrKEtwJIQjlKg,7
|
|
22
|
+
forest_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Troy Sincomb
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
forest
|