deepcell-cli 0.6.1__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.
- deepcell_cli/__init__.py +12 -0
- deepcell_cli/__main__.py +5 -0
- deepcell_cli/_findings.py +84 -0
- deepcell_cli/capabilities.py +560 -0
- deepcell_cli/capability-contract.json +15622 -0
- deepcell_cli/client.py +503 -0
- deepcell_cli/commands/__init__.py +1 -0
- deepcell_cli/commands/_batch_input.py +29 -0
- deepcell_cli/commands/_datatypes.py +56 -0
- deepcell_cli/commands/_negative_args.py +133 -0
- deepcell_cli/commands/_swapped_args.py +153 -0
- deepcell_cli/commands/_version_display.py +40 -0
- deepcell_cli/commands/_write_opts.py +139 -0
- deepcell_cli/commands/account.py +123 -0
- deepcell_cli/commands/auth.py +610 -0
- deepcell_cli/commands/changes.py +307 -0
- deepcell_cli/commands/deck.py +594 -0
- deepcell_cli/commands/defs.py +3890 -0
- deepcell_cli/commands/describe.py +902 -0
- deepcell_cli/commands/doc.py +529 -0
- deepcell_cli/commands/doctor.py +257 -0
- deepcell_cli/commands/download.py +36 -0
- deepcell_cli/commands/edit.py +384 -0
- deepcell_cli/commands/example.py +161 -0
- deepcell_cli/commands/export.py +81 -0
- deepcell_cli/commands/export_docx.py +57 -0
- deepcell_cli/commands/export_pdf.py +66 -0
- deepcell_cli/commands/export_pptx.py +45 -0
- deepcell_cli/commands/files.py +386 -0
- deepcell_cli/commands/grep.py +90 -0
- deepcell_cli/commands/guide.py +431 -0
- deepcell_cli/commands/help_cmd.py +348 -0
- deepcell_cli/commands/impact.py +382 -0
- deepcell_cli/commands/import_cmd.py +208 -0
- deepcell_cli/commands/ingest.py +110 -0
- deepcell_cli/commands/merge.py +399 -0
- deepcell_cli/commands/query.py +718 -0
- deepcell_cli/commands/reasoning.py +2981 -0
- deepcell_cli/commands/ref.py +279 -0
- deepcell_cli/commands/replace.py +326 -0
- deepcell_cli/commands/rules.py +206 -0
- deepcell_cli/commands/share.py +186 -0
- deepcell_cli/commands/sync.py +804 -0
- deepcell_cli/commands/upgrade.py +185 -0
- deepcell_cli/commands/variant.py +353 -0
- deepcell_cli/commands/version.py +445 -0
- deepcell_cli/commands/viewer.py +54 -0
- deepcell_cli/commands/workspace.py +101 -0
- deepcell_cli/config.py +352 -0
- deepcell_cli/context.py +187 -0
- deepcell_cli/errors.py +141 -0
- deepcell_cli/logging_setup.py +161 -0
- deepcell_cli/main.py +518 -0
- deepcell_cli/mcp_server.py +906 -0
- deepcell_cli/oauth_provider.py +580 -0
- deepcell_cli/output.py +503 -0
- deepcell_cli/revision.py +164 -0
- deepcell_cli/stages.py +223 -0
- deepcell_cli/surface.py +628 -0
- deepcell_cli/sync_state.py +120 -0
- deepcell_cli/upgrade_check.py +399 -0
- deepcell_cli/xml_replace.py +89 -0
- deepcell_cli-0.6.1.dist-info/METADATA +264 -0
- deepcell_cli-0.6.1.dist-info/RECORD +67 -0
- deepcell_cli-0.6.1.dist-info/WHEEL +5 -0
- deepcell_cli-0.6.1.dist-info/entry_points.txt +3 -0
- deepcell_cli-0.6.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
"""Sync commands: clone, pull, push, status, commit.
|
|
2
|
+
|
|
3
|
+
Git-like sync between a local folder and a cloud DeepCell workspace.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from deepcell_cli.context import Ctx, pass_ctx
|
|
13
|
+
from deepcell_cli.output import (
|
|
14
|
+
echo_error,
|
|
15
|
+
echo_info,
|
|
16
|
+
echo_success,
|
|
17
|
+
echo_validation_map,
|
|
18
|
+
echo_warning,
|
|
19
|
+
)
|
|
20
|
+
from deepcell_cli.sync_state import (
|
|
21
|
+
FileChecksum,
|
|
22
|
+
SyncState,
|
|
23
|
+
compute_local_hash,
|
|
24
|
+
find_sync_root,
|
|
25
|
+
load_sync_state,
|
|
26
|
+
save_sync_state,
|
|
27
|
+
scan_local_files,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# -- Helpers -------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _require_sync_root(ctx: Ctx | None = None) -> tuple[Path, SyncState]:
|
|
35
|
+
"""Locate sync root from CWD or raise, and reject a contradictory --workspace.
|
|
36
|
+
|
|
37
|
+
`--workspace` is a root option documented as overriding the active
|
|
38
|
+
workspace for the invocation. It cannot do that here: a clone directory is
|
|
39
|
+
bound to one workspace by its sync state, and honouring the flag would
|
|
40
|
+
pull one workspace's files into another's checkout, or push them there.
|
|
41
|
+
|
|
42
|
+
So the flag was ignored — silently, which is the worse of the two failures
|
|
43
|
+
it could have. A reader who passes it believes they are inspecting the
|
|
44
|
+
workspace they named while looking at the one on disk, and `status` gives
|
|
45
|
+
them a clean bill of health for a workspace it never contacted.
|
|
46
|
+
|
|
47
|
+
Naming the same workspace is not a contradiction and passes through: a
|
|
48
|
+
script that sets `DEEPCELL_WORKSPACE` globally and then cds into that
|
|
49
|
+
workspace's clone is doing nothing wrong.
|
|
50
|
+
"""
|
|
51
|
+
root = find_sync_root()
|
|
52
|
+
if root is None:
|
|
53
|
+
raise click.ClickException(
|
|
54
|
+
"Not inside a synced workspace. Run `deepcell clone <slug>` first."
|
|
55
|
+
)
|
|
56
|
+
state = load_sync_state(root)
|
|
57
|
+
requested = getattr(ctx, "workspace", None) if ctx is not None else None
|
|
58
|
+
if requested and requested != state.workspace_slug:
|
|
59
|
+
raise click.UsageError(
|
|
60
|
+
f"--workspace {requested!r} does not apply to a sync command: "
|
|
61
|
+
f"{root}/ is a clone of workspace {state.workspace_slug!r} and "
|
|
62
|
+
f"every sync command reads that binding from .deepcell-sync. "
|
|
63
|
+
f"Run this from a clone of {requested!r}, or "
|
|
64
|
+
f"`deepcell clone {requested}` to make one."
|
|
65
|
+
)
|
|
66
|
+
return root, state
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _get_head_sha(client, slug: str, variant: str | None = None) -> str:
|
|
70
|
+
"""Fetch the HEAD commit SHA for a workspace (or a variant branch)."""
|
|
71
|
+
if variant:
|
|
72
|
+
data = client.get(f"/workspaces/{slug}/variants/{variant}")
|
|
73
|
+
if isinstance(data, dict) and data.get("head_sha"):
|
|
74
|
+
return data["head_sha"]
|
|
75
|
+
return ""
|
|
76
|
+
# Try head_sha on workspace detail first (fast path)
|
|
77
|
+
ws = client.get(f"/workspaces/{slug}")
|
|
78
|
+
if isinstance(ws, dict) and ws.get("head_sha"):
|
|
79
|
+
return ws["head_sha"]
|
|
80
|
+
# Fallback: fetch latest version entry
|
|
81
|
+
versions = client.get(f"/workspaces/{slug}/versions", params={"max_count": 1})
|
|
82
|
+
if versions and isinstance(versions, list):
|
|
83
|
+
return versions[0].get("sha", "")
|
|
84
|
+
return ""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _get_remote_files(
|
|
88
|
+
client, slug: str, variant: str | None = None, *, include_uploaded: bool = False
|
|
89
|
+
) -> list[dict]:
|
|
90
|
+
"""Fetch the remote file list with git_sha per file (variant-aware).
|
|
91
|
+
|
|
92
|
+
Uploaded attachments (`file_type == "uploaded"`) live in object storage,
|
|
93
|
+
not git: they carry no git_sha and the file-read route 404s on them. Every
|
|
94
|
+
sync path here downloads what it lists, so they are filtered out by
|
|
95
|
+
default — `clone` filtered them at its own call site but `pull` and
|
|
96
|
+
`variant checkout` did not, and a single attachment uploaded in the web UI
|
|
97
|
+
then broke every later pull permanently. Pass *include_uploaded* only to
|
|
98
|
+
report on them.
|
|
99
|
+
"""
|
|
100
|
+
if variant:
|
|
101
|
+
files = client.get(f"/workspaces/{slug}/variants/{variant}/files")
|
|
102
|
+
else:
|
|
103
|
+
files = client.get(f"/workspaces/{slug}/files")
|
|
104
|
+
if include_uploaded:
|
|
105
|
+
return files
|
|
106
|
+
return [f for f in files if f.get("file_type") != "uploaded"]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _download_file(client, slug: str, filename: str, *, revision: str | None = None) -> str:
|
|
110
|
+
"""Download a single file's content from the workspace (main branch)."""
|
|
111
|
+
params = {"revision": revision} if revision else None
|
|
112
|
+
data = client.get(f"/workspaces/{slug}/files/{filename}", params=params)
|
|
113
|
+
if isinstance(data, dict) and "content" in data:
|
|
114
|
+
return data["content"]
|
|
115
|
+
return str(data)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _download_branch_file(
|
|
119
|
+
client, slug: str, filename: str, *, variant: str | None = None, revision: str | None = None
|
|
120
|
+
) -> str:
|
|
121
|
+
"""Download a single file's content from a branch (variant-aware).
|
|
122
|
+
|
|
123
|
+
When *variant* is set, hits the variant read route; otherwise the main route.
|
|
124
|
+
"""
|
|
125
|
+
if not variant:
|
|
126
|
+
return _download_file(client, slug, filename, revision=revision)
|
|
127
|
+
if revision:
|
|
128
|
+
# The variant read route declares no `revision` param, so FastAPI
|
|
129
|
+
# dropped it and answered with the branch HEAD — the caller silently
|
|
130
|
+
# got different bytes than it asked for. Commit SHAs are
|
|
131
|
+
# branch-independent, so read a pinned revision via the main route.
|
|
132
|
+
raise ValueError(
|
|
133
|
+
f"Cannot read revision {revision!r} from variant '{variant}': the "
|
|
134
|
+
f"variant file route only serves branch HEAD. Read the pinned "
|
|
135
|
+
f"revision through the main branch instead (commit SHAs are "
|
|
136
|
+
f"branch-independent)."
|
|
137
|
+
)
|
|
138
|
+
data = client.get(
|
|
139
|
+
f"/workspaces/{slug}/variants/{variant}/files/{filename}"
|
|
140
|
+
)
|
|
141
|
+
if isinstance(data, dict) and "content" in data:
|
|
142
|
+
return data["content"]
|
|
143
|
+
return str(data)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _try_merge(client, base_xml: str, ours_xml: str, theirs_xml: str, filename: str) -> dict | None:
|
|
147
|
+
"""Attempt a 3-way merge via the merge API. Returns response dict or None on failure."""
|
|
148
|
+
try:
|
|
149
|
+
return client.post("/merge/preview", json={
|
|
150
|
+
"base_xml": base_xml,
|
|
151
|
+
"ours_xml": ours_xml,
|
|
152
|
+
"theirs_xml": theirs_xml,
|
|
153
|
+
"filename": filename,
|
|
154
|
+
})
|
|
155
|
+
except Exception:
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# -- clone ---------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@click.command()
|
|
163
|
+
@click.argument("slug")
|
|
164
|
+
@click.argument("directory", required=False)
|
|
165
|
+
@pass_ctx
|
|
166
|
+
def clone(ctx: Ctx, slug: str, directory: str | None) -> None:
|
|
167
|
+
"""Clone a workspace to a local folder.
|
|
168
|
+
|
|
169
|
+
\b
|
|
170
|
+
deepcell clone my-workspace # creates ./my-workspace/
|
|
171
|
+
deepcell clone my-workspace ./local-dir # creates ./local-dir/
|
|
172
|
+
"""
|
|
173
|
+
client = ctx.client
|
|
174
|
+
|
|
175
|
+
# 1. Validate workspace access
|
|
176
|
+
ws = client.get(f"/workspaces/{slug}")
|
|
177
|
+
workspace_id = str(ws.get("id", ""))
|
|
178
|
+
|
|
179
|
+
# 2. Get remote file list
|
|
180
|
+
remote_files = _get_remote_files(client, slug, include_uploaded=True)
|
|
181
|
+
|
|
182
|
+
# 3. Get HEAD SHA
|
|
183
|
+
head_sha = _get_head_sha(client, slug)
|
|
184
|
+
|
|
185
|
+
# 4. Determine target directory
|
|
186
|
+
target = Path(directory) if directory else Path(slug)
|
|
187
|
+
pre_existing = target.exists()
|
|
188
|
+
if pre_existing and any(target.iterdir()):
|
|
189
|
+
raise click.ClickException(f"Directory '{target}' already exists and is not empty.")
|
|
190
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
|
|
192
|
+
# 5. Download each file. Uploaded attachments live in object storage, not
|
|
193
|
+
# git: they carry file_type="uploaded" and no git_sha, and the git read
|
|
194
|
+
# route 404s on them. Downloading them aborted the clone mid-way and left
|
|
195
|
+
# a partial directory that blocked every retry.
|
|
196
|
+
git_files = [f for f in remote_files if f.get("file_type") != "uploaded"]
|
|
197
|
+
non_git = [f["filename"] for f in remote_files if f.get("file_type") == "uploaded"]
|
|
198
|
+
|
|
199
|
+
checksums: dict[str, FileChecksum] = {}
|
|
200
|
+
try:
|
|
201
|
+
for f in git_files:
|
|
202
|
+
fname = f["filename"]
|
|
203
|
+
git_sha = f.get("git_sha", "")
|
|
204
|
+
echo_info(f" Downloading {fname}")
|
|
205
|
+
content = _download_file(client, slug, fname)
|
|
206
|
+
(target / fname).write_text(content, encoding="utf-8")
|
|
207
|
+
checksums[fname] = FileChecksum(
|
|
208
|
+
git_sha=git_sha,
|
|
209
|
+
local_hash=compute_local_hash(content),
|
|
210
|
+
)
|
|
211
|
+
except Exception:
|
|
212
|
+
# Leave no half-written directory behind: a retry would fail on
|
|
213
|
+
# "already exists and is not empty" instead of re-cloning.
|
|
214
|
+
if not pre_existing:
|
|
215
|
+
import shutil
|
|
216
|
+
|
|
217
|
+
shutil.rmtree(target, ignore_errors=True)
|
|
218
|
+
raise
|
|
219
|
+
|
|
220
|
+
# 6. Write sync state
|
|
221
|
+
from deepcell_cli.config import get_api_url
|
|
222
|
+
|
|
223
|
+
state = SyncState(
|
|
224
|
+
workspace_slug=slug,
|
|
225
|
+
workspace_id=workspace_id,
|
|
226
|
+
api_url=get_api_url(),
|
|
227
|
+
last_sync_sha=head_sha,
|
|
228
|
+
file_checksums=checksums,
|
|
229
|
+
)
|
|
230
|
+
save_sync_state(target, state)
|
|
231
|
+
|
|
232
|
+
echo_success(
|
|
233
|
+
f"Cloned workspace '{slug}' to {target}/ "
|
|
234
|
+
f"({len(git_files)} file(s), HEAD {head_sha[:8]})"
|
|
235
|
+
)
|
|
236
|
+
if non_git:
|
|
237
|
+
echo_warning(
|
|
238
|
+
f"{len(non_git)} uploaded attachment(s) not cloned (they are not in "
|
|
239
|
+
f"git): {', '.join(non_git)} — open the workspace in the Workbench "
|
|
240
|
+
f"to download them. `deepcell download` reads the git route, which "
|
|
241
|
+
f"404s on these."
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# -- status --------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@click.command("status")
|
|
249
|
+
@pass_ctx
|
|
250
|
+
def sync_status(ctx: Ctx) -> None:
|
|
251
|
+
"""Show local changes vs last sync.
|
|
252
|
+
|
|
253
|
+
\b
|
|
254
|
+
A filename — added locally
|
|
255
|
+
M filename — modified locally
|
|
256
|
+
D filename — deleted locally
|
|
257
|
+
|
|
258
|
+
Which workspace this reports on comes from the clone's `.deepcell-sync`,
|
|
259
|
+
not from `--workspace` — a clone directory is bound to one workspace. The
|
|
260
|
+
root `--workspace` flag is rejected here when it names a different one
|
|
261
|
+
rather than being quietly ignored.
|
|
262
|
+
"""
|
|
263
|
+
root, state = _require_sync_root(ctx)
|
|
264
|
+
active_variant = state.active_variant or None
|
|
265
|
+
local_files = scan_local_files(root, tracked=state.file_checksums)
|
|
266
|
+
|
|
267
|
+
added: list[str] = []
|
|
268
|
+
modified: list[str] = []
|
|
269
|
+
deleted: list[str] = []
|
|
270
|
+
|
|
271
|
+
# Check for modifications and deletions
|
|
272
|
+
for name, ck in state.file_checksums.items():
|
|
273
|
+
if name not in local_files:
|
|
274
|
+
deleted.append(name)
|
|
275
|
+
elif local_files[name] != ck.local_hash:
|
|
276
|
+
modified.append(name)
|
|
277
|
+
|
|
278
|
+
# Check for additions
|
|
279
|
+
for name in local_files:
|
|
280
|
+
if name not in state.file_checksums:
|
|
281
|
+
added.append(name)
|
|
282
|
+
|
|
283
|
+
if not added and not modified and not deleted:
|
|
284
|
+
echo_info("Clean — no local changes since last sync.")
|
|
285
|
+
else:
|
|
286
|
+
for name in sorted(added):
|
|
287
|
+
click.echo(click.style("A ", fg="green") + name)
|
|
288
|
+
for name in sorted(modified):
|
|
289
|
+
click.echo(click.style("M ", fg="yellow") + name)
|
|
290
|
+
for name in sorted(deleted):
|
|
291
|
+
click.echo(click.style("D ", fg="red") + name)
|
|
292
|
+
|
|
293
|
+
# Optionally check remote
|
|
294
|
+
try:
|
|
295
|
+
client = ctx.client
|
|
296
|
+
head_sha = _get_head_sha(client, state.workspace_slug, variant=active_variant)
|
|
297
|
+
if head_sha and state.last_sync_sha and head_sha != state.last_sync_sha:
|
|
298
|
+
echo_info(f"Remote has new commit(s) since last sync (HEAD {head_sha[:8]})")
|
|
299
|
+
except Exception:
|
|
300
|
+
pass # offline is fine
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# -- pull ----------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@click.command()
|
|
307
|
+
@pass_ctx
|
|
308
|
+
def pull(ctx: Ctx) -> None:
|
|
309
|
+
"""Fetch latest cloud changes to the local folder.
|
|
310
|
+
|
|
311
|
+
Downloads remote changes and detects conflicts when a file was modified
|
|
312
|
+
both locally and on the server.
|
|
313
|
+
|
|
314
|
+
Which workspace it pulls from comes from the clone's `.deepcell-sync`, not
|
|
315
|
+
from `--workspace`; passing a different one is a usage error, not a
|
|
316
|
+
redirect. Run it from a clone of the workspace you mean.
|
|
317
|
+
"""
|
|
318
|
+
root, state = _require_sync_root(ctx)
|
|
319
|
+
client = ctx.client
|
|
320
|
+
slug = state.workspace_slug
|
|
321
|
+
active_variant = state.active_variant or None
|
|
322
|
+
|
|
323
|
+
# 1. Check remote HEAD (of the ACTIVE branch — variant or main)
|
|
324
|
+
head_sha = _get_head_sha(client, slug, variant=active_variant)
|
|
325
|
+
if head_sha == state.last_sync_sha:
|
|
326
|
+
echo_info("Already up to date.")
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
# 2. Get remote file list (of the active branch)
|
|
330
|
+
remote_files = _get_remote_files(client, slug, variant=active_variant)
|
|
331
|
+
remote_map: dict[str, dict] = {f["filename"]: f for f in remote_files}
|
|
332
|
+
|
|
333
|
+
# 3. Scan local state
|
|
334
|
+
local_files = scan_local_files(root, tracked=state.file_checksums)
|
|
335
|
+
|
|
336
|
+
# 4. Diff remote vs stored checksums
|
|
337
|
+
new_checksums: dict[str, FileChecksum] = {}
|
|
338
|
+
conflicts = 0
|
|
339
|
+
|
|
340
|
+
# Process remote files (new or modified)
|
|
341
|
+
for fname, finfo in remote_map.items():
|
|
342
|
+
remote_git_sha = finfo.get("git_sha", "")
|
|
343
|
+
stored = state.file_checksums.get(fname)
|
|
344
|
+
|
|
345
|
+
if stored and stored.git_sha == remote_git_sha:
|
|
346
|
+
# Remote unchanged — keep current local state
|
|
347
|
+
local_hash = local_files.get(fname, stored.local_hash)
|
|
348
|
+
new_checksums[fname] = FileChecksum(
|
|
349
|
+
git_sha=remote_git_sha,
|
|
350
|
+
local_hash=local_hash,
|
|
351
|
+
)
|
|
352
|
+
continue
|
|
353
|
+
|
|
354
|
+
# Remote changed (or new file)
|
|
355
|
+
local_modified = (
|
|
356
|
+
stored is not None
|
|
357
|
+
and fname in local_files
|
|
358
|
+
and local_files[fname] != stored.local_hash
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
if local_modified:
|
|
362
|
+
# Conflict: both sides changed
|
|
363
|
+
local_content = (root / fname).read_text(encoding="utf-8")
|
|
364
|
+
theirs_content = _download_branch_file(
|
|
365
|
+
client, slug, fname, variant=active_variant,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
# Try semantic merge for .deepcell files
|
|
369
|
+
merged = False
|
|
370
|
+
if fname.endswith(".deepcell") and state.last_sync_sha:
|
|
371
|
+
try:
|
|
372
|
+
# The merge BASE is pinned to a commit SHA (last_sync_sha),
|
|
373
|
+
# which is branch-independent. The variant file route reads
|
|
374
|
+
# only at the variant branch HEAD and ignores any `revision`
|
|
375
|
+
# param, so read the base via the MAIN route by SHA.
|
|
376
|
+
base_content = _download_file(
|
|
377
|
+
client, slug, fname, revision=state.last_sync_sha,
|
|
378
|
+
)
|
|
379
|
+
result = _try_merge(
|
|
380
|
+
client, base_content, local_content, theirs_content, fname,
|
|
381
|
+
)
|
|
382
|
+
if result and result.get("success") and result.get("merged_xml"):
|
|
383
|
+
from deepcell_cli.commands.merge import echo_merge_data_loss
|
|
384
|
+
|
|
385
|
+
# `success` counts conflicts only — an apply-time drop
|
|
386
|
+
# still comes back clean, so check before discarding
|
|
387
|
+
# the pre-merge local copy.
|
|
388
|
+
lossy = echo_merge_data_loss(result)
|
|
389
|
+
(root / fname).write_text(result["merged_xml"], encoding="utf-8")
|
|
390
|
+
new_checksums[fname] = FileChecksum(
|
|
391
|
+
git_sha=remote_git_sha,
|
|
392
|
+
# Baseline is what the SERVER holds (theirs), not
|
|
393
|
+
# the merge result: the merged content exists only
|
|
394
|
+
# on disk and still has to be pushed. Recording the
|
|
395
|
+
# merged hash made `push` say "Nothing to push" and
|
|
396
|
+
# `status` say Clean, and the next pull took the
|
|
397
|
+
# git_sha fast path — the merge was stranded with
|
|
398
|
+
# no command left to reveal it.
|
|
399
|
+
local_hash=compute_local_hash(theirs_content),
|
|
400
|
+
)
|
|
401
|
+
auto_count = result.get("auto_resolved_count", 0)
|
|
402
|
+
if lossy:
|
|
403
|
+
# Keep the pre-merge local copy and count it as a
|
|
404
|
+
# conflict so the pull exits 1 instead of reading
|
|
405
|
+
# as a clean auto-merge.
|
|
406
|
+
(root / f"{fname}.local").write_text(
|
|
407
|
+
local_content, encoding="utf-8"
|
|
408
|
+
)
|
|
409
|
+
conflicts += 1
|
|
410
|
+
echo_error(
|
|
411
|
+
f"INCOMPLETE MERGE: {fname} (pre-merge local saved "
|
|
412
|
+
f"as {fname}.local)"
|
|
413
|
+
)
|
|
414
|
+
else:
|
|
415
|
+
echo_info(f" Auto-merged {fname} ({auto_count} change(s) resolved)")
|
|
416
|
+
merged = True
|
|
417
|
+
elif result and not result.get("success"):
|
|
418
|
+
# Merge has conflicts — fall back to .local but show details
|
|
419
|
+
conflict_list = result.get("conflicts", [])
|
|
420
|
+
for c in conflict_list[:5]:
|
|
421
|
+
item = c.get("item_ref", "?")
|
|
422
|
+
cref = c.get("context_ref", "")
|
|
423
|
+
ours_val = c.get("ours", "")
|
|
424
|
+
theirs_val = c.get("theirs", "")
|
|
425
|
+
echo_warning(
|
|
426
|
+
f" {item}[{cref}]: ours={ours_val} vs theirs={theirs_val}"
|
|
427
|
+
)
|
|
428
|
+
if len(conflict_list) > 5:
|
|
429
|
+
echo_warning(f" ... and {len(conflict_list) - 5} more conflict(s)")
|
|
430
|
+
except Exception:
|
|
431
|
+
pass # graceful degradation — fall back to .local
|
|
432
|
+
|
|
433
|
+
if not merged:
|
|
434
|
+
conflicts += 1
|
|
435
|
+
local_backup = root / f"{fname}.local"
|
|
436
|
+
local_backup.write_text(local_content, encoding="utf-8")
|
|
437
|
+
(root / fname).write_text(theirs_content, encoding="utf-8")
|
|
438
|
+
new_checksums[fname] = FileChecksum(
|
|
439
|
+
git_sha=remote_git_sha,
|
|
440
|
+
local_hash=compute_local_hash(theirs_content),
|
|
441
|
+
)
|
|
442
|
+
echo_error(f"CONFLICT: {fname} (local saved as {fname}.local)")
|
|
443
|
+
if fname.endswith(".deepcell"):
|
|
444
|
+
echo_info(f" Hint: run `deepcell merge resolve {fname}` to resolve")
|
|
445
|
+
continue
|
|
446
|
+
|
|
447
|
+
# Download remote version (no conflict)
|
|
448
|
+
echo_info(f" Updating {fname}")
|
|
449
|
+
content = _download_branch_file(client, slug, fname, variant=active_variant)
|
|
450
|
+
(root / fname).write_text(content, encoding="utf-8")
|
|
451
|
+
new_checksums[fname] = FileChecksum(
|
|
452
|
+
git_sha=remote_git_sha,
|
|
453
|
+
local_hash=compute_local_hash(content),
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
# Handle remote deletions (file in stored checksums but not in remote)
|
|
457
|
+
for fname, stored in state.file_checksums.items():
|
|
458
|
+
if fname in remote_map:
|
|
459
|
+
continue
|
|
460
|
+
local_modified = (
|
|
461
|
+
fname in local_files
|
|
462
|
+
and local_files[fname] != stored.local_hash
|
|
463
|
+
)
|
|
464
|
+
if local_modified:
|
|
465
|
+
# Remote deleted, local modified — keep local
|
|
466
|
+
echo_info(f" Keeping locally modified {fname} (deleted on remote)")
|
|
467
|
+
new_checksums[fname] = FileChecksum(
|
|
468
|
+
git_sha="",
|
|
469
|
+
local_hash=local_files[fname],
|
|
470
|
+
)
|
|
471
|
+
elif fname in local_files:
|
|
472
|
+
# Remote deleted, no local changes — delete locally
|
|
473
|
+
echo_info(f" Deleting {fname}")
|
|
474
|
+
(root / fname).unlink()
|
|
475
|
+
|
|
476
|
+
# Preserve locally added files not tracked by sync
|
|
477
|
+
for fname in local_files:
|
|
478
|
+
if fname not in new_checksums and fname not in state.file_checksums:
|
|
479
|
+
# Locally added file — not in remote, keep it
|
|
480
|
+
new_checksums[fname] = FileChecksum(
|
|
481
|
+
git_sha="",
|
|
482
|
+
local_hash=local_files[fname],
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
# 5. Update sync state. Never overwrite a known baseline with a blank HEAD
|
|
486
|
+
# (a malformed 200 response with no head_sha): that would silently disarm the
|
|
487
|
+
# optimistic-lock guard on the next push (symmetric to the push guard). Keep
|
|
488
|
+
# the prior baseline armed and warn instead.
|
|
489
|
+
if head_sha:
|
|
490
|
+
state.last_sync_sha = head_sha
|
|
491
|
+
elif state.last_sync_sha:
|
|
492
|
+
echo_info(
|
|
493
|
+
"Pulled, but the server did not report a HEAD SHA; keeping the "
|
|
494
|
+
"previous sync baseline so the next push stays guarded."
|
|
495
|
+
)
|
|
496
|
+
else:
|
|
497
|
+
state.last_sync_sha = head_sha
|
|
498
|
+
state.file_checksums = new_checksums
|
|
499
|
+
save_sync_state(root, state)
|
|
500
|
+
|
|
501
|
+
if conflicts:
|
|
502
|
+
echo_warning(
|
|
503
|
+
f"Pulled with {conflicts} unresolved conflict(s). "
|
|
504
|
+
"Resolve with `deepcell merge resolve <file>` or edit .local files manually."
|
|
505
|
+
)
|
|
506
|
+
# Sync state is already saved; exit nonzero so a scripted
|
|
507
|
+
# `deepcell pull && deepcell push` never proceeds on a conflicted tree.
|
|
508
|
+
raise click.exceptions.Exit(1)
|
|
509
|
+
echo_success(f"Pulled latest changes (HEAD {head_sha[:8]})")
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
# -- push ----------------------------------------------------------------------
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
@click.command()
|
|
516
|
+
@click.option("-m", "--message", default="", help="Commit message for the push.")
|
|
517
|
+
@pass_ctx
|
|
518
|
+
def push(ctx: Ctx, message: str) -> None:
|
|
519
|
+
"""Upload local changes to the cloud workspace.
|
|
520
|
+
|
|
521
|
+
Rejects if the remote has changed since your last sync.
|
|
522
|
+
Run `deepcell pull` first to incorporate remote changes.
|
|
523
|
+
"""
|
|
524
|
+
root, state = _require_sync_root(ctx)
|
|
525
|
+
client = ctx.client
|
|
526
|
+
slug = state.workspace_slug
|
|
527
|
+
active_variant = state.active_variant or None
|
|
528
|
+
|
|
529
|
+
# 1. Check remote HEAD matches our last sync (against the ACTIVE branch)
|
|
530
|
+
head_sha = _get_head_sha(client, slug, variant=active_variant)
|
|
531
|
+
if head_sha and state.last_sync_sha and head_sha != state.last_sync_sha:
|
|
532
|
+
raise click.ClickException(
|
|
533
|
+
"Remote has new changes. Run `deepcell pull` first."
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
# 2. Compute local changes
|
|
537
|
+
local_files = scan_local_files(root, tracked=state.file_checksums)
|
|
538
|
+
added: list[str] = []
|
|
539
|
+
modified: list[str] = []
|
|
540
|
+
deleted: list[str] = []
|
|
541
|
+
|
|
542
|
+
for name in local_files:
|
|
543
|
+
if name not in state.file_checksums:
|
|
544
|
+
added.append(name)
|
|
545
|
+
elif local_files[name] != state.file_checksums[name].local_hash:
|
|
546
|
+
modified.append(name)
|
|
547
|
+
|
|
548
|
+
for name in state.file_checksums:
|
|
549
|
+
if name not in local_files:
|
|
550
|
+
deleted.append(name)
|
|
551
|
+
|
|
552
|
+
if not added and not modified and not deleted:
|
|
553
|
+
echo_info("Nothing to push.")
|
|
554
|
+
return
|
|
555
|
+
|
|
556
|
+
# 3. Upload changes
|
|
557
|
+
commit_msg = message or "Sync from CLI"
|
|
558
|
+
if not message:
|
|
559
|
+
import sys
|
|
560
|
+
print(
|
|
561
|
+
"Warning: no commit message given (-m). Proceeding with 'Sync from CLI'. "
|
|
562
|
+
"Run `deepcell push -m '<message>'` to describe your changes.",
|
|
563
|
+
file=sys.stderr,
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
# Variant deletions can't be applied (no backend route), so they're skipped.
|
|
567
|
+
# Tracked separately so they don't inflate the pushed count and so the still-
|
|
568
|
+
# on-server files stay in the tracked checksums (else they resurface as new
|
|
569
|
+
# on the next pull).
|
|
570
|
+
skipped_deletes: list[str] = []
|
|
571
|
+
|
|
572
|
+
# Files the server committed but flagged with validation errors. The push
|
|
573
|
+
# is real either way (the server persists invalid .deepcell content on
|
|
574
|
+
# purpose), but it must not read as a clean success or exit 0.
|
|
575
|
+
invalid_files: list[str] = []
|
|
576
|
+
|
|
577
|
+
def _surface_validation(fname: str, validation: object) -> None:
|
|
578
|
+
if not isinstance(validation, dict):
|
|
579
|
+
return
|
|
580
|
+
for w in validation.get("warnings") or []:
|
|
581
|
+
echo_warning(f"{fname}: {w}")
|
|
582
|
+
errors = validation.get("errors") or []
|
|
583
|
+
for e in errors:
|
|
584
|
+
echo_error(f"{fname}: Error: {e}")
|
|
585
|
+
if errors:
|
|
586
|
+
invalid_files.append(fname)
|
|
587
|
+
|
|
588
|
+
# Set only by the main-branch batch path; the per-file variant path has no
|
|
589
|
+
# batch response to read committed hashes from.
|
|
590
|
+
batch_result: dict | None = None
|
|
591
|
+
|
|
592
|
+
if active_variant:
|
|
593
|
+
# Variant mode: commit each changed file individually via the variant file endpoint.
|
|
594
|
+
# (No batch variant endpoint exists; per-file is the v1 approach.)
|
|
595
|
+
# Deleted files on a variant have no backend route — warn and skip.
|
|
596
|
+
last_commit_sha = ""
|
|
597
|
+
for fname in added + modified:
|
|
598
|
+
content = (root / fname).read_text(encoding="utf-8")
|
|
599
|
+
echo_info(f" Pushing {fname} → variant '{active_variant}'")
|
|
600
|
+
result = client.post(
|
|
601
|
+
f"/workspaces/{slug}/variants/{active_variant}/files/{fname}",
|
|
602
|
+
json={"content": content, "commit_message": commit_msg},
|
|
603
|
+
)
|
|
604
|
+
if isinstance(result, dict) and result.get("commit_sha"):
|
|
605
|
+
last_commit_sha = result["commit_sha"]
|
|
606
|
+
if isinstance(result, dict):
|
|
607
|
+
_surface_validation(fname, result.get("validation"))
|
|
608
|
+
|
|
609
|
+
if deleted:
|
|
610
|
+
skipped_deletes = list(deleted)
|
|
611
|
+
for fname in deleted:
|
|
612
|
+
echo_warning(
|
|
613
|
+
f" Skipping deletion of {fname} on variant — "
|
|
614
|
+
"no backend route for variant file-deletes (deferred)"
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
new_head = last_commit_sha or _get_head_sha(client, slug, variant=active_variant)
|
|
618
|
+
else:
|
|
619
|
+
# Main mode: single atomic batch commit
|
|
620
|
+
files_payload: dict[str, str] = {}
|
|
621
|
+
for fname in added + modified:
|
|
622
|
+
content = (root / fname).read_text(encoding="utf-8")
|
|
623
|
+
echo_info(f" Staging {fname}")
|
|
624
|
+
files_payload[fname] = content
|
|
625
|
+
|
|
626
|
+
for fname in deleted:
|
|
627
|
+
echo_info(f" Deleting {fname}")
|
|
628
|
+
|
|
629
|
+
batch_result = client.post(
|
|
630
|
+
f"/workspaces/{slug}/files:batch",
|
|
631
|
+
json={
|
|
632
|
+
"files": files_payload,
|
|
633
|
+
"deletes": deleted,
|
|
634
|
+
"commit_message": commit_msg,
|
|
635
|
+
# Optimistic-lock guard: the server rejects with 409 if main HEAD
|
|
636
|
+
# advanced past the revision we synced against. None on first sync
|
|
637
|
+
# (no baseline yet) so the server skips the CAS.
|
|
638
|
+
"expected_parent": state.last_sync_sha or None,
|
|
639
|
+
},
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
new_head = (
|
|
643
|
+
batch_result.get("commit_sha")
|
|
644
|
+
if isinstance(batch_result, dict) and batch_result.get("commit_sha")
|
|
645
|
+
else _get_head_sha(client, slug)
|
|
646
|
+
)
|
|
647
|
+
if isinstance(batch_result, dict) and isinstance(
|
|
648
|
+
batch_result.get("validation"), dict
|
|
649
|
+
):
|
|
650
|
+
for fname, v in batch_result["validation"].items():
|
|
651
|
+
_surface_validation(fname, v)
|
|
652
|
+
remote_files = _get_remote_files(client, slug, variant=active_variant)
|
|
653
|
+
remote_map = {f["filename"]: f for f in remote_files}
|
|
654
|
+
|
|
655
|
+
# The server normalizes and recomputes .deepcell content before
|
|
656
|
+
# committing, so what landed in git may differ from what we sent.
|
|
657
|
+
# Recording the on-disk hash as the baseline made `status` report "Clean"
|
|
658
|
+
# while the two differed, and `pull` (which compares git SHAs) never
|
|
659
|
+
# reconciled it. Pull back exactly the files whose committed hash differs.
|
|
660
|
+
committed_hashes = (
|
|
661
|
+
batch_result.get("content_hashes")
|
|
662
|
+
if isinstance(batch_result, dict) else None
|
|
663
|
+
) or {}
|
|
664
|
+
rewritten: list[str] = []
|
|
665
|
+
# Files we could not reconcile: baseline them against the server's hash so
|
|
666
|
+
# the drift stays visible instead of being papered over as Clean.
|
|
667
|
+
baseline_overrides: dict[str, str] = {}
|
|
668
|
+
for fname, committed_hash in committed_hashes.items():
|
|
669
|
+
if fname not in local_files or local_files[fname] == committed_hash:
|
|
670
|
+
continue
|
|
671
|
+
try:
|
|
672
|
+
server_content = _download_branch_file(
|
|
673
|
+
client, slug, fname, variant=active_variant,
|
|
674
|
+
)
|
|
675
|
+
except Exception:
|
|
676
|
+
# Leave the local copy alone, but baseline it against what the
|
|
677
|
+
# server committed so `status` actually reports the drift. Keeping
|
|
678
|
+
# the on-disk hash here made the baseline match the file and
|
|
679
|
+
# `status` say Clean — reinstating the very bug this reconcile
|
|
680
|
+
# exists to fix, just on the error path.
|
|
681
|
+
baseline_overrides[fname] = committed_hash
|
|
682
|
+
continue
|
|
683
|
+
(root / fname).write_text(server_content, encoding="utf-8")
|
|
684
|
+
local_files[fname] = compute_local_hash(server_content)
|
|
685
|
+
rewritten.append(fname)
|
|
686
|
+
|
|
687
|
+
new_checksums: dict[str, FileChecksum] = {}
|
|
688
|
+
for fname in local_files:
|
|
689
|
+
remote_info = remote_map.get(fname, {})
|
|
690
|
+
new_checksums[fname] = FileChecksum(
|
|
691
|
+
git_sha=remote_info.get("git_sha", ""),
|
|
692
|
+
local_hash=baseline_overrides.get(fname, local_files[fname]),
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
# Skipped variant deletions are still on the server: keep them tracked (with
|
|
696
|
+
# their server git_sha) so the next pull sees them as unchanged rather than
|
|
697
|
+
# re-downloading them as "new". They remain locally deleted — status keeps
|
|
698
|
+
# showing D until a real variant delete route exists.
|
|
699
|
+
for fname in skipped_deletes:
|
|
700
|
+
remote_info = remote_map.get(fname)
|
|
701
|
+
if remote_info is None:
|
|
702
|
+
continue # genuinely gone from the server — let it drop from tracking
|
|
703
|
+
prev = state.file_checksums.get(fname)
|
|
704
|
+
new_checksums[fname] = FileChecksum(
|
|
705
|
+
git_sha=remote_info.get("git_sha", ""),
|
|
706
|
+
local_hash=prev.local_hash if prev else "",
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
# Never persist a blank baseline. An empty new_head (no commit_sha AND the
|
|
710
|
+
# HEAD fallback also returned "") would silently disable the optimistic-lock
|
|
711
|
+
# guard on the next push. The upload/commit already succeeded above, so fail
|
|
712
|
+
# loudly and tell the user to re-sync rather than corrupting the baseline.
|
|
713
|
+
if not new_head:
|
|
714
|
+
raise click.ClickException(
|
|
715
|
+
"Push completed but could not determine the new HEAD SHA. "
|
|
716
|
+
"Run `deepcell pull` to re-sync before pushing again."
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
state.last_sync_sha = new_head
|
|
720
|
+
state.file_checksums = new_checksums
|
|
721
|
+
save_sync_state(root, state)
|
|
722
|
+
|
|
723
|
+
# Skipped variant deletions weren't applied, so they don't count as pushed.
|
|
724
|
+
total = len(added) + len(modified) + (len(deleted) - len(skipped_deletes))
|
|
725
|
+
echo_success(
|
|
726
|
+
f"Pushed {total} change(s) to '{slug}' (HEAD {new_head[:8]})"
|
|
727
|
+
)
|
|
728
|
+
if rewritten:
|
|
729
|
+
echo_info(
|
|
730
|
+
f" {len(rewritten)} file(s) updated locally to match server "
|
|
731
|
+
f"normalization/recalculation: {', '.join(rewritten)}"
|
|
732
|
+
)
|
|
733
|
+
if skipped_deletes:
|
|
734
|
+
echo_warning(
|
|
735
|
+
f"{len(skipped_deletes)} deletion(s) skipped "
|
|
736
|
+
"(no variant delete route — files remain on the server)"
|
|
737
|
+
)
|
|
738
|
+
if invalid_files:
|
|
739
|
+
echo_warning(
|
|
740
|
+
f"Push committed, but {len(invalid_files)} file(s) have validation "
|
|
741
|
+
f"error(s): {', '.join(invalid_files)} — fix the errors above and "
|
|
742
|
+
"push again"
|
|
743
|
+
)
|
|
744
|
+
raise click.exceptions.Exit(1)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
# -- commit (server-side pending) ---------------------------------------------
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
@click.command()
|
|
751
|
+
@click.option("-m", "--message", default=None, help="Commit message.")
|
|
752
|
+
@pass_ctx
|
|
753
|
+
def commit(ctx: Ctx, message: str | None) -> None:
|
|
754
|
+
"""Commit changes staged on the server by something other than you.
|
|
755
|
+
|
|
756
|
+
You almost certainly do not need this. `defs`, `edit`, `write` and
|
|
757
|
+
`reasoning` each commit their own change with their own `-m` — there is no
|
|
758
|
+
staging area they queue into. This commits what a *different* actor left
|
|
759
|
+
pending on the server, such as files created by an agent session.
|
|
760
|
+
|
|
761
|
+
Nothing staged is a no-op, not an error.
|
|
762
|
+
"""
|
|
763
|
+
root, state = _require_sync_root(ctx)
|
|
764
|
+
slug = state.workspace_slug
|
|
765
|
+
|
|
766
|
+
# `-m` is no longer `required=True`, so this reaches the server even when
|
|
767
|
+
# the caller gave no message. The server checks "is anything staged"
|
|
768
|
+
# first and answers the no-op; a commit that will actually write still
|
|
769
|
+
# 422s without a message, and that is re-raised below as a usage error.
|
|
770
|
+
#
|
|
771
|
+
# It used to be rejected by Click during parsing, so a worker whose defs
|
|
772
|
+
# ops had each already committed and who reasonably tried to finalize got
|
|
773
|
+
# "Missing option '-m' / '--message'" — an argument-syntax error for a
|
|
774
|
+
# command with nothing to do. That is what the 2026-08-02 CLI eval hit.
|
|
775
|
+
data = ctx.client.post(
|
|
776
|
+
f"/workspaces/{slug}/versions/commit",
|
|
777
|
+
json={"message": message or ""},
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
# A commit with nothing staged is a benign no-op (the endpoint answers
|
|
781
|
+
# 200 with committed=false, sha=null) — report it instead of slicing a
|
|
782
|
+
# null sha.
|
|
783
|
+
if isinstance(data, dict) and data.get("committed") is False:
|
|
784
|
+
echo_info(
|
|
785
|
+
"Nothing to commit — no pending changes. `defs`, `edit`, `write` "
|
|
786
|
+
"and `reasoning` each commit their own change, so there is "
|
|
787
|
+
"normally nothing here to finalize."
|
|
788
|
+
)
|
|
789
|
+
return
|
|
790
|
+
|
|
791
|
+
sha = (data.get("sha") or "")[:8] if isinstance(data, dict) else ""
|
|
792
|
+
# Staged content is committed either way (the server persists invalid
|
|
793
|
+
# .deepcell on purpose), so report the errors rather than letting a clean
|
|
794
|
+
# "Committed" line stand in for them — parity with write/push.
|
|
795
|
+
invalid = echo_validation_map(
|
|
796
|
+
data.get("validation") if isinstance(data, dict) else None
|
|
797
|
+
)
|
|
798
|
+
if invalid:
|
|
799
|
+
echo_warning(
|
|
800
|
+
f"Committed ({sha}), but {len(invalid)} file(s) have validation "
|
|
801
|
+
f"error(s): {', '.join(invalid)} — fix the errors above"
|
|
802
|
+
)
|
|
803
|
+
raise click.exceptions.Exit(1)
|
|
804
|
+
echo_success(f"Committed ({sha}): {message}")
|