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.
Files changed (67) hide show
  1. deepcell_cli/__init__.py +12 -0
  2. deepcell_cli/__main__.py +5 -0
  3. deepcell_cli/_findings.py +84 -0
  4. deepcell_cli/capabilities.py +560 -0
  5. deepcell_cli/capability-contract.json +15622 -0
  6. deepcell_cli/client.py +503 -0
  7. deepcell_cli/commands/__init__.py +1 -0
  8. deepcell_cli/commands/_batch_input.py +29 -0
  9. deepcell_cli/commands/_datatypes.py +56 -0
  10. deepcell_cli/commands/_negative_args.py +133 -0
  11. deepcell_cli/commands/_swapped_args.py +153 -0
  12. deepcell_cli/commands/_version_display.py +40 -0
  13. deepcell_cli/commands/_write_opts.py +139 -0
  14. deepcell_cli/commands/account.py +123 -0
  15. deepcell_cli/commands/auth.py +610 -0
  16. deepcell_cli/commands/changes.py +307 -0
  17. deepcell_cli/commands/deck.py +594 -0
  18. deepcell_cli/commands/defs.py +3890 -0
  19. deepcell_cli/commands/describe.py +902 -0
  20. deepcell_cli/commands/doc.py +529 -0
  21. deepcell_cli/commands/doctor.py +257 -0
  22. deepcell_cli/commands/download.py +36 -0
  23. deepcell_cli/commands/edit.py +384 -0
  24. deepcell_cli/commands/example.py +161 -0
  25. deepcell_cli/commands/export.py +81 -0
  26. deepcell_cli/commands/export_docx.py +57 -0
  27. deepcell_cli/commands/export_pdf.py +66 -0
  28. deepcell_cli/commands/export_pptx.py +45 -0
  29. deepcell_cli/commands/files.py +386 -0
  30. deepcell_cli/commands/grep.py +90 -0
  31. deepcell_cli/commands/guide.py +431 -0
  32. deepcell_cli/commands/help_cmd.py +348 -0
  33. deepcell_cli/commands/impact.py +382 -0
  34. deepcell_cli/commands/import_cmd.py +208 -0
  35. deepcell_cli/commands/ingest.py +110 -0
  36. deepcell_cli/commands/merge.py +399 -0
  37. deepcell_cli/commands/query.py +718 -0
  38. deepcell_cli/commands/reasoning.py +2981 -0
  39. deepcell_cli/commands/ref.py +279 -0
  40. deepcell_cli/commands/replace.py +326 -0
  41. deepcell_cli/commands/rules.py +206 -0
  42. deepcell_cli/commands/share.py +186 -0
  43. deepcell_cli/commands/sync.py +804 -0
  44. deepcell_cli/commands/upgrade.py +185 -0
  45. deepcell_cli/commands/variant.py +353 -0
  46. deepcell_cli/commands/version.py +445 -0
  47. deepcell_cli/commands/viewer.py +54 -0
  48. deepcell_cli/commands/workspace.py +101 -0
  49. deepcell_cli/config.py +352 -0
  50. deepcell_cli/context.py +187 -0
  51. deepcell_cli/errors.py +141 -0
  52. deepcell_cli/logging_setup.py +161 -0
  53. deepcell_cli/main.py +518 -0
  54. deepcell_cli/mcp_server.py +906 -0
  55. deepcell_cli/oauth_provider.py +580 -0
  56. deepcell_cli/output.py +503 -0
  57. deepcell_cli/revision.py +164 -0
  58. deepcell_cli/stages.py +223 -0
  59. deepcell_cli/surface.py +628 -0
  60. deepcell_cli/sync_state.py +120 -0
  61. deepcell_cli/upgrade_check.py +399 -0
  62. deepcell_cli/xml_replace.py +89 -0
  63. deepcell_cli-0.6.1.dist-info/METADATA +264 -0
  64. deepcell_cli-0.6.1.dist-info/RECORD +67 -0
  65. deepcell_cli-0.6.1.dist-info/WHEEL +5 -0
  66. deepcell_cli-0.6.1.dist-info/entry_points.txt +3 -0
  67. deepcell_cli-0.6.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,185 @@
