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.
@@ -0,0 +1,161 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+
4
+ import typer
5
+ from rich.panel import Panel
6
+
7
+ from imp import ai, console, git, prompts
8
+ from imp.theme import theme
9
+
10
+ MARKER = "<<<<<<<"
11
+
12
+
13
+ def _theirs_branch () -> str:
14
+ git_dir = subprocess.run (
15
+ [ "git", "rev-parse", "--git-dir" ],
16
+ capture_output=True,
17
+ text=True,
18
+ check=False,
19
+ ).stdout.strip ()
20
+
21
+ merge_msg = Path (git_dir, "MERGE_MSG")
22
+ if merge_msg.exists ():
23
+ first_line = merge_msg.read_text ().splitlines () [0]
24
+ parts = first_line.split ("'")
25
+ if len (parts) >= 2:
26
+ return parts [1]
27
+
28
+ return "incoming"
29
+
30
+
31
+ def _checkout_ours (path: str):
32
+ subprocess.run (
33
+ [ "git", "checkout", "--ours", "--", path ],
34
+ check=True,
35
+ capture_output=True,
36
+ )
37
+
38
+
39
+ def _checkout_theirs (path: str):
40
+ subprocess.run (
41
+ [ "git", "checkout", "--theirs", "--", path ],
42
+ check=True,
43
+ capture_output=True,
44
+ )
45
+
46
+
47
+ def resolve (
48
+ whisper: str = typer.Option ("", "--whisper", "-w", help="Hint to guide the AI"),
49
+ ):
50
+ """Resolve merge conflicts with AI assistance.
51
+
52
+ Walks through each conflicted file, sends it to AI for resolution,
53
+ and lets you accept, edit, or choose ours/theirs for each file.
54
+ """
55
+
56
+ git.require ()
57
+
58
+ files = git.conflicts ()
59
+ if not files:
60
+ console.muted ("No conflicts to resolve")
61
+ raise typer.Exit (0)
62
+
63
+ console.header ("Resolve")
64
+
65
+ console.label (f"{len (files)} conflicted file(s)")
66
+ for f in files:
67
+ console.item (f)
68
+ console.out.print ()
69
+
70
+ ours = git.branch () or "HEAD"
71
+ theirs = _theirs_branch ()
72
+
73
+ num_resolved = 0
74
+ num_skipped = 0
75
+
76
+ for path in files:
77
+ console.label (path)
78
+
79
+ content = git.conflict_content (path)
80
+ has_markers = MARKER in content
81
+
82
+ if not has_markers:
83
+ console.muted ("No conflict markers (delete/rename conflict)")
84
+
85
+ choice = console.choose (
86
+ "Resolution",
87
+ [ "Keep", "Delete", "Skip" ],
88
+ )
89
+
90
+ if choice == "Keep":
91
+ git.add ([ path ])
92
+ num_resolved += 1
93
+ elif choice == "Delete":
94
+ subprocess.run (
95
+ [ "git", "rm", "--", path ],
96
+ check=True,
97
+ capture_output=True,
98
+ )
99
+ num_resolved += 1
100
+ else:
101
+ num_skipped += 1
102
+
103
+ continue
104
+
105
+ result = ai.smart (prompts.resolve (content, path, ours, theirs, whisper))
106
+
107
+ if MARKER in result:
108
+ console.warn ("AI left conflict markers, retrying...")
109
+ result = ai.smart (prompts.resolve (content, path, ours, theirs, whisper))
110
+
111
+ if MARKER in result:
112
+ console.warn ("AI still left markers, showing best attempt")
113
+
114
+ console.out.print (Panel (
115
+ result,
116
+ border_style=theme.accent,
117
+ title=path,
118
+ title_align="left",
119
+ padding=(1, 2),
120
+ ))
121
+ console.out.print ()
122
+
123
+ choice = console.choose (
124
+ "Resolution",
125
+ [ "AI suggestion", "Ours", "Theirs", "Edit", "Skip" ],
126
+ )
127
+
128
+ if choice == "AI suggestion":
129
+ Path (path).write_text (result)
130
+ git.add ([ path ])
131
+ num_resolved += 1
132
+ elif choice == "Ours":
133
+ _checkout_ours (path)
134
+ git.add ([ path ])
135
+ num_resolved += 1
136
+ elif choice == "Theirs":
137
+ _checkout_theirs (path)
138
+ git.add ([ path ])
139
+ num_resolved += 1
140
+ elif choice == "Edit":
141
+ edited = console.edit (result)
142
+ if edited.strip ():
143
+ Path (path).write_text (edited)
144
+ git.add ([ path ])
145
+ num_resolved += 1
146
+ else:
147
+ console.muted ("Empty content, skipped")
148
+ num_skipped += 1
149
+ else:
150
+ num_skipped += 1
151
+
152
+ console.out.print ()
153
+ console.success (f"{num_resolved} resolved, {num_skipped} skipped")
154
+
155
+ if num_skipped == 0 and num_resolved > 0:
156
+ if git.merge_in_progress ():
157
+ console.hint ("git merge --continue")
158
+ elif git.rebase_in_progress ():
159
+ console.hint ("git rebase --continue")
160
+ else:
161
+ console.hint ("All conflicts resolved")
imp/commands/revert.py ADDED
@@ -0,0 +1,83 @@
1
+ import typer
2
+
3
+ from imp import ai, console, git, prompts
4
+
5
+
6
+ def revert (
7
+ ref: str | None = typer.Argument (None, help="Commit hash to revert"),
8
+ whisper: str = typer.Option ("", "--whisper", "-w", help="Hint to guide the AI"),
9
+ ):
10
+ """Safely revert a pushed commit.
11
+
12
+ Pass a commit hash, or omit it to pick from the last 10 commits
13
+ interactively. Shows the changes that will be undone, then uses AI to
14
+ generate a revert commit message that you can review or edit before
15
+ committing.
16
+ """
17
+
18
+ git.require ()
19
+
20
+ console.header ("Revert")
21
+
22
+ if not ref:
23
+ log = git.log_oneline (count=10)
24
+ if not log:
25
+ console.muted ("No commits to revert")
26
+ raise typer.Exit (0)
27
+
28
+ lines = log.splitlines ()
29
+ choice = console.choose ("Select commit to revert:", lines)
30
+ ref = choice.split (" ", 1) [0]
31
+
32
+ if not ref:
33
+ console.muted ("Cancelled")
34
+ raise typer.Exit (0)
35
+
36
+ if not git.rev_parse (ref):
37
+ console.err (f"Invalid commit: {ref}")
38
+ raise typer.Exit (1)
39
+
40
+ commit_msg = git.show (ref, fmt="%s")
41
+ commit_hash = git.rev_parse_short (ref)
42
+
43
+ console.label ("Reverting")
44
+ console.item (f"{commit_hash} {commit_msg}")
45
+ console.out.print ()
46
+
47
+ stat = git.show (ref, stat=True)
48
+ if stat:
49
+ console.muted ("Changes to undo:")
50
+ for line in stat.splitlines ():
51
+ if line.strip ():
52
+ console.item (line)
53
+ console.out.print ()
54
+
55
+ if not console.confirm ("Create revert commit?"):
56
+ console.muted ("Cancelled")
57
+ raise typer.Exit (0)
58
+
59
+ d = git.diff_range (f"{ref}~1..{ref}", max_lines=500)
60
+
61
+ msg = ai.fast (prompts.revert (commit_msg, d, whisper))
62
+ msg = ai.oneline (msg)
63
+
64
+ git.revert_commit (ref, no_commit=True)
65
+
66
+ choice = console.review (msg)
67
+
68
+ if choice == "Edit":
69
+ msg = console.edit (msg)
70
+ if not msg.strip ():
71
+ git.revert_abort ()
72
+ console.muted ("Empty message, cancelled")
73
+ raise typer.Exit (0)
74
+ git.commit (msg)
75
+ elif choice == "Yes":
76
+ git.commit (msg)
77
+ else:
78
+ git.revert_abort ()
79
+ console.muted ("Cancelled")
80
+ raise typer.Exit (0)
81
+
82
+ console.success (f"Reverted {commit_hash}")
83
+ console.hint ("imp sync to push, or imp undo to cancel")
imp/commands/review.py ADDED
@@ -0,0 +1,76 @@
1
+ import shutil
2
+ import subprocess
3
+ import tempfile
4
+
5
+ import typer
6
+
7
+ from imp import ai, console, git, prompts
8
+
9
+
10
+ def _handoff (findings: str):
11
+ if not shutil.which ("claude"):
12
+ console.err ("Claude Code not found")
13
+ console.hint ("curl -fsSL https://claude.ai/install.sh | bash")
14
+ raise typer.Exit (1)
15
+
16
+ prompt = f"Fix the following code review findings. Apply each fix directly to the files.\n\n{findings}"
17
+
18
+ with tempfile.NamedTemporaryFile (mode="w", suffix=".md", delete=True) as f:
19
+ f.write (prompt)
20
+ f.flush ()
21
+
22
+ console.out.print ()
23
+ console.muted ("Handing off to Claude Code...")
24
+ subprocess.run (
25
+ [ "claude", f"Read {f.name} and follow the instructions inside." ],
26
+ check=False,
27
+ )
28
+
29
+
30
+ def review (
31
+ fix: bool = typer.Option (False, "--fix", "-f", help="Send review findings to Claude Code for fixing"),
32
+ whisper: str = typer.Option ("", "--whisper", "-w", help="Hint to guide the AI"),
33
+ ):
34
+ """AI code review of current changes.
35
+
36
+ Sends staged changes (or unstaged if nothing is staged) to the smart
37
+ AI model for review. Outputs feedback in markdown covering bugs, style
38
+ issues, and suggestions. Use --fix to send findings to Claude Code.
39
+ """
40
+
41
+ git.require ()
42
+
43
+ console.header ("Review")
44
+
45
+ d = git.diff (staged=True)
46
+ context = "staged changes"
47
+
48
+ if not d:
49
+ d = git.diff ()
50
+ context = "unstaged changes"
51
+
52
+ if not d:
53
+ console.muted ("No changes to review")
54
+ console.hint ("make some changes first")
55
+ raise typer.Exit (0)
56
+
57
+ d = ai.truncate (d)
58
+
59
+ result = console.spin (
60
+ "Thinking...",
61
+ ai.smart,
62
+ prompts.review (d, whisper),
63
+ False,
64
+ )
65
+
66
+ console.divider ()
67
+ console.md (result)
68
+ console.divider ()
69
+
70
+ should_fix = fix
71
+ if not fix:
72
+ choice = console.choose ("Next?", [ "Fix with Claude Code", "Done" ])
73
+ should_fix = choice == "Fix with Claude Code"
74
+
75
+ if should_fix:
76
+ _handoff (result)
imp/commands/ship.py ADDED
@@ -0,0 +1,131 @@
1
+ import subprocess
2
+ from datetime import date
3
+ from pathlib import Path
4
+
5
+ import typer
6
+
7
+ from imp import ai, console, git, prompts, version
8
+ from imp.commands.release import (
9
+ _push_release,
10
+ _rollback_release,
11
+ _squash_commits,
12
+ _write_changelog,
13
+ )
14
+
15
+
16
+ def ship (
17
+ level: str = typer.Argument ("patch", help="Version bump: patch, minor, or major"),
18
+ whisper: str = typer.Option ("", "--whisper", "-w", help="Hint to guide the AI"),
19
+ ):
20
+ """Commit all changes and release in one shot.
21
+
22
+ Stages everything, generates a commit message, bumps the version,
23
+ updates the changelog, squashes, tags, and pushes. No prompts.
24
+ Equivalent to imp commit -a followed by imp release with auto-accept.
25
+ """
26
+
27
+ git.require ()
28
+
29
+ base = git.base_branch ()
30
+ if git.branch () != base:
31
+ console.warn (f"Releasing from {git.branch ()}, not {base}")
32
+
33
+ if level not in ("patch", "minor", "major"):
34
+ console.err (f"Invalid level: {level}")
35
+ console.hint ("use patch, minor, or major")
36
+ raise typer.Exit (1)
37
+
38
+ console.header ("Ship")
39
+
40
+ git.stage (all=True)
41
+ d = git.diff (staged=True)
42
+
43
+ if not d:
44
+ console.muted ("No changes to ship")
45
+ raise typer.Exit (0)
46
+
47
+ b = git.branch ()
48
+ msg = ai.commit_message (prompts.commit (d, b, whisper))
49
+
50
+ console.label ("Commit")
51
+ console.item (msg)
52
+ git.commit (msg)
53
+ console.success ("Committed")
54
+ console.out.print ()
55
+
56
+ tag = git.last_tag ()
57
+
58
+ log = ""
59
+ if tag:
60
+ log = git.log_oneline (rev_range=f"{tag}..HEAD")
61
+
62
+ if not log:
63
+ log = git.log_oneline (count=20)
64
+ tag = ""
65
+
66
+ if not log:
67
+ console.muted ("No commits to release")
68
+ raise typer.Exit (0)
69
+
70
+ count = len (log.splitlines ())
71
+
72
+ highest = git.highest_tag ()
73
+ current = highest.lstrip ("v") if highest else "0.0.0"
74
+ if not current:
75
+ current = "0.0.0"
76
+
77
+ new_version = version.bump (current, level)
78
+
79
+ if git.tag_exists (f"v{new_version}"):
80
+ console.err (f"Tag v{new_version} already exists")
81
+ console.hint (f"pick a different version, or: git tag -d v{new_version}")
82
+ raise typer.Exit (1)
83
+
84
+ if tag:
85
+ subjects = git.log_subjects (rev_range=f"{tag}..HEAD")
86
+ else:
87
+ subjects = git.log_subjects (count=count)
88
+
89
+ entry = version.changelog_from_commits (subjects)
90
+ summary = f"chore: release v{new_version}"
91
+ today = date.today ().isoformat ()
92
+ new_entry = f"## [{new_version}] - {today}\n\n{entry}"
93
+
94
+ root = git.repo_root ()
95
+ changelog_path = Path (root) / "CHANGELOG.md"
96
+
97
+ original_head = git.rev_parse ("HEAD")
98
+ original_changelog = ""
99
+ if changelog_path.is_file ():
100
+ original_changelog = changelog_path.read_text ()
101
+
102
+ committed = False
103
+
104
+ try:
105
+ _write_changelog (changelog_path, new_entry)
106
+ console.success ("Updated CHANGELOG.md")
107
+
108
+ can_squash = _squash_commits (tag, summary, changelog_path, count)
109
+ committed = True
110
+
111
+ git.tag (f"v{new_version}")
112
+ console.success (f"Tagged v{new_version}")
113
+
114
+ except (subprocess.CalledProcessError, OSError) as e:
115
+ msg = getattr (e, "stderr", "") or str (e)
116
+ console.err (f"Release failed: {msg.strip ()}")
117
+ _rollback_release (
118
+ new_version, original_head, changelog_path,
119
+ original_changelog, committed,
120
+ )
121
+ raise typer.Exit (1) from None
122
+
123
+ if git.remote_exists ():
124
+ try:
125
+ _push_release (new_version, entry, can_squash)
126
+ except (subprocess.CalledProcessError, OSError) as e:
127
+ msg = getattr (e, "stderr", "") or str (e)
128
+ console.err (f"Push failed: {msg.strip ()}")
129
+ raise typer.Exit (1) from None
130
+ else:
131
+ console.muted ("No remote, skipped push")
imp/commands/split.py ADDED
@@ -0,0 +1,176 @@
1
+ import json
2
+ import re
3
+ from datetime import datetime, timezone
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from imp import ai, console, git, prompts, validate
9
+
10
+
11
+ def _build_file_diffs (root: str, files: list [str]) -> str:
12
+ parts = []
13
+ for f in files:
14
+ d = git.diff_file (f)
15
+
16
+ full = Path (root) / f
17
+ if not d and full.is_file ():
18
+ try:
19
+ lines = full.read_text ().splitlines (keepends=True) [:30]
20
+ d = "".join ("+" + line for line in lines)
21
+ except (OSError, UnicodeDecodeError):
22
+ pass
23
+
24
+ if d:
25
+ snippet = "\n".join (d.splitlines () [:30])
26
+ parts.append (f"--- {f} ---\n{snippet}")
27
+ else:
28
+ parts.append (f"--- {f} --- (no diff available)")
29
+
30
+ return "\n".join (parts)
31
+
32
+
33
+ def _validate_response (response: str, all_files: list [str]) -> list [dict] | None:
34
+ try:
35
+ groups = json.loads (response)
36
+ except json.JSONDecodeError:
37
+ console.err ("AI returned invalid JSON")
38
+ return None
39
+
40
+ if not isinstance (groups, list) or len (groups) == 0:
41
+ console.err ("AI returned empty or non-array response")
42
+ return None
43
+
44
+ grouped = set ()
45
+ for g in groups:
46
+ for f in g.get ("files", []):
47
+ grouped.add (f)
48
+
49
+ expected = set (all_files)
50
+ if grouped != expected:
51
+ console.err ("File mismatch: AI groups don't cover all files")
52
+ missing = expected - grouped
53
+ extra = grouped - expected
54
+ if missing:
55
+ console.items ("Missing", "\n".join (sorted (missing)))
56
+ if extra:
57
+ console.items ("Extra", "\n".join (sorted (extra)))
58
+ return None
59
+
60
+ for g in groups:
61
+ msg = g.get ("message", "")
62
+ if not validate.commit (msg):
63
+ console.err ("AI output not in Conventional Commits format")
64
+ console.muted (msg)
65
+ return None
66
+
67
+ return groups
68
+
69
+
70
+ def split (
71
+ yes: bool = typer.Option (False, "--yes", "-y", help="Accept AI grouping without review"),
72
+ whisper: str = typer.Option ("", "--whisper", "-w", help="Hint to guide the AI"),
73
+ ):
74
+ """Group dirty files into logical commits via AI.
75
+
76
+ Analyzes all changed files and uses AI to partition them into logical
77
+ groups, each with its own Conventional Commits message. Commits each
78
+ group sequentially. Rolls back if any commit fails. Requires at least
79
+ two changed files.
80
+ """
81
+
82
+ git.require ()
83
+
84
+ root = git.repo_root ()
85
+
86
+ files = git.diff_names ()
87
+
88
+ if not files:
89
+ console.err ("No changes to split")
90
+ console.hint ("make some changes first")
91
+ raise typer.Exit (1)
92
+
93
+ if len (files) == 1:
94
+ console.err ("Only 1 file changed")
95
+ console.hint ("use imp commit instead")
96
+ raise typer.Exit (1)
97
+
98
+ console.header ("Split")
99
+
100
+ console.items (f"Files ({len (files)})", "\n".join (files))
101
+
102
+ file_diffs = _build_file_diffs (root, files)
103
+ file_diffs = ai.truncate (file_diffs)
104
+ b = git.branch ()
105
+
106
+ response = ai.smart (prompts.split (file_diffs, b, whisper))
107
+ response = re.sub (r"^```\w*\n?", "", response, flags=re.MULTILINE)
108
+ response = re.sub (r"\n?```$", "", response.strip ())
109
+
110
+ groups = _validate_response (response, files)
111
+
112
+ if groups is None:
113
+ console.warn ("Retrying...")
114
+ response = ai.smart (prompts.split (file_diffs, b, whisper))
115
+ response = re.sub (r"^```\w*\n?", "", response, flags=re.MULTILINE)
116
+ response = re.sub (r"\n?```$", "", response.strip ())
117
+ groups = _validate_response (response, files)
118
+
119
+ if groups is None:
120
+ console.err ("Split failed after retry")
121
+ raise typer.Exit (1)
122
+
123
+ if len (groups) == 1:
124
+ console.warn ("AI grouped everything into a single commit")
125
+ console.hint ("use imp commit instead")
126
+ raise typer.Exit (0)
127
+
128
+ console.out.print ()
129
+ for i, g in enumerate (groups):
130
+ console.label (f"Group {i + 1}: {g ['message']}")
131
+ for f in g ["files"]:
132
+ console.item (f)
133
+ console.out.print ()
134
+
135
+ if not yes and not console.confirm (f"Commit {len (groups)} groups?"):
136
+ console.muted ("Cancelled")
137
+ raise typer.Exit (0)
138
+
139
+ for g in groups:
140
+ mtimes = []
141
+ for f in g ["files"]:
142
+ full = Path (root) / f
143
+ if full.exists ():
144
+ mtimes.append (full.stat ().st_mtime)
145
+ g ["date"] = max (mtimes) if mtimes else 0
146
+
147
+ groups.sort (key=lambda g: g ["date"])
148
+
149
+ original_head = git.rev_parse ("HEAD")
150
+ is_fresh = git.commit_count () == 0
151
+
152
+ git.stage (all=True)
153
+
154
+ try:
155
+ for i, g in enumerate (groups):
156
+ git.unstage ()
157
+ git.add (g ["files"])
158
+
159
+ date = ""
160
+ if g ["date"] > 0:
161
+ dt = datetime.fromtimestamp (g ["date"], tz=timezone.utc)
162
+ date = dt.strftime ("%Y-%m-%dT%H:%M:%S%z")
163
+
164
+ git.commit (g ["message"], date=date)
165
+ console.success (f"Group {i + 1}: {g ['message']}")
166
+ except Exception as e:
167
+ console.err (f"Split failed: {e}")
168
+ console.warn ("Rolling back...")
169
+ if is_fresh:
170
+ git.unstage ()
171
+ else:
172
+ git.reset (original_head, soft=True)
173
+ git.unstage ()
174
+ raise typer.Exit (1) from None
175
+
176
+ console.hint ("imp log to review")