imp-git 0.0.26__py3-none-any.whl

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.
imp/git.py ADDED
@@ -0,0 +1,428 @@
1
+ import subprocess
2
+
3
+ import typer
4
+
5
+ from imp import console
6
+
7
+
8
+ def _run (*args: str, check: bool = True, timeout: int = 60, env: dict [str, str] | None = None) -> subprocess.CompletedProcess [str]:
9
+ import os
10
+
11
+ run_env = None
12
+ if env:
13
+ run_env = { **os.environ, **env }
14
+
15
+ try:
16
+ return subprocess.run (
17
+ [ "git", *args ],
18
+ capture_output=True,
19
+ text=True,
20
+ check=check,
21
+ timeout=timeout,
22
+ env=run_env,
23
+ )
24
+ except subprocess.TimeoutExpired:
25
+ console.err (f"git {args [0]} timed out")
26
+ raise typer.Exit (1) from None
27
+
28
+
29
+ def require ():
30
+ result = _run ("rev-parse", "--git-dir", check=False)
31
+ if result.returncode != 0:
32
+ console.err ("Not a git repository")
33
+ raise typer.Exit (1)
34
+
35
+
36
+ def require_clean (hint: str = "imp commit first"):
37
+ if not is_clean ():
38
+ console.err ("Uncommitted changes")
39
+ console.hint (hint)
40
+ raise typer.Exit (1)
41
+
42
+
43
+ def stage (all: bool = False):
44
+ if all:
45
+ _run ("add", "-A")
46
+
47
+
48
+ def add (files: list [str]):
49
+ _run ("add", "--", *files)
50
+
51
+
52
+ def staged_files () -> list [str]:
53
+ result = _run ("diff", "--cached", "--name-only", check=False)
54
+ return [ f.strip () for f in result.stdout.splitlines () if f.strip () ]
55
+
56
+
57
+ def diff (staged: bool = False) -> str:
58
+ args = [ "diff" ]
59
+ if staged:
60
+ args.append ("--cached")
61
+
62
+ result = _run (*args)
63
+ return result.stdout
64
+
65
+
66
+ def diff_range (rev_range: str, max_lines: int = 0) -> str:
67
+ args = [ "diff", rev_range ]
68
+ result = _run (*args, check=False)
69
+ text = result.stdout
70
+ if max_lines > 0:
71
+ lines = text.splitlines ()
72
+ text = "\n".join (lines [:max_lines])
73
+ return text
74
+
75
+
76
+ def diff_names () -> list [str]:
77
+ staged = _run ("diff", "--cached", "--name-only", check=False).stdout.strip ()
78
+ unstaged = _run ("diff", "--name-only", check=False).stdout.strip ()
79
+ untracked = _run (
80
+ "ls-files", "--others", "--exclude-standard",
81
+ check=False,
82
+ ).stdout.strip ()
83
+
84
+ names = set ()
85
+ for block in [ staged, unstaged, untracked ]:
86
+ for line in block.splitlines ():
87
+ line = line.strip ()
88
+ if line:
89
+ names.add (line)
90
+
91
+ return sorted (names)
92
+
93
+
94
+ def diff_file (path: str) -> str:
95
+ result = _run ("diff", "HEAD", "--", path, check=False)
96
+ text = result.stdout
97
+ if not text:
98
+ result = _run ("diff", "--cached", "--", path, check=False)
99
+ text = result.stdout
100
+ return text
101
+
102
+
103
+ def diff_numstat () -> str:
104
+ staged = _run ("diff", "--cached", "--numstat", check=False).stdout.strip ()
105
+ unstaged = _run ("diff", "--numstat", check=False).stdout.strip ()
106
+ combined = ""
107
+ if staged:
108
+ combined += staged + "\n"
109
+ if unstaged:
110
+ combined += unstaged
111
+ return combined.strip ()
112
+
113
+
114
+ def branch () -> str:
115
+ result = _run ("branch", "--show-current", check=False)
116
+ return result.stdout.strip ()
117
+
118
+
119
+ def branches_local () -> list [str]:
120
+ result = _run ("branch", "--format=%(refname:short)", check=False)
121
+ return [ b.strip () for b in result.stdout.splitlines () if b.strip () ]
122
+
123
+
124
+ def branches_merged (base: str) -> list [str]:
125
+ result = _run ("branch", "--merged", base, check=False)
126
+ current = branch ()
127
+ merged = []
128
+ for line in result.stdout.splitlines ():
129
+ name = line.removeprefix ("* ").strip ()
130
+ if name and name != base and name != current:
131
+ merged.append (name)
132
+ return merged
133
+
134
+
135
+ def commit (msg: str, amend: bool = False, date: str = ""):
136
+ args = [ "commit", "-m", msg ]
137
+ if amend:
138
+ args.insert (1, "--amend")
139
+ if date:
140
+ args.extend ([ "--date", date ])
141
+
142
+ _run (*args, env={ "GIT_COMMITTER_DATE": date } if date else {})
143
+
144
+
145
+ def commit_count () -> int:
146
+ result = _run ("rev-list", "--count", "HEAD", check=False)
147
+ try:
148
+ return int (result.stdout.strip ())
149
+ except ValueError:
150
+ return 0
151
+
152
+
153
+ def is_clean () -> bool:
154
+ result = _run ("status", "--porcelain")
155
+ return result.stdout.strip () == ""
156
+
157
+
158
+ def base_branch () -> str:
159
+ for name in [ "main", "master" ]:
160
+ result = _run ("rev-parse", "--verify", name, check=False)
161
+ if result.returncode == 0:
162
+ return name
163
+
164
+ return "main"
165
+
166
+
167
+ def last_tag () -> str:
168
+ result = _run ("describe", "--tags", "--abbrev=0", check=False)
169
+ return result.stdout.strip ()
170
+
171
+
172
+ def highest_tag () -> str:
173
+ result = _run ("tag", "-l", "v*", "--sort=-v:refname", check=False)
174
+ lines = result.stdout.strip ().splitlines ()
175
+ return lines [0].strip () if lines and lines [0].strip () else ""
176
+
177
+
178
+ def tag (name: str):
179
+ _run ("tag", name)
180
+
181
+
182
+ def tag_exists (name: str) -> bool:
183
+ result = _run ("rev-parse", name, check=False)
184
+ return result.returncode == 0
185
+
186
+
187
+ def tag_delete (name: str):
188
+ _run ("tag", "-d", name, check=False)
189
+
190
+
191
+ def has_upstream () -> bool:
192
+ result = _run ("rev-parse", "--verify", "@{u}", check=False)
193
+ return result.returncode == 0
194
+
195
+
196
+ def count_ahead () -> int:
197
+ result = _run ("rev-list", "--count", "@{u}..HEAD", check=False)
198
+ try:
199
+ return int (result.stdout.strip ())
200
+ except ValueError:
201
+ return 0
202
+
203
+
204
+ def count_behind () -> int:
205
+ result = _run ("rev-list", "--count", "HEAD..@{u}", check=False)
206
+ try:
207
+ return int (result.stdout.strip ())
208
+ except ValueError:
209
+ return 0
210
+
211
+
212
+ def log_oneline (count: int = 10, rev_range: str = "") -> str:
213
+ args = [ "log", "--oneline" ]
214
+ if rev_range:
215
+ args.append (rev_range)
216
+ else:
217
+ args.extend ([ "-n", str (count) ])
218
+ result = _run (*args, check=False)
219
+ return result.stdout.strip ()
220
+
221
+
222
+ def log_graph (count: int = 20, ref: str = "") -> str:
223
+ args = [
224
+ "log", "--oneline", "--graph",
225
+ "--decorate", "--color=always",
226
+ "-n", str (count),
227
+ ]
228
+ if ref:
229
+ args.append (ref)
230
+ result = _run (*args, check=False)
231
+ return result.stdout.strip ()
232
+
233
+
234
+ def log_subjects (rev_range: str = "", count: int = 0) -> str:
235
+ args = [ "log", "--format=%s" ]
236
+ if rev_range:
237
+ args.append (rev_range)
238
+ elif count > 0:
239
+ args.extend ([ "-n", str (count) ])
240
+ result = _run (*args, check=False)
241
+ return result.stdout.strip ()
242
+
243
+
244
+ def fetch (prune: bool = False):
245
+ args = [ "fetch" ]
246
+ if prune:
247
+ args.append ("--prune")
248
+ _run (*args, check=False)
249
+
250
+
251
+ def rebase () -> bool:
252
+ result = _run ("rebase", check=False)
253
+ return result.returncode == 0
254
+
255
+
256
+ def push (
257
+ force_lease: bool = False,
258
+ set_upstream: bool = False,
259
+ target: str = "",
260
+ ref: str = "",
261
+ ):
262
+ args = [ "push" ]
263
+ if force_lease:
264
+ args.append ("--force-with-lease")
265
+ if set_upstream:
266
+ args.extend ([ "-u", "origin" ])
267
+ if target:
268
+ args.append (target)
269
+ elif ref:
270
+ args.extend ([ "origin", ref ])
271
+ _run (*args)
272
+
273
+
274
+ def merge (ref: str, no_ff: bool = False) -> bool:
275
+ args = [ "merge" ]
276
+ if no_ff:
277
+ args.append ("--no-ff")
278
+ args.append (ref)
279
+ result = _run (*args, check=False)
280
+ return result.returncode == 0
281
+
282
+
283
+ def is_merged (branch_name: str, into: str) -> bool:
284
+ result = _run ("merge-base", "--is-ancestor", branch_name, into, check=False)
285
+ return result.returncode == 0
286
+
287
+
288
+ def pull ():
289
+ _run ("pull", check=False)
290
+
291
+
292
+ def revert_commit (ref: str, no_commit: bool = False):
293
+ args = [ "revert" ]
294
+ if no_commit:
295
+ args.append ("--no-commit")
296
+ args.append (ref)
297
+ _run (*args)
298
+
299
+
300
+ def revert_abort ():
301
+ _run ("revert", "--abort", check=False)
302
+
303
+
304
+ def reset (ref: str, soft: bool = False, hard: bool = False):
305
+ args = [ "reset" ]
306
+ if soft:
307
+ args.append ("--soft")
308
+ elif hard:
309
+ args.append ("--hard")
310
+ args.append (ref)
311
+ _run (*args)
312
+
313
+
314
+ def checkout (ref: str, create: bool = False):
315
+ args = [ "checkout" ]
316
+ if create:
317
+ args.append ("-b")
318
+ args.append (ref)
319
+ _run (*args)
320
+
321
+
322
+ def show (ref: str = "HEAD", fmt: str = "", stat: bool = False) -> str:
323
+ args = [ "show" ]
324
+ if fmt:
325
+ args.append (f"--format={fmt}")
326
+ else:
327
+ args.append ("--format=")
328
+ if stat:
329
+ args.append ("--stat")
330
+ args.append (ref)
331
+ result = _run (*args, check=False)
332
+ return result.stdout.strip ()
333
+
334
+
335
+ def status_short () -> str:
336
+ result = _run ("status", "--short")
337
+ return result.stdout.strip ()
338
+
339
+
340
+ def worktree_list () -> str:
341
+ result = _run ("worktree", "list", check=False)
342
+ return result.stdout.strip ()
343
+
344
+
345
+ def remote_has_branch (name: str) -> bool:
346
+ result = _run ("ls-remote", "--heads", "origin", name, check=False)
347
+ return name in result.stdout
348
+
349
+
350
+ def remote_exists () -> bool:
351
+ result = _run ("remote", check=False)
352
+ return result.stdout.strip () != ""
353
+
354
+
355
+ def delete_branch (name: str, force: bool = False, remote: bool = False) -> bool:
356
+ if remote:
357
+ result = _run ("push", "origin", "--delete", name, check=False)
358
+ return result.returncode == 0
359
+ else:
360
+ flag = "-D" if force else "-d"
361
+ result = _run ("branch", flag, name, check=False)
362
+ return result.returncode == 0
363
+
364
+
365
+ def unstage (files: list [str] | None = None):
366
+ if files:
367
+ if commit_count () > 0:
368
+ _run ("reset", "HEAD", "--", *files, check=False)
369
+ else:
370
+ _run ("rm", "--cached", "--", *files, check=False)
371
+ elif commit_count () > 0:
372
+ _run ("reset", "HEAD", check=False)
373
+ else:
374
+ _run ("rm", "-r", "--cached", ".", check=False)
375
+
376
+
377
+ def repo_root () -> str:
378
+ result = _run ("rev-parse", "--show-toplevel", check=False)
379
+ return result.stdout.strip ()
380
+
381
+
382
+ def repo_name () -> str:
383
+ from pathlib import Path
384
+ return Path (repo_root ()).name
385
+
386
+
387
+ def rev_parse (ref: str) -> str:
388
+ result = _run ("rev-parse", ref, check=False)
389
+ return result.stdout.strip ()
390
+
391
+
392
+ def rev_parse_short (ref: str) -> str:
393
+ result = _run ("rev-parse", "--short", ref, check=False)
394
+ return result.stdout.strip ()
395
+
396
+
397
+ def conflicts () -> list [str]:
398
+ result = _run ("diff", "--name-only", "--diff-filter=U", check=False)
399
+ lines = result.stdout.strip ().splitlines ()
400
+ return [ line.strip () for line in lines if line.strip () ]
401
+
402
+
403
+ def conflict_content (path: str) -> str:
404
+ from pathlib import Path
405
+
406
+ return Path (path).read_text ()
407
+
408
+
409
+ def merge_in_progress () -> bool:
410
+ from pathlib import Path
411
+
412
+ git_dir = _run ("rev-parse", "--git-dir", check=False).stdout.strip ()
413
+ return Path (git_dir, "MERGE_HEAD").exists ()
414
+
415
+
416
+ def rebase_in_progress () -> bool:
417
+ from pathlib import Path
418
+
419
+ git_dir = _run ("rev-parse", "--git-dir", check=False).stdout.strip ()
420
+ return (
421
+ Path (git_dir, "rebase-merge").exists ()
422
+ or Path (git_dir, "rebase-apply").exists ()
423
+ )
424
+
425
+
426
+ def branch_age (name: str) -> str:
427
+ result = _run ("log", "-1", "--format=%cr", name, check=False)
428
+ return result.stdout.strip () or "unknown"
imp/main.py ADDED
@@ -0,0 +1,74 @@
1
+ import typer
2
+ from rich.console import Console
3
+
4
+ from imp import __version__
5
+ from imp.commands.amend import amend
6
+ from imp.commands.branch import branch
7
+ from imp.commands.clean import clean
8
+ from imp.commands.commit import commit
9
+ from imp.commands.config import configure
10
+ from imp.commands.doctor import doctor
11
+ from imp.commands.done import done
12
+ from imp.commands.fix import fix
13
+ from imp.commands.help import help
14
+ from imp.commands.log import log
15
+ from imp.commands.pr import pr
16
+ from imp.commands.push import push
17
+ from imp.commands.release import release
18
+ from imp.commands.resolve import resolve
19
+ from imp.commands.revert import revert
20
+ from imp.commands.review import review
21
+ from imp.commands.ship import ship
22
+ from imp.commands.split import split
23
+ from imp.commands.status import status
24
+ from imp.commands.sync import sync
25
+ from imp.commands.undo import undo
26
+
27
+ app = typer.Typer (
28
+ name="imp",
29
+ no_args_is_help=True,
30
+ rich_markup_mode="rich",
31
+ add_completion=False,
32
+ )
33
+
34
+
35
+ def _version (value: bool):
36
+ if value:
37
+ Console ().print (f"imp {__version__}")
38
+ raise typer.Exit ()
39
+
40
+
41
+ @app.callback ()
42
+ def main (
43
+ version: bool | None = typer.Option (
44
+ None,
45
+ "--version", "-v",
46
+ help="Show version and exit",
47
+ callback=_version,
48
+ is_eager=True,
49
+ ),
50
+ ):
51
+ """[green]imp[/green] — AI-powered git workflow"""
52
+
53
+
54
+ app.command () (amend)
55
+ app.command () (branch)
56
+ app.command () (clean)
57
+ app.command () (commit)
58
+ app.command ("config") (configure)
59
+ app.command () (doctor)
60
+ app.command () (done)
61
+ app.command () (fix)
62
+ app.command () (help)
63
+ app.command () (log)
64
+ app.command () (pr)
65
+ app.command () (push)
66
+ app.command () (release)
67
+ app.command () (resolve)
68
+ app.command () (revert)
69
+ app.command () (review)
70
+ app.command () (ship)
71
+ app.command () (split)
72
+ app.command () (status)
73
+ app.command () (sync)
74
+ app.command () (undo)
imp/prompts.py ADDED
@@ -0,0 +1,168 @@
1
+ import re
2
+
3
+
4
+ def _ticket_rule (branch: str) -> str:
5
+ match = re.search (r"([A-Z]+-[0-9]+)", branch)
6
+ if not match:
7
+ return ""
8
+
9
+ ticket = match.group (1)
10
+ return f'- Include ticket {ticket} after the type, e.g. "fix: {ticket} message"\n'
11
+
12
+
13
+ def _whisper (text: str) -> str:
14
+ if not text:
15
+ return ""
16
+ return f"\nUser hint: {text}\n"
17
+
18
+
19
+ def commit (diff: str, branch: str = "", whisper: str = "") -> str:
20
+ return f"""\
21
+ Generate a Conventional Commits message for this diff.
22
+ {_whisper (whisper)}\
23
+ Format: type: message
24
+ Types: feat, fix, refactor, build, chore, docs, test, style, perf, ci
25
+ {_ticket_rule (branch)}
26
+ Rules:
27
+ - Subject only, one line, max 72 chars, no period
28
+ - ALL LOWERCASE after the colon (except ticket IDs like IMP-123)
29
+ - Imperative mood: "add" not "added", "fix" not "fixes"
30
+ - Pick the type that best fits the primary change
31
+ - No markdown, no backticks, no quotes
32
+ - No body, no bullet points, just the subject line
33
+ - Output will be validated against commitlint rules; it must pass
34
+
35
+ Diff:
36
+ {diff}
37
+
38
+ Output ONLY the commit message, nothing else:"""
39
+
40
+
41
+ def review (diff: str, whisper: str = "") -> str:
42
+ return f"""\
43
+ Review this code diff. Be concise and actionable.
44
+ {_whisper (whisper)}\
45
+ Check for:
46
+ - Bugs or logic errors
47
+ - Security issues
48
+ - Performance problems
49
+ - Code style issues
50
+ - Missing error handling
51
+
52
+ If the code looks good, say so briefly.
53
+
54
+ Diff:
55
+ {diff}
56
+
57
+ Output ONLY the review:"""
58
+
59
+
60
+ def branch_name (description: str, whisper: str = "") -> str:
61
+ return f"""\
62
+ Suggest a git branch name for: {description}
63
+ {_whisper (whisper)}\
64
+ Rules:
65
+ - Lowercase, hyphens only, no spaces
66
+ - Max 30 chars
67
+ - Format: type/short-name
68
+ - Types: feat, fix, refactor, docs, test, chore
69
+
70
+ Output ONLY the branch name:"""
71
+
72
+
73
+ def revert (commit_msg: str, diff: str, whisper: str = "") -> str:
74
+ return f"""\
75
+ Generate a commit message for reverting this change. Start with 'Revert:'. Max 50 chars:
76
+ {_whisper (whisper)}\
77
+ Original: {commit_msg}
78
+
79
+ Changes reverted:
80
+ {diff}
81
+
82
+ Output ONLY the commit message:"""
83
+
84
+
85
+ def fix (title: str, body: str, whisper: str = "") -> str:
86
+ return f"""\
87
+ Suggest a git branch name for fixing this issue:
88
+ {_whisper (whisper)}\
89
+ Title: {title}
90
+ Description: {body}
91
+
92
+ Rules:
93
+ - Lowercase, hyphens only
94
+ - Max 30 chars
95
+ - Format: fix/<short-name>
96
+ - Include issue number if fits
97
+
98
+ Output ONLY the branch name:"""
99
+
100
+
101
+ def pr (branch: str, log: str, diff: str, whisper: str = "") -> str:
102
+ return f"""\
103
+ Generate a GitHub pull request title and description.
104
+ {_whisper (whisper)}\
105
+ Branch: {branch}
106
+ Commits:
107
+ {log}
108
+
109
+ Diff summary:
110
+ {diff}
111
+
112
+ Format:
113
+ TITLE: <50 char title>
114
+
115
+ DESCRIPTION:
116
+ ## Summary
117
+ <2-3 bullet points>
118
+
119
+ ## Changes
120
+ <list main changes>
121
+
122
+ Output ONLY in this format:"""
123
+
124
+
125
+ def split (file_diffs: str, branch: str = "", whisper: str = "") -> str:
126
+ return f"""\
127
+ Group these changed files into logical commits. Each group = one commit.
128
+ {_whisper (whisper)}\
129
+ Format: type: message
130
+ Types: feat, fix, refactor, build, chore, docs, test, style, perf, ci
131
+ {_ticket_rule (branch)}
132
+ Rules:
133
+ - Output a JSON array, no markdown fences, no explanation
134
+ - Each element: {{"files": ["path1", "path2"], "message": "type: description"}}
135
+ - ALL LOWERCASE after the colon (except ticket IDs like IMP-123)
136
+ - Imperative mood: "add" not "added", "fix" not "fixes"
137
+ - Max 72 chars per message, no period at end
138
+ - Every file must appear in exactly one group
139
+ - Minimize number of groups (prefer fewer, larger groups)
140
+ - Group by logical change, not by directory
141
+
142
+ Branch: {branch}
143
+
144
+ File diffs:
145
+ {file_diffs}
146
+
147
+ Output ONLY the JSON array:"""
148
+
149
+
150
+ def resolve (content: str, path: str, ours: str, theirs: str, whisper: str = "") -> str:
151
+ return f"""\
152
+ Resolve all merge conflicts in this file.
153
+ {_whisper (whisper)}\
154
+ Branches:
155
+ - Ours (current): {ours}
156
+ - Theirs (incoming): {theirs}
157
+
158
+ File: {path}
159
+
160
+ Rules:
161
+ - Resolve every conflict marked by <<<<<<<, =======, >>>>>>>
162
+ - Preserve all non-conflicted code exactly as-is
163
+ - Output the complete resolved file, nothing else
164
+ - No explanation, no markdown fences, no commentary
165
+
166
+ {content}
167
+
168
+ Output ONLY the resolved file:"""
imp/theme.py ADDED
@@ -0,0 +1,14 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass (frozen=True)
5
+ class Theme:
6
+ accent: str = "#1eff00"
7
+ success: str = "#00ff00"
8
+ error: str = "#ff3131"
9
+ warning: str = "#ff8000"
10
+ muted: str = "#4e7a4e"
11
+ highlight: str = "#a335ee"
12
+
13
+
14
+ theme = Theme ()
imp/validate.py ADDED
@@ -0,0 +1,28 @@
1
+ import re
2
+
3
+ _COMMIT_RE = re.compile (
4
+ r"^(feat|fix|refactor|build|chore|docs|test|style|perf|ci)"
5
+ r"(\(.+\))?!?: .+"
6
+ )
7
+
8
+ _TICKET_RE = re.compile (r"^[A-Z]+-[0-9]")
9
+
10
+ _BRANCH_RE = re.compile (r"^[a-zA-Z0-9][a-zA-Z0-9/_.-]*$")
11
+
12
+
13
+ def commit (msg: str) -> bool:
14
+ subject = msg.split ("\n", 1) [0]
15
+
16
+ if not _COMMIT_RE.match (subject):
17
+ return False
18
+
19
+ parts = subject.split (": ", 1)
20
+ if len (parts) < 2 or not parts [1]:
21
+ return False
22
+
23
+ desc = parts [1]
24
+ return not (desc [0].isupper () and not _TICKET_RE.match (desc))
25
+
26
+
27
+ def branch (name: str) -> bool:
28
+ return bool (_BRANCH_RE.match (name))