Skip to content

Commit e1f5207

Browse files
committed
✨ feat(commit): add secret detection support
1 parent 66d4d45 commit e1f5207

File tree

4 files changed

+386
-266
lines changed

4 files changed

+386
-266
lines changed

tests/unit/test_commit.py

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from tgit.commit import (
1111
CommitArgs,
1212
CommitData,
13+
PotentialSecret,
1314
TemplateParams,
1415
get_changed_files_from_status,
1516
get_file_change_sizes,
@@ -61,17 +62,24 @@ class TestCommitData:
6162

6263
def test_commit_data_creation(self):
6364
"""Test creating CommitData instance."""
64-
data = CommitData(type="feat", scope="auth", msg="add login functionality", is_breaking=False)
65+
data = CommitData(type="feat", scope="auth", msg="add login functionality", is_breaking=False, secrets=[])
6566
assert data.type == "feat"
6667
assert data.scope == "auth"
6768
assert data.msg == "add login functionality"
6869
assert data.is_breaking is False
6970

7071
def test_commit_data_with_none_scope(self):
7172
"""Test CommitData with None scope."""
72-
data = CommitData(type="fix", scope=None, msg="fix bug", is_breaking=False)
73+
data = CommitData(type="fix", scope=None, msg="fix bug", is_breaking=False, secrets=[])
7374
assert data.scope is None
7475

76+
def test_commit_data_with_secrets(self):
77+
"""Test CommitData with suspected secrets."""
78+
secret = PotentialSecret(file="config.env", description="looks like api key")
79+
data = CommitData(type="chore", scope=None, msg="update config", is_breaking=False, secrets=[secret])
80+
assert len(data.secrets) == 1
81+
assert data.secrets[0].file == "config.env"
82+
7583

7684
class TestGetChangedFilesFromStatus:
7785
"""Test get_changed_files_from_status function."""
@@ -296,7 +304,7 @@ def test_generate_commit_with_ai_success(self, mock_settings, mock_template, moc
296304

297305
# Mock the response
298306
mock_response = Mock()
299-
mock_commit_data = CommitData(type="feat", scope="auth", msg="add login", is_breaking=False)
307+
mock_commit_data = CommitData(type="feat", scope="auth", msg="add login", is_breaking=False, secrets=[])
300308
mock_response.output_parsed = mock_commit_data
301309
mock_client.responses.parse.return_value = mock_response
302310

@@ -334,7 +342,7 @@ def test_generate_commit_with_ai_reasoning_model(self, mock_settings, mock_templ
334342
mock_settings.model = "o1-mini"
335343

336344
mock_response = Mock()
337-
mock_commit_data = CommitData(type="fix", scope=None, msg="correct bug", is_breaking=False)
345+
mock_commit_data = CommitData(type="fix", scope=None, msg="correct bug", is_breaking=False, secrets=[])
338346
mock_response.output_parsed = mock_commit_data
339347
mock_client.responses.parse.return_value = mock_response
340348

@@ -396,7 +404,7 @@ def test_get_ai_command_success(self, mock_settings, mock_get_commit_command, mo
396404
mock_repo_instance.active_branch.name = "main"
397405
mock_settings.commit.emoji = True
398406

399-
mock_commit_data = CommitData(type="feat", scope="auth", msg="add login", is_breaking=False)
407+
mock_commit_data = CommitData(type="feat", scope="auth", msg="add login", is_breaking=False, secrets=[])
400408
mock_generate.return_value = mock_commit_data
401409
mock_get_commit_command.return_value = "git commit -m 'feat(auth): add login'"
402410

@@ -406,6 +414,63 @@ def test_get_ai_command_success(self, mock_settings, mock_get_commit_command, mo
406414
mock_generate.assert_called_once()
407415
mock_get_commit_command.assert_called_once_with("feat", "auth", "add login", use_emoji=True, is_breaking=False)
408416

417+
@patch("tgit.commit.click.confirm")
418+
@patch("tgit.commit.Path.cwd")
419+
@patch("tgit.commit.git.Repo")
420+
@patch("tgit.commit.get_filtered_diff_files")
421+
@patch("tgit.commit._generate_commit_with_ai")
422+
@patch("tgit.commit.get_commit_command")
423+
@patch("tgit.commit.settings")
424+
def test_get_ai_command_detected_secrets_abort(self, mock_settings, mock_get_commit_command, mock_generate, mock_get_files, mock_repo, mock_cwd, mock_confirm):
425+
"""Test get_ai_command aborts when secrets are detected and user declines."""
426+
mock_cwd.return_value = Path(tempfile.gettempdir())
427+
mock_repo_instance = Mock()
428+
mock_repo.return_value = mock_repo_instance
429+
mock_get_files.return_value = (["src/file.py"], [])
430+
mock_repo_instance.git.diff.return_value = "diff content"
431+
mock_repo_instance.active_branch.name = "main"
432+
mock_settings.commit.emoji = True
433+
434+
secret = PotentialSecret(file="src/file.py", description="possible api key")
435+
mock_commit_data = CommitData(type="feat", scope="auth", msg="add login", is_breaking=False, secrets=[secret])
436+
mock_generate.return_value = mock_commit_data
437+
mock_confirm.return_value = False
438+
439+
result = get_ai_command()
440+
441+
assert result is None
442+
mock_confirm.assert_called_once_with("Detected potential secrets. Continue with commit?", default=False)
443+
mock_get_commit_command.assert_not_called()
444+
445+
@patch("tgit.commit.click.confirm")
446+
@patch("tgit.commit.Path.cwd")
447+
@patch("tgit.commit.git.Repo")
448+
@patch("tgit.commit.get_filtered_diff_files")
449+
@patch("tgit.commit._generate_commit_with_ai")
450+
@patch("tgit.commit.get_commit_command")
451+
@patch("tgit.commit.settings")
452+
def test_get_ai_command_detected_secrets_continue(self, mock_settings, mock_get_commit_command, mock_generate, mock_get_files, mock_repo, mock_cwd, mock_confirm):
453+
"""Test get_ai_command continues when secrets are detected and user agrees."""
454+
mock_cwd.return_value = Path(tempfile.gettempdir())
455+
mock_repo_instance = Mock()
456+
mock_repo.return_value = mock_repo_instance
457+
mock_get_files.return_value = (["src/file.py"], [])
458+
mock_repo_instance.git.diff.return_value = "diff content"
459+
mock_repo_instance.active_branch.name = "main"
460+
mock_settings.commit.emoji = True
461+
462+
secret = PotentialSecret(file="src/file.py", description="possible api key")
463+
mock_commit_data = CommitData(type="feat", scope="auth", msg="add login", is_breaking=False, secrets=[secret])
464+
mock_generate.return_value = mock_commit_data
465+
mock_get_commit_command.return_value = "git commit -m 'feat(auth): add login'"
466+
mock_confirm.return_value = True
467+
468+
result = get_ai_command()
469+
470+
assert result == "git commit -m 'feat(auth): add login'"
471+
mock_confirm.assert_called_once_with("Detected potential secrets. Continue with commit?", default=False)
472+
mock_get_commit_command.assert_called_once_with("feat", "auth", "add login", use_emoji=True, is_breaking=False)
473+
409474
@patch("tgit.commit.Path.cwd")
410475
@patch("tgit.commit.git.Repo")
411476
@patch("tgit.commit.get_filtered_diff_files")

tgit/commit.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import click
99
import git
1010
from jinja2 import Environment, FileSystemLoader
11-
from pydantic import BaseModel
11+
from pydantic import BaseModel, Field
1212
from rich import get_console, print
1313

1414
from tgit.constants import DEFAULT_MODEL, REASONING_MODEL_HINTS
@@ -65,11 +65,18 @@ class TemplateParams:
6565
branch: str
6666
specified_type: str | None = None
6767

68+
69+
class PotentialSecret(BaseModel):
70+
file: str
71+
description: str
72+
73+
6874
class CommitData(BaseModel):
6975
type: str
7076
scope: str | None = None
7177
msg: str
7278
is_breaking: bool = False
79+
secrets: list[PotentialSecret] = Field(default_factory=list)
7380

7481

7582
def _supports_reasoning(model: str) -> bool:
@@ -243,6 +250,16 @@ def get_ai_command(specified_type: str | None = None) -> str | None:
243250
print(e)
244251
return None
245252

253+
detected_secrets: list[PotentialSecret] = resp.secrets if resp.secrets else []
254+
if detected_secrets:
255+
print("[red]Detected potential secrets in these files:[/red]")
256+
for secret in detected_secrets:
257+
print(f"[red]- {secret.file}: {secret.description}[/red]")
258+
proceed = click.confirm("Detected potential secrets. Continue with commit?", default=False)
259+
if not proceed:
260+
print("[yellow]Commit aborted. Please review sensitive content.[/yellow]")
261+
return None
262+
246263
# 如果用户指定了类型,则使用用户指定的类型,否则使用 AI 生成的类型
247264
commit_type = specified_type if specified_type is not None else resp.type
248265

tgit/prompts/commit.txt

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,18 @@ Mark `is_breaking: true` only if changes:
3434
- Require user action for compatibility
3535
- Remove or significantly change existing functionality
3636

37+
### Secret Detection
38+
- Review the diff for potential secrets (API keys, tokens, passwords, private keys, credentials, etc.).
39+
- For every suspected secret, add an entry to the `secrets` array with the file path and a brief description of why it is sensitive.
40+
- If no secrets are found, return an empty array.
41+
3742
## Analysis Process
3843
1. Review the diff comprehensively
3944
2. Identify the primary type of change
4045
3. Determine appropriate scope from modified files/areas
4146
4. Craft a concise message covering main changes
4247
5. Assess backward compatibility impact
48+
6. Flag any suspected secrets
4349

4450
## Output Format
4551
Return valid JSON matching this structure:
@@ -48,15 +54,22 @@ Return valid JSON matching this structure:
4854
"type": "string",
4955
"scope": "string|null",
5056
"msg": "string",
51-
"is_breaking": "boolean"
57+
"is_breaking": "boolean",
58+
"secrets": [
59+
{
60+
"file": "string",
61+
"description": "string"
62+
}
63+
]
5264
}
5365
```
5466

5567
## Examples
5668
```json
57-
{"type": "feat", "scope": "auth", "msg": "add oauth2 login", "is_breaking": false}
58-
{"type": "fix", "scope": "api", "msg": "handle null user responses", "is_breaking": false}
59-
{"type": "refactor", "scope": null, "msg": "restructure project layout", "is_breaking": true}
69+
{"type": "feat", "scope": "auth", "msg": "add oauth2 login", "is_breaking": false, "secrets": []}
70+
{"type": "fix", "scope": "api", "msg": "handle null user responses", "is_breaking": false, "secrets": []}
71+
{"type": "refactor", "scope": null, "msg": "restructure project layout", "is_breaking": true, "secrets": []}
72+
{"type": "chore", "scope": "config", "msg": "update secrets storage", "is_breaking": false, "secrets": [{"file": "config/.env", "description": "detected value resembling api key"}]}
6073
```
6174

62-
Now analyze the provided diff and generate the commit message.
75+
Now analyze the provided diff and generate the commit message.

0 commit comments

Comments
 (0)