gitslip 0.1.0__tar.gz

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.
gitslip-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 gitslip contributors
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.
gitslip-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: gitslip
3
+ Version: 0.1.0
4
+ Summary: Find files that slipped past .gitignore but are still tracked by git — and get the exact git rm --cached fix. Zero dependencies.
5
+ Author: yyfjj
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/jjdoor/gitslip-py
8
+ Project-URL: Repository, https://github.com/jjdoor/gitslip-py
9
+ Project-URL: Issues, https://github.com/jjdoor/gitslip-py/issues
10
+ Keywords: git,gitignore,tracked,cli,lint,cleanup,repo-hygiene,pre-commit,devops
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Software Development :: Version Control :: Git
18
+ Classifier: Topic :: Utilities
19
+ Requires-Python: >=3.8
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # gitslip
25
+
26
+ **Find files that slipped past `.gitignore` but are still tracked by git.** You
27
+ add `*.log` or `.env` to `.gitignore`, but the file that was committed *before*
28
+ the rule existed keeps getting tracked — git only ignores files it isn't
29
+ already following. `gitslip` finds those leftovers and hands you the exact fix.
30
+
31
+ ```bash
32
+ pipx run gitslip
33
+ # 2 tracked files are ignored by your rules but still committed:
34
+ #
35
+ # config/secrets.env
36
+ # ↳ .gitignore:7 *.env
37
+ # logs/app.log
38
+ # ↳ .gitignore:2 *.log
39
+ #
40
+ # Fix — stop tracking them (keeps your local copy):
41
+ # git rm --cached -- config/secrets.env
42
+ # git rm --cached -- logs/app.log
43
+ ```
44
+
45
+ Zero dependencies, pure standard library. Also available for Node:
46
+ `npx gitslip` — byte-for-byte identical behaviour.
47
+
48
+ ## Why
49
+
50
+ Adding a path to `.gitignore` does **nothing** to a file git already tracks.
51
+ That's by design — but it means secrets, build artifacts and logs routinely sit
52
+ in repos long after someone "gitignored" them. The usual `git rm --cached`
53
+ fix-up is only run once someone *notices*, and a raw `grep` over `.gitignore`
54
+ can't tell a still-tracked leftover from a file that's correctly excluded.
55
+
56
+ `gitslip` answers one question precisely: **which tracked files do my own ignore
57
+ rules say should be excluded?** It then prints the `git rm --cached` commands to
58
+ untrack them (your working copy stays put).
59
+
60
+ ## How it works
61
+
62
+ It defers all the matching to git, so negation rules (`!keep.log`), directory
63
+ rules (`build/`), nested `.gitignore` files, `.git/info/exclude` and your global
64
+ `core.excludesFile` are all handled correctly:
65
+
66
+ 1. **Detect** — `git ls-files -i -c --exclude-standard` lists files that are
67
+ both *tracked* and *ignored*. That's the authoritative slipped set.
68
+ 2. **Attribute** — for each one, `gitslip` names the rule that catches it
69
+ (`.gitignore:7 *.env`) by asking `git check-ignore` against an empty index
70
+ (tracked files are otherwise reported as "not ignored").
71
+
72
+ No file matching logic of our own = no subtle disagreements with git.
73
+
74
+ ## Usage
75
+
76
+ ```bash
77
+ gitslip # audit the whole repo (exit 1 if anything slipped)
78
+ gitslip src/ config/ # limit the audit to pathspecs
79
+ gitslip --json # machine-readable, for CI
80
+ gitslip --apply # run the git rm --cached for you (keeps files on disk)
81
+ ```
82
+
83
+ `--apply` only un-tracks; it never deletes your files. Review the diff and
84
+ commit when you're happy:
85
+
86
+ ```bash
87
+ gitslip --apply
88
+ git commit -m "stop tracking ignored files"
89
+ ```
90
+
91
+ ### In CI
92
+
93
+ ```yaml
94
+ - run: pipx run gitslip # fails the job if a tracked file is gitignored
95
+ ```
96
+
97
+ Exit codes: `0` clean · `1` slipped files found · `2` error (not a repo, git
98
+ missing).
99
+
100
+ ## Install
101
+
102
+ ```bash
103
+ pip install gitslip # or `pipx run gitslip`
104
+ npm i -g gitslip # Node build, identical behaviour
105
+ ```
106
+
107
+ Requires git on `PATH` and Python ≥ 3.8 (or Node ≥ 18).
108
+
109
+ ## License
110
+
111
+ MIT
@@ -0,0 +1,88 @@
1
+ # gitslip
2
+
3
+ **Find files that slipped past `.gitignore` but are still tracked by git.** You
4
+ add `*.log` or `.env` to `.gitignore`, but the file that was committed *before*
5
+ the rule existed keeps getting tracked — git only ignores files it isn't
6
+ already following. `gitslip` finds those leftovers and hands you the exact fix.
7
+
8
+ ```bash
9
+ pipx run gitslip
10
+ # 2 tracked files are ignored by your rules but still committed:
11
+ #
12
+ # config/secrets.env
13
+ # ↳ .gitignore:7 *.env
14
+ # logs/app.log
15
+ # ↳ .gitignore:2 *.log
16
+ #
17
+ # Fix — stop tracking them (keeps your local copy):
18
+ # git rm --cached -- config/secrets.env
19
+ # git rm --cached -- logs/app.log
20
+ ```
21
+
22
+ Zero dependencies, pure standard library. Also available for Node:
23
+ `npx gitslip` — byte-for-byte identical behaviour.
24
+
25
+ ## Why
26
+
27
+ Adding a path to `.gitignore` does **nothing** to a file git already tracks.
28
+ That's by design — but it means secrets, build artifacts and logs routinely sit
29
+ in repos long after someone "gitignored" them. The usual `git rm --cached`
30
+ fix-up is only run once someone *notices*, and a raw `grep` over `.gitignore`
31
+ can't tell a still-tracked leftover from a file that's correctly excluded.
32
+
33
+ `gitslip` answers one question precisely: **which tracked files do my own ignore
34
+ rules say should be excluded?** It then prints the `git rm --cached` commands to
35
+ untrack them (your working copy stays put).
36
+
37
+ ## How it works
38
+
39
+ It defers all the matching to git, so negation rules (`!keep.log`), directory
40
+ rules (`build/`), nested `.gitignore` files, `.git/info/exclude` and your global
41
+ `core.excludesFile` are all handled correctly:
42
+
43
+ 1. **Detect** — `git ls-files -i -c --exclude-standard` lists files that are
44
+ both *tracked* and *ignored*. That's the authoritative slipped set.
45
+ 2. **Attribute** — for each one, `gitslip` names the rule that catches it
46
+ (`.gitignore:7 *.env`) by asking `git check-ignore` against an empty index
47
+ (tracked files are otherwise reported as "not ignored").
48
+
49
+ No file matching logic of our own = no subtle disagreements with git.
50
+
51
+ ## Usage
52
+
53
+ ```bash
54
+ gitslip # audit the whole repo (exit 1 if anything slipped)
55
+ gitslip src/ config/ # limit the audit to pathspecs
56
+ gitslip --json # machine-readable, for CI
57
+ gitslip --apply # run the git rm --cached for you (keeps files on disk)
58
+ ```
59
+
60
+ `--apply` only un-tracks; it never deletes your files. Review the diff and
61
+ commit when you're happy:
62
+
63
+ ```bash
64
+ gitslip --apply
65
+ git commit -m "stop tracking ignored files"
66
+ ```
67
+
68
+ ### In CI
69
+
70
+ ```yaml
71
+ - run: pipx run gitslip # fails the job if a tracked file is gitignored
72
+ ```
73
+
74
+ Exit codes: `0` clean · `1` slipped files found · `2` error (not a repo, git
75
+ missing).
76
+
77
+ ## Install
78
+
79
+ ```bash
80
+ pip install gitslip # or `pipx run gitslip`
81
+ npm i -g gitslip # Node build, identical behaviour
82
+ ```
83
+
84
+ Requires git on `PATH` and Python ≥ 3.8 (or Node ≥ 18).
85
+
86
+ ## License
87
+
88
+ MIT
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "gitslip"
7
+ version = "0.1.0"
8
+ description = "Find files that slipped past .gitignore but are still tracked by git — and get the exact git rm --cached fix. Zero dependencies."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "yyfjj" }]
13
+ keywords = ["git", "gitignore", "tracked", "cli", "lint", "cleanup", "repo-hygiene", "pre-commit", "devops"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: Software Development :: Version Control :: Git",
22
+ "Topic :: Utilities",
23
+ ]
24
+ dependencies = []
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/jjdoor/gitslip-py"
28
+ Repository = "https://github.com/jjdoor/gitslip-py"
29
+ Issues = "https://github.com/jjdoor/gitslip-py/issues"
30
+
31
+ [project.scripts]
32
+ gitslip = "gitslip.cli:main"
33
+
34
+ [tool.setuptools]
35
+ package-dir = { "" = "src" }
36
+
37
+ [tool.setuptools.packages.find]
38
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """gitslip — find files that slipped past .gitignore but are still tracked. Zero dependencies."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,116 @@
1
+ import json
2
+ import os
3
+ import signal
4
+ import sys
5
+
6
+ from . import __version__
7
+ from . import core
8
+ from . import git as gitio
9
+
10
+
11
+ def _paint():
12
+ use_color = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
13
+
14
+ def col(c, s):
15
+ return f"\x1b[{c}m{s}\x1b[0m" if use_color else s
16
+
17
+ return {
18
+ "red": lambda s: col("31", s), "green": lambda s: col("32", s),
19
+ "yellow": lambda s: col("33", s), "dim": lambda s: col("2", s),
20
+ "bold": lambda s: col("1", s),
21
+ }
22
+
23
+
24
+ def _help(p):
25
+ return (
26
+ f"{p['bold']('gitslip')} — find files that slipped past .gitignore but are still tracked by git.\n"
27
+ "\n"
28
+ "A file is \"slipped\" when git keeps tracking it even though your ignore rules say\n"
29
+ "to exclude it — usually committed before the rule existed, or force-added. The\n"
30
+ "classic case: a .env, build artifact or *.log that's quietly still in the repo.\n"
31
+ "\n"
32
+ f"{p['bold']('Usage')}\n"
33
+ " gitslip [pathspec...] Audit the repo; list tracked files your rules exclude\n"
34
+ " gitslip --json Machine-readable output (for CI)\n"
35
+ " gitslip --apply Stop tracking the slipped files (git rm --cached; keeps your copy)\n"
36
+ " gitslip --help | --version\n"
37
+ "\n"
38
+ f"{p['bold']('Exit')} 0 clean · 1 slipped files found · 2 error\n"
39
+ )
40
+
41
+
42
+ def main(argv=None):
43
+ # Restore default SIGPIPE so piping into `head` doesn't dump a traceback.
44
+ try:
45
+ signal.signal(signal.SIGPIPE, signal.SIG_DFL)
46
+ except (AttributeError, ValueError):
47
+ pass
48
+
49
+ argv = list(sys.argv[1:] if argv is None else argv)
50
+ p = _paint()
51
+
52
+ def die(msg):
53
+ sys.stderr.write(p["red"](f"gitslip: {msg}\n"))
54
+ return 2
55
+
56
+ if "-h" in argv or "--help" in argv:
57
+ sys.stdout.write(_help(p))
58
+ return 0
59
+ if "-v" in argv or "--version" in argv:
60
+ sys.stdout.write(__version__ + "\n")
61
+ return 0
62
+
63
+ # Single pass: collect flags + pathspecs, honour a `--` terminator, and reject
64
+ # unknown options (so a typo'd `--aply` errors instead of silently scanning all).
65
+ as_json = False
66
+ apply = False
67
+ dd_seen = False
68
+ pathspecs = []
69
+ for a in argv:
70
+ if dd_seen:
71
+ pathspecs.append(a)
72
+ elif a == "--":
73
+ dd_seen = True
74
+ elif a == "--json":
75
+ as_json = True
76
+ elif a == "--apply":
77
+ apply = True
78
+ elif a in ("-h", "--help", "-v", "--version"):
79
+ continue
80
+ elif a.startswith("-"):
81
+ return die(f"unknown option: {a} (use -- to pass a path that starts with '-')")
82
+ else:
83
+ pathspecs.append(a)
84
+
85
+ if not gitio.git_available():
86
+ return die("git is not installed or not on PATH")
87
+ if not gitio.inside_repo():
88
+ return die("not inside a git repository")
89
+
90
+ try:
91
+ paths_bytes = gitio.slipped_paths_z(pathspecs)
92
+ paths = core.parse_nul_list(paths_bytes.decode("utf-8", "replace"))
93
+ attr = core.attribution_map(core.parse_check_ignore_z(gitio.attribution_z(paths_bytes))) if paths else {}
94
+ slips = core.build_slips(paths, attr)
95
+ except RuntimeError as e:
96
+ return die(str(e))
97
+
98
+ if apply:
99
+ if not slips:
100
+ sys.stdout.write(p["green"]("✓ nothing to fix\n"))
101
+ return 0
102
+ try:
103
+ gitio.untrack([s["path"] for s in slips])
104
+ except RuntimeError as e:
105
+ return die(str(e))
106
+ sys.stdout.write(p["green"](f"✓ untracked {len(slips)} file(s) — kept on disk. Commit to finish.\n"))
107
+ for s in slips:
108
+ sys.stdout.write(p["dim"](f" - {s['path']}\n"))
109
+ return 0
110
+
111
+ if as_json:
112
+ sys.stdout.write(json.dumps(core.to_json(slips), indent=2, ensure_ascii=False) + "\n")
113
+ return 0 if not slips else 1
114
+
115
+ sys.stdout.write(core.format_report(slips, p) + "\n")
116
+ return 0 if not slips else 1
@@ -0,0 +1,118 @@
1
+ """gitslip core — pure parsing & reporting. No subprocess, no fs, no clock.
2
+
3
+ A "slipped" file is one git is *tracking* even though your ignore rules say it
4
+ should be excluded — it slipped into the repo before the rule existed, or was
5
+ force-added. Detection is done by git itself (``git ls-files -i -c
6
+ --exclude-standard``, which honours negations, directory rules and the global
7
+ excludes); this module just parses git's NUL-delimited output, attaches the
8
+ matching rule, and renders the report. All IO lives in git.py.
9
+
10
+ Output is kept byte-for-byte identical to the Node build.
11
+ """
12
+
13
+ import re
14
+
15
+ # Plain paths that need no shell quoting. Use ``fullmatch`` (not a ``$`` anchor)
16
+ # so a trailing newline can't sneak a match through — matching the JS regex.
17
+ _PLAIN_PATH = re.compile(r"[A-Za-z0-9_./@%+=:,-]+")
18
+
19
+
20
+ def parse_nul_list(text):
21
+ """Split a NUL-delimited blob into entries, dropping the empty tail."""
22
+ return [s for s in text.split("\0") if s != ""]
23
+
24
+
25
+ def parse_check_ignore_z(text):
26
+ """Parse ``git check-ignore -v -z`` output into attribution records.
27
+
28
+ Each match is four NUL-terminated fields: source, linenum, pattern, path.
29
+ """
30
+ f = text.split("\0")
31
+ out = []
32
+ i = 0
33
+ while i + 3 < len(f):
34
+ source, line, pattern, path = f[i], f[i + 1], f[i + 2], f[i + 3]
35
+ i += 4
36
+ if path == "":
37
+ continue
38
+ out.append({"source": source, "line": (None if line == "" else int(line)),
39
+ "pattern": pattern, "path": path})
40
+ return out
41
+
42
+
43
+ def attribution_map(records):
44
+ """path -> {source, line, pattern}. Negations skipped; later records win."""
45
+ m = {}
46
+ for r in records:
47
+ if not r["pattern"] or r["pattern"].startswith("!"):
48
+ continue
49
+ m[r["path"]] = {"source": r["source"], "line": r["line"], "pattern": r["pattern"]}
50
+ return m
51
+
52
+
53
+ def build_slips(paths, attr_map):
54
+ """Combine the slipped-path list with attribution, sorted by UTF-8 bytes."""
55
+ seen = set()
56
+ slips = []
57
+ for path in paths:
58
+ if path in seen:
59
+ continue
60
+ seen.add(path)
61
+ a = attr_map.get(path)
62
+ slips.append({
63
+ "path": path,
64
+ "source": a["source"] if a else None,
65
+ "line": a["line"] if a else None,
66
+ "pattern": a["pattern"] if a else None,
67
+ })
68
+ slips.sort(key=lambda s: s["path"].encode("utf-8"))
69
+ return slips
70
+
71
+
72
+ def shell_quote(p):
73
+ """Shell-quote a path for a copy-pasteable command (POSIX single-quote)."""
74
+ if _PLAIN_PATH.fullmatch(p):
75
+ return p
76
+ return "'" + p.replace("'", "'\\''") + "'"
77
+
78
+
79
+ def fix_commands(slips):
80
+ """The remediation commands: untrack each file but keep it on disk."""
81
+ return [f"git rm --cached -- {shell_quote(s['path'])}" for s in slips]
82
+
83
+
84
+ def to_json(slips):
85
+ """Machine-readable result."""
86
+ return {
87
+ "slipped": [{"path": s["path"], "source": s["source"], "line": s["line"],
88
+ "pattern": s["pattern"]} for s in slips],
89
+ "count": len(slips),
90
+ }
91
+
92
+
93
+ # Identity palette — used by tests and when color is off. The real ANSI palette
94
+ # is injected from the CLI so report rendering stays unit-testable.
95
+ PLAIN = {"red": lambda s: s, "green": lambda s: s, "yellow": lambda s: s,
96
+ "dim": lambda s: s, "bold": lambda s: s}
97
+
98
+
99
+ def format_report(slips, paint=None):
100
+ """Render the human report. ``paint`` is a palette of (str)->str colorizers,
101
+ injected so the exact text can be asserted in tests with PLAIN."""
102
+ p = paint or PLAIN
103
+ if not slips:
104
+ return p["green"]("✓ clean — no tracked file is matched by your ignore rules")
105
+ n = len(slips)
106
+ head = f"{n} tracked {'file is' if n == 1 else 'files are'} ignored by your rules but still committed:"
107
+ lines = [p["bold"](head), ""]
108
+ for s in slips:
109
+ lines.append(" " + p["yellow"](s["path"]))
110
+ if s["pattern"]:
111
+ lines.append(" " + p["dim"](f"↳ {s['source']}:{s['line']} {s['pattern']}"))
112
+ lines.append("")
113
+ lines.append(p["bold"]("Fix — stop tracking them (keeps your local copy):"))
114
+ for cmd in fix_commands(slips):
115
+ lines.append(p["dim"](" " + cmd))
116
+ lines.append("")
117
+ lines.append(p["dim"](" or let gitslip do it: gitslip --apply"))
118
+ return "\n".join(lines)
@@ -0,0 +1,77 @@
1
+ """gitslip IO — every call that shells out to git. No business logic here.
2
+
3
+ Detection leans entirely on git so the gnarly bits (negation rules, directory
4
+ patterns, nested .gitignore, the global excludesFile, .git/info/exclude) are
5
+ git's problem, not ours. Rule attribution uses a throwaway empty index so
6
+ check-ignore will name the matching pattern even for already-tracked paths (git
7
+ otherwise short-circuits tracked files to "not ignored").
8
+ """
9
+
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ import tempfile
14
+
15
+
16
+ def _git(args, **kw):
17
+ return subprocess.run(["git", *args], capture_output=True, **kw)
18
+
19
+
20
+ def _err(r):
21
+ msg = (r.stderr or b"").decode("utf-8", "replace").strip()
22
+ return msg or f"exit {r.returncode}"
23
+
24
+
25
+ def git_available():
26
+ try:
27
+ return _git(["--version"]).returncode == 0
28
+ except FileNotFoundError:
29
+ return False
30
+
31
+
32
+ def inside_repo():
33
+ r = _git(["rev-parse", "--is-inside-work-tree"])
34
+ return r.returncode == 0 and r.stdout.decode("utf-8", "replace").strip() == "true"
35
+
36
+
37
+ def slipped_paths_z(pathspecs):
38
+ """Authoritative slipped list: tracked (-c) AND ignored (-i), NUL-separated.
39
+
40
+ ``-i`` requires an exclude source, hence ``--exclude-standard``.
41
+ """
42
+ args = ["ls-files", "-i", "-c", "--exclude-standard", "-z"]
43
+ if pathspecs:
44
+ args += ["--", *pathspecs]
45
+ r = _git(args)
46
+ if r.returncode != 0:
47
+ raise RuntimeError("git ls-files failed: " + _err(r))
48
+ return r.stdout # bytes
49
+
50
+
51
+ def attribution_z(paths_bytes):
52
+ """Best-effort rule attribution. Runs check-ignore against an empty index
53
+ (GIT_INDEX_FILE -> a non-existent path git treats as empty and never writes)
54
+ so tracked paths aren't short-circuited. Returns '' on any failure."""
55
+ if not paths_bytes:
56
+ return ""
57
+ # A throwaway, guaranteed-fresh empty index — git never writes it (check-ignore
58
+ # is read-only) but mkdtemp rules out a stale/poisoned file at a fixed path.
59
+ tmpdir = tempfile.mkdtemp(prefix="gitslip-")
60
+ try:
61
+ env = {**os.environ, "GIT_INDEX_FILE": os.path.join(tmpdir, "index")}
62
+ r = _git(["check-ignore", "-v", "-z", "--stdin"], input=paths_bytes, env=env)
63
+ if r.returncode not in (0, 1): # 128 / errors -> best-effort empty
64
+ return ""
65
+ return r.stdout.decode("utf-8", "replace")
66
+ finally:
67
+ shutil.rmtree(tmpdir, ignore_errors=True)
68
+
69
+
70
+ def untrack(paths):
71
+ """Stop tracking the given paths (keeps the working-tree copy). Batched."""
72
+ batch = 100
73
+ for i in range(0, len(paths), batch):
74
+ chunk = paths[i:i + batch]
75
+ r = _git(["rm", "--cached", "--quiet", "--", *chunk])
76
+ if r.returncode != 0:
77
+ raise RuntimeError("git rm --cached failed: " + _err(r))
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: gitslip
3
+ Version: 0.1.0
4
+ Summary: Find files that slipped past .gitignore but are still tracked by git — and get the exact git rm --cached fix. Zero dependencies.
5
+ Author: yyfjj
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/jjdoor/gitslip-py
8
+ Project-URL: Repository, https://github.com/jjdoor/gitslip-py
9
+ Project-URL: Issues, https://github.com/jjdoor/gitslip-py/issues
10
+ Keywords: git,gitignore,tracked,cli,lint,cleanup,repo-hygiene,pre-commit,devops
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Software Development :: Version Control :: Git
18
+ Classifier: Topic :: Utilities
19
+ Requires-Python: >=3.8
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # gitslip
25
+
26
+ **Find files that slipped past `.gitignore` but are still tracked by git.** You
27
+ add `*.log` or `.env` to `.gitignore`, but the file that was committed *before*
28
+ the rule existed keeps getting tracked — git only ignores files it isn't
29
+ already following. `gitslip` finds those leftovers and hands you the exact fix.
30
+
31
+ ```bash
32
+ pipx run gitslip
33
+ # 2 tracked files are ignored by your rules but still committed:
34
+ #
35
+ # config/secrets.env
36
+ # ↳ .gitignore:7 *.env
37
+ # logs/app.log
38
+ # ↳ .gitignore:2 *.log
39
+ #
40
+ # Fix — stop tracking them (keeps your local copy):
41
+ # git rm --cached -- config/secrets.env
42
+ # git rm --cached -- logs/app.log
43
+ ```
44
+
45
+ Zero dependencies, pure standard library. Also available for Node:
46
+ `npx gitslip` — byte-for-byte identical behaviour.
47
+
48
+ ## Why
49
+
50
+ Adding a path to `.gitignore` does **nothing** to a file git already tracks.
51
+ That's by design — but it means secrets, build artifacts and logs routinely sit
52
+ in repos long after someone "gitignored" them. The usual `git rm --cached`
53
+ fix-up is only run once someone *notices*, and a raw `grep` over `.gitignore`
54
+ can't tell a still-tracked leftover from a file that's correctly excluded.
55
+
56
+ `gitslip` answers one question precisely: **which tracked files do my own ignore
57
+ rules say should be excluded?** It then prints the `git rm --cached` commands to
58
+ untrack them (your working copy stays put).
59
+
60
+ ## How it works
61
+
62
+ It defers all the matching to git, so negation rules (`!keep.log`), directory
63
+ rules (`build/`), nested `.gitignore` files, `.git/info/exclude` and your global
64
+ `core.excludesFile` are all handled correctly:
65
+
66
+ 1. **Detect** — `git ls-files -i -c --exclude-standard` lists files that are
67
+ both *tracked* and *ignored*. That's the authoritative slipped set.
68
+ 2. **Attribute** — for each one, `gitslip` names the rule that catches it
69
+ (`.gitignore:7 *.env`) by asking `git check-ignore` against an empty index
70
+ (tracked files are otherwise reported as "not ignored").
71
+
72
+ No file matching logic of our own = no subtle disagreements with git.
73
+
74
+ ## Usage
75
+
76
+ ```bash
77
+ gitslip # audit the whole repo (exit 1 if anything slipped)
78
+ gitslip src/ config/ # limit the audit to pathspecs
79
+ gitslip --json # machine-readable, for CI
80
+ gitslip --apply # run the git rm --cached for you (keeps files on disk)
81
+ ```
82
+
83
+ `--apply` only un-tracks; it never deletes your files. Review the diff and
84
+ commit when you're happy:
85
+
86
+ ```bash
87
+ gitslip --apply
88
+ git commit -m "stop tracking ignored files"
89
+ ```
90
+
91
+ ### In CI
92
+
93
+ ```yaml
94
+ - run: pipx run gitslip # fails the job if a tracked file is gitignored
95
+ ```
96
+
97
+ Exit codes: `0` clean · `1` slipped files found · `2` error (not a repo, git
98
+ missing).
99
+
100
+ ## Install
101
+
102
+ ```bash
103
+ pip install gitslip # or `pipx run gitslip`
104
+ npm i -g gitslip # Node build, identical behaviour
105
+ ```
106
+
107
+ Requires git on `PATH` and Python ≥ 3.8 (or Node ≥ 18).
108
+
109
+ ## License
110
+
111
+ MIT
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/gitslip/__init__.py
5
+ src/gitslip/__main__.py
6
+ src/gitslip/cli.py
7
+ src/gitslip/core.py
8
+ src/gitslip/git.py
9
+ src/gitslip.egg-info/PKG-INFO
10
+ src/gitslip.egg-info/SOURCES.txt
11
+ src/gitslip.egg-info/dependency_links.txt
12
+ src/gitslip.egg-info/entry_points.txt
13
+ src/gitslip.egg-info/top_level.txt
14
+ tests/test_core.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gitslip = gitslip.cli:main
@@ -0,0 +1 @@
1
+ gitslip
@@ -0,0 +1,107 @@
1
+ import pytest
2
+
3
+ from gitslip.core import (
4
+ parse_nul_list, parse_check_ignore_z, attribution_map, build_slips,
5
+ shell_quote, fix_commands, to_json, format_report,
6
+ )
7
+
8
+
9
+ def test_parse_nul_list_drops_trailing_empty():
10
+ assert parse_nul_list("a\0b/c\0d\0") == ["a", "b/c", "d"]
11
+ assert parse_nul_list("") == []
12
+ assert parse_nul_list("only\0") == ["only"]
13
+
14
+
15
+ def test_parse_check_ignore_z_reads_groups():
16
+ text = ".gitignore\x001\x00*.log\x00app.log\x00.gitignore\x003\x00build/\x00build/out.o\x00"
17
+ assert parse_check_ignore_z(text) == [
18
+ {"source": ".gitignore", "line": 1, "pattern": "*.log", "path": "app.log"},
19
+ {"source": ".gitignore", "line": 3, "pattern": "build/", "path": "build/out.o"},
20
+ ]
21
+
22
+
23
+ def test_parse_check_ignore_z_empty():
24
+ assert parse_check_ignore_z("") == []
25
+
26
+
27
+ def test_attribution_map_skips_negation_and_last_wins():
28
+ recs = [
29
+ {"source": ".gitignore", "line": 1, "pattern": "*.log", "path": "a.log"},
30
+ {"source": ".gitignore", "line": 2, "pattern": "!keep.log", "path": "keep.log"},
31
+ {"source": ".gitignore", "line": 1, "pattern": "*.log", "path": "a.log"},
32
+ ]
33
+ m = attribution_map(recs)
34
+ assert m["a.log"] == {"source": ".gitignore", "line": 1, "pattern": "*.log"}
35
+ assert "keep.log" not in m
36
+
37
+
38
+ def test_build_slips_merges_dedups_sorts():
39
+ paths = ["src/z.log", "a.log", "a.log"]
40
+ attr = {"a.log": {"source": ".gitignore", "line": 1, "pattern": "*.log"}}
41
+ slips = build_slips(paths, attr)
42
+ assert [s["path"] for s in slips] == ["a.log", "src/z.log"]
43
+ assert slips[0] == {"path": "a.log", "source": ".gitignore", "line": 1, "pattern": "*.log"}
44
+ assert slips[1] == {"path": "src/z.log", "source": None, "line": None, "pattern": None}
45
+
46
+
47
+ def test_byte_order_sort_separators():
48
+ # 0x2e '.' < 0x2f '/' < 0x62 'b'
49
+ slips = build_slips(["a/b", "a.b", "ab"], {})
50
+ assert [s["path"] for s in slips] == ["a.b", "a/b", "ab"]
51
+
52
+
53
+ def test_shell_quote():
54
+ assert shell_quote("src/app.log") == "src/app.log"
55
+ assert shell_quote("weird name.log") == "'weird name.log'"
56
+ assert shell_quote("it's.log") == "'it'\\''s.log'"
57
+
58
+
59
+ def test_shell_quote_rejects_trailing_newline():
60
+ # fullmatch (not $) so a newline can't slip through unquoted — Node parity.
61
+ assert shell_quote("a\n") == "'a\n'"
62
+
63
+
64
+ def test_fix_commands():
65
+ slips = [{"path": "a.log"}, {"path": "a b.log"}]
66
+ assert fix_commands(slips) == [
67
+ "git rm --cached -- a.log",
68
+ "git rm --cached -- 'a b.log'",
69
+ ]
70
+
71
+
72
+ def test_to_json_shape():
73
+ slips = [{"path": "a.log", "source": ".gitignore", "line": 1, "pattern": "*.log"},
74
+ {"path": "b", "source": None, "line": None, "pattern": None}]
75
+ assert to_json(slips) == {
76
+ "slipped": [
77
+ {"path": "a.log", "source": ".gitignore", "line": 1, "pattern": "*.log"},
78
+ {"path": "b", "source": None, "line": None, "pattern": None},
79
+ ],
80
+ "count": 2,
81
+ }
82
+
83
+
84
+ def test_format_report_clean():
85
+ assert format_report([]).startswith("✓ clean")
86
+
87
+
88
+ def test_format_report_lists_and_fix():
89
+ slips = [{"path": "app.log", "source": ".gitignore", "line": 1, "pattern": "*.log"}]
90
+ out = format_report(slips)
91
+ assert "1 tracked file is ignored" in out
92
+ assert "app.log" in out
93
+ assert "↳ .gitignore:1 *.log" in out
94
+ assert "git rm --cached -- app.log" in out
95
+ assert "gitslip --apply" in out
96
+
97
+
98
+ def test_format_report_plural_and_unattributed():
99
+ slips = [
100
+ {"path": "a.log", "source": None, "line": None, "pattern": None},
101
+ {"path": "b.log", "source": ".gitignore", "line": 1, "pattern": "*.log"},
102
+ ]
103
+ out = format_report(slips)
104
+ assert "2 tracked files are ignored" in out
105
+ lines = out.split("\n")
106
+ ai = next(i for i, l in enumerate(lines) if "a.log" in l)
107
+ assert "↳" not in lines[ai + 1]