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
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Typed helpers for Git object and reference plumbing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal, Sequence, cast
|
|
8
|
+
|
|
9
|
+
from git_paoding.gitio.runner import run_git
|
|
10
|
+
|
|
11
|
+
ObjectType = Literal["blob", "tree", "commit", "tag"]
|
|
12
|
+
TreeObjectType = Literal["blob", "tree", "commit"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class TreeEntry:
|
|
17
|
+
"""One direct child returned by ``git ls-tree``."""
|
|
18
|
+
|
|
19
|
+
mode: str
|
|
20
|
+
object_type: TreeObjectType
|
|
21
|
+
oid: str
|
|
22
|
+
path: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class GitIdentity:
|
|
27
|
+
"""Identity and optional timestamp used by ``git commit-tree``."""
|
|
28
|
+
|
|
29
|
+
name: str
|
|
30
|
+
email: str
|
|
31
|
+
date: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class RemoteRef:
|
|
36
|
+
"""One ref advertised by ``git ls-remote``."""
|
|
37
|
+
|
|
38
|
+
oid: str
|
|
39
|
+
ref: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def rev_parse(repo: Path, revision: str) -> str:
|
|
43
|
+
"""Resolve and verify a revision or object expression."""
|
|
44
|
+
|
|
45
|
+
result = run_git(("rev-parse", "--verify", "--end-of-options", revision), cwd=repo)
|
|
46
|
+
return result.stdout_text().strip()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cat_file(repo: Path, oid: str, *, object_type: ObjectType = "blob") -> bytes:
|
|
50
|
+
"""Read an object while requiring its expected Git type."""
|
|
51
|
+
|
|
52
|
+
return run_git(("cat-file", object_type, oid), cwd=repo).stdout
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def hash_object(repo: Path, data: bytes, *, object_type: ObjectType = "blob") -> str:
|
|
56
|
+
"""Write an object to the repository object database and return its OID."""
|
|
57
|
+
|
|
58
|
+
result = run_git(
|
|
59
|
+
("hash-object", "-w", "--stdin", "-t", object_type),
|
|
60
|
+
cwd=repo,
|
|
61
|
+
input_data=data,
|
|
62
|
+
)
|
|
63
|
+
return result.stdout_text().strip()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def ls_tree(repo: Path, treeish: str) -> tuple[TreeEntry, ...]:
|
|
67
|
+
"""List the direct entries of a tree without consulting the index."""
|
|
68
|
+
|
|
69
|
+
output = run_git(("ls-tree", "-z", treeish), cwd=repo).stdout
|
|
70
|
+
entries: list[TreeEntry] = []
|
|
71
|
+
for raw_entry in output.split(b"\0"):
|
|
72
|
+
if not raw_entry:
|
|
73
|
+
continue
|
|
74
|
+
metadata, raw_path = raw_entry.split(b"\t", maxsplit=1)
|
|
75
|
+
raw_mode, raw_type, raw_oid = metadata.split(b" ", maxsplit=2)
|
|
76
|
+
object_type = raw_type.decode("ascii")
|
|
77
|
+
if object_type not in {"blob", "tree", "commit"}:
|
|
78
|
+
raise ValueError(f"Unexpected ls-tree object type: {object_type}")
|
|
79
|
+
entries.append(
|
|
80
|
+
TreeEntry(
|
|
81
|
+
mode=raw_mode.decode("ascii"),
|
|
82
|
+
object_type=cast(TreeObjectType, object_type),
|
|
83
|
+
oid=raw_oid.decode("ascii"),
|
|
84
|
+
path=raw_path.decode("utf-8", errors="surrogateescape"),
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
return tuple(entries)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def mktree(repo: Path, entries: Sequence[TreeEntry]) -> str:
|
|
91
|
+
"""Write a tree from direct entries and return its OID."""
|
|
92
|
+
|
|
93
|
+
records: list[bytes] = []
|
|
94
|
+
for entry in entries:
|
|
95
|
+
path = entry.path.encode("utf-8", errors="surrogateescape")
|
|
96
|
+
metadata = f"{entry.mode} {entry.object_type} {entry.oid}\t".encode()
|
|
97
|
+
records.append(metadata + path + b"\0")
|
|
98
|
+
result = run_git(("mktree", "-z"), cwd=repo, input_data=b"".join(records))
|
|
99
|
+
return result.stdout_text().strip()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def commit_tree(
|
|
103
|
+
repo: Path,
|
|
104
|
+
tree_oid: str,
|
|
105
|
+
message: str,
|
|
106
|
+
*,
|
|
107
|
+
parents: Sequence[str] = (),
|
|
108
|
+
author: GitIdentity | None = None,
|
|
109
|
+
committer: GitIdentity | None = None,
|
|
110
|
+
) -> str:
|
|
111
|
+
"""Create a commit object without changing HEAD, a worktree, or the index."""
|
|
112
|
+
|
|
113
|
+
args = ["commit-tree", tree_oid]
|
|
114
|
+
for parent in parents:
|
|
115
|
+
args.extend(("-p", parent))
|
|
116
|
+
|
|
117
|
+
command_env: dict[str, str] = {}
|
|
118
|
+
if author is not None:
|
|
119
|
+
command_env["GIT_AUTHOR_NAME"] = author.name
|
|
120
|
+
command_env["GIT_AUTHOR_EMAIL"] = author.email
|
|
121
|
+
if author.date is not None:
|
|
122
|
+
command_env["GIT_AUTHOR_DATE"] = author.date
|
|
123
|
+
if committer is not None:
|
|
124
|
+
command_env["GIT_COMMITTER_NAME"] = committer.name
|
|
125
|
+
command_env["GIT_COMMITTER_EMAIL"] = committer.email
|
|
126
|
+
if committer.date is not None:
|
|
127
|
+
command_env["GIT_COMMITTER_DATE"] = committer.date
|
|
128
|
+
|
|
129
|
+
result = run_git(
|
|
130
|
+
args,
|
|
131
|
+
cwd=repo,
|
|
132
|
+
input_data=message.encode("utf-8", errors="surrogateescape"),
|
|
133
|
+
env=command_env,
|
|
134
|
+
)
|
|
135
|
+
return result.stdout_text().strip()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def commit_committer_date(repo: Path, commit_oid: str) -> str:
|
|
139
|
+
"""Return a commit's strict ISO committer date for deterministic synthesis."""
|
|
140
|
+
|
|
141
|
+
result = run_git(("show", "--no-patch", "--format=%cI", commit_oid), cwd=repo)
|
|
142
|
+
date = result.stdout_text().strip()
|
|
143
|
+
if not date:
|
|
144
|
+
raise ValueError(f"Commit {commit_oid!r} did not expose a committer date")
|
|
145
|
+
return date
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def update_ref(repo: Path, ref: str, new_oid: str | None, *, old_oid: str | None = None) -> None:
|
|
149
|
+
"""Create, compare-and-swap, or delete a ref."""
|
|
150
|
+
|
|
151
|
+
if new_oid is None:
|
|
152
|
+
args = ["update-ref", "-d", ref]
|
|
153
|
+
if old_oid is not None:
|
|
154
|
+
args.append(old_oid)
|
|
155
|
+
else:
|
|
156
|
+
args = ["update-ref", ref, new_oid]
|
|
157
|
+
if old_oid is not None:
|
|
158
|
+
args.append(old_oid)
|
|
159
|
+
run_git(args, cwd=repo)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def ls_remote(repo: Path, remote: str, *patterns: str) -> tuple[RemoteRef, ...]:
|
|
163
|
+
"""Read refs advertised by a remote without fetching or updating local refs."""
|
|
164
|
+
|
|
165
|
+
output = run_git(("ls-remote", remote, *patterns), cwd=repo).stdout
|
|
166
|
+
refs: list[RemoteRef] = []
|
|
167
|
+
for line in output.splitlines():
|
|
168
|
+
raw_oid, raw_ref = line.split(b"\t", maxsplit=1)
|
|
169
|
+
refs.append(RemoteRef(oid=raw_oid.decode("ascii"), ref=raw_ref.decode("utf-8")))
|
|
170
|
+
return tuple(refs)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Generated projection-ref naming and idempotent remote synchronization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from git_paoding.gitio.plumbing import ls_remote, update_ref
|
|
9
|
+
from git_paoding.gitio.runner import run_git
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class GeneratedRefs:
|
|
14
|
+
"""The two branch refs backing one slice pull request."""
|
|
15
|
+
|
|
16
|
+
base: str
|
|
17
|
+
head: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class RefSyncResult:
|
|
22
|
+
"""Which generated refs required a remote repair or refresh."""
|
|
23
|
+
|
|
24
|
+
refs: GeneratedRefs
|
|
25
|
+
base_pushed: bool
|
|
26
|
+
head_pushed: bool
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def is_no_op(self) -> bool:
|
|
30
|
+
"""Return whether the remote already advertised both desired OIDs."""
|
|
31
|
+
|
|
32
|
+
return not self.base_pushed and not self.head_pushed
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class RefDeleteResult:
|
|
37
|
+
"""Which generated refs existed remotely and were deleted."""
|
|
38
|
+
|
|
39
|
+
refs: GeneratedRefs
|
|
40
|
+
base_deleted: bool
|
|
41
|
+
head_deleted: bool
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def is_no_op(self) -> bool:
|
|
45
|
+
"""Return whether the remote already lacked both generated refs."""
|
|
46
|
+
|
|
47
|
+
return not self.base_deleted and not self.head_deleted
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def generated_refs(branch_key: str, slice_id: str) -> GeneratedRefs:
|
|
51
|
+
"""Return generated base and head ref names for one branch and review slice."""
|
|
52
|
+
|
|
53
|
+
if not branch_key or "/" in branch_key:
|
|
54
|
+
raise ValueError("branch_key must be a non-empty single ref component")
|
|
55
|
+
if not slice_id or "/" in slice_id:
|
|
56
|
+
raise ValueError("slice_id must be a non-empty single ref component")
|
|
57
|
+
prefix = f"refs/heads/paoding/{branch_key}/{slice_id}"
|
|
58
|
+
return GeneratedRefs(base=f"{prefix}/base", head=f"{prefix}/head")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def update_local_projection_refs(
|
|
62
|
+
repo: Path,
|
|
63
|
+
refs: GeneratedRefs,
|
|
64
|
+
*,
|
|
65
|
+
base_oid: str,
|
|
66
|
+
head_oid: str,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Point both local generated refs at deterministic projection commits."""
|
|
69
|
+
|
|
70
|
+
update_ref(repo, refs.base, base_oid)
|
|
71
|
+
update_ref(repo, refs.head, head_oid)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _force_push(repo: Path, remote: str, ref: str) -> None:
|
|
75
|
+
run_git(("push", "--force", remote, f"{ref}:{ref}"), cwd=repo)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _delete_remote_ref(repo: Path, remote: str, ref: str) -> None:
|
|
79
|
+
run_git(("push", remote, "--delete", ref), cwd=repo)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def sync_projection_refs(
|
|
83
|
+
repo: Path,
|
|
84
|
+
remote: str,
|
|
85
|
+
refs: GeneratedRefs,
|
|
86
|
+
*,
|
|
87
|
+
base_oid: str,
|
|
88
|
+
head_oid: str,
|
|
89
|
+
) -> RefSyncResult:
|
|
90
|
+
"""Repair remote projection refs using one authoritative ``ls-remote``.
|
|
91
|
+
|
|
92
|
+
Local refs are updated from the deterministic desired OIDs, but each remote
|
|
93
|
+
push occurs only when the batched advertisement differs. Base is always
|
|
94
|
+
pushed before head so an interrupted publication is safely repairable by a
|
|
95
|
+
later call without any session-side OID cache.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
update_local_projection_refs(
|
|
99
|
+
repo,
|
|
100
|
+
refs,
|
|
101
|
+
base_oid=base_oid,
|
|
102
|
+
head_oid=head_oid,
|
|
103
|
+
)
|
|
104
|
+
advertised = {item.ref: item.oid for item in ls_remote(repo, remote, refs.base, refs.head)}
|
|
105
|
+
base_pushed = advertised.get(refs.base) != base_oid
|
|
106
|
+
head_pushed = advertised.get(refs.head) != head_oid
|
|
107
|
+
|
|
108
|
+
if base_pushed:
|
|
109
|
+
_force_push(repo, remote, refs.base)
|
|
110
|
+
if head_pushed:
|
|
111
|
+
_force_push(repo, remote, refs.head)
|
|
112
|
+
|
|
113
|
+
return RefSyncResult(
|
|
114
|
+
refs=refs,
|
|
115
|
+
base_pushed=base_pushed,
|
|
116
|
+
head_pushed=head_pushed,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def delete_projection_refs(repo: Path, remote: str, refs: GeneratedRefs) -> RefDeleteResult:
|
|
121
|
+
"""Delete one archived slice's generated refs locally and remotely.
|
|
122
|
+
|
|
123
|
+
Remote existence is read in one batch so retries skip refs that an earlier
|
|
124
|
+
attempt already removed. The head disappears before the base, reversing
|
|
125
|
+
publication order and avoiding an intermediate advertised head whose
|
|
126
|
+
generated base has already gone away. Local derived refs are removed only
|
|
127
|
+
after remote cleanup succeeds.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
advertised = {item.ref: item.oid for item in ls_remote(repo, remote, refs.base, refs.head)}
|
|
131
|
+
base_deleted = refs.base in advertised
|
|
132
|
+
head_deleted = refs.head in advertised
|
|
133
|
+
|
|
134
|
+
if head_deleted:
|
|
135
|
+
_delete_remote_ref(repo, remote, refs.head)
|
|
136
|
+
if base_deleted:
|
|
137
|
+
_delete_remote_ref(repo, remote, refs.base)
|
|
138
|
+
|
|
139
|
+
update_ref(repo, refs.head, None)
|
|
140
|
+
update_ref(repo, refs.base, None)
|
|
141
|
+
return RefDeleteResult(
|
|
142
|
+
refs=refs,
|
|
143
|
+
base_deleted=base_deleted,
|
|
144
|
+
head_deleted=head_deleted,
|
|
145
|
+
)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""The single process boundary for invoking Git."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Mapping, Sequence
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GitFailureKind(str, Enum):
|
|
14
|
+
"""Stable categories for failures reported by Git."""
|
|
15
|
+
|
|
16
|
+
NOT_REPOSITORY = "not-repository"
|
|
17
|
+
UNKNOWN_REVISION = "unknown-revision"
|
|
18
|
+
MISSING_OBJECT = "missing-object"
|
|
19
|
+
INVALID_INPUT = "invalid-input"
|
|
20
|
+
REMOTE = "remote"
|
|
21
|
+
OTHER = "other"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class GitResult:
|
|
26
|
+
"""Successful Git command output."""
|
|
27
|
+
|
|
28
|
+
stdout: bytes
|
|
29
|
+
stderr: str
|
|
30
|
+
|
|
31
|
+
def stdout_text(self) -> str:
|
|
32
|
+
"""Decode standard output without losing unusual path bytes."""
|
|
33
|
+
|
|
34
|
+
return self.stdout.decode("utf-8", errors="surrogateescape")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GitError(RuntimeError):
|
|
38
|
+
"""Base class for failures at the Git process boundary."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GitUnavailableError(GitError):
|
|
42
|
+
"""Raised when the Git executable cannot be found."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class GitCommandError(GitError):
|
|
46
|
+
"""A non-zero Git command result with a mapped failure category."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
*,
|
|
51
|
+
args: tuple[str, ...],
|
|
52
|
+
cwd: Path,
|
|
53
|
+
returncode: int,
|
|
54
|
+
stderr: str,
|
|
55
|
+
kind: GitFailureKind,
|
|
56
|
+
) -> None:
|
|
57
|
+
self.args_list = args
|
|
58
|
+
self.cwd = cwd
|
|
59
|
+
self.returncode = returncode
|
|
60
|
+
self.stderr = stderr
|
|
61
|
+
self.kind = kind
|
|
62
|
+
detail = stderr.strip() or "Git exited without an error message"
|
|
63
|
+
super().__init__(f"git {' '.join(args)} failed in {cwd}: {detail}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _classify_failure(stderr: str) -> GitFailureKind:
|
|
67
|
+
normalized = stderr.casefold()
|
|
68
|
+
if "not a git repository" in normalized:
|
|
69
|
+
return GitFailureKind.NOT_REPOSITORY
|
|
70
|
+
if any(
|
|
71
|
+
marker in normalized
|
|
72
|
+
for marker in (
|
|
73
|
+
"unknown revision",
|
|
74
|
+
"ambiguous argument",
|
|
75
|
+
"needed a single revision",
|
|
76
|
+
"not a valid object name",
|
|
77
|
+
)
|
|
78
|
+
):
|
|
79
|
+
return GitFailureKind.UNKNOWN_REVISION
|
|
80
|
+
if any(marker in normalized for marker in ("missing blob", "missing tree", "bad object")):
|
|
81
|
+
return GitFailureKind.MISSING_OBJECT
|
|
82
|
+
if any(marker in normalized for marker in ("malformed", "invalid path", "invalid object")):
|
|
83
|
+
return GitFailureKind.INVALID_INPUT
|
|
84
|
+
if any(
|
|
85
|
+
marker in normalized
|
|
86
|
+
for marker in (
|
|
87
|
+
"could not read from remote",
|
|
88
|
+
"could not resolve host",
|
|
89
|
+
"authentication failed",
|
|
90
|
+
)
|
|
91
|
+
):
|
|
92
|
+
return GitFailureKind.REMOTE
|
|
93
|
+
return GitFailureKind.OTHER
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def run_git(
|
|
97
|
+
args: Sequence[str],
|
|
98
|
+
*,
|
|
99
|
+
cwd: Path,
|
|
100
|
+
input_data: bytes | None = None,
|
|
101
|
+
env: Mapping[str, str] | None = None,
|
|
102
|
+
) -> GitResult:
|
|
103
|
+
"""Run Git in an explicit repository directory and return byte-preserving output."""
|
|
104
|
+
|
|
105
|
+
command_args = tuple(args)
|
|
106
|
+
process_env = os.environ.copy()
|
|
107
|
+
process_env["LC_ALL"] = "C"
|
|
108
|
+
if env is not None:
|
|
109
|
+
process_env.update(env)
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
completed = subprocess.run(
|
|
113
|
+
("git", *command_args),
|
|
114
|
+
cwd=cwd,
|
|
115
|
+
env=process_env,
|
|
116
|
+
input=input_data,
|
|
117
|
+
stdout=subprocess.PIPE,
|
|
118
|
+
stderr=subprocess.PIPE,
|
|
119
|
+
check=False,
|
|
120
|
+
)
|
|
121
|
+
except FileNotFoundError as error:
|
|
122
|
+
raise GitUnavailableError("Git executable was not found on PATH") from error
|
|
123
|
+
|
|
124
|
+
stderr = completed.stderr.decode("utf-8", errors="surrogateescape")
|
|
125
|
+
if completed.returncode != 0:
|
|
126
|
+
raise GitCommandError(
|
|
127
|
+
args=command_args,
|
|
128
|
+
cwd=cwd,
|
|
129
|
+
returncode=completed.returncode,
|
|
130
|
+
stderr=stderr,
|
|
131
|
+
kind=_classify_failure(stderr),
|
|
132
|
+
)
|
|
133
|
+
return GitResult(stdout=completed.stdout, stderr=stderr)
|
git_paoding/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Versioned JSON session persistence in the repository's common Git directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic import ValidationError
|
|
14
|
+
|
|
15
|
+
from git_paoding.core.model import (
|
|
16
|
+
SCHEMA_VERSION,
|
|
17
|
+
Session,
|
|
18
|
+
SessionNotFoundError,
|
|
19
|
+
SessionValidationError,
|
|
20
|
+
UnsupportedSchemaVersionError,
|
|
21
|
+
)
|
|
22
|
+
from git_paoding.gitio.runner import run_git
|
|
23
|
+
|
|
24
|
+
_UNSAFE_BRANCH_CHARACTERS = re.compile(r"[^A-Za-z0-9._-]+")
|
|
25
|
+
_REPEATED_DASHES = re.compile(r"-{2,}")
|
|
26
|
+
_BRANCH_STEM_LIMIT = 64
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def branch_key(canonical_branch: str) -> str:
|
|
30
|
+
"""Return a filesystem- and ref-safe stable key for a canonical branch."""
|
|
31
|
+
|
|
32
|
+
if not canonical_branch:
|
|
33
|
+
raise ValueError("canonical branch must not be empty")
|
|
34
|
+
sanitized = _UNSAFE_BRANCH_CHARACTERS.sub("-", canonical_branch)
|
|
35
|
+
sanitized = _REPEATED_DASHES.sub("-", sanitized).strip("-._")
|
|
36
|
+
stem = sanitized[:_BRANCH_STEM_LIMIT].rstrip("-._") or "branch"
|
|
37
|
+
digest = hashlib.sha256(canonical_branch.encode("utf-8")).hexdigest()[:8]
|
|
38
|
+
return f"{stem}-{digest}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def common_git_dir(repo: Path) -> Path:
|
|
42
|
+
"""Resolve Git's common directory, shared by the main and linked worktrees."""
|
|
43
|
+
|
|
44
|
+
repository = repo.resolve()
|
|
45
|
+
raw_path = run_git(("rev-parse", "--git-common-dir"), cwd=repository).stdout_text().strip()
|
|
46
|
+
path = Path(raw_path)
|
|
47
|
+
if not path.is_absolute():
|
|
48
|
+
path = repository / path
|
|
49
|
+
return path.resolve()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def paoding_dir(repo: Path) -> Path:
|
|
53
|
+
"""Return the local metadata root without creating it."""
|
|
54
|
+
|
|
55
|
+
return common_git_dir(repo) / "paoding"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class JsonSessionStore:
|
|
59
|
+
"""Load and atomically save one JSON session per canonical branch."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, repo: Path) -> None:
|
|
62
|
+
self.repo = repo.resolve()
|
|
63
|
+
self.root = paoding_dir(self.repo)
|
|
64
|
+
self.sessions_dir = self.root / "sessions"
|
|
65
|
+
|
|
66
|
+
def session_path(self, canonical_branch: str) -> Path:
|
|
67
|
+
"""Return the canonical path for a branch's session JSON."""
|
|
68
|
+
|
|
69
|
+
return self.sessions_dir / f"{branch_key(canonical_branch)}.json"
|
|
70
|
+
|
|
71
|
+
def exists(self, canonical_branch: str) -> bool:
|
|
72
|
+
"""Return whether a session exists without creating metadata directories."""
|
|
73
|
+
|
|
74
|
+
return self.session_path(canonical_branch).is_file()
|
|
75
|
+
|
|
76
|
+
def load(self, canonical_branch: str) -> Session:
|
|
77
|
+
"""Load and validate a session, rejecting unsupported schemas explicitly."""
|
|
78
|
+
|
|
79
|
+
path = self.session_path(canonical_branch)
|
|
80
|
+
try:
|
|
81
|
+
raw = path.read_text(encoding="utf-8")
|
|
82
|
+
except FileNotFoundError as error:
|
|
83
|
+
raise SessionNotFoundError(
|
|
84
|
+
f"No git-paoding session exists for branch {canonical_branch!r}"
|
|
85
|
+
) from error
|
|
86
|
+
except OSError as error:
|
|
87
|
+
raise SessionValidationError(f"Could not read session file {path}: {error}") from error
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
payload: Any = json.loads(raw)
|
|
91
|
+
except json.JSONDecodeError as error:
|
|
92
|
+
raise SessionValidationError(
|
|
93
|
+
f"Session file {path} is not valid JSON: {error.msg}"
|
|
94
|
+
) from error
|
|
95
|
+
if not isinstance(payload, dict):
|
|
96
|
+
raise SessionValidationError(f"Session file {path} must contain a JSON object")
|
|
97
|
+
|
|
98
|
+
version = payload.get("schema_version")
|
|
99
|
+
if version != SCHEMA_VERSION:
|
|
100
|
+
rendered = "missing" if version is None else repr(version)
|
|
101
|
+
raise UnsupportedSchemaVersionError(
|
|
102
|
+
f"Session file {path} has unsupported schema_version {rendered}; "
|
|
103
|
+
f"this git-paoding version supports only {SCHEMA_VERSION}. "
|
|
104
|
+
"Automatic migration is intentionally disabled."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
session = Session.model_validate(payload)
|
|
109
|
+
except ValidationError as error:
|
|
110
|
+
raise SessionValidationError(f"Session file {path} is invalid: {error}") from error
|
|
111
|
+
if session.canonical_branch != canonical_branch:
|
|
112
|
+
raise SessionValidationError(
|
|
113
|
+
f"Session file {path} belongs to branch {session.canonical_branch!r}, "
|
|
114
|
+
f"not {canonical_branch!r}"
|
|
115
|
+
)
|
|
116
|
+
return session
|
|
117
|
+
|
|
118
|
+
def save(self, session: Session) -> Path:
|
|
119
|
+
"""Atomically persist a validated session and return its path."""
|
|
120
|
+
|
|
121
|
+
path = self.session_path(session.canonical_branch)
|
|
122
|
+
self.sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
serialized = session.model_dump_json(indent=2) + "\n"
|
|
124
|
+
|
|
125
|
+
temporary_path: Path | None = None
|
|
126
|
+
try:
|
|
127
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
128
|
+
dir=self.sessions_dir,
|
|
129
|
+
prefix=f".{path.name}.",
|
|
130
|
+
suffix=".tmp",
|
|
131
|
+
)
|
|
132
|
+
temporary_path = Path(temporary_name)
|
|
133
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
134
|
+
stream.write(serialized)
|
|
135
|
+
stream.flush()
|
|
136
|
+
os.fsync(stream.fileno())
|
|
137
|
+
os.replace(temporary_path, path)
|
|
138
|
+
except OSError as error:
|
|
139
|
+
if temporary_path is not None:
|
|
140
|
+
temporary_path.unlink(missing_ok=True)
|
|
141
|
+
raise SessionValidationError(f"Could not write session file {path}: {error}") from error
|
|
142
|
+
return path
|