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.
- calkit/agent_skills/resolve-merge-conflicts/SKILL.md +72 -0
- calkit/cli/__init__.py +10 -0
- calkit/cli/core.py +21 -5
- calkit/cli/fix.py +196 -0
- calkit/cli/main/core.py +200 -9
- calkit/cli/overleaf.py +136 -19
- calkit/cli/update.py +1 -1
- calkit/core.py +49 -0
- calkit/environments.py +50 -20
- calkit/hub.py +3 -1
- calkit/merge.py +636 -0
- calkit/models/pipeline.py +5 -0
- calkit/overleaf.py +2 -1
- calkit/pipeline.py +30 -8
- calkit/tests/cli/main/test_core.py +78 -0
- calkit/tests/cli/test_core.py +22 -0
- calkit/tests/cli/test_fix.py +174 -0
- calkit/tests/cli/test_overleaf.py +135 -6
- calkit/tests/models/test_pipeline.py +14 -0
- calkit/tests/test_core.py +75 -0
- calkit/tests/test_environments.py +28 -1
- calkit/tests/test_merge.py +182 -0
- calkit/tests/test_pipeline.py +41 -0
- calkit/upgrade.py +3 -1
- {calkit_python-0.49.3.dist-info → calkit_python-0.49.5.dist-info}/METADATA +1 -1
- {calkit_python-0.49.3.dist-info → calkit_python-0.49.5.dist-info}/RECORD +42 -37
- {calkit_python-0.49.3.data → calkit_python-0.49.5.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
- {calkit_python-0.49.3.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
- {calkit_python-0.49.3.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
- {calkit_python-0.49.3.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
- {calkit_python-0.49.3.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
- {calkit_python-0.49.3.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
- {calkit_python-0.49.3.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
- {calkit_python-0.49.3.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
- {calkit_python-0.49.3.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
- {calkit_python-0.49.3.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
- {calkit_python-0.49.3.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
- {calkit_python-0.49.3.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
- {calkit_python-0.49.3.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
- {calkit_python-0.49.3.dist-info → calkit_python-0.49.5.dist-info}/WHEEL +0 -0
- {calkit_python-0.49.3.dist-info → calkit_python-0.49.5.dist-info}/entry_points.txt +0 -0
- {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.
|
|
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.
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
634
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3135
|
-
|
|
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
|
-
|
|
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[
|