claude-commit 0.6.0__tar.gz → 0.8.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claude-commit
3
- Version: 0.6.0
3
+ Version: 0.8.0
4
4
  Summary: AI-powered git commit message generator using Claude Agent SDK
5
5
  Author-email: Johannlai <johannli666@gmail.com>
6
6
  License: MIT
@@ -35,10 +35,6 @@ Dynamic: license-file
35
35
 
36
36
  `claude-commit` uses Claude AI to analyze your code changes and write meaningful commit messages. Claude reads your files, understands the context, and generates commit messages following best practices.
37
37
 
38
- ## Demo
39
-
40
- [![asciicast](https://asciinema.org/a/ZubvhPFyP7hPFLsqZiZUc930L.svg)](https://asciinema.org/a/ZubvhPFyP7hPFLsqZiZUc930L?autoplay=1&speed=3)
41
-
42
38
  ## Installation
43
39
 
44
40
  ### Prerequisites
@@ -181,12 +177,12 @@ claude-commit alias unset quick
181
177
 
182
178
  ## How It Works
183
179
 
184
- Claude autonomously analyzes your changes:
180
+ Claude autonomously analyzes your changes using [subagents](https://platform.claude.com/docs/agent-sdk/subagents) for parallel analysis:
185
181
 
186
- 1. **Reads** your modified files to understand context
187
- 2. **Searches** the codebase for related code
188
- 3. **Understands** the intent and impact of changes
189
- 4. **Generates** a clear commit message following conventions
182
+ 1. **Detects style** a lightweight subagent checks git history for commit conventions (conventional commits, gitmoji, language, etc.)
183
+ 2. **Analyzes changes** a dedicated subagent reads modified files, searches for context, and understands the intent
184
+ 3. **Runs in parallel** both subagents execute concurrently for faster results
185
+ 4. **Generates** a clear commit message combining style and change analysis
190
186
 
191
187
  **Example:**
192
188
  ```
@@ -6,10 +6,6 @@
6
6
 
7
7
  `claude-commit` uses Claude AI to analyze your code changes and write meaningful commit messages. Claude reads your files, understands the context, and generates commit messages following best practices.
8
8
 
9
- ## Demo
10
-
11
- [![asciicast](https://asciinema.org/a/ZubvhPFyP7hPFLsqZiZUc930L.svg)](https://asciinema.org/a/ZubvhPFyP7hPFLsqZiZUc930L?autoplay=1&speed=3)
12
-
13
9
  ## Installation
14
10
 
15
11
  ### Prerequisites
@@ -152,12 +148,12 @@ claude-commit alias unset quick
152
148
 
153
149
  ## How It Works
154
150
 
155
- Claude autonomously analyzes your changes:
151
+ Claude autonomously analyzes your changes using [subagents](https://platform.claude.com/docs/agent-sdk/subagents) for parallel analysis:
156
152
 
157
- 1. **Reads** your modified files to understand context
158
- 2. **Searches** the codebase for related code
159
- 3. **Understands** the intent and impact of changes
160
- 4. **Generates** a clear commit message following conventions
153
+ 1. **Detects style** a lightweight subagent checks git history for commit conventions (conventional commits, gitmoji, language, etc.)
154
+ 2. **Analyzes changes** a dedicated subagent reads modified files, searches for context, and understands the intent
155
+ 3. **Runs in parallel** both subagents execute concurrently for faster results
156
+ 4. **Generates** a clear commit message combining style and change analysis
161
157
 
162
158
  **Example:**
163
159
  ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "claude-commit"
7
- version = "0.6.0"
7
+ version = "0.8.0"
8
8
  description = "AI-powered git commit message generator using Claude Agent SDK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -15,6 +15,7 @@ from typing import Optional
15
15
 
16
16
  import pyperclip
17
17
  from claude_agent_sdk import (
18
+ AgentDefinition,
18
19
  AssistantMessage,
19
20
  ClaudeAgentOptions,
20
21
  CLINotFoundError,
@@ -32,6 +33,75 @@ from rich.progress import Progress, SpinnerColumn, TextColumn
32
33
  from .config import Config, resolve_alias
33
34
 
34
35
  console = Console()
36
+ error_console = Console(stderr=True)
37
+
38
+
39
+ def extract_commit_message(all_text: list) -> Optional[str]:
40
+ """Extract commit message from collected AI response text blocks.
41
+
42
+ Looks for the COMMIT_MESSAGE: marker first. Falls back to using the last
43
+ text block that doesn't start with an explanatory prefix.
44
+
45
+ Args:
46
+ all_text: List of text blocks from the AI response.
47
+
48
+ Returns:
49
+ Extracted commit message or None.
50
+ """
51
+ full_response = "\n".join(all_text)
52
+ commit_message = None
53
+
54
+ if "COMMIT_MESSAGE:" in full_response:
55
+ parts = full_response.split("COMMIT_MESSAGE:", 1)
56
+ if len(parts) > 1:
57
+ commit_message = parts[1].strip()
58
+ else:
59
+ for text in reversed(all_text):
60
+ text = text.strip()
61
+ if text and not any(
62
+ text.lower().startswith(prefix)
63
+ for prefix in [
64
+ "let me",
65
+ "i'll",
66
+ "i will",
67
+ "now i",
68
+ "first",
69
+ "i can see",
70
+ ]
71
+ ):
72
+ commit_message = text
73
+ break
74
+
75
+ if commit_message:
76
+ commit_message = clean_markdown_fences(commit_message)
77
+
78
+ return commit_message
79
+
80
+
81
+ def clean_markdown_fences(text: str) -> str:
82
+ """Remove markdown code block fences from text, keeping content outside fences.
83
+
84
+ Lines starting with ``` toggle a "code block" state. Lines inside code blocks
85
+ are dropped; lines outside are kept.
86
+
87
+ Args:
88
+ text: Text potentially containing markdown code fences.
89
+
90
+ Returns:
91
+ Cleaned text with fences and their content removed.
92
+ """
93
+ lines = text.split("\n")
94
+ cleaned_lines = []
95
+ in_code_block = False
96
+
97
+ for line in lines:
98
+ if line.strip().startswith("```"):
99
+ in_code_block = not in_code_block
100
+ continue
101
+ if not in_code_block:
102
+ cleaned_lines.append(line.rstrip())
103
+
104
+ return "\n".join(cleaned_lines).strip()
35
105
 
36
106
 
37
107
  SYSTEM_PROMPT = """You are an expert software engineer tasked with analyzing code changes and writing excellent git commit messages.
@@ -82,6 +152,20 @@ You have access to these tools for analyzing the codebase:
82
152
  **Pro tip**: Grep is faster than reading entire files. Use it to quickly assess impact before deciding which files to read in detail.
83
153
  </available_tools>
84
154
 
155
+ <subagents>
156
+ You have access to specialized subagents via the **Task** tool. Use them to parallelize your analysis for faster results.
157
+
158
+ Available subagents:
159
+ - **style-detector**: Detects commit message style/conventions from git history. Use this to check commit style while you analyze changes in parallel.
160
+ - **diff-analyzer**: Deep-dives into code changes, reads files, greps for context. Use this for understanding complex or large diffs.
161
+
162
+ **Parallelization strategy**: When analyzing a repository, launch subagents in parallel to speed things up. For example:
163
+ - Launch `style-detector` AND `diff-analyzer` simultaneously in a single response
164
+ - Combine their results to generate the final commit message
165
+
166
+ To invoke a subagent, use the Task tool with `subagent_type` set to the agent name.
167
+ </subagents>
168
+
85
169
  <analysis_approach>
86
170
  Follow this approach (you decide what's necessary based on the changes):
87
171
 
@@ -299,7 +383,43 @@ Begin your analysis now.
299
383
  "Grep", # Search patterns in files (POWERFUL!)
300
384
  "Glob", # Find files by pattern
301
385
  "Edit", # Make precise edits to files (useful for analyzing multi-line changes)
386
+ "Task", # Invoke subagents for parallel analysis
302
387
  ],
388
+ agents={
389
+ "style-detector": AgentDefinition(
390
+ description="Detect commit message style and conventions from git history. Use this to determine the project's commit format (conventional commits, gitmoji, language, etc.) by examining recent commits.",
391
+ prompt="""You are a commit style detector. Your ONLY job is to analyze the git commit history and report the style conventions used.
392
+
393
+ Steps:
394
+ 1. Run: git log -10 --oneline
395
+ 2. Analyze the output for:
396
+ - Format: conventional commits (feat:, fix:), gitmoji, plain text, etc.
397
+ - Language: English, Chinese, or other
398
+ - Emoji usage: gitmoji style or none
399
+ - Any other patterns (scope, capitalization, etc.)
400
+
401
+ Output a concise summary of the detected style, for example:
402
+ "Conventional commits format, English, no emoji. Example: feat: add user auth"
403
+ """,
404
+ tools=["Bash"],
405
+ model="haiku",
406
+ ),
407
+ "diff-analyzer": AgentDefinition(
408
+ description="Deep-dive into code changes to understand what changed and why. Use this for analyzing complex or large diffs, reading modified files, and understanding code relationships.",
409
+ prompt="""You are a code change analyzer. Your job is to deeply understand what changed in the repository and WHY.
410
+
411
+ Steps:
412
+ 1. Run git status and git diff (or git diff --cached for staged changes) to see what changed
413
+ 2. For significant changes, READ the modified files to understand context
414
+ 3. Use GREP to understand code relationships (where functions are called, imports, etc.)
415
+ 4. Summarize: what changed, why it changed, and the impact
416
+
417
+ Output a clear, structured summary of the changes suitable for writing a commit message.
418
+ """,
419
+ tools=["Bash", "Read", "Grep", "Glob"],
420
+ model="sonnet",
421
+ ),
422
+ },
303
423
  permission_mode="acceptEdits",
304
424
  cwd=str(repo_path.absolute()),
305
425
  max_turns=30,
@@ -431,48 +551,7 @@ Begin your analysis now.
431
551
  console.print(f"[cyan]🔄 Turns: {message.num_turns}[/cyan]")
432
552
 
433
553
  if not message.is_error:
434
- # Extract commit message from COMMIT_MESSAGE: marker
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()
554
+ commit_message = extract_commit_message(all_text)
476
555
 
477
556
  # Make sure progress is stopped before returning
478
557
  if progress is not None and task_id is not None:
@@ -490,23 +569,29 @@ Begin your analysis now.
490
569
  # Stop progress on error
491
570
  if "progress" in locals() and progress is not None:
492
571
  progress.stop()
493
- console.print("[red]❌ Error: Claude Code CLI not found.[/red]", file=sys.stderr)
494
- console.print(
495
- "[yellow]📦 Please install it: npm install -g @anthropic-ai/claude-code[/yellow]",
496
- file=sys.stderr,
572
+ error_console.print("[red]❌ Error: Claude Code CLI not found.[/red]")
573
+ error_console.print(
574
+ "[yellow]📦 Please install it: npm install -g @anthropic-ai/claude-code[/yellow]"
497
575
  )
498
576
  return None
499
577
  except ProcessError as e:
500
578
  if "progress" in locals() and progress is not None:
501
579
  progress.stop()
502
- console.print(f"[red]❌ Process error: {e}[/red]", file=sys.stderr)
580
+ # If we already extracted a commit message, return it despite the process error
581
+ if commit_message:
582
+ return commit_message
583
+ error_console.print(f"[red]❌ Process error: {e}[/red]")
503
584
  if e.stderr:
504
- console.print(f" stderr: {e.stderr}", file=sys.stderr)
585
+ error_console.print(f" stderr: {e.stderr}")
505
586
  return None
506
587
  except Exception as e:
507
588
  if "progress" in locals() and progress is not None:
508
589
  progress.stop()
509
- console.print(f"[red]❌ Unexpected error: {e}[/red]", file=sys.stderr)
590
+ # If we already extracted a commit message, return it despite the error
591
+ # (the SDK sometimes raises after the CLI has already completed successfully)
592
+ if commit_message:
593
+ return commit_message
594
+ error_console.print(f"[red]❌ Unexpected error: {e}[/red]")
510
595
  if verbose:
511
596
  import traceback
512
597
 
@@ -947,11 +1032,11 @@ Alias Management:
947
1032
  )
948
1033
  )
949
1034
  except KeyboardInterrupt:
950
- console.print("\n[yellow]⚠️ Interrupted by user[/yellow]", file=sys.stderr)
1035
+ error_console.print("\n[yellow]⚠️ Interrupted by user[/yellow]")
951
1036
  sys.exit(130)
952
1037
 
953
1038
  if not commit_message:
954
- console.print("[red]❌ Failed to generate commit message[/red]", file=sys.stderr)
1039
+ error_console.print("[red]❌ Failed to generate commit message[/red]")
955
1040
  sys.exit(1)
956
1041
 
957
1042
  # Display the generated message with rich formatting
@@ -975,9 +1060,7 @@ Alias Management:
975
1060
  pyperclip.copy(commit_message)
976
1061
  console.print("\n[green]✅ Commit message copied to clipboard![/green]")
977
1062
  except Exception as e:
978
- console.print(
979
- f"\n[yellow]⚠️ Failed to copy to clipboard: {e}[/yellow]", file=sys.stderr
980
- )
1063
+ error_console.print(f"\n[yellow]⚠️ Failed to copy to clipboard: {e}[/yellow]")
981
1064
 
982
1065
  if args.commit:
983
1066
  try:
@@ -1004,12 +1087,12 @@ Alias Management:
1004
1087
  if result.stdout:
1005
1088
  console.print(result.stdout)
1006
1089
  except subprocess.CalledProcessError as e:
1007
- console.print(f"\n[red]❌ Failed to commit: {e}[/red]", file=sys.stderr)
1090
+ error_console.print(f"\n[red]❌ Failed to commit: {e}[/red]")
1008
1091
  if e.stderr:
1009
- console.print(e.stderr, file=sys.stderr)
1092
+ error_console.print(e.stderr)
1010
1093
  sys.exit(1)
1011
1094
  except Exception as e:
1012
- console.print(f"\n[red]❌ Unexpected error during commit: {e}[/red]", file=sys.stderr)
1095
+ error_console.print(f"\n[red]❌ Unexpected error during commit: {e}[/red]")
1013
1096
  sys.exit(1)
1014
1097
  else:
1015
1098
  # Default: just show the command
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claude-commit
3
- Version: 0.6.0
3
+ Version: 0.8.0
4
4
  Summary: AI-powered git commit message generator using Claude Agent SDK
5
5
  Author-email: Johannlai <johannli666@gmail.com>
6
6
  License: MIT
@@ -35,10 +35,6 @@ Dynamic: license-file
35
35
 
36
36
  `claude-commit` uses Claude AI to analyze your code changes and write meaningful commit messages. Claude reads your files, understands the context, and generates commit messages following best practices.
37
37
 
38
- ## Demo
39
-
40
- [![asciicast](https://asciinema.org/a/ZubvhPFyP7hPFLsqZiZUc930L.svg)](https://asciinema.org/a/ZubvhPFyP7hPFLsqZiZUc930L?autoplay=1&speed=3)
41
-
42
38
  ## Installation
43
39
 
44
40
  ### Prerequisites
@@ -181,12 +177,12 @@ claude-commit alias unset quick
181
177
 
182
178
  ## How It Works
183
179
 
184
- Claude autonomously analyzes your changes:
180
+ Claude autonomously analyzes your changes using [subagents](https://platform.claude.com/docs/agent-sdk/subagents) for parallel analysis:
185
181
 
186
- 1. **Reads** your modified files to understand context
187
- 2. **Searches** the codebase for related code
188
- 3. **Understands** the intent and impact of changes
189
- 4. **Generates** a clear commit message following conventions
182
+ 1. **Detects style** a lightweight subagent checks git history for commit conventions (conventional commits, gitmoji, language, etc.)
183
+ 2. **Analyzes changes** a dedicated subagent reads modified files, searches for context, and understands the intent
184
+ 3. **Runs in parallel** both subagents execute concurrently for faster results
185
+ 4. **Generates** a clear commit message combining style and change analysis
190
186
 
191
187
  **Example:**
192
188
  ```
@@ -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