jj-stack 0.1.0__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 (88) hide show
  1. jj_stack/__init__.py +11 -0
  2. jj_stack/__main__.py +8 -0
  3. jj_stack/bootstrap.py +213 -0
  4. jj_stack/cli.py +1297 -0
  5. jj_stack/cli_help.py +680 -0
  6. jj_stack/commands/__init__.py +1 -0
  7. jj_stack/commands/_cleanup_actions.py +389 -0
  8. jj_stack/commands/_json_status.py +84 -0
  9. jj_stack/commands/checkout.py +668 -0
  10. jj_stack/commands/cleanup/command.py +702 -0
  11. jj_stack/commands/cleanup/shared.py +62 -0
  12. jj_stack/commands/cleanup/stale.py +79 -0
  13. jj_stack/commands/doctor.py +358 -0
  14. jj_stack/commands/in_use.py +31 -0
  15. jj_stack/commands/list_.py +667 -0
  16. jj_stack/commands/merge/__init__.py +1 -0
  17. jj_stack/commands/merge/command.py +385 -0
  18. jj_stack/commands/merge/github_stack.py +333 -0
  19. jj_stack/commands/merge/models.py +97 -0
  20. jj_stack/commands/merge/plan.py +109 -0
  21. jj_stack/commands/merge/preconditions.py +162 -0
  22. jj_stack/commands/merge/render.py +52 -0
  23. jj_stack/commands/relink.py +210 -0
  24. jj_stack/commands/submit/__init__.py +1 -0
  25. jj_stack/commands/submit/auto_close.py +108 -0
  26. jj_stack/commands/submit/changes.py +90 -0
  27. jj_stack/commands/submit/command.py +780 -0
  28. jj_stack/commands/submit/descriptions.py +570 -0
  29. jj_stack/commands/submit/github_stack.py +139 -0
  30. jj_stack/commands/submit/inputs.py +168 -0
  31. jj_stack/commands/submit/models.py +198 -0
  32. jj_stack/commands/submit/overview_comments.py +178 -0
  33. jj_stack/commands/submit/prs.py +461 -0
  34. jj_stack/commands/submit/render.py +129 -0
  35. jj_stack/commands/sync.py +510 -0
  36. jj_stack/commands/sync_apply.py +474 -0
  37. jj_stack/commands/unstack.py +321 -0
  38. jj_stack/commands/view.py +1151 -0
  39. jj_stack/completion.py +484 -0
  40. jj_stack/concurrency.py +93 -0
  41. jj_stack/config.py +170 -0
  42. jj_stack/console.py +708 -0
  43. jj_stack/errors.py +152 -0
  44. jj_stack/formatting.py +95 -0
  45. jj_stack/github/__init__.py +1 -0
  46. jj_stack/github/auth.py +34 -0
  47. jj_stack/github/client.py +1098 -0
  48. jj_stack/github/error_messages.py +89 -0
  49. jj_stack/github/overview_comments.py +35 -0
  50. jj_stack/github/pr_refs.py +70 -0
  51. jj_stack/github/resolution.py +208 -0
  52. jj_stack/github/stack_availability.py +30 -0
  53. jj_stack/identifiers.py +7 -0
  54. jj_stack/jj/__init__.py +1 -0
  55. jj_stack/jj/cli_args.py +23 -0
  56. jj_stack/jj/client.py +1459 -0
  57. jj_stack/jj/colors.py +199 -0
  58. jj_stack/models/__init__.py +1 -0
  59. jj_stack/models/git.py +15 -0
  60. jj_stack/models/github.py +243 -0
  61. jj_stack/models/stack.py +70 -0
  62. jj_stack/models/tracking.py +115 -0
  63. jj_stack/pr_branch_namespace.py +78 -0
  64. jj_stack/stack/__init__.py +1 -0
  65. jj_stack/stack/change_status.py +216 -0
  66. jj_stack/stack/convergence.py +501 -0
  67. jj_stack/stack/convergence_models.py +69 -0
  68. jj_stack/stack/convergence_observation.py +162 -0
  69. jj_stack/stack/github_stack_safety.py +132 -0
  70. jj_stack/stack/global_convergence.py +270 -0
  71. jj_stack/stack/path.py +235 -0
  72. jj_stack/stack/pr_branches.py +126 -0
  73. jj_stack/stack/pr_facts.py +165 -0
  74. jj_stack/stack/repo.py +95 -0
  75. jj_stack/stack/selected.py +358 -0
  76. jj_stack/stack/selection.py +124 -0
  77. jj_stack/stack/status.py +711 -0
  78. jj_stack/stack/trunk_evidence.py +174 -0
  79. jj_stack/state/__init__.py +1 -0
  80. jj_stack/state/operation_lock.py +220 -0
  81. jj_stack/state/store.py +260 -0
  82. jj_stack/ui.py +230 -0
  83. jj_stack-0.1.0.dist-info/METADATA +170 -0
  84. jj_stack-0.1.0.dist-info/RECORD +88 -0
  85. jj_stack-0.1.0.dist-info/WHEEL +4 -0
  86. jj_stack-0.1.0.dist-info/entry_points.txt +2 -0
  87. jj_stack-0.1.0.dist-info/licenses/LICENSE +201 -0
  88. jj_stack-0.1.0.dist-info/licenses/NOTICE +2 -0
