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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claude-commit
3
- Version: 0.6.0
3
+ Version: 0.7.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
@@ -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.7.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"
@@ -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
- # 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()
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
- 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,
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
- console.print(f"[red]❌ Process error: {e}[/red]", file=sys.stderr)
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
- console.print(f" stderr: {e.stderr}", file=sys.stderr)
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
- console.print(f"[red]❌ Unexpected error: {e}[/red]", file=sys.stderr)
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
- console.print("\n[yellow]⚠️ Interrupted by user[/yellow]", file=sys.stderr)
984
+ error_console.print("\n[yellow]⚠️ Interrupted by user[/yellow]")
951
985
  sys.exit(130)
952
986
 
953
987
  if not commit_message:
954
- console.print("[red]❌ Failed to generate commit message[/red]", file=sys.stderr)
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
- console.print(
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
- console.print(f"\n[red]❌ Failed to commit: {e}[/red]", file=sys.stderr)
1039
+ error_console.print(f"\n[red]❌ Failed to commit: {e}[/red]")
1008
1040
  if e.stderr:
1009
- console.print(e.stderr, file=sys.stderr)
1041
+ error_console.print(e.stderr)
1010
1042
  sys.exit(1)
1011
1043
  except Exception as e:
1012
- console.print(f"\n[red]❌ Unexpected error during commit: {e}[/red]", file=sys.stderr)
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claude-commit
3
- Version: 0.6.0
3
+ Version: 0.7.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
@@ -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