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,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,6 @@
1
+ """Local session storage package."""
2
+
3
+ from git_paoding.store.jsonstore import JsonSessionStore, branch_key, common_git_dir
4
+ from git_paoding.store.lock import SessionLock
5
+
6
+ __all__ = ["JsonSessionStore", "SessionLock", "branch_key", "common_git_dir"]
@@ -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