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
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
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}")
|