gitwise-cli 0.30.0__tar.gz → 0.31.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.
Files changed (176) hide show
  1. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/CHANGELOG.md +6 -0
  2. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/PKG-INFO +1 -1
  3. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/__init__.py +1 -1
  4. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/_cli_dispatch.py +2 -0
  5. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/_cli_parser.py +10 -0
  6. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/_i18n_data.json +32 -0
  7. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/commit.py +69 -1
  8. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/diff.py +50 -0
  9. gitwise_cli-0.31.0/gitwise/utils/secret_scan.py +201 -0
  10. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/pyproject.toml +1 -1
  11. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/commit.json +5 -0
  12. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/diff.json +5 -0
  13. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_commit.py +58 -0
  14. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_diff.py +24 -0
  15. gitwise_cli-0.31.0/tests/test_secret_scan.py +177 -0
  16. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/.gitignore +0 -0
  17. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/LICENSE +0 -0
  18. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/README.md +0 -0
  19. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/bin/gitwise +0 -0
  20. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/__main__.py +0 -0
  21. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/_cli_completions.py +0 -0
  22. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/_cli_introspection.py +0 -0
  23. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/_cli_setup_agents.py +0 -0
  24. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/_paths.py +0 -0
  25. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/_runtime_config.py +0 -0
  26. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/audit.py +0 -0
  27. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/branches.py +0 -0
  28. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/clean.py +0 -0
  29. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/conflicts.py +0 -0
  30. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/context.py +0 -0
  31. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/design.py +0 -0
  32. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/doctor.py +0 -0
  33. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/git.py +0 -0
  34. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/health.py +0 -0
  35. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/i18n.py +0 -0
  36. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/log.py +0 -0
  37. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/merge.py +0 -0
  38. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/optimize.py +0 -0
  39. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/output.py +0 -0
  40. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/pick.py +0 -0
  41. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/pr.py +0 -0
  42. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/py.typed +0 -0
  43. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/schema.py +0 -0
  44. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup.py +0 -0
  45. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/__init__.py +0 -0
  46. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/adapters/__init__.py +0 -0
  47. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/adapters/aider.py +0 -0
  48. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/adapters/base.py +0 -0
  49. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/adapters/codex.py +0 -0
  50. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/adapters/continue_adapter.py +0 -0
  51. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/adapters/cursor.py +0 -0
  52. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/adapters/opencode.py +0 -0
  53. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/adapters/pi.py +0 -0
  54. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/exec.py +0 -0
  55. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/format.py +0 -0
  56. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/plan.py +0 -0
  57. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/plan_gitfiles.py +0 -0
  58. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/plan_skills.py +0 -0
  59. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/providers/__init__.py +0 -0
  60. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/providers/aider.py +0 -0
  61. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/providers/base.py +0 -0
  62. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/providers/claude.py +0 -0
  63. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/providers/codex.py +0 -0
  64. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/providers/continue_adapter.py +0 -0
  65. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/providers/cursor.py +0 -0
  66. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/providers/opencode.py +0 -0
  67. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/providers/pi.py +0 -0
  68. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/state.py +0 -0
  69. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/types.py +0 -0
  70. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/show.py +0 -0
  71. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/snapshot.py +0 -0
  72. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/stash.py +0 -0
  73. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/status.py +0 -0
  74. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/suggest.py +0 -0
  75. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/summarize.py +0 -0
  76. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/sync.py +0 -0
  77. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/tag.py +0 -0
  78. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/undo.py +0 -0
  79. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/update.py +0 -0
  80. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/utils/__init__.py +0 -0
  81. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/utils/git_output.py +0 -0
  82. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/utils/in_progress.py +0 -0
  83. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/utils/json_envelope.py +0 -0
  84. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/utils/parsing.py +0 -0
  85. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/worktree.py +0 -0
  86. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/install.sh +0 -0
  87. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/agents/skills/git-audit/SKILL.md +0 -0
  88. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/agents/skills/git-clean/SKILL.md +0 -0
  89. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/agents/skills/git-optimize/SKILL.md +0 -0
  90. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/aider/CONVENTIONS.md.template +0 -0
  91. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/aider/aider.conf.yml.template +0 -0
  92. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/claude/CLAUDE.md.template +0 -0
  93. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/claude/rules/gitwise.md +0 -0
  94. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/claude/settings.json.template +0 -0
  95. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/claude/skills/git-audit/SKILL.md +0 -0
  96. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/claude/skills/git-clean/SKILL.md +0 -0
  97. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/claude/skills/git-optimize/SKILL.md +0 -0
  98. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/codex/agents/gitwise.toml.template +0 -0
  99. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/continue/rules/gitwise.md.template +0 -0
  100. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/cursor/rules/gitwise.mdc.template +0 -0
  101. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/git-config-modern.txt +0 -0
  102. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/hooks/commit-msg +0 -0
  103. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/hooks/pre-commit +0 -0
  104. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/opencode/agents/gitwise.md.template +0 -0
  105. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/pi/skills/gitwise.md.template +0 -0
  106. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/audit.json +0 -0
  107. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/branches.json +0 -0
  108. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/clean.json +0 -0
  109. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/commands.json +0 -0
  110. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/completions.json +0 -0
  111. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/conflicts.json +0 -0
  112. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/context.json +0 -0
  113. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/doctor.json +0 -0
  114. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/health.json +0 -0
  115. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/log.json +0 -0
  116. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/merge.json +0 -0
  117. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/optimize.json +0 -0
  118. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/pick.json +0 -0
  119. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/pr.json +0 -0
  120. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/schema.json +0 -0
  121. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/setup-agents.json +0 -0
  122. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/setup.json +0 -0
  123. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/show.json +0 -0
  124. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/snapshot.json +0 -0
  125. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/stash.json +0 -0
  126. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/status.json +0 -0
  127. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/suggest.json +0 -0
  128. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/summarize.json +0 -0
  129. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/sync.json +0 -0
  130. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/tag.json +0 -0
  131. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/undo.json +0 -0
  132. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/update.json +0 -0
  133. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/worktree.json +0 -0
  134. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/__init__.py +0 -0
  135. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/conftest.py +0 -0
  136. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_adapters.py +0 -0
  137. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_audit.py +0 -0
  138. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_branches.py +0 -0
  139. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_clean.py +0 -0
  140. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_conflicts.py +0 -0
  141. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_context.py +0 -0
  142. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_design.py +0 -0
  143. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_doctor.py +0 -0
  144. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_edge_cases.py +0 -0
  145. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_git.py +0 -0
  146. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_git_output_utils.py +0 -0
  147. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_health.py +0 -0
  148. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_i18n.py +0 -0
  149. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_in_progress.py +0 -0
  150. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_json_envelope_utils.py +0 -0
  151. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_log.py +0 -0
  152. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_main.py +0 -0
  153. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_md_links_script.py +0 -0
  154. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_merge.py +0 -0
  155. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_optimize.py +0 -0
  156. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_output.py +0 -0
  157. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_parsing_utils.py +0 -0
  158. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_pick.py +0 -0
  159. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_pr.py +0 -0
  160. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_roadmap_baseline_script.py +0 -0
  161. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_sa_plan.py +0 -0
  162. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_sa_unit.py +0 -0
  163. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_schema_catalog.py +0 -0
  164. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_setup.py +0 -0
  165. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_setup_agents.py +0 -0
  166. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_show.py +0 -0
  167. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_snapshot.py +0 -0
  168. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_stash.py +0 -0
  169. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_status.py +0 -0
  170. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_suggest.py +0 -0
  171. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_summarize.py +0 -0
  172. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_sync.py +0 -0
  173. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_sync_changelog_es_script.py +0 -0
  174. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_tag.py +0 -0
  175. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_undo.py +0 -0
  176. {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_worktree.py +0 -0
@@ -3,6 +3,12 @@
3
3
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
4
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## v0.31.0 (2026-06-19)
7
+
8
+ ### Feat
9
+
10
+ - **secret-scan**: commit-time guard and diff --scan-secrets (Sprint 2 D3) (#68)
11
+
6
12
  ## v0.30.0 (2026-06-19)
7
13
 
8
14
  ### Feat
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitwise-cli
3
- Version: 0.30.0
3
+ Version: 0.31.0
4
4
  Summary: Python CLI for optimizing git workflows and Claude Code integration
5
5
  Project-URL: Homepage, https://github.com/drzioner/gitwise
6
6
  Project-URL: Repository, https://github.com/drzioner/gitwise
@@ -1,6 +1,6 @@
1
1
  """gitwise -- CLI for optimizing git workflows and coding-agent integration."""
2
2
 
3
- __version__ = "0.30.0"
3
+ __version__ = "0.31.0"
4
4
 
5
5
 
6
6
  def get_version() -> str:
@@ -193,6 +193,7 @@ def _run_diff(args: argparse.Namespace) -> int:
193
193
  name_only=args.name_only,
194
194
  full=args.full,
195
195
  summary=args.summary,
196
+ scan_secrets=args.scan_secrets,
196
197
  as_json=args.json,
197
198
  )
198
199
 
@@ -232,6 +233,7 @@ def _run_commit(args: argparse.Namespace) -> int:
232
233
  breaking=args.breaking,
233
234
  amend=args.amend,
234
235
  dry_run=args.dry_run,
236
+ allow_secret=args.allow_secret,
235
237
  as_json=args.json,
236
238
  )
