messygit 0.1.3__tar.gz → 0.1.4__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: messygit
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: CLI that drafts Conventional Commits from staged git diffs with Claude, then commit, cancel, or edit.
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -0,0 +1,240 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import subprocess
5
+ from dataclasses import dataclass
6
+ from subprocess import CompletedProcess
7
+
8
+ TOKEN_CHAR_ESTIMATE = 4
9
+ MAX_CONTEXT_TOKENS = 60_000
10
+ MAX_CONTEXT_CHARS = MAX_CONTEXT_TOKENS * TOKEN_CHAR_ESTIMATE
11
+
12
+ NOISE_PATTERNS: tuple[str, ...] = (
13
+ "package-lock.json",
14
+ "yarn.lock",
15
+ "pnpm-lock.yaml",
16
+ "Pipfile.lock",
17
+ "poetry.lock",
18
+ "Cargo.lock",
19
+ "composer.lock",
20
+ "Gemfile.lock",
21
+ "go.sum",
22
+ ".DS_Store",
23
+ "Thumbs.db",
24
+ "*.min.js",
25
+ "*.min.css",
26
+ "*.map",
27
+ "*.bundle.js",
28
+ "*.chunk.js",
29
+ "*.pb.go",
30
+ "*.generated.*",
31
+ "*.snap",
32
+ )
33
+
34
+ _DIFF_FILE_HEADER = re.compile(r"^diff --git a/.+ b/(.+)$")
35
+ _HUNK_HEADER = re.compile(r"^@@\s")
36
+
37
+
38
+ def _is_noise_file(path: str) -> bool:
39
+ """Return True if path matches a common build/generated pattern we always skip."""
40
+ from fnmatch import fnmatch
41
+
42
+ name = path.rsplit("/", 1)[-1]
43
+ for pattern in NOISE_PATTERNS:
44
+ if fnmatch(name, pattern) or fnmatch(path, pattern):
45
+ return True
46
+ return False
47
+
48
+
49
+ def _parse_compact_diff(raw_diff: str) -> str:
50
+ """Parse a -U0 unified diff into a compact per-file changed-lines format.
51
+
52
+ Output looks like:
53
+
54
+ === path/to/file.py ===
55
+ + added line
56
+ - removed line
57
+ === another/file.ts ===
58
+ + another addition
59
+ """
60
+ lines = raw_diff.splitlines()
61
+ out: list[str] = []
62
+ current_file: str | None = None
63
+ skip_file = False
64
+
65
+ for line in lines:
66
+ header_match = _DIFF_FILE_HEADER.match(line)
67
+ if header_match:
68
+ current_file = header_match.group(1)
69
+ skip_file = _is_noise_file(current_file)
70
+ if not skip_file:
71
+ out.append(f"\n=== {current_file} ===")
72
+ continue
73
+
74
+ if skip_file:
75
+ continue
76
+
77
+ if _HUNK_HEADER.match(line):
78
+ continue
79
+
80
+ if line.startswith("+") and not line.startswith("+++"):
81
+ out.append(line)
82
+ elif line.startswith("-") and not line.startswith("---"):
83
+ out.append(line)
84
+
85
+ return "\n".join(out).strip()
86
+
87
+
88
+ @dataclass
89
+ class FileStat:
90
+ path: str
91
+ added: int
92
+ removed: int
93
+
94
+ @property
95
+ def total_changed(self) -> int:
96
+ return self.added + self.removed
97
+
98
+
99
+ _STAT_LINE_RE = re.compile(
100
+ r"^\s*(\d+)\s+(\d+)\s+(.+)$"
101
+ )
102
+
103
+
104
+ def _get_raw_staged_diff() -> str:
105
+ result = subprocess.run(
106
+ ["git", "diff", "--cached", "-U0"],
107
+ capture_output=True,
108
+ text=True,
109
+ )
110
+ return result.stdout
111
+
112
+
113
+ def _get_staged_numstat() -> list[FileStat]:
114
+ """Run git diff --cached --numstat and parse per-file added/removed counts."""
115
+ result = subprocess.run(
116
+ ["git", "diff", "--cached", "--numstat"],
117
+ capture_output=True,
118
+ text=True,
119
+ )
120
+ stats: list[FileStat] = []
121
+ for line in result.stdout.strip().splitlines():
122
+ match = _STAT_LINE_RE.match(line)
123
+ if not match:
124
+ continue
125
+ added_str, removed_str, path = match.groups()
126
+ if added_str == "-" or removed_str == "-":
127
+ continue
128
+ path = path.strip()
129
+ if _is_noise_file(path):
130
+ continue
131
+ stats.append(FileStat(path=path, added=int(added_str), removed=int(removed_str)))
132
+ return stats
133
+
134
+
135
+ def _get_stat_summary() -> str:
136
+ """Run git diff --cached --stat and return the summary string."""
137
+ result = subprocess.run(
138
+ ["git", "diff", "--cached", "--stat"],
139
+ capture_output=True,
140
+ text=True,
141
+ )
142
+ return result.stdout.strip()
143
+
144
+
145
+ def _compact_diff_for_files(paths: set[str], raw_diff: str) -> str:
146
+ """Extract compact changed lines only for the given file paths."""
147
+ raw_lines = raw_diff.splitlines()
148
+ collected: list[str] = []
149
+ active_path: str | None = None
150
+ include = False
151
+
152
+ for raw_line in raw_lines:
153
+ file_match = _DIFF_FILE_HEADER.match(raw_line)
154
+ if file_match:
155
+ active_path = file_match.group(1)
156
+ include = active_path in paths
157
+ if include:
158
+ collected.append(f"\n=== {active_path} ===")
159
+ continue
160
+
161
+ if not include:
162
+ continue
163
+
164
+ if _HUNK_HEADER.match(raw_line):
165
+ continue
166
+
167
+ if raw_line.startswith("+") and not raw_line.startswith("+++"):
168
+ collected.append(raw_line)
169
+ elif raw_line.startswith("-") and not raw_line.startswith("---"):
170
+ collected.append(raw_line)
171
+
172
+ return "\n".join(collected).strip()
173
+
174
+
175
+ def build_staged_context() -> str:
176
+ """Build the context string sent to the LLM.
177
+
178
+ If the full compact diff fits within the token budget, return it as-is.
179
+ Otherwise, fall back to:
180
+ - The full --stat summary (file list with bar chart)
181
+ - Full compact diff of only the most-changed files that fit the budget
182
+ """
183
+ raw_diff = _get_raw_staged_diff()
184
+ full_compact = _parse_compact_diff(raw_diff)
185
+
186
+ if len(full_compact) <= MAX_CONTEXT_CHARS:
187
+ return full_compact
188
+
189
+ stat_summary = _get_stat_summary()
190
+ file_stats = _get_staged_numstat()
191
+ file_stats.sort(key=lambda fs: fs.total_changed, reverse=True)
192
+
193
+ header = (
194
+ "This diff was too large to include in full. "
195
+ "Below is the complete --stat summary followed by the full changed lines "
196
+ "of the most-changed files.\n\n"
197
+ f"--- stat summary ---\n{stat_summary}\n\n"
198
+ "--- most-changed files (full changed lines) ---\n"
199
+ )
200
+
201
+ budget = MAX_CONTEXT_CHARS - len(header)
202
+ selected_paths: set[str] = set()
203
+ for fs in file_stats:
204
+ file_diff = _compact_diff_for_files({fs.path}, raw_diff)
205
+ if len(file_diff) > budget:
206
+ continue
207
+ selected_paths.add(fs.path)
208
+ budget -= len(file_diff)
209
+
210
+ if not selected_paths:
211
+ return header.strip()
212
+
213
+ top_files_diff = _compact_diff_for_files(selected_paths, raw_diff)
214
+ return f"{header}{top_files_diff}"
215
+
216
+
217
+ def get_staged_diff() -> str:
218
+ """Return a compact, changed-lines-only representation of staged changes."""
219
+ return build_staged_context()
220
+
221
+
222
+ def get_staged_files() -> list[str]:
223
+ """Return list of staged file paths, excluding noise files."""
224
+ result = subprocess.run(
225
+ ["git", "diff", "--cached", "--name-only"],
226
+ capture_output=True,
227
+ text=True,
228
+ )
229
+ files = result.stdout.strip()
230
+ if not files:
231
+ return []
232
+ return [f for f in files.split("\n") if not _is_noise_file(f)]
233
+
234
+
235
+ def git_commit(message: str) -> CompletedProcess[str]:
236
+ return subprocess.run(
237
+ ["git", "commit", "-m", message],
238
+ capture_output=True,
239
+ text=True,
240
+ )
@@ -3,9 +3,9 @@ You are a git commit message generator. Your sole purpose is to produce \
3
3
  a single Conventional Commits subject line from staged changes.
