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,162 @@
1
+ """Observe the additional repo facts needed to plan selected convergence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+
7
+ from jj_stack.bootstrap import CommandContext
8
+ from jj_stack.github.client import GithubClient
9
+ from jj_stack.github.resolution import GithubRepoAddress
10
+ from jj_stack.models.github import GithubStack
11
+ from jj_stack.models.stack import LocalCommit
12
+ from jj_stack.stack.pr_facts import (
13
+ PRFacts,
14
+ RepoFacts,
15
+ observe_prs,
16
+ )
17
+ from jj_stack.stack.repo import observe_repo_paths
18
+
19
+
20
+ async def complete_sync_observation(
21
+ *,
22
+ context: CommandContext,
23
+ github: GithubClient,
24
+ initial: RepoFacts,
25
+ remote_name: str,
26
+ repo: GithubRepoAddress,
27
+ selected: tuple[LocalCommit, ...],
28
+ stacks: tuple[GithubStack, ...],
29
+ ) -> tuple[RepoFacts, tuple[GithubStack, ...], bool]:
30
+ state = context.state_store.load()
31
+ selected_prs = {
32
+ identity.pr_number
33
+ for change in selected
34
+ if (identity := state.pr_identities.get(change.change_id)) is not None
35
+ }
36
+ affected = tuple(stack for stack in stacks if not selected_prs.isdisjoint(stack.pr_numbers))
37
+ resource_prs = {number for stack in affected for number in stack.pr_numbers}
38
+ tracked_prs = {
39
+ identity.pr_number
40
+ for identity in state.pr_identities.values()
41
+ if identity.repo_key == repo.repo_key
42
+ }
43
+ if not any(
44
+ _pr_changed(observed, include_remote_target=False)
45
+ for change in selected
46
+ if (observed := initial.prs.get(change.change_id)) is not None
47
+ ) and (resource_prs & tracked_prs).issubset(selected_prs):
48
+ return initial, (), False
49
+ change_ids = tuple(
50
+ change_id
51
+ for change_id, identity in state.pr_identities.items()
52
+ if identity.repo_key == repo.repo_key
53
+ and identity.pr_number in resource_prs
54
+ and change_id in state.submitted_baselines
55
+ )
56
+ missing_ids = tuple(change_id for change_id in change_ids if change_id not in initial.prs)
57
+ missing = (
58
+ await observe_prs(
59
+ change_ids=missing_ids,
60
+ context=context,
61
+ github_client=github,
62
+ github_repo_snapshot=initial.github_repo,
63
+ include_remote_targets=False,
64
+ remote_name=remote_name,
65
+ )
66
+ if missing_ids
67
+ else None
68
+ )
69
+ prs = {
70
+ **initial.prs,
71
+ **(missing.prs if missing is not None else {}),
72
+ }
73
+ identities = tuple(item.identity for item in prs.values() if item.identity is not None)
74
+ remote = initial.remote or (missing.remote if missing is not None else None)
75
+ targets = (
76
+ context.jj_client.list_remote_branches(
77
+ remote=remote.name,
78
+ patterns=tuple(f"refs/heads/{item.head_ref}" for item in identities),
79
+ )
80
+ if remote is not None and identities
81
+ else {}
82
+ )
83
+ observation = replace(
84
+ initial,
85
+ prs={
86
+ change_id: replace(
87
+ item,
88
+ remote_pr_branch_target=(
89
+ targets.get(item.identity.head_ref) if item.identity is not None else None
90
+ ),
91
+ )
92
+ for change_id, item in prs.items()
93
+ },
94
+ )
95
+ changed = any(_pr_changed(item) for item in observation.prs.values())
96
+ return observation, stacks if changed else (), changed
97
+
98
+
99
+ def queued_pr_numbers(
100
+ observation: RepoFacts,
101
+ selected: tuple[LocalCommit, ...],
102
+ ) -> tuple[int, ...]:
103
+ return tuple(
104
+ pr.number
105
+ for change in selected
106
+ if (observed := observation.prs.get(change.change_id)) is not None
107
+ and (pr := observed.pr) is not None
108
+ and pr.normalize_state().state == "open"
109
+ and pr.is_queued
110
+ )
111
+
112
+
113
+ def dependent_path_heads(
114
+ *,
115
+ ancestor_commit_ids: tuple[str, ...],
116
+ context: CommandContext,
117
+ excluded_change_ids: frozenset[str],
118
+ ) -> dict[str, tuple[LocalCommit, ...]]:
119
+ if not ancestor_commit_ids:
120
+ return {}
121
+ paths = observe_repo_paths(
122
+ jj_client=context.jj_client,
123
+ descendant_of=ancestor_commit_ids,
124
+ include_working_copies=True,
125
+ state=context.state_store.load(),
126
+ ).paths
127
+ result: dict[str, tuple[LocalCommit, ...]] = {}
128
+ for ancestor in ancestor_commit_ids:
129
+ heads: dict[str, LocalCommit] = {}
130
+ for path in paths:
131
+ if not any(item.commit_id == ancestor for item in path.stack.changes):
132
+ continue
133
+ head = next(
134
+ (
135
+ change
136
+ for change in reversed(path.stack.changes)
137
+ if change.change_id not in excluded_change_ids
138
+ ),
139
+ None,
140
+ )
141
+ if head is not None:
142
+ heads[head.commit_id] = head
143
+ result[ancestor] = tuple(heads.values())
144
+ return result
145
+
146
+
147
+ def _pr_changed(
148
+ observed: PRFacts,
149
+ *,
150
+ include_remote_target: bool = True,
151
+ ) -> bool:
152
+ pr = observed.pr
153
+ if (baseline := observed.baseline) is None:
154
+ return False
155
+ if pr is None:
156
+ return True
157
+ return (
158
+ pr.normalize_state().state == "merged"
159
+ or pr.head.sha != baseline.commit_id
160
+ or (include_remote_target and observed.remote_pr_branch_target != baseline.commit_id)
161
+ or any(commit.immutable for commit in observed.local_commits)
162
+ )
@@ -0,0 +1,132 @@
1
+ """Mutation guards for GitHub stack resources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Collection, Sequence
6
+ from dataclasses import dataclass
7
+
8
+ import jj_stack.ui as ui
9
+ from jj_stack.errors import CliError
10
+ from jj_stack.github.client import GithubClient, GithubClientError
11
+ from jj_stack.models.github import GithubStack
12
+
13
+
14
+ def selected_github_stack(
15
+ *,
16
+ selected_pr_numbers: Collection[int],
17
+ stacks: Sequence[GithubStack],
18
+ ) -> GithubStack | None:
19
+ """Return the one GitHub stack the selected pull requests belong to.
20
+
21
+ The selection may overlap at most one resource, and every active member of that resource
22
+ must be selected. A merged prefix GitHub retains is always valid, so a resource the selection
23
+ touches only through merged members is still returned for callers that reconcile them.
24
+ Callers that mutate a resource separately require a selected pull request to remain active.
25
+ """
26
+
27
+ selected = set(selected_pr_numbers)
28
+ overlapping = tuple(stack for stack in stacks if not selected.isdisjoint(stack.pr_numbers))
29
+ if not overlapping:
30
+ return None
31
+ # Only a resource the selection still has an active member in can be dissolved. GitHub keeps
32
+ # merged members forever, so unstacking a fully merged resource changes nothing, and naming
33
+ # one in a hint would leave the user retrying a command that cannot make progress.
34
+ dissolvable = tuple(
35
+ stack for stack in overlapping if not selected.isdisjoint(stack.active_pr_numbers)
36
+ )
37
+ if len(dissolvable) > 1:
38
+ numbers = tuple(sorted(stack.number for stack in dissolvable))
39
+ raise CliError(
40
+ t"The selected pull requests belong to GitHub stacks "
41
+ t"{ui.join(lambda number: f'#{number}', numbers)}.",
42
+ hint=t"Run "
43
+ t"{ui.join(lambda number: ui.cmd(f'jj-stack unstack --stack {number}'), numbers)}, "
44
+ t"then retry.",
45
+ )
46
+ if not dissolvable and len(overlapping) > 1:
47
+ numbers = tuple(sorted(stack.number for stack in overlapping))
48
+ raise CliError(
49
+ t"The selected pull requests are merged members of GitHub stacks "
50
+ t"{ui.join(lambda number: f'#{number}', numbers)}.",
51
+ hint="Select changes belonging to one of those stacks, then retry.",
52
+ )
53
+ stack = dissolvable[0] if dissolvable else overlapping[0]
54
+ unselected = tuple(number for number in stack.active_pr_numbers if number not in selected)
55
+ if unselected:
56
+ raise CliError(
57
+ t"GitHub stack #{stack.number} keeps "
58
+ t"{ui.join(lambda number: f'#{number}', unselected)} active outside the selected "
59
+ t"stack.",
60
+ hint=t"Select the complete stack, or run "
61
+ t"{ui.cmd(f'jj-stack unstack --stack {stack.number}')}, then retry.",
62
+ )
63
+ return stack
64
+
65
+
66
+ async def dissolve_github_stack(
67
+ *,
68
+ github_client: GithubClient,
69
+ stack: GithubStack,
70
+ ) -> None:
71
+ """Dissolve an observed stack and reject an incomplete mutation result."""
72
+
73
+ try:
74
+ remaining = await github_client.unstack(stack_number=stack.number)
75
+ except GithubClientError as error:
76
+ if error.status_code == 422:
77
+ raise CliError(
78
+ t"GitHub could not remove any pull requests from stack #{stack.number}.",
79
+ hint=t"Resolve its locked pull requests, then retry "
80
+ t"{ui.cmd(f'jj-stack unstack --stack {stack.number}')}",
81
+ ) from None
82
+ raise CliError(t"Could not remove GitHub stack grouping #{stack.number}.") from error
83
+ if remaining is not None and remaining.active_pr_numbers:
84
+ members = ", ".join(f"#{number}" for number in remaining.active_pr_numbers)
85
+ raise CliError(
86
+ t"GitHub stack #{stack.number} still contains {members}.",
87
+ hint=t"Resolve its locked pull requests, then retry "
88
+ t"{ui.cmd(f'jj-stack unstack --stack {stack.number}')}.",
89
+ )
90
+
91
+
92
+ @dataclass(frozen=True, slots=True)
93
+ class GithubStackSelection:
94
+ """Live stack membership for one exact ordered PR selection."""
95
+
96
+ github_client: GithubClient
97
+ pr_numbers: tuple[int, ...]
98
+
99
+ async def observe(self) -> tuple[GithubStack, ...]:
100
+ """Return the current complete GitHub stack resources."""
101
+
102
+ try:
103
+ return await self.github_client.list_stacks()
104
+ except GithubClientError as error:
105
+ raise CliError("Could not inspect GitHub stack membership.") from error
106
+
107
+ async def active_stacks(self) -> tuple[GithubStack, ...]:
108
+ """Return resources in which a selected pull request is still an active member.
109
+
110
+ Only these resources can be dissolved or blocked on: GitHub keeps merged members
111
+ forever, so a resource the selection only touches through them needs no mutation.
112
+ """
113
+
114
+ if not self.pr_numbers:
115
+ return ()
116
+ stacks = await self.observe()
117
+ selected = set(self.pr_numbers)
118
+ return tuple(
119
+ stack for stack in stacks if not selected.isdisjoint(stack.active_pr_numbers)
120
+ )
121
+
122
+ async def require_unstacked(self) -> None:
123
+ """Reject a mutation while a selected pull request is still an active member."""
124
+
125
+ blocking = await self.active_stacks()
126
+ if not blocking:
127
+ return
128
+ stack_number = blocking[0].number
129
+ raise CliError(
130
+ t"GitHub stack #{stack_number} blocks this jj-stack operation.",
131
+ hint=t"Run {ui.cmd(f'jj-stack unstack --stack {stack_number}')} and retry.",
132
+ )
@@ -0,0 +1,270 @@
1
+ """Observe and classify repo-wide convergence without mutating it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+
9
+ import jj_stack.github.resolution as github_resolution
10
+ from jj_stack.bootstrap import CommandContext
11
+ from jj_stack.github.client import GithubClient
12
+ from jj_stack.models.github import GithubPR, GithubStack
13
+ from jj_stack.models.stack import LocalCommit
14
+ from jj_stack.models.tracking import TrackedPR, TrackingState
15
+ from jj_stack.stack.convergence_models import (
16
+ FinishPR,
17
+ PRFinishPlan,
18
+ SkipPRFinish,
19
+ )
20
+ from jj_stack.stack.path import RepoStackPath
21
+ from jj_stack.stack.pr_branches import prepare_visible_pr_snapshots
22
+ from jj_stack.stack.pr_facts import (
23
+ RepoFacts,
24
+ observe_github_stacks,
25
+ observe_prs,
26
+ )
27
+ from jj_stack.stack.repo import observe_repo_paths
28
+ from jj_stack.stack.trunk_evidence import (
29
+ CommitAncestry,
30
+ TrunkEvidenceKind,
31
+ classify_commit_ancestries,
32
+ classify_proven_kind,
33
+ )
34
+ from jj_stack.ui import Message
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class GlobalConvergencePlan:
39
+ blocked: tuple[tuple[TrackedPR, Message], ...]
40
+ finishes: tuple[PRFinishPlan, ...]
41
+ sync_change_ids: tuple[str, ...]
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class GlobalSyncFacts:
46
+ """One repo-wide observation for global classification."""
47
+
48
+ ancestries: Mapping[str, CommitAncestry]
49
+ local_copies: Mapping[str, tuple[LocalCommit, ...]]
50
+ paths: tuple[RepoStackPath, ...]
51
+ pr_facts: RepoFacts
52
+ stacks: tuple[GithubStack, ...]
53
+
54
+
55
+ async def observe_global_sync(
56
+ *,
57
+ context: CommandContext,
58
+ github: GithubClient,
59
+ remote_name: str,
60
+ trunk_commit_id: str,
61
+ ) -> GlobalSyncFacts:
62
+ """Observe tracked pull requests from tracking toward affected local paths."""
63
+
64
+ state = context.state_store.load()
65
+ candidates = state.tracked_prs()
66
+ change_ids = tuple(candidate.change_id for candidate in candidates)
67
+ prepare_visible_pr_snapshots(
68
+ jj_client=context.jj_client,
69
+ state=state,
70
+ )
71
+ all_copies, local_copies = context.jj_client.query_commits_by_change_ids_with_off_trunk(
72
+ change_ids
73
+ )
74
+ anchors = tuple(commit.commit_id for commits in local_copies.values() for commit in commits)
75
+ paths = (
76
+ observe_repo_paths(
77
+ jj_client=context.jj_client,
78
+ descendant_of=anchors,
79
+ exclude_trunk_descendants=True,
80
+ include_working_copies=True,
81
+ state=state,
82
+ ).paths
83
+ if anchors
84
+ else ()
85
+ )
86
+ pr_observations, stacks = await asyncio.gather(
87
+ observe_prs(
88
+ change_ids=change_ids,
89
+ context=context,
90
+ github_client=github,
91
+ include_remote_targets=False,
92
+ local_commits_snapshot=all_copies,
93
+ remote_name=remote_name,
94
+ ),
95
+ observe_github_stacks(github=github),
96
+ )
97
+ commit_ids = _observation_commit_ids(pr_observations)
98
+ return GlobalSyncFacts(
99
+ ancestries=classify_commit_ancestries(
100
+ commit_ids=commit_ids,
101
+ context=context,
102
+ trunk_commit_id=trunk_commit_id,
103
+ ),
104
+ local_copies=local_copies,
105
+ paths=paths,
106
+ pr_facts=pr_observations,
107
+ stacks=stacks,
108
+ )
109
+
110
+
111
+ def build_global_convergence_plan(
112
+ *,
113
+ facts: GlobalSyncFacts,
114
+ repo: github_resolution.GithubRepoAddress,
115
+ state: TrackingState,
116
+ ) -> GlobalConvergencePlan:
117
+ blocked: list[tuple[TrackedPR, Message]] = []
118
+ finishes: list[PRFinishPlan] = []
119
+ heads: list[str] = []
120
+ tracked_prs = frozenset(
121
+ identity.pr_number
122
+ for identity in state.pr_identities.values()
123
+ if identity.repo_key == repo.repo_key
124
+ )
125
+ for candidate in state.tracked_prs():
126
+ reason, finish, candidate_heads = _classify_global_candidate(
127
+ candidate=candidate,
128
+ facts=facts,
129
+ repo=repo,
130
+ tracked_pr_numbers=tracked_prs,
131
+ )
132
+ heads.extend(candidate_heads)
133
+ if reason is not None:
134
+ blocked.append((candidate, reason))
135
+ if finish is not None:
136
+ finishes.append(finish)
137
+ return GlobalConvergencePlan(
138
+ blocked=tuple(blocked),
139
+ finishes=tuple(finishes),
140
+ sync_change_ids=tuple(dict.fromkeys(heads)),
141
+ )
142
+
143
+
144
+ def _classify_global_candidate(
145
+ *,
146
+ candidate: TrackedPR,
147
+ facts: GlobalSyncFacts,
148
+ repo: github_resolution.GithubRepoAddress,
149
+ tracked_pr_numbers: frozenset[int],
150
+ ) -> tuple[Message | None, PRFinishPlan | None, tuple[str, ...]]:
151
+ ancestry = facts.ancestries[candidate.submitted_baseline.commit_id]
152
+ pr = facts.pr_facts.prs[candidate.change_id].pr
153
+ evidence: TrunkEvidenceKind | None = None
154
+ reason: Message = ""
155
+ if pr is not None:
156
+ evidence, reason = classify_proven_kind(
157
+ ancestries=facts.ancestries,
158
+ candidate=candidate,
159
+ pr=pr,
160
+ repo=repo,
161
+ )
162
+ heads = _candidate_path_heads(candidate, facts=facts)
163
+ affected = ancestry == "on_trunk" or evidence == "rewritten"
164
+ if affected:
165
+ return _affected_candidate_plan(
166
+ candidate=candidate,
167
+ evidence=evidence,
168
+ facts=facts,
169
+ heads=heads,
170
+ pr=pr,
171
+ reason=reason,
172
+ tracked_prs=tracked_pr_numbers,
173
+ )
174
+ if pr is None:
175
+ return t"GitHub no longer reports PR #{candidate.pr_identity.pr_number}", None, ()
176
+ if ancestry == "unresolved":
177
+ return "the submitted commit is unavailable locally", None, ()
178
+ if reason and (
179
+ pr.normalize_state().state != "open" or not candidate.pr_identity.matches_pr(pr)
180
+ ):
181
+ return reason, None, ()
182
+ return None, None, ()
183
+
184
+
185
+ def _affected_candidate_plan(
186
+ *,
187
+ candidate: TrackedPR,
188
+ evidence: TrunkEvidenceKind | None,
189
+ facts: GlobalSyncFacts,
190
+ heads: tuple[str, ...] | None,
191
+ pr: GithubPR | None,
192
+ reason: Message,
193
+ tracked_prs: frozenset[int],
194
+ ) -> tuple[Message | None, PRFinishPlan | None, tuple[str, ...]]:
195
+ if heads is None:
196
+ return "local history is not a supported stack", None, ()
197
+ if heads:
198
+ return None, None, heads
199
+ if pr is None:
200
+ return t"GitHub no longer reports PR #{candidate.pr_identity.pr_number}", None, ()
201
+ if evidence is None:
202
+ return reason, None, ()
203
+ stack_reason, historical = _detached_stack_blocker(
204
+ candidate=candidate,
205
+ stacks=facts.stacks,
206
+ tracked_pr_numbers=tracked_prs,
207
+ )
208
+ if stack_reason is not None:
209
+ return stack_reason, None, ()
210
+ finish = (
211
+ SkipPRFinish(candidate)
212
+ if evidence == "rewritten" or historical or pr.normalize_state().state != "open"
213
+ else FinishPR(candidate, pr)
214
+ )
215
+ return None, finish, ()
216
+
217
+
218
+ def _candidate_path_heads(
219
+ candidate: TrackedPR, *, facts: GlobalSyncFacts
220
+ ) -> tuple[str, ...] | None:
221
+ copies = {commit.commit_id for commit in facts.local_copies[candidate.change_id]}
222
+ if not copies:
223
+ return ()
224
+ heads = tuple(
225
+ path.stack.head.change_id
226
+ for path in facts.paths
227
+ if any(change.commit_id in copies for change in path.stack.changes)
228
+ )
229
+ return heads or None
230
+
231
+
232
+ def _detached_stack_blocker(
233
+ *,
234
+ candidate: TrackedPR,
235
+ stacks: tuple[GithubStack, ...],
236
+ tracked_pr_numbers: frozenset[int],
237
+ ) -> tuple[Message | None, bool]:
238
+ number = candidate.pr_identity.pr_number
239
+ matching = tuple(
240
+ member for stack in stacks for member in stack.prs if member.number == number
241
+ )
242
+ if not matching:
243
+ return None, False
244
+ if not matching[0].is_historical:
245
+ return t"GitHub still lists PR #{number} as an active member of its stack", False
246
+ blocked = any(
247
+ number in stack.pr_numbers
248
+ and not set(stack.active_pr_numbers).isdisjoint(tracked_pr_numbers)
249
+ for stack in stacks
250
+ )
251
+ return (
252
+ (
253
+ t"PR #{number} is in a GitHub stack that still has active members tracked here"
254
+ if blocked
255
+ else None
256
+ ),
257
+ True,
258
+ )
259
+
260
+
261
+ def _observation_commit_ids(observation: RepoFacts) -> tuple[str, ...]:
262
+ return tuple(
263
+ commit_id
264
+ for item in observation.prs.values()
265
+ for commit_id in (
266
+ item.baseline.commit_id if item.baseline is not None else None,
267
+ item.pr.merge_commit_sha if item.pr is not None else None,
268
+ )
269
+ if commit_id is not None
270
+ )