calkit-python 0.49.3__py3-none-any.whl → 0.49.5__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 (42) hide show
  1. calkit/agent_skills/resolve-merge-conflicts/SKILL.md +72 -0
  2. calkit/cli/__init__.py +10 -0
  3. calkit/cli/core.py +21 -5
  4. calkit/cli/fix.py +196 -0
  5. calkit/cli/main/core.py +200 -9
  6. calkit/cli/overleaf.py +136 -19
  7. calkit/cli/update.py +1 -1
  8. calkit/core.py +49 -0
  9. calkit/environments.py +50 -20
  10. calkit/hub.py +3 -1
  11. calkit/merge.py +636 -0
  12. calkit/models/pipeline.py +5 -0
  13. calkit/overleaf.py +2 -1
  14. calkit/pipeline.py +30 -8
  15. calkit/tests/cli/main/test_core.py +78 -0
  16. calkit/tests/cli/test_core.py +22 -0
  17. calkit/tests/cli/test_fix.py +174 -0
  18. calkit/tests/cli/test_overleaf.py +135 -6
  19. calkit/tests/models/test_pipeline.py +14 -0
  20. calkit/tests/test_core.py +75 -0
  21. calkit/tests/test_environments.py +28 -1
  22. calkit/tests/test_merge.py +182 -0
  23. calkit/tests/test_pipeline.py +41 -0
  24. calkit/upgrade.py +3 -1
  25. {calkit_python-0.49.3.dist-info → calkit_python-0.49.5.dist-info}/METADATA +1 -1
  26. {calkit_python-0.49.3.dist-info → calkit_python-0.49.5.dist-info}/RECORD +42 -37
  27. {calkit_python-0.49.3.data → calkit_python-0.49.5.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  28. {calkit_python-0.49.3.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  29. {calkit_python-0.49.3.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  30. {calkit_python-0.49.3.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  31. {calkit_python-0.49.3.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
  32. {calkit_python-0.49.3.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
  33. {calkit_python-0.49.3.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
  34. {calkit_python-0.49.3.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
  35. {calkit_python-0.49.3.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
  36. {calkit_python-0.49.3.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
  37. {calkit_python-0.49.3.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
  38. {calkit_python-0.49.3.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  39. {calkit_python-0.49.3.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  40. {calkit_python-0.49.3.dist-info → calkit_python-0.49.5.dist-info}/WHEEL +0 -0
  41. {calkit_python-0.49.3.dist-info → calkit_python-0.49.5.dist-info}/entry_points.txt +0 -0
  42. {calkit_python-0.49.3.dist-info → calkit_python-0.49.5.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,72 @@
1
+ ---
2
+ name: resolve-merge-conflicts
3
+ description: Resolve Git merge conflicts in a Calkit project, including
4
+ dvc.lock, stage run records, and pipeline outputs. Use when the user
5
+ invokes `/calkit:resolve-merge-conflicts`, asks to merge a branch, or a
6
+ merge, pull, or rebase stopped with conflicts.
7
+ ---
8
+
9
+ # Resolve merge conflicts
10
+
11
+ Some conflicted files record what the pipeline ran rather than what anyone
12
+ wrote, so they're resolved per stage by Calkit, not by editing text.
13
+
14
+ ## Division of labor
15
+
16
+ Done by Calkit, never by hand:
17
+
18
+ - `dvc.lock` and `.calkit/stage-runs.json`, in the project and any
19
+ subproject. Don't edit them or take one side whole with
20
+ `git checkout --ours/--theirs`: that mixes stages, or drops the records of
21
+ stages that only ran on the other side.
22
+ - Git-tracked pipeline outputs, which are checked out from the side each
23
+ stage's `dvc.lock` entry came from.
24
+ - `dvc.yaml`, which is generated from `calkit.yaml`. Once `calkit.yaml` is
25
+ resolved, take either side (`git checkout --ours dvc.yaml`) and run
26
+ `calkit status`, which rewrites it.
27
+
28
+ Done by you, with judgment: everything else, e.g., source code,
29
+ `calkit.yaml`, and manuscripts. When the two sides disagree about intent,
30
+ not just text, ask the user.
31
+
32
+ ## Starting a merge
33
+
34
+ Prefer `calkit merge BRANCH` to `git merge`. It sets up the merge driver,
35
+ resolves what it can, and checks out DVC-tracked outputs afterwards. Most
36
+ stages merge without a conflict: one changed on one side only takes that
37
+ side.
38
+
39
+ ## Stages changed on both sides
40
+
41
+ Each needs one choice, which decides its `dvc.lock` entry, run record, and
42
+ Git-tracked outputs together:
43
+
44
+ - `ours` or `theirs`: keep that side's result.
45
+ - `rerun`: keep neither, so `calkit run` reruns it.
46
+
47
+ You have no terminal for the prompt, so pass choices as options:
48
+
49
+ ```sh
50
+ calkit fix merge-conflicts --theirs fit --rerun figures
51
+ calkit fix merge-conflicts --prefer rerun
52
+ ```
53
+
54
+ Name subproject stages as `calkit check stage-runs` does, e.g.,
55
+ `sub/dvc.yaml:stage`. Run it without options first to list the stages that
56
+ need a choice.
57
+
58
+ To choose, compare each side's inputs with the merged ones. If the merged
59
+ inputs match one side, keep that side. If they match neither, e.g., both
60
+ sides edited the code, the stage will rerun either way, so choose `rerun`.
61
+ If a stage is expensive to run, or its result is shared with others, ask the
62
+ user. During a rebase, "ours" is the branch being rebased onto, and
63
+ "theirs" is the commit being replayed.
64
+
65
+ ## Finishing
66
+
67
+ 1. Resolve and `git add` the remaining files.
68
+ 2. For a merge, run `calkit merge --continue`, with any stage choices still
69
+ needed. For a rebase or cherry-pick, run `calkit fix merge-conflicts`,
70
+ then `git rebase --continue` or `git cherry-pick --continue`.
71
+ 3. Run `calkit status`, then `calkit run` if stages are stale.
72
+ 4. Run `calkit check stage-runs` to confirm every stage matches its run.
calkit/cli/__init__.py CHANGED
@@ -19,6 +19,16 @@ def run() -> None:
19
19
  # otherwise print the *current* CLI's help instead of re-execing
20
20
  # under the requested version.
21
21
  _maybe_exec_with_version()
22
+ if sys.platform == "win32":
23
+ import io
24
+
25
+ from calkit.core import is_mintty
26
+
27
+ # mintty shows UTF-8, so emoji needn't be replaced, and output is
28
+ # line buffered as it would be to a terminal
29
+ for stream in (sys.stdout, sys.stderr):
30
+ if isinstance(stream, io.TextIOWrapper) and is_mintty(stream):
31
+ stream.reconfigure(encoding="utf-8", line_buffering=True)
22
32
  from .main import app
23
33
 
24
34
  # Completion lists commands, so needs them all
calkit/cli/core.py CHANGED
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import os
6
6
  import re
7
7
  import subprocess
8
+ import sys
8
9
  from enum import Enum
9
10
  from typing import Any, NoReturn
10
11
 
@@ -152,8 +153,24 @@ def echo_json(obj: Any) -> None:
152
153
  typer.echo(json.dumps(obj, default=str))
153
154
 
154
155
 
156
+ def _echo_above_spinner(text: str, err: bool) -> bool:
157
+ """Print through a spinner's stand-in for the stream, if there is one.
158
+
159
+ Click writes to a Windows console's handle directly, which would put the
160
+ text on the spinner's line rather than above it.
161
+ """
162
+ file_proxy = sys.modules.get("rich.file_proxy")
163
+ stream = sys.stderr if err else sys.stdout
164
+ if file_proxy is None or not isinstance(stream, file_proxy.FileProxy):
165
+ return False
166
+ print(text, file=stream, flush=True)
167
+ return True
168
+
169
+
155
170
  def raise_error(txt: str) -> NoReturn:
156
- typer.echo(typer.style("Error: " + str(txt), fg="red"), err=True)
171
+ text = typer.style("Error: " + str(txt), fg="red")
172
+ if not _echo_above_spinner(text, err=True):
173
+ typer.echo(text, err=True)
157
174
  raise typer.Exit(1)
158
175
 
159
176
 
@@ -164,7 +181,6 @@ def warn(txt: str, prefix: str = "Warning: ", err: bool = False):
164
181
  # defined.
165
182
  from calkit.core import encode_safe
166
183
 
167
- typer.echo(
168
- typer.style(prefix + encode_safe(str(txt), err=err), fg="yellow"),
169
- err=err,
170
- )
184
+ text = typer.style(prefix + encode_safe(str(txt), err=err), fg="yellow")
185
+ if not _echo_above_spinner(text, err=err):
186
+ typer.echo(text, err=err)
calkit/cli/fix.py ADDED
@@ -0,0 +1,196 @@
1
+ """CLI for fixing things."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import posixpath
6
+ import sys
7
+
8
+ import click
9
+ import typer
10
+ from typing_extensions import Annotated
11
+
12
+ from calkit.cli import AliasGroup, raise_error, warn
13
+
14
+ fix_app = typer.Typer(cls=AliasGroup, no_args_is_help=True)
15
+
16
+ OursOpt = Annotated[
17
+ list[str],
18
+ typer.Option(
19
+ "--ours",
20
+ "-o",
21
+ help="Keep our side of this stage (repeatable).",
22
+ show_default=False,
23
+ ),
24
+ ]
25
+ TheirsOpt = Annotated[
26
+ list[str],
27
+ typer.Option(
28
+ "--theirs",
29
+ "-t",
30
+ help="Keep their side of this stage (repeatable).",
31
+ show_default=False,
32
+ ),
33
+ ]
34
+ RerunOpt = Annotated[
35
+ list[str],
36
+ typer.Option(
37
+ "--rerun",
38
+ "-r",
39
+ help="Keep neither side of this stage, so it reruns (repeatable).",
40
+ show_default=False,
41
+ ),
42
+ ]
43
+ PreferOpt = Annotated[
44
+ str | None,
45
+ typer.Option(
46
+ "--prefer",
47
+ help="Choice for every other stage changed on both sides "
48
+ "(ours, theirs, or rerun).",
49
+ ),
50
+ ]
51
+
52
+
53
+ def resolve_merge_conflicts(
54
+ ours: list[str] | None = None,
55
+ theirs: list[str] | None = None,
56
+ rerun: list[str] | None = None,
57
+ prefer: str | None = None,
58
+ ) -> dict:
59
+ """Resolve conflicts in the files Calkit merges, asking for choices.
60
+
61
+ Returns what ``calkit.merge.resolve_conflicts`` does.
62
+ """
63
+ import calkit.git
64
+ import calkit.merge
65
+
66
+ if prefer is not None and prefer not in calkit.merge.CHOICES:
67
+ raise_error(
68
+ f"--prefer must be one of {', '.join(calkit.merge.CHOICES)}"
69
+ )
70
+ choices: dict[str, str] = {}
71
+ for choice, names in [
72
+ (calkit.merge.OURS, ours),
73
+ (calkit.merge.THEIRS, theirs),
74
+ (calkit.merge.RERUN, rerun),
75
+ ]:
76
+ for name in names or []:
77
+ if name in choices and choices[name] != choice:
78
+ raise_error(f"Stage '{name}' was given more than one choice")
79
+ choices[name] = choice
80
+ repo = calkit.git.get_repo()
81
+ operation = calkit.merge.get_operation(repo)
82
+ if operation is None:
83
+ raise_error("No merge, rebase, or cherry-pick is in progress")
84
+ assert operation is not None
85
+ labels = {}
86
+ for side, ref in [("ours", "HEAD"), ("theirs", operation[1])]:
87
+ labels[side] = repo.git.log("-1", "--format=%h %s", ref)
88
+
89
+ def choose(name: str, info: dict) -> str:
90
+ if not sys.stdin.isatty():
91
+ raise_error(
92
+ f"Stage '{name}' changed on both sides; choose with --ours, "
93
+ "--theirs, --rerun, or --prefer"
94
+ )
95
+ typer.echo(f"\nStage '{name}' changed on both sides:")
96
+ for side in ("ours", "theirs"):
97
+ typer.echo(f" {side} ({labels[side]}): {info[side]}")
98
+ if info["changed_deps"]:
99
+ typer.echo(
100
+ " Inputs that differ: " + ", ".join(info["changed_deps"])
101
+ )
102
+ answer = typer.prompt(
103
+ "Keep [o]urs, [t]heirs, or [r]erun it",
104
+ type=click.Choice(["o", "t", "r"]),
105
+ show_choices=False,
106
+ )
107
+ return {"o": "ours", "t": "theirs", "r": "rerun"}[answer]
108
+
109
+ try:
110
+ result = calkit.merge.resolve_conflicts(
111
+ repo, choices=choices, prefer=prefer, choose=choose
112
+ )
113
+ except calkit.merge.MergeError as e:
114
+ raise_error(str(e))
115
+ unknown = sorted(set(choices) - set(result["choices"]))
116
+ for name in unknown:
117
+ warn(
118
+ f"Stage '{name}' wasn't changed on both sides; ignoring its choice"
119
+ )
120
+ for path in result["resolved"]:
121
+ typer.echo(f"Resolved {path}")
122
+ rerun_names = [
123
+ n for n, c in result["choices"].items() if c == calkit.merge.RERUN
124
+ ]
125
+ if rerun_names:
126
+ typer.echo(
127
+ "Run 'calkit run' to rerun " + ", ".join(sorted(rerun_names))
128
+ )
129
+ if result["unmerged"]:
130
+ typer.echo("Still in conflict:")
131
+ for path in result["unmerged"]:
132
+ typer.echo(f" {path}")
133
+ return result
134
+
135
+
136
+ def checkout_dvc_outputs() -> None:
137
+ """Check out DVC-tracked outputs to match dvc.lock."""
138
+ import calkit.dvc
139
+ import calkit.dvc.zip
140
+
141
+ if calkit.dvc.run_dvc_command(["checkout"]) != 0:
142
+ warn(
143
+ "Some DVC-tracked outputs couldn't be checked out; run "
144
+ "'calkit dvc pull' to fetch them"
145
+ )
146
+ calkit.dvc.zip.sync_all(direction="to-workspace")
147
+
148
+
149
+ @fix_app.command(name="merge-conflicts")
150
+ def fix_merge_conflicts(
151
+ ours: OursOpt = [],
152
+ theirs: TheirsOpt = [],
153
+ rerun: RerunOpt = [],
154
+ prefer: PreferOpt = None,
155
+ ) -> None:
156
+ """Resolve merge conflicts in dvc.lock and stage run records.
157
+
158
+ Each stage is merged whole. A stage changed on one side takes that side,
159
+ and one changed on both needs a choice, which also decides its run record
160
+ and Git-tracked outputs. Stages are named as in 'calkit check
161
+ stage-runs', e.g., 'sub/dvc.yaml:stage' for a subproject's.
162
+ """
163
+ result = resolve_merge_conflicts(
164
+ ours=ours, theirs=theirs, rerun=rerun, prefer=prefer
165
+ )
166
+ operation = result["operation"]
167
+ unmerged = result["unmerged"]
168
+ finish = {
169
+ "merge": "calkit merge --continue",
170
+ "squash": "calkit merge --continue",
171
+ "rebase": "git rebase --continue",
172
+ "cherry-pick": "git cherry-pick --continue",
173
+ }[operation]
174
+ # DVC can't read the pipeline while a dvc.yaml is in conflict, and
175
+ # 'calkit merge --continue' checks outputs out after committing anyway
176
+ steps = [finish]
177
+ if any(posixpath.basename(p) == "dvc.yaml" for p in unmerged):
178
+ if operation not in ("merge", "squash"):
179
+ steps.insert(0, "calkit dvc checkout")
180
+ else:
181
+ checkout_dvc_outputs()
182
+ then = " and ".join(f"'{step}'" for step in steps)
183
+ if unmerged:
184
+ raise_error(f"Resolve the rest and 'git add' them, then run {then}")
185
+ typer.echo(f"Run {then} to finish")
186
+
187
+
188
+ @fix_app.command(name="storage")
189
+ def fix_storage() -> None:
190
+ """Move paths to where they're declared to be stored (Git or DVC).
191
+
192
+ Same as 'calkit check storage --fix'.
193
+ """
194
+ from calkit.cli.check import check_storage
195
+
196
+ check_storage(fix=True, as_json=False)
calkit/cli/main/core.py CHANGED
@@ -26,6 +26,7 @@ import typer
26
26
  from typing_extensions import Annotated, Optional
27
27
 
28
28
  import calkit
29
+ import calkit.merge
29
30
  from calkit import (
30
31
  AUTO_IGNORE_PATHS,
31
32
  AUTO_IGNORE_PREFIXES,
@@ -54,6 +55,13 @@ from calkit.cli.config import config_app
54
55
  from calkit.cli.delete import delete_app
55
56
  from calkit.cli.describe import describe_app
56
57
  from calkit.cli.dev import dev_app
58
+ from calkit.cli.fix import (
59
+ OursOpt,
60
+ PreferOpt,
61
+ RerunOpt,
62
+ TheirsOpt,
63
+ fix_app,
64
+ )
57
65
  from calkit.cli.hub import hub_app
58
66
  from calkit.cli.import_ import import_app
59
67
  from calkit.cli.latex import latex_app
@@ -89,6 +97,7 @@ app.add_typer(import_app, name="import", help="Import objects.")
89
97
  app.add_typer(office_app, name="office", help="Work with Microsoft Office.")
90
98
  app.add_typer(update_app, name="update", help="Update objects.")
91
99
  app.add_typer(check_app, name="check", help="Check things.")
100
+ app.add_typer(fix_app, name="fix", help="Fix things.")
92
101
  app.add_typer(latex_app, name="latex|tex", help="Work with LaTeX.")
93
102
  app.add_typer(overleaf_app, name="overleaf|ol", help="Interact with Overleaf.")
94
103
  app.add_typer(hub_app, name="hub|cloud", help="Interact with a Calkit hub.")
@@ -413,6 +422,8 @@ def clone(
413
422
  location = url.split("/")[-1].removesuffix(".git")
414
423
  typer.echo(f"Moving into repo dir: {location}")
415
424
  os.chdir(location)
425
+ # So Git merges dvc.lock and stage run records stage by stage
426
+ calkit.merge.configure_driver()
416
427
  # Setup auth for any Calkit remotes
417
428
  if not no_config_remote:
418
429
  remotes = calkit.dvc.get_remotes()
@@ -542,6 +553,18 @@ def get_status(
542
553
  if isinstance(spinner, Status):
543
554
  spinner.update(text)
544
555
 
556
+ def start_docker() -> None:
557
+ # Asked with the spinner out of the way, as in 'calkit run'
558
+ import calkit.docker
559
+
560
+ if isinstance(spinner, Status):
561
+ spinner.stop()
562
+ try:
563
+ calkit.docker.ensure_ready(interactive=True)
564
+ finally:
565
+ if isinstance(spinner, Status):
566
+ spinner.start()
567
+
545
568
  def get_dvc_data_status() -> dict:
546
569
  # Reuse what the pipeline status computed under the same DVC lock
547
570
  if (
@@ -624,15 +647,23 @@ def get_status(
624
647
  # while, e.g., when environments are checked again, so a spinner shows
625
648
  # until then. It writes to its own copy of the terminal, since what the
626
649
  # environment checks' tools print is discarded.
650
+ # Whether there's someone to ask is worked out before the spinner starts
651
+ can_ask = not as_json and calkit.core.is_tty()
627
652
  spinner: AbstractContextManager = nullcontext()
628
- if not as_json and sys.stderr.isatty():
653
+ mintty = calkit.core.is_mintty(sys.stderr)
654
+ if not as_json and (sys.stderr.isatty() or mintty):
629
655
  import logging
630
656
 
631
657
  from rich.console import Console
632
658
 
633
- spinner = Console(file=os.fdopen(os.dup(2), "w")).status(
634
- "Checking status"
635
- )
659
+ # mintty takes ANSI and UTF-8, though it isn't a console
660
+ spinner = Console(
661
+ file=os.fdopen(
662
+ os.dup(2), "w", encoding="utf-8" if mintty else None
663
+ ),
664
+ force_terminal=mintty or None,
665
+ legacy_windows=False if mintty else None,
666
+ ).status("Checking status")
636
667
  # DVC's progress bars would each be printed above the spinner, and
637
668
  # they're shown only if this is at or below their level
638
669
  logging.getLogger("dvc.progress").setLevel(logging.CRITICAL + 1)
@@ -665,6 +696,7 @@ def get_status(
665
696
  with_storage_problems="dvc" in categories,
666
697
  quiet_environment_checks=True,
667
698
  progress=step,
699
+ start_docker=start_docker if can_ask else None,
668
700
  )
669
701
  if pipeline_status.failed_environment_checks:
670
702
  warn(
@@ -834,6 +866,19 @@ def get_status(
834
866
  if targets:
835
867
  git_cmd += ["--"] + targets
836
868
  run_cmd(git_cmd)
869
+ try:
870
+ repo = calkit.git.get_repo()
871
+ unmerged = calkit.merge.get_unmerged_paths(repo)
872
+ operation = calkit.merge.get_operation(repo)
873
+ except Exception:
874
+ unmerged, operation = [], None
875
+ if operation is not None and any(
876
+ calkit.merge.is_merged_file(p) for p in unmerged
877
+ ):
878
+ typer.echo(
879
+ "Run 'calkit fix merge-conflicts' to resolve the conflicts "
880
+ "in dvc.lock and stage run records"
881
+ )
837
882
  typer.echo()
838
883
  if "dvc" in categories:
839
884
  print_sep("DVC")
@@ -1798,12 +1843,23 @@ def pull(
1798
1843
  dvc_args.append("-f")
1799
1844
  if not no_git:
1800
1845
  typer.echo("Git pulling")
1846
+ # So Git merges dvc.lock and stage run records stage by stage
1847
+ calkit.merge.configure_driver()
1801
1848
  try:
1802
1849
  git_cmd = ["git", "pull"]
1803
1850
  if not no_recursive and "--recurse-submodules" not in git_args:
1804
1851
  git_cmd.append("--recurse-submodules")
1805
1852
  subprocess.check_call(git_cmd + git_args)
1806
1853
  except subprocess.CalledProcessError:
1854
+ repo = calkit.git.get_repo()
1855
+ if calkit.merge.get_operation(repo) is not None and any(
1856
+ calkit.merge.is_merged_file(p)
1857
+ for p in calkit.merge.get_unmerged_paths(repo)
1858
+ ):
1859
+ typer.echo(
1860
+ "Run 'calkit fix merge-conflicts' to resolve the "
1861
+ "conflicts in dvc.lock and stage run records"
1862
+ )
1807
1863
  raise_error("Git pull failed")
1808
1864
  if not no_dvc:
1809
1865
  typer.echo("DVC pulling")
@@ -3112,6 +3168,8 @@ def run(
3112
3168
  subprocess.check_call(["git", "init"])
3113
3169
  except subprocess.CalledProcessError:
3114
3170
  raise_error("Failed to initialize Git repo")
3171
+ # So Git merges dvc.lock and stage run records stage by stage
3172
+ calkit.merge.configure_driver()
3115
3173
  # Set env vars
3116
3174
  calkit.set_env_vars(ck_info=ck_info)
3117
3175
  # Stages that can't run here are skipped, keeping their outputs, and
@@ -3128,12 +3186,18 @@ def run(
3128
3186
  # someone to ask is worked out first
3129
3187
  can_ask = is_tty()
3130
3188
  spinner: Any = None
3131
- if not (quiet or verbose) and sys.stderr.isatty():
3189
+ mintty = calkit.core.is_mintty(sys.stderr)
3190
+ if not (quiet or verbose) and (sys.stderr.isatty() or mintty):
3132
3191
  from rich.console import Console
3133
3192
 
3134
- spinner = Console(file=os.fdopen(os.dup(2), "w")).status(
3135
- "Getting ready to run"
3136
- )
3193
+ # mintty takes ANSI and UTF-8, though it isn't a console
3194
+ spinner = Console(
3195
+ file=os.fdopen(
3196
+ os.dup(2), "w", encoding="utf-8" if mintty else None
3197
+ ),
3198
+ force_terminal=mintty or None,
3199
+ legacy_windows=False if mintty else None,
3200
+ ).status("Getting ready to run")
3137
3201
  with spinner or contextlib.nullcontext():
3138
3202
  step("Getting system information", "💻")
3139
3203
  # Only the stages this run can reach are checked, so nothing else
@@ -4781,7 +4845,7 @@ def install_app(
4781
4845
  ok = _install.prompt_and_install(name, interactive=is_tty())
4782
4846
  if not ok:
4783
4847
  raise_error(f"Failed to install '{name}'")
4784
- typer.echo(f"✅ Installed '{name}'")
4848
+ calkit.echo(f"✅ Installed '{name}'")
4785
4849
 
4786
4850
 
4787
4851
  @app.command(name="xproc|runproc", help="Execute a procedure.")
@@ -5046,6 +5110,133 @@ def switch_branch(
5046
5110
  calkit.dvc.zip.sync_all(direction="to-workspace")
5047
5111
 
5048
5112
 
5113
+ @app.command(name="merge")
5114
+ def merge(
5115
+ branch: Annotated[
5116
+ str | None,
5117
+ typer.Argument(help="Branch or commit to merge into the current one."),
5118
+ ] = None,
5119
+ cont: Annotated[
5120
+ bool,
5121
+ typer.Option(
5122
+ "--continue", help="Finish a merge stopped by conflicts."
5123
+ ),
5124
+ ] = False,
5125
+ abort: Annotated[
5126
+ bool, typer.Option("--abort", help="Abort the merge in progress.")
5127
+ ] = False,
5128
+ squash: Annotated[
5129
+ bool,
5130
+ typer.Option(
5131
+ "--squash", help="Combine the branch's changes into one commit."
5132
+ ),
5133
+ ] = False,
5134
+ message: Annotated[
5135
+ str | None,
5136
+ typer.Option(
5137
+ "--message",
5138
+ "-m",
5139
+ help="Commit message (default: Git's for the merge or squash).",
5140
+ ),
5141
+ ] = None,
5142
+ ours: OursOpt = [],
5143
+ theirs: TheirsOpt = [],
5144
+ rerun: RerunOpt = [],
5145
+ prefer: PreferOpt = None,
5146
+ git_args: Annotated[
5147
+ list[str],
5148
+ typer.Option("--git-arg", help="Additional Git merge args."),
5149
+ ] = [],
5150
+ ) -> None:
5151
+ """Merge a branch, resolving dvc.lock and stage run records per stage.
5152
+
5153
+ A stage changed on both sides needs a choice: ours, theirs, or rerun,
5154
+ which also decides its run record and Git-tracked outputs. DVC-tracked
5155
+ outputs are checked out to match afterwards. Other conflicts are left
5156
+ to resolve before 'calkit merge --continue'.
5157
+ """
5158
+ from calkit.cli.fix import checkout_dvc_outputs, resolve_merge_conflicts
5159
+
5160
+ if sum([branch is not None, cont, abort]) != 1:
5161
+ raise_error("Give a branch, --continue, or --abort")
5162
+ if squash and branch is None:
5163
+ raise_error("--squash needs a branch")
5164
+ repo = calkit.git.get_repo()
5165
+
5166
+ def commit() -> None:
5167
+ cmd = ["git", "commit"] + (
5168
+ ["-m", message] if message else ["--no-edit"]
5169
+ )
5170
+ if subprocess.call(cmd) != 0:
5171
+ raise_error("Git commit failed")
5172
+ checkout_dvc_outputs()
5173
+
5174
+ if abort:
5175
+ op = calkit.merge.get_operation(repo)
5176
+ # A squash merge has no merge to abort, only changes to reset
5177
+ if op is not None and op[0] == "squash":
5178
+ cmd = ["git", "reset", "--merge"]
5179
+ else:
5180
+ cmd = ["git", "merge", "--abort"]
5181
+ if subprocess.call(cmd) != 0:
5182
+ raise_error("Git merge abort failed")
5183
+ checkout_dvc_outputs()
5184
+ return
5185
+ if branch is not None:
5186
+ calkit.merge.configure_driver()
5187
+ cmd = ["git", "merge", "--no-edit"]
5188
+ if squash:
5189
+ cmd.append("--squash")
5190
+ elif message:
5191
+ cmd += ["-m", message]
5192
+ if subprocess.call(cmd + git_args + [branch]) == 0:
5193
+ # A squash merge stops short of committing
5194
+ staged = subprocess.call(["git", "diff", "--cached", "--quiet"])
5195
+ if squash and staged:
5196
+ commit()
5197
+ else:
5198
+ checkout_dvc_outputs()
5199
+ return
5200
+ op = calkit.merge.get_operation(repo)
5201
+ if op is None or op[0] not in ("merge", "squash"):
5202
+ raise_error(
5203
+ "Git merge failed"
5204
+ if branch is not None
5205
+ else "No merge is in progress"
5206
+ )
5207
+ if any(
5208
+ calkit.merge.is_merged_file(p)
5209
+ for p in calkit.merge.get_unmerged_paths(repo)
5210
+ ):
5211
+ result = resolve_merge_conflicts(
5212
+ ours=ours, theirs=theirs, rerun=rerun, prefer=prefer
5213
+ )
5214
+ unmerged = result["unmerged"]
5215
+ else:
5216
+ unmerged = calkit.merge.get_unmerged_paths(repo)
5217
+ if unmerged:
5218
+ typer.echo("Still in conflict:")
5219
+ for path in unmerged:
5220
+ typer.echo(f" {path}")
5221
+ if unmerged:
5222
+ raise_error(
5223
+ "Resolve the conflicts, 'git add' them, then run "
5224
+ "'calkit merge --continue'"
5225
+ )
5226
+ commit()
5227
+
5228
+
5229
+ @app.command(name="merge-driver", hidden=True)
5230
+ def merge_driver(
5231
+ base: Annotated[str, typer.Argument()],
5232
+ ours: Annotated[str, typer.Argument()],
5233
+ theirs: Annotated[str, typer.Argument()],
5234
+ path: Annotated[str, typer.Argument()],
5235
+ ) -> None:
5236
+ """Merge a file whose content is grouped by stage, as Git's merge driver."""
5237
+ raise typer.Exit(calkit.merge.run_driver(base, ours, theirs, path))
5238
+
5239
+
5049
5240
  @app.command(name="stash")
5050
5241
  def stash(
5051
5242
  pop: Annotated[