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 ADDED
@@ -0,0 +1,8 @@
1
+ """strayguard: catch files that do not belong before you commit them."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("strayguard")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0+unknown"
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ strayguard = strayguard.cli:main
@@ -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.