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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ __all__ = ["main"]
4
+ __version__ = "0.1.0"
@@ -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,2 @@
1
+ [console_scripts]
2
+ smart-gitignore = smart_gitignore.cli:main
@@ -0,0 +1,3 @@
1
+
2
+ [dev]
3
+ pytest>=7.0
@@ -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