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/__init__.py +7 -0
- imp/__main__.py +3 -0
- imp/ai.py +130 -0
- imp/commands/__init__.py +0 -0
- imp/commands/amend.py +68 -0
- imp/commands/branch.py +72 -0
- imp/commands/clean.py +47 -0
- imp/commands/commit.py +67 -0
- imp/commands/config.py +62 -0
- imp/commands/doctor.py +87 -0
- imp/commands/done.py +98 -0
- imp/commands/fix.py +78 -0
- imp/commands/help.py +103 -0
- imp/commands/log.py +25 -0
- imp/commands/pr.py +125 -0
- imp/commands/push.py +42 -0
- imp/commands/release.py +273 -0
- imp/commands/resolve.py +161 -0
- imp/commands/revert.py +83 -0
- imp/commands/review.py +76 -0
- imp/commands/ship.py +131 -0
- imp/commands/split.py +176 -0
- imp/commands/status.py +96 -0
- imp/commands/sync.py +64 -0
- imp/commands/undo.py +45 -0
- imp/config.py +51 -0
- imp/console.py +168 -0
- imp/git.py +428 -0
- imp/main.py +74 -0
- imp/prompts.py +168 -0
- imp/theme.py +14 -0
- imp/validate.py +28 -0
- imp/version.py +67 -0
- imp_git-0.0.26.dist-info/METADATA +226 -0
- imp_git-0.0.26.dist-info/RECORD +38 -0
- imp_git-0.0.26.dist-info/WHEEL +4 -0
- imp_git-0.0.26.dist-info/entry_points.txt +2 -0
- imp_git-0.0.26.dist-info/licenses/LICENSE +21 -0
imp/__init__.py
ADDED
imp/__main__.py
ADDED
imp/ai.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
import urllib.error
|
|
4
|
+
import urllib.request
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from imp import config, console
|
|
9
|
+
|
|
10
|
+
MAX_DIFF_LINES = 2000
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _claude (prompt: str, model: str) -> str:
|
|
14
|
+
result = subprocess.run (
|
|
15
|
+
[
|
|
16
|
+
"claude", "-p",
|
|
17
|
+
"--model", model,
|
|
18
|
+
"--max-turns", "1",
|
|
19
|
+
"--tools", "",
|
|
20
|
+
],
|
|
21
|
+
input=prompt,
|
|
22
|
+
capture_output=True,
|
|
23
|
+
text=True,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if result.returncode != 0:
|
|
27
|
+
console.err ("claude CLI failed")
|
|
28
|
+
raise typer.Exit (1)
|
|
29
|
+
|
|
30
|
+
return result.stdout
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _ollama (prompt: str, model: str) -> str:
|
|
34
|
+
payload = json.dumps ({
|
|
35
|
+
"model": model,
|
|
36
|
+
"prompt": prompt,
|
|
37
|
+
"stream": False,
|
|
38
|
+
}).encode ()
|
|
39
|
+
|
|
40
|
+
req = urllib.request.Request (
|
|
41
|
+
"http://localhost:11434/api/generate",
|
|
42
|
+
data=payload,
|
|
43
|
+
headers={"Content-Type": "application/json"},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
with urllib.request.urlopen (req) as resp:
|
|
48
|
+
body = json.loads (resp.read ())
|
|
49
|
+
return body.get ("response", "")
|
|
50
|
+
except (urllib.error.URLError, json.JSONDecodeError, OSError) as e:
|
|
51
|
+
console.err (f"ollama request failed: {e}")
|
|
52
|
+
raise typer.Exit (1) from None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _call (prompt: str, model: str) -> str:
|
|
56
|
+
provider = config.get ("provider")
|
|
57
|
+
if provider == "claude":
|
|
58
|
+
return _claude (prompt, model)
|
|
59
|
+
elif provider == "ollama":
|
|
60
|
+
return _ollama (prompt, model)
|
|
61
|
+
else:
|
|
62
|
+
console.err (f"Unknown AI provider: {provider}")
|
|
63
|
+
raise typer.Exit (1)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def fast (prompt: str, spin: bool = True) -> str:
|
|
67
|
+
model = config.get ("model:fast")
|
|
68
|
+
if spin:
|
|
69
|
+
result = console.spin ("Thinking...", _call, prompt, model)
|
|
70
|
+
else:
|
|
71
|
+
result = _call (prompt, model)
|
|
72
|
+
if not result or not result.strip ():
|
|
73
|
+
console.err ("Empty response from AI")
|
|
74
|
+
raise typer.Exit (1)
|
|
75
|
+
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def smart (prompt: str, spin: bool = True) -> str:
|
|
80
|
+
model = config.get ("model:smart")
|
|
81
|
+
if spin:
|
|
82
|
+
result = console.spin ("Thinking...", _call, prompt, model)
|
|
83
|
+
else:
|
|
84
|
+
result = _call (prompt, model)
|
|
85
|
+
if not result or not result.strip ():
|
|
86
|
+
console.err ("Empty response from AI")
|
|
87
|
+
raise typer.Exit (1)
|
|
88
|
+
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def ping () -> bool:
|
|
93
|
+
try:
|
|
94
|
+
model = config.get ("model:fast")
|
|
95
|
+
result = _call ("Reply with OK", model)
|
|
96
|
+
return bool (result and result.strip ())
|
|
97
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
|
|
98
|
+
return False
|
|
99
|
+
except SystemExit:
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def oneline (text: str) -> str:
|
|
104
|
+
return text.replace ("\n", "").strip ()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def truncate (text: str, max_lines: int = MAX_DIFF_LINES) -> str:
|
|
108
|
+
lines = text.splitlines ()
|
|
109
|
+
if len (lines) <= max_lines:
|
|
110
|
+
return text
|
|
111
|
+
return "\n".join (lines [:max_lines])
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def commit_message (prompt: str) -> str:
|
|
115
|
+
from imp import validate
|
|
116
|
+
|
|
117
|
+
msg = fast (prompt)
|
|
118
|
+
msg = oneline (msg)
|
|
119
|
+
|
|
120
|
+
if not validate.commit (msg):
|
|
121
|
+
console.warn ("Retrying (invalid format)...")
|
|
122
|
+
msg = fast (prompt)
|
|
123
|
+
msg = oneline (msg)
|
|
124
|
+
|
|
125
|
+
if not validate.commit (msg):
|
|
126
|
+
console.err ("AI output not in Conventional Commits format")
|
|
127
|
+
console.muted (msg)
|
|
128
|
+
raise typer.Exit (1)
|
|
129
|
+
|
|
130
|
+
return msg
|
imp/commands/__init__.py
ADDED
|
File without changes
|
imp/commands/amend.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from imp import ai, console, git, prompts
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def amend (
|
|
7
|
+
yes: bool = typer.Option (False, "--yes", "-y", help="Accept AI message without review"),
|
|
8
|
+
whisper: str = typer.Option ("", "--whisper", "-w", help="Hint to guide the AI"),
|
|
9
|
+
):
|
|
10
|
+
"""Amend last commit with a new AI-generated message.
|
|
11
|
+
|
|
12
|
+
Stages any uncommitted changes, regenerates the commit message from the
|
|
13
|
+
full diff, and amends the previous commit. You can review, edit, or
|
|
14
|
+
cancel the new message before it's applied.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
git.require ()
|
|
18
|
+
|
|
19
|
+
console.header ("Amend")
|
|
20
|
+
|
|
21
|
+
total = git.commit_count ()
|
|
22
|
+
if total == 0:
|
|
23
|
+
console.err ("No commits to amend")
|
|
24
|
+
console.hint ("imp commit first")
|
|
25
|
+
raise typer.Exit (1)
|
|
26
|
+
|
|
27
|
+
last_msg = git.show ("HEAD", fmt="%s")
|
|
28
|
+
|
|
29
|
+
if total == 1:
|
|
30
|
+
combined = git.show ("HEAD")
|
|
31
|
+
changes = git.diff ()
|
|
32
|
+
if changes:
|
|
33
|
+
combined = combined + "\n" + changes
|
|
34
|
+
else:
|
|
35
|
+
combined = git.diff_range ("HEAD~1")
|
|
36
|
+
|
|
37
|
+
if not combined:
|
|
38
|
+
console.err ("Nothing to amend")
|
|
39
|
+
raise typer.Exit (1)
|
|
40
|
+
|
|
41
|
+
msg = ai.commit_message (prompts.commit (combined, whisper=whisper))
|
|
42
|
+
|
|
43
|
+
console.label ("Previous")
|
|
44
|
+
console.item (last_msg)
|
|
45
|
+
console.out.print ()
|
|
46
|
+
|
|
47
|
+
git.stage (all=True)
|
|
48
|
+
|
|
49
|
+
if yes:
|
|
50
|
+
console.item (msg)
|
|
51
|
+
git.commit (msg, amend=True)
|
|
52
|
+
else:
|
|
53
|
+
choice = console.review (msg)
|
|
54
|
+
|
|
55
|
+
if choice == "Edit":
|
|
56
|
+
msg = console.edit (msg)
|
|
57
|
+
if not msg.strip ():
|
|
58
|
+
console.muted ("Empty message, cancelled")
|
|
59
|
+
raise typer.Exit (0)
|
|
60
|
+
git.commit (msg, amend=True)
|
|
61
|
+
elif choice == "Yes":
|
|
62
|
+
git.commit (msg, amend=True)
|
|
63
|
+
else:
|
|
64
|
+
console.muted ("Cancelled")
|
|
65
|
+
raise typer.Exit (0)
|
|
66
|
+
|
|
67
|
+
console.success ("Amended")
|
|
68
|
+
console.hint ("imp commit again, or imp release when ready")
|
imp/commands/branch.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from imp import ai, console, git, prompts, validate
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def branch (
|
|
7
|
+
description: list [str] | None = typer.Argument (None, help="Branch description"),
|
|
8
|
+
yes: bool = typer.Option (False, "--yes", "-y", help="Accept suggested branch name"),
|
|
9
|
+
whisper: str = typer.Option ("", "--whisper", "-w", help="Hint to guide the AI"),
|
|
10
|
+
):
|
|
11
|
+
"""Create or switch branches. No args: interactive picker.
|
|
12
|
+
|
|
13
|
+
With arguments, uses AI to generate a clean branch name from your
|
|
14
|
+
description. Without arguments, shows local branches with their age
|
|
15
|
+
and lets you pick one to switch to.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
git.require ()
|
|
19
|
+
|
|
20
|
+
if not description:
|
|
21
|
+
_switch ()
|
|
22
|
+
else:
|
|
23
|
+
_create (" ".join (description), whisper, yes)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _switch ():
|
|
27
|
+
console.header ("Branches")
|
|
28
|
+
|
|
29
|
+
branches = git.branches_local ()
|
|
30
|
+
if len (branches) <= 1:
|
|
31
|
+
console.muted ("Only one branch exists")
|
|
32
|
+
raise typer.Exit (0)
|
|
33
|
+
|
|
34
|
+
labels = []
|
|
35
|
+
for b in branches:
|
|
36
|
+
age = git.branch_age (b)
|
|
37
|
+
labels.append (f"{b} ({age})")
|
|
38
|
+
|
|
39
|
+
choice = console.choose ("Switch to:", labels)
|
|
40
|
+
target = choice.split (" (") [0]
|
|
41
|
+
|
|
42
|
+
current = git.branch ()
|
|
43
|
+
if target == current:
|
|
44
|
+
console.muted (f"Already on {target}")
|
|
45
|
+
raise typer.Exit (0)
|
|
46
|
+
|
|
47
|
+
git.require_clean ()
|
|
48
|
+
git.checkout (target)
|
|
49
|
+
console.success (f"Switched to {target}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _create (desc: str, whisper: str = "", yes: bool = False):
|
|
53
|
+
console.header ("Branch")
|
|
54
|
+
|
|
55
|
+
name = ai.fast (prompts.branch_name (desc, whisper))
|
|
56
|
+
name = ai.oneline (name)
|
|
57
|
+
|
|
58
|
+
if not validate.branch (name):
|
|
59
|
+
console.err (f"Invalid branch name: {name}")
|
|
60
|
+
raise typer.Exit (1)
|
|
61
|
+
|
|
62
|
+
console.label ("Suggested")
|
|
63
|
+
console.item (name)
|
|
64
|
+
console.out.print ()
|
|
65
|
+
|
|
66
|
+
if yes or console.confirm ("Create branch?"):
|
|
67
|
+
git.checkout (name, create=True)
|
|
68
|
+
console.success (f"Switched to {name}")
|
|
69
|
+
console.hint ("make changes, then imp commit")
|
|
70
|
+
else:
|
|
71
|
+
console.muted ("Cancelled")
|
|
72
|
+
raise typer.Exit (0)
|
imp/commands/clean.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from imp import console, git
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def clean ():
|
|
7
|
+
"""Delete branches already merged into base.
|
|
8
|
+
|
|
9
|
+
Fetches from origin, finds local branches fully merged into the base
|
|
10
|
+
branch (main/master), and deletes them locally and remotely. Prompts
|
|
11
|
+
for confirmation before deleting.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
git.require ()
|
|
15
|
+
|
|
16
|
+
console.header ("Clean")
|
|
17
|
+
|
|
18
|
+
base = git.base_branch ()
|
|
19
|
+
|
|
20
|
+
console.spin ("Fetching...", git.fetch, True)
|
|
21
|
+
|
|
22
|
+
merged = git.branches_merged (base)
|
|
23
|
+
|
|
24
|
+
if not merged:
|
|
25
|
+
console.success ("No merged branches to clean")
|
|
26
|
+
raise typer.Exit (0)
|
|
27
|
+
|
|
28
|
+
console.label (f"Merged into {base}")
|
|
29
|
+
for b in merged:
|
|
30
|
+
console.item (b)
|
|
31
|
+
console.out.print ()
|
|
32
|
+
|
|
33
|
+
count = len (merged)
|
|
34
|
+
if not console.confirm (f"Delete {count} branch(es)?"):
|
|
35
|
+
console.muted ("Cancelled")
|
|
36
|
+
raise typer.Exit (0)
|
|
37
|
+
|
|
38
|
+
console.out.print ()
|
|
39
|
+
for b in merged:
|
|
40
|
+
git.delete_branch (b)
|
|
41
|
+
console.success (f"Deleted {b}")
|
|
42
|
+
|
|
43
|
+
if git.remote_has_branch (b):
|
|
44
|
+
git.delete_branch (b, remote=True)
|
|
45
|
+
console.success (f"Deleted remote {b}")
|
|
46
|
+
|
|
47
|
+
console.hint ("imp branch to start something new")
|
imp/commands/commit.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from fnmatch import fnmatch
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from imp import ai, console, git, prompts
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def commit (
|
|
10
|
+
all: bool = typer.Option (False, "--all", "-a", help="Stage all changes first"),
|
|
11
|
+
exclude: Optional [list [str]] = typer.Option (None, "--exclude", "-E", help="Glob patterns to exclude from staging"),
|
|
12
|
+
yes: bool = typer.Option (False, "--yes", "-y", help="Accept AI message without review"),
|
|
13
|
+
whisper: str = typer.Option ("", "--whisper", "-w", help="Hint to guide the AI"),
|
|
14
|
+
):
|
|
15
|
+
"""Generate an AI commit message for staged changes.
|
|
16
|
+
|
|
17
|
+
Reads the staged diff and uses AI to produce a Conventional Commits
|
|
18
|
+
message. Use --all/-a to stage everything first. You can review, edit,
|
|
19
|
+
or cancel the message before committing.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
git.require ()
|
|
23
|
+
|
|
24
|
+
if all:
|
|
25
|
+
git.stage (all=True)
|
|
26
|
+
|
|
27
|
+
if exclude:
|
|
28
|
+
staged = git.staged_files ()
|
|
29
|
+
to_unstage = [
|
|
30
|
+
f for f in staged
|
|
31
|
+
if any (fnmatch (f, pat) for pat in exclude)
|
|
32
|
+
]
|
|
33
|
+
if to_unstage:
|
|
34
|
+
git.unstage (to_unstage)
|
|
35
|
+
console.muted (f"Excluded {len (to_unstage)} files")
|
|
36
|
+
|
|
37
|
+
d = git.diff (staged=True)
|
|
38
|
+
if not d:
|
|
39
|
+
console.err ("Nothing staged")
|
|
40
|
+
console.hint ("git add <files>, or imp commit -a")
|
|
41
|
+
raise typer.Exit (1)
|
|
42
|
+
|
|
43
|
+
console.header ("Commit")
|
|
44
|
+
|
|
45
|
+
b = git.branch ()
|
|
46
|
+
msg = ai.commit_message (prompts.commit (d, b, whisper))
|
|
47
|
+
|
|
48
|
+
if yes:
|
|
49
|
+
console.item (msg)
|
|
50
|
+
git.commit (msg)
|
|
51
|
+
else:
|
|
52
|
+
choice = console.review (msg)
|
|
53
|
+
|
|
54
|
+
if choice == "Edit":
|
|
55
|
+
msg = console.edit (msg)
|
|
56
|
+
if not msg.strip ():
|
|
57
|
+
console.muted ("Empty message, cancelled")
|
|
58
|
+
raise typer.Exit (0)
|
|
59
|
+
git.commit (msg)
|
|
60
|
+
elif choice == "Yes":
|
|
61
|
+
git.commit (msg)
|
|
62
|
+
else:
|
|
63
|
+
console.muted ("Cancelled")
|
|
64
|
+
raise typer.Exit (0)
|
|
65
|
+
|
|
66
|
+
console.success ("Committed")
|
|
67
|
+
console.hint ("imp commit again, or imp release when ready")
|
imp/commands/config.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from imp import config, console
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _provider_choices () -> list [str]:
|
|
5
|
+
return [ "claude", "ollama" ]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _claude_models () -> list [str]:
|
|
9
|
+
return [ "haiku", "sonnet", "opus" ]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _ollama_models () -> list [str]:
|
|
13
|
+
return [
|
|
14
|
+
"llama3.2",
|
|
15
|
+
"llama3.1",
|
|
16
|
+
"mistral",
|
|
17
|
+
"codellama",
|
|
18
|
+
"custom",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def configure ():
|
|
23
|
+
"""Interactively configure AI provider and models.
|
|
24
|
+
|
|
25
|
+
Sets the AI provider (Claude or Ollama) and which models to use for
|
|
26
|
+
fast and smart operations. Configuration is stored in
|
|
27
|
+
~/.config/imp/config.json.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
console.header ("Config")
|
|
31
|
+
|
|
32
|
+
cfg = config.load ()
|
|
33
|
+
|
|
34
|
+
console.muted (f"Config: {config.path ()}")
|
|
35
|
+
console.out.print ()
|
|
36
|
+
|
|
37
|
+
provider = console.choose ("AI provider", _provider_choices ())
|
|
38
|
+
cfg ["provider"] = provider
|
|
39
|
+
|
|
40
|
+
if provider == "claude":
|
|
41
|
+
models = _claude_models ()
|
|
42
|
+
else:
|
|
43
|
+
models = _ollama_models ()
|
|
44
|
+
|
|
45
|
+
fast = console.choose ("Fast model (commits, branches)", models)
|
|
46
|
+
if fast == "custom":
|
|
47
|
+
fast = console.prompt ("Model name:")
|
|
48
|
+
cfg ["model:fast"] = fast
|
|
49
|
+
|
|
50
|
+
smart = console.choose ("Smart model (review, PR, split)", models)
|
|
51
|
+
if smart == "custom":
|
|
52
|
+
smart = console.prompt ("Model name:")
|
|
53
|
+
cfg ["model:smart"] = smart
|
|
54
|
+
|
|
55
|
+
config.save (cfg)
|
|
56
|
+
|
|
57
|
+
console.out.print ()
|
|
58
|
+
console.success ("Saved")
|
|
59
|
+
console.muted (f" provider: {cfg ['provider']}")
|
|
60
|
+
console.muted (f" model:fast: {cfg ['model:fast']}")
|
|
61
|
+
console.muted (f" model:smart: {cfg ['model:smart']}")
|
|
62
|
+
console.hint ("imp doctor to verify")
|
imp/commands/doctor.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from imp import ai, config, console
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _check (name: str, cmd: str, url: str, required: bool = True) -> bool:
|
|
10
|
+
path = shutil.which (cmd)
|
|
11
|
+
if path:
|
|
12
|
+
try:
|
|
13
|
+
result = subprocess.run (
|
|
14
|
+
[ cmd, "--version" ],
|
|
15
|
+
capture_output=True,
|
|
16
|
+
text=True,
|
|
17
|
+
timeout=5,
|
|
18
|
+
)
|
|
19
|
+
version = result.stdout.strip ().splitlines () [0] if result.stdout.strip () else "installed"
|
|
20
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
|
|
21
|
+
version = "installed"
|
|
22
|
+
console.success (f"{name} ({version})")
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
if required:
|
|
26
|
+
console.err (f"{name} not found")
|
|
27
|
+
console.item (url)
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
console.muted (f" {name} not found (optional)")
|
|
31
|
+
console.item (url)
|
|
32
|
+
return True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def doctor ():
|
|
36
|
+
"""Check tools and configuration.
|
|
37
|
+
|
|
38
|
+
Verifies that required (git) and optional (claude, ollama, gh) tools are
|
|
39
|
+
installed, shows their versions, and confirms at least one AI provider is
|
|
40
|
+
available. Also displays the active provider and model settings from
|
|
41
|
+
~/.config/imp/config.json.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
console.header ("Doctor")
|
|
45
|
+
|
|
46
|
+
ok = True
|
|
47
|
+
ok = _check ("git", "git", "https://git-scm.com") and ok
|
|
48
|
+
_check ("claude", "claude", "https://docs.anthropic.com/en/docs/claude-code", required=False)
|
|
49
|
+
_check ("ollama", "ollama", "https://ollama.com", required=False)
|
|
50
|
+
_check ("gh", "gh", "https://cli.github.com", required=False)
|
|
51
|
+
|
|
52
|
+
console.out.print ()
|
|
53
|
+
|
|
54
|
+
has_claude = shutil.which ("claude") is not None
|
|
55
|
+
has_ollama = shutil.which ("ollama") is not None
|
|
56
|
+
|
|
57
|
+
if not has_claude and not has_ollama:
|
|
58
|
+
console.err ("No AI provider found (need claude or ollama)")
|
|
59
|
+
ok = False
|
|
60
|
+
|
|
61
|
+
cfg = config.load ()
|
|
62
|
+
provider = cfg ["provider"]
|
|
63
|
+
console.muted (f"Provider: {provider}")
|
|
64
|
+
console.muted (f"Fast model: {cfg ['model:fast']}")
|
|
65
|
+
console.muted (f"Smart model: {cfg ['model:smart']}")
|
|
66
|
+
console.muted (f"Config: {config.path ()}")
|
|
67
|
+
|
|
68
|
+
console.out.print ()
|
|
69
|
+
|
|
70
|
+
if has_claude or has_ollama:
|
|
71
|
+
if console.spin ("Testing AI connection...", ai.ping):
|
|
72
|
+
console.success ("AI responding")
|
|
73
|
+
else:
|
|
74
|
+
console.err ("AI not responding")
|
|
75
|
+
if provider == "claude":
|
|
76
|
+
console.hint ("run: claude to authenticate")
|
|
77
|
+
else:
|
|
78
|
+
console.hint ("is ollama running? try: ollama serve")
|
|
79
|
+
ok = False
|
|
80
|
+
|
|
81
|
+
console.out.print ()
|
|
82
|
+
|
|
83
|
+
if ok:
|
|
84
|
+
console.success ("All good")
|
|
85
|
+
else:
|
|
86
|
+
console.warn ("Some issues found")
|
|
87
|
+
raise typer.Exit (1)
|
imp/commands/done.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from imp import console, git
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def done (
|
|
7
|
+
target: str | None = typer.Argument (None, help="Branch to merge into"),
|
|
8
|
+
yes: bool = typer.Option (False, "--yes", "-y", help="Skip confirmation"),
|
|
9
|
+
):
|
|
10
|
+
"""Merge feature branch into target, then clean up.
|
|
11
|
+
|
|
12
|
+
Merges the current branch into a target branch (defaults to main/master),
|
|
13
|
+
then deletes the feature branch locally and remotely. If the branch was
|
|
14
|
+
already merged via PR, pulls the target instead of merging locally.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
git.require ()
|
|
18
|
+
git.require_clean ("imp commit first")
|
|
19
|
+
|
|
20
|
+
feature = git.branch ()
|
|
21
|
+
base = target or git.base_branch ()
|
|
22
|
+
|
|
23
|
+
if feature == base:
|
|
24
|
+
console.err (f"Already on {base}")
|
|
25
|
+
console.hint ("switch to a feature branch first")
|
|
26
|
+
raise typer.Exit (1)
|
|
27
|
+
|
|
28
|
+
if target and not git.rev_parse (target):
|
|
29
|
+
if git.remote_has_branch (target):
|
|
30
|
+
git.checkout (target)
|
|
31
|
+
git.checkout (feature)
|
|
32
|
+
else:
|
|
33
|
+
console.err (f"Branch {target} does not exist")
|
|
34
|
+
raise typer.Exit (1)
|
|
35
|
+
|
|
36
|
+
console.header ("Done")
|
|
37
|
+
|
|
38
|
+
git.fetch (prune=True)
|
|
39
|
+
|
|
40
|
+
already_merged = False
|
|
41
|
+
remote_base = f"origin/{base}"
|
|
42
|
+
if git.rev_parse (remote_base):
|
|
43
|
+
already_merged = git.is_merged (feature, remote_base)
|
|
44
|
+
|
|
45
|
+
has_remote_feature = git.remote_has_branch (feature)
|
|
46
|
+
|
|
47
|
+
console.label (f"{feature} → {base}")
|
|
48
|
+
console.out.print ()
|
|
49
|
+
console.item (f"Switch to {base}")
|
|
50
|
+
|
|
51
|
+
if already_merged:
|
|
52
|
+
console.item ("Pull latest (already merged remotely)")
|
|
53
|
+
else:
|
|
54
|
+
console.item (f"Merge {feature} into {base} (--no-ff)")
|
|
55
|
+
|
|
56
|
+
console.item (f"Delete {feature} (local)")
|
|
57
|
+
|
|
58
|
+
if has_remote_feature:
|
|
59
|
+
console.item (f"Delete {feature} (remote)")
|
|
60
|
+
|
|
61
|
+
console.out.print ()
|
|
62
|
+
|
|
63
|
+
if not yes and not console.confirm ("Proceed?"):
|
|
64
|
+
console.muted ("Cancelled")
|
|
65
|
+
raise typer.Exit (0)
|
|
66
|
+
|
|
67
|
+
git.checkout (base)
|
|
68
|
+
|
|
69
|
+
if already_merged:
|
|
70
|
+
git.pull ()
|
|
71
|
+
console.success (f"Pulled {base} (already merged)")
|
|
72
|
+
else:
|
|
73
|
+
if git.has_upstream ():
|
|
74
|
+
git.pull ()
|
|
75
|
+
|
|
76
|
+
if not git.merge (feature, no_ff=True):
|
|
77
|
+
console.err ("Merge conflict")
|
|
78
|
+
console.hint ("imp resolve to fix conflicts")
|
|
79
|
+
raise typer.Exit (1)
|
|
80
|
+
|
|
81
|
+
console.success (f"Merged {feature} into {base}")
|
|
82
|
+
|
|
83
|
+
if git.delete_branch (feature):
|
|
84
|
+
console.success (f"Deleted local branch {feature}")
|
|
85
|
+
else:
|
|
86
|
+
console.warn (f"Branch {feature} has unmerged changes")
|
|
87
|
+
console.out.print ()
|
|
88
|
+
if console.confirm (f"Force delete {feature}?"):
|
|
89
|
+
git.delete_branch (feature, force=True)
|
|
90
|
+
console.success (f"Force deleted local branch {feature}")
|
|
91
|
+
else:
|
|
92
|
+
console.muted (f"Kept local branch {feature}")
|
|
93
|
+
|
|
94
|
+
if has_remote_feature:
|
|
95
|
+
git.delete_branch (feature, remote=True)
|
|
96
|
+
console.success (f"Deleted remote branch {feature}")
|
|
97
|
+
|
|
98
|
+
console.hint ("imp branch to start something new")
|