compforge-repokit 0.15.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.
repokit/git.py ADDED
@@ -0,0 +1,296 @@
1
+ """Git operations with explicit arguments, bounded execution and observable failures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from .git_index import IndexView
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class GitResult:
18
+ rc: int
19
+ out: str
20
+ err: str
21
+ uncertain: bool = False
22
+
23
+ @property
24
+ def ok(self) -> bool:
25
+ return self.rc == 0
26
+
27
+
28
+ def git(
29
+ repo_dir: str | Path,
30
+ *args: str,
31
+ timeout: float = 5,
32
+ raw: bool = False,
33
+ index_file: Path | None = None,
34
+ ) -> GitResult:
35
+ env = {**os.environ, "GIT_INDEX_FILE": str(index_file.absolute())} if index_file else None
36
+ return _run(["git", "-C", str(repo_dir), *args], timeout, raw=raw, env=env)
37
+
38
+
39
+ def git_global(*args: str, timeout: float = 5) -> GitResult:
40
+ return _run(["git", *args], timeout)
41
+
42
+
43
+ def _run(
44
+ argv: list[str], timeout: float, *, raw: bool = False, env: dict[str, str] | None = None
45
+ ) -> GitResult:
46
+ from ._process import run_command
47
+ from .budget import current_budget
48
+
49
+ budget = current_budget(timeout)
50
+ # Expiry before launch is a known non-action; transport interruption after
51
+ # launch cannot prove that a write was rolled back.
52
+ try:
53
+ budget.remaining()
54
+ except (TimeoutError, InterruptedError) as exc:
55
+ return GitResult(-1, "", str(exc))
56
+ try:
57
+ result = run_command(argv, budget, env=env)
58
+ out = os.fsdecode(result.stdout)
59
+ return GitResult(
60
+ 0, out if raw or "\0" in out else out.strip(), os.fsdecode(result.stderr).strip()
61
+ )
62
+ except subprocess.CalledProcessError as exc:
63
+ out = os.fsdecode(exc.output or b"")
64
+ return GitResult(
65
+ exc.returncode,
66
+ out if raw or "\0" in out else out.strip(),
67
+ os.fsdecode(exc.stderr or b"").strip(),
68
+ )
69
+ except (TimeoutError, InterruptedError, ValueError) as exc:
70
+ return GitResult(-1, "", str(exc), uncertain=True)
71
+ except OSError as exc:
72
+ return GitResult(-1, "", str(exc))
73
+
74
+
75
+ def stage(
76
+ repo: str | Path,
77
+ paths: list[str],
78
+ *,
79
+ validate: Callable[[IndexView], None] | None = None,
80
+ ) -> GitResult:
81
+ """Stage literal paths, retaining unrelated entries.
82
+
83
+ With validate, prepare an isolated index and pass its IndexView (entries and
84
+ changes against HEAD) to the callback. A raised exception propagates and leaves
85
+ the original index untouched, as does a failed add. Empty paths validate the
86
+ current index.
87
+ The callback must only inspect facts: the real index is locked until installation.
88
+ Git objects/clean-filter side effects are not rolled back.
89
+ """
90
+ if validate is not None:
91
+ from ._staging import stage_validated
92
+
93
+ return stage_validated(repo, paths, validate)
94
+ if not paths:
95
+ raise ValueError("stage requires explicit paths")
96
+ return git(repo, "--literal-pathspecs", "add", "--", *paths, timeout=30)
97
+
98
+
99
+ def create_branch(repo: str | Path, name: str, base: str) -> GitResult:
100
+ """Create and check out a branch at a resolved commit; never stash implicitly."""
101
+ resolved = git(repo, "rev-parse", "--verify", "--end-of-options", f"{base}^{{commit}}")
102
+ return git(repo, "checkout", "-b", name, resolved.out, timeout=30) if resolved.ok else resolved
103
+
104
+
105
+ def commit(repo: str | Path, message: str) -> GitResult:
106
+ """Commit the current index. Selection and validation belong to the caller."""
107
+ return git(repo, "commit", "-m", message, timeout=120)
108
+
109
+
110
+ def push(
111
+ repo: str | Path,
112
+ branch: str,
113
+ *,
114
+ remote: str = "origin",
115
+ upstream: bool = False,
116
+ lease: str | None = None,
117
+ ) -> GitResult:
118
+ args = ["push"]
119
+ if upstream:
120
+ args.append("--set-upstream")
121
+ if lease is not None:
122
+ args.append(f"--force-with-lease=refs/heads/{branch}:{lease}")
123
+ return git(repo, *args, "--", remote, f"HEAD:refs/heads/{branch}", timeout=120)
124
+
125
+
126
+ def rebase(repo: str | Path, base: str) -> GitResult:
127
+ """Leave conflicts in place for the caller; never auto-abort or discard work."""
128
+ resolved = git(repo, "rev-parse", "--verify", "--end-of-options", f"{base}^{{commit}}")
129
+ return git(repo, "rebase", resolved.out, timeout=120) if resolved.ok else resolved
130
+
131
+
132
+ def continue_rebase(repo: str | Path, *, editor: str | None = None) -> GitResult:
133
+ """Continue staged conflict resolutions; optionally set core.editor for this call.
134
+
135
+ Git's editor environment variables still take precedence over core.editor.
136
+ Failure leaves the operation in place for the caller to inspect.
137
+ """
138
+ config = ["-c", f"core.editor={editor}"] if editor is not None else []
139
+ return git(repo, *config, "rebase", "--continue", timeout=120)
140
+
141
+
142
+ def abort_rebase(repo: str | Path) -> GitResult:
143
+ """Ask Git to abort the current rebase and restore its original checkout state."""
144
+ return git(repo, "rebase", "--abort", timeout=30)
145
+
146
+
147
+ def add_worktree(
148
+ repo: str | Path, path: str | Path, ref: str, *, branch: str | None = None, detach: bool = False
149
+ ) -> GitResult:
150
+ if branch is not None and detach:
151
+ raise ValueError("branch and detach are mutually exclusive")
152
+ args = ["worktree", "add"]
153
+ if detach:
154
+ args.append("--detach")
155
+ if branch is not None:
156
+ args += ["-b", branch]
157
+ return git(repo, *args, "--", str(path), ref, timeout=30)
158
+
159
+
160
+ def remove_worktree(repo: str | Path, path: str | Path, *, force: bool = False) -> GitResult:
161
+ """Default removal preserves dirty and locked worktrees."""
162
+ return git(
163
+ repo,
164
+ "worktree",
165
+ "remove",
166
+ *(["--force", "--force"] if force else []),
167
+ "--",
168
+ str(path),
169
+ timeout=30,
170
+ )
171
+
172
+
173
+ def prune_worktrees(repo: str | Path) -> GitResult:
174
+ """Prune stale worktree registrations using Git's default expiration policy.
175
+
176
+ This removes metadata, not checkout directories or branches. Git preserves locked
177
+ worktree registrations. The caller decides when cleanup is appropriate.
178
+ """
179
+ return git(repo, "worktree", "prune", timeout=15)
180
+
181
+
182
+ def add_exclude(repo: str | Path, pattern: str) -> bool:
183
+ """Append one exact pattern to info/exclude; return whether a line was added.
184
+
185
+ Resolve metadata through Git so linked worktrees share the correct exclude file.
186
+ Preserve existing bytes and pattern whitespace. Git/filesystem failures propagate;
187
+ choosing patterns and accepting a best-effort failure are caller policies.
188
+ """
189
+ from .git_state import git_path
190
+
191
+ if not pattern or any(char in pattern for char in "\r\n\0"):
192
+ raise ValueError("exclude pattern must be one nonempty line without NUL")
193
+ path = git_path(repo, "info/exclude")
194
+ try:
195
+ existing = path.read_bytes()
196
+ except FileNotFoundError:
197
+ existing = b""
198
+ encoded = pattern.encode("utf-8")
199
+ if any(line.removesuffix(b"\r") == encoded for line in existing.split(b"\n")):
200
+ return False
201
+ path.parent.mkdir(parents=True, exist_ok=True)
202
+ separator = b"\n" if existing and not existing.endswith(b"\n") else b""
203
+ with path.open("ab") as output:
204
+ output.write(separator + encoded + b"\n")
205
+ return True
206
+
207
+
208
+ def changed_paths(repo: str | Path, *, base: str = "HEAD", head: str | None = None) -> list[str]:
209
+ """Changed paths, including both rename sides; Git failure remains an error."""
210
+ from ._process import Budget, run
211
+
212
+ budget = Budget(10, None)
213
+ root = Path(repo)
214
+ before = (
215
+ run(root, ["rev-parse", "--verify", "--end-of-options", f"{base}^{{commit}}"], budget)
216
+ .decode()
217
+ .strip()
218
+ )
219
+ args = ["diff", "--no-renames", "--name-only", "-z", before]
220
+ if head is not None:
221
+ after = (
222
+ run(root, ["rev-parse", "--verify", "--end-of-options", f"{head}^{{commit}}"], budget)
223
+ .decode()
224
+ .strip()
225
+ )
226
+ args.append(after)
227
+ output = run(root, [*args, "--"], budget)
228
+ if head is None:
229
+ output += run(root, ["ls-files", "--others", "--exclude-standard", "-z"], budget)
230
+ return list(
231
+ dict.fromkeys(os.fsdecode(p) for p in output.split(b"\0") if p and not p.endswith(b"/"))
232
+ )
233
+
234
+
235
+ def committed_paths(repo: str | Path, rev: str = "HEAD") -> list[str]:
236
+ """Paths changed by one commit, against its first parent (empty tree for roots)."""
237
+ from ._process import Budget, run
238
+
239
+ budget = Budget(10, None)
240
+ root = Path(repo)
241
+ sha = (
242
+ run(root, ["rev-parse", "--verify", "--end-of-options", f"{rev}^{{commit}}"], budget)
243
+ .decode()
244
+ .strip()
245
+ )
246
+ output = run(
247
+ root,
248
+ [
249
+ "diff-tree",
250
+ "--root",
251
+ "--diff-merges=first-parent",
252
+ "--no-commit-id",
253
+ "-r",
254
+ "--no-renames",
255
+ "--name-only",
256
+ "-z",
257
+ sha,
258
+ "--",
259
+ ],
260
+ budget,
261
+ )
262
+ return list(dict.fromkeys(os.fsdecode(p) for p in output.split(b"\0") if p))
263
+
264
+
265
+ def range_paths(repo: str | Path, base: str, head: str = "HEAD") -> list[str]:
266
+ """Branch changes from the merge base to head, preserving both rename sides."""
267
+ from ._process import Budget, run
268
+
269
+ budget = Budget(10, None)
270
+ root = Path(repo)
271
+ refs = [
272
+ run(root, ["rev-parse", "--verify", "--end-of-options", f"{ref}^{{commit}}"], budget)
273
+ .decode()
274
+ .strip()
275
+ for ref in (base, head)
276
+ ]
277
+ output = run(
278
+ root, ["diff", "--no-renames", "--name-only", "-z", f"{refs[0]}...{refs[1]}", "--"], budget
279
+ )
280
+ return list(dict.fromkeys(os.fsdecode(p) for p in output.split(b"\0") if p))
281
+
282
+
283
+ def fetch_branches(
284
+ repo: str | Path, branches: list[str], *, remote: str = "origin", timeout: float = 60
285
+ ) -> GitResult:
286
+ """Refresh explicit tracking refs, including remote rewrites and narrow fetch configs."""
287
+ from .git_state import validate_branch_name
288
+
289
+ if not branches:
290
+ raise ValueError("fetch requires explicit branches")
291
+ for branch in branches:
292
+ validate_branch_name(repo, branch)
293
+ # Validate the destination namespace before constructing refspecs.
294
+ validate_branch_name(repo, remote)
295
+ refs = [f"+refs/heads/{b}:refs/remotes/{remote}/{b}" for b in dict.fromkeys(branches)]
296
+ return git(repo, "fetch", remote, *refs, timeout=timeout)
repokit/git_index.py ADDED
@@ -0,0 +1,275 @@
1
+ """Working-tree and index facts, preserving literal repository-relative paths."""
2
+
3
+ import hashlib
4
+ import os
5
+ import stat
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ from . import git as gitcmd
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class StatusEntry:
14
+ path: str
15
+ index_status: str
16
+ worktree_status: str
17
+ original_path: str | None = None
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class IndexChange:
22
+ path: str
23
+ old_mode: str
24
+ new_mode: str
25
+ status: str
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class IndexEntry:
30
+ """One Git index entry; mode and oid retain Git's meaning, including gitlinks."""
31
+
32
+ path: str
33
+ mode: str
34
+ oid: str
35
+ stage: int
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class IndexView:
40
+ """Captured index entries and changes, with config queries bound to their blob IDs.
41
+
42
+ Gitlinks are entries like any other path. Registration and commit acceptance
43
+ belong to consumers. Queries never fall back to working-tree files.
44
+ """
45
+
46
+ changes: tuple[IndexChange, ...]
47
+ entries: tuple[IndexEntry, ...]
48
+ _repo: Path = field(repr=False)
49
+
50
+ def config_values(self, path: str, pattern: str) -> list[str]:
51
+ """Read matching Git-config values from a regular file in this index.
52
+
53
+ Missing paths or keys return an empty list. Unmerged/non-file entries,
54
+ malformed config and failed queries raise. Includes are not followed.
55
+ """
56
+ entries = [entry for entry in self.entries if entry.path == path]
57
+ if not entries:
58
+ return []
59
+ if len(entries) != 1 or entries[0].stage != 0:
60
+ raise OSError(f"unmerged config entry: {path}")
61
+ entry = entries[0]
62
+ if entry.mode not in ("100644", "100755"):
63
+ raise OSError(f"config entry is not a regular file: {path}")
64
+ result = gitcmd.git(
65
+ self._repo,
66
+ "config",
67
+ "--no-includes",
68
+ "--null",
69
+ "--blob",
70
+ entry.oid,
71
+ "--get-regexp",
72
+ pattern,
73
+ raw=True,
74
+ )
75
+ # Git's blob reader also returns 1 for malformed config, with stderr.
76
+ # Only a clean no-match result means an empty selection.
77
+ if result.rc == 1 and not result.err:
78
+ return []
79
+ if not result.ok:
80
+ raise OSError(result.err or f"cannot read index config: {path}")
81
+ return [record.partition("\n")[2] for record in result.out.split("\0") if record]
82
+
83
+
84
+ def index_view(repo: str | Path, *, index_file: Path | None = None) -> IndexView:
85
+ """Capture index facts; callers must serialize writes while this is collected."""
86
+ entries = []
87
+ for record in _query(
88
+ repo, "ls-files", "--stage", "--full-name", "-z", index_file=index_file
89
+ ).split("\0"):
90
+ if not record:
91
+ continue
92
+ metadata, separator, path = record.partition("\t")
93
+ fields = metadata.split()
94
+ if not separator or not path or len(fields) != 3 or fields[2] not in ("0", "1", "2", "3"):
95
+ raise OSError("invalid Git index entry")
96
+ entries.append(IndexEntry(path, fields[0], fields[1], int(fields[2])))
97
+ return IndexView(
98
+ tuple(staged_changes(repo, index_file=index_file)), tuple(entries), Path(repo).resolve()
99
+ )
100
+
101
+
102
+ def _query(repo: str | Path, *args: str, index_file: Path | None = None) -> str:
103
+ result = gitcmd.git(repo, *args, raw=True, index_file=index_file)
104
+ if not result.ok:
105
+ raise OSError(f"cannot read Git {args[0]}: {result.err}")
106
+ return result.out
107
+
108
+
109
+ def status_entries(repo: str | Path) -> list[StatusEntry]:
110
+ """Status of each changed/untracked file, excluding ignored files.
111
+
112
+ Rename/copy entries retain both paths. Failures raise; an empty list means clean.
113
+ Untracked directories are expanded so callers can select individual files.
114
+ """
115
+ records = iter(
116
+ _query(
117
+ repo, "--no-optional-locks", "status", "--porcelain=v1", "-z", "--untracked-files=all"
118
+ ).split("\0")
119
+ )
120
+ entries = []
121
+ for record in records:
122
+ if not record:
123
+ continue
124
+ if len(record) < 4 or record[2] != " ":
125
+ raise OSError("invalid Git status entry")
126
+ original = None
127
+ if "R" in record[:2] or "C" in record[:2]:
128
+ original = next(records, "")
129
+ if not original:
130
+ raise OSError("missing Git rename/copy source")
131
+ entries.append(StatusEntry(record[3:], record[0], record[1], original))
132
+ return entries
133
+
134
+
135
+ def deleted_tracked_paths(repo: str | Path) -> list[str]:
136
+ """Tracked paths absent from the working tree; failed reads raise."""
137
+ return list(
138
+ dict.fromkeys(
139
+ p for p in _query(repo, "ls-files", "--deleted", "--full-name", "-z").split("\0") if p
140
+ )
141
+ )
142
+
143
+
144
+ def staged_changes(repo: str | Path, *, index_file: Path | None = None) -> list[IndexChange]:
145
+ """Index changes against HEAD (the empty tree for an unborn branch).
146
+
147
+ Renames are represented as deletion/addition, so each path retains its own
148
+ old/new mode. This also preserves gitlink removals and type changes.
149
+ """
150
+ records = iter(
151
+ _query(
152
+ repo,
153
+ "diff",
154
+ "--cached",
155
+ "--raw",
156
+ "--no-renames",
157
+ "--no-ext-diff",
158
+ "-z",
159
+ "--",
160
+ index_file=index_file,
161
+ ).split("\0")
162
+ )
163
+ entries = []
164
+ for record in records:
165
+ if not record:
166
+ continue
167
+ fields = record.split()
168
+ path = next(records, "")
169
+ if len(fields) != 5 or not fields[0].startswith(":") or not path:
170
+ raise OSError("invalid Git index change")
171
+ entries.append(IndexChange(path, fields[0][1:], fields[1], fields[4]))
172
+ return entries
173
+
174
+
175
+ def registered_submodules(repo: str | Path) -> list[str]:
176
+ """Paths declared by the working-tree .gitmodules, without initializing them.
177
+
178
+ An absent file or absent declarations means no registrations. Invalid config
179
+ and failed reads raise rather than presenting unregistered paths as known.
180
+ """
181
+ root = _query(repo, "rev-parse", "--show-toplevel").removesuffix("\n")
182
+ manifest = Path(root) / ".gitmodules"
183
+ if not manifest.exists():
184
+ return []
185
+ result = gitcmd.git(
186
+ repo,
187
+ "config",
188
+ "--null",
189
+ "--file",
190
+ str(manifest),
191
+ "--get-regexp",
192
+ r"^submodule\..*\.path$",
193
+ raw=True,
194
+ )
195
+ if result.rc == 1: # git config distinguishes no matching keys from read errors.
196
+ return []
197
+ if not result.ok:
198
+ raise OSError(result.err or "cannot read submodule registrations")
199
+ return [record.split("\n", 1)[1] for record in result.out.split("\0") if record]
200
+
201
+
202
+ def staged_patch(repo: str | Path, path: str) -> str:
203
+ """Raw zero-context patch for a literal path; failed reads raise."""
204
+ return _query(
205
+ repo,
206
+ "--literal-pathspecs",
207
+ "diff",
208
+ "--cached",
209
+ "--no-ext-diff",
210
+ "--no-textconv",
211
+ "-U0",
212
+ "--",
213
+ path,
214
+ )
215
+
216
+
217
+ def working_tree_differences(repo: str | Path, index: IndexView) -> list[str]:
218
+ """Paths whose raw working bytes/modes differ from a captured index.
219
+
220
+ @spec Clean filters are not applied: transformed staged bytes are a difference.
221
+ Includes untracked files, deletions, symlinks, conflicts and gitlink references.
222
+ Does not validate the project, acquire a writer lock or change the index.
223
+ @link component://docs/git.md
224
+ """
225
+ root = Path(repo).resolve()
226
+ listed = gitcmd.git(
227
+ repo, "ls-files", "--cached", "--others", "--exclude-standard", "-z", raw=True
228
+ )
229
+ if not listed.ok:
230
+ raise OSError(f"cannot observe working-tree inputs: {listed.err}")
231
+ original = {entry.path: entry for entry in index_view(repo).entries}
232
+ names = {
233
+ name
234
+ for name in listed.out.split("\0")
235
+ if name
236
+ and (os.path.lexists(root / name) or original.get(name) and original[name].mode == "160000")
237
+ }
238
+ candidates = {entry.path: entry for entry in index.entries}
239
+ different = names.symmetric_difference(candidates)
240
+ for name in names.intersection(candidates):
241
+ entry, path = candidates[name], root / name
242
+ if entry.stage != 0:
243
+ different.add(name)
244
+ continue
245
+ if entry.mode == "160000":
246
+ # A deinitialized gitlink has no child checkout; its index OID is the input.
247
+ if (path / ".git").exists():
248
+ child = gitcmd.git(path, "rev-parse", "HEAD")
249
+ if not child.ok:
250
+ raise OSError(f"cannot read gitlink {name!r}: {child.err}")
251
+ oid = child.out.strip()
252
+ else:
253
+ oid = original[name].oid if name in original else ""
254
+ if entry.oid != oid:
255
+ different.add(name)
256
+ continue
257
+ info = path.lstat()
258
+ algorithm = "sha256" if len(entry.oid) == 64 else "sha1"
259
+ if stat.S_ISLNK(info.st_mode):
260
+ data = os.fsencode(os.readlink(path))
261
+ digest = hashlib.new(algorithm, f"blob {len(data)}\0".encode() + data).hexdigest()
262
+ mode = "120000"
263
+ elif stat.S_ISREG(info.st_mode):
264
+ hasher = hashlib.new(algorithm, f"blob {info.st_size}\0".encode())
265
+ with path.open("rb") as source:
266
+ while chunk := source.read(65536):
267
+ hasher.update(chunk)
268
+ digest = hasher.hexdigest()
269
+ mode = "100755" if info.st_mode & stat.S_IXUSR else "100644"
270
+ else:
271
+ different.add(name)
272
+ continue
273
+ if (entry.oid, entry.mode) != (digest, mode):
274
+ different.add(name)
275
+ return sorted(different)