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
jj_stack/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Stacked GitHub pull request tooling for jj."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+ from time import perf_counter
5
+
6
+ PROCESS_START = perf_counter()
7
+
8
+ try:
9
+ __version__ = version("jj-stack")
10
+ except PackageNotFoundError:
11
+ __version__ = "0.0.0"
jj_stack/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Package entrypoint for `python -m jj_stack`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from jj_stack.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
jj_stack/bootstrap.py ADDED
@@ -0,0 +1,213 @@
1
+ """Runtime bootstrap helpers for CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import subprocess
7
+ import time
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ import jj_stack
12
+ import jj_stack.console as console
13
+ import jj_stack.ui as ui
14
+ from jj_stack.config import AppConfig, load_config
15
+ from jj_stack.errors import CliError
16
+ from jj_stack.jj.cli_args import JjCliArgs
17
+ from jj_stack.jj.client import JjClient
18
+ from jj_stack.pr_branch_namespace import install_pr_branch_namespace
19
+ from jj_stack.state.store import TrackingStore
20
+
21
+ _MINIMUM_JJ_VERSION = (0, 44, 0)
22
+ _MINIMUM_JJ_VERSION_STRING = "0.44.0"
23
+ _jj_version_verified = False
24
+
25
+ time_output_active: bool = False
26
+
27
+
28
+ class _ElapsedFormatter(logging.Formatter):
29
+ """Prepend the `--time-output` prefix when it's active."""
30
+
31
+ def format(self, record: logging.LogRecord) -> str:
32
+ base = super().format(record)
33
+ if not time_output_active:
34
+ return base
35
+ elapsed = time.perf_counter() - jj_stack.PROCESS_START
36
+ return console.style_time_prefix(f"[{elapsed:0.6f}] ") + base
37
+
38
+
39
+ @dataclass(slots=True, frozen=True)
40
+ class RuntimeOptions:
41
+ """Command-line options that influence bootstrap behavior."""
42
+
43
+ cli_args: JjCliArgs
44
+ debug: bool
45
+ repo: Path | None
46
+
47
+
48
+ @dataclass(slots=True, frozen=True)
49
+ class CommandContext:
50
+ """Typed runtime state shared by command handlers."""
51
+
52
+ config: AppConfig
53
+ jj_client: JjClient
54
+ options: RuntimeOptions
55
+ repo_root: Path
56
+ state_store: TrackingStore
57
+
58
+
59
+ def bootstrap_context(
60
+ *,
61
+ repo: Path | None,
62
+ cli_args: JjCliArgs,
63
+ debug: bool,
64
+ ) -> CommandContext:
65
+ """Resolve the repo, load config, and initialize logging."""
66
+
67
+ repo = _resolve_optional_path(repo)
68
+ _validate_repo_path(repo)
69
+ check_jj_version()
70
+ repo_root = resolve_repo_root(repo or Path.cwd())
71
+ jj_client = JjClient(repo_root, cli_args=cli_args)
72
+ config = load_config(jj_client=jj_client)
73
+ install_pr_branch_namespace(config.branch_prefix)
74
+ jj_client.enable_initial_working_copy_snapshot()
75
+ configure_logging(debug=debug, configured_level=config.logging.level)
76
+ return CommandContext(
77
+ config=config,
78
+ jj_client=jj_client,
79
+ options=RuntimeOptions(
80
+ cli_args=cli_args,
81
+ debug=debug,
82
+ repo=repo,
83
+ ),
84
+ repo_root=repo_root,
85
+ state_store=TrackingStore.for_repo(repo_root),
86
+ )
87
+
88
+
89
+ def configure_logging(*, debug: bool, configured_level: str) -> None:
90
+ """Apply process-wide logging defaults for the current command."""
91
+
92
+ root_level = _resolve_logging_level(
93
+ configured_level.upper(),
94
+ original_value=configured_level,
95
+ )
96
+ logging.basicConfig(
97
+ format="%(levelname)s %(name)s: %(message)s",
98
+ force=True,
99
+ level=root_level,
100
+ )
101
+ formatter = _ElapsedFormatter("%(levelname)s %(name)s: %(message)s")
102
+ for handler in logging.getLogger().handlers:
103
+ handler.setFormatter(formatter)
104
+ app_level = logging.DEBUG if debug else root_level
105
+ logging.getLogger("jj_stack").setLevel(app_level)
106
+ logging.getLogger("httpxyz").setLevel(logging.WARNING)
107
+ logging.getLogger("httpcorexyz").setLevel(logging.WARNING)
108
+ logging.getLogger("asyncio").setLevel(logging.WARNING)
109
+
110
+
111
+ def _resolve_logging_level(level_name: str, *, original_value: str) -> int:
112
+ level_names = logging.getLevelNamesMapping()
113
+ if level_name not in level_names:
114
+ valid_levels = ", ".join(sorted(level_names))
115
+ raise CliError(f"Invalid logging level {original_value}. Expected one of: {valid_levels}")
116
+ return level_names[level_name]
117
+
118
+
119
+ def resolve_repo_root(start_dir: Path) -> Path:
120
+ """Resolve the jj workspace root by walking up from `start_dir`.
121
+
122
+ Mirrors what `jj root` does internally (searches for the nearest ancestor
123
+ containing a `.jj` directory) without forking a subprocess. Couples to
124
+ jj's on-disk layout: every workspace root is assumed to hold `.jj` as a
125
+ directory, as jj does today. If jj ever grows a `.jj`-as-file pointer
126
+ (analogous to git's submodule/worktree `.git` files), this needs to
127
+ learn about that form.
128
+ """
129
+
130
+ try:
131
+ resolved = start_dir.resolve(strict=False)
132
+ except OSError as error:
133
+ raise CliError(f"Could not resolve path {start_dir}: {error}") from error
134
+
135
+ for candidate in (resolved, *resolved.parents):
136
+ if (candidate / ".jj").is_dir():
137
+ return candidate
138
+ raise CliError(f"Not inside a jj workspace (from {start_dir}).")
139
+
140
+
141
+ def check_jj_version() -> None:
142
+ """Verify that the installed `jj` meets the minimum required version.
143
+
144
+ Raises `CliError` if `jj` is absent, if its version string cannot be parsed,
145
+ or if the installed version is older than the minimum. A successful check
146
+ holds for the lifetime of the process and is not repeated.
147
+ """
148
+
149
+ global _jj_version_verified
150
+ if _jj_version_verified:
151
+ return
152
+ try:
153
+ completed = subprocess.run(
154
+ ["jj", "--version"],
155
+ capture_output=True,
156
+ check=False,
157
+ text=True,
158
+ )
159
+ except FileNotFoundError as error:
160
+ raise CliError(t"{ui.cmd('jj')} is not installed or is not on PATH.") from error
161
+
162
+ if completed.returncode != 0:
163
+ message = completed.stderr.strip() or completed.stdout.strip() or "unknown error"
164
+ raise CliError(t"{ui.cmd('jj --version')} failed: {message}")
165
+
166
+ version = _parse_jj_version(completed.stdout.strip())
167
+ if version is None:
168
+ raise CliError(
169
+ t"Could not parse {ui.cmd('jj --version')} output: {completed.stdout.strip()!r}. "
170
+ t"jj-stack requires jj {_MINIMUM_JJ_VERSION_STRING} or later."
171
+ )
172
+ if version < _MINIMUM_JJ_VERSION:
173
+ installed = ".".join(str(x) for x in version)
174
+ raise CliError(
175
+ f"jj {installed} is too old. "
176
+ f"jj-stack requires jj {_MINIMUM_JJ_VERSION_STRING} or later. "
177
+ "Please upgrade jj."
178
+ )
179
+ _jj_version_verified = True
180
+
181
+
182
+ def _parse_jj_version(version_output: str) -> tuple[int, ...] | None:
183
+ """Parse version tuple from `jj --version` output.
184
+
185
+ Expected formats: ``"jj 0.44.0"`` or ``"jj 0.44.0-<build-hash>"``.
186
+ Returns ``None`` if the output does not match the expected format.
187
+ """
188
+
189
+ parts = version_output.split()
190
+ if len(parts) < 2 or parts[0] != "jj":
191
+ return None
192
+ version_str = parts[1].split("-")[0]
193
+ try:
194
+ return tuple(int(x) for x in version_str.split("."))
195
+ except ValueError:
196
+ return None
197
+
198
+
199
+ def _resolve_optional_path(raw_path: Path | str | None) -> Path | None:
200
+ if raw_path is None:
201
+ return None
202
+ if isinstance(raw_path, Path):
203
+ return raw_path.resolve()
204
+ return Path(str(raw_path)).resolve()
205
+
206
+
207
+ def _validate_repo_path(repo: Path | None) -> None:
208
+ if repo is None:
209
+ return
210
+ if not repo.exists():
211
+ raise CliError(f"Repo path does not exist: {repo}")
212
+ if not repo.is_dir():
213
+ raise CliError(f"Repo path is not a directory: {repo}")