4
4
 
5
5
  # Input format
6
- You will receive a compact summary of staged changes, NOT a raw unified diff. \
7
- The format is:
6
+ You will receive staged changes in one of two formats:
8
7
 
8
+ ## Format A — full compact diff (small changes)
9
9
  === path/to/file.py ===
10
10
  + added line
11
11
  - removed line
@@ -16,6 +16,15 @@ Each "=== filename ===" header marks the file that the following +/- lines \
16
16
  belong to. Lines starting with "+" were added; lines starting with "-" were \
17
17
  removed. Context lines and diff metadata are already stripped.
18
18
 
19
+ ## Format B — truncated large diff
20
+ When the diff exceeds the token budget, you receive:
21
+ 1. A note explaining the diff was too large.
22
+ 2. The complete `git diff --stat` summary (file list with insertions/deletions bar chart).
23
+ 3. Full changed lines for the most-changed files only.
24
+
25
+ Use the stat summary to understand the overall scope, then use the detailed \
26
+ changed lines to infer what the commit actually does.
27
+
19
28
  # Output rules (absolute, no exceptions)
20
29
  - Output EXACTLY one line: type(scope): description
21
30
  - No markdown, no quotes, no code fences, no bullet points, no explanation.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "messygit"
7
- version = "0.1.3"
7
+ version = "0.1.4"
8
8
  description = "CLI that drafts Conventional Commits from staged git diffs with Claude, then commit, cancel, or edit."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,109 +0,0 @@
