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.
@@ -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))