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.
- compforge_repokit-0.15.0.dist-info/METADATA +114 -0
- compforge_repokit-0.15.0.dist-info/RECORD +37 -0
- compforge_repokit-0.15.0.dist-info/WHEEL +4 -0
- repokit/__init__.py +40 -0
- repokit/_git.py +177 -0
- repokit/_inspect.py +70 -0
- repokit/_language.py +23 -0
- repokit/_layout.py +187 -0
- repokit/_process.py +135 -0
- repokit/_staging.py +59 -0
- repokit/budget.py +40 -0
- repokit/dependencies.py +378 -0
- repokit/forge/__init__.py +13 -0
- repokit/forge/_http_state.py +92 -0
- repokit/forge/_page.py +34 -0
- repokit/forge/_rest.py +233 -0
- repokit/forge/creation.py +88 -0
- repokit/forge/github.py +440 -0
- repokit/forge/gitlab.py +407 -0
- repokit/forge/model.py +380 -0
- repokit/forge/observation/__init__.py +9 -0
- repokit/forge/observation/context.py +255 -0
- repokit/forge/observation/model.py +40 -0
- repokit/forge/observation/service.py +283 -0
- repokit/forge/observation/store.py +136 -0
- repokit/git.py +296 -0
- repokit/git_index.py +275 -0
- repokit/git_state.py +447 -0
- repokit/languages.json +1115 -0
- repokit/model.py +59 -0
- repokit/py.typed +0 -0
- repokit/remote.py +41 -0
- repokit/repository.py +84 -0
- repokit/snapshot.py +275 -0
- repokit/stash.py +66 -0
- repokit/tree.py +191 -0
- repokit/validation.py +508 -0
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)
|