envtidy 0.2.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.
envtidy-0.2.0/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nidhi Sebastian
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.
22
+
envtidy-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: envtidy
3
+ Version: 0.2.0
4
+ Summary: Keep your .env files honest: catch drift, sync examples, find leaks. Zero dependencies.
5
+ Author: Nidhi Sebastian
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/nidhisebastian008/envtidy
8
+ Project-URL: Issues, https://github.com/nidhisebastian008/envtidy/issues
9
+ Keywords: dotenv,env,environment-variables,cli,devtools,secrets
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Quality Assurance
15
+ Classifier: Topic :: Utilities
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Dynamic: license-file
20
+
21
+ # envtidy
22
+
23
+ > Keep your `.env` files honest — catch drift, sync examples, find leaks.
24
+
25
+ ![zero dependencies](https://img.shields.io/badge/dependencies-zero-brightgreen)
26
+ ![python](https://img.shields.io/badge/python-3.9%2B-blue)
27
+ ![license](https://img.shields.io/badge/license-MIT-lightgrey)
28
+
29
+ Every team has lived this bug: someone adds `REDIS_URL` to their local `.env`, forgets to update `.env.example`, and the next person to clone the repo burns an hour debugging a crash that was really just a missing variable. Or worse — someone's `staging.env` was never gitignored and ships to GitHub with live credentials inside.
30
+
31
+ **envtidy** is a tiny CLI that catches both. Pure Python stdlib, zero dependencies, single-purpose.
32
+
33
+ ![envtidy demo](demo.svg)
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pipx install git+https://github.com/nidhisebastian008/envtidy
39
+ # or
40
+ pip install git+https://github.com/nidhisebastian008/envtidy
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ### `envtidy check` — catch drift
46
+
47
+ Compares `.env` against `.env.example` (also auto-detects `.sample`, `.template`, `.dist`):
48
+
49
+ ```
50
+ $ envtidy check
51
+ envtidy check .env vs .env.example
52
+ missing REDIS_URL (in example, not in env)
53
+ extra DEBUG (in env, not in example)
54
+ empty SMTP_HOST (declared but has no value)
55
+
56
+ 3 issues found (1 missing, 1 extra, 1 empty)
57
+ ```
58
+
59
+ Exits `1` when drift is found, `0` when clean — drop it straight into CI or a pre-commit hook:
60
+
61
+ ```yaml
62
+ # .pre-commit-config.yaml
63
+ - repo: local
64
+ hooks:
65
+ - id: envtidy
66
+ name: envtidy check
67
+ entry: envtidy check
68
+ language: system
69
+ pass_filenames: false
70
+ ```
71
+
72
+ ### `envtidy sync` — regenerate the example file
73
+
74
+ Rewrites `.env.example` from your real `.env`, stripping every value but preserving comments, blank lines, and key order:
75
+
76
+ ```
77
+ $ envtidy sync
78
+ created .env.example (4 keys, values stripped)
79
+ ```
80
+
81
+ Use `--dry-run` to preview without writing.
82
+
83
+ ### `envtidy scan` — find files that leaked (or are about to)
84
+
85
+ Walks a directory tree, finds every env file, and checks each one against git — **including the full git history**. Deleting a committed `.env` doesn't remove it; anyone with a clone can still recover it. `scan` catches that:
86
+
87
+ ```
88
+ $ envtidy scan
89
+ envtidy scan ~/code/myapp
90
+ ignored .env
91
+ exposed staging.env (not in .gitignore — one `git add .` from leaking)
92
+ TRACKED api/.env.production (committed to git — rotate these secrets)
93
+ HISTORY old/.env.local (deleted, but still in git history — rotate + scrub)
94
+
95
+ 3 files at risk
96
+ hint: scrub history with `git filter-repo --sensitive-data-removal --invert-paths --path <file>`
97
+ ```
98
+
99
+ - **ignored** — safely gitignored, never committed ✅
100
+ - **exposed** — exists but *not* gitignored; one `git add .` away from a leak
101
+ - **TRACKED** — committed right now; treat those secrets as compromised and rotate them
102
+ - **HISTORY** — not in the working tree or index anymore, but recoverable from git history; rotate the secrets *and* scrub the history
103
+
104
+ ## Alternatives
105
+
106
+ Prior art exists — pick the right tool for your job:
107
+
108
+ - [dotenv-linter](https://github.com/dotenv-linter/dotenv-linter) — fast Rust linter for `.env` *style* (duplicate keys, ordering, quoting), with a `compare` command for key diffs. No git awareness, no example generation.
109
+ - [sync-dotenv](https://github.com/luqmanoop/sync-dotenv) — Node tool doing what `envtidy sync` does.
110
+ - [gitleaks](https://github.com/gitleaks/gitleaks) / trufflehog / detect-secrets — heavyweight *content-level* secret scanners with rules and entropy checks. Use them for full audits; use `envtidy scan` for the quick file-level answer to "are my env files safe in this repo?"
111
+
112
+ envtidy's niche: all three jobs (drift check, example sync, git-aware leak scan) in one zero-dependency CLI with no config.
113
+
114
+ ## Details
115
+
116
+ - Understands `export KEY=value`, quoted values, inline `# comments`
117
+ - Respects [`NO_COLOR`](https://no-color.org/); colors auto-disable when piped
118
+ - Skips `node_modules`, `.venv`, `.git`, build dirs while scanning
119
+ - Exit codes: `0` clean · `1` issues found · `2` usage error
120
+
121
+ ## License
122
+
123
+ MIT
@@ -0,0 +1,103 @@
1
+ # envtidy
2
+
3
+ > Keep your `.env` files honest — catch drift, sync examples, find leaks.
4
+
5
+ ![zero dependencies](https://img.shields.io/badge/dependencies-zero-brightgreen)
6
+ ![python](https://img.shields.io/badge/python-3.9%2B-blue)
7
+ ![license](https://img.shields.io/badge/license-MIT-lightgrey)
8
+
9
+ Every team has lived this bug: someone adds `REDIS_URL` to their local `.env`, forgets to update `.env.example`, and the next person to clone the repo burns an hour debugging a crash that was really just a missing variable. Or worse — someone's `staging.env` was never gitignored and ships to GitHub with live credentials inside.
10
+
11
+ **envtidy** is a tiny CLI that catches both. Pure Python stdlib, zero dependencies, single-purpose.
12
+
13
+ ![envtidy demo](demo.svg)
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pipx install git+https://github.com/nidhisebastian008/envtidy
19
+ # or
20
+ pip install git+https://github.com/nidhisebastian008/envtidy
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ### `envtidy check` — catch drift
26
+
27
+ Compares `.env` against `.env.example` (also auto-detects `.sample`, `.template`, `.dist`):
28
+
29
+ ```
30
+ $ envtidy check
31
+ envtidy check .env vs .env.example
32
+ missing REDIS_URL (in example, not in env)
33
+ extra DEBUG (in env, not in example)
34
+ empty SMTP_HOST (declared but has no value)
35
+
36
+ 3 issues found (1 missing, 1 extra, 1 empty)
37
+ ```
38
+
39
+ Exits `1` when drift is found, `0` when clean — drop it straight into CI or a pre-commit hook:
40
+
41
+ ```yaml
42
+ # .pre-commit-config.yaml
43
+ - repo: local
44
+ hooks:
45
+ - id: envtidy
46
+ name: envtidy check
47
+ entry: envtidy check
48
+ language: system
49
+ pass_filenames: false
50
+ ```
51
+
52
+ ### `envtidy sync` — regenerate the example file
53
+
54
+ Rewrites `.env.example` from your real `.env`, stripping every value but preserving comments, blank lines, and key order:
55
+
56
+ ```
57
+ $ envtidy sync
58
+ created .env.example (4 keys, values stripped)
59
+ ```
60
+
61
+ Use `--dry-run` to preview without writing.
62
+
63
+ ### `envtidy scan` — find files that leaked (or are about to)
64
+
65
+ Walks a directory tree, finds every env file, and checks each one against git — **including the full git history**. Deleting a committed `.env` doesn't remove it; anyone with a clone can still recover it. `scan` catches that:
66
+
67
+ ```
68
+ $ envtidy scan
69
+ envtidy scan ~/code/myapp
70
+ ignored .env
71
+ exposed staging.env (not in .gitignore — one `git add .` from leaking)
72
+ TRACKED api/.env.production (committed to git — rotate these secrets)
73
+ HISTORY old/.env.local (deleted, but still in git history — rotate + scrub)
74
+
75
+ 3 files at risk
76
+ hint: scrub history with `git filter-repo --sensitive-data-removal --invert-paths --path <file>`
77
+ ```
78
+
79
+ - **ignored** — safely gitignored, never committed ✅
80
+ - **exposed** — exists but *not* gitignored; one `git add .` away from a leak
81
+ - **TRACKED** — committed right now; treat those secrets as compromised and rotate them
82
+ - **HISTORY** — not in the working tree or index anymore, but recoverable from git history; rotate the secrets *and* scrub the history
83
+
84
+ ## Alternatives
85
+
86
+ Prior art exists — pick the right tool for your job:
87
+
88
+ - [dotenv-linter](https://github.com/dotenv-linter/dotenv-linter) — fast Rust linter for `.env` *style* (duplicate keys, ordering, quoting), with a `compare` command for key diffs. No git awareness, no example generation.
89
+ - [sync-dotenv](https://github.com/luqmanoop/sync-dotenv) — Node tool doing what `envtidy sync` does.
90
+ - [gitleaks](https://github.com/gitleaks/gitleaks) / trufflehog / detect-secrets — heavyweight *content-level* secret scanners with rules and entropy checks. Use them for full audits; use `envtidy scan` for the quick file-level answer to "are my env files safe in this repo?"
91
+
92
+ envtidy's niche: all three jobs (drift check, example sync, git-aware leak scan) in one zero-dependency CLI with no config.
93
+
94
+ ## Details
95
+
96
+ - Understands `export KEY=value`, quoted values, inline `# comments`
97
+ - Respects [`NO_COLOR`](https://no-color.org/); colors auto-disable when piped
98
+ - Skips `node_modules`, `.venv`, `.git`, build dirs while scanning
99
+ - Exit codes: `0` clean · `1` issues found · `2` usage error
100
+
101
+ ## License
102
+
103
+ MIT
@@ -0,0 +1,3 @@
1
+ """envtidy — keep your .env files honest."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,350 @@
1
+ """envtidy — keep your .env files honest.
2
+
3
+ Zero-dependency CLI that catches .env drift, generates sanitized
4
+ .env.example files, and finds env files at risk of being committed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import os
11
+ import re
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from . import __version__
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Terminal colors (respects NO_COLOR and non-tty output)
20
+ # ---------------------------------------------------------------------------
21
+
22
+
23
+ def _use_color() -> bool:
24
+ if os.environ.get("NO_COLOR") is not None:
25
+ return False
26
+ if os.environ.get("FORCE_COLOR") is not None:
27
+ return True
28
+ return sys.stdout.isatty()
29
+
30
+
31
+ class C:
32
+ enabled = _use_color()
33
+
34
+ @classmethod
35
+ def _wrap(cls, code: str, text: str) -> str:
36
+ return f"\033[{code}m{text}\033[0m" if cls.enabled else text
37
+
38
+ @classmethod
39
+ def red(cls, t: str) -> str:
40
+ return cls._wrap("31", t)
41
+
42
+ @classmethod
43
+ def green(cls, t: str) -> str:
44
+ return cls._wrap("32", t)
45
+
46
+ @classmethod
47
+ def yellow(cls, t: str) -> str:
48
+ return cls._wrap("33", t)
49
+
50
+ @classmethod
51
+ def cyan(cls, t: str) -> str:
52
+ return cls._wrap("36", t)
53
+
54
+ @classmethod
55
+ def dim(cls, t: str) -> str:
56
+ return cls._wrap("2", t)
57
+
58
+ @classmethod
59
+ def bold(cls, t: str) -> str:
60
+ return cls._wrap("1", t)
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Dotenv parsing
65
+ # ---------------------------------------------------------------------------
66
+
67
+ # KEY=VALUE with optional `export ` prefix; permissive on key charset
68
+ _LINE_RE = re.compile(
69
+ r"""^\s*(?:export\s+)?(?P<key>[A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(?P<value>.*)$"""
70
+ )
71
+
72
+
73
+ def parse_env(path: Path) -> dict[str, str]:
74
+ """Parse a dotenv file into an ordered {key: raw_value} dict."""
75
+ entries: dict[str, str] = {}
76
+ for raw in path.read_text(encoding="utf-8", errors="replace").splitlines():
77
+ line = raw.strip()
78
+ if not line or line.startswith("#"):
79
+ continue
80
+ m = _LINE_RE.match(raw)
81
+ if not m:
82
+ continue
83
+ value = m.group("value").strip()
84
+ # Strip surrounding quotes and trailing unquoted comments
85
+ if value[:1] in ("'", '"') and value[-1:] == value[:1] and len(value) >= 2:
86
+ value = value[1:-1]
87
+ else:
88
+ value = value.split(" #", 1)[0].rstrip()
89
+ entries[m.group("key")] = value
90
+ return entries
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # check — compare .env against .env.example
95
+ # ---------------------------------------------------------------------------
96
+
97
+ EXAMPLE_SUFFIXES = (".example", ".sample", ".template", ".dist")
98
+
99
+
100
+ def find_example(env_path: Path) -> Path | None:
101
+ for suffix in EXAMPLE_SUFFIXES:
102
+ candidate = env_path.with_name(env_path.name + suffix)
103
+ if candidate.is_file():
104
+ return candidate
105
+ return None
106
+
107
+
108
+ def cmd_check(args: argparse.Namespace) -> int:
109
+ env_path = Path(args.env)
110
+ if not env_path.is_file():
111
+ print(f"{C.red('error:')} {env_path} not found", file=sys.stderr)
112
+ return 2
113
+
114
+ example_path = Path(args.example) if args.example else find_example(env_path)
115
+ if example_path is None or not example_path.is_file():
116
+ print(
117
+ f"{C.red('error:')} no example file found next to {env_path} "
118
+ f"(tried {', '.join(env_path.name + s for s in EXAMPLE_SUFFIXES)})",
119
+ file=sys.stderr,
120
+ )
121
+ return 2
122
+
123
+ env = parse_env(env_path)
124
+ example = parse_env(example_path)
125
+
126
+ missing = [k for k in example if k not in env]
127
+ extra = [k for k in env if k not in example]
128
+ empty = [k for k, v in env.items() if v == "" and k in example]
129
+
130
+ print(C.bold(f"envtidy check {C.dim(f'{env_path} vs {example_path}')}"))
131
+ issues = 0
132
+ for key in missing:
133
+ issues += 1
134
+ print(f" {C.red('missing')} {key} {C.dim('(in example, not in env)')}")
135
+ for key in extra:
136
+ issues += 1
137
+ print(f" {C.yellow('extra')} {key} {C.dim('(in env, not in example)')}")
138
+ for key in empty:
139
+ issues += 1
140
+ print(f" {C.yellow('empty')} {key} {C.dim('(declared but has no value)')}")
141
+
142
+ if issues == 0:
143
+ print(f" {C.green('ok')} {len(env)} keys in sync")
144
+ return 0
145
+ print(
146
+ f"\n{C.bold(str(issues))} issue{'s' if issues != 1 else ''} found "
147
+ f"({len(missing)} missing, {len(extra)} extra, {len(empty)} empty)"
148
+ )
149
+ if extra and not args.no_hint:
150
+ print(C.dim("hint: run `envtidy sync` to update the example file"))
151
+ return 1
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # sync — generate/update a sanitized .env.example from .env
156
+ # ---------------------------------------------------------------------------
157
+
158
+
159
+ def sanitize_lines(env_path: Path) -> list[str]:
160
+ """Rewrite dotenv content with values stripped, preserving structure."""
161
+ out: list[str] = []
162
+ for raw in env_path.read_text(encoding="utf-8", errors="replace").splitlines():
163
+ stripped = raw.strip()
164
+ if not stripped or stripped.startswith("#"):
165
+ out.append(raw)
166
+ continue
167
+ m = _LINE_RE.match(raw)
168
+ if m:
169
+ prefix = "export " if raw.lstrip().startswith("export ") else ""
170
+ out.append(f"{prefix}{m.group('key')}=")
171
+ else:
172
+ out.append(raw)
173
+ return out
174
+
175
+
176
+ def cmd_sync(args: argparse.Namespace) -> int:
177
+ env_path = Path(args.env)
178
+ if not env_path.is_file():
179
+ print(f"{C.red('error:')} {env_path} not found", file=sys.stderr)
180
+ return 2
181
+
182
+ example_path = (
183
+ Path(args.example)
184
+ if args.example
185
+ else (find_example(env_path) or env_path.with_name(env_path.name + ".example"))
186
+ )
187
+ content = "\n".join(sanitize_lines(env_path)) + "\n"
188
+
189
+ if args.dry_run:
190
+ sys.stdout.write(content)
191
+ return 0
192
+
193
+ existed = example_path.exists()
194
+ example_path.write_text(content, encoding="utf-8")
195
+ verb = "updated" if existed else "created"
196
+ n_keys = len(parse_env(env_path))
197
+ print(f"{C.green(verb)} {example_path} ({n_keys} keys, values stripped)")
198
+ return 0
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # scan — find env files at risk of being committed
203
+ # ---------------------------------------------------------------------------
204
+
205
+ SKIP_DIRS = {
206
+ ".git", "node_modules", ".venv", "venv", "__pycache__",
207
+ ".tox", ".mypy_cache", "dist", "build", ".next", "target",
208
+ }
209
+
210
+ _ENV_NAME_RE = re.compile(r"^\.env(\..+)?$|^.+\.env$")
211
+
212
+
213
+ def is_env_file(name: str) -> bool:
214
+ if any(name.endswith(s) for s in EXAMPLE_SUFFIXES):
215
+ return False
216
+ return bool(_ENV_NAME_RE.match(name))
217
+
218
+
219
+ def _git(root: Path, *argv: str) -> subprocess.CompletedProcess:
220
+ return subprocess.run(
221
+ ["git", "-C", str(root), *argv],
222
+ capture_output=True,
223
+ text=True,
224
+ check=False,
225
+ )
226
+
227
+
228
+ def env_files_in_history(root: Path) -> set[str]:
229
+ """Paths (relative to scan root) of env files ever added to git history."""
230
+ prefix = _git(root, "rev-parse", "--show-prefix").stdout.strip()
231
+ log = _git(
232
+ root, "log", "--all", "--diff-filter=A", "--name-only", "--pretty=format:"
233
+ )
234
+ if log.returncode != 0:
235
+ return set()
236
+ hits: set[str] = set()
237
+ for line in log.stdout.splitlines():
238
+ line = line.strip()
239
+ if not line or not is_env_file(os.path.basename(line)):
240
+ continue
241
+ # git log paths are relative to the repo top level; keep only those
242
+ # under the scan root and re-relativize them to it
243
+ if prefix:
244
+ if not line.startswith(prefix):
245
+ continue
246
+ line = line[len(prefix):]
247
+ hits.add(line)
248
+ return hits
249
+
250
+
251
+ def cmd_scan(args: argparse.Namespace) -> int:
252
+ root = Path(args.dir).resolve()
253
+ if not root.is_dir():
254
+ print(f"{C.red('error:')} {root} is not a directory", file=sys.stderr)
255
+ return 2
256
+
257
+ env_files: list[Path] = []
258
+ for dirpath, dirnames, filenames in os.walk(root):
259
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
260
+ for name in filenames:
261
+ if is_env_file(name):
262
+ env_files.append(Path(dirpath) / name)
263
+
264
+ print(C.bold(f"envtidy scan {C.dim(str(root))}"))
265
+ in_git = _git(root, "rev-parse", "--is-inside-work-tree").returncode == 0
266
+ history = env_files_in_history(root) if in_git else set()
267
+
268
+ if not env_files and not history:
269
+ print(f" {C.green('ok')} no env files found")
270
+ return 0
271
+
272
+ issues = 0
273
+ seen: set[str] = set()
274
+ for path in sorted(env_files):
275
+ rel = str(path.relative_to(root))
276
+ seen.add(rel)
277
+ if not in_git:
278
+ print(f" {C.cyan('found')} {rel} {C.dim('(not a git repo)')}")
279
+ continue
280
+ tracked = _git(root, "ls-files", "--error-unmatch", rel).returncode == 0
281
+ if tracked:
282
+ issues += 1
283
+ print(f" {C.red('TRACKED')} {rel} {C.dim('(committed to git — rotate these secrets)')}")
284
+ continue
285
+ if rel in history:
286
+ issues += 1
287
+ print(f" {C.red('HISTORY')} {rel} {C.dim('(untracked now, but still in git history — rotate + scrub)')}")
288
+ continue
289
+ ignored = _git(root, "check-ignore", "-q", rel).returncode == 0
290
+ if ignored:
291
+ print(f" {C.green('ignored')} {rel}")
292
+ else:
293
+ issues += 1
294
+ print(f" {C.yellow('exposed')} {rel} {C.dim('(not in .gitignore — one `git add .` from leaking)')}")
295
+
296
+ # env files deleted from the working tree but still recoverable from history
297
+ for rel in sorted(history - seen):
298
+ if _git(root, "ls-files", "--error-unmatch", rel).returncode == 0:
299
+ continue # currently tracked under a path outside the walk (skipped dir)
300
+ issues += 1
301
+ print(f" {C.red('HISTORY')} {rel} {C.dim('(deleted, but still in git history — rotate + scrub)')}")
302
+
303
+ if issues:
304
+ print(f"\n{C.bold(str(issues))} file{'s' if issues != 1 else ''} at risk")
305
+ if any(rel in history for rel in seen) or history - seen:
306
+ print(C.dim("hint: scrub history with `git filter-repo --sensitive-data-removal --invert-paths --path <file>`"))
307
+ return 1
308
+ print(f"\n{C.green('all clear')} — every env file is gitignored and absent from git history")
309
+ return 0
310
+
311
+
312
+ # ---------------------------------------------------------------------------
313
+ # entry point
314
+ # ---------------------------------------------------------------------------
315
+
316
+
317
+ def build_parser() -> argparse.ArgumentParser:
318
+ parser = argparse.ArgumentParser(
319
+ prog="envtidy",
320
+ description="Keep your .env files honest: catch drift, sync examples, find leaks.",
321
+ )
322
+ parser.add_argument("--version", action="version", version=f"envtidy {__version__}")
323
+ sub = parser.add_subparsers(dest="command", required=True)
324
+
325
+ p_check = sub.add_parser("check", help="compare .env against .env.example")
326
+ p_check.add_argument("env", nargs="?", default=".env", help="env file (default: .env)")
327
+ p_check.add_argument("--example", help="example file (default: auto-detect)")
328
+ p_check.add_argument("--no-hint", action="store_true", help=argparse.SUPPRESS)
329
+ p_check.set_defaults(func=cmd_check)
330
+
331
+ p_sync = sub.add_parser("sync", help="generate .env.example from .env (values stripped)")
332
+ p_sync.add_argument("env", nargs="?", default=".env", help="env file (default: .env)")
333
+ p_sync.add_argument("--example", help="output file (default: <env>.example)")
334
+ p_sync.add_argument("--dry-run", action="store_true", help="print result instead of writing")
335
+ p_sync.set_defaults(func=cmd_sync)
336
+
337
+ p_scan = sub.add_parser("scan", help="find env files at risk of being committed")
338
+ p_scan.add_argument("dir", nargs="?", default=".", help="directory to scan (default: .)")
339
+ p_scan.set_defaults(func=cmd_scan)
340
+
341
+ return parser
342
+
343
+
344
+ def main(argv: list[str] | None = None) -> int:
345
+ args = build_parser().parse_args(argv)
346
+ return args.func(args)
347
+
348
+
349
+ if __name__ == "__main__":
350
+ sys.exit(main())
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: envtidy
3
+ Version: 0.2.0
4
+ Summary: Keep your .env files honest: catch drift, sync examples, find leaks. Zero dependencies.
5
+ Author: Nidhi Sebastian
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/nidhisebastian008/envtidy
8
+ Project-URL: Issues, https://github.com/nidhisebastian008/envtidy/issues
9
+ Keywords: dotenv,env,environment-variables,cli,devtools,secrets
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Quality Assurance
15
+ Classifier: Topic :: Utilities
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Dynamic: license-file
20
+
21
+ # envtidy
22
+
23
+ > Keep your `.env` files honest — catch drift, sync examples, find leaks.
24
+
25
+ ![zero dependencies](https://img.shields.io/badge/dependencies-zero-brightgreen)
26
+ ![python](https://img.shields.io/badge/python-3.9%2B-blue)
27
+ ![license](https://img.shields.io/badge/license-MIT-lightgrey)
28
+
29
+ Every team has lived this bug: someone adds `REDIS_URL` to their local `.env`, forgets to update `.env.example`, and the next person to clone the repo burns an hour debugging a crash that was really just a missing variable. Or worse — someone's `staging.env` was never gitignored and ships to GitHub with live credentials inside.
30
+
31
+ **envtidy** is a tiny CLI that catches both. Pure Python stdlib, zero dependencies, single-purpose.
32
+
33
+ ![envtidy demo](demo.svg)
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pipx install git+https://github.com/nidhisebastian008/envtidy
39
+ # or
40
+ pip install git+https://github.com/nidhisebastian008/envtidy
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ### `envtidy check` — catch drift
46
+
47
+ Compares `.env` against `.env.example` (also auto-detects `.sample`, `.template`, `.dist`):
48
+
49
+ ```
50
+ $ envtidy check
51
+ envtidy check .env vs .env.example
52
+ missing REDIS_URL (in example, not in env)
53
+ extra DEBUG (in env, not in example)
54
+ empty SMTP_HOST (declared but has no value)
55
+
56
+ 3 issues found (1 missing, 1 extra, 1 empty)
57
+ ```
58
+
59
+ Exits `1` when drift is found, `0` when clean — drop it straight into CI or a pre-commit hook:
60
+
61
+ ```yaml
62
+ # .pre-commit-config.yaml
63
+ - repo: local
64
+ hooks:
65
+ - id: envtidy
66
+ name: envtidy check
67
+ entry: envtidy check
68
+ language: system
69
+ pass_filenames: false
70
+ ```
71
+
72
+ ### `envtidy sync` — regenerate the example file
73
+
74
+ Rewrites `.env.example` from your real `.env`, stripping every value but preserving comments, blank lines, and key order:
75
+
76
+ ```
77
+ $ envtidy sync
78
+ created .env.example (4 keys, values stripped)
79
+ ```
80
+
81
+ Use `--dry-run` to preview without writing.
82
+
83
+ ### `envtidy scan` — find files that leaked (or are about to)
84
+
85
+ Walks a directory tree, finds every env file, and checks each one against git — **including the full git history**. Deleting a committed `.env` doesn't remove it; anyone with a clone can still recover it. `scan` catches that:
86
+
87
+ ```
88
+ $ envtidy scan
89
+ envtidy scan ~/code/myapp
90
+ ignored .env
91
+ exposed staging.env (not in .gitignore — one `git add .` from leaking)
92
+ TRACKED api/.env.production (committed to git — rotate these secrets)
93
+ HISTORY old/.env.local (deleted, but still in git history — rotate + scrub)
94
+
95
+ 3 files at risk
96
+ hint: scrub history with `git filter-repo --sensitive-data-removal --invert-paths --path <file>`
97
+ ```
98
+
99
+ - **ignored** — safely gitignored, never committed ✅
100
+ - **exposed** — exists but *not* gitignored; one `git add .` away from a leak
101
+ - **TRACKED** — committed right now; treat those secrets as compromised and rotate them
102
+ - **HISTORY** — not in the working tree or index anymore, but recoverable from git history; rotate the secrets *and* scrub the history
103
+
104
+ ## Alternatives
105
+
106
+ Prior art exists — pick the right tool for your job:
107
+
108
+ - [dotenv-linter](https://github.com/dotenv-linter/dotenv-linter) — fast Rust linter for `.env` *style* (duplicate keys, ordering, quoting), with a `compare` command for key diffs. No git awareness, no example generation.
109
+ - [sync-dotenv](https://github.com/luqmanoop/sync-dotenv) — Node tool doing what `envtidy sync` does.
110
+ - [gitleaks](https://github.com/gitleaks/gitleaks) / trufflehog / detect-secrets — heavyweight *content-level* secret scanners with rules and entropy checks. Use them for full audits; use `envtidy scan` for the quick file-level answer to "are my env files safe in this repo?"
111
+
112
+ envtidy's niche: all three jobs (drift check, example sync, git-aware leak scan) in one zero-dependency CLI with no config.
113
+
114
+ ## Details
115
+
116
+ - Understands `export KEY=value`, quoted values, inline `# comments`
117
+ - Respects [`NO_COLOR`](https://no-color.org/); colors auto-disable when piped
118
+ - Skips `node_modules`, `.venv`, `.git`, build dirs while scanning
119
+ - Exit codes: `0` clean · `1` issues found · `2` usage error
120
+
121
+ ## License
122
+
123
+ MIT
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ envtidy/__init__.py
5
+ envtidy/cli.py
6
+ envtidy.egg-info/PKG-INFO
7
+ envtidy.egg-info/SOURCES.txt
8
+ envtidy.egg-info/dependency_links.txt
9
+ envtidy.egg-info/entry_points.txt
10
+ envtidy.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ envtidy = envtidy.cli:main
@@ -0,0 +1 @@
1
+ envtidy
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "envtidy"
7
+ version = "0.2.0"
8
+ description = "Keep your .env files honest: catch drift, sync examples, find leaks. Zero dependencies."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Nidhi Sebastian" }]
13
+ keywords = ["dotenv", "env", "environment-variables", "cli", "devtools", "secrets"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Software Development :: Quality Assurance",
20
+ "Topic :: Utilities",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/nidhisebastian008/envtidy"
25
+ Issues = "https://github.com/nidhisebastian008/envtidy/issues"
26
+
27
+ [project.scripts]
28
+ envtidy = "envtidy.cli:main"
29
+
30
+ [tool.setuptools]
31
+ packages = ["envtidy"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+