envshield 4.7.0__tar.gz → 4.7.1__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.
Files changed (49) hide show
  1. {envshield-4.7.0 → envshield-4.7.1}/PKG-INFO +19 -1
  2. {envshield-4.7.0 → envshield-4.7.1}/README.md +18 -0
  3. envshield-4.7.1/envshield/__init__.py +1 -0
  4. {envshield-4.7.0 → envshield-4.7.1}/envshield/cli.py +121 -1
  5. {envshield-4.7.0 → envshield-4.7.1}/envshield/core/hooks_manager.py +13 -2
  6. {envshield-4.7.0 → envshield-4.7.1}/envshield/core/scanner.py +68 -26
  7. {envshield-4.7.0 → envshield-4.7.1}/envshield.egg-info/PKG-INFO +19 -1
  8. {envshield-4.7.0 → envshield-4.7.1}/pyproject.toml +1 -1
  9. envshield-4.7.0/envshield/__init__.py +0 -1
  10. {envshield-4.7.0 → envshield-4.7.1}/LICENSE.md +0 -0
  11. {envshield-4.7.0 → envshield-4.7.1}/envshield/__main__.py +0 -0
  12. {envshield-4.7.0 → envshield-4.7.1}/envshield/config/__init__.py +0 -0
  13. {envshield-4.7.0 → envshield-4.7.1}/envshield/config/manager.py +0 -0
  14. {envshield-4.7.0 → envshield-4.7.1}/envshield/core/__init__.py +0 -0
  15. {envshield-4.7.0 → envshield-4.7.1}/envshield/core/contract_diff.py +0 -0
  16. {envshield-4.7.0 → envshield-4.7.1}/envshield/core/dependency_diff.py +0 -0
  17. {envshield-4.7.0 → envshield-4.7.1}/envshield/core/dependency_snapshot.py +0 -0
  18. {envshield-4.7.0 → envshield-4.7.1}/envshield/core/discovery.py +0 -0
  19. {envshield-4.7.0 → envshield-4.7.1}/envshield/core/doctor.py +0 -0
  20. {envshield-4.7.0 → envshield-4.7.1}/envshield/core/exceptions.py +0 -0
  21. {envshield-4.7.0 → envshield-4.7.1}/envshield/core/explain.py +0 -0
  22. {envshield-4.7.0 → envshield-4.7.1}/envshield/core/file_updater.py +0 -0
  23. {envshield-4.7.0 → envshield-4.7.1}/envshield/core/generator.py +0 -0
  24. {envshield-4.7.0 → envshield-4.7.1}/envshield/core/importer.py +0 -0
  25. {envshield-4.7.0 → envshield-4.7.1}/envshield/core/inspector.py +0 -0
  26. {envshield-4.7.0 → envshield-4.7.1}/envshield/core/schema_manager.py +0 -0
  27. {envshield-4.7.0 → envshield-4.7.1}/envshield/core/schema_snapshot.py +0 -0
  28. {envshield-4.7.0 → envshield-4.7.1}/envshield/core/schema_types.py +0 -0
  29. {envshield-4.7.0 → envshield-4.7.1}/envshield/core/service_discovery.py +0 -0
  30. {envshield-4.7.0 → envshield-4.7.1}/envshield/core/service_manager.py +0 -0
  31. {envshield-4.7.0 → envshield-4.7.1}/envshield/core/setup_manager.py +0 -0
  32. {envshield-4.7.0 → envshield-4.7.1}/envshield/parsers/__init__.py +0 -0
  33. {envshield-4.7.0 → envshield-4.7.1}/envshield/parsers/_base.py +0 -0
  34. {envshield-4.7.0 → envshield-4.7.1}/envshield/parsers/_deployment.py +0 -0
  35. {envshield-4.7.0 → envshield-4.7.1}/envshield/parsers/_docker_compose.py +0 -0
  36. {envshield-4.7.0 → envshield-4.7.1}/envshield/parsers/_dotenv.py +0 -0
  37. {envshield-4.7.0 → envshield-4.7.1}/envshield/parsers/_kubernetes.py +0 -0
  38. {envshield-4.7.0 → envshield-4.7.1}/envshield/parsers/_python.py +0 -0
  39. {envshield-4.7.0 → envshield-4.7.1}/envshield/parsers/factory.py +0 -0
  40. {envshield-4.7.0 → envshield-4.7.1}/envshield/state.py +0 -0
  41. {envshield-4.7.0 → envshield-4.7.1}/envshield/utils/__init__.py +0 -0
  42. {envshield-4.7.0 → envshield-4.7.1}/envshield/utils/git_utils.py +0 -0
  43. {envshield-4.7.0 → envshield-4.7.1}/envshield/utils/paths.py +0 -0
  44. {envshield-4.7.0 → envshield-4.7.1}/envshield.egg-info/SOURCES.txt +0 -0
  45. {envshield-4.7.0 → envshield-4.7.1}/envshield.egg-info/dependency_links.txt +0 -0
  46. {envshield-4.7.0 → envshield-4.7.1}/envshield.egg-info/entry_points.txt +0 -0
  47. {envshield-4.7.0 → envshield-4.7.1}/envshield.egg-info/requires.txt +0 -0
  48. {envshield-4.7.0 → envshield-4.7.1}/envshield.egg-info/top_level.txt +0 -0
  49. {envshield-4.7.0 → envshield-4.7.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: envshield
3
- Version: 4.7.0
3
+ Version: 4.7.1
4
4
  Summary: EnvShield: environment variables as a version-controlled configuration contract.
5
5
  Author-email: Rabbil Yasar Sajal <rabbilyasar@gmail.com>
6
6
  License: MIT License
@@ -427,6 +427,24 @@ A supporting check alongside the contract, not the product itself. `secret = tru
427
427
 
428
428
  ---
429
429
 
430
+ ## Removing EnvShield
431
+
432
+ ```bash
433
+ envshield uninstall
434
+ ```
435
+
436
+ Removes EnvShield's Git hook integration while preserving your project configuration — it does not delete `envshield.yml`, any `env.schema.toml`, `.env.example`, any registered local configuration file (`.env`, a Python config module, etc.), `.gitignore`, or any code `envshield generate` has produced, regardless of whether EnvShield originally created any of it. Those are your project's own configuration contract and application config, not disposable installation artifacts.
437
+
438
+ The only thing `uninstall` ever deletes is a Git hook whose content exactly matches what EnvShield would generate right now — the same ownership rule `hook remove` already uses. A hand-modified hook, a foreign hook (even one that happens to contain EnvShield's marker comment), or an EnvShield hook that's gone stale relative to your current `envshield.yml` (e.g. after registering a new service) is left in place and reported, never deleted or silently regenerated.
439
+
440
+ ```bash
441
+ envshield uninstall --yes # skip the confirmation prompt
442
+ ```
443
+
444
+ `--yes` only skips the prompt — it never overrides the ownership check; a modified, foreign, or stale hook is preserved with `--yes` exactly as it is interactively. There is no `--force` and no `--purge`: `uninstall` is intentionally not a "delete everything EnvShield ever created" command. To remove EnvShield's configuration from a project entirely, delete `envshield.yml` and the schema files yourself once you're sure you no longer need them.
445
+
446
+ ---
447
+
430
448
  ## Known limitations
431
449
 
432
450
  EnvShield is built around the patterns real projects actually use. A few narrower cases aren't handled yet — none of them let a secret leak or let a genuinely missing required variable pass as clean:
@@ -368,6 +368,24 @@ A supporting check alongside the contract, not the product itself. `secret = tru
368
368
 
369
369
  ---
370
370
 
371
+ ## Removing EnvShield
372
+
373
+ ```bash
374
+ envshield uninstall
375
+ ```
376
+
377
+ Removes EnvShield's Git hook integration while preserving your project configuration — it does not delete `envshield.yml`, any `env.schema.toml`, `.env.example`, any registered local configuration file (`.env`, a Python config module, etc.), `.gitignore`, or any code `envshield generate` has produced, regardless of whether EnvShield originally created any of it. Those are your project's own configuration contract and application config, not disposable installation artifacts.
378
+
379
+ The only thing `uninstall` ever deletes is a Git hook whose content exactly matches what EnvShield would generate right now — the same ownership rule `hook remove` already uses. A hand-modified hook, a foreign hook (even one that happens to contain EnvShield's marker comment), or an EnvShield hook that's gone stale relative to your current `envshield.yml` (e.g. after registering a new service) is left in place and reported, never deleted or silently regenerated.
380
+
381
+ ```bash
382
+ envshield uninstall --yes # skip the confirmation prompt
383
+ ```
384
+
385
+ `--yes` only skips the prompt — it never overrides the ownership check; a modified, foreign, or stale hook is preserved with `--yes` exactly as it is interactively. There is no `--force` and no `--purge`: `uninstall` is intentionally not a "delete everything EnvShield ever created" command. To remove EnvShield's configuration from a project entirely, delete `envshield.yml` and the schema files yourself once you're sure you no longer need them.
386
+
387
+ ---
388
+
371
389
  ## Known limitations
372
390
 
373
391
  EnvShield is built around the patterns real projects actually use. A few narrower cases aren't handled yet — none of them let a secret leak or let a genuinely missing required variable pass as clean:
@@ -0,0 +1 @@
1
+ __version__ = "4.7.1"
@@ -1855,11 +1855,12 @@ def hook_remove(
1855
1855
  console.print("[yellow]Cancelled.[/yellow]")
1856
1856
  raise typer.Exit()
1857
1857
  try:
1858
- removed = scanner.remove_hooks()
1858
+ results = scanner.remove_hooks()
1859
1859
  except EnvShieldException as e:
1860
1860
  console.print(f"[bold red]Error:[/bold red] {e}")
1861
1861
  raise typer.Exit(code=1)
1862
1862
 
1863
+ removed = [r["name"] for r in results if r["status"] == "removed"]
1863
1864
  if not removed:
1864
1865
  console.print("[yellow]No EnvShield-installed hooks found to remove.[/yellow]")
1865
1866
  return
@@ -1867,6 +1868,125 @@ def hook_remove(
1867
1868
  console.print(f"[bold green]✓[/bold green] Removed {hook_name} hook.")
1868
1869
 
1869
1870
 
1871
+ @app.command()
1872
+ def uninstall(
1873
+ yes: bool = typer.Option(
1874
+ False, "--yes", "-y", help="Skip the confirmation prompt."
1875
+ ),
1876
+ ):
1877
+ """
1878
+ Removes EnvShield's Git hook integration from this project.
1879
+
1880
+ Only ever deletes a hook file whose content exactly matches what
1881
+ EnvShield would generate right now -- the same ownership standard
1882
+ 'hook remove' already uses. A hand-modified hook, a foreign hook, or
1883
+ an EnvShield hook that's gone stale relative to your current
1884
+ envshield.yml (e.g. after registering a new service) is left alone
1885
+ and reported, never deleted.
1886
+
1887
+ Never touches project configuration -- envshield.yml, any
1888
+ env.schema.toml, .env.example, or any registered local configuration
1889
+ file are always preserved and reported, never removed. This is not a
1890
+ "delete everything EnvShield ever created" command.
1891
+ """
1892
+ if not _confirm_hook_action(
1893
+ "EnvShield will remove Git hooks that are verified as EnvShield-owned.\n"
1894
+ "Project configuration and schema files will be preserved.\n\n"
1895
+ "Continue?",
1896
+ yes,
1897
+ ):
1898
+ console.print("[yellow]Cancelled.[/yellow]")
1899
+ raise typer.Exit()
1900
+
1901
+ console.print("\n[bold]EnvShield uninstall[/bold]\n")
1902
+
1903
+ try:
1904
+ hook_results = scanner.remove_hooks()
1905
+ except EnvShieldException:
1906
+ # No Git repository -- there are no hooks to remove, which isn't
1907
+ # an error condition for a project-level uninstall the way it is
1908
+ # for 'hook remove' (a command that only ever makes sense inside
1909
+ # one). Continue on to reporting project configuration.
1910
+ hook_results = []
1911
+
1912
+ # Reused, not re-derived: 'remove_hooks()' already resolves this same
1913
+ # directory (respecting a configured 'core.hooksPath', e.g. Husky) to
1914
+ # decide where to look/delete -- the report must name that same real
1915
+ # location, never a hardcoded '.git/hooks/' that could be wrong.
1916
+ # 'get_hooks_dir()' always returns an absolute path; displaying it
1917
+ # relative to the current directory (when possible) is purely
1918
+ # cosmetic -- it keeps the common case reading exactly as it always
1919
+ # has, and avoids Rich hard-wrapping a long absolute path mid-string.
1920
+ hooks_dir = git_utils.get_hooks_dir()
1921
+ if hooks_dir:
1922
+ try:
1923
+ hooks_dir = os.path.relpath(hooks_dir, os.getcwd())
1924
+ except ValueError:
1925
+ pass
1926
+ else:
1927
+ hooks_dir = os.path.join(".git", "hooks")
1928
+
1929
+ removed_names = [r["name"] for r in hook_results if r["status"] == "removed"]
1930
+ preserved_hooks = [r for r in hook_results if r["status"] == "preserved"]
1931
+
1932
+ if removed_names:
1933
+ console.print("[bold]Removed:[/bold]")
1934
+ for hook_name in removed_names:
1935
+ console.print(
1936
+ f" [bold green]✓[/bold green] {os.path.join(hooks_dir, hook_name)}"
1937
+ )
1938
+ console.print()
1939
+ else:
1940
+ console.print(
1941
+ "[yellow]Nothing to remove -- no safely-owned EnvShield hooks were found.[/yellow]\n"
1942
+ )
1943
+
1944
+ # Everything below is report-only -- uninstall never deletes or
1945
+ # modifies any of it, regardless of whether EnvShield originally
1946
+ # created it (envshield.yml, a schema, .env.example) or only ever
1947
+ # populated it (a registered local_file, which may hold real secret
1948
+ # values and is never "EnvShield's own" to reclaim).
1949
+ preserved_lines = []
1950
+ for r in preserved_hooks:
1951
+ preserved_lines.append(
1952
+ (
1953
+ os.path.join(hooks_dir, r["name"]),
1954
+ "hook contents do not exactly match what EnvShield would "
1955
+ "generate now -- hand-modified, foreign, or stale relative "
1956
+ "to your current config",
1957
+ )
1958
+ )
1959
+
1960
+ if os.path.exists(config_manager.CONFIG_FILE_NAME):
1961
+ preserved_lines.append(
1962
+ (config_manager.CONFIG_FILE_NAME, "project configuration")
1963
+ )
1964
+
1965
+ for service_name in config_manager.get_services():
1966
+ schema_path = config_manager.get_service_schema_path(service_name)
1967
+ if schema_path and os.path.exists(schema_path):
1968
+ preserved_lines.append((schema_path, "project configuration"))
1969
+
1970
+ paths = config_manager.get_env_paths(service_name)
1971
+ if os.path.exists(paths["example_file"]):
1972
+ preserved_lines.append((paths["example_file"], "preserved"))
1973
+ if os.path.exists(paths["local_file"]):
1974
+ preserved_lines.append((paths["local_file"], "application configuration"))
1975
+
1976
+ if preserved_lines:
1977
+ console.print("[bold]Preserved:[/bold]")
1978
+ for path, reason in preserved_lines:
1979
+ console.print(f" • {path} -- {reason}")
1980
+ console.print()
1981
+
1982
+ if removed_names:
1983
+ console.print(
1984
+ "[bold green]EnvShield Git hook integration removed safely.[/bold green]"
1985
+ )
1986
+ if preserved_lines:
1987
+ console.print("Project configuration was preserved.")
1988
+
1989
+
1870
1990
  @app.command(name="import")
1871
1991
  def import_command(
1872
1992
  file: str = typer.Argument(
@@ -122,6 +122,14 @@ class HooksManager:
122
122
  def _do_install_hooks(self, force: bool = False) -> None:
123
123
  """Actually install the hooks (called after user confirms).
124
124
 
125
+ Only installs whichever hook(s) are actually missing -- an
126
+ already-present hook must not be touched (and re-prompted for its
127
+ own separate overwrite confirmation) merely because the OTHER hook
128
+ needed installing. `prompt_install_hooks`'s own "Install git
129
+ hooks? (...)" prompt already tells the user which hook(s) are
130
+ about to be installed; installing an unrelated, already-present
131
+ one here would silently do more than that prompt described.
132
+
125
133
  Args:
126
134
  force: If True, overwrite existing hooks.
127
135
  """
@@ -129,8 +137,11 @@ class HooksManager:
129
137
  # Import here to avoid circular imports
130
138
  from . import scanner
131
139
 
132
- scanner.install_pre_commit_hook(force=force, non_interactive=False)
133
- scanner.install_post_merge_hook(force=force, non_interactive=False)
140
+ pre_commit, post_merge = self.are_hooks_installed()
141
+ if not pre_commit:
142
+ scanner.install_pre_commit_hook(force=force, non_interactive=False)
143
+ if not post_merge:
144
+ scanner.install_post_merge_hook(force=force, non_interactive=False)
134
145
  except EnvShieldException as e:
135
146
  console.print(f"[bold yellow]⚠️ Warning:[/] {e}")
136
147
 
@@ -890,39 +890,70 @@ def scan_result(
890
890
  ENVSHIELD_HOOK_MARKER = "# Hook installed by EnvShield"
891
891
 
892
892
 
893
- def remove_hooks() -> List[str]:
893
+ def remove_hooks() -> List[Dict[str, str]]:
894
894
  """
895
- Removes any EnvShield-installed Git hook (pre-commit, post-merge),
896
- identified by the same '# Hook installed by EnvShield' marker `install`
897
- already checks before offering to overwrite -- a hook that isn't
898
- EnvShield's is left alone rather than deleted out from under whatever
899
- else manages it (Husky, a hand-written script, etc.).
900
-
901
- Returns the names of the hooks actually removed.
895
+ Removes only a hook file EnvShield can prove is its own, unmodified
896
+ output -- an exact content match against what EnvShield would generate
897
+ right now, the same ownership standard install_pre_commit_hook/
898
+ install_post_merge_hook already use (see their own comment: the marker
899
+ comment alone is not proof of ownership -- a user can modify a
900
+ generated hook, or hand-write one containing the same comment, while
901
+ keeping the marker text intact). A hook that isn't provably
902
+ EnvShield's own unmodified output is left alone -- whether it's
903
+ genuinely foreign (Husky, a hand-written script) or an EnvShield hook
904
+ a user has since extended.
905
+
906
+ Returns one {"name": ..., "status": ...} entry per hook type EnvShield
907
+ manages (pre-commit, post-merge), regardless of outcome -- "removed"
908
+ (deleted, exact match), "preserved" (a file exists but its content
909
+ doesn't exactly match current generated output -- hand-modified,
910
+ foreign, or stale relative to the current config), or "missing" (no
911
+ file at that path). This is the single source of truth for hook
912
+ ownership; 'hook remove' filters for "removed", 'uninstall' also
913
+ reports "preserved" -- neither re-derives the ownership check itself.
902
914
  """
903
915
  git_root = git_utils.get_git_root()
904
916
  if not git_root:
905
917
  raise EnvShieldException("Not inside a Git repository.")
906
918
 
907
919
  hooks_dir = git_utils.get_hooks_dir()
908
- removed = []
909
- for hook_name in ("pre-commit", "post-merge"):
920
+ generators = {
921
+ "pre-commit": _generate_pre_commit_hook_content,
922
+ "post-merge": _generate_post_merge_hook_content,
923
+ }
924
+ results = []
925
+ for hook_name, generate_content in generators.items():
910
926
  hook_path = os.path.join(hooks_dir, hook_name)
911
927
  if not os.path.exists(hook_path):
928
+ results.append({"name": hook_name, "status": "missing"})
912
929
  continue
913
930
  with open(hook_path, "r") as f:
914
931
  content = f.read()
915
- if ENVSHIELD_HOOK_MARKER not in content:
932
+ if content != generate_content():
933
+ results.append({"name": hook_name, "status": "preserved"})
916
934
  continue
917
935
  os.remove(hook_path)
918
- removed.append(hook_name)
919
- return removed
936
+ results.append({"name": hook_name, "status": "removed"})
937
+ return results
920
938
 
921
939
 
922
- def _describe_existing_hook(content: str) -> str:
923
- """Best-effort description of an existing hook file, for the overwrite warning."""
924
- if ENVSHIELD_HOOK_MARKER in content:
940
+ def _describe_existing_hook(content: str, is_safely_regeneratable: bool) -> str:
941
+ """
942
+ Best-effort description of an existing hook file, for the overwrite
943
+ warning. `is_safely_regeneratable` is an exact-content match against
944
+ what EnvShield would generate right now -- the marker comment alone is
945
+ not proof of that (see the call site's own comment): a user can modify
946
+ a generated hook, or hand-write one, while keeping the marker text
947
+ intact.
948
+ """
949
+ if is_safely_regeneratable:
925
950
  return "previously installed by EnvShield -- safe to regenerate"
951
+ if ENVSHIELD_HOOK_MARKER in content:
952
+ return (
953
+ "previously installed by EnvShield, but its contents no longer match "
954
+ "what EnvShield would generate now (hand-edited, or stale relative to "
955
+ "your current config) -- overwriting could discard those changes"
956
+ )
926
957
  if "husky.sh" in content or ".husky" in content:
927
958
  return "managed by Husky"
928
959
  non_comment_lines = [
@@ -1056,10 +1087,18 @@ def install_pre_commit_hook(force: bool = False, non_interactive: bool = False):
1056
1087
  with open(pre_commit_path, "r") as f:
1057
1088
  existing_content = f.read()
1058
1089
 
1059
- # Regenerating EnvShield's own hook is always safe -- non-interactive
1060
- # mode only needs to hold back from a genuinely foreign one, not
1061
- # bail out on every re-run just because the file already exists.
1062
- if non_interactive and ENVSHIELD_HOOK_MARKER not in existing_content:
1090
+ # A marker comment alone is not proof this file is EnvShield's own,
1091
+ # untouched output -- a user can modify a generated hook (or
1092
+ # hand-write one containing the same comment) while keeping the
1093
+ # marker text intact. Only an exact match against what EnvShield
1094
+ # would generate right now is provably safe to replace without
1095
+ # asking; non-interactive mode's fast path is scoped to exactly
1096
+ # that case -- anything else (including a marker-bearing file
1097
+ # that no longer matches) falls through to the same protected
1098
+ # confirmation/warning path as a genuinely foreign hook.
1099
+ is_safely_regeneratable = existing_content == hook_script_content
1100
+
1101
+ if non_interactive and not is_safely_regeneratable:
1063
1102
  console.print(
1064
1103
  "[bold yellow]⚠️ Warning:[/] A pre-commit hook already exists. EnvShield was not installed automatically."
1065
1104
  )
@@ -1070,7 +1109,7 @@ def install_pre_commit_hook(force: bool = False, non_interactive: bool = False):
1070
1109
 
1071
1110
  if not force and not non_interactive:
1072
1111
  overwrite = questionary.confirm(
1073
- f"A pre-commit hook already exists ({_describe_existing_hook(existing_content)}). Do you want to overwrite it?",
1112
+ f"A pre-commit hook already exists ({_describe_existing_hook(existing_content, is_safely_regeneratable)}). Do you want to overwrite it?",
1074
1113
  default=False,
1075
1114
  ).ask()
1076
1115
  if not overwrite:
@@ -1179,10 +1218,13 @@ def install_post_merge_hook(force: bool = False, non_interactive: bool = False):
1179
1218
  with open(post_merge_path, "r") as f:
1180
1219
  existing_content = f.read()
1181
1220
 
1182
- # Regenerating EnvShield's own hook is always safe -- non-interactive
1183
- # mode only needs to hold back from a genuinely foreign one, not
1184
- # bail out on every re-run just because the file already exists.
1185
- if non_interactive and ENVSHIELD_HOOK_MARKER not in existing_content:
1221
+ # See install_pre_commit_hook's identical comment: a marker
1222
+ # comment alone is not proof this file is EnvShield's own,
1223
+ # untouched output. Only an exact match against what EnvShield
1224
+ # would generate right now is treated as provably safe.
1225
+ is_safely_regeneratable = existing_content == hook_script_content
1226
+
1227
+ if non_interactive and not is_safely_regeneratable:
1186
1228
  console.print(
1187
1229
  "[bold yellow]⚠️ Warning:[/] A post-merge hook already exists. EnvShield was not installed automatically."
1188
1230
  )
@@ -1193,7 +1235,7 @@ def install_post_merge_hook(force: bool = False, non_interactive: bool = False):
1193
1235
 
1194
1236
  if not force and not non_interactive:
1195
1237
  overwrite = questionary.confirm(
1196
- f"A post-merge hook already exists ({_describe_existing_hook(existing_content)}). Do you want to overwrite it?",
1238
+ f"A post-merge hook already exists ({_describe_existing_hook(existing_content, is_safely_regeneratable)}). Do you want to overwrite it?",
1197
1239
  default=False,
1198
1240
  ).ask()
1199
1241
  if not overwrite:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: envshield
3
- Version: 4.7.0
3
+ Version: 4.7.1
4
4
  Summary: EnvShield: environment variables as a version-controlled configuration contract.
5
5
  Author-email: Rabbil Yasar Sajal <rabbilyasar@gmail.com>
6
6
  License: MIT License
@@ -427,6 +427,24 @@ A supporting check alongside the contract, not the product itself. `secret = tru
427
427
 
428
428
  ---
429
429
 
430
+ ## Removing EnvShield
431
+
432
+ ```bash
433
+ envshield uninstall
434
+ ```
435
+
436
+ Removes EnvShield's Git hook integration while preserving your project configuration — it does not delete `envshield.yml`, any `env.schema.toml`, `.env.example`, any registered local configuration file (`.env`, a Python config module, etc.), `.gitignore`, or any code `envshield generate` has produced, regardless of whether EnvShield originally created any of it. Those are your project's own configuration contract and application config, not disposable installation artifacts.
437
+
438
+ The only thing `uninstall` ever deletes is a Git hook whose content exactly matches what EnvShield would generate right now — the same ownership rule `hook remove` already uses. A hand-modified hook, a foreign hook (even one that happens to contain EnvShield's marker comment), or an EnvShield hook that's gone stale relative to your current `envshield.yml` (e.g. after registering a new service) is left in place and reported, never deleted or silently regenerated.
439
+
440
+ ```bash
441
+ envshield uninstall --yes # skip the confirmation prompt
442
+ ```
443
+
444
+ `--yes` only skips the prompt — it never overrides the ownership check; a modified, foreign, or stale hook is preserved with `--yes` exactly as it is interactively. There is no `--force` and no `--purge`: `uninstall` is intentionally not a "delete everything EnvShield ever created" command. To remove EnvShield's configuration from a project entirely, delete `envshield.yml` and the schema files yourself once you're sure you no longer need them.
445
+
446
+ ---
447
+
430
448
  ## Known limitations
431
449
 
432
450
  EnvShield is built around the patterns real projects actually use. A few narrower cases aren't handled yet — none of them let a secret leak or let a genuinely missing required variable pass as clean:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "envshield"
7
- version = "4.7.0" # must be a string for PEP 621 compliance
7
+ version = "4.7.1" # must be a string for PEP 621 compliance
8
8
  description = "EnvShield: environment variables as a version-controlled configuration contract."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1 +0,0 @@
1
- __version__ = "4.7.0"
File without changes
File without changes
File without changes