git-paoding 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.
- git_paoding/__init__.py +32 -0
- git_paoding/_agent_plugins/__init__.py +1 -0
- git_paoding/_agent_plugins/git-paoding/.claude-plugin/plugin.json +12 -0
- git_paoding/_agent_plugins/git-paoding/.codex-plugin/plugin.json +22 -0
- git_paoding/_agent_plugins/git-paoding/skills/git-paoding/SKILL.md +206 -0
- git_paoding/agent_install.py +113 -0
- git_paoding/api.py +383 -0
- git_paoding/cli/__init__.py +1 -0
- git_paoding/cli/facade.py +133 -0
- git_paoding/cli/main.py +277 -0
- git_paoding/cli/render.py +205 -0
- git_paoding/core/__init__.py +1 -0
- git_paoding/core/diffatoms.py +208 -0
- git_paoding/core/model.py +292 -0
- git_paoding/core/projection.py +376 -0
- git_paoding/core/publish.py +644 -0
- git_paoding/core/reconcile.py +220 -0
- git_paoding/core/selectors.py +279 -0
- git_paoding/github/__init__.py +1 -0
- git_paoding/github/backend.py +53 -0
- git_paoding/github/gh_cli.py +339 -0
- git_paoding/github/lifecycle.py +123 -0
- git_paoding/github/prbody.py +281 -0
- git_paoding/gitio/__init__.py +49 -0
- git_paoding/gitio/diffparse.py +273 -0
- git_paoding/gitio/plumbing.py +170 -0
- git_paoding/gitio/refs.py +145 -0
- git_paoding/gitio/runner.py +133 -0
- git_paoding/py.typed +1 -0
- git_paoding/store/__init__.py +6 -0
- git_paoding/store/jsonstore.py +142 -0
- git_paoding/store/lock.py +165 -0
- git_paoding-0.1.0.dist-info/METADATA +351 -0
- git_paoding-0.1.0.dist-info/RECORD +36 -0
- git_paoding-0.1.0.dist-info/WHEEL +4 -0
- git_paoding-0.1.0.dist-info/entry_points.txt +3 -0
git_paoding/cli/main.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Command-line entry point."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import NoReturn
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from git_paoding import __version__
|
|
10
|
+
from git_paoding.agent_install import AgentInstallError, install_agent_skill
|
|
11
|
+
from git_paoding.cli.facade import ApiFacade, CliFacade
|
|
12
|
+
from git_paoding.cli.render import (
|
|
13
|
+
render_archive,
|
|
14
|
+
render_assign,
|
|
15
|
+
render_focus,
|
|
16
|
+
render_publish,
|
|
17
|
+
render_slice_added,
|
|
18
|
+
render_slice_list,
|
|
19
|
+
render_slice_removed,
|
|
20
|
+
render_slice_renamed,
|
|
21
|
+
render_status,
|
|
22
|
+
)
|
|
23
|
+
from git_paoding.core.model import AssignBatchRequest, PaodingError
|
|
24
|
+
from git_paoding.github.gh_cli import GhCliBackend
|
|
25
|
+
from git_paoding.gitio.runner import GitError
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _backend(repo: Path) -> GhCliBackend:
|
|
29
|
+
return GhCliBackend(repo)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_facade: CliFacade = ApiFacade()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _raise_cli_error(error: Exception) -> NoReturn:
|
|
36
|
+
raise click.ClickException(str(error)) from error
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@click.group(
|
|
40
|
+
epilog=(
|
|
41
|
+
"Exit codes: 0 = success/clean; 2 = action needed because attribution remains; "
|
|
42
|
+
"1 = operational error."
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
@click.version_option(version=__version__, prog_name="git-paoding")
|
|
46
|
+
def main() -> None:
|
|
47
|
+
"""Semantic review slicing for large agent-generated changes."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@main.group("agent")
|
|
51
|
+
def agent_group() -> None:
|
|
52
|
+
"""Install the packaged workflow for supported coding agents."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@agent_group.command("install")
|
|
56
|
+
@click.option(
|
|
57
|
+
"--target",
|
|
58
|
+
"targets",
|
|
59
|
+
type=click.Choice(("codex", "claude"), case_sensitive=False),
|
|
60
|
+
multiple=True,
|
|
61
|
+
required=True,
|
|
62
|
+
help="Agent integration to install; repeat to install both.",
|
|
63
|
+
)
|
|
64
|
+
@click.option(
|
|
65
|
+
"--scope",
|
|
66
|
+
type=click.Choice(("user", "project"), case_sensitive=False),
|
|
67
|
+
default="user",
|
|
68
|
+
show_default=True,
|
|
69
|
+
help="Install for the current user or the current repository.",
|
|
70
|
+
)
|
|
71
|
+
@click.option(
|
|
72
|
+
"--force",
|
|
73
|
+
is_flag=True,
|
|
74
|
+
help="Overwrite packaged files when the destination has different contents.",
|
|
75
|
+
)
|
|
76
|
+
def agent_install_command(targets: tuple[str, ...], scope: str, force: bool) -> None:
|
|
77
|
+
"""Install the bundled standalone skill without a plugin marketplace UI."""
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
results = [
|
|
81
|
+
install_agent_skill(
|
|
82
|
+
target, # type: ignore[arg-type]
|
|
83
|
+
scope, # type: ignore[arg-type]
|
|
84
|
+
project_root=Path.cwd(),
|
|
85
|
+
force=force,
|
|
86
|
+
)
|
|
87
|
+
for target in targets
|
|
88
|
+
]
|
|
89
|
+
except (AgentInstallError, OSError) as error:
|
|
90
|
+
_raise_cli_error(error)
|
|
91
|
+
|
|
92
|
+
for result in results:
|
|
93
|
+
action = "Installed" if result.changed else "Already installed"
|
|
94
|
+
click.echo(f"{action} {result.target} skill: {result.destination}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@main.command("init")
|
|
98
|
+
@click.option("--base", required=True, help="Base ref to pin for this review session.")
|
|
99
|
+
@click.option(
|
|
100
|
+
"--slice-prefix",
|
|
101
|
+
default="slice",
|
|
102
|
+
show_default=True,
|
|
103
|
+
help="Short identifier used in generated slice pull-request titles.",
|
|
104
|
+
)
|
|
105
|
+
def init_command(base: str, slice_prefix: str) -> None:
|
|
106
|
+
"""Initialize a review session on the current branch."""
|
|
107
|
+
|
|
108
|
+
repo = Path.cwd()
|
|
109
|
+
try:
|
|
110
|
+
result = _facade.init_session(
|
|
111
|
+
repo,
|
|
112
|
+
base,
|
|
113
|
+
backend=_backend(repo),
|
|
114
|
+
slice_pr_prefix=slice_prefix,
|
|
115
|
+
)
|
|
116
|
+
except (PaodingError, GitError, ValueError, OSError) as error:
|
|
117
|
+
_raise_cli_error(error)
|
|
118
|
+
click.echo(render_status(result))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@main.group("slice")
|
|
122
|
+
def slice_group() -> None:
|
|
123
|
+
"""Manage stable semantic slice identities."""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@slice_group.command("add")
|
|
127
|
+
@click.argument("slice_id")
|
|
128
|
+
@click.option("--title", required=True, help="Human-facing slice title.")
|
|
129
|
+
def slice_add_command(slice_id: str, title: str) -> None:
|
|
130
|
+
"""Add one active slice."""
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
result = _facade.add_slice(Path.cwd(), slice_id, title)
|
|
134
|
+
except (PaodingError, GitError, ValueError, OSError) as error:
|
|
135
|
+
_raise_cli_error(error)
|
|
136
|
+
click.echo(render_slice_added(result, slice_id=slice_id, title=title))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@slice_group.command("list")
|
|
140
|
+
def slice_list_command() -> None:
|
|
141
|
+
"""List slices and their current diffstats without changing session state."""
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
result = _facade.list_slices(Path.cwd())
|
|
145
|
+
except (PaodingError, GitError, ValueError, OSError) as error:
|
|
146
|
+
_raise_cli_error(error)
|
|
147
|
+
click.echo(render_slice_list(result))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@slice_group.command("remove")
|
|
151
|
+
@click.argument("slice_id")
|
|
152
|
+
def slice_remove_command(slice_id: str) -> None:
|
|
153
|
+
"""Remove one active slice identity."""
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
result = _facade.remove_slice(Path.cwd(), slice_id)
|
|
157
|
+
except (PaodingError, GitError, ValueError, OSError) as error:
|
|
158
|
+
_raise_cli_error(error)
|
|
159
|
+
click.echo(render_slice_removed(result, slice_id=slice_id))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@slice_group.command("rename")
|
|
163
|
+
@click.argument("slice_id")
|
|
164
|
+
@click.option("--title", required=True, help="New human-facing slice title.")
|
|
165
|
+
def slice_rename_command(slice_id: str, title: str) -> None:
|
|
166
|
+
"""Rename a slice while preserving its stable identity."""
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
result = _facade.rename_slice(Path.cwd(), slice_id, title)
|
|
170
|
+
except (PaodingError, GitError, ValueError, OSError) as error:
|
|
171
|
+
_raise_cli_error(error)
|
|
172
|
+
click.echo(render_slice_renamed(result, slice_id=slice_id, title=title))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@main.command("status")
|
|
176
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit the versioned JSON contract.")
|
|
177
|
+
@click.option(
|
|
178
|
+
"--full",
|
|
179
|
+
is_flag=True,
|
|
180
|
+
help="Show complete changed-hunk previews (default: 3 changed lines).",
|
|
181
|
+
)
|
|
182
|
+
def status_command(as_json: bool, full: bool) -> None:
|
|
183
|
+
"""Reconcile and report local attribution status."""
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
result = _facade.get_status(Path.cwd(), full=full)
|
|
187
|
+
except (PaodingError, GitError, ValueError, OSError) as error:
|
|
188
|
+
_raise_cli_error(error)
|
|
189
|
+
click.echo(result.model_dump_json(indent=2) if as_json else render_status(result, full=full))
|
|
190
|
+
if result.unassigned_count or result.ambiguous_count:
|
|
191
|
+
raise click.exceptions.Exit(2)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@main.command("assign")
|
|
195
|
+
@click.argument("slice_id", required=False)
|
|
196
|
+
@click.argument("selectors", nargs=-1)
|
|
197
|
+
@click.option(
|
|
198
|
+
"--force",
|
|
199
|
+
is_flag=True,
|
|
200
|
+
help="Allow broad selectors to take atoms from another slice.",
|
|
201
|
+
)
|
|
202
|
+
@click.option(
|
|
203
|
+
"--batch",
|
|
204
|
+
type=click.Path(exists=True, dir_okay=False, allow_dash=True, path_type=Path),
|
|
205
|
+
help="Read the versioned batch JSON contract from a file, or '-' for stdin.",
|
|
206
|
+
)
|
|
207
|
+
def assign_command(
|
|
208
|
+
slice_id: str | None,
|
|
209
|
+
selectors: tuple[str, ...],
|
|
210
|
+
force: bool,
|
|
211
|
+
batch: Path | None,
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Assign atoms using ids, paths, directories/globs, or Final line ranges."""
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
if batch is not None:
|
|
217
|
+
if slice_id is not None or selectors:
|
|
218
|
+
raise PaodingError("--batch cannot be combined with a slice id or selectors")
|
|
219
|
+
if force:
|
|
220
|
+
raise PaodingError("--force cannot be combined with --batch; use batch JSON force")
|
|
221
|
+
source = sys.stdin.read() if str(batch) == "-" else batch.read_text(encoding="utf-8")
|
|
222
|
+
request = AssignBatchRequest.model_validate_json(source)
|
|
223
|
+
result = _facade.assign_batch(Path.cwd(), request)
|
|
224
|
+
else:
|
|
225
|
+
if slice_id is None or not selectors:
|
|
226
|
+
raise PaodingError(
|
|
227
|
+
"Interactive assignment requires a slice id and at least one selector"
|
|
228
|
+
)
|
|
229
|
+
result = _facade.assign(Path.cwd(), slice_id, selectors, force=force)
|
|
230
|
+
except (PaodingError, GitError, ValueError, OSError) as error:
|
|
231
|
+
_raise_cli_error(error)
|
|
232
|
+
click.echo(render_assign(result))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@main.command("focus")
|
|
236
|
+
@click.argument("slice_id", required=False)
|
|
237
|
+
@click.option("--clear", "clear_focus", is_flag=True, help="Clear the session-global focus.")
|
|
238
|
+
def focus_command(slice_id: str | None, clear_focus: bool) -> None:
|
|
239
|
+
"""Set a default slice prior for genuinely new atoms, or clear it."""
|
|
240
|
+
|
|
241
|
+
if (slice_id is None) == (not clear_focus):
|
|
242
|
+
_raise_cli_error(PaodingError("Pass exactly one slice id or --clear"))
|
|
243
|
+
target = None if clear_focus else slice_id
|
|
244
|
+
try:
|
|
245
|
+
result = _facade.set_focus(Path.cwd(), target)
|
|
246
|
+
except (PaodingError, GitError, ValueError, OSError) as error:
|
|
247
|
+
_raise_cli_error(error)
|
|
248
|
+
click.echo(render_focus(result, slice_id=target))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@main.command("publish")
|
|
252
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit the versioned JSON contract.")
|
|
253
|
+
@click.option("--remote", default="origin", show_default=True, help="Git remote for projections.")
|
|
254
|
+
def publish_command(as_json: bool, remote: str) -> None:
|
|
255
|
+
"""Publish or refresh Draft review projections idempotently."""
|
|
256
|
+
|
|
257
|
+
repo = Path.cwd()
|
|
258
|
+
try:
|
|
259
|
+
result = _facade.publish(repo, backend=_backend(repo), remote=remote)
|
|
260
|
+
except (PaodingError, GitError, ValueError, OSError) as error:
|
|
261
|
+
_raise_cli_error(error)
|
|
262
|
+
click.echo(result.model_dump_json(indent=2) if as_json else render_publish(result))
|
|
263
|
+
if result.action_needed:
|
|
264
|
+
raise click.exceptions.Exit(2)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@main.command("archive")
|
|
268
|
+
@click.option("--remote", default="origin", show_default=True, help="Git remote for projections.")
|
|
269
|
+
def archive_command(remote: str) -> None:
|
|
270
|
+
"""Archive slice PRs and generated refs after integration merges."""
|
|
271
|
+
|
|
272
|
+
repo = Path.cwd()
|
|
273
|
+
try:
|
|
274
|
+
result = _facade.archive(repo, backend=_backend(repo), remote=remote)
|
|
275
|
+
except (PaodingError, GitError, ValueError, OSError) as error:
|
|
276
|
+
_raise_cli_error(error)
|
|
277
|
+
click.echo(render_archive(result))
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Human-readable terminal rendering of facade result models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from git_paoding.core.model import (
|
|
8
|
+
AssignResult,
|
|
9
|
+
Atom,
|
|
10
|
+
AtomState,
|
|
11
|
+
PublishResult,
|
|
12
|
+
SliceStatus,
|
|
13
|
+
SliceSummary,
|
|
14
|
+
StatusResult,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
_DEFAULT_PREVIEW_LINES = 3
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _slice_lines(slices: Sequence[SliceSummary]) -> list[str]:
|
|
21
|
+
lines = [" ID STATUS DIFFSTAT PR TITLE"]
|
|
22
|
+
if not slices:
|
|
23
|
+
lines.append(" (none)")
|
|
24
|
+
return lines
|
|
25
|
+
for slice_ in slices:
|
|
26
|
+
diffstat = slice_.diffstat
|
|
27
|
+
pr = f"#{slice_.pr_number}" if slice_.pr_number else "-"
|
|
28
|
+
lines.append(
|
|
29
|
+
f" {slice_.id} {slice_.status.value} "
|
|
30
|
+
f"{diffstat.files_changed} files +{diffstat.additions} -{diffstat.deletions} "
|
|
31
|
+
f"{pr} {slice_.title}"
|
|
32
|
+
)
|
|
33
|
+
return lines
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _preview_lines(preview: str, *, full: bool, indent: str) -> list[str]:
|
|
37
|
+
if not preview:
|
|
38
|
+
return []
|
|
39
|
+
source = preview.splitlines()
|
|
40
|
+
visible = source if full else source[:_DEFAULT_PREVIEW_LINES]
|
|
41
|
+
lines = [f"{indent}{line}" for line in visible]
|
|
42
|
+
if not full and len(source) > _DEFAULT_PREVIEW_LINES and visible[-1] != "…":
|
|
43
|
+
lines.append(f"{indent}…")
|
|
44
|
+
return lines
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _atom_lines(atoms: Sequence[Atom], *, full: bool) -> list[str]:
|
|
48
|
+
if not atoms:
|
|
49
|
+
return [" (none)"]
|
|
50
|
+
lines: list[str] = []
|
|
51
|
+
for atom in atoms:
|
|
52
|
+
owner = atom.owner or "-"
|
|
53
|
+
lines.append(
|
|
54
|
+
f" {atom.atom_id} {atom.state.value} owner={owner} {atom.path} "
|
|
55
|
+
f"base:{atom.base_start}+{atom.base_len} "
|
|
56
|
+
f"final:{atom.final_start}+{atom.final_len}"
|
|
57
|
+
)
|
|
58
|
+
lines.extend(_preview_lines(atom.preview, full=full, indent=" "))
|
|
59
|
+
return lines
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _mutation_summary(result: StatusResult) -> list[str]:
|
|
63
|
+
active_count = sum(slice_.status is SliceStatus.ACTIVE for slice_ in result.slices)
|
|
64
|
+
return [
|
|
65
|
+
f"Session: {result.session.canonical_branch}",
|
|
66
|
+
f"Slices: {active_count} active",
|
|
67
|
+
(
|
|
68
|
+
f"Action needed: {result.unassigned_count} unassigned, "
|
|
69
|
+
f"{result.ambiguous_count} ambiguous"
|
|
70
|
+
),
|
|
71
|
+
"Run `git-paoding status` to inspect atoms.",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def render_status(result: StatusResult, *, full: bool = False) -> str:
|
|
76
|
+
"""Render session, slice, and atom attribution status."""
|
|
77
|
+
|
|
78
|
+
lines = [
|
|
79
|
+
f"Session: {result.session.canonical_branch}",
|
|
80
|
+
f"Base: {result.session.base_oid}",
|
|
81
|
+
f"Final: {result.session.last_final_oid or '-'}",
|
|
82
|
+
(
|
|
83
|
+
f"Action needed: {result.unassigned_count} unassigned, "
|
|
84
|
+
f"{result.ambiguous_count} ambiguous"
|
|
85
|
+
),
|
|
86
|
+
f"Focus: {result.session.focus_slice or '-'}",
|
|
87
|
+
(
|
|
88
|
+
"Defaulted by focus: " + ", ".join(result.defaulted_atom_ids)
|
|
89
|
+
if result.defaulted_atom_ids
|
|
90
|
+
else "Defaulted by focus: (none)"
|
|
91
|
+
),
|
|
92
|
+
"Slices:",
|
|
93
|
+
]
|
|
94
|
+
lines.extend(_slice_lines(result.slices))
|
|
95
|
+
action_needed = [
|
|
96
|
+
atom for atom in result.atoms if atom.state in {AtomState.UNASSIGNED, AtomState.AMBIGUOUS}
|
|
97
|
+
]
|
|
98
|
+
settled = [
|
|
99
|
+
atom
|
|
100
|
+
for atom in result.atoms
|
|
101
|
+
if atom.state not in {AtomState.UNASSIGNED, AtomState.AMBIGUOUS}
|
|
102
|
+
]
|
|
103
|
+
lines.append("Action-needed atoms:")
|
|
104
|
+
lines.extend(_atom_lines(action_needed, full=full))
|
|
105
|
+
lines.append("Assigned/updated atoms:")
|
|
106
|
+
lines.extend(_atom_lines(settled, full=full))
|
|
107
|
+
return "\n".join(lines)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def render_slice_list(result: StatusResult) -> str:
|
|
111
|
+
"""Render only slice identities and diffstats for the read-only list verb."""
|
|
112
|
+
|
|
113
|
+
return "\n".join(
|
|
114
|
+
[f"Session: {result.session.canonical_branch}", "Slices:", *_slice_lines(result.slices)]
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def render_slice_added(result: StatusResult, *, slice_id: str, title: str) -> str:
|
|
119
|
+
"""Render a concise acknowledgement for the slice-add mutation."""
|
|
120
|
+
|
|
121
|
+
return "\n".join(
|
|
122
|
+
[
|
|
123
|
+
f"Added slice: {slice_id}",
|
|
124
|
+
f"Title: {title}",
|
|
125
|
+
*_mutation_summary(result),
|
|
126
|
+
]
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def render_slice_removed(result: StatusResult, *, slice_id: str) -> str:
|
|
131
|
+
"""Render the delta from removing one slice."""
|
|
132
|
+
|
|
133
|
+
return "\n".join(
|
|
134
|
+
[
|
|
135
|
+
f"Removed slice: {slice_id}",
|
|
136
|
+
"Its atoms are now unassigned and must be reassigned before publishing.",
|
|
137
|
+
*_mutation_summary(result),
|
|
138
|
+
]
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def render_slice_renamed(result: StatusResult, *, slice_id: str, title: str) -> str:
|
|
143
|
+
"""Render the delta from renaming one slice."""
|
|
144
|
+
|
|
145
|
+
return "\n".join([f"Renamed slice: {slice_id}", f"Title: {title}", *_mutation_summary(result)])
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def render_focus(result: StatusResult, *, slice_id: str | None) -> str:
|
|
149
|
+
"""Render the session-global focus delta."""
|
|
150
|
+
|
|
151
|
+
focus_line = f"Focus: {slice_id}" if slice_id is not None else "Focus: cleared"
|
|
152
|
+
return "\n".join([focus_line, *_mutation_summary(result)])
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def render_archive(result: StatusResult) -> str:
|
|
156
|
+
"""Render a concise archive completion summary."""
|
|
157
|
+
|
|
158
|
+
archived_count = sum(slice_.status is SliceStatus.ARCHIVED for slice_ in result.slices)
|
|
159
|
+
return "\n".join(
|
|
160
|
+
[
|
|
161
|
+
f"Archived session: {result.session.canonical_branch}",
|
|
162
|
+
f"Slices archived: {archived_count}",
|
|
163
|
+
]
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def render_assign(result: AssignResult) -> str:
|
|
168
|
+
"""Render exactly which atoms were assigned or skipped."""
|
|
169
|
+
|
|
170
|
+
lines: list[str] = []
|
|
171
|
+
for record in result.assigned:
|
|
172
|
+
lines.append(f"assigned {record.atom_id} {record.path} -> {record.owner}")
|
|
173
|
+
if record.preview:
|
|
174
|
+
lines.extend(f" {line}" for line in record.preview.splitlines())
|
|
175
|
+
for record in result.skipped:
|
|
176
|
+
lines.append(f"skipped {record.atom_id} {record.path} (owned by {record.previous_owner})")
|
|
177
|
+
if record.preview:
|
|
178
|
+
lines.extend(f" {line}" for line in record.preview.splitlines())
|
|
179
|
+
return "\n".join(lines) if lines else "No atoms changed."
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def render_publish(result: PublishResult) -> str:
|
|
183
|
+
"""Render action-needed status or per-slice publication outcomes."""
|
|
184
|
+
|
|
185
|
+
if result.action_needed:
|
|
186
|
+
if result.status is None:
|
|
187
|
+
return "Action needed before publishing."
|
|
188
|
+
return "Publish stopped before remote effects.\n" + render_status(result.status)
|
|
189
|
+
|
|
190
|
+
lines = [
|
|
191
|
+
(
|
|
192
|
+
f"Integration PR: #{result.integration_pr} {result.integration_pr_url}"
|
|
193
|
+
if result.integration_pr is not None
|
|
194
|
+
else "Integration PR: -"
|
|
195
|
+
)
|
|
196
|
+
]
|
|
197
|
+
lines.append("Slices:")
|
|
198
|
+
if not result.slices:
|
|
199
|
+
lines.append(" (none)")
|
|
200
|
+
for slice_ in result.slices:
|
|
201
|
+
suffix = f" PR #{slice_.pr_number} {slice_.url}" if slice_.pr_number else ""
|
|
202
|
+
lines.append(f" {slice_.slice_id} {slice_.outcome.value}{suffix}")
|
|
203
|
+
if result.status is not None and result.status.defaulted_atom_ids:
|
|
204
|
+
lines.append("Defaulted by focus: " + ", ".join(result.status.defaulted_atom_ids))
|
|
205
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core domain package."""
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Construct persistent atom metadata and ephemeral replay payloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from collections.abc import Iterable, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from git_paoding.core.model import Atom, AtomKind, AtomState
|
|
11
|
+
from git_paoding.gitio.diffparse import RawDiffHunk
|
|
12
|
+
|
|
13
|
+
_PREVIEW_LINE_LIMIT = 3
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class ReplayAtom:
|
|
18
|
+
"""An atom paired with the full text payload needed for replay.
|
|
19
|
+
|
|
20
|
+
``Atom`` remains the compact persistent and contract-facing metadata type.
|
|
21
|
+
A ``ReplayAtom`` is deliberately ephemeral: callers reconstruct it from the
|
|
22
|
+
authoritative Base-to-Final diff whenever they need to replay content.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
atom: Atom
|
|
26
|
+
removed_lines: tuple[bytes, ...]
|
|
27
|
+
added_lines: tuple[bytes, ...]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _line_bytes(line: str) -> bytes:
|
|
31
|
+
return line.encode("utf-8", errors="surrogateescape")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _hash_fields(fields: Iterable[bytes]) -> str:
|
|
35
|
+
"""Hash length-delimited fields without concatenation ambiguities."""
|
|
36
|
+
|
|
37
|
+
digest = hashlib.sha256()
|
|
38
|
+
for field in fields:
|
|
39
|
+
digest.update(len(field).to_bytes(8, byteorder="big"))
|
|
40
|
+
digest.update(field)
|
|
41
|
+
return digest.hexdigest()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _text_content_hash(hunk: RawDiffHunk) -> str:
|
|
45
|
+
fields = [b"removed"]
|
|
46
|
+
fields.extend(_line_bytes(line) for line in hunk.removed_lines)
|
|
47
|
+
fields.append(b"added")
|
|
48
|
+
fields.extend(_line_bytes(line) for line in hunk.added_lines)
|
|
49
|
+
return _hash_fields(fields)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _whole_file_content_hash(hunk: RawDiffHunk) -> str:
|
|
53
|
+
"""Fingerprint a non-text change from its authoritative Git tree entries."""
|
|
54
|
+
|
|
55
|
+
descriptors = (hunk.base_mode, hunk.base_oid, hunk.final_mode, hunk.final_oid)
|
|
56
|
+
if all(value is None for value in descriptors):
|
|
57
|
+
raise ValueError(f"whole-file hunk for {hunk.path!r} lacks Git object metadata")
|
|
58
|
+
return _hash_fields(
|
|
59
|
+
(
|
|
60
|
+
b"whole-file",
|
|
61
|
+
(hunk.base_mode or "missing").encode("ascii"),
|
|
62
|
+
(hunk.base_oid or "missing").encode("ascii"),
|
|
63
|
+
(hunk.final_mode or "missing").encode("ascii"),
|
|
64
|
+
(hunk.final_oid or "missing").encode("ascii"),
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _atom_id_digest(
|
|
70
|
+
*,
|
|
71
|
+
path: str,
|
|
72
|
+
base_start: int,
|
|
73
|
+
base_len: int,
|
|
74
|
+
gap_seq: int,
|
|
75
|
+
content_hash: str,
|
|
76
|
+
) -> str:
|
|
77
|
+
return _hash_fields(
|
|
78
|
+
(
|
|
79
|
+
path.encode("utf-8", errors="surrogateescape"),
|
|
80
|
+
str(base_start).encode("ascii"),
|
|
81
|
+
str(base_len).encode("ascii"),
|
|
82
|
+
str(gap_seq).encode("ascii"),
|
|
83
|
+
content_hash.encode("ascii"),
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _kind(hunk: RawDiffHunk) -> AtomKind:
|
|
89
|
+
if hunk.is_binary or hunk.is_mode_change or hunk.is_symlink:
|
|
90
|
+
return AtomKind.WHOLE_FILE
|
|
91
|
+
if hunk.is_add_file:
|
|
92
|
+
return AtomKind.ADD_FILE
|
|
93
|
+
if hunk.is_delete_file:
|
|
94
|
+
return AtomKind.DELETE_FILE
|
|
95
|
+
return AtomKind.MODIFY
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _whole_file_preview(hunk: RawDiffHunk) -> str:
|
|
99
|
+
def render(mode: str | None, oid: str | None) -> str:
|
|
100
|
+
if mode is None and oid is None:
|
|
101
|
+
return "missing"
|
|
102
|
+
short_oid = (oid or "unknown")[:8]
|
|
103
|
+
return f"{mode or 'unknown'} {short_oid}"
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
f"whole-file: {render(hunk.base_mode, hunk.base_oid)} -> "
|
|
107
|
+
f"{render(hunk.final_mode, hunk.final_oid)}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _preview(hunk: RawDiffHunk) -> str:
|
|
112
|
+
def safe_line(prefix: str, line: str) -> str:
|
|
113
|
+
raw_line = _line_bytes(line)
|
|
114
|
+
return prefix + raw_line.decode("utf-8", errors="replace")
|
|
115
|
+
|
|
116
|
+
changed_lines = [safe_line("-", line) for line in hunk.removed_lines]
|
|
117
|
+
changed_lines.extend(safe_line("+", line) for line in hunk.added_lines)
|
|
118
|
+
preview = "".join(changed_lines[:_PREVIEW_LINE_LIMIT])
|
|
119
|
+
if len(changed_lines) > _PREVIEW_LINE_LIMIT:
|
|
120
|
+
if preview and not preview.endswith("\n"):
|
|
121
|
+
preview += "\n"
|
|
122
|
+
preview += "…"
|
|
123
|
+
return preview
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def atomize_hunks(hunks: Sequence[RawDiffHunk]) -> tuple[ReplayAtom, ...]:
|
|
127
|
+
"""Convert raw hunks to atoms plus non-persistent text replay payloads.
|
|
128
|
+
|
|
129
|
+
Pure insertions sharing a Base gap receive monotonically increasing
|
|
130
|
+
``gap_seq`` values in their Final/diff order. Atom IDs use the first eight
|
|
131
|
+
hexadecimal characters of the atom-identity SHA-256 digest and receive
|
|
132
|
+
deterministic ``-N`` suffixes on collisions.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
gap_counts: dict[tuple[str, int], int] = defaultdict(int)
|
|
136
|
+
id_counts: dict[str, int] = defaultdict(int)
|
|
137
|
+
whole_file_descriptors: dict[str, tuple[str | None, ...]] = {}
|
|
138
|
+
replay_atoms: list[ReplayAtom] = []
|
|
139
|
+
|
|
140
|
+
for hunk in hunks:
|
|
141
|
+
kind = _kind(hunk)
|
|
142
|
+
is_whole_file = kind is AtomKind.WHOLE_FILE
|
|
143
|
+
if is_whole_file:
|
|
144
|
+
descriptor = (hunk.base_mode, hunk.base_oid, hunk.final_mode, hunk.final_oid)
|
|
145
|
+
prior_descriptor = whole_file_descriptors.get(hunk.path)
|
|
146
|
+
if prior_descriptor is not None:
|
|
147
|
+
if descriptor != prior_descriptor:
|
|
148
|
+
raise ValueError(
|
|
149
|
+
f"whole-file hunks for {hunk.path!r} disagree on Git object metadata"
|
|
150
|
+
)
|
|
151
|
+
continue
|
|
152
|
+
whole_file_descriptors[hunk.path] = descriptor
|
|
153
|
+
|
|
154
|
+
gap_seq = 0
|
|
155
|
+
base_start = 0 if is_whole_file else hunk.base_start
|
|
156
|
+
base_len = 0 if is_whole_file else hunk.base_len
|
|
157
|
+
final_start = 0 if is_whole_file else hunk.final_start
|
|
158
|
+
final_len = 0 if is_whole_file else hunk.final_len
|
|
159
|
+
if not is_whole_file and base_len == 0:
|
|
160
|
+
gap_key = (hunk.path, hunk.base_start)
|
|
161
|
+
gap_seq = gap_counts[gap_key]
|
|
162
|
+
gap_counts[gap_key] += 1
|
|
163
|
+
|
|
164
|
+
content_hash = _whole_file_content_hash(hunk) if is_whole_file else _text_content_hash(hunk)
|
|
165
|
+
short_id = _atom_id_digest(
|
|
166
|
+
path=hunk.path,
|
|
167
|
+
base_start=base_start,
|
|
168
|
+
base_len=base_len,
|
|
169
|
+
gap_seq=gap_seq,
|
|
170
|
+
content_hash=content_hash,
|
|
171
|
+
)[:8]
|
|
172
|
+
id_counts[short_id] += 1
|
|
173
|
+
collision_number = id_counts[short_id]
|
|
174
|
+
atom_id = short_id if collision_number == 1 else f"{short_id}-{collision_number}"
|
|
175
|
+
|
|
176
|
+
atom = Atom(
|
|
177
|
+
atom_id=atom_id,
|
|
178
|
+
path=hunk.path,
|
|
179
|
+
kind=kind,
|
|
180
|
+
base_start=base_start,
|
|
181
|
+
base_len=base_len,
|
|
182
|
+
final_start=final_start,
|
|
183
|
+
final_len=final_len,
|
|
184
|
+
gap_seq=gap_seq,
|
|
185
|
+
content_hash=content_hash,
|
|
186
|
+
owner=None,
|
|
187
|
+
state=AtomState.UNASSIGNED,
|
|
188
|
+
preview=_whole_file_preview(hunk) if is_whole_file else _preview(hunk),
|
|
189
|
+
)
|
|
190
|
+
replay_atoms.append(
|
|
191
|
+
ReplayAtom(
|
|
192
|
+
atom=atom,
|
|
193
|
+
removed_lines=(
|
|
194
|
+
() if is_whole_file else tuple(_line_bytes(line) for line in hunk.removed_lines)
|
|
195
|
+
),
|
|
196
|
+
added_lines=(
|
|
197
|
+
() if is_whole_file else tuple(_line_bytes(line) for line in hunk.added_lines)
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
return tuple(replay_atoms)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def build_atoms(hunks: Sequence[RawDiffHunk]) -> tuple[Atom, ...]:
|
|
206
|
+
"""Convert raw hunks to compact persistent atoms."""
|
|
207
|
+
|
|
208
|
+
return tuple(replay_atom.atom for replay_atom in atomize_hunks(hunks))
|