calkit-python 0.49.4__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 (25) hide show
  1. calkit/agent_skills/resolve-merge-conflicts/SKILL.md +72 -0
  2. calkit/cli/fix.py +196 -0
  3. calkit/cli/main/core.py +164 -0
  4. calkit/merge.py +636 -0
  5. calkit/tests/cli/main/test_core.py +78 -0
  6. calkit/tests/cli/test_fix.py +174 -0
  7. calkit/tests/test_merge.py +182 -0
  8. {calkit_python-0.49.4.dist-info → calkit_python-0.49.5.dist-info}/METADATA +1 -1
  9. {calkit_python-0.49.4.dist-info → calkit_python-0.49.5.dist-info}/RECORD +25 -20
  10. {calkit_python-0.49.4.data → calkit_python-0.49.5.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  11. {calkit_python-0.49.4.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  12. {calkit_python-0.49.4.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  13. {calkit_python-0.49.4.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  14. {calkit_python-0.49.4.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
  15. {calkit_python-0.49.4.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
  16. {calkit_python-0.49.4.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
  17. {calkit_python-0.49.4.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
  18. {calkit_python-0.49.4.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
  19. {calkit_python-0.49.4.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
  20. {calkit_python-0.49.4.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
  21. {calkit_python-0.49.4.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  22. {calkit_python-0.49.4.data → calkit_python-0.49.5.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  23. {calkit_python-0.49.4.dist-info → calkit_python-0.49.5.dist-info}/WHEEL +0 -0
  24. {calkit_python-0.49.4.dist-info → calkit_python-0.49.5.dist-info}/entry_points.txt +0 -0
  25. {calkit_python-0.49.4.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/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()
@@ -855,6 +866,19 @@ def get_status(
855
866
  if targets:
856
867
  git_cmd += ["--"] + targets
857
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
+ )
858
882
  typer.echo()
859
883
  if "dvc" in categories:
860
884
  print_sep("DVC")
@@ -1819,12 +1843,23 @@ def pull(
1819
1843
  dvc_args.append("-f")
1820
1844
  if not no_git:
1821
1845
  typer.echo("Git pulling")
1846
+ # So Git merges dvc.lock and stage run records stage by stage
1847
+ calkit.merge.configure_driver()
1822
1848
  try:
1823
1849
  git_cmd = ["git", "pull"]
1824
1850
  if not no_recursive and "--recurse-submodules" not in git_args:
1825
1851
  git_cmd.append("--recurse-submodules")
1826
1852
  subprocess.check_call(git_cmd + git_args)
1827
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
+ )
1828
1863
  raise_error("Git pull failed")
1829
1864
  if not no_dvc:
1830
1865
  typer.echo("DVC pulling")
@@ -3133,6 +3168,8 @@ def run(
3133
3168
  subprocess.check_call(["git", "init"])
3134
3169
  except subprocess.CalledProcessError:
3135
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()
3136
3173
  # Set env vars
3137
3174
  calkit.set_env_vars(ck_info=ck_info)
3138
3175
  # Stages that can't run here are skipped, keeping their outputs, and
@@ -5073,6 +5110,133 @@ def switch_branch(
5073
5110
  calkit.dvc.zip.sync_all(direction="to-workspace")
5074
5111
 
5075
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
+
5076
5240
  @app.command(name="stash")
5077
5241
  def stash(
5078
5242
  pop: Annotated[