237
239
 
@@ -184,6 +184,11 @@ def build_parser() -> argparse.ArgumentParser:
184
184
  help="compact summary: additions/deletions per file, no patch",
185
185
  )
186
186
  p.add_argument("--stat", action="store_true", help="show diffstat (default behavior)")
187
+ p.add_argument(
188
+ "--scan-secrets",
189
+ action="store_true",
190
+ help="scan the diff for leaked credentials (advisory, opt-in)",
191
+ )
187
192
  p.add_argument(
188
193
  "refspec",
189
194
  nargs="?",
@@ -218,6 +223,11 @@ def build_parser() -> argparse.ArgumentParser:
218
223
  p.add_argument("--breaking", action="store_true", help="breaking change (!)")
219
224
  p.add_argument("--amend", action="store_true", help="amend last commit")
220
225
  p.add_argument("--dry-run", action="store_true", help="show without committing")
226
+ p.add_argument(
227
+ "--allow-secret",
228
+ action="store_true",
229
+ help="proceed past a high-severity secret finding (with confirmation)",
230
+ )
221
231
 
222
232
  p = sub.add_parser("branches", help="branch intelligence dashboard", parents=[parent])
223
233
  p.add_argument("--stale", action="store_true", help="show stale [gone] branches only")
@@ -463,6 +463,38 @@
463
463
  "es": "demasiados argumentos posicionales antes de '--' ({count}); gitwise diff acepta un unico refspec (usa a..b para un rango)",
464
464
  "en": "too many positional arguments before '--' ({count}); gitwise diff accepts a single refspec (use a..b for a range)"
465
465
  },