1
- import re
2
- import subprocess
3
- from subprocess import CompletedProcess
4
-
5
- NOISE_PATTERNS: tuple[str, ...] = (
6
- "package-lock.json",
7
- "yarn.lock",
8
- "pnpm-lock.yaml",
9
- "Pipfile.lock",
10
- "poetry.lock",
11
- "Cargo.lock",
12
- "composer.lock",
13
- "Gemfile.lock",
14
- "go.sum",
15
- ".DS_Store",
16
- "Thumbs.db",
17
- "*.min.js",
18
- "*.min.css",
19
- "*.map",
20
- "*.bundle.js",
21
- "*.chunk.js",
22
- "*.pb.go",
23
- "*.generated.*",
24
- "*.snap",
25
- )
26
-
27
- _DIFF_FILE_HEADER = re.compile(r"^diff --git a/.+ b/(.+)$")
28
- _HUNK_HEADER = re.compile(r"^@@\s")
29
-
30
-
31
- def _is_noise_file(path: str) -> bool:
32
- """Return True if path matches a common build/generated pattern we always skip."""
33
- from fnmatch import fnmatch
34
-
35
- name = path.rsplit("/", 1)[-1]
36
- for pattern in NOISE_PATTERNS:
37
- if fnmatch(name, pattern) or fnmatch(path, pattern):
38
- return True
39
- return False
40
-
41
-
42
- def _parse_compact_diff(raw_diff: str) -> str:
43
- """Parse a -U0 unified diff into a compact per-file changed-lines format.
44
-
45
- Output looks like:
46
-
47
- === path/to/file.py ===
48
- + added line
49
- - removed line
50
- === another/file.ts ===
51
- + another addition
52
- """
53
- lines = raw_diff.splitlines()
54
- out: list[str] = []
55
- current_file: str | None = None
56
- skip_file = False
57
-
58
- for line in lines:
59
- header_match = _DIFF_FILE_HEADER.match(line)
60
- if header_match:
61
- current_file = header_match.group(1)
62
- skip_file = _is_noise_file(current_file)
63
- if not skip_file:
64
- out.append(f"\n=== {current_file} ===")
65
- continue
66
-
67
- if skip_file:
68
- continue
69
-
70
- if _HUNK_HEADER.match(line):
71
- continue
72
-
73
- if line.startswith("+") and not line.startswith("+++"):
74
- out.append(line)
75
- elif line.startswith("-") and not line.startswith("---"):
76
- out.append(line)
77
-
78
- return "\n".join(out).strip()
79
-
80
-
81
- def get_staged_diff() -> str:
82
- """Return a compact, changed-lines-only representation of staged changes."""
83
- result = subprocess.run(
84
- ["git", "diff", "--cached", "-U0"],
85
- capture_output=True,
86
- text=True,
87
- )
88
- return _parse_compact_diff(result.stdout)
89
-
90
-
91
- def get_staged_files() -> list[str]:
92
- """Return list of staged file paths, excluding noise files."""
93
- result = subprocess.run(
94
- ["git", "diff", "--cached", "--name-only"],
95
- capture_output=True,
96
- text=True,
97
- )
98
- files = result.stdout.strip()
99
- if not files:
100
- return []
101
- return [f for f in files.split("\n") if not _is_noise_file(f)]
102
-
103
-
104
- def git_commit(message: str) -> CompletedProcess[str]:
105
- return subprocess.run(
106
- ["git", "commit", "-m", message],
107
- capture_output=True,
108
- text=True,
109
- )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes