cuff-cli 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.
- cuff/__init__.py +4 -0
- cuff/__main__.py +4 -0
- cuff/cli.py +230 -0
- cuff/errors.py +19 -0
- cuff/gate.py +206 -0
- cuff/git.py +113 -0
- cuff/ledger.py +640 -0
- cuff/subject.py +180 -0
- cuff/workspace.py +204 -0
- cuff_cli-0.1.0.dist-info/METADATA +162 -0
- cuff_cli-0.1.0.dist-info/RECORD +15 -0
- cuff_cli-0.1.0.dist-info/WHEEL +5 -0
- cuff_cli-0.1.0.dist-info/entry_points.txt +2 -0
- cuff_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- cuff_cli-0.1.0.dist-info/top_level.txt +1 -0
cuff/__init__.py
ADDED
cuff/__main__.py
ADDED
cuff/cli.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Cuff's intentionally small command line surface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .errors import CuffError
|
|
13
|
+
from .gate import check
|
|
14
|
+
from .ledger import append, create_claim, seal, verify
|
|
15
|
+
from .subject import declared_subject, filesystem_subject
|
|
16
|
+
from .workspace import find_workspace, initialize_workspace
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
20
|
+
parser = argparse.ArgumentParser(prog="cuff")
|
|
21
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
22
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
23
|
+
|
|
24
|
+
initialize = commands.add_parser("init", help="initialize a Cuff workspace")
|
|
25
|
+
initialize.add_argument("--workspace", type=Path)
|
|
26
|
+
initialize.add_argument("--json", action="store_true")
|
|
27
|
+
|
|
28
|
+
claim = commands.add_parser("claim", help="record a completion claim")
|
|
29
|
+
claim.add_argument("--workspace", type=Path)
|
|
30
|
+
claim.add_argument("--work-item", required=True)
|
|
31
|
+
claim.add_argument("--summary", required=True)
|
|
32
|
+
_add_subject_arguments(claim)
|
|
33
|
+
claim.add_argument("--actor")
|
|
34
|
+
claim.add_argument("--json", action="store_true")
|
|
35
|
+
|
|
36
|
+
verification = commands.add_parser("verify", help="run a command and record its result")
|
|
37
|
+
verification.add_argument("--workspace", type=Path)
|
|
38
|
+
verification.add_argument("--work-item", required=True)
|
|
39
|
+
verification.add_argument("--claim", required=True)
|
|
40
|
+
verification.add_argument("--timeout", type=float, default=300)
|
|
41
|
+
verification.add_argument("--actor")
|
|
42
|
+
verification.add_argument("--json", action="store_true")
|
|
43
|
+
verification.add_argument("verification_command", nargs=argparse.REMAINDER)
|
|
44
|
+
|
|
45
|
+
sealing = commands.add_parser("seal", help="atomically record a claim and executed evidence")
|
|
46
|
+
sealing.add_argument("--workspace", type=Path)
|
|
47
|
+
sealing.add_argument("--work-item", required=True)
|
|
48
|
+
sealing.add_argument("--summary", required=True)
|
|
49
|
+
_add_subject_arguments(sealing)
|
|
50
|
+
sealing.add_argument("--timeout", type=float, default=300)
|
|
51
|
+
sealing.add_argument("--actor")
|
|
52
|
+
sealing.add_argument("--json", action="store_true")
|
|
53
|
+
sealing.add_argument("verification_command", nargs=argparse.REMAINDER)
|
|
54
|
+
|
|
55
|
+
readiness = commands.add_parser("check", help="require fresh evidence for the latest claim")
|
|
56
|
+
readiness.add_argument("--workspace", type=Path)
|
|
57
|
+
readiness.add_argument("--work-item", required=True)
|
|
58
|
+
readiness.add_argument("--base")
|
|
59
|
+
readiness.add_argument("--head")
|
|
60
|
+
readiness.add_argument("--json", action="store_true")
|
|
61
|
+
return parser
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main(argv: list[str] | None = None) -> int:
|
|
65
|
+
parser = build_parser()
|
|
66
|
+
args = parser.parse_args(sys.argv[1:] if argv is None else argv)
|
|
67
|
+
try:
|
|
68
|
+
if args.command == "init":
|
|
69
|
+
data = initialize_workspace(args.workspace)
|
|
70
|
+
return _finish(args, data, f"Cuff project: {data['status']}")
|
|
71
|
+
|
|
72
|
+
root = find_workspace(args.workspace)
|
|
73
|
+
if args.command == "claim":
|
|
74
|
+
subject = _subject(root, args)
|
|
75
|
+
record = create_claim(root, args.work_item, args.summary, subject, args.actor)
|
|
76
|
+
path, line = append(root, record)
|
|
77
|
+
return _finish(
|
|
78
|
+
args,
|
|
79
|
+
{"ok": True, "record": record, "path": str(path), "line": line},
|
|
80
|
+
f"Claim {record['id']} recorded for {record['work_item']}",
|
|
81
|
+
)
|
|
82
|
+
if args.command == "verify":
|
|
83
|
+
observed = verify(
|
|
84
|
+
root,
|
|
85
|
+
args.work_item,
|
|
86
|
+
args.claim,
|
|
87
|
+
_verification_command(args.verification_command),
|
|
88
|
+
timeout=args.timeout,
|
|
89
|
+
actor=args.actor,
|
|
90
|
+
)
|
|
91
|
+
data = {
|
|
92
|
+
"ok": observed.record["exit_code"] == 0,
|
|
93
|
+
"record": observed.record,
|
|
94
|
+
"path": str(observed.path),
|
|
95
|
+
"line": observed.line,
|
|
96
|
+
"timed_out": observed.timed_out,
|
|
97
|
+
}
|
|
98
|
+
return _finish_observation(
|
|
99
|
+
args,
|
|
100
|
+
data,
|
|
101
|
+
observed.stdout,
|
|
102
|
+
observed.stderr,
|
|
103
|
+
f"Evidence {observed.record['id']} recorded: exit {observed.record['exit_code']}",
|
|
104
|
+
)
|
|
105
|
+
if args.command == "seal":
|
|
106
|
+
sealed = seal(
|
|
107
|
+
root,
|
|
108
|
+
args.work_item,
|
|
109
|
+
args.summary,
|
|
110
|
+
_subject(root, args),
|
|
111
|
+
_verification_command(args.verification_command),
|
|
112
|
+
timeout=args.timeout,
|
|
113
|
+
actor=args.actor,
|
|
114
|
+
)
|
|
115
|
+
data = {
|
|
116
|
+
"ok": sealed.evidence["exit_code"] == 0,
|
|
117
|
+
"claim": sealed.claim,
|
|
118
|
+
"evidence": sealed.evidence,
|
|
119
|
+
"path": str(sealed.path),
|
|
120
|
+
"lines": sealed.lines,
|
|
121
|
+
"timed_out": sealed.timed_out,
|
|
122
|
+
}
|
|
123
|
+
return _finish_observation(
|
|
124
|
+
args,
|
|
125
|
+
data,
|
|
126
|
+
sealed.stdout,
|
|
127
|
+
sealed.stderr,
|
|
128
|
+
f"Claim {sealed.claim['id']} and evidence {sealed.evidence['id']} recorded: "
|
|
129
|
+
f"exit {sealed.evidence['exit_code']}",
|
|
130
|
+
)
|
|
131
|
+
if args.command == "check":
|
|
132
|
+
data = check(root, args.work_item, base=args.base, head=args.head)
|
|
133
|
+
return _finish_result(args, data, "Cuff readiness")
|
|
134
|
+
parser.error("unknown command")
|
|
135
|
+
except CuffError as exc:
|
|
136
|
+
return _finish_error(args, exc)
|
|
137
|
+
except KeyboardInterrupt:
|
|
138
|
+
return 130
|
|
139
|
+
except Exception as exc:
|
|
140
|
+
if getattr(args, "json", False):
|
|
141
|
+
print(json.dumps({
|
|
142
|
+
"ok": False,
|
|
143
|
+
"errors": [{"code": "CUFF_UNEXPECTED", "message": f"{type(exc).__name__}: {exc}"}],
|
|
144
|
+
}, sort_keys=True, indent=2))
|
|
145
|
+
else:
|
|
146
|
+
print(f"CUFF_UNEXPECTED: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
147
|
+
return 3
|
|
148
|
+
return 2
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _add_subject_arguments(parser: argparse.ArgumentParser) -> None:
|
|
152
|
+
parser.add_argument("--subject-kind")
|
|
153
|
+
parser.add_argument("--subject-ref")
|
|
154
|
+
parser.add_argument("--subject-digest")
|
|
155
|
+
parser.add_argument("--subject-path", type=Path)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _subject(root: Path, args: argparse.Namespace) -> dict[str, str]:
|
|
159
|
+
declared = (args.subject_kind, args.subject_ref, args.subject_digest)
|
|
160
|
+
if args.subject_path is not None:
|
|
161
|
+
if any(value is not None for value in declared):
|
|
162
|
+
raise CuffError(
|
|
163
|
+
"CUFF_SUBJECT_INVALID",
|
|
164
|
+
"Choose either --subject-path or all declared subject arguments",
|
|
165
|
+
)
|
|
166
|
+
return filesystem_subject(root, args.subject_path)
|
|
167
|
+
if any(value is None for value in declared):
|
|
168
|
+
raise CuffError(
|
|
169
|
+
"CUFF_SUBJECT_REQUIRED",
|
|
170
|
+
"Pass --subject-path or --subject-kind, --subject-ref, and --subject-digest",
|
|
171
|
+
)
|
|
172
|
+
return declared_subject(*declared)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _verification_command(command: list[str]) -> list[str]:
|
|
176
|
+
return command[1:] if command[:1] == ["--"] else command
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _finish(args: argparse.Namespace, data: dict[str, Any], message: str) -> int:
|
|
180
|
+
if args.json:
|
|
181
|
+
print(json.dumps(data, sort_keys=True, indent=2))
|
|
182
|
+
else:
|
|
183
|
+
print(message)
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _finish_observation(
|
|
188
|
+
args: argparse.Namespace,
|
|
189
|
+
data: dict[str, Any],
|
|
190
|
+
stdout: bytes,
|
|
191
|
+
stderr: bytes,
|
|
192
|
+
message: str,
|
|
193
|
+
) -> int:
|
|
194
|
+
if args.json:
|
|
195
|
+
print(json.dumps(data, sort_keys=True, indent=2))
|
|
196
|
+
else:
|
|
197
|
+
_replay(stdout, sys.stdout)
|
|
198
|
+
_replay(stderr, sys.stderr)
|
|
199
|
+
print(message)
|
|
200
|
+
return 0 if data["ok"] else 1
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _finish_result(args: argparse.Namespace, data: dict[str, Any], label: str) -> int:
|
|
204
|
+
if args.json:
|
|
205
|
+
print(json.dumps(data, sort_keys=True, indent=2))
|
|
206
|
+
else:
|
|
207
|
+
print(f"{label}: {'PASS' if data['ok'] else 'FAIL'}")
|
|
208
|
+
for error in data["errors"]:
|
|
209
|
+
print(f"ERROR {error['code']}: {error['message']}")
|
|
210
|
+
return 0 if data["ok"] else 1
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _finish_error(args: argparse.Namespace, error: CuffError) -> int:
|
|
214
|
+
data = {"ok": False, "errors": [error.to_dict()]}
|
|
215
|
+
if getattr(args, "json", False):
|
|
216
|
+
print(json.dumps(data, sort_keys=True, indent=2))
|
|
217
|
+
else:
|
|
218
|
+
print(str(error), file=sys.stderr)
|
|
219
|
+
return 1
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _replay(content: bytes, stream: Any) -> None:
|
|
223
|
+
if content:
|
|
224
|
+
stream.write(content.decode(errors="replace"))
|
|
225
|
+
if not content.endswith(b"\n"):
|
|
226
|
+
stream.write("\n")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
if __name__ == "__main__":
|
|
230
|
+
raise SystemExit(main())
|
cuff/errors.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Small, stable failure shapes shared by the CLI and gate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class CuffError(Exception):
|
|
11
|
+
code: str
|
|
12
|
+
message: str
|
|
13
|
+
context: dict[str, Any] = field(default_factory=dict)
|
|
14
|
+
|
|
15
|
+
def __str__(self) -> str:
|
|
16
|
+
return f"{self.code}: {self.message}"
|
|
17
|
+
|
|
18
|
+
def to_dict(self) -> dict[str, Any]:
|
|
19
|
+
return {"code": self.code, "message": self.message, **self.context}
|
cuff/gate.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""The single latest-claim readiness decision."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .errors import CuffError
|
|
9
|
+
from .ledger import RECORDS_DIR, normalize_work_item, read, read_all
|
|
10
|
+
from .subject import current_subject
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def check(
|
|
14
|
+
root: Path,
|
|
15
|
+
work_item: str,
|
|
16
|
+
*,
|
|
17
|
+
base: str | None = None,
|
|
18
|
+
head: str | None = None,
|
|
19
|
+
) -> dict[str, Any]:
|
|
20
|
+
normalized = normalize_work_item(work_item)
|
|
21
|
+
result: dict[str, Any] = {
|
|
22
|
+
"ok": False,
|
|
23
|
+
"errors": [],
|
|
24
|
+
"work_item": normalized,
|
|
25
|
+
"latest_claim": None,
|
|
26
|
+
"selected_evidence": None,
|
|
27
|
+
"record_count": 0,
|
|
28
|
+
}
|
|
29
|
+
try:
|
|
30
|
+
read_all(root)
|
|
31
|
+
records = read(root, normalized)
|
|
32
|
+
except CuffError as exc:
|
|
33
|
+
result["errors"].append(exc.to_dict())
|
|
34
|
+
return result
|
|
35
|
+
|
|
36
|
+
result["record_count"] = len(records)
|
|
37
|
+
claims = [record for record in records if record["type"] == "claim"]
|
|
38
|
+
claim = claims[-1] if claims else None
|
|
39
|
+
result["latest_claim"] = claim
|
|
40
|
+
|
|
41
|
+
git_context = _check_git_workspace(root, base, head, result["errors"])
|
|
42
|
+
if claim is None:
|
|
43
|
+
_fail(result["errors"], "CUFF_CLAIM_MISSING", "No completion claim exists", work_item=normalized)
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
passing = [
|
|
47
|
+
record
|
|
48
|
+
for record in records
|
|
49
|
+
if record["type"] == "evidence"
|
|
50
|
+
and record["claim"] == claim["id"]
|
|
51
|
+
and record["exit_code"] == 0
|
|
52
|
+
]
|
|
53
|
+
for evidence in reversed(passing):
|
|
54
|
+
if not _subject_fresh(root, claim):
|
|
55
|
+
break
|
|
56
|
+
if git_context is None or not _evidence_fresh(root, evidence, head, git_context):
|
|
57
|
+
continue
|
|
58
|
+
result["selected_evidence"] = evidence
|
|
59
|
+
result["ok"] = not result["errors"]
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
_fail(
|
|
63
|
+
result["errors"],
|
|
64
|
+
"CUFF_EVIDENCE_MISSING",
|
|
65
|
+
"Latest claim has no fresh passing evidence",
|
|
66
|
+
work_item=normalized,
|
|
67
|
+
record_id=claim["id"],
|
|
68
|
+
)
|
|
69
|
+
return result
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _subject_fresh(root: Path, claim: dict[str, Any]) -> bool:
|
|
73
|
+
try:
|
|
74
|
+
return current_subject(root, claim["subject"]) == claim["subject"]
|
|
75
|
+
except CuffError:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _check_git_workspace(
|
|
80
|
+
root: Path,
|
|
81
|
+
base: str | None,
|
|
82
|
+
head: str | None,
|
|
83
|
+
errors: list[dict[str, Any]],
|
|
84
|
+
) -> tuple[Path, str] | None:
|
|
85
|
+
from . import git
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
workspace = root.resolve()
|
|
89
|
+
repository = git.repo_root(workspace)
|
|
90
|
+
if repository != workspace:
|
|
91
|
+
raise CuffError(
|
|
92
|
+
"CUFF_WORKSPACE_NOT_ROOT",
|
|
93
|
+
"Cuff workspace must be the Git worktree root",
|
|
94
|
+
{"workspace": str(workspace), "repository": str(repository)},
|
|
95
|
+
)
|
|
96
|
+
dirty = [
|
|
97
|
+
path for path in git.dirty_paths(repository)
|
|
98
|
+
if not _is_record_path(repository, workspace, path)
|
|
99
|
+
]
|
|
100
|
+
if dirty:
|
|
101
|
+
_fail(
|
|
102
|
+
errors,
|
|
103
|
+
"CUFF_REPOSITORY_DIRTY",
|
|
104
|
+
"Git readiness cannot evaluate uncommitted non-ledger changes",
|
|
105
|
+
paths=dirty,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
proposed_ref = head or "HEAD"
|
|
109
|
+
proposed = git.head(repository, proposed_ref)
|
|
110
|
+
comparison_base = _comparison_base(repository, base, proposed_ref)
|
|
111
|
+
if comparison_base is not None:
|
|
112
|
+
if not git.is_ancestor(comparison_base, proposed, repository):
|
|
113
|
+
_fail(
|
|
114
|
+
errors,
|
|
115
|
+
"CUFF_GIT_ANCESTRY",
|
|
116
|
+
"The comparison base is not an ancestor of the selected head",
|
|
117
|
+
base=comparison_base,
|
|
118
|
+
head=proposed,
|
|
119
|
+
)
|
|
120
|
+
_check_append_only(repository, workspace, comparison_base, head, errors)
|
|
121
|
+
return repository, proposed
|
|
122
|
+
except CuffError as exc:
|
|
123
|
+
errors.append(exc.to_dict())
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _comparison_base(repository: Path, base: str | None, proposed_ref: str) -> str | None:
|
|
128
|
+
from . import git
|
|
129
|
+
|
|
130
|
+
if base is not None:
|
|
131
|
+
return git.head(repository, base)
|
|
132
|
+
return git.default_base(repository, proposed_ref)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _check_append_only(
|
|
136
|
+
repository: Path,
|
|
137
|
+
root: Path,
|
|
138
|
+
base: str,
|
|
139
|
+
head: str | None,
|
|
140
|
+
errors: list[dict[str, Any]],
|
|
141
|
+
) -> None:
|
|
142
|
+
from . import git
|
|
143
|
+
|
|
144
|
+
pathspec = _record_prefix(repository, root)
|
|
145
|
+
for status, path in git.diff_status(repository, base, head, pathspec):
|
|
146
|
+
target = (
|
|
147
|
+
git.show_file(repository, head, path)
|
|
148
|
+
if head is not None
|
|
149
|
+
else (repository / path).read_bytes()
|
|
150
|
+
if (repository / path).exists()
|
|
151
|
+
else None
|
|
152
|
+
)
|
|
153
|
+
if status == "A" and target is not None and target.endswith(b"\n"):
|
|
154
|
+
continue
|
|
155
|
+
if status == "M":
|
|
156
|
+
original = git.show_file(repository, base, path)
|
|
157
|
+
if (
|
|
158
|
+
original is not None
|
|
159
|
+
and target is not None
|
|
160
|
+
and target.startswith(original)
|
|
161
|
+
and target.endswith(b"\n")
|
|
162
|
+
):
|
|
163
|
+
continue
|
|
164
|
+
_fail(
|
|
165
|
+
errors,
|
|
166
|
+
"CUFF_LEDGER_REWRITE",
|
|
167
|
+
"Ledger changes must only append complete lines",
|
|
168
|
+
path=path,
|
|
169
|
+
status=status,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _evidence_fresh(
|
|
174
|
+
root: Path,
|
|
175
|
+
evidence: dict[str, Any],
|
|
176
|
+
head: str | None,
|
|
177
|
+
git_context: tuple[Path, str],
|
|
178
|
+
) -> bool:
|
|
179
|
+
from . import git
|
|
180
|
+
|
|
181
|
+
repository, proposed = git_context
|
|
182
|
+
try:
|
|
183
|
+
evidence_commit = git.head(repository, evidence["provenance"]["commit"])
|
|
184
|
+
if not git.is_ancestor(evidence_commit, proposed, repository):
|
|
185
|
+
return False
|
|
186
|
+
changed = git.changed_files(repository, evidence_commit, proposed if head is not None else None)
|
|
187
|
+
return not any(not _is_record_path(repository, root, path) for path in changed)
|
|
188
|
+
except CuffError:
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _record_prefix(repository: Path, root: Path) -> str:
|
|
193
|
+
try:
|
|
194
|
+
return (root / RECORDS_DIR).resolve().relative_to(repository.resolve()).as_posix()
|
|
195
|
+
except ValueError as exc:
|
|
196
|
+
raise CuffError("CUFF_NOT_A_REPOSITORY", "Cuff workspace is outside the selected repository") from exc
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _is_record_path(repository: Path, root: Path, path: str) -> bool:
|
|
200
|
+
prefix = _record_prefix(repository, root)
|
|
201
|
+
normalized = path.replace("\\", "/")
|
|
202
|
+
return normalized == prefix or normalized.startswith(prefix + "/")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _fail(errors: list[dict[str, Any]], code: str, message: str, **context: Any) -> None:
|
|
206
|
+
errors.append({"code": code, "message": message, **context})
|
cuff/git.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""The few Git operations required for provenance and freshness."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .errors import CuffError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _run(args: list[str], *, root: Path | str | None = None, check: bool = True) -> str:
|
|
12
|
+
try:
|
|
13
|
+
process = subprocess.run(
|
|
14
|
+
["git", *args],
|
|
15
|
+
cwd=root,
|
|
16
|
+
text=True,
|
|
17
|
+
stdout=subprocess.PIPE,
|
|
18
|
+
stderr=subprocess.PIPE,
|
|
19
|
+
timeout=15,
|
|
20
|
+
check=False,
|
|
21
|
+
)
|
|
22
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
23
|
+
raise CuffError("CUFF_GIT_FAILED", f"Git command could not complete: {exc}") from exc
|
|
24
|
+
if check and process.returncode != 0:
|
|
25
|
+
raise CuffError(
|
|
26
|
+
"CUFF_GIT_FAILED",
|
|
27
|
+
process.stderr.strip() or "Git command failed",
|
|
28
|
+
{"command": ["git", *args]},
|
|
29
|
+
)
|
|
30
|
+
return process.stdout
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def repo_root(cwd: Path | str | None = None) -> Path:
|
|
34
|
+
value = _run(["rev-parse", "--show-toplevel"], root=cwd, check=False).strip()
|
|
35
|
+
if not value:
|
|
36
|
+
raise CuffError("CUFF_NOT_A_REPOSITORY", "Cuff must run inside a Git repository")
|
|
37
|
+
return Path(value).resolve()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def head(root: Path, ref: str = "HEAD") -> str:
|
|
41
|
+
value = _run(["rev-parse", "--verify", f"{ref}^{{commit}}"], root=root, check=False).strip()
|
|
42
|
+
if not value:
|
|
43
|
+
raise CuffError("CUFF_GIT_REF_INVALID", f"Git ref cannot be resolved: {ref}")
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_ancestor(ancestor: str, descendant: str, root: Path) -> bool:
|
|
48
|
+
try:
|
|
49
|
+
process = subprocess.run(
|
|
50
|
+
["git", "merge-base", "--is-ancestor", ancestor, descendant],
|
|
51
|
+
cwd=root,
|
|
52
|
+
stdout=subprocess.DEVNULL,
|
|
53
|
+
stderr=subprocess.PIPE,
|
|
54
|
+
timeout=15,
|
|
55
|
+
check=False,
|
|
56
|
+
)
|
|
57
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
58
|
+
raise CuffError("CUFF_GIT_FAILED", f"Git ancestry check could not complete: {exc}") from exc
|
|
59
|
+
if process.returncode in (0, 1):
|
|
60
|
+
return process.returncode == 0
|
|
61
|
+
raise CuffError("CUFF_GIT_FAILED", process.stderr.decode().strip() or "Git ancestry check failed")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def default_base(root: Path, proposed_head: str = "HEAD") -> str | None:
|
|
65
|
+
parent = _run(["rev-parse", "--verify", f"{proposed_head}^"], root=root, check=False).strip()
|
|
66
|
+
return parent or None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def changed_files(root: Path, start: str, end: str | None = None) -> list[str]:
|
|
70
|
+
args = ["-c", "core.quotePath=false", "diff", "--name-only", start]
|
|
71
|
+
if end is not None:
|
|
72
|
+
args.append(end)
|
|
73
|
+
return [line for line in _run(args, root=root).splitlines() if line]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def dirty_paths(root: Path) -> list[str]:
|
|
77
|
+
tracked = _run(
|
|
78
|
+
["-c", "core.quotePath=false", "diff", "--name-only", "HEAD"],
|
|
79
|
+
root=root,
|
|
80
|
+
).splitlines()
|
|
81
|
+
untracked = _run(
|
|
82
|
+
["-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard"],
|
|
83
|
+
root=root,
|
|
84
|
+
).splitlines()
|
|
85
|
+
return sorted({path for path in (*tracked, *untracked) if path})
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def diff_status(root: Path, base: str, head_ref: str | None, pathspec: str) -> list[tuple[str, str]]:
|
|
89
|
+
args = ["-c", "core.quotePath=false", "diff", "--name-status", "--no-renames", base]
|
|
90
|
+
if head_ref is not None:
|
|
91
|
+
args.append(head_ref)
|
|
92
|
+
args.extend(["--", pathspec])
|
|
93
|
+
rows: list[tuple[str, str]] = []
|
|
94
|
+
for line in _run(args, root=root).splitlines():
|
|
95
|
+
status, separator, path = line.partition("\t")
|
|
96
|
+
if separator:
|
|
97
|
+
rows.append((status, path))
|
|
98
|
+
return rows
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def show_file(root: Path, ref: str, path: str) -> bytes | None:
|
|
102
|
+
try:
|
|
103
|
+
process = subprocess.run(
|
|
104
|
+
["git", "show", f"{ref}:{path}"],
|
|
105
|
+
cwd=root,
|
|
106
|
+
stdout=subprocess.PIPE,
|
|
107
|
+
stderr=subprocess.DEVNULL,
|
|
108
|
+
timeout=15,
|
|
109
|
+
check=False,
|
|
110
|
+
)
|
|
111
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
112
|
+
raise CuffError("CUFF_GIT_FAILED", f"Historical ledger could not be read: {exc}") from exc
|
|
113
|
+
return process.stdout if process.returncode == 0 else None
|