466
+ "secret_scan_clean": {
467
+ "es": "no se detectaron secretos en el diff",
468
+ "en": "no secrets detected in the diff"
469
+ },
470
+ "secret_scan_found": {
471
+ "es": "{rule} en {path}:{line} ({preview})",
472
+ "en": "{rule} at {path}:{line} ({preview})"
473
+ },
474
+ "secret_scan_found_header": {
475
+ "es": "Secretos detectados -- {count} hallazgo(s):",
476
+ "en": "Secrets detected -- {count} finding(s):"
477
+ },
478
+ "secret_scan_blocked_high": {
479
+ "es": "commit bloqueado: {count} secreto(s) de alta severidad en el área de staging",
480
+ "en": "commit blocked: {count} high-severity secret(s) in the staging area"
481
+ },
482
+ "secret_scan_blocked_hint": {
483
+ "es": "revoque o rote el secreto, remuévalo del stage, o use --allow-secret (con confirmación) para commitear de todos modos",
484
+ "en": "revoke/rotate the secret, unstage it, or use --allow-secret (with confirmation) to commit anyway"
485
+ },
486
+ "secret_scan_allow_confirm": {
487
+ "es": "Se detectaron {count} secreto(s) de alta severidad. ¿Commitear de todos modos?",
488
+ "en": "{count} high-severity secret(s) detected. Commit anyway?"
489
+ },
490
+ "secret_scan_medium_warning": {
491
+ "es": "advertencia: {rule} en {path}:{line} ({preview})",
492
+ "en": "warning: {rule} at {path}:{line} ({preview})"
493
+ },
494
+ "secret_scan_unavailable": {
495
+ "es": "no se pudo escanear el área de staging (fail-closed): {error}",
496
+ "en": "could not scan the staging area (fail-closed): {error}"
497
+ },
466
498
  "directory_exists": {
467
499
  "es": "el directorio ya existe: {path}",
468
500
  "en": "directory already exists: {path}"
@@ -6,9 +6,10 @@ from pathlib import Path
6
6
  from .git import PROTECTED_BRANCHES, current_branch, gpg_status, require_root
7
7
  from .git import run as git_run
8
8
  from .i18n import t
9
- from .output import error, print_bracket, print_header, print_json
9
+ from .output import confirm, error, print_bracket, print_header, print_json, warn
10
10
  from .utils.in_progress import detect_in_progress, in_progress_hint
11
11
  from .utils.json_envelope import error_envelope, ok_envelope
12
+ from .utils.secret_scan import SecretScanUnavailable, scan_staged_diff
12
13
 
13
14
  _CONVENTIONAL_RE = re.compile(
14
15
  r"^(feat|fix|refactor|docs|chore|test|style|perf|ci|build|revert)(\(.+\))?!?: .{1,72}"
@@ -110,6 +111,68 @@ def _report_commit_error(*, as_json: bool, err: str) -> int:
110
111
  return 1
111
112
 
112
113
 
114
+ def _enforce_secret_guard(*, root: Path, allow_secret: bool, as_json: bool) -> int | None:
115
+ """Scan staged content for leaked credentials; return 1 to block, None to proceed.
116
+
117
+ Runs by default on every commit. High-severity findings block unless
118
+ ``allow_secret`` is set (human mode still asks for confirmation); medium
119
+ findings only warn. The full secret is never printed -- previews are redacted.
120
+ Fails closed: if the staged diff cannot be read, the commit is blocked
121
+ rather than allowed through unscanned.
122
+ """
123
+ try:
124
+ findings = scan_staged_diff(root)
125
+ except SecretScanUnavailable as exc:
126
+ unavailable = t("secret_scan_unavailable", error=str(exc))
127
+ if as_json:
128
+ print_json(error_envelope(error=unavailable, code="secret_scan_unavailable"))
129
+ else:
130
+ error(unavailable)
131
+ return 1
132
+ if not findings:
133
+ return None
134
+ high = [f for f in findings if f["severity"] == "high"]
135
+ medium = [f for f in findings if f["severity"] == "medium"]
136
+ for f in medium:
137
+ warn(
138
+ t(
139
+ "secret_scan_medium_warning",
140
+ rule=f["rule"],
141
+ path=f["path"],
142
+ line=str(f["line"]),
143
+ preview=f["preview"],
144
+ )
145
+ )
146
+ if not high:
147
+ return None
148
+ if not allow_secret:
149
+ if as_json:
150
+ print_json(
151
+ error_envelope(
152
+ error=t("secret_scan_blocked_high", count=str(len(high))),
153
+ code="secret_leak_high",
154
+ hint=t("secret_scan_blocked_hint"),
155
+ findings=high,
156
+ )
157
+ )
158
+ else:
159
+ for f in high:
160
+ error(
161
+ t(
162
+ "secret_scan_found",
163
+ rule=f["rule"],
164
+ path=f["path"],
165
+ line=str(f["line"]),
166
+ preview=f["preview"],
167
+ )
168
+ )
169
+ error(t("secret_scan_blocked_hint"))
170
+ return 1
171
+ if not as_json and not confirm(t("secret_scan_allow_confirm", count=str(len(high)))):
172
+ return 1
173
+ return None
174
+
175
+
113
176
  def run_commit(
114
177
  *,
115
178
  message: str | None = None,
@@ -118,6 +181,7 @@ def run_commit(
118
181
  breaking: bool = False,
119
182
  amend: bool = False,
120
183
  dry_run: bool = False,
184
+ allow_secret: bool = False,
121
185
  as_json: bool = False,
122
186
  ) -> int:
123
187
  """Validate a conventional-commit message and create a GPG-signed commit.
@@ -164,6 +228,10 @@ def run_commit(
164
228
  if not _validate_gpg_ready(root):
165
229
  return 1
166
230
 
231
+ secret_rc = _enforce_secret_guard(root=root, allow_secret=allow_secret, as_json=as_json)
232
+ if secret_rc is not None:
233
+ return secret_rc
234
+
167
235
  if dry_run:
168
236
  if as_json:
169
237
  print_json(ok_envelope(message=full_msg, amend=amend, dry_run=True))
@@ -20,6 +20,7 @@ from .output import (
20
20
  )
21
21
  from .utils.git_output import parse_name_status_entries, status_label
22
22
  from .utils.json_envelope import ok_envelope
23
+ from .utils.secret_scan import secret_scan
23
24
 
24
25
  DiffValue = str | int | bool
25
26
  DiffFileEntry = dict[str, DiffValue]
@@ -414,6 +415,51 @@ def _render_summary_output(
414
415
  return 0
415
416
 
416
417
 
418
+ def _render_secret_scan_output(
419
+ *, root: Path, refspec: str | None, paths: list[str] | None, as_json: bool
420
+ ) -> int:
421
+ """Scan the diff patch for leaked credentials and report findings (opt-in).
422
+
423
+ The diff flags defeat user config that could bypass the scanner
424
+ (``color.ui=always`` ANSI, external/textconv drivers) -- same reasoning as
425
+ the commit-time guard in ``_staged_diff_text``.
426
+ """
427
+ args = ["--no-pager", "diff", "--no-color", "--no-ext-diff", "--no-textconv"]
428
+ if refspec:
429
+ args.append(refspec)
430
+ else:
431
+ args.append("HEAD")
432
+ if paths:
433
+ args.append("--")
434
+ args.extend(paths)
435
+ result = git_run(args, cwd=root, check=False)
436
+ if result.returncode != 0:
437
+ error(t("git_diff_failed", error=result.stderr.strip()))
438
+ return 1
439
+ findings = secret_scan(result.stdout)
440
+ if as_json:
441
+ print_json(ok_envelope(findings=findings, count=len(findings)))
442
+ return 1 if any(f["severity"] == "high" for f in findings) else 0
443
+ if not findings:
444
+ info(t("secret_scan_clean"))
445
+ return 0
446
+ print_header(t("secret_scan_found_header", count=str(len(findings))))
447
+ for f in findings:
448
+ sev = f["severity"]
449
+ line = t(
450
+ "secret_scan_found",
451
+ rule=f["rule"],
452
+ path=f["path"],
453
+ line=str(f["line"]),
454
+ preview=f["preview"],
455
+ )
456
+ if sev == "high":
457
+ error(line)
458
+ else:
459
+ warn(line)
460
+ return 1 if any(f["severity"] == "high" for f in findings) else 0
461
+
462
+
417
463
  def run_diff(
418
464
  *,
419
465
  refspec: str | None = None,
@@ -423,6 +469,7 @@ def run_diff(
423
469
  name_only: bool = False,
424
470
  full: bool = False,
425
471
  summary: bool = False,
472
+ scan_secrets: bool = False,
426
473
  as_json: bool = False,
427
474
  ) -> int:
428
475
  """Render a diff of changed files, a refspec, or a range.
@@ -447,6 +494,9 @@ def run_diff(
447
494
  info(t("no_commits_yet"))
448
495
  return 0
449
496
 
497
+ if scan_secrets:
498
+ return _render_secret_scan_output(root=cwd, refspec=refspec, paths=paths, as_json=as_json)
499
+
450
500
  if summary:
451
501
  numstat_details = _numstat_details(cwd, staged=staged, refspec=refspec, paths=paths)
452
502
  summary_files = list(numstat_details.values())
@@ -0,0 +1,201 @@
1
+ """Detect leaked credentials in diffs before they reach the object database.
2
+
3
+ Scans added lines (`+`, excluding the `+++` header) of a diff for high-signal
4
+ credential patterns. Designed for the commit-time guard (block on high
5
+ severity) and `diff --scan-secrets` (advisory). False-positive avoidance is a
6
+ hard requirement: the ruleset favours prefixed, vendor-documented formats over
7
+ generic "looks like a key" heuristics, so the project's own test corpus scans
8
+ clean.
9
+
10
+ Verified sources (token formats change over time; the GitHub `ghs_` rule in
11
+ particular was rewritten in 2026 to match the new stateless long-form):
12
+ - GitHub credential prefixes and the 2026 stateless `ghs_APPID_JWT` rollout:
13
+ github.blog/changelog/2026-04-24-notice-about-upcoming-new-format-for-github-app-installation-tokens
14
+ and github/docs content/organizations/.../github-credential-types.md
15
+ - Dual `ghs_` regex (legacy 36-char + long-form dotted) ported from
16
+ github/gh-aw PR #34737 (2026-05-25)
17
+ - GitLab PAT length 27-300: docs.gitlab.com/security/tokens/ via GitLab MR 169322
18
+ - AWS access key id format `AKIA[0-9A-Z]{16}`: docs.aws.amazon.com/IAM/latest/.../reference_identifiers.html
19
+ - OpenSSH new-format magic `b3BlbnNzaC1rZXktdjE` (base64 of "openssh-key-v1"):
20
+ protSPEC openssh/PROTOCOL.key
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ from pathlib import Path
27
+ from typing import Literal, TypedDict
28
+
29
+ from ..git import run as git_run
30
+
31
+ SecretSeverity = Literal["high", "medium"]
32
+
33
+ _DIFF_ADDED_LINE_RE = re.compile(r"^\+(?!\+\+)")
34
+ _HUNK_HEADER_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
35
+
36
+
37
+ class SecretScanUnavailable(RuntimeError):
38
+ """Raised when the staged diff cannot be read, so a scan cannot run."""
39
+
40
+
41
+ class SecretFinding(TypedDict):
42
+ """One credential match located in a scanned diff."""
43
+
44
+ rule: str
45
+ path: str
46
+ line: int
47
+ preview: str
48
+ severity: SecretSeverity
49
+
50
+
51
+ # Each rule: (compiled pattern, human rule name, severity). Patterns target the
52
+ # credential itself; surrounding assignment context is handled where it raises
53
+ # precision (e.g. AWS secret key).
54
+ _GITHUB_TOKEN_RE = re.compile(
55
+ r"(?:"
56
+ r"gh[pou]_[A-Za-z0-9]{36,}"
57
+ r"|ghr_[A-Za-z0-9]{36,}"
58
+ r"|ghs_(?:[0-9A-Za-z]{36}(?![0-9A-Za-z._-])|[0-9A-Za-z_-]{10,}(?:\.[0-9A-Za-z_-]{10,}){2,})"
59
+ r"|github_pat_[A-Za-z0-9_]{80,}"
60
+ r")"
61
+ )
62
+
63
+ _RULES: list[tuple[re.Pattern[str], str, SecretSeverity]] = [
64
+ (re.compile(r"AKIA[0-9A-Z]{16}"), "aws_access_key_id", "high"),
65
+ (
66
+ re.compile(r"(?i)aws_secret_access_key['\"]?\s*[:=]\s*['\"]?([A-Za-z0-9/+=]{40})"),
67
+ "aws_secret_access_key",
68
+ "high",
69
+ ),
70
+ (_GITHUB_TOKEN_RE, "github_token", "high"),
71
+ (re.compile(r"glpat-[A-Za-z0-9_-]{20,300}"), "gitlab_pat", "high"),
72
+ (
73
+ re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED |)PRIVATE KEY-----"),
74
+ "private_key_pem",
75
+ "high",
76
+ ),
77
+ (re.compile(r"b3BlbnNzaC1rZXktdjE[A-Za-z0-9+/=]*"), "openssh_private_key", "high"),
78
+ (
79
+ re.compile(
80
+ r"(?i)(?:api[_-]?key|secret|token|password)['\"]?\s*[:=]\s*['\"]([A-Za-z0-9_\-]{24,})['\"]"
81
+ ),
82
+ "generic_secret_assignment",
83
+ "medium",
84
+ ),
85
+ ]
86
+
87
+ _PREVIEW_KEEP_PREFIX = 8
88
+ _PREVIEW_KEEP_SUFFIX = 4
89
+
90
+
91
+ def _redact(match: str) -> str:
92
+ """Truncate a credential for display so the full secret is never printed."""
93
+ if len(match) <= _PREVIEW_KEEP_PREFIX + _PREVIEW_KEEP_SUFFIX:
94
+ return "***"
95
+ return f"{match[:_PREVIEW_KEEP_PREFIX]}...{match[-_PREVIEW_KEEP_SUFFIX:]}"
96
+
97
+
98
+ def _staged_diff_text(root: Path) -> str:
99
+ """Return the full cached diff text.
100
+
101
+ Raises ``SecretScanUnavailable`` when git itself reports a failure, so the
102
+ commit guard fails closed (block) instead of silently treating an
103
+ unscannable index as clean. The diff flags defeat user config that could
104
+ otherwise bypass the scanner: ``--no-color`` strips ANSI that breaks line
105
+ parsing, ``--no-ext-diff`` and ``--no-textconv`` ignore external/textconv
106
+ drivers that could hide added content while still exiting 0.
107
+ """
108
+ result = git_run(
109
+ ["--no-pager", "diff", "--cached", "--no-color", "--no-ext-diff", "--no-textconv"],
110
+ cwd=root,
111
+ check=False,
112
+ )
113
+ if result.returncode != 0:
114
+ raise SecretScanUnavailable(result.stderr.strip() or "git diff --cached failed")
115
+ return result.stdout
116
+
117
+
118
+ def _parse_diff_hunk_for_path(line: str) -> str | None:
119
+ """Return the path from a `+++ <path>` diff header, or None.
120
+
121
+ Handles both the default `+++ b/<path>` prefix and the prefixless form a
122
+ user gets with `diff.noprefix=true`, plus `/dev/null` (file absent on the
123
+ new side of a delete).
124
+ """
125
+ if not line.startswith("+++ "):
126
+ return None
127
+ rest = line[4:]
128
+ if rest == "/dev/null":
129
+ return None
130
+ if rest.startswith("b/"):
131
+ return rest[2:]
132
+ return rest
133
+
134
+
135
+ def secret_scan(diff_text: str) -> list[SecretFinding]:
136
+ """Scan added lines of a unified diff for credential patterns.
137
+
138
+ Only lines introduced by the diff (leading `+`, excluding the `+++` header)
139
+ are inspected, so pre-existing content is never flagged. Reported line
140
+ numbers are the true new-file line numbers parsed from each
141
+ ``@@ -old,+new @@`` hunk header (so a finding points at the exact line to
142
+ remediate even across multi-hunk or delete-heavy diffs). Each match becomes
143
+ a ``SecretFinding`` with a redacted preview.
144
+ """
145
+ findings: list[SecretFinding] = []
146
+ current_path = ""
147
+ line_no = 0
148
+ for raw in diff_text.splitlines():
149
+ path_from_header = _parse_diff_hunk_for_path(raw)
150
+ if path_from_header is not None:
151
+ current_path = path_from_header
152
+ line_no = 0
153
+ continue
154
+ hunk = _HUNK_HEADER_RE.match(raw)
155
+ if hunk:
156
+ # Next context/added line in the new file is +new_start; decrement
157
+ # so the increment below lands on the right number.
158
+ line_no = int(hunk.group(1)) - 1
159
+ continue
160
+ if raw.startswith("-"):
161
+ # Removed lines belong to the old file only and do not advance the
162
+ # new-file line counter.
163
+ continue
164
+ if not _DIFF_ADDED_LINE_RE.match(raw):
165
+ if raw.startswith(" "):
166
+ line_no += 1
167
+ continue
168
+ line_no += 1
169
+ payload = raw[1:]
170
+ for pattern, rule, severity in _RULES:
171
+ for match in pattern.finditer(payload):
172
+ findings.append(
173
+ SecretFinding(
174
+ rule=rule,
175
+ path=current_path,
176
+ line=line_no,
177
+ preview=_redact(match.group(0)),
178
+ severity=severity,
179
+ )
180
+ )
181
+ # Suppress the generic rule when a vendor-specific rule already flagged the
182
+ # same line: "ghp_..." should report github_token (high), not also a noisy
183
+ # medium generic_secret_assignment on top of it.
184
+ specific_keys = {
185
+ (f["path"], f["line"]) for f in findings if f["rule"] != "generic_secret_assignment"
186
+ }
187
+ return [
188
+ f
189
+ for f in findings
190
+ if f["rule"] != "generic_secret_assignment" or (f["path"], f["line"]) not in specific_keys
191
+ ]
192
+
193
+
194
+ def scan_staged_diff(root: Path) -> list[SecretFinding]:
195
+ """Convenience wrapper: scan the index (staged) diff of `root`."""
196
+ return secret_scan(_staged_diff_text(root))
197
+
198
+
199
+ def has_high_severity(findings: list[SecretFinding]) -> bool:
200
+ """Return True if any finding is high severity (commit-blocking)."""
201
+ return any(finding["severity"] == "high" for finding in findings)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gitwise-cli"
3
- version = "0.30.0"
3
+ version = "0.31.0"
4
4
  description = "Python CLI for optimizing git workflows and Claude Code integration"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -58,6 +58,11 @@
58
58
  "type": "boolean",
59
59
  "description": "show without committing",
60
60
  "default": false
61
+ },
62
+ "allow_secret": {
63
+ "type": "boolean",
64
+ "description": "proceed past a high-severity secret finding (with confirmation)",
65
+ "default": false
61
66
  }
62
67
  }
63
68
  }
@@ -57,6 +57,11 @@
57
57
  "description": "show diffstat (default behavior)",
58
58
  "default": false
59
59
  },
60
+ "scan_secrets": {
61
+ "type": "boolean",
62
+ "description": "scan the diff for leaked credentials (advisory, opt-in)",
63
+ "default": false
64
+ },
60
65
  "refspec": {
61
66
  "type": "string",
62
67
  "description": "commit, branch, or range to diff (e.g. HEAD~3, main..feat, main...feat)"
@@ -98,3 +98,61 @@ def test_commit_no_duplicate_type(tmp_git_repo: Path) -> None:
98
98
  r = run_gitwise("commit", "-m", "feat: already typed", "--dry-run", cwd=tmp_git_repo)
99
99
  assert r.returncode == 0
100
100
  assert "feat: feat: already typed" not in r.stdout
101
+
102
+
103
+ # -- secret-scan guard (D3) ---------------------------------------------------
104
+ # Sample secrets are assembled at runtime from fragments so the test source
105
+ # stays scanner-clean; otherwise the commit guard would flag this file.
106
+
107
+
108
+ def _aws_key() -> str:
109
+ return "AK" + "IAIOSFODNN7EXAMPLE"
110
+
111
+
112
+ def test_commit_blocked_on_secret_high(tmp_git_repo: Path) -> None:
113
+ (tmp_git_repo / "config.py").write_text(f"aws_key = '{_aws_key()}'\n")
114
+ _git(["add", "config.py"], tmp_git_repo)
115
+ r = run_gitwise("commit", "-m", "feat: add config", cwd=tmp_git_repo)
116
+ assert r.returncode == 1
117
+ assert "secret" in r.stderr.lower() or "blocked" in r.stderr.lower()
118
+
119
+
120
+ def test_commit_blocked_on_secret_high_json(tmp_git_repo: Path) -> None:
121
+ (tmp_git_repo / "config.py").write_text(f"key = '{_aws_key()}'\n")
122
+ _git(["add", "config.py"], tmp_git_repo)
123
+ r = run_gitwise("commit", "-m", "feat: add config", "--json", cwd=tmp_git_repo)
124
+ assert r.returncode == 1
125
+ import json
126
+
127
+ data = json.loads(r.stdout)
128
+ assert data["errors"][0]["code"] == "secret_leak_high"
129
+
130
+
131
+ def test_commit_allow_secret_json_proceeds(tmp_git_repo: Path) -> None:
132
+ (tmp_git_repo / "config.py").write_text(f"key = '{_aws_key()}'\n")
133
+ _git(["add", "config.py"], tmp_git_repo)
134
+ r = run_gitwise(
135
+ "commit", "-m", "feat: add config", "--allow-secret", "--json", cwd=tmp_git_repo
136
+ )
137
+ assert r.returncode == 0
138
+ # The commit was actually created despite the high-severity finding.
139
+ log = _git(["--no-pager", "log", "--oneline"], tmp_git_repo).stdout.decode()
140
+ assert "add config" in log
141
+
142
+
143
+ def test_commit_warns_on_secret_medium(tmp_git_repo: Path) -> None:
144
+ body = "a" * 30
145
+ (tmp_git_repo / "config.py").write_text(f'api_key = "{body}"\n')
146
+ _git(["add", "config.py"], tmp_git_repo)
147
+ r = run_gitwise("commit", "-m", "feat: add config", cwd=tmp_git_repo)
148
+ assert r.returncode == 0
149
+ assert "warning" in r.stderr.lower() or "secret" in r.stderr.lower()
150
+
151
+
152
+ def test_commit_blocked_on_secret_despite_color_always(tmp_git_repo: Path) -> None:
153
+ """color.ui=always must not bypass the guard via ANSI-wrapped diff lines."""
154
+ _git(["config", "color.ui", "always"], tmp_git_repo)
155
+ (tmp_git_repo / "config.py").write_text(f"key = '{_aws_key()}'\n")
156
+ _git(["add", "config.py"], tmp_git_repo)
157
+ r = run_gitwise("commit", "-m", "feat: add config", cwd=tmp_git_repo)
158
+ assert r.returncode == 1
@@ -325,3 +325,27 @@ def test_diff_binary_lfs_warning_json(tmp_git_repo):
325
325
  data = json.loads(result.stdout)
326
326
  assert "binary_warnings" in data
327
327
  assert len(data["binary_warnings"]) >= 1
328
+
329
+
330
+ # ── D3: --scan-secrets (opt-in advisory scan) ────────────────────────────────
331
+
332
+
333
+ def test_diff_scan_secrets_detects_high(tmp_git_repo):
334
+ """--scan-secrets flags a staged high-severity credential."""
335
+ aws = "AK" + "IAIOSFODNN7EXAMPLE"
336
+ (tmp_git_repo / "config.py").write_text(f"key = '{aws}'\n")
337
+ _git(["add", "config.py"], tmp_git_repo)
338
+ result = _run("diff", "--scan-secrets", "--json", cwd=tmp_git_repo)
339
+ assert result.returncode == 1
340
+ data = json.loads(result.stdout)
341
+ assert data["count"] >= 1
342
+ assert any(f["severity"] == "high" for f in data["findings"])
343
+
344
+
345
+ def test_diff_scan_secrets_clean(tmp_git_repo):
346
+ """--scan-secrets on a clean diff reports no findings and exits 0."""
347
+ (tmp_git_repo / "README.md").write_text("hello world\n")
348
+ _git(["add", "README.md"], tmp_git_repo)
349
+ result = _run("diff", "--scan-secrets", cwd=tmp_git_repo)
350
+ assert result.returncode == 0
351
+ assert "no secrets" in result.stdout.lower()