strayguard 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.
- strayguard/__init__.py +8 -0
- strayguard/cli.py +142 -0
- strayguard/decide.py +103 -0
- strayguard/evidence.py +105 -0
- strayguard/git.py +115 -0
- strayguard/jev.py +118 -0
- strayguard/rules.py +22 -0
- strayguard/ui.py +118 -0
- strayguard-0.1.0.dist-info/METADATA +245 -0
- strayguard-0.1.0.dist-info/RECORD +13 -0
- strayguard-0.1.0.dist-info/WHEEL +4 -0
- strayguard-0.1.0.dist-info/entry_points.txt +2 -0
- strayguard-0.1.0.dist-info/licenses/LICENSE +21 -0
strayguard/__init__.py
ADDED
strayguard/cli.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Command line: install, uninstall and check (SPEC 6, 7.11)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from . import __version__, decide, evidence, git, jev, rules, ui
|
|
11
|
+
|
|
12
|
+
MARKER = "# strayguard"
|
|
13
|
+
HOOK = """#!/bin/sh
|
|
14
|
+
# strayguard
|
|
15
|
+
exe="{exe}"
|
|
16
|
+
[ -x "$exe" ] || exe="$(command -v strayguard)" || exit 0
|
|
17
|
+
exec "$exe" check
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def check() -> int:
|
|
22
|
+
"""Run the full check on the staged new files. Return the exit code."""
|
|
23
|
+
if os.environ.get("STRAYGUARD", "on").lower() == "off":
|
|
24
|
+
return 0
|
|
25
|
+
|
|
26
|
+
new_files = git.new_files()
|
|
27
|
+
if not new_files:
|
|
28
|
+
return 0
|
|
29
|
+
|
|
30
|
+
model = os.environ.get("STRAYGUARD_MODEL", jev.DEFAULT_MODEL)
|
|
31
|
+
store = decide.Store(git.state_dir(), model, jev.PROMPT_VERSION)
|
|
32
|
+
new_files = [f for f in new_files if f.blob not in store.kept]
|
|
33
|
+
items, total, repo = evidence.build_items(new_files)
|
|
34
|
+
if total > len(items):
|
|
35
|
+
ui.note(f"checked {len(items)} of {total} new items.")
|
|
36
|
+
|
|
37
|
+
verdicts, to_ask = [], []
|
|
38
|
+
for item in items:
|
|
39
|
+
category = rules.match(item.path)
|
|
40
|
+
if category:
|
|
41
|
+
verdicts.append(decide.Verdict(item, category, None))
|
|
42
|
+
else:
|
|
43
|
+
to_ask.append(item)
|
|
44
|
+
|
|
45
|
+
answers = {}
|
|
46
|
+
for item in to_ask:
|
|
47
|
+
cached = store.cached_answer(item)
|
|
48
|
+
if cached:
|
|
49
|
+
answers[item.key] = cached
|
|
50
|
+
missing = [item for item in to_ask if item.key not in answers]
|
|
51
|
+
if missing:
|
|
52
|
+
fresh = ask_jev(missing, repo, model)
|
|
53
|
+
store.remember(fresh)
|
|
54
|
+
answers.update(fresh)
|
|
55
|
+
|
|
56
|
+
verdicts += decide.decide(to_ask, answers, decide.threshold_from_env())
|
|
57
|
+
if not verdicts:
|
|
58
|
+
return 0
|
|
59
|
+
return act(verdicts, store)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def ask_jev(items: list, repo: list[str], model: str) -> dict:
|
|
63
|
+
"""Ask Jev. On any failure, show one line and return no answers (fail open)."""
|
|
64
|
+
api_key = os.environ.get("TYPESAFE_API_KEY")
|
|
65
|
+
if not api_key:
|
|
66
|
+
ui.note("no TYPESAFE_API_KEY set, so only the local rules ran.")
|
|
67
|
+
return {}
|
|
68
|
+
try:
|
|
69
|
+
return jev.ask(items, repo, api_key, model)
|
|
70
|
+
except jev.JevError as error:
|
|
71
|
+
ui.note(f"{error}, so only the local rules ran.")
|
|
72
|
+
return {}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def act(verdicts: list, store: decide.Store) -> int:
|
|
76
|
+
"""Show the verdicts and do what the user chooses (SG-60 to SG-73)."""
|
|
77
|
+
if not ui.can_prompt():
|
|
78
|
+
strict = os.environ.get("STRAYGUARD_STRICT") == "1"
|
|
79
|
+
ui.warn(verdicts, blocking=strict)
|
|
80
|
+
return 1 if strict else 0
|
|
81
|
+
|
|
82
|
+
choices = ui.prompt(verdicts)
|
|
83
|
+
store.keep(v.item for v in choices["k"])
|
|
84
|
+
if choices["g"]:
|
|
85
|
+
git.add_to_gitignore([v.item for v in choices["g"]])
|
|
86
|
+
removed = choices["u"] + choices["g"]
|
|
87
|
+
if removed:
|
|
88
|
+
git.unstage([v.item.path for v in removed])
|
|
89
|
+
ui.report(choices)
|
|
90
|
+
return 1 if removed else 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def install() -> int:
|
|
94
|
+
hook = git.hooks_dir() / "pre-commit"
|
|
95
|
+
if hook.exists() and MARKER not in hook.read_text(errors="replace"):
|
|
96
|
+
print(f"{hook} already exists. Add this line to it:\n strayguard check")
|
|
97
|
+
return 1
|
|
98
|
+
exe = shutil.which("strayguard") or os.path.abspath(sys.argv[0])
|
|
99
|
+
hook.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
hook.write_text(HOOK.format(exe=exe))
|
|
101
|
+
hook.chmod(0o755)
|
|
102
|
+
print(f"✓ strayguard installed in {hook}")
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def uninstall() -> int:
|
|
107
|
+
hook = git.hooks_dir() / "pre-commit"
|
|
108
|
+
if hook.exists() and MARKER in hook.read_text(errors="replace"):
|
|
109
|
+
hook.unlink()
|
|
110
|
+
print(f"✓ strayguard removed from {hook}")
|
|
111
|
+
else:
|
|
112
|
+
print("strayguard hook not found. Nothing to remove.")
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
COMMANDS = {"install": install, "uninstall": uninstall, "check": check}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def main(argv: list[str] | None = None) -> int:
|
|
120
|
+
parser = argparse.ArgumentParser(
|
|
121
|
+
prog="strayguard",
|
|
122
|
+
description="Catch files that do not belong before you commit them.",
|
|
123
|
+
)
|
|
124
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
125
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
126
|
+
commands.add_parser("install", help="add the pre-commit hook to this repository")
|
|
127
|
+
commands.add_parser("uninstall", help="remove the pre-commit hook")
|
|
128
|
+
commands.add_parser("check", help="check the staged new files (the hook runs this)")
|
|
129
|
+
args = parser.parse_args(argv)
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
return COMMANDS[args.command]()
|
|
133
|
+
except KeyboardInterrupt:
|
|
134
|
+
return 130
|
|
135
|
+
except git.GitError as error:
|
|
136
|
+
ui.note(str(error))
|
|
137
|
+
return 0 if args.command == "check" else 1
|
|
138
|
+
except Exception as error:
|
|
139
|
+
if args.command != "check":
|
|
140
|
+
raise
|
|
141
|
+
ui.note(f"internal error ({error!r}), check skipped.")
|
|
142
|
+
return 0
|
strayguard/decide.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Decide which items to flag, and remember answers and kept files (SPEC 7.6, 7.7)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
BELONGS = "belongs"
|
|
11
|
+
DEFAULT_THRESHOLD = 0.70
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Verdict:
|
|
16
|
+
"""One flagged item. `probability` is None when a local rule flagged it."""
|
|
17
|
+
|
|
18
|
+
item: object
|
|
19
|
+
category: str
|
|
20
|
+
probability: float | None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def threshold_from_env() -> float:
|
|
24
|
+
"""Read STRAYGUARD_THRESHOLD (SG-47). Use the default for a bad value."""
|
|
25
|
+
try:
|
|
26
|
+
value = float(os.environ.get("STRAYGUARD_THRESHOLD", DEFAULT_THRESHOLD))
|
|
27
|
+
except ValueError:
|
|
28
|
+
return DEFAULT_THRESHOLD
|
|
29
|
+
return value if 0.0 < value <= 1.0 else DEFAULT_THRESHOLD
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_flagged(answer: dict, threshold: float) -> bool:
|
|
33
|
+
"""SG-46: flag when the selected option is not `belongs` and is likely enough."""
|
|
34
|
+
choice = answer.get("choice")
|
|
35
|
+
probability = answer.get("probabilities", {}).get(choice, 0.0)
|
|
36
|
+
return choice != BELONGS and probability >= threshold
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def decide(items: list, answers: dict, threshold: float) -> list[Verdict]:
|
|
40
|
+
"""Turn Jev answers into verdicts. Items without an answer are not flagged."""
|
|
41
|
+
verdicts = []
|
|
42
|
+
for item in items:
|
|
43
|
+
answer = answers.get(item.key)
|
|
44
|
+
if answer and is_flagged(answer, threshold):
|
|
45
|
+
choice = answer["choice"]
|
|
46
|
+
verdicts.append(Verdict(item, choice, answer["probabilities"][choice]))
|
|
47
|
+
return verdicts
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Store:
|
|
51
|
+
"""The kept list and the answer cache in .git/strayguard/ (SG-50 to SG-54)."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, state_dir: Path, model: str, prompt_version: str):
|
|
54
|
+
self.dir = state_dir
|
|
55
|
+
self.kept_file = state_dir / "kept"
|
|
56
|
+
self.cache_file = state_dir / "cache.json"
|
|
57
|
+
self.suffix = f":{model}:{prompt_version}"
|
|
58
|
+
self.kept = self._read_kept()
|
|
59
|
+
self.cache = self._read_cache()
|
|
60
|
+
|
|
61
|
+
def _read_kept(self) -> set[str]:
|
|
62
|
+
try:
|
|
63
|
+
return set(self.kept_file.read_text().split())
|
|
64
|
+
except OSError:
|
|
65
|
+
return set()
|
|
66
|
+
|
|
67
|
+
def _read_cache(self) -> dict:
|
|
68
|
+
try:
|
|
69
|
+
data = json.loads(self.cache_file.read_text())
|
|
70
|
+
except FileNotFoundError:
|
|
71
|
+
return {}
|
|
72
|
+
except (OSError, ValueError):
|
|
73
|
+
data = None
|
|
74
|
+
if not isinstance(data, dict):
|
|
75
|
+
self.cache_file.unlink(missing_ok=True)
|
|
76
|
+
return {}
|
|
77
|
+
return data
|
|
78
|
+
|
|
79
|
+
def cached_answer(self, item) -> dict | None:
|
|
80
|
+
return self.cache.get(item.key + self.suffix)
|
|
81
|
+
|
|
82
|
+
def remember(self, answers: dict) -> None:
|
|
83
|
+
for key, answer in answers.items():
|
|
84
|
+
self.cache[key + self.suffix] = {
|
|
85
|
+
"choice": answer["choice"],
|
|
86
|
+
"probabilities": answer["probabilities"],
|
|
87
|
+
}
|
|
88
|
+
self._write(self.cache_file, json.dumps(self.cache))
|
|
89
|
+
|
|
90
|
+
def keep(self, items) -> None:
|
|
91
|
+
for item in items:
|
|
92
|
+
self.kept.update(item.blob_ids)
|
|
93
|
+
self._write(self.kept_file, "".join(f"{blob}\n" for blob in sorted(self.kept)))
|
|
94
|
+
|
|
95
|
+
def _write(self, path: Path, text: str) -> None:
|
|
96
|
+
"""Write to a temporary file first, so a stopped commit never leaves half a file."""
|
|
97
|
+
try:
|
|
98
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
temporary = path.with_name(path.name + ".tmp")
|
|
100
|
+
temporary.write_text(text)
|
|
101
|
+
os.replace(temporary, path)
|
|
102
|
+
except OSError:
|
|
103
|
+
pass
|
strayguard/evidence.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Make the items that strayguard sends to Jev (SPEC 7.2, 7.4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import posixpath
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
from . import git, rules
|
|
11
|
+
|
|
12
|
+
ROLL_UP_ABOVE = 10
|
|
13
|
+
MAX_ITEMS = 100
|
|
14
|
+
MAX_CONTENT_BYTES = 1_000_000
|
|
15
|
+
BINARY_SCAN_BYTES = 8_000
|
|
16
|
+
HEAD_LINES = 40
|
|
17
|
+
HEAD_CHARS = 2_000
|
|
18
|
+
SAME_FOLDER_NAMES = 10
|
|
19
|
+
TOP_LEVEL_NAMES = 30
|
|
20
|
+
FOLDER_SAMPLE = 5
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Item:
|
|
25
|
+
path: str
|
|
26
|
+
key: str
|
|
27
|
+
blob_ids: tuple
|
|
28
|
+
is_folder: bool = False
|
|
29
|
+
evidence: dict = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parents(path: str) -> list[str]:
|
|
33
|
+
"""All parent folders, highest first: 'a/b/c.txt' gives ['a/', 'a/b/']."""
|
|
34
|
+
parts = path.split("/")[:-1]
|
|
35
|
+
return ["/".join(parts[: i + 1]) + "/" for i in range(len(parts))]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_items(new_files: list) -> tuple[list[Item], int, list[str]]:
|
|
39
|
+
"""Return (items, total number of items, repo top-level names)."""
|
|
40
|
+
new_paths = {f.path for f in new_files}
|
|
41
|
+
tracked = [p for p in git.tracked_files() if p not in new_paths]
|
|
42
|
+
tracked_folders = {folder for p in tracked for folder in _parents(p)}
|
|
43
|
+
|
|
44
|
+
counts: dict[str, int] = {}
|
|
45
|
+
for f in new_files:
|
|
46
|
+
for folder in _parents(f.path):
|
|
47
|
+
counts[folder] = counts.get(folder, 0) + 1
|
|
48
|
+
|
|
49
|
+
groups: dict[str, list] = {}
|
|
50
|
+
singles = []
|
|
51
|
+
for f in new_files:
|
|
52
|
+
folder = next((d for d in _parents(f.path)
|
|
53
|
+
if d not in tracked_folders and counts[d] > ROLL_UP_ABOVE), None)
|
|
54
|
+
if folder:
|
|
55
|
+
groups.setdefault(folder, []).append(f)
|
|
56
|
+
else:
|
|
57
|
+
singles.append(f)
|
|
58
|
+
|
|
59
|
+
items = [_folder_item(folder, files) for folder, files in groups.items()]
|
|
60
|
+
items += _file_items(singles, tracked)
|
|
61
|
+
items.sort(key=lambda item: item.path)
|
|
62
|
+
return items[:MAX_ITEMS], len(items), _top_level(tracked)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _folder_item(folder: str, files: list) -> Item:
|
|
66
|
+
"""SG-11: one item for a whole new folder."""
|
|
67
|
+
blobs = tuple(sorted(f.blob for f in files))
|
|
68
|
+
key = "folder:" + hashlib.sha1(" ".join(blobs).encode()).hexdigest()
|
|
69
|
+
sample = sorted(f.path for f in files)[:FOLDER_SAMPLE]
|
|
70
|
+
evidence = {"path": folder, "kind": "folder", "new_files": len(files), "sample": sample}
|
|
71
|
+
return Item(folder, key, blobs, is_folder=True, evidence=evidence)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _file_items(files: list, tracked: list[str]) -> list[Item]:
|
|
75
|
+
names_only = os.environ.get("STRAYGUARD_NAMES_ONLY") == "1"
|
|
76
|
+
sizes = git.blob_sizes([f.blob for f in files]) if files else {}
|
|
77
|
+
readable = [f.blob for f in files
|
|
78
|
+
if not names_only and not rules.match(f.path) and sizes[f.blob] <= MAX_CONTENT_BYTES]
|
|
79
|
+
contents = git.read_blobs(readable)
|
|
80
|
+
|
|
81
|
+
items = []
|
|
82
|
+
for f in files:
|
|
83
|
+
evidence = {"path": f.path, "size_bytes": sizes[f.blob],
|
|
84
|
+
"same_folder": _same_folder(f.path, tracked)}
|
|
85
|
+
data = contents.get(f.blob)
|
|
86
|
+
if data is not None:
|
|
87
|
+
evidence["binary"] = b"\0" in data[:BINARY_SCAN_BYTES]
|
|
88
|
+
if not evidence["binary"]:
|
|
89
|
+
lines = data.decode(errors="replace").splitlines()[:HEAD_LINES]
|
|
90
|
+
evidence["head"] = "\n".join(lines)[:HEAD_CHARS]
|
|
91
|
+
items.append(Item(f.path, f.blob, (f.blob,), evidence=evidence))
|
|
92
|
+
return items
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _same_folder(path: str, tracked: list[str]) -> list[str]:
|
|
96
|
+
"""SG-34: names of tracked files next to this file."""
|
|
97
|
+
folder = posixpath.dirname(path)
|
|
98
|
+
names = sorted(posixpath.basename(p) for p in tracked if posixpath.dirname(p) == folder)
|
|
99
|
+
return names[:SAME_FOLDER_NAMES]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _top_level(tracked: list[str]) -> list[str]:
|
|
103
|
+
"""SG-35: tracked top-level files and folders. Folders end with '/'."""
|
|
104
|
+
names = {p.split("/")[0] + ("/" if "/" in p else "") for p in tracked}
|
|
105
|
+
return sorted(names)[:TOP_LEVEL_NAMES]
|
strayguard/git.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""All git calls (SPEC 7.1, 7.8, 7.11). No other module runs git."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
SUBMODULE_MODE = "160000"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GitError(Exception):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class NewFile:
|
|
19
|
+
path: str
|
|
20
|
+
blob: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _run(*args: str, input: bytes | None = None, cwd: Path | None = None) -> bytes:
|
|
24
|
+
try:
|
|
25
|
+
result = subprocess.run(["git", *args], input=input, capture_output=True, cwd=cwd)
|
|
26
|
+
except FileNotFoundError:
|
|
27
|
+
raise GitError("git not found") from None
|
|
28
|
+
if result.returncode != 0:
|
|
29
|
+
message = result.stderr.decode(errors="replace").strip()
|
|
30
|
+
raise GitError(message or f"git {args[0]} failed")
|
|
31
|
+
return result.stdout
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@lru_cache(maxsize=None)
|
|
35
|
+
def root() -> Path:
|
|
36
|
+
return Path(_run("rev-parse", "--show-toplevel").decode().strip())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _in_root(*args: str, input: bytes | None = None) -> bytes:
|
|
40
|
+
"""Run git in the top folder, because staged paths are relative to it."""
|
|
41
|
+
return _run(*args, input=input, cwd=root())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def new_files() -> list[NewFile]:
|
|
45
|
+
"""SG-01, SG-02: staged files with status A. Renames and submodules are skipped."""
|
|
46
|
+
raw = _in_root("diff", "--cached", "--raw", "-z", "--no-abbrev",
|
|
47
|
+
"--find-renames", "--diff-filter=A").decode(errors="surrogateescape")
|
|
48
|
+
fields = raw.split("\0")
|
|
49
|
+
files = []
|
|
50
|
+
for meta, path in zip(fields[0::2], fields[1::2]):
|
|
51
|
+
_, new_mode, _, blob, _ = meta.lstrip(":").split(" ")
|
|
52
|
+
if new_mode != SUBMODULE_MODE:
|
|
53
|
+
files.append(NewFile(path, blob))
|
|
54
|
+
return files
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def tracked_files() -> list[str]:
|
|
58
|
+
"""All paths in the index. The caller removes the new files from this list."""
|
|
59
|
+
return [p for p in _in_root("ls-files", "-z").decode(errors="surrogateescape").split("\0") if p]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def blob_sizes(blobs: list[str]) -> dict[str, int]:
|
|
63
|
+
out = _run("cat-file", "--batch-check", input="\n".join(blobs).encode() + b"\n")
|
|
64
|
+
sizes = {}
|
|
65
|
+
for line in out.decode().splitlines():
|
|
66
|
+
blob, _, size = line.split(" ")
|
|
67
|
+
sizes[blob] = int(size)
|
|
68
|
+
return sizes
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def read_blobs(blobs: list[str]) -> dict[str, bytes]:
|
|
72
|
+
"""Read the staged content of many files with one git process."""
|
|
73
|
+
if not blobs:
|
|
74
|
+
return {}
|
|
75
|
+
out = _run("cat-file", "--batch", input="\n".join(blobs).encode() + b"\n")
|
|
76
|
+
contents, position = {}, 0
|
|
77
|
+
for blob in blobs:
|
|
78
|
+
header_end = out.index(b"\n", position)
|
|
79
|
+
size = int(out[position:header_end].split(b" ")[2])
|
|
80
|
+
contents[blob] = out[header_end + 1:header_end + 1 + size]
|
|
81
|
+
position = header_end + 1 + size + 1
|
|
82
|
+
return contents
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def state_dir() -> Path:
|
|
86
|
+
common = Path(_run("rev-parse", "--git-common-dir").decode().strip())
|
|
87
|
+
return common.resolve() / "strayguard"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def hooks_dir() -> Path:
|
|
91
|
+
"""`--git-path hooks` respects core.hooksPath (SPEC 7.11)."""
|
|
92
|
+
return Path(_run("rev-parse", "--git-path", "hooks").decode().strip()).resolve()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def unstage(paths: list[str]) -> None:
|
|
96
|
+
"""SG-64: remove the paths from the index only. The files stay on the disk."""
|
|
97
|
+
_in_root("rm", "--cached", "-r", "-f", "--quiet", "--", *paths)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _ignore_pattern(path: str) -> str:
|
|
101
|
+
escaped = "".join("\\" + c if c in "*?[\\" else c for c in path)
|
|
102
|
+
return "/" + escaped
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def add_to_gitignore(items: list) -> None:
|
|
106
|
+
"""SG-65, SG-66: add one anchored line for each item, then stage .gitignore."""
|
|
107
|
+
gitignore = root() / ".gitignore"
|
|
108
|
+
text = gitignore.read_text() if gitignore.exists() else ""
|
|
109
|
+
present = set(text.splitlines())
|
|
110
|
+
new_lines = [p for p in (_ignore_pattern(i.path) for i in items) if p not in present]
|
|
111
|
+
if new_lines:
|
|
112
|
+
if text and not text.endswith("\n"):
|
|
113
|
+
text += "\n"
|
|
114
|
+
gitignore.write_text(text + "\n".join(new_lines) + "\n")
|
|
115
|
+
_in_root("add", "--", ".gitignore")
|
strayguard/jev.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""The Jev request: batches, timeout and retry (SPEC 7.5, 7.10, 8)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import socket
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor, wait
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
|
|
14
|
+
ENDPOINT = "https://api.typesafe.ai/v1/systemone"
|
|
15
|
+
DEFAULT_MODEL = "jev-latest"
|
|
16
|
+
PROMPT_VERSION = "1"
|
|
17
|
+
BATCH_SIZE = 20
|
|
18
|
+
PARALLEL_REQUESTS = 5
|
|
19
|
+
REQUEST_TIMEOUT = 4.0
|
|
20
|
+
TOTAL_TIMEOUT = 5.0
|
|
21
|
+
RETRY_STATUSES = {429, 529}
|
|
22
|
+
|
|
23
|
+
# SPEC 8.2. Keep `belongs` first: Jev prefers the first option a little.
|
|
24
|
+
OPTIONS = {
|
|
25
|
+
"belongs": "Part of the project. Source code, tests in a test folder, documentation, "
|
|
26
|
+
"project config, lockfiles, fixtures, assets, and example files such as "
|
|
27
|
+
".env.example. Generated code that the project commits on purpose also belongs.",
|
|
28
|
+
"scratch": "A temporary file for one person or one session. Throwaway scripts, quick "
|
|
29
|
+
"experiments, personal notes, copies such as file_old.py or file2.py, and "
|
|
30
|
+
"files that a coding agent left behind.",
|
|
31
|
+
"output-dump": "Output that a program wrote. Logs, debug dumps, stack traces, test "
|
|
32
|
+
"results, profiling data, and exported data rows.",
|
|
33
|
+
"build-output": "Files that a tool can make again. Compiled files, bundles, caches, "
|
|
34
|
+
"coverage reports, and dependency folders such as node_modules/.",
|
|
35
|
+
"local-only": "Settings for one machine or one person. Editor files, OS files, local "
|
|
36
|
+
"environment files, personal config, and credentials.",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class JevError(Exception):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def ask(items: list, repo: list[str], api_key: str, model: str) -> dict:
|
|
45
|
+
"""Ask one Choice question for each item. Return {item.key: answer}."""
|
|
46
|
+
deadline = time.monotonic() + TOTAL_TIMEOUT
|
|
47
|
+
batches = [items[i:i + BATCH_SIZE] for i in range(0, len(items), BATCH_SIZE)]
|
|
48
|
+
pool = ThreadPoolExecutor(max_workers=PARALLEL_REQUESTS)
|
|
49
|
+
futures = [pool.submit(_ask_batch, b, repo, api_key, model, deadline) for b in batches]
|
|
50
|
+
done, not_done = wait(futures, timeout=TOTAL_TIMEOUT)
|
|
51
|
+
pool.shutdown(wait=False, cancel_futures=True)
|
|
52
|
+
if not_done:
|
|
53
|
+
raise JevError(f"Jev did not answer in {TOTAL_TIMEOUT:g} s")
|
|
54
|
+
answers = {}
|
|
55
|
+
for future in done:
|
|
56
|
+
answers.update(future.result())
|
|
57
|
+
return answers
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _ask_batch(items: list, repo: list[str], api_key: str, model: str, deadline: float) -> dict:
|
|
61
|
+
ids = {f"f{n}": item for n, item in enumerate(items, start=1)}
|
|
62
|
+
body = {
|
|
63
|
+
"model": model,
|
|
64
|
+
"state": {"repo": {"top_level": repo},
|
|
65
|
+
"files": {fid: item.evidence for fid, item in ids.items()}},
|
|
66
|
+
"questions": {fid: _question(fid, item.is_folder) for fid, item in ids.items()},
|
|
67
|
+
}
|
|
68
|
+
response = _post(body, api_key, deadline)
|
|
69
|
+
answers = {}
|
|
70
|
+
for fid, answer in response.get("answers", {}).items():
|
|
71
|
+
if fid in ids and _is_valid(answer):
|
|
72
|
+
answers[ids[fid].key] = answer
|
|
73
|
+
return answers
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _question(fid: str, is_folder: bool) -> dict:
|
|
77
|
+
"""SG-44: each question points to its own item by name."""
|
|
78
|
+
kind = "folder" if is_folder else "file"
|
|
79
|
+
hint = "Use `repo` to see how the project is laid out." if is_folder else \
|
|
80
|
+
"Use `repo` and `same_folder` to see how the project is laid out."
|
|
81
|
+
return {"type": "choice",
|
|
82
|
+
"instructions": f"Which category fits the new {kind} in `files.{fid}`? {hint}",
|
|
83
|
+
"criteria": OPTIONS}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _is_valid(answer: dict) -> bool:
|
|
87
|
+
return answer.get("choice") in OPTIONS and isinstance(answer.get("probabilities"), dict)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _post(body: dict, api_key: str, deadline: float) -> dict:
|
|
91
|
+
"""SG-40, SG-81, SG-82. The API key never appears in an error message."""
|
|
92
|
+
request = urllib.request.Request(
|
|
93
|
+
ENDPOINT,
|
|
94
|
+
data=json.dumps(body).encode(),
|
|
95
|
+
headers={"Authorization": f"Bearer {api_key}",
|
|
96
|
+
"Content-Type": "application/json",
|
|
97
|
+
"User-Agent": f"strayguard/{__version__}"},
|
|
98
|
+
)
|
|
99
|
+
for attempt in (1, 2):
|
|
100
|
+
timeout = min(REQUEST_TIMEOUT, deadline - time.monotonic())
|
|
101
|
+
if timeout <= 0:
|
|
102
|
+
raise JevError(f"Jev did not answer in {TOTAL_TIMEOUT:g} s")
|
|
103
|
+
try:
|
|
104
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
105
|
+
return json.loads(response.read())
|
|
106
|
+
except urllib.error.HTTPError as error:
|
|
107
|
+
if error.code in RETRY_STATUSES and attempt == 1:
|
|
108
|
+
time.sleep(1.0)
|
|
109
|
+
continue
|
|
110
|
+
raise JevError(f"Jev returned HTTP {error.code}") from None
|
|
111
|
+
except (socket.timeout, TimeoutError):
|
|
112
|
+
raise JevError(f"Jev did not answer in {TOTAL_TIMEOUT:g} s") from None
|
|
113
|
+
except urllib.error.URLError as error:
|
|
114
|
+
if isinstance(error.reason, (socket.timeout, TimeoutError)):
|
|
115
|
+
raise JevError(f"Jev did not answer in {TOTAL_TIMEOUT:g} s") from None
|
|
116
|
+
raise JevError(f"could not reach Jev ({error.reason})") from None
|
|
117
|
+
except ValueError:
|
|
118
|
+
raise JevError("Jev sent a response that is not JSON") from None
|
strayguard/rules.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Local rules: checks in code that do not use Jev (SPEC 7.3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import posixpath
|
|
6
|
+
from fnmatch import fnmatchcase
|
|
7
|
+
|
|
8
|
+
LOCAL_ONLY = (
|
|
9
|
+
".env", ".env.*", "*.pem", "*.key", "*.p12", "*.pfx", "id_rsa*", "id_ed25519*",
|
|
10
|
+
".DS_Store", "Thumbs.db", "desktop.ini",
|
|
11
|
+
)
|
|
12
|
+
ALLOWED = (".env.example", ".env.sample", ".env.template")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def match(path: str) -> str | None:
|
|
16
|
+
"""Return 'local-only' if a rule matches the file name. Else return None."""
|
|
17
|
+
name = posixpath.basename(path.rstrip("/"))
|
|
18
|
+
if name in ALLOWED:
|
|
19
|
+
return None
|
|
20
|
+
if any(fnmatchcase(name, pattern) for pattern in LOCAL_ONLY):
|
|
21
|
+
return "local-only"
|
|
22
|
+
return None
|
strayguard/ui.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""All output and the prompt (SPEC 5, 7.8, 7.9). Output goes to standard error."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
LABELS = {
|
|
10
|
+
"scratch": "scratch / leftover",
|
|
11
|
+
"output-dump": "output dump",
|
|
12
|
+
"build-output": "build output",
|
|
13
|
+
"local-only": "local only",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _say(text: str = "") -> None:
|
|
18
|
+
print(text, file=sys.stderr, flush=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _color(code: str, text: str) -> str:
|
|
22
|
+
if sys.stderr.isatty() and "NO_COLOR" not in os.environ:
|
|
23
|
+
return f"\033[{code}m{text}\033[0m"
|
|
24
|
+
return text
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def note(text: str) -> None:
|
|
28
|
+
_say(f"strayguard: {text}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def can_prompt() -> bool:
|
|
32
|
+
"""SG-60: a person must see the output, and strayguard must read the keyboard."""
|
|
33
|
+
if os.environ.get("CI") or os.name == "nt" or not sys.stderr.isatty():
|
|
34
|
+
return False
|
|
35
|
+
try:
|
|
36
|
+
with open("/dev/tty"):
|
|
37
|
+
return True
|
|
38
|
+
except OSError:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _files(count: int, word: str = "new file") -> str:
|
|
43
|
+
return f"1 {word}" if count == 1 else f"{count} {word}s"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _rows(verdicts: list) -> list[str]:
|
|
47
|
+
width = max(len(v.item.path) for v in verdicts)
|
|
48
|
+
rows = []
|
|
49
|
+
for v in verdicts:
|
|
50
|
+
score = "rule" if v.probability is None else f"{round(v.probability * 100)}%"
|
|
51
|
+
rows.append(f" {v.item.path:<{width}} {LABELS.get(v.category, v.category):<18} {score}")
|
|
52
|
+
return rows
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _header(verdicts: list, text: str) -> None:
|
|
56
|
+
_say()
|
|
57
|
+
_say(_color("33", f"⚠ strayguard: {_files(len(verdicts))} {text}"))
|
|
58
|
+
_say()
|
|
59
|
+
for row in _rows(verdicts):
|
|
60
|
+
_say(row)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def warn(verdicts: list, blocking: bool) -> None:
|
|
64
|
+
"""SG-70, SG-71: non-interactive mode. Show the command that fixes it."""
|
|
65
|
+
status = "commit stopped" if blocking else "not blocking"
|
|
66
|
+
_header(verdicts, f"may not belong ({status})")
|
|
67
|
+
paths = " ".join(shlex.quote(v.item.path) for v in verdicts)
|
|
68
|
+
_say()
|
|
69
|
+
_say(" To unstage it:" if len(verdicts) == 1 else " To unstage them:")
|
|
70
|
+
_say(f" git rm --cached -r -- {paths}")
|
|
71
|
+
_say()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _ask(question: str, keys: str) -> str:
|
|
75
|
+
"""Read one answer from the keyboard. Enter means keep (decision 1)."""
|
|
76
|
+
with open("/dev/tty") as tty:
|
|
77
|
+
while True:
|
|
78
|
+
print(question, end="", file=sys.stderr, flush=True)
|
|
79
|
+
answer = tty.readline().strip().lower()[:1]
|
|
80
|
+
if not answer:
|
|
81
|
+
return "k"
|
|
82
|
+
if answer in keys:
|
|
83
|
+
return answer
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def prompt(verdicts: list) -> dict:
|
|
87
|
+
"""SG-61 to SG-63. Return {"u": [...], "g": [...], "k": [...]}."""
|
|
88
|
+
_header(verdicts, "looks like it doesn't belong" if len(verdicts) == 1
|
|
89
|
+
else "look like they don't belong")
|
|
90
|
+
_say()
|
|
91
|
+
_say(" [u] unstage [g] add to .gitignore [k] keep and commit [o] one by one")
|
|
92
|
+
key = _ask(" > ", "ugko")
|
|
93
|
+
if key != "o":
|
|
94
|
+
return {k: list(verdicts) if k == key else [] for k in "ugk"}
|
|
95
|
+
choices = {"u": [], "g": [], "k": []}
|
|
96
|
+
for v, row in zip(verdicts, _rows(verdicts)):
|
|
97
|
+
choices[_ask(f"{row} [u/g/k] > ", "ugk")].append(v)
|
|
98
|
+
return choices
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def report(choices: dict) -> None:
|
|
102
|
+
"""Say what strayguard did, then say if the commit stopped (SG-68)."""
|
|
103
|
+
tick = _color("32", "✓")
|
|
104
|
+
_say()
|
|
105
|
+
if choices["g"]:
|
|
106
|
+
_say(f" {tick} Added {_files(len(choices['g']), 'file')} to .gitignore and staged .gitignore.")
|
|
107
|
+
removed = len(choices["u"]) + len(choices["g"])
|
|
108
|
+
if removed:
|
|
109
|
+
it = "It is" if removed == 1 else "They are"
|
|
110
|
+
_say(f" {tick} Unstaged {_files(removed, 'file')}. {it} still on your disk.")
|
|
111
|
+
kept = len(choices["k"])
|
|
112
|
+
if kept:
|
|
113
|
+
it = "it" if kept == 1 else "them"
|
|
114
|
+
_say(f" {tick} Kept {_files(kept, 'file')}. strayguard will not ask about {it} again.")
|
|
115
|
+
if removed:
|
|
116
|
+
_say()
|
|
117
|
+
_say(" Commit stopped so you can check. Run git commit again.")
|
|
118
|
+
_say()
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: strayguard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A git pre-commit hook that asks TypeSafe Jev whether each new file belongs in your repo.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Eve2512/Strayguard
|
|
6
|
+
Project-URL: Issues, https://github.com/Eve2512/Strayguard/issues
|
|
7
|
+
Author: Eva Hill
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: git,gitignore,hook,jev,pre-commit,typesafe
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# Strayguard
|
|
24
|
+
Catch files that don't belong before you commit them. A git pre-commit hook that asks TypeSafe Jev whether each new file belongs in your repo. It catches scratch scripts, debug dumps, build output and local config before they land. Takes under a second, costs a fraction of a cent, needs no config.
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Why
|
|
28
|
+
|
|
29
|
+
```git add .``` adds everything. Sometimes that includes:
|
|
30
|
+
|
|
31
|
+
- ```test_fix2.py```, a throwaway script
|
|
32
|
+
- ```debug_output.json```, which you dumped while chasing a bug
|
|
33
|
+
- ```notes-to-self.md```
|
|
34
|
+
- ```data_export.csv``` with real customer rows
|
|
35
|
+
- ```.env.local``` with your own settings
|
|
36
|
+
- a whole ```dist/``` folder
|
|
37
|
+
|
|
38
|
+
Coding agents make this worse, they create helper scripts and scratch files and often don't clean them up.
|
|
39
|
+
Whatever coding tool you use most likely miss most of these files:
|
|
40
|
+
|
|
41
|
+
| Tool | Catches | Misses |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| ```.gitignore``` | Names you listed in advance | Every name you didn't think of |
|
|
44
|
+
| Secret scanners | API keys and tokens | Junk scripts, data dumps, notes |
|
|
45
|
+
| Large-file checks | Big files | Small junk files |
|
|
46
|
+
| **strayguard** | **Files that don't look like part of the project** | Changes to files git already tracks |
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
### How it works
|
|
50
|
+
|
|
51
|
+
1. You run ```git commit```.
|
|
52
|
+
2. strayguard looks only at files that are **new** in this commit.
|
|
53
|
+
3. A few local rules run first, such as ```.env``` and ```.DS_Store```. Files they match are never sent anywhere.
|
|
54
|
+
4. For the other files, Jev picks one category: **belongs**, **scratch / leftover**, **output dump**, **build output** or **local only**.
|
|
55
|
+
5. If Jev is at least 70% sure a file doesn't belong, strayguard shows it to you.
|
|
56
|
+
6. If everything belongs, you see nothing.
|
|
57
|
+
|
|
58
|
+
Jev never writes text(not good for that anyways) It only picks a category and returns a probability. The same file always gets the same answer, because strayguard caches the result.
|
|
59
|
+
|
|
60
|
+
### Install
|
|
61
|
+
|
|
62
|
+
You need Python 3.9+ and a [TypeSafe API key](https://console.typesafe.ai/keys).
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pipx install strayguard
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Until the first PyPI release, install it from GitHub instead:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pipx install git+https://github.com/Eve2512/Strayguard
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
export TYPESAFE_API_KEY=your-key
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Then, inside your repo:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
strayguard install
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```text
|
|
85
|
+
✓ strayguard installed in .git/hooks/pre-commit
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
That's it, there's no config file
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
### With the pre-commit framework
|
|
92
|
+
|
|
93
|
+
Add this to `.pre-commit-config.yaml`:
|
|
94
|
+
|
|
95
|
+
```yaml
|
|
96
|
+
repos:
|
|
97
|
+
- repo: https://github.com/Eve2512/Strayguard
|
|
98
|
+
rev: v0.1.0
|
|
99
|
+
hooks:
|
|
100
|
+
- id: strayguard
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The pre-commit framework captures the output of its hooks, so strayguard cannot ask you questions there. It shows the warning and lets the commit through, as described in [GUI clients, CI and coding agents](#gui-clients-ci-and-coding-agents).
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
## What you'll see
|
|
107
|
+
|
|
108
|
+
### Everything belongs
|
|
109
|
+
|
|
110
|
+
Nothing, your commit goes through as normal.
|
|
111
|
+
|
|
112
|
+
```text
|
|
113
|
+
$ git commit -m "add login page"
|
|
114
|
+
[main 4f2a91c] add login page
|
|
115
|
+
3 files changed, 120 insertions(+)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Something looks wrong
|
|
119
|
+
|
|
120
|
+
```text
|
|
121
|
+
$ git commit -m "add login page"
|
|
122
|
+
|
|
123
|
+
⚠ strayguard: 2 new files look like they don't belong
|
|
124
|
+
|
|
125
|
+
debug_output.json output dump 87%
|
|
126
|
+
test_fix2.py scratch / leftover 91%
|
|
127
|
+
|
|
128
|
+
[u] unstage [g] add to .gitignore [k] keep and commit [o] one by one
|
|
129
|
+
> g
|
|
130
|
+
|
|
131
|
+
✓ Added 2 files to .gitignore and staged .gitignore.
|
|
132
|
+
✓ Unstaged 2 files. They are still on your disk.
|
|
133
|
+
|
|
134
|
+
Commit stopped so you can check. Run git commit again.
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
| Key | What it does |
|
|
138
|
+
|---|---|
|
|
139
|
+
| `u` | Takes the files out of this commit. They stay on your disk. |
|
|
140
|
+
| `g` | Adds the files to `.gitignore` so this never happens again, and takes them out of this commit. |
|
|
141
|
+
| `k` | Commits the files anyway. strayguard won't ask about them again. |
|
|
142
|
+
| `o` | Lets you choose `u`, `g` or `k` for each file. |
|
|
143
|
+
| Enter | Same as `k`. |
|
|
144
|
+
|
|
145
|
+
strayguard **never deletes files**. It stops the commit only if it changed something for you, so you can check before you commit again.
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
### GUI clients, CI and coding agents
|
|
149
|
+
|
|
150
|
+
strayguard asks you a question only when its output goes straight to your terminal. GUI git clients, CI, coding agents and the pre-commit framework all capture the output, so there strayguard warns and lets the commit through:
|
|
151
|
+
|
|
152
|
+
```text
|
|
153
|
+
⚠ strayguard: 2 new files may not belong (not blocking)
|
|
154
|
+
|
|
155
|
+
debug_output.json output dump 87%
|
|
156
|
+
test_fix2.py scratch / leftover 91%
|
|
157
|
+
|
|
158
|
+
To unstage them:
|
|
159
|
+
git rm --cached -r -- debug_output.json test_fix2.py
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
To block the commit instead, set `STRAYGUARD_STRICT=1`, this is a good setting for coding-agent sessions.
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
### Files caught by a local rule
|
|
166
|
+
|
|
167
|
+
These files are never sent to Jev, so they show `rule` instead of a percentage:
|
|
168
|
+
|
|
169
|
+
```text
|
|
170
|
+
.env.local local only rule
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### When strayguard can't reach Jev
|
|
174
|
+
|
|
175
|
+
It prints one line, still runs its local rules, and lets the commit through:
|
|
176
|
+
|
|
177
|
+
```text
|
|
178
|
+
strayguard: no TYPESAFE_API_KEY set, so only the local rules ran.
|
|
179
|
+
strayguard: Jev did not answer in 5 s, so only the local rules ran.
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Settings
|
|
183
|
+
|
|
184
|
+
Everything is optional except the key.
|
|
185
|
+
|
|
186
|
+
| Variable | Default | What it does |
|
|
187
|
+
|---|---|---|
|
|
188
|
+
| `TYPESAFE_API_KEY` | none | **Required.** Your TypeSafe API key. |
|
|
189
|
+
| `STRAYGUARD=off` | `on` | Skip the check for one commit: `STRAYGUARD=off git commit ...` |
|
|
190
|
+
| `STRAYGUARD_STRICT=1` | `0` | Block the commit when strayguard can't ask you, such as in CI or with agents. |
|
|
191
|
+
| `STRAYGUARD_THRESHOLD` | `0.70` | How sure Jev must be before strayguard warns you. |
|
|
192
|
+
| `STRAYGUARD_MODEL` | `jev-latest` | Pin a version, for example `jev-1.13.0`, to keep answers stable. |
|
|
193
|
+
| `STRAYGUARD_NAMES_ONLY=1` | `0` | Send only file names and sizes, never content. |
|
|
194
|
+
|
|
195
|
+
You can also skip every hook for one commit with `git commit --no-verify`.
|
|
196
|
+
|
|
197
|
+
## Privacy
|
|
198
|
+
|
|
199
|
+
For each **new** text file, strayguard sends TypeSafe its path, its size, its first 40 lines (at most 2,000 characters) and the names of a few tracked files next to it.
|
|
200
|
+
|
|
201
|
+
It never sends:
|
|
202
|
+
|
|
203
|
+
- files that a local rule catches, such as `.env` files, keys and certificates
|
|
204
|
+
- binary files, or files larger than 1 MB
|
|
205
|
+
- any content at all when `STRAYGUARD_NAMES_ONLY=1` is set
|
|
206
|
+
|
|
207
|
+
TypeSafe says it does not train models on customer data. See its [legal page](https://docs.typesafe.ai/legal).
|
|
208
|
+
|
|
209
|
+
## Cost and speed
|
|
210
|
+
|
|
211
|
+
- A typical commit costs less than $0.0001.
|
|
212
|
+
- The check usually finishes in under a second.
|
|
213
|
+
- Each file's answer is cached by its content, so running the commit again is instant and free.
|
|
214
|
+
|
|
215
|
+
## Uninstall
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
strayguard uninstall
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
This removes the hook only if strayguard wrote it.
|
|
222
|
+
|
|
223
|
+
## Commands
|
|
224
|
+
|
|
225
|
+
| Command | What it does |
|
|
226
|
+
|---|---|
|
|
227
|
+
| `strayguard install` | Add the pre-commit hook to this repo |
|
|
228
|
+
| `strayguard uninstall` | Remove the hook |
|
|
229
|
+
| `strayguard check` | Run the check on staged new files (the hook runs this) |
|
|
230
|
+
| `strayguard --version` | Show the version |
|
|
231
|
+
|
|
232
|
+
## Development
|
|
233
|
+
|
|
234
|
+
Run the unit tests. They use no network and no API key:
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
uv run --no-project --with pytest pytest
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
GitHub Actions runs the same tests on Python 3.9 to 3.14 for each push to `main` and for each pull request.
|
|
241
|
+
|
|
242
|
+
## License
|
|
243
|
+
|
|
244
|
+
MIT. See [LICENSE](https://github.com/Eve2512/Strayguard/blob/main/LICENSE).
|
|
245
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
strayguard/__init__.py,sha256=pYDGbWRfXAHZrrUn3llLmg2TXX5_DxU6aLla6gHZR_M,244
|
|
2
|
+
strayguard/cli.py,sha256=W1EwVYreu5KQ3SGM7qM5N169sIEVb1d9ZPPJvJIoEnU,4647
|
|
3
|
+
strayguard/decide.py,sha256=YLAJ-wHqD7jpY_5EknGflMT1F_3wAIVCtz_eQd7zJVI,3454
|
|
4
|
+
strayguard/evidence.py,sha256=kCMGgsVhodTzhlViyoKMdyFMkLOeRjc22Uux_WQLrrY,3750
|
|
5
|
+
strayguard/git.py,sha256=c-bW-bvFUTwyr4P63sJfJzjnnwof7ym2-tUZAam3Xx4,3889
|
|
6
|
+
strayguard/jev.py,sha256=vx5MfcLg7B0jkuqDzJpZxdyPHitIWZjLUbzuUfYdDxE,5116
|
|
7
|
+
strayguard/rules.py,sha256=k7yQityEoDjyPYoXdMPwkS3iFeT77d4XIqifhmbB_ks,679
|
|
8
|
+
strayguard/ui.py,sha256=0Foyg3o2WEitbzxrXEXec-3KImK8fBc_MZNH0WiFWZc,3855
|
|
9
|
+
strayguard-0.1.0.dist-info/METADATA,sha256=sPm_OvLblZtL0R1QS-R_8nXmRfGV_OML730BR1xgO1g,7983
|
|
10
|
+
strayguard-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
strayguard-0.1.0.dist-info/entry_points.txt,sha256=lr038o8N2ZNvHIMrRdtTLqELHUdDH9Z0yCdzK0YM1kw,51
|
|
12
|
+
strayguard-0.1.0.dist-info/licenses/LICENSE,sha256=96EDP-JS5lyHFO6OUQPy6xSGuILsPMTm_vCvYNs_WAY,1060
|
|
13
|
+
strayguard-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eva
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|