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,358 @@
1
+ """Observe and project one ordinary selected stack path."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+
8
+ import jj_stack.ui as ui
9
+ from jj_stack.errors import CliError
10
+ from jj_stack.jj.client import (
11
+ JjClient,
12
+ JjCommandError,
13
+ UnsupportedStackError,
14
+ divergent_change_id_from_error,
15
+ )
16
+ from jj_stack.models.stack import LocalCommit
17
+ from jj_stack.models.tracking import TrackingState
18
+ from jj_stack.stack.path import (
19
+ SelectedPathObservation,
20
+ SelectedStackPath,
21
+ project_selected_path,
22
+ )
23
+ from jj_stack.stack.pr_branches import prepare_visible_pr_snapshots
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class _ObservedPathRow:
28
+ """One commit with named membership in the path observation revsets."""
29
+
30
+ commit: LocalCommit
31
+ is_trunk: bool
32
+ is_selector: bool
33
+ is_linked_selector: bool
34
+ is_candidate: bool
35
+ is_path: bool
36
+ is_trunk_path: bool
37
+
38
+
39
+ def select_stack_path(
40
+ *,
41
+ inspection_mode: bool = False,
42
+ jj_client: JjClient,
43
+ state: TrackingState,
44
+ revset: str | None = None,
45
+ ) -> SelectedStackPath:
46
+ """Collect the bounded facts for one selector and project its parent path."""
47
+
48
+ prepare_visible_pr_snapshots(jj_client=jj_client, state=state)
49
+
50
+ if revset is None:
51
+ selector = "@ | @-"
52
+ selected_revset = "@"
53
+ select_mutable_copy = False
54
+ elif _is_full_change_id(revset):
55
+ selector = _change_id_revset(revset)
56
+ selected_revset = revset
57
+ select_mutable_copy = True
58
+ else:
59
+ selector = revset
60
+ selected_revset = revset
61
+ select_mutable_copy = False
62
+
63
+ try:
64
+ rows = _observe_path_rows(
65
+ jj_client=jj_client,
66
+ selector=selector,
67
+ selected_revset=revset,
68
+ )
69
+ except JjCommandError as error:
70
+ if revset is None or divergent_change_id_from_error(error) != revset:
71
+ raise
72
+ selector = _change_id_revset(revset)
73
+ select_mutable_copy = True
74
+ rows = _observe_path_rows(
75
+ jj_client=jj_client,
76
+ selector=selector,
77
+ selected_revset=revset,
78
+ )
79
+ path = _project_rows(
80
+ rows=rows,
81
+ selected_revset=selected_revset,
82
+ select_mutable_copy=select_mutable_copy,
83
+ selector_commits=tuple(row.commit for row in rows if row.is_selector),
84
+ state=state,
85
+ use_default=revset is None,
86
+ inspection_mode=inspection_mode,
87
+ )
88
+ if revset is None and path.stack.head.current_working_copy:
89
+ return _replace_selected_revset(path, "@")
90
+ if revset is None:
91
+ return _replace_selected_revset(path, "@-")
92
+ return path
93
+
94
+
95
+ def select_stack_path_containing_change(
96
+ *,
97
+ inspection_mode: bool = False,
98
+ change_id: str,
99
+ jj_client: JjClient,
100
+ state: TrackingState,
101
+ ) -> SelectedStackPath:
102
+ """Project the unique ordinary path whose head descends from one tracked change."""
103
+
104
+ prepare_visible_pr_snapshots(jj_client=jj_client, state=state)
105
+ linked_selector = _change_id_revset(change_id)
106
+ trunk_path = "first_ancestors(trunk())"
107
+ nonempty_descendants = f"((({linked_selector}) ~ {trunk_path}):: ~ {trunk_path}) ~ empty()"
108
+ selected_empty_change = f"({linked_selector}) & empty()"
109
+ containing_heads = f"heads(({nonempty_descendants}) | ({selected_empty_change}))"
110
+ rows = _observe_path_rows(
111
+ jj_client=jj_client,
112
+ linked_selector=linked_selector,
113
+ selector=containing_heads,
114
+ selected_revset=change_id,
115
+ )
116
+ selected_change_path = _project_rows(
117
+ rows=rows,
118
+ selected_revset=change_id,
119
+ select_mutable_copy=True,
120
+ selector_commits=tuple(row.commit for row in rows if row.is_linked_selector),
121
+ state=state,
122
+ use_default=False,
123
+ inspection_mode=inspection_mode,
124
+ )
125
+ heads = tuple(row.commit for row in rows if row.is_selector)
126
+ containing_heads = _heads_containing_commit(
127
+ commit_id=selected_change_path.stack.head.commit_id,
128
+ heads=heads,
129
+ commits=tuple(row.commit for row in rows),
130
+ )
131
+ selected_revset = containing_heads[0].change_id if len(containing_heads) == 1 else change_id
132
+ return _project_rows(
133
+ candidate_commit_ids=frozenset(head.commit_id for head in containing_heads),
134
+ rows=rows,
135
+ selected_revset=selected_revset,
136
+ select_mutable_copy=False,
137
+ selector_commits=tuple(row.commit for row in rows if row.is_selector),
138
+ state=state,
139
+ use_default=False,
140
+ inspection_mode=inspection_mode,
141
+ )
142
+
143
+
144
+ def require_submittable_changes(changes: tuple[LocalCommit, ...]) -> None:
145
+ """Require the ordinary mutable changes accepted for publishing or relinking."""
146
+
147
+ for change in changes:
148
+ if change.immutable:
149
+ raise UnsupportedStackError.stack_shape(
150
+ change.change_id,
151
+ "immutable changes are not submittable.",
152
+ reason="immutable_commit",
153
+ )
154
+ if change.divergent:
155
+ raise UnsupportedStackError.stack_shape(
156
+ change.change_id,
157
+ "divergent changes are not supported.",
158
+ reason="divergent_change",
159
+ )
160
+
161
+
162
+ def _observe_path_rows(
163
+ *,
164
+ jj_client: JjClient,
165
+ linked_selector: str | None = None,
166
+ selector: str,
167
+ selected_revset: str | None,
168
+ ) -> tuple[_ObservedPathRow, ...]:
169
+ trunk_path = "first_ancestors(trunk())"
170
+ off_trunk = f"({selector}) ~ {trunk_path}"
171
+ ancestors = f"first_ancestors({off_trunk})"
172
+ trunk_boundaries = f"parents(({ancestors}) ~ {trunk_path}) & {trunk_path}"
173
+ candidate_neighborhood = f"(visible() & (({selector}) | children({selector})))"
174
+ candidate_commits = (
175
+ f"((({candidate_neighborhood}) ~ {trunk_path} ~ working_copies()) "
176
+ f"| (@ & {candidate_neighborhood}))"
177
+ )
178
+ linked_selector_membership = linked_selector or "none()"
179
+ query = " | ".join(
180
+ (
181
+ "trunk()",
182
+ f"({selector})",
183
+ f"({ancestors}) ~ {trunk_path}",
184
+ trunk_boundaries,
185
+ candidate_commits,
186
+ *((linked_selector,) if linked_selector is not None else ()),
187
+ )
188
+ )
189
+ raw_rows = jj_client.query_commits_with_membership(
190
+ query,
191
+ membership_revsets=(
192
+ "trunk()",
193
+ selector,
194
+ linked_selector_membership,
195
+ candidate_commits,
196
+ ancestors,
197
+ trunk_path,
198
+ ),
199
+ selected_revset=selected_revset,
200
+ )
201
+ return tuple(
202
+ _ObservedPathRow(
203
+ commit=commit,
204
+ is_trunk=is_trunk,
205
+ is_selector=is_selector,
206
+ is_linked_selector=is_linked_selector,
207
+ is_candidate=is_candidate,
208
+ is_path=is_path,
209
+ is_trunk_path=is_trunk_path,
210
+ )
211
+ for commit, (
212
+ is_trunk,
213
+ is_selector,
214
+ is_linked_selector,
215
+ is_candidate,
216
+ is_path,
217
+ is_trunk_path,
218
+ ) in raw_rows
219
+ )
220
+
221
+
222
+ def _project_rows(
223
+ *,
224
+ candidate_commit_ids: frozenset[str] | None = None,
225
+ inspection_mode: bool,
226
+ rows: tuple[_ObservedPathRow, ...],
227
+ selected_revset: str,
228
+ select_mutable_copy: bool,
229
+ selector_commits: tuple[LocalCommit, ...],
230
+ state: TrackingState,
231
+ use_default: bool,
232
+ ) -> SelectedStackPath:
233
+ trunks = tuple(row.commit for row in rows if row.is_trunk)
234
+ if len(trunks) != 1:
235
+ raise CliError(t"Could not resolve {ui.revset('trunk()')} to one commit.")
236
+ trunk = trunks[0]
237
+ if not trunk.parents:
238
+ raise UnsupportedStackError(
239
+ "No trunk bookmark is configured for this repo.",
240
+ hint=t"Create a trunk bookmark such as {ui.bookmark('main')}, then retry.",
241
+ reason="trunk_resolved_to_root",
242
+ )
243
+
244
+ candidates = tuple(
245
+ commit
246
+ for commit in selector_commits
247
+ if candidate_commit_ids is None or commit.commit_id in candidate_commit_ids
248
+ )
249
+ if not candidates:
250
+ raise CliError(
251
+ t"Revset {ui.revset(selected_revset)} did not resolve to a visible commit."
252
+ )
253
+ current_working_copy_commit_id = (
254
+ next(
255
+ (commit.commit_id for commit in candidates if commit.current_working_copy),
256
+ None,
257
+ )
258
+ if use_default
259
+ else None
260
+ )
261
+ path_commits = tuple(row.commit for row in rows if row.is_path)
262
+ if not inspection_mode and any(len(commit.parents) > 1 for commit in path_commits):
263
+ raise UnsupportedStackError(
264
+ "Unsupported stack shape: merge changes are not supported.",
265
+ reason="merge_commit",
266
+ )
267
+ if any(not commit.parents for commit in path_commits):
268
+ raise UnsupportedStackError(
269
+ t"Unsupported stack shape: selected-parent path reached the root commit before "
270
+ t"{ui.revset('trunk()')}.",
271
+ reason="reached_root_before_trunk",
272
+ )
273
+ path = project_selected_path(
274
+ SelectedPathObservation(
275
+ candidate_commit_ids=frozenset(
276
+ row.commit.commit_id for row in rows if row.is_candidate
277
+ ),
278
+ current_working_copy_commit_id=current_working_copy_commit_id,
279
+ fetched_trunk_commit_ids=frozenset(
280
+ row.commit.commit_id for row in rows if row.is_trunk_path
281
+ ),
282
+ commits=tuple(row.commit for row in rows),
283
+ selected_revset=selected_revset,
284
+ selector_commits=candidates,
285
+ select_mutable_copy=select_mutable_copy,
286
+ trunk=trunk,
287
+ )
288
+ )
289
+ _validate_selected_path(path, inspection_mode=inspection_mode)
290
+ return path
291
+
292
+
293
+ def _heads_containing_commit(
294
+ *,
295
+ commit_id: str,
296
+ heads: tuple[LocalCommit, ...],
297
+ commits: tuple[LocalCommit, ...],
298
+ ) -> tuple[LocalCommit, ...]:
299
+ commits_by_id = {commit.commit_id: commit for commit in commits}
300
+ containing: list[LocalCommit] = []
301
+ for head in heads:
302
+ current = head
303
+ while current.commit_id != commit_id and current.parents:
304
+ parent = commits_by_id.get(current.parents[0])
305
+ if parent is None:
306
+ break
307
+ current = parent
308
+ if current.commit_id == commit_id:
309
+ containing.append(head)
310
+ return tuple(containing)
311
+
312
+
313
+ def _validate_selected_path(
314
+ path: SelectedStackPath,
315
+ *,
316
+ inspection_mode: bool,
317
+ ) -> None:
318
+ if inspection_mode:
319
+ return
320
+ for change in path.stack.changes:
321
+ if change.is_working_copy and change.empty:
322
+ raise UnsupportedStackError.stack_shape(
323
+ change.change_id,
324
+ "empty working-copy changes are not submittable.",
325
+ reason="empty_working_copy",
326
+ )
327
+ if change.is_working_copy and not change.description.strip():
328
+ raise UnsupportedStackError.stack_shape(
329
+ change.change_id,
330
+ t"describe it with {ui.cmd('jj describe')} before submitting it.",
331
+ reason="undescribed_working_copy",
332
+ )
333
+
334
+
335
+ def _replace_selected_revset(
336
+ path: SelectedStackPath,
337
+ selected_revset: str,
338
+ ) -> SelectedStackPath:
339
+ return SelectedStackPath(
340
+ is_maximal=path.is_maximal,
341
+ stack=path.stack.model_copy(update={"selected_revset": selected_revset}),
342
+ )
343
+
344
+
345
+ def _change_id_revset(change_id: str) -> str:
346
+ return f"change_id({json.dumps(change_id)})"
347
+
348
+
349
+ def _is_full_change_id(value: str) -> bool:
350
+ return len(value) == 32 and is_change_id_prefix(value)
351
+
352
+
353
+ def is_change_id_prefix(value: str | None) -> bool:
354
+ """Return whether a bare selector has jj change-ID syntax."""
355
+
356
+ return (
357
+ value is not None and bool(value) and all("k" <= character <= "z" for character in value)
358
+ )
@@ -0,0 +1,124 @@
1
+ """Change and pull-request selection helpers for command modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ import jj_stack.ui as ui
8
+ from jj_stack.errors import AmbiguousSelectionError, CliError, UsageError
9
+ from jj_stack.github.pr_refs import (
10
+ parse_pr_number,
11
+ parse_repo_pr_reference,
12
+ )
13
+ from jj_stack.github.resolution import parse_github_repo, select_submit_remote
14
+ from jj_stack.jj.client import JjClient
15
+ from jj_stack.state.store import TrackingStore
16
+
17
+
18
+ def resolve_selected_revset(
19
+ *,
20
+ command_label: str,
21
+ default_revset: str | None = None,
22
+ require_explicit: bool,
23
+ revset: str | None,
24
+ ) -> str | None:
25
+ """Resolve an optional `<revset>` for change-oriented commands."""
26
+
27
+ if revset is not None:
28
+ return revset
29
+ if require_explicit:
30
+ raise UsageError(t"{ui.cmd(command_label)} requires an explicit change selection.")
31
+ return default_revset
32
+
33
+
34
+ def parse_comma_separated_flag_values(
35
+ values: Sequence[str] | None,
36
+ ) -> list[str] | None:
37
+ """Parse repeated comma-separated flag values into a deduplicated list."""
38
+
39
+ if values is None:
40
+ return None
41
+
42
+ parsed_values: list[str] = []
43
+ seen: set[str] = set()
44
+ for value in values:
45
+ for item in value.split(","):
46
+ normalized = item.strip()
47
+ if not normalized or normalized in seen:
48
+ continue
49
+ seen.add(normalized)
50
+ parsed_values.append(normalized)
51
+ return parsed_values
52
+
53
+
54
+ def resolve_linked_change_for_pr(
55
+ *,
56
+ jj_client: JjClient,
57
+ pr_reference: str,
58
+ revset: str | None,
59
+ ) -> tuple[int, str]:
60
+ """Resolve `--pull-request` to one linked visible local change ID."""
61
+
62
+ if revset is not None:
63
+ raise UsageError(
64
+ t"Use either {ui.cmd('<revset>')} or {ui.cmd('--pull-request')}, not both."
65
+ )
66
+
67
+ pr_number = resolve_pr_number(
68
+ jj_client=jj_client,
69
+ pr_reference=pr_reference,
70
+ )
71
+ state = TrackingStore.for_repo(jj_client.repo_root).load()
72
+ matching_change_ids = [
73
+ change_id
74
+ for change_id, pr_identity in state.pr_identities.items()
75
+ if pr_identity.pr_number == pr_number
76
+ ]
77
+ if not matching_change_ids:
78
+ raise CliError(
79
+ t"PR #{pr_number} is not linked to any local change.",
80
+ hint=(
81
+ t"Use an explicit change instead, or run {ui.cmd('checkout')} or "
82
+ t"{ui.cmd('relink')} first."
83
+ ),
84
+ )
85
+ if len(matching_change_ids) > 1:
86
+ raise AmbiguousSelectionError(
87
+ t"PR #{pr_number} is linked to multiple local changes.",
88
+ hint=t"Use an explicit change ID after pointing the remote at the intended repo.",
89
+ )
90
+
91
+ return pr_number, matching_change_ids[0]
92
+
93
+
94
+ def resolve_pr_number(
95
+ *,
96
+ jj_client: JjClient,
97
+ pr_reference: str,
98
+ ) -> int:
99
+ """Resolve a pull-request selector as a pull request number for this repo."""
100
+
101
+ pr_number = parse_pr_number(pr_reference)
102
+ if pr_number is not None:
103
+ return pr_number
104
+
105
+ remotes = jj_client.list_git_remotes()
106
+ try:
107
+ remote = select_submit_remote(remotes)
108
+ except CliError as error:
109
+ raise CliError(
110
+ t"Could not determine the GitHub repo for {ui.cmd('--pull-request')}; "
111
+ t"use a pull request number or fix the selected remote.",
112
+ hint=error.hint,
113
+ ) from error
114
+ github_repo = parse_github_repo(remote)
115
+ if github_repo is None:
116
+ raise CliError(
117
+ t"Could not determine the GitHub repo for {ui.cmd('--pull-request')}; "
118
+ t"use a pull request number or fix the selected remote."
119
+ )
120
+
121
+ return parse_repo_pr_reference(
122
+ reference=pr_reference,
123
+ github_repo=github_repo,
124
+ )