@@ -0,0 +1,501 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import jj_stack.ui as ui
6
+ from jj_stack.bootstrap import CommandContext
7
+ from jj_stack.errors import CliError
8
+ from jj_stack.github.resolution import GithubRepoAddress
9
+ from jj_stack.models.github import GithubPR, GithubStack, GithubStackPR
10
+ from jj_stack.models.stack import LocalCommit
11
+ from jj_stack.models.tracking import TrackedPR, TrackingState
12
+ from jj_stack.stack.convergence_models import (
13
+ AdoptedSurvivor,
14
+ ConvergenceActions,
15
+ FinishPR,
16
+ GithubStackMergePlan,
17
+ GithubStackRebasePlan,
18
+ OnTrunkChange,
19
+ OrdinaryConvergencePlan,
20
+ PRFinishPlan,
21
+ SelectedConvergencePlan,
22
+ SkipPRFinish,
23
+ )
24
+ from jj_stack.stack.github_stack_safety import selected_github_stack
25
+ from jj_stack.stack.pr_facts import RepoFacts
26
+ from jj_stack.stack.status import PreparedStatus
27
+ from jj_stack.stack.trunk_evidence import (
28
+ CommitAncestry,
29
+ TrunkEvidenceKind,
30
+ classify_proven_kind,
31
+ )
32
+
33
+
34
+ class CheckedOutMergedChangeError(CliError):
35
+ def __init__(self, message, *, workspaces: tuple[str, ...]) -> None:
36
+ super().__init__(message)
37
+ self.workspaces = workspaces
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class _NoGithubStack:
42
+ pass
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class _GithubStackMerge:
47
+ history: tuple[OnTrunkChange, ...]
48
+ adopted: tuple[AdoptedSurvivor, ...]
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class _GithubStackRebase:
53
+ adopted: tuple[AdoptedSurvivor, ...]
54
+
55
+
56
+ type _GithubStackEffect = _NoGithubStack | _GithubStackMerge | _GithubStackRebase
57
+
58
+
59
+ def build_selected_convergence_plan(
60
+ *,
61
+ ancestries: dict[str, CommitAncestry],
62
+ context: CommandContext,
63
+ github_stacks: tuple[GithubStack, ...],
64
+ observation: RepoFacts,
65
+ prepared_status: PreparedStatus,
66
+ repo: GithubRepoAddress,
67
+ trunk_branch: str,
68
+ ) -> SelectedConvergencePlan:
69
+ selected = prepared_status.prepared.stack.changes
70
+ state = prepared_status.prepared.state
71
+ effect = _classify_github_stack(
72
+ ancestries=ancestries,
73
+ github_stacks=github_stacks,
74
+ observation=observation,
75
+ repo=repo,
76
+ selected=selected,
77
+ state=state,
78
+ trunk_branch=trunk_branch,
79
+ trunk_commit_id=prepared_status.prepared.stack.trunk.commit_id,
80
+ )
81
+ history = effect.history if isinstance(effect, _GithubStackMerge) else ()
82
+ adopted = effect.adopted if not isinstance(effect, _NoGithubStack) else ()
83
+ history_ids = {item.candidate.change_id for item in history}
84
+ active_ids = {item.candidate.change_id for item in adopted}
85
+ on_trunk = list(history)
86
+ survivors: list[LocalCommit] = []
87
+ for change in (item for item in selected if item.change_id not in history_ids):
88
+ candidate = state.tracked_pr(change.change_id)
89
+ evidence_kind = (
90
+ None
91
+ if candidate is None or change.change_id in active_ids
92
+ else _trunk_evidence_kind_for(
93
+ ancestries=ancestries,
94
+ candidate=candidate,
95
+ observation=observation,
96
+ repo=repo,
97
+ )
98
+ )
99
+ if candidate is None or evidence_kind is None:
100
+ survivors.append(change)
101
+ continue
102
+ if survivors:
103
+ raise CliError(
104
+ t"Cannot sync submitted {ui.change_id(change.change_id)} because these "
105
+ t"unmerged local changes are its parents: "
106
+ t"{ui.join(lambda item: ui.change_id(item.change_id), tuple(survivors))}. "
107
+ t"The submitted change is already on fetched trunk, so sync cannot decide "
108
+ t"whether those local changes belong before or after it.\n"
109
+ t"Submitted commit: "
110
+ t"{ui.semantic_text(candidate.submitted_baseline.commit_id, 'commit_id')}\n"
111
+ t"Local copy commit: {ui.semantic_text(change.commit_id, 'commit_id')}\n"
112
+ t"Fetched trunk commit: "
113
+ t"{
114
+ ui.semantic_text(prepared_status.prepared.stack.trunk.commit_id, 'commit_id')
115
+ }",
116
+ hint=t"Inspect the local and fetched histories with "
117
+ t"{
118
+ ui.cmd(f"jj log -r 'trunk() | (trunk()..{selected[-1].commit_id})'")
119
+ }. Choose the intended order with {ui.cmd('jj')}; ask an agent to inspect "
120
+ t"this repo and these commit IDs if useful. Then inspect the remaining "
121
+ t"local pull requests with {ui.cmd('jj-stack view')}. Run "
122
+ t"{ui.cmd('jj-stack sync <head-change-id>')} for a remaining mutable submitted "
123
+ t"head, or {ui.cmd('jj-stack cleanup')} if none remains.",
124
+ )
125
+ on_trunk.append(
126
+ OnTrunkChange(
127
+ candidate=candidate,
128
+ evidence_kind=evidence_kind,
129
+ finish=_finish_plan(candidate, observation, evidence_kind == "exact"),
130
+ change=change,
131
+ )
132
+ )
133
+
134
+ _require_no_unpublished_edits(tuple(on_trunk))
135
+ _require_no_checked_out_merged_changes(tuple(on_trunk), context=context)
136
+ submitted = _submitted_survivors(
137
+ survivors=tuple(survivors),
138
+ state=state,
139
+ observation=observation,
140
+ repo=repo,
141
+ )
142
+ local_head = selected[-1]
143
+ working_copy_children = tuple(
144
+ commit.commit_id
145
+ for commit in context.jj_client.query_descendant_commits((local_head.commit_id,))
146
+ if commit.is_working_copy and commit.empty and commit.parents == (local_head.commit_id,)
147
+ )
148
+ actions = ConvergenceActions(
149
+ on_trunk=tuple(on_trunk),
150
+ submitted_survivors=submitted,
151
+ survivors=tuple(survivors),
152
+ working_copy_children=working_copy_children,
153
+ )
154
+ _require_no_divergent_survivors(actions, adopted=adopted)
155
+ if isinstance(effect, _GithubStackRebase):
156
+ return GithubStackRebasePlan(actions=actions, adopted_survivors=adopted)
157
+ if isinstance(effect, _GithubStackMerge):
158
+ return GithubStackMergePlan(actions=actions, adopted_survivors=adopted)
159
+ return OrdinaryConvergencePlan(actions=actions)
160
+
161
+
162
+ def _submitted_survivors(
163
+ *,
164
+ survivors: tuple[LocalCommit, ...],
165
+ state: TrackingState,
166
+ observation: RepoFacts,
167
+ repo: GithubRepoAddress,
168
+ ) -> tuple[LocalCommit, ...]:
169
+ submitted: list[LocalCommit] = []
170
+ saw_unsubmitted = False
171
+ for change in survivors:
172
+ candidate = state.tracked_pr(change.change_id)
173
+ if candidate is None:
174
+ saw_unsubmitted = True
175
+ continue
176
+ if saw_unsubmitted:
177
+ raise CliError(
178
+ t"Cannot sync because submitted {ui.change_id(change.change_id)} appears "
179
+ t"above an unsubmitted change.",
180
+ hint="Submit the intervening change or select a stack that ends below it.",
181
+ )
182
+ pr = observation.prs[change.change_id].pr
183
+ identity = candidate.pr_identity
184
+ if pr is None or identity.repo_key != repo.repo_key or not identity.matches_pr(pr):
185
+ raise CliError(
186
+ t"The pull request no longer matches saved tracking for "
187
+ t"{ui.change_id(candidate.change_id)}.",
188
+ hint=t"Reattach the intended PR with {ui.cmd('jj-stack relink')}, or forget "
189
+ t"the incorrect link with {ui.cmd('jj-stack unstack --local')} before "
190
+ t"submitting again.",
191
+ )
192
+ lifecycle = pr.normalize_state().state
193
+ if lifecycle != "open":
194
+ raise CliError(
195
+ t"PR #{pr.number} for {ui.change_id(candidate.change_id)} is "
196
+ t"{lifecycle}, so sync cannot update that PR.",
197
+ hint=t"Reopen it on GitHub, or run {ui.cmd('jj-stack cleanup')} before "
198
+ t"submitting again.",
199
+ )
200
+ submitted.append(change)
201
+ return tuple(submitted)
202
+
203
+
204
+ def _trunk_evidence_kind_for(
205
+ *,
206
+ ancestries: dict[str, CommitAncestry],
207
+ candidate: TrackedPR,
208
+ observation: RepoFacts,
209
+ repo: GithubRepoAddress,
210
+ ) -> TrunkEvidenceKind | None:
211
+ observed = observation.prs[candidate.change_id]
212
+ if observed.identity != candidate.pr_identity:
213
+ raise CliError(
214
+ t"Saved PR tracking changed for {ui.change_id(candidate.change_id)}.",
215
+ hint=t"Inspect it with {ui.cmd('jj-stack view')}, then reattach the intended "
216
+ t"PR with {ui.cmd('jj-stack relink')}.",
217
+ )
218
+ pr = observed.pr
219
+ if pr is None:
220
+ raise CliError(
221
+ t"GitHub no longer reports PR #{candidate.pr_identity.pr_number}.",
222
+ hint=t"Confirm it with {ui.cmd('jj-stack view')}, then reattach an open "
223
+ t"replacement with {ui.cmd('jj-stack relink')}, or forget the missing link with "
224
+ t"{ui.cmd('jj-stack unstack --local')} before submitting again.",
225
+ )
226
+ evidence_kind, reason = classify_proven_kind(
227
+ ancestries=ancestries,
228
+ candidate=candidate,
229
+ pr=pr,
230
+ repo=repo,
231
+ )
232
+ if evidence_kind is None and pr.normalize_state().state in {"closed", "merged"}:
233
+ raise CliError(
234
+ t"Cannot remove {ui.change_id(candidate.change_id)}: {reason}.",
235
+ hint="Make GitHub's reported merge commit reachable from trunk, then rerun sync.",
236
+ )
237
+ return evidence_kind
238
+
239
+
240
+ def _require_no_divergent_survivors(
241
+ actions: ConvergenceActions,
242
+ *,
243
+ adopted: tuple[AdoptedSurvivor, ...],
244
+ ) -> None:
245
+ expected_remote_copies = {item.candidate.change_id for item in adopted}
246
+ for change in actions.survivors:
247
+ if change.divergent and change.change_id not in expected_remote_copies:
248
+ raise CliError(
249
+ t"Cannot rebase remaining {ui.change_id(change.change_id)} because it has "
250
+ t"multiple visible commits.",
251
+ hint=t"Resolve the divergence with {ui.cmd('jj')}, then rerun sync for this "
252
+ t"stack.",
253
+ )
254
+
255
+
256
+ def _classify_github_stack(
257
+ *,
258
+ ancestries: dict[str, CommitAncestry],
259
+ github_stacks: tuple[GithubStack, ...],
260
+ observation: RepoFacts,
261
+ repo: GithubRepoAddress,
262
+ selected: tuple[LocalCommit, ...],
263
+ state: TrackingState,
264
+ trunk_branch: str,
265
+ trunk_commit_id: str,
266
+ ) -> _GithubStackEffect:
267
+ selected_by_id = {change.change_id: change for change in selected}
268
+ by_pr = {
269
+ candidate.pr_identity.pr_number: candidate
270
+ for candidate in state.tracked_prs()
271
+ if candidate.pr_identity.repo_key == repo.repo_key
272
+ }
273
+ candidates = tuple(
274
+ candidate
275
+ for change in selected
276
+ if (candidate := state.tracked_pr(change.change_id)) is not None
277
+ )
278
+ prs = {candidate.pr_identity.pr_number for candidate in candidates}
279
+ stack = selected_github_stack(selected_pr_numbers=prs, stacks=github_stacks)
280
+ if stack is None:
281
+ return _NoGithubStack()
282
+ ordered = tuple(number for number in stack.pr_numbers if number in prs)
283
+ if tuple(candidate.pr_identity.pr_number for candidate in candidates) != ordered:
284
+ raise CliError(
285
+ t"Selected PRs do not match GitHub stack #{stack.number}'s ordered members.",
286
+ hint=t"Bring them back into line with {ui.cmd('jj-stack submit')}, or remove the "
287
+ t"grouping with {ui.cmd(f'jj-stack unstack --stack {stack.number}')} and resubmit.",
288
+ )
289
+ merge_mode = _is_stack_merge(stack=stack, by_pr=by_pr, candidates=candidates)
290
+ history: list[OnTrunkChange] = []
291
+ adopted: list[AdoptedSurvivor] = []
292
+ expected_base = trunk_branch
293
+ for member in stack.prs:
294
+ candidate = by_pr.get(member.number)
295
+ if candidate is None:
296
+ continue
297
+ pr = _validated_member(candidate, member, observation)
298
+ if member.is_historical:
299
+ change = selected_by_id.get(candidate.change_id)
300
+ mutable_copies = tuple(
301
+ item
302
+ for item in observation.prs[candidate.change_id].local_commits
303
+ if not item.immutable
304
+ )
305
+ if change is None and len(mutable_copies) > 1:
306
+ raise CliError(
307
+ t"Historical stack member {ui.change_id(candidate.change_id)} has more "
308
+ t"than one mutable local copy.",
309
+ hint=t"Resolve the divergent change with {ui.cmd('jj')}, then rerun sync.",
310
+ )
311
+ kind, reason = classify_proven_kind(
312
+ ancestries=ancestries,
313
+ candidate=candidate,
314
+ pr=pr,
315
+ repo=repo,
316
+ )
317
+ if kind is None:
318
+ raise CliError(
319
+ t"Cannot remove the saved link for stack member PR #{member.number}: "
320
+ t"{reason}.",
321
+ hint="Make GitHub's merge result reachable from trunk, then rerun sync.",
322
+ )
323
+ history.append(
324
+ OnTrunkChange(
325
+ candidate,
326
+ kind,
327
+ SkipPRFinish(candidate),
328
+ change or (mutable_copies[0] if mutable_copies else None),
329
+ )
330
+ )
331
+ continue
332
+ local = selected_by_id[candidate.change_id]
333
+ _validate_active_member(
334
+ candidate=candidate,
335
+ expected_base=expected_base,
336
+ merge_mode=merge_mode,
337
+ member=member,
338
+ observation=observation,
339
+ pr=pr,
340
+ selected_change=local,
341
+ stack=stack,
342
+ )
343
+ adopted.append(AdoptedSurvivor(candidate, local, member.head.sha))
344
+ expected_base = candidate.pr_identity.head_ref
345
+ result = tuple(adopted)
346
+ if not merge_mode:
347
+ if any(
348
+ item.remote_commit_id == item.candidate.submitted_baseline.commit_id
349
+ for item in result
350
+ ):
351
+ raise _unproven_rewrite_error(stack)
352
+ return _GithubStackRebase(result)
353
+ return _GithubStackMerge(tuple(history), result)
354
+
355
+
356
+ def _is_stack_merge(
357
+ *,
358
+ stack: GithubStack,
359
+ by_pr: dict[int, TrackedPR],
360
+ candidates: tuple[TrackedPR, ...],
361
+ ) -> bool:
362
+ merge_mode = any(member.number in by_pr for member in stack.historical_prs)
363
+ if not merge_mode and (
364
+ stack.historical_prs
365
+ or stack.active_pr_numbers
366
+ != tuple(candidate.pr_identity.pr_number for candidate in candidates)
367
+ ):
368
+ raise _unproven_rewrite_error(stack)
369
+ return merge_mode
370
+
371
+
372
+ def _validated_member(
373
+ candidate: TrackedPR,
374
+ member: GithubStackPR,
375
+ observation: RepoFacts,
376
+ ) -> GithubPR:
377
+ observed = observation.prs.get(candidate.change_id)
378
+ pr = observed.pr if observed is not None else None
379
+ identity = candidate.pr_identity
380
+ if (
381
+ observed is None
382
+ or observed.identity != identity
383
+ or pr is None
384
+ or not identity.matches_pr(pr)
385
+ or pr.head.ref != member.head.ref
386
+ ):
387
+ raise CliError(
388
+ t"Stack member PR #{member.number} no longer matches its saved PR identity.",
389
+ hint=t"Reattach it with {ui.cmd('jj-stack relink')}, or forget the incorrect link "
390
+ t"with {ui.cmd('jj-stack unstack --local')} before submitting again.",
391
+ )
392
+ return pr
393
+
394
+
395
+ def _validate_active_member(
396
+ *,
397
+ candidate: TrackedPR,
398
+ expected_base: str,
399
+ merge_mode: bool,
400
+ member: GithubStackPR,
401
+ observation: RepoFacts,
402
+ pr: GithubPR,
403
+ selected_change: LocalCommit,
404
+ stack: GithubStack,
405
+ ) -> None:
406
+ observed = observation.prs[candidate.change_id]
407
+ expected = {selected_change.commit_id, member.head.sha}
408
+ if any(
409
+ not item.immutable and item.commit_id not in expected for item in observed.local_commits
410
+ ):
411
+ raise CliError(
412
+ t"Cannot sync {ui.change_id(candidate.change_id)} because it has more than one "
413
+ t"mutable local copy.",
414
+ hint=t"Resolve the divergence with {ui.cmd('jj')}, then rerun sync for this stack.",
415
+ )
416
+ if selected_change.immutable and selected_change.commit_id != member.head.sha:
417
+ raise CliError(
418
+ t"GitHub still lists PR #{member.number} as active in stack #{stack.number}, but "
419
+ t"{ui.change_id(candidate.change_id)} is already immutable here, so this repo "
420
+ t"cannot tell what GitHub did with it.",
421
+ hint=t"Check GitHub's result with {ui.cmd('jj-stack view')}, then rerun sync once it "
422
+ t"reports the merge.",
423
+ )
424
+ if merge_mode and selected_change.holds_unpublished_edit(
425
+ (candidate.submitted_baseline.commit_id, member.head.sha)
426
+ ):
427
+ raise CliError(
428
+ t"Cannot sync {ui.change_id(candidate.change_id)} because it has unpublished local "
429
+ t"edits since submit.",
430
+ hint=t"Publish them with {ui.cmd('jj-stack submit')}, or drop them, then rerun sync.",
431
+ )
432
+ if pr.head.sha != member.head.sha or observed.remote_pr_branch_target != member.head.sha:
433
+ raise CliError(
434
+ t"Active stack member PR #{member.number} does not match its PR branch.",
435
+ hint=t"Republish the PR with {ui.cmd('jj-stack submit')}, then rerun sync.",
436
+ )
437
+ if not merge_mode and pr.base.ref != expected_base:
438
+ raise CliError(
439
+ t"PR #{member.number} no longer has the base expected for this stack.",
440
+ hint=t"Restore the stack on GitHub, or run "
441
+ t"{ui.cmd(f'jj-stack unstack --stack {stack.number}')} and resubmit it.",
442
+ )
443
+
444
+
445
+ def _unproven_rewrite_error(stack: GithubStack) -> CliError:
446
+ return CliError(
447
+ t"GitHub stack #{stack.number} changed, but none of its merged members is tracked here "
448
+ t"and the whole active stack was not rebased, so jj-stack cannot determine how GitHub "
449
+ t"changed the pull requests.",
450
+ hint=t"Inspect it with {ui.cmd('jj-stack view')}. Restore or resubmit the PR "
451
+ t"branches, then rerun sync.",
452
+ )
453
+
454
+
455
+ def _require_no_unpublished_edits(
456
+ changes: tuple[OnTrunkChange, ...],
457
+ ) -> None:
458
+ for item in changes:
459
+ if item.change is not None and item.change.holds_unpublished_edit(
460
+ (item.candidate.submitted_baseline.commit_id,)
461
+ ):
462
+ raise CliError(
463
+ t"Cannot remove merged {ui.change_id(item.candidate.change_id)} because it has "
464
+ t"unpublished local edits since submit.",
465
+ hint=t"Publish them with {ui.cmd('jj-stack submit')}, or drop them, then rerun "
466
+ t"sync.",
467
+ )
468
+
469
+
470
+ def _finish_plan(
471
+ candidate: TrackedPR,
472
+ observation: RepoFacts,
473
+ allowed: bool,
474
+ ) -> PRFinishPlan:
475
+ pr = observation.prs[candidate.change_id].pr
476
+ if not allowed or pr is None or pr.normalize_state().state != "open":
477
+ return SkipPRFinish(candidate)
478
+ return FinishPR(candidate, pr)
479
+
480
+
481
+ def _require_no_checked_out_merged_changes(
482
+ changes: tuple[OnTrunkChange, ...],
483
+ *,
484
+ context: CommandContext,
485
+ ) -> None:
486
+ for item in changes:
487
+ change = item.change
488
+ if change is None or not change.is_working_copy:
489
+ continue
490
+ workspaces = change.working_copy_workspaces
491
+ if not workspaces:
492
+ location = "the current workspace"
493
+ elif len(workspaces) == 1:
494
+ location = t"workspace {ui.code(workspaces[0])}"
495
+ else:
496
+ location = t"workspaces {ui.join(ui.code, workspaces)}"
497
+ raise CheckedOutMergedChangeError(
498
+ t"Cannot remove merged {ui.change_id(item.candidate.change_id)} because it is "
499
+ t"checked out in {location}.",
500
+ workspaces=workspaces,
501
+ )
@@ -0,0 +1,69 @@
1
+ """Complete side-effect-free plans for selected convergence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from jj_stack.models.github import GithubPR
8
+ from jj_stack.models.stack import LocalCommit
9
+ from jj_stack.models.tracking import TrackedPR
10
+ from jj_stack.stack.trunk_evidence import TrunkEvidenceKind
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class FinishPR:
15
+ candidate: TrackedPR
16
+ pr: GithubPR
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class SkipPRFinish:
21
+ candidate: TrackedPR
22
+
23
+
24
+ type PRFinishPlan = FinishPR | SkipPRFinish
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class OnTrunkChange:
29
+ candidate: TrackedPR
30
+ evidence_kind: TrunkEvidenceKind
31
+ finish: PRFinishPlan
32
+ change: LocalCommit | None
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class ConvergenceActions:
37
+ on_trunk: tuple[OnTrunkChange, ...]
38
+ submitted_survivors: tuple[LocalCommit, ...]
39
+ survivors: tuple[LocalCommit, ...]
40
+ working_copy_children: tuple[str, ...]
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class AdoptedSurvivor:
45
+ candidate: TrackedPR
46
+ local_change: LocalCommit
47
+ remote_commit_id: str
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class OrdinaryConvergencePlan:
52
+ actions: ConvergenceActions
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class GithubStackMergePlan:
57
+ actions: ConvergenceActions
58
+ adopted_survivors: tuple[AdoptedSurvivor, ...]
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class GithubStackRebasePlan:
63
+ actions: ConvergenceActions
64
+ adopted_survivors: tuple[AdoptedSurvivor, ...]
65
+
66
+
67
+ type SelectedConvergencePlan = (
68
+ OrdinaryConvergencePlan | GithubStackMergePlan | GithubStackRebasePlan
69
+ )