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 +21 -0
- gitslip-0.1.0/PKG-INFO +111 -0
- gitslip-0.1.0/README.md +88 -0
- gitslip-0.1.0/pyproject.toml +38 -0
- gitslip-0.1.0/setup.cfg +4 -0
- gitslip-0.1.0/src/gitslip/__init__.py +3 -0
- gitslip-0.1.0/src/gitslip/__main__.py +6 -0
- gitslip-0.1.0/src/gitslip/cli.py +116 -0
- gitslip-0.1.0/src/gitslip/core.py +118 -0
- gitslip-0.1.0/src/gitslip/git.py +77 -0
- gitslip-0.1.0/src/gitslip.egg-info/PKG-INFO +111 -0
- gitslip-0.1.0/src/gitslip.egg-info/SOURCES.txt +14 -0
- gitslip-0.1.0/src/gitslip.egg-info/dependency_links.txt +1 -0
- gitslip-0.1.0/src/gitslip.egg-info/entry_points.txt +2 -0
- gitslip-0.1.0/src/gitslip.egg-info/top_level.txt +1 -0
- gitslip-0.1.0/tests/test_core.py +107 -0
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
|
gitslip-0.1.0/README.md
ADDED
|
@@ -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"]
|
gitslip-0.1.0/setup.cfg
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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]
|