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.
- jj_stack/__init__.py +11 -0
- jj_stack/__main__.py +8 -0
- jj_stack/bootstrap.py +213 -0
- jj_stack/cli.py +1297 -0
- jj_stack/cli_help.py +680 -0
- jj_stack/commands/__init__.py +1 -0
- jj_stack/commands/_cleanup_actions.py +389 -0
- jj_stack/commands/_json_status.py +84 -0
- jj_stack/commands/checkout.py +668 -0
- jj_stack/commands/cleanup/command.py +702 -0
- jj_stack/commands/cleanup/shared.py +62 -0
- jj_stack/commands/cleanup/stale.py +79 -0
- jj_stack/commands/doctor.py +358 -0
- jj_stack/commands/in_use.py +31 -0
- jj_stack/commands/list_.py +667 -0
- jj_stack/commands/merge/__init__.py +1 -0
- jj_stack/commands/merge/command.py +385 -0
- jj_stack/commands/merge/github_stack.py +333 -0
- jj_stack/commands/merge/models.py +97 -0
- jj_stack/commands/merge/plan.py +109 -0
- jj_stack/commands/merge/preconditions.py +162 -0
- jj_stack/commands/merge/render.py +52 -0
- jj_stack/commands/relink.py +210 -0
- jj_stack/commands/submit/__init__.py +1 -0
- jj_stack/commands/submit/auto_close.py +108 -0
- jj_stack/commands/submit/changes.py +90 -0
- jj_stack/commands/submit/command.py +780 -0
- jj_stack/commands/submit/descriptions.py +570 -0
- jj_stack/commands/submit/github_stack.py +139 -0
- jj_stack/commands/submit/inputs.py +168 -0
- jj_stack/commands/submit/models.py +198 -0
- jj_stack/commands/submit/overview_comments.py +178 -0
- jj_stack/commands/submit/prs.py +461 -0
- jj_stack/commands/submit/render.py +129 -0
- jj_stack/commands/sync.py +510 -0
- jj_stack/commands/sync_apply.py +474 -0
- jj_stack/commands/unstack.py +321 -0
- jj_stack/commands/view.py +1151 -0
- jj_stack/completion.py +484 -0
- jj_stack/concurrency.py +93 -0
- jj_stack/config.py +170 -0
- jj_stack/console.py +708 -0
- jj_stack/errors.py +152 -0
- jj_stack/formatting.py +95 -0
- jj_stack/github/__init__.py +1 -0
- jj_stack/github/auth.py +34 -0
- jj_stack/github/client.py +1098 -0
- jj_stack/github/error_messages.py +89 -0
- jj_stack/github/overview_comments.py +35 -0
- jj_stack/github/pr_refs.py +70 -0
- jj_stack/github/resolution.py +208 -0
- jj_stack/github/stack_availability.py +30 -0
- jj_stack/identifiers.py +7 -0
- jj_stack/jj/__init__.py +1 -0
- jj_stack/jj/cli_args.py +23 -0
- jj_stack/jj/client.py +1459 -0
- jj_stack/jj/colors.py +199 -0
- jj_stack/models/__init__.py +1 -0
- jj_stack/models/git.py +15 -0
- jj_stack/models/github.py +243 -0
- jj_stack/models/stack.py +70 -0
- jj_stack/models/tracking.py +115 -0
- jj_stack/pr_branch_namespace.py +78 -0
- jj_stack/stack/__init__.py +1 -0
- jj_stack/stack/change_status.py +216 -0
- jj_stack/stack/convergence.py +501 -0
- jj_stack/stack/convergence_models.py +69 -0
- jj_stack/stack/convergence_observation.py +162 -0
- jj_stack/stack/github_stack_safety.py +132 -0
- jj_stack/stack/global_convergence.py +270 -0
- jj_stack/stack/path.py +235 -0
- jj_stack/stack/pr_branches.py +126 -0
- jj_stack/stack/pr_facts.py +165 -0
- jj_stack/stack/repo.py +95 -0
- jj_stack/stack/selected.py +358 -0
- jj_stack/stack/selection.py +124 -0
- jj_stack/stack/status.py +711 -0
- jj_stack/stack/trunk_evidence.py +174 -0
- jj_stack/state/__init__.py +1 -0
- jj_stack/state/operation_lock.py +220 -0
- jj_stack/state/store.py +260 -0
- jj_stack/ui.py +230 -0
- jj_stack-0.1.0.dist-info/METADATA +170 -0
- jj_stack-0.1.0.dist-info/RECORD +88 -0
- jj_stack-0.1.0.dist-info/WHEEL +4 -0
- jj_stack-0.1.0.dist-info/entry_points.txt +2 -0
- jj_stack-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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
|
+
)
|