1
+ """``deepcell upgrade`` — the upgrade check's switch, its state, and how to upgrade.
2
+
3
+ The group deliberately does **not** install anything. Running ``pip`` from
4
+ inside the running CLI would have to guess how this copy was installed (pip,
5
+ pipx, uv, a venv, the system package manager) and would rewrite the very
6
+ process executing the command. So the CLI reports and prints the exact
7
+ command; the human runs it.
8
+
9
+ Bare ``deepcell upgrade`` shows status rather than erroring, because that is
10
+ what someone typing it is asking — "am I current, and if not, what do I run?"
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import time
17
+
18
+ import click
19
+
20
+ from deepcell_cli import __version__
21
+ from deepcell_cli.context import Ctx, pass_ctx
22
+ from deepcell_cli.output import echo_success
23
+ from deepcell_cli import upgrade_check
24
+
25
+
26
+ def _report() -> dict:
27
+ """Everything the two read-only views show, in one shape."""
28
+ state = upgrade_check.read_state()
29
+ latest = state.get("latest")
30
+ checked_at = state.get("checked_at")
31
+ known_newer = upgrade_check.is_newer(latest)
32
+ install_command = (
33
+ state.get("install_command")
34
+ or f"pip install --upgrade {upgrade_check.PACKAGE_NAME}"
35
+ )
36
+ return {
37
+ "current": __version__,
38
+ "latest_known": latest if isinstance(latest, str) else None,
39
+ "upgrade_available": known_newer,
40
+ "auto_check_enabled": upgrade_check.config_enabled(),
41
+ "silenced_by_env": upgrade_check.env_silenced(),
42
+ "last_checked": (
43
+ time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(checked_at))
44
+ if isinstance(checked_at, (int, float)) and not isinstance(checked_at, bool)
45
+ else None
46
+ ),
47
+ "check_interval_hours": upgrade_check.CHECK_INTERVAL_SECONDS // 3600,
48
+ # Only when there is something to install. Printing an install line
49
+ # to someone already current reads as "you should run this".
50
+ "install_command": install_command if known_newer else None,
51
+ }
52
+
53
+
54
+ def _render(report: dict) -> list[str]:
55
+ lines = [f"deepcell {report['current']}"]
56
+ if report["latest_known"] is None:
57
+ lines.append(" Latest unknown — run `deepcell upgrade check`")
58
+ elif report["upgrade_available"]:
59
+ lines.append(f" Latest {report['latest_known']} — upgrade available")
60
+ else:
61
+ lines.append(f" Latest {report['latest_known']} — up to date")
62
+
63
+ if report["silenced_by_env"]:
64
+ state = f"silenced by {upgrade_check.ENV_DISABLE}"
65
+ elif report["auto_check_enabled"]:
66
+ state = f"on — at most once every {report['check_interval_hours']}h, in the background"
67
+ else:
68
+ state = "off — run `deepcell upgrade enable` to turn it back on"
69
+ lines.append(f" Auto-check {state}")
70
+ lines.append(f" Last check {report['last_checked'] or 'never'}")
71
+ if report["install_command"]:
72
+ lines.append("")
73
+ lines.append(f" {report['install_command']}")
74
+ return lines
75
+
76
+
77
+ def _emit(ctx: Ctx, report: dict) -> None:
78
+ if ctx.fmt == "json":
79
+ click.echo(json.dumps(report, indent=2))
80
+ return
81
+ for line in _render(report):
82
+ click.echo(line)
83
+
84
+
85
+ @click.group(invoke_without_command=True)
86
+ @click.pass_context
87
+ def upgrade(ctx: click.Context) -> None:
88
+ """Check whether a newer deepcell CLI has been published.
89
+
90
+ The check runs by itself at most once a day, in a background thread, and
91
+ prints a one-line notice on stderr when a newer version exists. It never
92
+ installs anything and never delays a command — it tells you what to run.
93
+
94
+ It is on by default. Turn it off persistently with `deepcell upgrade
95
+ disable`, or for one process with `DEEPCELL_NO_UPGRADE_CHECK=1`.
96
+
97
+ \b
98
+ Examples:
99
+ deepcell upgrade # am I current, and what do I run?
100
+ deepcell upgrade check # ask the index right now
101
+ deepcell upgrade disable # stop the automatic notice
102
+ """
103
+ if ctx.invoked_subcommand is None:
104
+ ctx.invoke(status)
105
+
106
+
107
+ @upgrade.command("status")
108
+ @pass_ctx
109
+ def status(ctx: Ctx) -> None:
110
+ """Show the setting and the last cached answer. Never touches the network.
111
+
112
+ \b
113
+ Examples:
114
+ deepcell upgrade status
115
+ deepcell upgrade status --format json
116
+ """
117
+ _emit(ctx, _report())
118
+
119
+
120
+ @upgrade.command("check")
121
+ @click.option(
122
+ "--timeout",
123
+ type=float,
124
+ default=upgrade_check.FETCH_TIMEOUT_SECONDS,
125
+ show_default=True,
126
+ metavar="SECONDS",
127
+ help="How long to wait for each package index.",
128
+ )
129
+ @pass_ctx
130
+ def check(ctx: Ctx, timeout: float) -> None:
131
+ """Ask the package index right now and update the cached answer.
132
+
133
+ Runs even when the automatic check is disabled — asking explicitly is not
134
+ the thing that was turned off. Exits 0 whether or not an upgrade exists;
135
+ an unreachable index is reported, not raised.
136
+
137
+ \b
138
+ Examples:
139
+ deepcell upgrade check
140
+ deepcell upgrade check --timeout 10
141
+ """
142
+ state = upgrade_check.refresh(timeout)
143
+ report = _report()
144
+ if ctx.fmt == "json":
145
+ _emit(ctx, report)
146
+ return
147
+ if not state.get("ok"):
148
+ click.echo(
149
+ "Could not reach the package index — showing the last known answer.",
150
+ err=True,
151
+ )
152
+ _emit(ctx, report)
153
+
154
+
155
+ @upgrade.command("enable")
156
+ def enable() -> None:
157
+ """Turn the automatic upgrade check on (the default).
158
+
159
+ \b
160
+ Examples:
161
+ deepcell upgrade enable
162
+ """
163
+ upgrade_check.set_enabled(True)
164
+ echo_success("Automatic upgrade check enabled.")
165
+ if upgrade_check.env_silenced():
166
+ click.echo(
167
+ f"Note: {upgrade_check.ENV_DISABLE} is set in this environment, "
168
+ "which still silences the notice here.",
169
+ err=True,
170
+ )
171
+
172
+
173
+ @upgrade.command("disable")
174
+ def disable() -> None:
175
+ """Turn the automatic upgrade check off.
176
+
177
+ Nothing is checked and no notice is printed until `deepcell upgrade
178
+ enable`. `deepcell upgrade check` still works when asked directly.
179
+
180
+ \b
181
+ Examples:
182
+ deepcell upgrade disable
183
+ """
184
+ upgrade_check.set_enabled(False)
185
+ echo_success("Automatic upgrade check disabled.")
@@ -0,0 +1,353 @@
1
+ """Variant commands: list, create, checkout, diff, merge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from deepcell_cli.context import Ctx, pass_ctx
8
+ from deepcell_cli.output import (
9
+ echo_error,
10
+ echo_info,
11
+ echo_success,
12
+ echo_validation_map,
13
+ echo_warning,
14
+ output,
15
+ output_mutation,
16
+ )
17
+ from deepcell_cli.sync_state import find_sync_root, load_sync_state, save_sync_state
18
+
19
+
20
+ def _require_sync_root_for_variant():
21
+ """Locate sync root from CWD or raise a ClickException."""
22
+ from pathlib import Path
23
+
24
+ root = find_sync_root()
25
+ if root is None:
26
+ raise click.ClickException(
27
+ "Not inside a synced workspace. Run `deepcell clone <slug>` first."
28
+ )
29
+ return root, load_sync_state(root)
30
+
31
+
32
+ def _dirty_files(root, state) -> set[str]:
33
+ """Return tracked files that are missing/changed plus untracked files.
34
+
35
+ Mirrors how `status` computes A/M/D (added + modified + deleted).
36
+ """
37
+ from deepcell_cli.commands.sync import scan_local_files
38
+
39
+ local = scan_local_files(root, tracked=state.file_checksums)
40
+ changed_or_deleted = {
41
+ n for n, ck in state.file_checksums.items()
42
+ if n not in local or local[n] != ck.local_hash
43
+ }
44
+ untracked = {n for n in local if n not in state.file_checksums}
45
+ return changed_or_deleted | untracked
46
+
47
+
48
+ def _sync_branch_files(client, slug: str, root, state, variant: str | None) -> None:
49
+ """Atomically reconcile the working tree to the target branch's files.
50
+
51
+ The switch is all-or-nothing: ALL target-branch file contents are downloaded
52
+ into memory FIRST, and only once every download has succeeded do we touch the
53
+ disk — writing files, rebuilding checksums, and removing stranded files. If any
54
+ download fails, we abort having written NOTHING to disk and leaving
55
+ ``state.file_checksums`` unchanged; the caller never reaches ``save_sync_state``,
56
+ so sync.json keeps pointing at the previous branch. This prevents a partial
57
+ switch where files 1..k-1 already hold the target branch's content on disk while
58
+ sync.json still claims the old branch — which a later main-mode push would
59
+ happily upload onto main (silent cross-branch corruption).
60
+
61
+ Files that were tracked on the previous branch but are absent from the target
62
+ branch are deleted from disk, so switching branches does not leave orphan files
63
+ behind (which would otherwise resurface as untracked/added on the next status
64
+ or push and get re-uploaded onto the wrong branch). Callers must ensure the
65
+ working tree is clean first (checkout refuses on uncommitted changes), so every
66
+ local file at this point is tracked — untracked user files are never removed.
67
+ """
68
+ from deepcell_cli.commands.sync import _download_branch_file, _get_remote_files
69
+ from deepcell_cli.sync_state import FileChecksum, compute_local_hash
70
+
71
+ previously_tracked = set(state.file_checksums)
72
+ remote_files = _get_remote_files(client, slug, variant=variant)
73
+
74
+ # Phase 1 — download everything into memory. A failure here propagates before
75
+ # any disk mutation, so the previous branch stays fully intact on disk.
76
+ downloaded: list[tuple[str, str, str]] = [] # (fname, content, git_sha)
77
+ for f in remote_files:
78
+ fname = f["filename"]
79
+ content = _download_branch_file(client, slug, fname, variant=variant)
80
+ downloaded.append((fname, content, f.get("git_sha", "")))
81
+
82
+ # Phase 2 — commit. Every download succeeded; write each file to a hidden
83
+ # temp sibling FIRST, then atomically rename it into place. A mid-write
84
+ # failure (ENOSPC/EACCES) therefore leaves no final file holding the target
85
+ # branch's content — without this, files 1..k-1 would already be switched on
86
+ # disk while sync.json still claimed the old branch, which a later main-mode
87
+ # push would upload onto main (silent cross-branch corruption). Temp names
88
+ # are dot-prefixed so scan_local_files ignores any that a crash leaves behind.
89
+ import os
90
+
91
+ new_checksums: dict[str, FileChecksum] = {}
92
+ staged: list[tuple] = [] # (temp_path, final_path)
93
+ temp_paths: list = []
94
+ try:
95
+ for fname, content, git_sha in downloaded:
96
+ final = root / fname
97
+ tmp = final.with_name(f".{final.name}.tmp")
98
+ final.parent.mkdir(parents=True, exist_ok=True)
99
+ tmp.write_text(content, encoding="utf-8")
100
+ temp_paths.append(tmp)
101
+ staged.append((tmp, final))
102
+ new_checksums[fname] = FileChecksum(
103
+ git_sha=git_sha,
104
+ local_hash=compute_local_hash(content),
105
+ )
106
+ # All temps written — promote them (fast, per-file atomic).
107
+ for tmp, final in staged:
108
+ os.replace(tmp, final)
109
+ finally:
110
+ # Remove any temp not promoted (a write failed mid-loop).
111
+ for tmp in temp_paths:
112
+ tmp.unlink(missing_ok=True)
113
+
114
+ # Remove files tracked on the old branch but not present on the target branch.
115
+ for stale in previously_tracked - set(new_checksums):
116
+ (root / stale).unlink(missing_ok=True)
117
+
118
+ state.file_checksums = new_checksums
119
+
120
+
121
+ @click.group()
122
+ def variant() -> None:
123
+ """Manage variants (what-if branches).
124
+
125
+ Variants let you create parallel assumption sets for the same model —
126
+ e.g. Base Case, Best Case, Worst Case — without duplicating files.
127
+ """
128
+
129
+
130
+ @variant.command("list")
131
+ @pass_ctx
132
+ def variant_list(ctx: Ctx) -> None:
133
+ """List variants in the workspace."""
134
+ slug = ctx.require_workspace()
135
+ data = ctx.client.get(f"/workspaces/{slug}/variants")
136
+ output(data, ctx.fmt)
137
+
138
+
139
+ @variant.command("create")
140
+ @click.argument("name")
141
+ @pass_ctx
142
+ def variant_create(ctx: Ctx, name: str) -> None:
143
+ """Create a new variant branch."""
144
+ slug = ctx.require_workspace()
145
+ data = ctx.client.post(f"/workspaces/{slug}/variants/{name}", json={})
146
+ output_mutation(data, ctx.fmt, plain_key="variant_name")
147
+ echo_success(f"Variant '{name}' created")
148
+
149
+
150
+ @variant.command("checkout")
151
+ @click.argument("name", required=False, default=None)
152
+ @click.option("--main", "use_main", is_flag=True, default=False,
153
+ help="Switch back to the main branch (clear active variant).")
154
+ @pass_ctx
155
+ def variant_checkout(ctx: Ctx, name: str | None, use_main: bool) -> None:
156
+ """Check out a variant branch for local edits.
157
+
158
+ \b
159
+ deepcell variant checkout exp-1 # switch to variant exp-1
160
+ deepcell variant checkout main # return to main
161
+ deepcell variant checkout --main # same as above
162
+ """
163
+ from deepcell_cli.commands.sync import _get_head_sha
164
+
165
+ root, state = _require_sync_root_for_variant()
166
+ slug = state.workspace_slug
167
+ target_main = use_main or name == "main"
168
+
169
+ # Refuse to switch with uncommitted local changes — a branch switch
170
+ # overwrites the working tree, so dirty state would be silently clobbered.
171
+ dirty = _dirty_files(root, state)
172
+ if dirty:
173
+ raise click.ClickException(
174
+ "You have uncommitted local changes: "
175
+ + ", ".join(sorted(dirty))
176
+ + ". Push or discard them before switching branches."
177
+ )
178
+
179
+ if target_main:
180
+ # Pin the optimistic-lock baseline to main's HEAD. Hard-fail on an empty
181
+ # SHA rather than falling back to the variant's last_sync_sha (which would
182
+ # leave the baseline pointing at the variant after switching to main and
183
+ # cause spurious "Remote has new changes" on the next push).
184
+ main_head = _get_head_sha(ctx.client, slug)
185
+ if not main_head:
186
+ raise click.ClickException(
187
+ "Could not determine main HEAD SHA — cannot set lock baseline. "
188
+ "Try again, or run `deepcell pull` first."
189
+ )
190
+ state.active_variant = ""
191
+ state.last_sync_sha = main_head
192
+ _sync_branch_files(ctx.client, slug, root, state, variant=None)
193
+ save_sync_state(root, state)
194
+ echo_success("Switched to main branch")
195
+ return
196
+
197
+ if not name:
198
+ raise click.ClickException("Provide a variant name or use --main to return to main.")
199
+
200
+ # Validate the variant exists (404 → APIError → ClickException).
201
+ try:
202
+ detail = ctx.client.get(f"/workspaces/{slug}/variants/{name}")
203
+ except Exception as e:
204
+ raise click.ClickException(
205
+ f"Variant '{name}' does not exist (or is unreachable): {e}. "
206
+ f"Create it with `deepcell variant create {name}`."
207
+ )
208
+
209
+ if not isinstance(detail, dict) or not detail.get("head_sha"):
210
+ raise click.ClickException(
211
+ f"Variant '{name}' response missing head_sha — cannot set lock baseline."
212
+ )
213
+ state.active_variant = name
214
+ state.last_sync_sha = detail["head_sha"]
215
+ _sync_branch_files(ctx.client, slug, root, state, variant=name)
216
+ save_sync_state(root, state)
217
+ echo_success(f"Switched to variant '{name}'")
218
+
219
+
220
+ @variant.command("diff")
221
+ @click.argument("name", required=False, default=None)
222
+ @click.option("--file", "file_path", default=None, help="Limit the diff to a single file.")
223
+ @pass_ctx
224
+ def variant_diff(ctx: Ctx, name: str | None, file_path: str | None) -> None:
225
+ """Show diff between a variant branch and main.
226
+
227
+ NAME defaults to the currently checked-out variant (active_variant).
228
+ """
229
+ root, state = _require_sync_root_for_variant()
230
+ slug = state.workspace_slug
231
+
232
+ variant_name = name or state.active_variant
233
+ if not variant_name:
234
+ raise click.ClickException(
235
+ "No variant name given and no active variant. "
236
+ "Run `deepcell variant checkout <name>` first, or pass a name."
237
+ )
238
+
239
+ params = {"filename": file_path} if file_path else None
240
+ data = ctx.client.get(f"/workspaces/{slug}/variants/{variant_name}/diff", params=params)
241
+ output(data, ctx.fmt)
242
+
243
+
244
+ @variant.command("merge")
245
+ @click.argument("name", required=False, default=None)
246
+ @click.option(
247
+ "--resolve",
248
+ type=click.Choice(["ours", "theirs"]),
249
+ default=None,
250
+ help="Conflict resolution strategy: 'ours' keeps main, 'theirs' keeps variant.",
251
+ )
252
+ @pass_ctx
253
+ def variant_merge(ctx: Ctx, name: str | None, resolve: str | None) -> None:
254
+ """Merge a variant branch back into main.
255
+
256
+ NAME defaults to the currently checked-out variant (active_variant).
257
+
258
+ On merge conflicts, re-run with --resolve ours or --resolve theirs.
259
+ """
260
+ root, state = _require_sync_root_for_variant()
261
+ slug = state.workspace_slug
262
+
263
+ variant_name = name or state.active_variant
264
+ if not variant_name:
265
+ raise click.ClickException(
266
+ "No variant name given and no active variant. "
267
+ "Run `deepcell variant checkout <name>` first, or pass a name."
268
+ )
269
+
270
+ data = ctx.client.post(
271
+ f"/workspaces/{slug}/variants/{variant_name}/merge",
272
+ json={"resolve": resolve},
273
+ )
274
+
275
+ def _warn_skipped(d: dict) -> None:
276
+ skipped_nd = d.get("skipped_non_deepcell") or []
277
+ skipped_del = d.get("skipped_deletes") or []
278
+ unmerged = d.get("unmerged_items") or []
279
+ if skipped_nd:
280
+ echo_warning(
281
+ "Not merged (non-.deepcell files — copy them onto main manually): "
282
+ + ", ".join(skipped_nd)
283
+ )
284
+ if skipped_del:
285
+ echo_warning(
286
+ "Not merged (files deleted on the variant — delete them on main "
287
+ "manually if intended): " + ", ".join(skipped_del)
288
+ )
289
+ if unmerged:
290
+ echo_warning(
291
+ "Not merged (items the value-merge could not carry over — add them "
292
+ "to main manually): " + ", ".join(unmerged)
293
+ )
294
+
295
+ if isinstance(data, dict) and data.get("success"):
296
+ commit_sha = data.get("commit_sha") or ""
297
+ echo_success(f"Merged variant '{variant_name}' into main ({commit_sha[:8] if commit_sha else 'ok'})")
298
+ _warn_skipped(data)
299
+ # The merge recomputes and validates each merged .deepcell before
300
+ # committing. It commits either way, so invalid content must be
301
+ # reported here or it lands on main behind a success message.
302
+ # Report BOTH before exiting: these are independent failures and the
303
+ # data-loss message names work the user has to redo by hand. Exiting on
304
+ # the first one swallowed the second whenever they coincided — the same
305
+ # swallowed-signal class this sweep exists to close.
306
+ invalid = echo_validation_map(data.get("validation"))
307
+ if invalid:
308
+ echo_error(
309
+ f"Merge committed, but {len(invalid)} merged file(s) have validation "
310
+ f"errors: {', '.join(invalid)} — fix them on main"
311
+ )
312
+ if data.get("had_data_loss"):
313
+ echo_error(
314
+ "Merge committed but some changes were NOT carried over (see warnings "
315
+ "above). Apply them on main manually."
316
+ )
317
+ if invalid or data.get("had_data_loss"):
318
+ raise click.exceptions.Exit(1)
319
+ return
320
+
321
+ # Not successful — show conflicts.
322
+ # Keys mirror the backend's _serialise_conflict: file, item_ref, context_ref,
323
+ # status_ref, base_value, ours_value (main), theirs_value (variant).
324
+ conflicts = data.get("conflicts", []) if isinstance(data, dict) else []
325
+ if conflicts:
326
+ echo_error(f"Merge conflict in variant '{variant_name}':")
327
+ for c in conflicts:
328
+ fname = c.get("file", "?")
329
+ item = c.get("item_ref", "?")
330
+ cref = c.get("context_ref") or ""
331
+ sref = c.get("status_ref") or ""
332
+ coords = ", ".join(p for p in (cref, sref) if p)
333
+ loc = f"{item}[{coords}]" if coords else item
334
+
335
+ def _val(v: object) -> str:
336
+ return "(absent)" if v is None else str(v)
337
+
338
+ click.echo(
339
+ f" {fname} {loc}: ours(main)={_val(c.get('ours_value'))} "
340
+ f"theirs(variant)={_val(c.get('theirs_value'))}",
341
+ err=True,
342
+ )
343
+ else:
344
+ echo_error(f"Merge failed for variant '{variant_name}'")
345
+
346
+ if isinstance(data, dict):
347
+ _warn_skipped(data)
348
+
349
+ click.echo(
350
+ "Hint: re-run with --resolve ours (keep main) or --resolve theirs (keep variant).",
351
+ err=True,
352
+ )
353
+ raise click.exceptions.Exit(1)