assertion-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.
- api.py +211 -0
- assertion_cli-0.1.0.dist-info/METADATA +48 -0
- assertion_cli-0.1.0.dist-info/RECORD +15 -0
- assertion_cli-0.1.0.dist-info/WHEEL +5 -0
- assertion_cli-0.1.0.dist-info/entry_points.txt +2 -0
- assertion_cli-0.1.0.dist-info/top_level.txt +8 -0
- assertion_cli_templates/ACTIVATION.md +14 -0
- assertion_cli_templates/SKILL.md +177 -0
- assertion_cli_templates/__init__.py +0 -0
- bundle.py +26 -0
- git.py +189 -0
- link.py +21 -0
- main.py +499 -0
- models.py +86 -0
- session.py +220 -0
git.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import NoReturn, Sequence
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def exit_with_error(message: str) -> NoReturn:
|
|
9
|
+
typer.echo(message, err=True)
|
|
10
|
+
raise typer.Exit(code=1)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_git_command(repo_root: Path, args: Sequence[str]) -> str:
|
|
14
|
+
completed = subprocess.run(
|
|
15
|
+
["git", *args],
|
|
16
|
+
cwd=repo_root,
|
|
17
|
+
capture_output=True,
|
|
18
|
+
text=True,
|
|
19
|
+
check=False,
|
|
20
|
+
)
|
|
21
|
+
if completed.returncode != 0:
|
|
22
|
+
message = completed.stderr.strip() or completed.stdout.strip() or "git failed"
|
|
23
|
+
raise RuntimeError(message)
|
|
24
|
+
return completed.stdout.strip()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def find_git_root(start_path: Path) -> Path:
|
|
28
|
+
completed = subprocess.run(
|
|
29
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
30
|
+
cwd=start_path,
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=True,
|
|
33
|
+
check=False,
|
|
34
|
+
)
|
|
35
|
+
if completed.returncode != 0:
|
|
36
|
+
exit_with_error("Current directory is not inside a git repository.")
|
|
37
|
+
return Path(completed.stdout.strip())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def require_head_pushed(repo_root: Path) -> None:
|
|
41
|
+
try:
|
|
42
|
+
remote_refs = run_git_command(
|
|
43
|
+
repo_root, ["for-each-ref", "--format=%(refname:short)", "refs/remotes"]
|
|
44
|
+
)
|
|
45
|
+
except RuntimeError as exc:
|
|
46
|
+
exit_with_error(f"Failed to inspect remote refs: {exc}")
|
|
47
|
+
|
|
48
|
+
refs = [
|
|
49
|
+
ref for ref in remote_refs.splitlines() if ref and not ref.endswith("/HEAD")
|
|
50
|
+
]
|
|
51
|
+
if not refs:
|
|
52
|
+
exit_with_error(
|
|
53
|
+
"Current HEAD commit is not present on any remote-tracking branch."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
for ref in refs:
|
|
57
|
+
completed = subprocess.run(
|
|
58
|
+
["git", "merge-base", "--is-ancestor", "HEAD", ref],
|
|
59
|
+
cwd=repo_root,
|
|
60
|
+
capture_output=True,
|
|
61
|
+
text=True,
|
|
62
|
+
check=False,
|
|
63
|
+
)
|
|
64
|
+
if completed.returncode == 0:
|
|
65
|
+
return
|
|
66
|
+
if completed.returncode != 1:
|
|
67
|
+
message = (
|
|
68
|
+
completed.stderr.strip() or completed.stdout.strip() or "git failed"
|
|
69
|
+
)
|
|
70
|
+
exit_with_error(f"Failed to verify remote commit state: {message}")
|
|
71
|
+
|
|
72
|
+
exit_with_error("Current HEAD commit is not present on any remote-tracking branch.")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_head_sha(repo_root: Path) -> str:
|
|
76
|
+
try:
|
|
77
|
+
return run_git_command(repo_root, ["rev-parse", "HEAD"])
|
|
78
|
+
except RuntimeError as exc:
|
|
79
|
+
exit_with_error(f"Failed to get HEAD SHA: {exc}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_head_branch(repo_root: Path) -> str | None:
|
|
83
|
+
"""Return the current branch name, or None if HEAD is detached."""
|
|
84
|
+
try:
|
|
85
|
+
name = run_git_command(repo_root, ["rev-parse", "--abbrev-ref", "HEAD"])
|
|
86
|
+
except RuntimeError:
|
|
87
|
+
return None
|
|
88
|
+
return name if name and name != "HEAD" else None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_origin_github_repo(repo_root: Path) -> str | None:
|
|
92
|
+
"""Return the current repo's GitHub `owner/name` from origin, or None if not parseable.
|
|
93
|
+
|
|
94
|
+
Accepts the common remote URL forms:
|
|
95
|
+
git@github.com:owner/name(.git)?
|
|
96
|
+
https://github.com/owner/name(.git)?
|
|
97
|
+
ssh://git@github.com/owner/name(.git)?
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
url = run_git_command(repo_root, ["remote", "get-url", "origin"]).strip()
|
|
101
|
+
except RuntimeError:
|
|
102
|
+
return None
|
|
103
|
+
if not url:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
if url.startswith("git@github.com:"):
|
|
107
|
+
path = url[len("git@github.com:") :]
|
|
108
|
+
elif url.startswith("ssh://git@github.com/"):
|
|
109
|
+
path = url[len("ssh://git@github.com/") :]
|
|
110
|
+
elif url.startswith("https://github.com/"):
|
|
111
|
+
path = url[len("https://github.com/") :]
|
|
112
|
+
elif url.startswith("http://github.com/"):
|
|
113
|
+
path = url[len("http://github.com/") :]
|
|
114
|
+
else:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
path = path.rstrip("/")
|
|
118
|
+
if path.endswith(".git"):
|
|
119
|
+
path = path[: -len(".git")]
|
|
120
|
+
if path.count("/") != 1 or not all(path.split("/")):
|
|
121
|
+
return None
|
|
122
|
+
return path
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _build_untracked_diff(repo_root: Path, rel_path: str) -> str:
|
|
126
|
+
completed = subprocess.run(
|
|
127
|
+
[
|
|
128
|
+
"git",
|
|
129
|
+
"diff",
|
|
130
|
+
"--no-index",
|
|
131
|
+
"--src-prefix=a/",
|
|
132
|
+
"--dst-prefix=b/",
|
|
133
|
+
"--",
|
|
134
|
+
"/dev/null",
|
|
135
|
+
rel_path,
|
|
136
|
+
],
|
|
137
|
+
cwd=repo_root,
|
|
138
|
+
capture_output=True,
|
|
139
|
+
text=True,
|
|
140
|
+
check=False,
|
|
141
|
+
)
|
|
142
|
+
if completed.returncode not in (0, 1):
|
|
143
|
+
message = completed.stderr.strip() or completed.stdout.strip() or "git failed"
|
|
144
|
+
raise RuntimeError(message)
|
|
145
|
+
return completed.stdout.strip()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# Paths the assertion-cli owns end-to-end. Excluded from the diff bundle so
|
|
149
|
+
# reviewers don't flag our own bootstrap files as "unrelated changes" — they're
|
|
150
|
+
# generated/refreshed by `asrt init` and `asrt checkpoint` and have nothing to
|
|
151
|
+
# do with the customer's feature work. CLAUDE.md / AGENTS.md are intentionally
|
|
152
|
+
# NOT in this list: those are customer-owned files we only patch a marked
|
|
153
|
+
# block into, so the customer's other edits to them should still flow through.
|
|
154
|
+
_ASSERTION_EXCLUDED_PATHSPECS = [
|
|
155
|
+
":(exclude).assertion",
|
|
156
|
+
":(exclude).claude/skills/assertion-cli",
|
|
157
|
+
":(exclude).agents/skills/assertion-cli",
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def get_uncommitted_diff(repo_root: Path) -> str:
|
|
162
|
+
try:
|
|
163
|
+
tracked = run_git_command(
|
|
164
|
+
repo_root, ["diff", "--", *_ASSERTION_EXCLUDED_PATHSPECS]
|
|
165
|
+
)
|
|
166
|
+
staged = run_git_command(
|
|
167
|
+
repo_root, ["diff", "--cached", "--", *_ASSERTION_EXCLUDED_PATHSPECS]
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
untracked_output = run_git_command(
|
|
171
|
+
repo_root,
|
|
172
|
+
[
|
|
173
|
+
"ls-files",
|
|
174
|
+
"--others",
|
|
175
|
+
"--exclude-standard",
|
|
176
|
+
"--",
|
|
177
|
+
*_ASSERTION_EXCLUDED_PATHSPECS,
|
|
178
|
+
],
|
|
179
|
+
)
|
|
180
|
+
untracked_diffs = [
|
|
181
|
+
_build_untracked_diff(repo_root, rel_path)
|
|
182
|
+
for rel_path in untracked_output.splitlines()
|
|
183
|
+
if rel_path
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
parts = [p for p in [tracked, staged, "\n".join(untracked_diffs)] if p]
|
|
187
|
+
return "\n".join(parts)
|
|
188
|
+
except RuntimeError as exc:
|
|
189
|
+
exit_with_error(f"Failed to collect git diff: {exc}")
|
link.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from git import exit_with_error
|
|
4
|
+
|
|
5
|
+
LINK_FILE_NAME = "link"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def save_link(assertion_dir: Path, url: str) -> None:
|
|
9
|
+
(assertion_dir / LINK_FILE_NAME).write_text(url + "\n", encoding="utf-8")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_link(assertion_dir: Path) -> str:
|
|
13
|
+
link_path = assertion_dir / LINK_FILE_NAME
|
|
14
|
+
if not link_path.exists():
|
|
15
|
+
exit_with_error(
|
|
16
|
+
"No session link found. Run `asrt verify` first to generate a link."
|
|
17
|
+
)
|
|
18
|
+
content = link_path.read_text(encoding="utf-8").strip()
|
|
19
|
+
if not content:
|
|
20
|
+
exit_with_error("Session link file is empty. Run `asrt verify` to regenerate.")
|
|
21
|
+
return content
|