claude-commit 0.6.0__tar.gz → 0.7.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.
- {claude_commit-0.6.0/src/claude_commit.egg-info → claude_commit-0.7.0}/PKG-INFO +1 -1
- {claude_commit-0.6.0 → claude_commit-0.7.0}/pyproject.toml +1 -1
- {claude_commit-0.6.0 → claude_commit-0.7.0}/src/claude_commit/main.py +89 -57
- {claude_commit-0.6.0 → claude_commit-0.7.0/src/claude_commit.egg-info}/PKG-INFO +1 -1
- {claude_commit-0.6.0 → claude_commit-0.7.0}/src/claude_commit.egg-info/SOURCES.txt +3 -1
- claude_commit-0.7.0/tests/test_config.py +182 -0
- claude_commit-0.7.0/tests/test_main.py +315 -0
- {claude_commit-0.6.0 → claude_commit-0.7.0}/LICENSE +0 -0
- {claude_commit-0.6.0 → claude_commit-0.7.0}/README.md +0 -0
- {claude_commit-0.6.0 → claude_commit-0.7.0}/setup.cfg +0 -0
- {claude_commit-0.6.0 → claude_commit-0.7.0}/src/claude_commit/__init__.py +0 -0
- {claude_commit-0.6.0 → claude_commit-0.7.0}/src/claude_commit/config.py +0 -0
- {claude_commit-0.6.0 → claude_commit-0.7.0}/src/claude_commit.egg-info/dependency_links.txt +0 -0
- {claude_commit-0.6.0 → claude_commit-0.7.0}/src/claude_commit.egg-info/entry_points.txt +0 -0
- {claude_commit-0.6.0 → claude_commit-0.7.0}/src/claude_commit.egg-info/requires.txt +0 -0
- {claude_commit-0.6.0 → claude_commit-0.7.0}/src/claude_commit.egg-info/top_level.txt +0 -0
|
@@ -32,6 +32,75 @@ from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
|
32
32
|
from .config import Config, resolve_alias
|
|
33
33
|
|
|
34
34
|
console = Console()
|
|
35
|
+
error_console = Console(stderr=True)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def extract_commit_message(all_text: list) -> Optional[str]:
|
|
39
|
+
"""Extract commit message from collected AI response text blocks.
|
|
40
|
+
|
|
41
|
+
Looks for the COMMIT_MESSAGE: marker first. Falls back to using the last
|
|
42
|
+
text block that doesn't start with an explanatory prefix.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
all_text: List of text blocks from the AI response.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Extracted commit message or None.
|
|
49
|
+
"""
|
|
50
|
+
full_response = "\n".join(all_text)
|
|
51
|
+
commit_message = None
|
|
52
|
+
|
|
53
|
+
if "COMMIT_MESSAGE:" in full_response:
|
|
54
|
+
parts = full_response.split("COMMIT_MESSAGE:", 1)
|
|
55
|
+
if len(parts) > 1:
|
|
56
|
+
commit_message = parts[1].strip()
|
|
57
|
+
else:
|
|
58
|
+
for text in reversed(all_text):
|
|
59
|
+
text = text.strip()
|
|
60
|
+
if text and not any(
|
|
61
|
+
text.lower().startswith(prefix)
|
|
62
|
+
for prefix in [
|
|
63
|
+
"let me",
|
|
64
|
+
"i'll",
|
|
65
|
+
"i will",
|
|
66
|
+
"now i",
|
|
67
|
+
"first",
|
|
68
|
+
"i can see",
|
|
69
|
+
]
|
|
70
|
+
):
|
|
71
|
+
commit_message = text
|
|
72
|
+
break
|
|
73
|
+
|
|
74
|
+
if commit_message:
|
|
75
|
+
commit_message = clean_markdown_fences(commit_message)
|
|
76
|
+
|
|
77
|
+
return commit_message
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def clean_markdown_fences(text: str) -> str:
|
|
81
|
+
"""Remove markdown code block fences from text, keeping content outside fences.
|
|
82
|
+
|
|
83
|
+
Lines starting with ``` toggle a "code block" state. Lines inside code blocks
|
|
84
|
+
are dropped; lines outside are kept.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
text: Text potentially containing markdown code fences.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Cleaned text with fences and their content removed.
|
|
91
|
+
"""
|
|
92
|
+
lines = text.split("\n")
|
|
93
|
+
cleaned_lines = []
|
|
94
|
+
in_code_block = False
|
|
95
|
+
|
|
96
|
+
for line in lines:
|
|
97
|
+
if line.strip().startswith("```"):
|
|
98
|
+
in_code_block = not in_code_block
|
|
99
|
+
continue
|
|
100
|
+
if not in_code_block:
|
|
101
|
+
cleaned_lines.append(line.rstrip())
|
|
102
|
+
|
|
103
|
+
return "\n".join(cleaned_lines).strip()
|
|
35
104
|
|
|
36
105
|
|
|
37
106
|
SYSTEM_PROMPT = """You are an expert software engineer tasked with analyzing code changes and writing excellent git commit messages.
|
|
@@ -431,48 +500,7 @@ Begin your analysis now.
|
|
|
431
500
|
console.print(f"[cyan]🔄 Turns: {message.num_turns}[/cyan]")
|
|
432
501
|
|
|
433
502
|
if not message.is_error:
|
|
434
|
-
|
|
435
|
-
full_response = "\n".join(all_text)
|
|
436
|
-
|
|
437
|
-
# Look for COMMIT_MESSAGE: marker
|
|
438
|
-
if "COMMIT_MESSAGE:" in full_response:
|
|
439
|
-
# Extract everything after COMMIT_MESSAGE:
|
|
440
|
-
parts = full_response.split("COMMIT_MESSAGE:", 1)
|
|
441
|
-
if len(parts) > 1:
|
|
442
|
-
commit_message = parts[1].strip()
|
|
443
|
-
else:
|
|
444
|
-
# Fallback: try to extract the last meaningful text block
|
|
445
|
-
# Skip explanatory text and get the actual commit message
|
|
446
|
-
for text in reversed(all_text):
|
|
447
|
-
text = text.strip()
|
|
448
|
-
if text and not any(
|
|
449
|
-
text.lower().startswith(prefix)
|
|
450
|
-
for prefix in [
|
|
451
|
-
"let me",
|
|
452
|
-
"i'll",
|
|
453
|
-
"i will",
|
|
454
|
-
"now i",
|
|
455
|
-
"first",
|
|
456
|
-
"i can see",
|
|
457
|
-
]
|
|
458
|
-
):
|
|
459
|
-
commit_message = text
|
|
460
|
-
break
|
|
461
|
-
|
|
462
|
-
# Clean up markdown code blocks if present
|
|
463
|
-
if commit_message:
|
|
464
|
-
lines = commit_message.split("\n")
|
|
465
|
-
cleaned_lines = []
|
|
466
|
-
in_code_block = False
|
|
467
|
-
|
|
468
|
-
for line in lines:
|
|
469
|
-
if line.strip().startswith("```"):
|
|
470
|
-
in_code_block = not in_code_block
|
|
471
|
-
continue
|
|
472
|
-
if not in_code_block:
|
|
473
|
-
cleaned_lines.append(line.rstrip())
|
|
474
|
-
|
|
475
|
-
commit_message = "\n".join(cleaned_lines).strip()
|
|
503
|
+
commit_message = extract_commit_message(all_text)
|
|
476
504
|
|
|
477
505
|
# Make sure progress is stopped before returning
|
|
478
506
|
if progress is not None and task_id is not None:
|
|
@@ -490,23 +518,29 @@ Begin your analysis now.
|
|
|
490
518
|
# Stop progress on error
|
|
491
519
|
if "progress" in locals() and progress is not None:
|
|
492
520
|
progress.stop()
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
"[yellow]📦 Please install it: npm install -g @anthropic-ai/claude-code[/yellow]"
|
|
496
|
-
file=sys.stderr,
|
|
521
|
+
error_console.print("[red]❌ Error: Claude Code CLI not found.[/red]")
|
|
522
|
+
error_console.print(
|
|
523
|
+
"[yellow]📦 Please install it: npm install -g @anthropic-ai/claude-code[/yellow]"
|
|
497
524
|
)
|
|
498
525
|
return None
|
|
499
526
|
except ProcessError as e:
|
|
500
527
|
if "progress" in locals() and progress is not None:
|
|
501
528
|
progress.stop()
|
|
502
|
-
|
|
529
|
+
# If we already extracted a commit message, return it despite the process error
|
|
530
|
+
if commit_message:
|
|
531
|
+
return commit_message
|
|
532
|
+
error_console.print(f"[red]❌ Process error: {e}[/red]")
|
|
503
533
|
if e.stderr:
|
|
504
|
-
|
|
534
|
+
error_console.print(f" stderr: {e.stderr}")
|
|
505
535
|
return None
|
|
506
536
|
except Exception as e:
|
|
507
537
|
if "progress" in locals() and progress is not None:
|
|
508
538
|
progress.stop()
|
|
509
|
-
|
|
539
|
+
# If we already extracted a commit message, return it despite the error
|
|
540
|
+
# (the SDK sometimes raises after the CLI has already completed successfully)
|
|
541
|
+
if commit_message:
|
|
542
|
+
return commit_message
|
|
543
|
+
error_console.print(f"[red]❌ Unexpected error: {e}[/red]")
|
|
510
544
|
if verbose:
|
|
511
545
|
import traceback
|
|
512
546
|
|
|
@@ -947,11 +981,11 @@ Alias Management:
|
|
|
947
981
|
)
|
|
948
982
|
)
|
|
949
983
|
except KeyboardInterrupt:
|
|
950
|
-
|
|
984
|
+
error_console.print("\n[yellow]⚠️ Interrupted by user[/yellow]")
|
|
951
985
|
sys.exit(130)
|
|
952
986
|
|
|
953
987
|
if not commit_message:
|
|
954
|
-
|
|
988
|
+
error_console.print("[red]❌ Failed to generate commit message[/red]")
|
|
955
989
|
sys.exit(1)
|
|
956
990
|
|
|
957
991
|
# Display the generated message with rich formatting
|
|
@@ -975,9 +1009,7 @@ Alias Management:
|
|
|
975
1009
|
pyperclip.copy(commit_message)
|
|
976
1010
|
console.print("\n[green]✅ Commit message copied to clipboard![/green]")
|
|
977
1011
|
except Exception as e:
|
|
978
|
-
|
|
979
|
-
f"\n[yellow]⚠️ Failed to copy to clipboard: {e}[/yellow]", file=sys.stderr
|
|
980
|
-
)
|
|
1012
|
+
error_console.print(f"\n[yellow]⚠️ Failed to copy to clipboard: {e}[/yellow]")
|
|
981
1013
|
|
|
982
1014
|
if args.commit:
|
|
983
1015
|
try:
|
|
@@ -1004,12 +1036,12 @@ Alias Management:
|
|
|
1004
1036
|
if result.stdout:
|
|
1005
1037
|
console.print(result.stdout)
|
|
1006
1038
|
except subprocess.CalledProcessError as e:
|
|
1007
|
-
|
|
1039
|
+
error_console.print(f"\n[red]❌ Failed to commit: {e}[/red]")
|
|
1008
1040
|
if e.stderr:
|
|
1009
|
-
|
|
1041
|
+
error_console.print(e.stderr)
|
|
1010
1042
|
sys.exit(1)
|
|
1011
1043
|
except Exception as e:
|
|
1012
|
-
|
|
1044
|
+
error_console.print(f"\n[red]❌ Unexpected error during commit: {e}[/red]")
|
|
1013
1045
|
sys.exit(1)
|
|
1014
1046
|
else:
|
|
1015
1047
|
# Default: just show the command
|
|
@@ -9,4 +9,6 @@ src/claude_commit.egg-info/SOURCES.txt
|
|
|
9
9
|
src/claude_commit.egg-info/dependency_links.txt
|
|
10
10
|
src/claude_commit.egg-info/entry_points.txt
|
|
11
11
|
src/claude_commit.egg-info/requires.txt
|
|
12
|
-
src/claude_commit.egg-info/top_level.txt
|
|
12
|
+
src/claude_commit.egg-info/top_level.txt
|
|
13
|
+
tests/test_config.py
|
|
14
|
+
tests/test_main.py
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Tests for claude_commit.config — Config class and resolve_alias."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from claude_commit.config import Config, resolve_alias
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
# Config – defaults
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestConfigDefaults:
|
|
17
|
+
def test_new_config_has_default_aliases(self, tmp_path):
|
|
18
|
+
cfg = Config(config_path=tmp_path / "config.json")
|
|
19
|
+
aliases = cfg.list_aliases()
|
|
20
|
+
assert "cc" in aliases
|
|
21
|
+
assert "ccc" in aliases
|
|
22
|
+
assert "cca" in aliases
|
|
23
|
+
|
|
24
|
+
def test_default_alias_values(self, tmp_path):
|
|
25
|
+
cfg = Config(config_path=tmp_path / "config.json")
|
|
26
|
+
assert cfg.get_alias("cc") == ""
|
|
27
|
+
assert cfg.get_alias("ccc") == "--commit"
|
|
28
|
+
assert cfg.get_alias("cca") == "--all"
|
|
29
|
+
assert cfg.get_alias("ccac") == "--all --commit"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Config – set / get / delete / list
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TestConfigAliasOps:
|
|
38
|
+
def test_set_and_get_alias(self, tmp_path):
|
|
39
|
+
cfg = Config(config_path=tmp_path / "config.json")
|
|
40
|
+
cfg.set_alias("myalias", "--all --verbose")
|
|
41
|
+
assert cfg.get_alias("myalias") == "--all --verbose"
|
|
42
|
+
|
|
43
|
+
def test_get_alias_returns_none_for_unknown(self, tmp_path):
|
|
44
|
+
cfg = Config(config_path=tmp_path / "config.json")
|
|
45
|
+
assert cfg.get_alias("nonexistent") is None
|
|
46
|
+
|
|
47
|
+
def test_delete_alias_existing(self, tmp_path):
|
|
48
|
+
cfg = Config(config_path=tmp_path / "config.json")
|
|
49
|
+
cfg.set_alias("tmp", "--verbose")
|
|
50
|
+
assert cfg.delete_alias("tmp") is True
|
|
51
|
+
assert cfg.get_alias("tmp") is None
|
|
52
|
+
|
|
53
|
+
def test_delete_alias_missing(self, tmp_path):
|
|
54
|
+
cfg = Config(config_path=tmp_path / "config.json")
|
|
55
|
+
assert cfg.delete_alias("does_not_exist") is False
|
|
56
|
+
|
|
57
|
+
def test_list_aliases_returns_copy(self, tmp_path):
|
|
58
|
+
cfg = Config(config_path=tmp_path / "config.json")
|
|
59
|
+
aliases = cfg.list_aliases()
|
|
60
|
+
aliases["injected"] = "evil"
|
|
61
|
+
assert cfg.get_alias("injected") is None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Config – first run
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TestConfigFirstRun:
|
|
70
|
+
def test_is_first_run_true_when_no_file(self, tmp_path):
|
|
71
|
+
cfg = Config(config_path=tmp_path / "config.json")
|
|
72
|
+
assert cfg.is_first_run() is True
|
|
73
|
+
|
|
74
|
+
def test_is_first_run_false_after_save(self, tmp_path):
|
|
75
|
+
path = tmp_path / "config.json"
|
|
76
|
+
cfg = Config(config_path=path)
|
|
77
|
+
cfg.mark_first_run_complete()
|
|
78
|
+
assert cfg.is_first_run() is False
|
|
79
|
+
|
|
80
|
+
def test_mark_first_run_creates_file(self, tmp_path):
|
|
81
|
+
path = tmp_path / "subdir" / "config.json"
|
|
82
|
+
cfg = Config(config_path=path)
|
|
83
|
+
cfg.mark_first_run_complete()
|
|
84
|
+
assert path.exists()
|
|
85
|
+
|
|
86
|
+
def test_mark_first_run_idempotent(self, tmp_path):
|
|
87
|
+
path = tmp_path / "config.json"
|
|
88
|
+
cfg = Config(config_path=path)
|
|
89
|
+
cfg.mark_first_run_complete()
|
|
90
|
+
content1 = path.read_text()
|
|
91
|
+
cfg.mark_first_run_complete() # second call should be a no-op
|
|
92
|
+
content2 = path.read_text()
|
|
93
|
+
assert content1 == content2
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# Config – persistence across instances
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TestConfigPersistence:
|
|
102
|
+
def test_alias_persists_across_instances(self, tmp_path):
|
|
103
|
+
path = tmp_path / "config.json"
|
|
104
|
+
cfg1 = Config(config_path=path)
|
|
105
|
+
cfg1.set_alias("persist", "--all --commit")
|
|
106
|
+
|
|
107
|
+
cfg2 = Config(config_path=path)
|
|
108
|
+
assert cfg2.get_alias("persist") == "--all --commit"
|
|
109
|
+
|
|
110
|
+
def test_config_file_is_valid_json(self, tmp_path):
|
|
111
|
+
path = tmp_path / "config.json"
|
|
112
|
+
cfg = Config(config_path=path)
|
|
113
|
+
cfg.set_alias("test", "--verbose")
|
|
114
|
+
data = json.loads(path.read_text())
|
|
115
|
+
assert "aliases" in data
|
|
116
|
+
assert data["aliases"]["test"] == "--verbose"
|
|
117
|
+
|
|
118
|
+
def test_corrupt_config_file_returns_defaults(self, tmp_path):
|
|
119
|
+
path = tmp_path / "config.json"
|
|
120
|
+
path.write_text("not valid json!!!")
|
|
121
|
+
cfg = Config(config_path=path)
|
|
122
|
+
# Should fall back to defaults without raising
|
|
123
|
+
assert "cc" in cfg.list_aliases()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# resolve_alias
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class TestResolveAlias:
|
|
132
|
+
def test_empty_args_returns_empty(self, tmp_path, monkeypatch):
|
|
133
|
+
monkeypatch.setattr(
|
|
134
|
+
"claude_commit.config.Config.__init__",
|
|
135
|
+
lambda self, config_path=None: Config.__init__(
|
|
136
|
+
self, config_path=tmp_path / "config.json"
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
assert resolve_alias([]) == []
|
|
140
|
+
|
|
141
|
+
def test_known_alias_expands(self, tmp_path, monkeypatch):
|
|
142
|
+
path = tmp_path / "config.json"
|
|
143
|
+
cfg = Config(config_path=path)
|
|
144
|
+
cfg.set_alias("myalias", "--all --verbose")
|
|
145
|
+
|
|
146
|
+
# Patch Config() inside resolve_alias to use our tmp config
|
|
147
|
+
original_init = Config.__init__
|
|
148
|
+
|
|
149
|
+
def patched_init(self, config_path=None):
|
|
150
|
+
original_init(self, config_path=path)
|
|
151
|
+
|
|
152
|
+
monkeypatch.setattr("claude_commit.config.Config.__init__", patched_init)
|
|
153
|
+
|
|
154
|
+
result = resolve_alias(["myalias", "--extra"])
|
|
155
|
+
assert result == ["--all", "--verbose", "--extra"]
|
|
156
|
+
|
|
157
|
+
def test_empty_alias_drops_alias_arg(self, tmp_path, monkeypatch):
|
|
158
|
+
path = tmp_path / "config.json"
|
|
159
|
+
cfg = Config(config_path=path)
|
|
160
|
+
cfg.set_alias("bare", "")
|
|
161
|
+
|
|
162
|
+
original_init = Config.__init__
|
|
163
|
+
|
|
164
|
+
def patched_init(self, config_path=None):
|
|
165
|
+
original_init(self, config_path=path)
|
|
166
|
+
|
|
167
|
+
monkeypatch.setattr("claude_commit.config.Config.__init__", patched_init)
|
|
168
|
+
|
|
169
|
+
result = resolve_alias(["bare", "--verbose"])
|
|
170
|
+
assert result == ["--verbose"]
|
|
171
|
+
|
|
172
|
+
def test_unknown_arg_passes_through(self, tmp_path, monkeypatch):
|
|
173
|
+
path = tmp_path / "config.json"
|
|
174
|
+
original_init = Config.__init__
|
|
175
|
+
|
|
176
|
+
def patched_init(self, config_path=None):
|
|
177
|
+
original_init(self, config_path=path)
|
|
178
|
+
|
|
179
|
+
monkeypatch.setattr("claude_commit.config.Config.__init__", patched_init)
|
|
180
|
+
|
|
181
|
+
result = resolve_alias(["--all", "--verbose"])
|
|
182
|
+
assert result == ["--all", "--verbose"]
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Tests for claude_commit.main — pure logic (no Claude / git calls)."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from claude_commit.config import Config
|
|
9
|
+
from claude_commit.main import (
|
|
10
|
+
clean_markdown_fences,
|
|
11
|
+
extract_commit_message,
|
|
12
|
+
handle_alias_command,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# extract_commit_message — COMMIT_MESSAGE: marker
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestExtractCommitMessage:
|
|
22
|
+
def test_extracts_after_marker(self):
|
|
23
|
+
all_text = [
|
|
24
|
+
"Let me analyze the changes.",
|
|
25
|
+
"COMMIT_MESSAGE:\nfeat: add login\n\n- Add JWT auth",
|
|
26
|
+
]
|
|
27
|
+
result = extract_commit_message(all_text)
|
|
28
|
+
assert result is not None
|
|
29
|
+
assert result.startswith("feat: add login")
|
|
30
|
+
assert "- Add JWT auth" in result
|
|
31
|
+
|
|
32
|
+
def test_marker_in_middle_of_text(self):
|
|
33
|
+
all_text = [
|
|
34
|
+
"Analysis done. COMMIT_MESSAGE:\nfix: typo in README",
|
|
35
|
+
]
|
|
36
|
+
result = extract_commit_message(all_text)
|
|
37
|
+
assert result == "fix: typo in README"
|
|
38
|
+
|
|
39
|
+
def test_marker_across_text_blocks(self):
|
|
40
|
+
all_text = [
|
|
41
|
+
"Some analysis",
|
|
42
|
+
"More analysis\nCOMMIT_MESSAGE:\nchore: bump version",
|
|
43
|
+
]
|
|
44
|
+
result = extract_commit_message(all_text)
|
|
45
|
+
assert result == "chore: bump version"
|
|
46
|
+
|
|
47
|
+
def test_only_first_marker_used(self):
|
|
48
|
+
all_text = [
|
|
49
|
+
"COMMIT_MESSAGE:\nfirst message",
|
|
50
|
+
"COMMIT_MESSAGE:\nsecond message",
|
|
51
|
+
]
|
|
52
|
+
result = extract_commit_message(all_text)
|
|
53
|
+
assert "first message" in result
|
|
54
|
+
|
|
55
|
+
def test_returns_none_for_empty_input(self):
|
|
56
|
+
assert extract_commit_message([]) is None
|
|
57
|
+
|
|
58
|
+
def test_returns_none_for_only_explanatory_text(self):
|
|
59
|
+
all_text = [
|
|
60
|
+
"Let me analyze the changes.",
|
|
61
|
+
"I'll look at the diff now.",
|
|
62
|
+
"First, checking git status.",
|
|
63
|
+
]
|
|
64
|
+
result = extract_commit_message(all_text)
|
|
65
|
+
assert result is None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# extract_commit_message — fallback (no marker)
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TestExtractFallback:
|
|
74
|
+
def test_uses_last_non_explanatory_block(self):
|
|
75
|
+
all_text = [
|
|
76
|
+
"Let me analyze.",
|
|
77
|
+
"feat: add new feature",
|
|
78
|
+
"I'll commit this now.",
|
|
79
|
+
]
|
|
80
|
+
# "I'll commit this now." starts with "i'll" → skipped
|
|
81
|
+
# "feat: add new feature" → used
|
|
82
|
+
result = extract_commit_message(all_text)
|
|
83
|
+
assert result == "feat: add new feature"
|
|
84
|
+
|
|
85
|
+
def test_skips_all_known_prefixes(self):
|
|
86
|
+
prefixes = ["let me", "i'll", "i will", "now i", "first", "i can see"]
|
|
87
|
+
for prefix in prefixes:
|
|
88
|
+
all_text = [f"{prefix} do something"]
|
|
89
|
+
result = extract_commit_message(all_text)
|
|
90
|
+
assert result is None, f"Should skip prefix: {prefix}"
|
|
91
|
+
|
|
92
|
+
def test_prefix_check_is_case_insensitive(self):
|
|
93
|
+
all_text = ["Let Me analyze the diff"]
|
|
94
|
+
result = extract_commit_message(all_text)
|
|
95
|
+
assert result is None
|
|
96
|
+
|
|
97
|
+
def test_non_prefix_text_is_accepted(self):
|
|
98
|
+
all_text = ["refactor: clean up imports"]
|
|
99
|
+
result = extract_commit_message(all_text)
|
|
100
|
+
assert result == "refactor: clean up imports"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# clean_markdown_fences
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class TestCleanMarkdownFences:
|
|
109
|
+
def test_no_fences_unchanged(self):
|
|
110
|
+
text = "feat: add login\n\n- Add JWT auth"
|
|
111
|
+
assert clean_markdown_fences(text) == text
|
|
112
|
+
|
|
113
|
+
def test_strips_surrounding_fences(self):
|
|
114
|
+
# Content outside fences is kept; content inside is dropped.
|
|
115
|
+
# So fences around extra examples are removed.
|
|
116
|
+
text = "feat: add login\n\n```\ngit commit -m 'feat: add login'\n```"
|
|
117
|
+
result = clean_markdown_fences(text)
|
|
118
|
+
assert "```" not in result
|
|
119
|
+
assert "feat: add login" in result
|
|
120
|
+
assert "git commit" not in result
|
|
121
|
+
|
|
122
|
+
def test_strips_fences_with_language_tag(self):
|
|
123
|
+
text = "fix: typo\n\n```bash\nsome command\n```"
|
|
124
|
+
result = clean_markdown_fences(text)
|
|
125
|
+
assert "```" not in result
|
|
126
|
+
assert "some command" not in result
|
|
127
|
+
assert "fix: typo" in result
|
|
128
|
+
|
|
129
|
+
def test_multiple_fenced_blocks(self):
|
|
130
|
+
text = "header\n```\nblock1\n```\nmiddle\n```\nblock2\n```\nfooter"
|
|
131
|
+
result = clean_markdown_fences(text)
|
|
132
|
+
assert result == "header\nmiddle\nfooter"
|
|
133
|
+
|
|
134
|
+
def test_empty_string(self):
|
|
135
|
+
assert clean_markdown_fences("") == ""
|
|
136
|
+
|
|
137
|
+
def test_only_fences(self):
|
|
138
|
+
assert clean_markdown_fences("```\nhello\n```") == ""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# handle_alias_command
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class TestHandleAliasCommand:
|
|
147
|
+
def _make_config(self, tmp_path):
|
|
148
|
+
"""Create a Config with a temp path and patch Config() calls."""
|
|
149
|
+
return Config(config_path=tmp_path / "config.json")
|
|
150
|
+
|
|
151
|
+
def test_list_aliases(self, tmp_path, capsys, monkeypatch):
|
|
152
|
+
path = tmp_path / "config.json"
|
|
153
|
+
original_init = Config.__init__
|
|
154
|
+
|
|
155
|
+
def patched_init(self, config_path=None):
|
|
156
|
+
original_init(self, config_path=path)
|
|
157
|
+
|
|
158
|
+
monkeypatch.setattr(Config, "__init__", patched_init)
|
|
159
|
+
|
|
160
|
+
handle_alias_command(["list"])
|
|
161
|
+
captured = capsys.readouterr()
|
|
162
|
+
assert "Configured aliases" in captured.out
|
|
163
|
+
assert "ccc" in captured.out
|
|
164
|
+
|
|
165
|
+
def test_list_aliases_default(self, tmp_path, capsys, monkeypatch):
|
|
166
|
+
"""Calling with no args defaults to list."""
|
|
167
|
+
path = tmp_path / "config.json"
|
|
168
|
+
original_init = Config.__init__
|
|
169
|
+
|
|
170
|
+
def patched_init(self, config_path=None):
|
|
171
|
+
original_init(self, config_path=path)
|
|
172
|
+
|
|
173
|
+
monkeypatch.setattr(Config, "__init__", patched_init)
|
|
174
|
+
|
|
175
|
+
handle_alias_command([])
|
|
176
|
+
captured = capsys.readouterr()
|
|
177
|
+
assert "Configured aliases" in captured.out
|
|
178
|
+
|
|
179
|
+
def test_set_alias(self, tmp_path, capsys, monkeypatch):
|
|
180
|
+
path = tmp_path / "config.json"
|
|
181
|
+
original_init = Config.__init__
|
|
182
|
+
|
|
183
|
+
def patched_init(self, config_path=None):
|
|
184
|
+
original_init(self, config_path=path)
|
|
185
|
+
|
|
186
|
+
monkeypatch.setattr(Config, "__init__", patched_init)
|
|
187
|
+
|
|
188
|
+
handle_alias_command(["set", "myalias", "--all", "--verbose"])
|
|
189
|
+
captured = capsys.readouterr()
|
|
190
|
+
assert "myalias" in captured.out
|
|
191
|
+
|
|
192
|
+
# Verify it was persisted
|
|
193
|
+
cfg = Config(config_path=path)
|
|
194
|
+
assert cfg.get_alias("myalias") == "--all --verbose"
|
|
195
|
+
|
|
196
|
+
def test_set_alias_missing_name(self, tmp_path, monkeypatch):
|
|
197
|
+
path = tmp_path / "config.json"
|
|
198
|
+
original_init = Config.__init__
|
|
199
|
+
|
|
200
|
+
def patched_init(self, config_path=None):
|
|
201
|
+
original_init(self, config_path=path)
|
|
202
|
+
|
|
203
|
+
monkeypatch.setattr(Config, "__init__", patched_init)
|
|
204
|
+
|
|
205
|
+
with pytest.raises(SystemExit):
|
|
206
|
+
handle_alias_command(["set"])
|
|
207
|
+
|
|
208
|
+
def test_unset_alias(self, tmp_path, capsys, monkeypatch):
|
|
209
|
+
path = tmp_path / "config.json"
|
|
210
|
+
cfg = Config(config_path=path)
|
|
211
|
+
cfg.set_alias("removeme", "--verbose")
|
|
212
|
+
|
|
213
|
+
original_init = Config.__init__
|
|
214
|
+
|
|
215
|
+
def patched_init(self, config_path=None):
|
|
216
|
+
original_init(self, config_path=path)
|
|
217
|
+
|
|
218
|
+
monkeypatch.setattr(Config, "__init__", patched_init)
|
|
219
|
+
|
|
220
|
+
handle_alias_command(["unset", "removeme"])
|
|
221
|
+
captured = capsys.readouterr()
|
|
222
|
+
assert "removeme" in captured.out
|
|
223
|
+
assert "removed" in captured.out
|
|
224
|
+
|
|
225
|
+
def test_unset_alias_not_found(self, tmp_path, monkeypatch):
|
|
226
|
+
path = tmp_path / "config.json"
|
|
227
|
+
original_init = Config.__init__
|
|
228
|
+
|
|
229
|
+
def patched_init(self, config_path=None):
|
|
230
|
+
original_init(self, config_path=path)
|
|
231
|
+
|
|
232
|
+
monkeypatch.setattr(Config, "__init__", patched_init)
|
|
233
|
+
|
|
234
|
+
with pytest.raises(SystemExit):
|
|
235
|
+
handle_alias_command(["unset", "ghost"])
|
|
236
|
+
|
|
237
|
+
def test_unknown_subcommand(self, tmp_path, monkeypatch):
|
|
238
|
+
path = tmp_path / "config.json"
|
|
239
|
+
original_init = Config.__init__
|
|
240
|
+
|
|
241
|
+
def patched_init(self, config_path=None):
|
|
242
|
+
original_init(self, config_path=path)
|
|
243
|
+
|
|
244
|
+
monkeypatch.setattr(Config, "__init__", patched_init)
|
|
245
|
+
|
|
246
|
+
with pytest.raises(SystemExit):
|
|
247
|
+
handle_alias_command(["bogus"])
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
# Argument parsing
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class TestArgumentParsing:
|
|
256
|
+
"""Test argparse defaults and flag behaviour by importing the parser setup."""
|
|
257
|
+
|
|
258
|
+
def _parse(self, args):
|
|
259
|
+
"""Build the parser identical to main() and parse args."""
|
|
260
|
+
import argparse
|
|
261
|
+
from pathlib import Path
|
|
262
|
+
|
|
263
|
+
parser = argparse.ArgumentParser()
|
|
264
|
+
parser.add_argument("-a", "--all", action="store_true")
|
|
265
|
+
parser.add_argument("-v", "--verbose", action="store_true")
|
|
266
|
+
parser.add_argument("-p", "--path", type=Path, default=None)
|
|
267
|
+
parser.add_argument("--max-diff-lines", type=int, default=500)
|
|
268
|
+
parser.add_argument("-c", "--commit", action="store_true")
|
|
269
|
+
parser.add_argument("--copy", action="store_true")
|
|
270
|
+
parser.add_argument("--preview", action="store_true")
|
|
271
|
+
return parser.parse_args(args)
|
|
272
|
+
|
|
273
|
+
def test_defaults(self):
|
|
274
|
+
ns = self._parse([])
|
|
275
|
+
assert ns.all is False
|
|
276
|
+
assert ns.verbose is False
|
|
277
|
+
assert ns.path is None
|
|
278
|
+
assert ns.max_diff_lines == 500
|
|
279
|
+
assert ns.commit is False
|
|
280
|
+
assert ns.copy is False
|
|
281
|
+
assert ns.preview is False
|
|
282
|
+
|
|
283
|
+
def test_all_flag(self):
|
|
284
|
+
for flag in ["-a", "--all"]:
|
|
285
|
+
ns = self._parse([flag])
|
|
286
|
+
assert ns.all is True
|
|
287
|
+
|
|
288
|
+
def test_verbose_flag(self):
|
|
289
|
+
for flag in ["-v", "--verbose"]:
|
|
290
|
+
ns = self._parse([flag])
|
|
291
|
+
assert ns.verbose is True
|
|
292
|
+
|
|
293
|
+
def test_commit_flag(self):
|
|
294
|
+
for flag in ["-c", "--commit"]:
|
|
295
|
+
ns = self._parse([flag])
|
|
296
|
+
assert ns.commit is True
|
|
297
|
+
|
|
298
|
+
def test_copy_flag(self):
|
|
299
|
+
ns = self._parse(["--copy"])
|
|
300
|
+
assert ns.copy is True
|
|
301
|
+
|
|
302
|
+
def test_preview_flag(self):
|
|
303
|
+
ns = self._parse(["--preview"])
|
|
304
|
+
assert ns.preview is True
|
|
305
|
+
|
|
306
|
+
def test_max_diff_lines(self):
|
|
307
|
+
ns = self._parse(["--max-diff-lines", "1000"])
|
|
308
|
+
assert ns.max_diff_lines == 1000
|
|
309
|
+
|
|
310
|
+
def test_combined_flags(self):
|
|
311
|
+
ns = self._parse(["-a", "-v", "-c", "--max-diff-lines", "200"])
|
|
312
|
+
assert ns.all is True
|
|
313
|
+
assert ns.verbose is True
|
|
314
|
+
assert ns.commit is True
|
|
315
|
+
assert ns.max_diff_lines == 200
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|