smart-gitignore 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.
- smart_gitignore-0.1.0/LICENSE +21 -0
- smart_gitignore-0.1.0/PKG-INFO +149 -0
- smart_gitignore-0.1.0/README.md +129 -0
- smart_gitignore-0.1.0/pyproject.toml +31 -0
- smart_gitignore-0.1.0/setup.cfg +4 -0
- smart_gitignore-0.1.0/smart_gitignore/__init__.py +4 -0
- smart_gitignore-0.1.0/smart_gitignore/cli.py +307 -0
- smart_gitignore-0.1.0/smart_gitignore/rules.py +94 -0
- smart_gitignore-0.1.0/smart_gitignore.egg-info/PKG-INFO +149 -0
- smart_gitignore-0.1.0/smart_gitignore.egg-info/SOURCES.txt +13 -0
- smart_gitignore-0.1.0/smart_gitignore.egg-info/dependency_links.txt +1 -0
- smart_gitignore-0.1.0/smart_gitignore.egg-info/entry_points.txt +2 -0
- smart_gitignore-0.1.0/smart_gitignore.egg-info/requires.txt +3 -0
- smart_gitignore-0.1.0/smart_gitignore.egg-info/top_level.txt +1 -0
- smart_gitignore-0.1.0/tests/test_cli.py +201 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Your Name
|
|
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.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: smart-gitignore
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Scan a project and auto-generate sensible .gitignore rules for logs, env files, venvs, node_modules and more.
|
|
5
|
+
Author: Your Name
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/YOUR_USERNAME/smart-gitignore
|
|
8
|
+
Keywords: git,gitignore,cli,developer-tools
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
14
|
+
Requires-Python: >=3.7
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# smart-gitignore
|
|
22
|
+
|
|
23
|
+
Scan a project directory and auto-generate sensible `.gitignore` rules —
|
|
24
|
+
logs, `.env` files, virtual environments, `node_modules`, caches, build
|
|
25
|
+
artifacts, and OS junk — in a single command.
|
|
26
|
+
|
|
27
|
+
Built for a very common beginner mistake: forgetting to add `.gitignore`
|
|
28
|
+
*before* the first commit, then accidentally pushing log files, secrets,
|
|
29
|
+
or dependency folders into the repo.
|
|
30
|
+
|
|
31
|
+
## Why
|
|
32
|
+
|
|
33
|
+
Most `.gitignore` generators (gitignore.io, `gig`, etc.) ask you to pick a
|
|
34
|
+
language/framework and give you a template. `smart-gitignore` instead
|
|
35
|
+
**looks at what's actually sitting in your project folder** and suggests
|
|
36
|
+
rules based on real files it finds — logs, envs, venvs, dependency
|
|
37
|
+
folders — regardless of language.
|
|
38
|
+
|
|
39
|
+
It also does something most generators don't: it checks whether any of
|
|
40
|
+
those files are **already tracked by git**, and warns you that adding a
|
|
41
|
+
`.gitignore` rule alone won't remove them from the repo or its history.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install smart-gitignore
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
(Or, until published: clone this repo and run `pip install .`)
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# Preview what would be added, without touching any files
|
|
55
|
+
smart-gitignore /path/to/project --dry-run
|
|
56
|
+
|
|
57
|
+
# Scan current directory and write changes (with confirmation prompt)
|
|
58
|
+
smart-gitignore
|
|
59
|
+
|
|
60
|
+
# Skip the confirmation prompt for .gitignore writes
|
|
61
|
+
smart-gitignore --yes
|
|
62
|
+
|
|
63
|
+
# Also run git rm --cached automatically on already-tracked matches
|
|
64
|
+
# (still separate from --yes; off by default)
|
|
65
|
+
smart-gitignore --auto-untrack
|
|
66
|
+
|
|
67
|
+
# Also detect and ignore images/video/audio (off by default —
|
|
68
|
+
# many projects intentionally commit media assets)
|
|
69
|
+
smart-gitignore --include-media
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Example output
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
Scanning /home/you/my-project ...
|
|
76
|
+
|
|
77
|
+
Detected files that should probably be ignored:
|
|
78
|
+
|
|
79
|
+
[Logs]
|
|
80
|
+
*.log
|
|
81
|
+
logs/
|
|
82
|
+
[Environment / Secrets]
|
|
83
|
+
.env
|
|
84
|
+
[Python Virtual Environments]
|
|
85
|
+
venv/
|
|
86
|
+
|
|
87
|
+
⚠ WARNING: these files are already tracked by git.
|
|
88
|
+
Adding a pattern to .gitignore will NOT remove them from the repo
|
|
89
|
+
or from your git history. The fix is to untrack them:
|
|
90
|
+
|
|
91
|
+
git rm --cached "app.log" # matched pattern: *.log
|
|
92
|
+
|
|
93
|
+
Append these rules to .gitignore? [y/N] y
|
|
94
|
+
Done. Updated .gitignore
|
|
95
|
+
|
|
96
|
+
Run 'git rm --cached' on the 1 already-tracked file(s) shown above now? [y/N] y
|
|
97
|
+
|
|
98
|
+
Untracked 1 file(s):
|
|
99
|
+
git rm --cached "app.log" done
|
|
100
|
+
|
|
101
|
+
These files still exist on disk — only removed from git's index.
|
|
102
|
+
Commit this to finish: git commit -m "Stop tracking ignored files"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Writing `.gitignore` and untracking already-committed files are **two
|
|
106
|
+
separate confirmations** — saying yes to one never silently triggers the
|
|
107
|
+
other. Pass `--auto-untrack` to skip the second prompt if you're scripting
|
|
108
|
+
this (e.g. in CI or a setup script).
|
|
109
|
+
|
|
110
|
+
## What it detects by default
|
|
111
|
+
|
|
112
|
+
| Category | Examples |
|
|
113
|
+
|---|---|
|
|
114
|
+
| Logs | `*.log`, `logs/` |
|
|
115
|
+
| Environment / secrets | `.env`, `.env.local` |
|
|
116
|
+
| Python virtual environments | `venv/`, `.venv/`, `env/` |
|
|
117
|
+
| Python cache | `__pycache__/`, `*.pyc` |
|
|
118
|
+
| Node dependencies | `node_modules/` |
|
|
119
|
+
| Build artifacts | `dist/`, `build/`, `target/` |
|
|
120
|
+
| OS junk | `.DS_Store`, `Thumbs.db` |
|
|
121
|
+
| Editor/IDE | `.vscode/`, `.idea/` |
|
|
122
|
+
|
|
123
|
+
Images, video, and audio are **opt-in only** (`--include-media`) since
|
|
124
|
+
many repos legitimately track media assets (docs, icons, website content).
|
|
125
|
+
|
|
126
|
+
## Important: this doesn't rewrite git history
|
|
127
|
+
|
|
128
|
+
`smart-gitignore` only edits `.gitignore`. If a file is already committed,
|
|
129
|
+
ignoring it going forward requires:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
git rm --cached path/to/file
|
|
133
|
+
git commit -m "Stop tracking file"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
If sensitive data (API keys, passwords) was ever committed, `git rm --cached`
|
|
137
|
+
is **not enough** — it's still in old commits. Use
|
|
138
|
+
[`git filter-repo`](https://github.com/newren/git-filter-repo) or the
|
|
139
|
+
[BFG Repo-Cleaner](https://rtyley.github.io/bfg-repo-cleaner/) to scrub history,
|
|
140
|
+
and rotate/revoke the leaked credentials regardless.
|
|
141
|
+
|
|
142
|
+
## Contributing
|
|
143
|
+
|
|
144
|
+
PRs welcome — especially additional rule categories in `rules.py`
|
|
145
|
+
(other language ecosystems, other build tools, etc.).
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
MIT
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# smart-gitignore
|
|
2
|
+
|
|
3
|
+
Scan a project directory and auto-generate sensible `.gitignore` rules —
|
|
4
|
+
logs, `.env` files, virtual environments, `node_modules`, caches, build
|
|
5
|
+
artifacts, and OS junk — in a single command.
|
|
6
|
+
|
|
7
|
+
Built for a very common beginner mistake: forgetting to add `.gitignore`
|
|
8
|
+
*before* the first commit, then accidentally pushing log files, secrets,
|
|
9
|
+
or dependency folders into the repo.
|
|
10
|
+
|
|
11
|
+
## Why
|
|
12
|
+
|
|
13
|
+
Most `.gitignore` generators (gitignore.io, `gig`, etc.) ask you to pick a
|
|
14
|
+
language/framework and give you a template. `smart-gitignore` instead
|
|
15
|
+
**looks at what's actually sitting in your project folder** and suggests
|
|
16
|
+
rules based on real files it finds — logs, envs, venvs, dependency
|
|
17
|
+
folders — regardless of language.
|
|
18
|
+
|
|
19
|
+
It also does something most generators don't: it checks whether any of
|
|
20
|
+
those files are **already tracked by git**, and warns you that adding a
|
|
21
|
+
`.gitignore` rule alone won't remove them from the repo or its history.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install smart-gitignore
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
(Or, until published: clone this repo and run `pip install .`)
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Preview what would be added, without touching any files
|
|
35
|
+
smart-gitignore /path/to/project --dry-run
|
|
36
|
+
|
|
37
|
+
# Scan current directory and write changes (with confirmation prompt)
|
|
38
|
+
smart-gitignore
|
|
39
|
+
|
|
40
|
+
# Skip the confirmation prompt for .gitignore writes
|
|
41
|
+
smart-gitignore --yes
|
|
42
|
+
|
|
43
|
+
# Also run git rm --cached automatically on already-tracked matches
|
|
44
|
+
# (still separate from --yes; off by default)
|
|
45
|
+
smart-gitignore --auto-untrack
|
|
46
|
+
|
|
47
|
+
# Also detect and ignore images/video/audio (off by default —
|
|
48
|
+
# many projects intentionally commit media assets)
|
|
49
|
+
smart-gitignore --include-media
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Example output
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
Scanning /home/you/my-project ...
|
|
56
|
+
|
|
57
|
+
Detected files that should probably be ignored:
|
|
58
|
+
|
|
59
|
+
[Logs]
|
|
60
|
+
*.log
|
|
61
|
+
logs/
|
|
62
|
+
[Environment / Secrets]
|
|
63
|
+
.env
|
|
64
|
+
[Python Virtual Environments]
|
|
65
|
+
venv/
|
|
66
|
+
|
|
67
|
+
⚠ WARNING: these files are already tracked by git.
|
|
68
|
+
Adding a pattern to .gitignore will NOT remove them from the repo
|
|
69
|
+
or from your git history. The fix is to untrack them:
|
|
70
|
+
|
|
71
|
+
git rm --cached "app.log" # matched pattern: *.log
|
|
72
|
+
|
|
73
|
+
Append these rules to .gitignore? [y/N] y
|
|
74
|
+
Done. Updated .gitignore
|
|
75
|
+
|
|
76
|
+
Run 'git rm --cached' on the 1 already-tracked file(s) shown above now? [y/N] y
|
|
77
|
+
|
|
78
|
+
Untracked 1 file(s):
|
|
79
|
+
git rm --cached "app.log" done
|
|
80
|
+
|
|
81
|
+
These files still exist on disk — only removed from git's index.
|
|
82
|
+
Commit this to finish: git commit -m "Stop tracking ignored files"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Writing `.gitignore` and untracking already-committed files are **two
|
|
86
|
+
separate confirmations** — saying yes to one never silently triggers the
|
|
87
|
+
other. Pass `--auto-untrack` to skip the second prompt if you're scripting
|
|
88
|
+
this (e.g. in CI or a setup script).
|
|
89
|
+
|
|
90
|
+
## What it detects by default
|
|
91
|
+
|
|
92
|
+
| Category | Examples |
|
|
93
|
+
|---|---|
|
|
94
|
+
| Logs | `*.log`, `logs/` |
|
|
95
|
+
| Environment / secrets | `.env`, `.env.local` |
|
|
96
|
+
| Python virtual environments | `venv/`, `.venv/`, `env/` |
|
|
97
|
+
| Python cache | `__pycache__/`, `*.pyc` |
|
|
98
|
+
| Node dependencies | `node_modules/` |
|
|
99
|
+
| Build artifacts | `dist/`, `build/`, `target/` |
|
|
100
|
+
| OS junk | `.DS_Store`, `Thumbs.db` |
|
|
101
|
+
| Editor/IDE | `.vscode/`, `.idea/` |
|
|
102
|
+
|
|
103
|
+
Images, video, and audio are **opt-in only** (`--include-media`) since
|
|
104
|
+
many repos legitimately track media assets (docs, icons, website content).
|
|
105
|
+
|
|
106
|
+
## Important: this doesn't rewrite git history
|
|
107
|
+
|
|
108
|
+
`smart-gitignore` only edits `.gitignore`. If a file is already committed,
|
|
109
|
+
ignoring it going forward requires:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
git rm --cached path/to/file
|
|
113
|
+
git commit -m "Stop tracking file"
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
If sensitive data (API keys, passwords) was ever committed, `git rm --cached`
|
|
117
|
+
is **not enough** — it's still in old commits. Use
|
|
118
|
+
[`git filter-repo`](https://github.com/newren/git-filter-repo) or the
|
|
119
|
+
[BFG Repo-Cleaner](https://rtyley.github.io/bfg-repo-cleaner/) to scrub history,
|
|
120
|
+
and rotate/revoke the leaked credentials regardless.
|
|
121
|
+
|
|
122
|
+
## Contributing
|
|
123
|
+
|
|
124
|
+
PRs welcome — especially additional rule categories in `rules.py`
|
|
125
|
+
(other language ecosystems, other build tools, etc.).
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "smart-gitignore"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Scan a project and auto-generate sensible .gitignore rules for logs, env files, venvs, node_modules and more."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.7"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Your Name" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["git", "gitignore", "cli", "developer-tools"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Topic :: Software Development :: Version Control :: Git",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
dev = ["pytest>=7.0"]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
smart-gitignore = "smart_gitignore.cli:main"
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/YOUR_USERNAME/smart-gitignore"
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli.py
|
|
3
|
+
------
|
|
4
|
+
The actual smart-gitignore command.
|
|
5
|
+
|
|
6
|
+
Flow:
|
|
7
|
+
1. Walk the target directory (skipping .git) and collect:
|
|
8
|
+
- every file extension present
|
|
9
|
+
- every directory name present
|
|
10
|
+
- every exact filename present
|
|
11
|
+
2. Compare against DEFAULT_RULES (and MEDIA_RULES if --include-media).
|
|
12
|
+
3. Work out which patterns are genuinely new (not already in .gitignore).
|
|
13
|
+
4. If any matching files are ALREADY tracked by git, warn the user —
|
|
14
|
+
adding a pattern to .gitignore does NOT untrack files that are
|
|
15
|
+
already committed. This is the #1 confusion beginners have.
|
|
16
|
+
5. Show a preview. Write only on confirmation (or --yes).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import os
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
from datetime import date
|
|
24
|
+
from fnmatch import fnmatch
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from .rules import DEFAULT_RULES, MEDIA_RULES
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def warn_media_skipped(extensions, dirnames, filenames):
|
|
31
|
+
"""
|
|
32
|
+
If media files exist in the project but --include-media wasn't passed,
|
|
33
|
+
surface that immediately as a warning — not buried in the README.
|
|
34
|
+
We deliberately do NOT ignore media by default because many projects
|
|
35
|
+
intentionally commit images/icons/docs, and silently suggesting to
|
|
36
|
+
ignore them could make someone lose tracked assets.
|
|
37
|
+
"""
|
|
38
|
+
matched = match_rules(MEDIA_RULES, extensions, dirnames, filenames)
|
|
39
|
+
if not matched:
|
|
40
|
+
return
|
|
41
|
+
total_examples = []
|
|
42
|
+
for rule in matched:
|
|
43
|
+
total_examples.append(rule.name.replace(" (opt-in)", ""))
|
|
44
|
+
print(f"{WARN_PREFIX} found media files ({', '.join(total_examples)}) but skipping them by default.")
|
|
45
|
+
print(" Media is often committed on purpose (docs, icons, website assets), so this tool")
|
|
46
|
+
print(" will never silently suggest ignoring it. Re-run with --include-media if you")
|
|
47
|
+
print(" actually want these ignored.\n")
|
|
48
|
+
|
|
49
|
+
HEADER = "# --- Added by smart-gitignore on {date} ---"
|
|
50
|
+
|
|
51
|
+
WARN_PREFIX = "⚠ WARNING:"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def scan_directory(root: Path):
|
|
55
|
+
"""Walk the directory tree and collect extensions, dirnames, filenames."""
|
|
56
|
+
extensions = set()
|
|
57
|
+
dirnames = set()
|
|
58
|
+
filenames = set()
|
|
59
|
+
|
|
60
|
+
for current_root, dirs, files in os.walk(root):
|
|
61
|
+
# never descend into .git
|
|
62
|
+
if ".git" in dirs:
|
|
63
|
+
dirs.remove(".git")
|
|
64
|
+
|
|
65
|
+
for d in dirs:
|
|
66
|
+
dirnames.add(d)
|
|
67
|
+
|
|
68
|
+
for f in files:
|
|
69
|
+
filenames.add(f)
|
|
70
|
+
ext = Path(f).suffix
|
|
71
|
+
if ext:
|
|
72
|
+
extensions.add(ext)
|
|
73
|
+
|
|
74
|
+
return extensions, dirnames, filenames
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def match_rules(rules, extensions, dirnames, filenames):
|
|
78
|
+
"""Return the list of Rule objects that apply to what's on disk."""
|
|
79
|
+
matched = []
|
|
80
|
+
for rule in rules:
|
|
81
|
+
hit = False
|
|
82
|
+
if rule.match_extensions and extensions.intersection(rule.match_extensions):
|
|
83
|
+
hit = True
|
|
84
|
+
if rule.match_dirnames and dirnames.intersection(rule.match_dirnames):
|
|
85
|
+
hit = True
|
|
86
|
+
if rule.match_filenames and filenames.intersection(rule.match_filenames):
|
|
87
|
+
hit = True
|
|
88
|
+
if hit:
|
|
89
|
+
matched.append(rule)
|
|
90
|
+
return matched
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def load_existing_gitignore(path: Path):
|
|
94
|
+
if not path.exists():
|
|
95
|
+
return []
|
|
96
|
+
return [line.rstrip("\n") for line in path.read_text(encoding="utf-8").splitlines()]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def compute_new_patterns(matched_rules, existing_lines):
|
|
100
|
+
existing_set = set(line.strip() for line in existing_lines)
|
|
101
|
+
new_by_category = {}
|
|
102
|
+
for rule in matched_rules:
|
|
103
|
+
fresh = [p for p in rule.patterns if p not in existing_set]
|
|
104
|
+
if fresh:
|
|
105
|
+
new_by_category[rule.name] = fresh
|
|
106
|
+
return new_by_category
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def is_git_repo(root: Path) -> bool:
|
|
110
|
+
return (root / ".git").exists()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_tracked_files(root: Path):
|
|
114
|
+
"""Return the list of files git already tracks, or [] if not a repo / git missing."""
|
|
115
|
+
if not is_git_repo(root):
|
|
116
|
+
return []
|
|
117
|
+
try:
|
|
118
|
+
result = subprocess.run(
|
|
119
|
+
["git", "-C", str(root), "ls-files"],
|
|
120
|
+
capture_output=True, text=True, check=True,
|
|
121
|
+
)
|
|
122
|
+
return result.stdout.splitlines()
|
|
123
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
124
|
+
return []
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def find_already_tracked_matches(tracked_files, new_by_category):
|
|
128
|
+
"""Check whether any already-tracked file would match one of our new patterns."""
|
|
129
|
+
all_patterns = [p for patterns in new_by_category.values() for p in patterns]
|
|
130
|
+
warnings = []
|
|
131
|
+
for f in tracked_files:
|
|
132
|
+
basename = os.path.basename(f)
|
|
133
|
+
for pattern in all_patterns:
|
|
134
|
+
# directory-style pattern e.g. "venv/" -> check path prefix
|
|
135
|
+
if pattern.endswith("/"):
|
|
136
|
+
dirname = pattern.rstrip("/")
|
|
137
|
+
if f == dirname or f.startswith(dirname + "/") or ("/" + dirname + "/") in ("/" + f):
|
|
138
|
+
warnings.append((f, pattern))
|
|
139
|
+
break
|
|
140
|
+
else:
|
|
141
|
+
if fnmatch(basename, pattern) or fnmatch(f, pattern):
|
|
142
|
+
warnings.append((f, pattern))
|
|
143
|
+
break
|
|
144
|
+
return warnings
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def untrack_files(root: Path, warnings):
|
|
148
|
+
"""
|
|
149
|
+
Actually run `git rm --cached` for each already-tracked file that matches
|
|
150
|
+
a new .gitignore pattern. Only ever called after the user explicitly
|
|
151
|
+
typed 'y' — this tool never runs git commands without that confirmation.
|
|
152
|
+
Returns (succeeded, failed) lists of filenames.
|
|
153
|
+
"""
|
|
154
|
+
succeeded, failed = [], []
|
|
155
|
+
for f, _pattern in warnings:
|
|
156
|
+
try:
|
|
157
|
+
subprocess.run(
|
|
158
|
+
["git", "-C", str(root), "rm", "--cached", "--quiet", f],
|
|
159
|
+
check=True, capture_output=True, text=True,
|
|
160
|
+
)
|
|
161
|
+
succeeded.append(f)
|
|
162
|
+
except subprocess.CalledProcessError as e:
|
|
163
|
+
failed.append((f, e.stderr.strip() or str(e)))
|
|
164
|
+
return succeeded, failed
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def build_output_block(new_by_category):
|
|
168
|
+
lines = [HEADER.format(date=date.today().isoformat())]
|
|
169
|
+
for category, patterns in new_by_category.items():
|
|
170
|
+
lines.append(f"\n# {category}")
|
|
171
|
+
lines.extend(patterns)
|
|
172
|
+
return "\n".join(lines) + "\n"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def main(argv=None):
|
|
176
|
+
parser = argparse.ArgumentParser(
|
|
177
|
+
prog="smart-gitignore",
|
|
178
|
+
description="Scan a project and auto-add sensible .gitignore rules "
|
|
179
|
+
"(logs, env files, venvs, node_modules, caches, etc.)."
|
|
180
|
+
)
|
|
181
|
+
parser.add_argument("path", nargs="?", default=".", help="Project directory (default: current directory)")
|
|
182
|
+
parser.add_argument("--include-media", action="store_true",
|
|
183
|
+
help="Also detect and ignore images/video/audio files (off by default — "
|
|
184
|
+
"many projects intentionally commit media assets).")
|
|
185
|
+
parser.add_argument("--dry-run", action="store_true", help="Show what would change, write nothing.")
|
|
186
|
+
parser.add_argument("-y", "--yes", action="store_true", help="Write .gitignore changes without prompting.")
|
|
187
|
+
parser.add_argument("--auto-untrack", action="store_true",
|
|
188
|
+
help="Also run 'git rm --cached' on already-tracked matches without "
|
|
189
|
+
"prompting. Off by default — untracking is asked for separately "
|
|
190
|
+
"from writing .gitignore, since it touches your git index.")
|
|
191
|
+
args = parser.parse_args(argv)
|
|
192
|
+
|
|
193
|
+
root = Path(args.path).resolve()
|
|
194
|
+
if not root.exists():
|
|
195
|
+
print(f"Error: path '{root}' does not exist.")
|
|
196
|
+
sys.exit(1)
|
|
197
|
+
|
|
198
|
+
rules = list(DEFAULT_RULES)
|
|
199
|
+
if args.include_media:
|
|
200
|
+
rules += MEDIA_RULES
|
|
201
|
+
|
|
202
|
+
print(f"Scanning {root} ...")
|
|
203
|
+
extensions, dirnames, filenames = scan_directory(root)
|
|
204
|
+
matched_rules = match_rules(rules, extensions, dirnames, filenames)
|
|
205
|
+
|
|
206
|
+
# Design decision #1, surfaced live: warn about skipped media instead of
|
|
207
|
+
# just documenting it in the README.
|
|
208
|
+
if not args.include_media:
|
|
209
|
+
warn_media_skipped(extensions, dirnames, filenames)
|
|
210
|
+
|
|
211
|
+
gitignore_path = root / ".gitignore"
|
|
212
|
+
existing_lines = load_existing_gitignore(gitignore_path)
|
|
213
|
+
new_by_category = compute_new_patterns(matched_rules, existing_lines)
|
|
214
|
+
|
|
215
|
+
if not new_by_category:
|
|
216
|
+
print("Nothing new to add — your .gitignore already covers what's here, "
|
|
217
|
+
"or no risky file types were found.")
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
print("\nDetected files that should probably be ignored:\n")
|
|
221
|
+
for category, patterns in new_by_category.items():
|
|
222
|
+
print(f" [{category}]")
|
|
223
|
+
for p in patterns:
|
|
224
|
+
print(f" {p}")
|
|
225
|
+
print()
|
|
226
|
+
|
|
227
|
+
# Design decision #3, surfaced live: files already committed need manual
|
|
228
|
+
# untracking — a .gitignore rule alone changes nothing for them.
|
|
229
|
+
tracked_files = get_tracked_files(root)
|
|
230
|
+
already_tracked_hit = False
|
|
231
|
+
tracked_warnings = []
|
|
232
|
+
if tracked_files:
|
|
233
|
+
tracked_warnings = find_already_tracked_matches(tracked_files, new_by_category)
|
|
234
|
+
if tracked_warnings:
|
|
235
|
+
already_tracked_hit = True
|
|
236
|
+
print(f"{WARN_PREFIX} these files are already tracked by git.")
|
|
237
|
+
print(" Adding a pattern to .gitignore will NOT remove them from the repo")
|
|
238
|
+
print(" or from your git history. The fix is to untrack them:\n")
|
|
239
|
+
for f, pattern in tracked_warnings[:20]:
|
|
240
|
+
print(f" git rm --cached \"{f}\" # matched pattern: {pattern}")
|
|
241
|
+
if len(tracked_warnings) > 20:
|
|
242
|
+
print(f" ... and {len(tracked_warnings) - 20} more")
|
|
243
|
+
print("\n (If any of these ever contained secrets, git rm --cached is not")
|
|
244
|
+
print(" enough — they're still in old commits. Use git filter-repo or the")
|
|
245
|
+
print(" BFG Repo-Cleaner, and rotate the leaked credentials regardless.)\n")
|
|
246
|
+
|
|
247
|
+
if args.dry_run:
|
|
248
|
+
print("(dry run — no files were changed)")
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
# Design decision #2, surfaced live: this tool never runs a git command
|
|
252
|
+
# or touches files beyond what you explicitly confirm. Writing .gitignore
|
|
253
|
+
# and untracking already-committed files are two separate actions with
|
|
254
|
+
# two separate y/n prompts below — saying yes to one never triggers the other.
|
|
255
|
+
print(f"{WARN_PREFIX} this tool only changes what you explicitly confirm.")
|
|
256
|
+
print(" Writing .gitignore and untracking files are asked about separately.\n")
|
|
257
|
+
|
|
258
|
+
wrote_gitignore = False
|
|
259
|
+
if not args.yes:
|
|
260
|
+
answer = input(f"Append these rules to {gitignore_path}? [y/N] ").strip().lower()
|
|
261
|
+
if answer == "y":
|
|
262
|
+
wrote_gitignore = True
|
|
263
|
+
else:
|
|
264
|
+
print("Cancelled — .gitignore was not changed.")
|
|
265
|
+
else:
|
|
266
|
+
wrote_gitignore = True
|
|
267
|
+
|
|
268
|
+
if wrote_gitignore:
|
|
269
|
+
block = build_output_block(new_by_category)
|
|
270
|
+
with open(gitignore_path, "a", encoding="utf-8") as f:
|
|
271
|
+
if existing_lines:
|
|
272
|
+
f.write("\n")
|
|
273
|
+
f.write(block)
|
|
274
|
+
print(f"Done. Updated {gitignore_path}")
|
|
275
|
+
|
|
276
|
+
# Separate, explicit y/n for the destructive action: untracking files
|
|
277
|
+
# that are already committed. Never bundled with the prompt above unless
|
|
278
|
+
# the user opted in via --auto-untrack.
|
|
279
|
+
if already_tracked_hit:
|
|
280
|
+
print()
|
|
281
|
+
if args.auto_untrack:
|
|
282
|
+
run_untrack = True
|
|
283
|
+
else:
|
|
284
|
+
reply = input(
|
|
285
|
+
f"Run 'git rm --cached' on the {len(tracked_warnings)} already-tracked "
|
|
286
|
+
f"file(s) shown above now? [y/N] "
|
|
287
|
+
).strip().lower()
|
|
288
|
+
run_untrack = (reply == "y")
|
|
289
|
+
|
|
290
|
+
if run_untrack:
|
|
291
|
+
succeeded, failed = untrack_files(root, tracked_warnings)
|
|
292
|
+
if succeeded:
|
|
293
|
+
print(f"\nUntracked {len(succeeded)} file(s):")
|
|
294
|
+
for f in succeeded:
|
|
295
|
+
print(f" git rm --cached \"{f}\" done")
|
|
296
|
+
print("\n These files still exist on disk — only removed from git's index.")
|
|
297
|
+
print(" Commit this to finish: git commit -m \"Stop tracking ignored files\"")
|
|
298
|
+
if failed:
|
|
299
|
+
print(f"\nFailed to untrack {len(failed)} file(s):")
|
|
300
|
+
for f, err in failed:
|
|
301
|
+
print(f" {f}: {err}")
|
|
302
|
+
else:
|
|
303
|
+
print("Skipped — no files were untracked. You can run the commands above manually anytime.")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
if __name__ == "__main__":
|
|
307
|
+
main()
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
rules.py
|
|
3
|
+
--------
|
|
4
|
+
Defines the categories smart-gitignore knows how to detect, and the
|
|
5
|
+
patterns it will write into .gitignore for each one.
|
|
6
|
+
|
|
7
|
+
Design note:
|
|
8
|
+
Every category is "safe by default" — i.e. things that are almost NEVER
|
|
9
|
+
meant to be committed (logs, envs, venvs, dependency folders, caches).
|
|
10
|
+
|
|
11
|
+
Media files (images/video/audio) are kept OUT of the default set and only
|
|
12
|
+
included when the user explicitly passes --include-media, because many
|
|
13
|
+
projects legitimately commit images (docs, website assets, icons) and we
|
|
14
|
+
never want to silently suggest ignoring something that belongs in the repo.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import List
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Rule:
|
|
23
|
+
name: str # Human readable category name
|
|
24
|
+
patterns: List[str] # Patterns to add to .gitignore
|
|
25
|
+
match_extensions: List[str] = None # File extensions that trigger this rule
|
|
26
|
+
match_dirnames: List[str] = None # Directory names that trigger this rule
|
|
27
|
+
match_filenames: List[str] = None # Exact filenames that trigger this rule
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
DEFAULT_RULES: List[Rule] = [
|
|
31
|
+
Rule(
|
|
32
|
+
name="Logs",
|
|
33
|
+
patterns=["*.log", "*.log.*", "logs/", "npm-debug.log*", "yarn-debug.log*", "yarn-error.log*"],
|
|
34
|
+
match_extensions=[".log"],
|
|
35
|
+
match_dirnames=["logs"],
|
|
36
|
+
),
|
|
37
|
+
Rule(
|
|
38
|
+
name="Environment / Secrets",
|
|
39
|
+
patterns=[".env", ".env.local", ".env.*.local", "*.env"],
|
|
40
|
+
match_filenames=[".env"],
|
|
41
|
+
match_extensions=[".env"],
|
|
42
|
+
),
|
|
43
|
+
Rule(
|
|
44
|
+
name="Python Virtual Environments",
|
|
45
|
+
patterns=["venv/", ".venv/", "env/", "ENV/", "virtualenv/"],
|
|
46
|
+
match_dirnames=["venv", ".venv", "env", "ENV", "virtualenv"],
|
|
47
|
+
),
|
|
48
|
+
Rule(
|
|
49
|
+
name="Python Cache",
|
|
50
|
+
patterns=["__pycache__/", "*.pyc", "*.pyo", "*.pyd", ".pytest_cache/", ".mypy_cache/"],
|
|
51
|
+
match_extensions=[".pyc", ".pyo", ".pyd"],
|
|
52
|
+
match_dirnames=["__pycache__", ".pytest_cache", ".mypy_cache"],
|
|
53
|
+
),
|
|
54
|
+
Rule(
|
|
55
|
+
name="Node.js Dependencies",
|
|
56
|
+
patterns=["node_modules/", ".npm/", ".yarn/"],
|
|
57
|
+
match_dirnames=["node_modules"],
|
|
58
|
+
),
|
|
59
|
+
Rule(
|
|
60
|
+
name="Build Artifacts",
|
|
61
|
+
patterns=["dist/", "build/", "*.egg-info/", "target/", "out/"],
|
|
62
|
+
match_dirnames=["dist", "build", "target", "out"],
|
|
63
|
+
),
|
|
64
|
+
Rule(
|
|
65
|
+
name="OS Junk Files",
|
|
66
|
+
patterns=[".DS_Store", "Thumbs.db", "desktop.ini"],
|
|
67
|
+
match_filenames=[".DS_Store", "Thumbs.db", "desktop.ini"],
|
|
68
|
+
),
|
|
69
|
+
Rule(
|
|
70
|
+
name="Editor / IDE",
|
|
71
|
+
patterns=[".vscode/", ".idea/", "*.sublime-workspace", "*.swp"],
|
|
72
|
+
match_dirnames=[".vscode", ".idea"],
|
|
73
|
+
match_extensions=[".swp"],
|
|
74
|
+
),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
# Opt-in only — see design note above.
|
|
78
|
+
MEDIA_RULES: List[Rule] = [
|
|
79
|
+
Rule(
|
|
80
|
+
name="Media - Images (opt-in)",
|
|
81
|
+
patterns=["*.png", "*.jpg", "*.jpeg", "*.gif", "*.bmp", "*.webp"],
|
|
82
|
+
match_extensions=[".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"],
|
|
83
|
+
),
|
|
84
|
+
Rule(
|
|
85
|
+
name="Media - Video (opt-in)",
|
|
86
|
+
patterns=["*.mp4", "*.mov", "*.avi", "*.mkv", "*.wmv"],
|
|
87
|
+
match_extensions=[".mp4", ".mov", ".avi", ".mkv", ".wmv"],
|
|
88
|
+
),
|
|
89
|
+
Rule(
|
|
90
|
+
name="Media - Audio (opt-in)",
|
|
91
|
+
patterns=["*.mp3", "*.wav", "*.flac", "*.aac"],
|
|
92
|
+
match_extensions=[".mp3", ".wav", ".flac", ".aac"],
|
|
93
|
+
),
|
|
94
|
+
]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: smart-gitignore
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Scan a project and auto-generate sensible .gitignore rules for logs, env files, venvs, node_modules and more.
|
|
5
|
+
Author: Your Name
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/YOUR_USERNAME/smart-gitignore
|
|
8
|
+
Keywords: git,gitignore,cli,developer-tools
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
14
|
+
Requires-Python: >=3.7
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# smart-gitignore
|
|
22
|
+
|
|
23
|
+
Scan a project directory and auto-generate sensible `.gitignore` rules —
|
|
24
|
+
logs, `.env` files, virtual environments, `node_modules`, caches, build
|
|
25
|
+
artifacts, and OS junk — in a single command.
|
|
26
|
+
|
|
27
|
+
Built for a very common beginner mistake: forgetting to add `.gitignore`
|
|
28
|
+
*before* the first commit, then accidentally pushing log files, secrets,
|
|
29
|
+
or dependency folders into the repo.
|
|
30
|
+
|
|
31
|
+
## Why
|
|
32
|
+
|
|
33
|
+
Most `.gitignore` generators (gitignore.io, `gig`, etc.) ask you to pick a
|
|
34
|
+
language/framework and give you a template. `smart-gitignore` instead
|
|
35
|
+
**looks at what's actually sitting in your project folder** and suggests
|
|
36
|
+
rules based on real files it finds — logs, envs, venvs, dependency
|
|
37
|
+
folders — regardless of language.
|
|
38
|
+
|
|
39
|
+
It also does something most generators don't: it checks whether any of
|
|
40
|
+
those files are **already tracked by git**, and warns you that adding a
|
|
41
|
+
`.gitignore` rule alone won't remove them from the repo or its history.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install smart-gitignore
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
(Or, until published: clone this repo and run `pip install .`)
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# Preview what would be added, without touching any files
|
|
55
|
+
smart-gitignore /path/to/project --dry-run
|
|
56
|
+
|
|
57
|
+
# Scan current directory and write changes (with confirmation prompt)
|
|
58
|
+
smart-gitignore
|
|
59
|
+
|
|
60
|
+
# Skip the confirmation prompt for .gitignore writes
|
|
61
|
+
smart-gitignore --yes
|
|
62
|
+
|
|
63
|
+
# Also run git rm --cached automatically on already-tracked matches
|
|
64
|
+
# (still separate from --yes; off by default)
|
|
65
|
+
smart-gitignore --auto-untrack
|
|
66
|
+
|
|
67
|
+
# Also detect and ignore images/video/audio (off by default —
|
|
68
|
+
# many projects intentionally commit media assets)
|
|
69
|
+
smart-gitignore --include-media
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Example output
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
Scanning /home/you/my-project ...
|
|
76
|
+
|
|
77
|
+
Detected files that should probably be ignored:
|
|
78
|
+
|
|
79
|
+
[Logs]
|
|
80
|
+
*.log
|
|
81
|
+
logs/
|
|
82
|
+
[Environment / Secrets]
|
|
83
|
+
.env
|
|
84
|
+
[Python Virtual Environments]
|
|
85
|
+
venv/
|
|
86
|
+
|
|
87
|
+
⚠ WARNING: these files are already tracked by git.
|
|
88
|
+
Adding a pattern to .gitignore will NOT remove them from the repo
|
|
89
|
+
or from your git history. The fix is to untrack them:
|
|
90
|
+
|
|
91
|
+
git rm --cached "app.log" # matched pattern: *.log
|
|
92
|
+
|
|
93
|
+
Append these rules to .gitignore? [y/N] y
|
|
94
|
+
Done. Updated .gitignore
|
|
95
|
+
|
|
96
|
+
Run 'git rm --cached' on the 1 already-tracked file(s) shown above now? [y/N] y
|
|
97
|
+
|
|
98
|
+
Untracked 1 file(s):
|
|
99
|
+
git rm --cached "app.log" done
|
|
100
|
+
|
|
101
|
+
These files still exist on disk — only removed from git's index.
|
|
102
|
+
Commit this to finish: git commit -m "Stop tracking ignored files"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Writing `.gitignore` and untracking already-committed files are **two
|
|
106
|
+
separate confirmations** — saying yes to one never silently triggers the
|
|
107
|
+
other. Pass `--auto-untrack` to skip the second prompt if you're scripting
|
|
108
|
+
this (e.g. in CI or a setup script).
|
|
109
|
+
|
|
110
|
+
## What it detects by default
|
|
111
|
+
|
|
112
|
+
| Category | Examples |
|
|
113
|
+
|---|---|
|
|
114
|
+
| Logs | `*.log`, `logs/` |
|
|
115
|
+
| Environment / secrets | `.env`, `.env.local` |
|
|
116
|
+
| Python virtual environments | `venv/`, `.venv/`, `env/` |
|
|
117
|
+
| Python cache | `__pycache__/`, `*.pyc` |
|
|
118
|
+
| Node dependencies | `node_modules/` |
|
|
119
|
+
| Build artifacts | `dist/`, `build/`, `target/` |
|
|
120
|
+
| OS junk | `.DS_Store`, `Thumbs.db` |
|
|
121
|
+
| Editor/IDE | `.vscode/`, `.idea/` |
|
|
122
|
+
|
|
123
|
+
Images, video, and audio are **opt-in only** (`--include-media`) since
|
|
124
|
+
many repos legitimately track media assets (docs, icons, website content).
|
|
125
|
+
|
|
126
|
+
## Important: this doesn't rewrite git history
|
|
127
|
+
|
|
128
|
+
`smart-gitignore` only edits `.gitignore`. If a file is already committed,
|
|
129
|
+
ignoring it going forward requires:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
git rm --cached path/to/file
|
|
133
|
+
git commit -m "Stop tracking file"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
If sensitive data (API keys, passwords) was ever committed, `git rm --cached`
|
|
137
|
+
is **not enough** — it's still in old commits. Use
|
|
138
|
+
[`git filter-repo`](https://github.com/newren/git-filter-repo) or the
|
|
139
|
+
[BFG Repo-Cleaner](https://rtyley.github.io/bfg-repo-cleaner/) to scrub history,
|
|
140
|
+
and rotate/revoke the leaked credentials regardless.
|
|
141
|
+
|
|
142
|
+
## Contributing
|
|
143
|
+
|
|
144
|
+
PRs welcome — especially additional rule categories in `rules.py`
|
|
145
|
+
(other language ecosystems, other build tools, etc.).
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
smart_gitignore/__init__.py
|
|
5
|
+
smart_gitignore/cli.py
|
|
6
|
+
smart_gitignore/rules.py
|
|
7
|
+
smart_gitignore.egg-info/PKG-INFO
|
|
8
|
+
smart_gitignore.egg-info/SOURCES.txt
|
|
9
|
+
smart_gitignore.egg-info/dependency_links.txt
|
|
10
|
+
smart_gitignore.egg-info/entry_points.txt
|
|
11
|
+
smart_gitignore.egg-info/requires.txt
|
|
12
|
+
smart_gitignore.egg-info/top_level.txt
|
|
13
|
+
tests/test_cli.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
smart_gitignore
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""
|
|
2
|
+
test_cli.py
|
|
3
|
+
-----------
|
|
4
|
+
Automated tests for smart-gitignore. Run with:
|
|
5
|
+
|
|
6
|
+
pip install -e .[dev]
|
|
7
|
+
pytest -v
|
|
8
|
+
|
|
9
|
+
Covers:
|
|
10
|
+
- directory scanning (extensions/dirnames/filenames detection)
|
|
11
|
+
- rule matching
|
|
12
|
+
- computing only-new patterns (no duplicate lines)
|
|
13
|
+
- detecting already-tracked files that match new patterns
|
|
14
|
+
- full end-to-end run against a real temporary git repo
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import subprocess
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
22
|
+
from smart_gitignore.cli import (
|
|
23
|
+
scan_directory,
|
|
24
|
+
match_rules,
|
|
25
|
+
compute_new_patterns,
|
|
26
|
+
find_already_tracked_matches,
|
|
27
|
+
main,
|
|
28
|
+
)
|
|
29
|
+
from smart_gitignore.rules import DEFAULT_RULES, MEDIA_RULES
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def make_git_repo(path: Path):
|
|
33
|
+
subprocess.run(["git", "init", "-q"], cwd=path, check=True)
|
|
34
|
+
subprocess.run(["git", "config", "user.email", "t@t.com"], cwd=path, check=True)
|
|
35
|
+
subprocess.run(["git", "config", "user.name", "Test"], cwd=path, check=True)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def git_commit_all(path: Path, message="commit"):
|
|
39
|
+
subprocess.run(["git", "add", "-A"], cwd=path, check=True)
|
|
40
|
+
subprocess.run(["git", "commit", "-q", "-m", message], cwd=path, check=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def git_ls_files(path: Path):
|
|
44
|
+
result = subprocess.run(
|
|
45
|
+
["git", "ls-files"], cwd=path, capture_output=True, text=True, check=True
|
|
46
|
+
)
|
|
47
|
+
return result.stdout.splitlines()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------- unit tests ----------
|
|
51
|
+
|
|
52
|
+
def test_scan_directory_detects_extensions_and_dirs(tmp_path):
|
|
53
|
+
(tmp_path / "app.log").touch()
|
|
54
|
+
(tmp_path / "main.py").touch()
|
|
55
|
+
(tmp_path / "venv").mkdir()
|
|
56
|
+
(tmp_path / ".env").touch()
|
|
57
|
+
|
|
58
|
+
extensions, dirnames, filenames = scan_directory(tmp_path)
|
|
59
|
+
|
|
60
|
+
assert ".log" in extensions
|
|
61
|
+
assert ".py" in extensions
|
|
62
|
+
assert "venv" in dirnames
|
|
63
|
+
assert ".env" in filenames
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_scan_directory_skips_git_folder(tmp_path):
|
|
67
|
+
make_git_repo(tmp_path)
|
|
68
|
+
# .git contains lots of files/extensions internally; none should leak in
|
|
69
|
+
extensions, dirnames, filenames = scan_directory(tmp_path)
|
|
70
|
+
assert ".git" not in dirnames
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_match_rules_logs(tmp_path):
|
|
74
|
+
(tmp_path / "debug.log").touch()
|
|
75
|
+
extensions, dirnames, filenames = scan_directory(tmp_path)
|
|
76
|
+
matched = match_rules(DEFAULT_RULES, extensions, dirnames, filenames)
|
|
77
|
+
names = [r.name for r in matched]
|
|
78
|
+
assert "Logs" in names
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_match_rules_media_off_by_default(tmp_path):
|
|
82
|
+
(tmp_path / "logo.png").touch()
|
|
83
|
+
extensions, dirnames, filenames = scan_directory(tmp_path)
|
|
84
|
+
matched = match_rules(DEFAULT_RULES, extensions, dirnames, filenames)
|
|
85
|
+
names = [r.name for r in matched]
|
|
86
|
+
assert not any("Media" in n for n in names)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_match_rules_media_when_included(tmp_path):
|
|
90
|
+
(tmp_path / "logo.png").touch()
|
|
91
|
+
extensions, dirnames, filenames = scan_directory(tmp_path)
|
|
92
|
+
all_rules = DEFAULT_RULES + MEDIA_RULES
|
|
93
|
+
matched = match_rules(all_rules, extensions, dirnames, filenames)
|
|
94
|
+
names = [r.name for r in matched]
|
|
95
|
+
assert any("Media" in n for n in names)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_compute_new_patterns_skips_existing(tmp_path):
|
|
99
|
+
(tmp_path / "app.log").touch()
|
|
100
|
+
extensions, dirnames, filenames = scan_directory(tmp_path)
|
|
101
|
+
matched = match_rules(DEFAULT_RULES, extensions, dirnames, filenames)
|
|
102
|
+
existing_lines = ["*.log"] # already present
|
|
103
|
+
new = compute_new_patterns(matched, existing_lines)
|
|
104
|
+
logs_patterns = new.get("Logs", [])
|
|
105
|
+
assert "*.log" not in logs_patterns # shouldn't duplicate
|
|
106
|
+
assert "logs/" in logs_patterns # but other log patterns still offered
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_find_already_tracked_matches(tmp_path):
|
|
110
|
+
make_git_repo(tmp_path)
|
|
111
|
+
(tmp_path / "app.log").touch()
|
|
112
|
+
(tmp_path / "main.py").touch()
|
|
113
|
+
git_commit_all(tmp_path)
|
|
114
|
+
|
|
115
|
+
new_by_category = {"Logs": ["*.log", "logs/"]}
|
|
116
|
+
tracked = git_ls_files(tmp_path)
|
|
117
|
+
warnings = find_already_tracked_matches(tracked, new_by_category)
|
|
118
|
+
|
|
119
|
+
matched_files = [f for f, _pattern in warnings]
|
|
120
|
+
assert "app.log" in matched_files
|
|
121
|
+
assert "main.py" not in matched_files
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_find_already_tracked_matches_empty_when_nothing_tracked_matches(tmp_path):
|
|
125
|
+
make_git_repo(tmp_path)
|
|
126
|
+
(tmp_path / "main.py").touch()
|
|
127
|
+
git_commit_all(tmp_path)
|
|
128
|
+
|
|
129
|
+
new_by_category = {"Logs": ["*.log", "logs/"]}
|
|
130
|
+
tracked = git_ls_files(tmp_path)
|
|
131
|
+
warnings = find_already_tracked_matches(tracked, new_by_category)
|
|
132
|
+
assert warnings == []
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------- end-to-end tests (run the real CLI) ----------
|
|
136
|
+
|
|
137
|
+
def test_end_to_end_dry_run_does_not_write(tmp_path, capsys):
|
|
138
|
+
(tmp_path / "app.log").touch()
|
|
139
|
+
main([str(tmp_path), "--dry-run"])
|
|
140
|
+
captured = capsys.readouterr()
|
|
141
|
+
assert "Logs" in captured.out
|
|
142
|
+
assert not (tmp_path / ".gitignore").exists()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_end_to_end_yes_writes_gitignore(tmp_path):
|
|
146
|
+
(tmp_path / "app.log").touch()
|
|
147
|
+
main([str(tmp_path), "--yes"])
|
|
148
|
+
gitignore = tmp_path / ".gitignore"
|
|
149
|
+
assert gitignore.exists()
|
|
150
|
+
content = gitignore.read_text()
|
|
151
|
+
assert "*.log" in content
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_end_to_end_no_new_patterns_when_nothing_risky(tmp_path, capsys):
|
|
155
|
+
(tmp_path / "README.md").touch()
|
|
156
|
+
main([str(tmp_path), "--yes"])
|
|
157
|
+
captured = capsys.readouterr()
|
|
158
|
+
assert "Nothing new to add" in captured.out
|
|
159
|
+
assert not (tmp_path / ".gitignore").exists()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_end_to_end_auto_untrack_removes_from_git_index(tmp_path):
|
|
163
|
+
make_git_repo(tmp_path)
|
|
164
|
+
(tmp_path / "app.log").touch()
|
|
165
|
+
(tmp_path / "main.py").touch()
|
|
166
|
+
git_commit_all(tmp_path)
|
|
167
|
+
|
|
168
|
+
main([str(tmp_path), "--yes", "--auto-untrack"])
|
|
169
|
+
|
|
170
|
+
tracked_after = git_ls_files(tmp_path)
|
|
171
|
+
assert "app.log" not in tracked_after
|
|
172
|
+
assert "main.py" in tracked_after
|
|
173
|
+
# file must still exist on disk — untracking is not deleting
|
|
174
|
+
assert (tmp_path / "app.log").exists()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_end_to_end_media_skipped_unless_flag(tmp_path):
|
|
178
|
+
(tmp_path / "logo.png").touch()
|
|
179
|
+
main([str(tmp_path), "--yes"])
|
|
180
|
+
gitignore = tmp_path / ".gitignore"
|
|
181
|
+
# no risky non-media files -> nothing written at all
|
|
182
|
+
assert not gitignore.exists()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_end_to_end_media_included_with_flag(tmp_path):
|
|
186
|
+
(tmp_path / "logo.png").touch()
|
|
187
|
+
main([str(tmp_path), "--yes", "--include-media"])
|
|
188
|
+
gitignore = tmp_path / ".gitignore"
|
|
189
|
+
assert gitignore.exists()
|
|
190
|
+
assert "*.png" in gitignore.read_text()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_running_twice_does_not_duplicate_rules(tmp_path):
|
|
194
|
+
(tmp_path / "app.log").touch()
|
|
195
|
+
main([str(tmp_path), "--yes"])
|
|
196
|
+
first_content = (tmp_path / ".gitignore").read_text()
|
|
197
|
+
|
|
198
|
+
main([str(tmp_path), "--yes"])
|
|
199
|
+
second_content = (tmp_path / ".gitignore").read_text()
|
|
200
|
+
|
|
201
|
+
assert first_content == second_content # nothing new to append second time
|