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/commands/fix.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from imp import ai, console, git, prompts, validate
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def fix (
|
|
11
|
+
issue: int = typer.Argument (..., help="GitHub issue number"),
|
|
12
|
+
yes: bool = typer.Option (False, "--yes", "-y", help="Accept suggested branch name"),
|
|
13
|
+
whisper: str = typer.Option ("", "--whisper", "-w", help="Hint to guide the AI"),
|
|
14
|
+
):
|
|
15
|
+
"""Create a branch from a GitHub issue.
|
|
16
|
+
|
|
17
|
+
Fetches the issue title and body from GitHub using the gh CLI, then
|
|
18
|
+
uses AI to generate a branch name. After creation, displays the issue
|
|
19
|
+
context so you can start working immediately.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
git.require ()
|
|
23
|
+
|
|
24
|
+
if not shutil.which ("gh"):
|
|
25
|
+
console.err ("GitHub CLI (gh) not installed")
|
|
26
|
+
console.hint ("https://cli.github.com")
|
|
27
|
+
raise typer.Exit (1)
|
|
28
|
+
|
|
29
|
+
console.header (f"Fix Issue #{issue}")
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
result = console.spin (
|
|
33
|
+
"Fetching issue...",
|
|
34
|
+
subprocess.run,
|
|
35
|
+
[ "gh", "issue", "view", str (issue), "--json", "title,body,labels" ],
|
|
36
|
+
capture_output=True,
|
|
37
|
+
text=True,
|
|
38
|
+
check=True,
|
|
39
|
+
)
|
|
40
|
+
data = json.loads (result.stdout)
|
|
41
|
+
except (subprocess.CalledProcessError, json.JSONDecodeError, OSError) as e:
|
|
42
|
+
console.err (f"Could not fetch issue #{issue}: {e}")
|
|
43
|
+
raise typer.Exit (1) from None
|
|
44
|
+
|
|
45
|
+
title = data.get ("title", "")
|
|
46
|
+
body = (data.get ("body", "") or "") [:500]
|
|
47
|
+
|
|
48
|
+
console.label ("Issue")
|
|
49
|
+
console.item (f"#{issue}: {title}")
|
|
50
|
+
console.out.print ()
|
|
51
|
+
|
|
52
|
+
name = ai.fast (prompts.fix (title, body, whisper))
|
|
53
|
+
name = ai.oneline (name)
|
|
54
|
+
|
|
55
|
+
if not validate.branch (name):
|
|
56
|
+
console.err (f"Invalid branch name: {name}")
|
|
57
|
+
raise typer.Exit (1)
|
|
58
|
+
|
|
59
|
+
console.label ("Branch")
|
|
60
|
+
console.item (name)
|
|
61
|
+
console.out.print ()
|
|
62
|
+
|
|
63
|
+
if yes or console.confirm ("Create branch?"):
|
|
64
|
+
git.checkout (name, create=True)
|
|
65
|
+
console.success (f"Switched to {name}")
|
|
66
|
+
console.out.print ()
|
|
67
|
+
console.label ("Context")
|
|
68
|
+
console.divider ()
|
|
69
|
+
console.out.print (title)
|
|
70
|
+
console.out.print ()
|
|
71
|
+
body_preview = "\n".join (body.splitlines () [:10])
|
|
72
|
+
if body_preview:
|
|
73
|
+
console.out.print (body_preview)
|
|
74
|
+
console.divider ()
|
|
75
|
+
console.hint ("make changes, then imp commit")
|
|
76
|
+
else:
|
|
77
|
+
console.muted ("Cancelled")
|
|
78
|
+
raise typer.Exit (0)
|
imp/commands/help.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from imp import console
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def help ():
|
|
5
|
+
"""Show workflow guide and common commands.
|
|
6
|
+
|
|
7
|
+
Prints a quick-reference of all imp commands organized by workflow
|
|
8
|
+
phase (starting, working, syncing, shipping) with common flow
|
|
9
|
+
examples for solo, feature-branch, and hotfix patterns.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
console.header ("imp workflow")
|
|
13
|
+
|
|
14
|
+
console.out.print ("imp wraps git with AI. You commit locally as you work,")
|
|
15
|
+
console.out.print ("then squash everything into a clean release when ready.")
|
|
16
|
+
console.out.print ()
|
|
17
|
+
|
|
18
|
+
console.divider ()
|
|
19
|
+
console.out.print ()
|
|
20
|
+
|
|
21
|
+
console.out.print ("[bold]Starting a feature[/bold]")
|
|
22
|
+
console.out.print (' imp branch "add auth" [muted]# create branch from description[/muted]')
|
|
23
|
+
console.out.print (" imp branch [muted]# switch between branches[/muted]")
|
|
24
|
+
console.out.print (" imp fix 42 [muted]# or from a GitHub issue[/muted]")
|
|
25
|
+
console.out.print ()
|
|
26
|
+
|
|
27
|
+
console.out.print ("[bold]While working[/bold]")
|
|
28
|
+
console.out.print (" imp review [muted]# AI code review[/muted]")
|
|
29
|
+
console.out.print (" imp commit -a [muted]# stage all + AI commit message[/muted]")
|
|
30
|
+
console.out.print (" imp split [muted]# group changes into logical commits[/muted]")
|
|
31
|
+
console.out.print (" imp amend [muted]# fix last commit[/muted]")
|
|
32
|
+
console.out.print (" imp undo [N] [muted]# undo last N commits[/muted]")
|
|
33
|
+
console.out.print (" imp revert <hash> [muted]# safely undo a pushed commit[/muted]")
|
|
34
|
+
console.out.print ()
|
|
35
|
+
|
|
36
|
+
console.out.print ("[bold]Staying in sync[/bold]")
|
|
37
|
+
console.out.print (" imp sync [muted]# pull, rebase, push[/muted]")
|
|
38
|
+
console.out.print (" imp resolve [muted]# AI merge conflict resolution[/muted]")
|
|
39
|
+
console.out.print (" imp status [muted]# repo overview[/muted]")
|
|
40
|
+
console.out.print (" imp log [muted]# pretty commit graph[/muted]")
|
|
41
|
+
console.out.print ()
|
|
42
|
+
|
|
43
|
+
console.out.print ("[bold]Shipping[/bold]")
|
|
44
|
+
console.out.print (" imp pr [muted]# create pull request[/muted]")
|
|
45
|
+
console.out.print (" imp done [muted]# clean up after PR merge[/muted]")
|
|
46
|
+
console.out.print (" imp clean [muted]# delete merged branches[/muted]")
|
|
47
|
+
console.out.print (" imp release [muted]# squash + changelog + tag + push[/muted]")
|
|
48
|
+
console.out.print (" imp ship [level] [muted]# commit + release, no prompts[/muted]")
|
|
49
|
+
console.out.print ()
|
|
50
|
+
|
|
51
|
+
console.out.print ("[bold]Setup[/bold]")
|
|
52
|
+
console.out.print (" imp config [muted]# configure AI provider and models[/muted]")
|
|
53
|
+
console.out.print (" imp doctor [muted]# verify tools and connection[/muted]")
|
|
54
|
+
console.out.print ()
|
|
55
|
+
|
|
56
|
+
console.divider ()
|
|
57
|
+
console.out.print ()
|
|
58
|
+
|
|
59
|
+
console.out.print ("[bold]Commit format[/bold] [muted](Conventional Commits)[/muted]")
|
|
60
|
+
console.out.print ()
|
|
61
|
+
console.out.print (" [muted]type: message[/muted]")
|
|
62
|
+
console.out.print (" [muted]type(scope): message[/muted]")
|
|
63
|
+
console.out.print (" [muted]type!: message breaking change[/muted]")
|
|
64
|
+
console.out.print ()
|
|
65
|
+
console.out.print (" feat [muted]new feature[/muted] build [muted]build system, deps[/muted]")
|
|
66
|
+
console.out.print (" fix [muted]bug fix[/muted] chore [muted]maintenance, config[/muted]")
|
|
67
|
+
console.out.print (" refactor [muted]restructure code[/muted] docs [muted]documentation[/muted]")
|
|
68
|
+
console.out.print (" test [muted]add/update tests[/muted] style [muted]formatting, whitespace[/muted]")
|
|
69
|
+
console.out.print (" perf [muted]performance[/muted] ci [muted]CI/CD pipelines[/muted]")
|
|
70
|
+
console.out.print ()
|
|
71
|
+
console.out.print (" [muted]Tickets go after the colon:[/muted] fix: IMP-123 resolve timeout")
|
|
72
|
+
console.out.print (" [muted]Scopes are optional:[/muted] refactor(auth): simplify flow")
|
|
73
|
+
console.out.print ()
|
|
74
|
+
console.out.print (" [muted]All lowercase after colon (except ticket IDs)[/muted]")
|
|
75
|
+
console.out.print (" [muted]Imperative mood (add, not added). Max 72 chars, no period.[/muted]")
|
|
76
|
+
console.out.print ()
|
|
77
|
+
|
|
78
|
+
console.divider ()
|
|
79
|
+
console.out.print ()
|
|
80
|
+
|
|
81
|
+
console.out.print ("[bold]AI whisper[/bold]")
|
|
82
|
+
console.out.print ()
|
|
83
|
+
console.out.print (' [muted]Any AI command accepts[/muted] --whisper / -w [muted]to hint the AI:[/muted]')
|
|
84
|
+
console.out.print ()
|
|
85
|
+
console.out.print (' imp commit -a -w "use IMP-99999 as ticket"')
|
|
86
|
+
console.out.print (' imp branch "auth flow" -w "use feat/ prefix"')
|
|
87
|
+
console.out.print (' imp review -w "focus on error handling"')
|
|
88
|
+
console.out.print ()
|
|
89
|
+
|
|
90
|
+
console.divider ()
|
|
91
|
+
console.out.print ()
|
|
92
|
+
|
|
93
|
+
console.out.print ("[bold]Common flows[/bold]")
|
|
94
|
+
console.out.print ()
|
|
95
|
+
console.muted ("Solo (trunk-based):")
|
|
96
|
+
console.out.print (" imp commit -a → imp commit -a → imp release")
|
|
97
|
+
console.out.print ()
|
|
98
|
+
console.muted ("Feature branch:")
|
|
99
|
+
console.out.print (" imp branch → imp commit -a → imp pr → imp done")
|
|
100
|
+
console.out.print ()
|
|
101
|
+
console.muted ("Hotfix:")
|
|
102
|
+
console.out.print (" imp fix 42 → imp commit -a → imp pr → imp done")
|
|
103
|
+
console.out.print ()
|
imp/commands/log.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from imp import console, git
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def log (
|
|
7
|
+
count: int = typer.Option (20, "-n", help="Number of commits"),
|
|
8
|
+
ref: str | None = typer.Argument (None, help="Branch or commit ref"),
|
|
9
|
+
):
|
|
10
|
+
"""Show pretty commit graph.
|
|
11
|
+
|
|
12
|
+
Displays a decorated commit graph with branch topology. Defaults to
|
|
13
|
+
the last 20 commits; use -n to adjust. Optionally pass a branch or
|
|
14
|
+
ref to view its history instead of the current branch.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
git.require ()
|
|
18
|
+
|
|
19
|
+
console.header ("Log")
|
|
20
|
+
|
|
21
|
+
output = git.log_graph (count, ref or "")
|
|
22
|
+
if output:
|
|
23
|
+
print (output)
|
|
24
|
+
else:
|
|
25
|
+
console.muted ("No commits")
|
imp/commands/pr.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from imp import ai, console, git, prompts
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _parse_response (content: str) -> tuple [str, str]:
|
|
10
|
+
title = ""
|
|
11
|
+
for line in content.splitlines ():
|
|
12
|
+
if line.startswith ("TITLE:"):
|
|
13
|
+
title = line [6:].strip ()
|
|
14
|
+
break
|
|
15
|
+
|
|
16
|
+
description = ""
|
|
17
|
+
parts = content.split ("DESCRIPTION:", 1)
|
|
18
|
+
if len (parts) > 1:
|
|
19
|
+
description = parts [1].strip ()
|
|
20
|
+
|
|
21
|
+
return title, description
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def pr (
|
|
25
|
+
yes: bool = typer.Option (False, "--yes", "-y", help="Accept AI description without review"),
|
|
26
|
+
whisper: str = typer.Option ("", "--whisper", "-w", help="Hint to guide the AI"),
|
|
27
|
+
):
|
|
28
|
+
"""Create a GitHub pull request with AI-generated description.
|
|
29
|
+
|
|
30
|
+
Diffs the current branch against the base branch, then uses AI to
|
|
31
|
+
generate a PR title and description. Pushes to origin if needed and
|
|
32
|
+
creates the PR via the gh CLI. Requires gh to be installed.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
git.require ()
|
|
36
|
+
|
|
37
|
+
if not shutil.which ("gh"):
|
|
38
|
+
console.err ("GitHub CLI (gh) not installed")
|
|
39
|
+
console.hint ("https://cli.github.com")
|
|
40
|
+
raise typer.Exit (1)
|
|
41
|
+
|
|
42
|
+
console.header ("Pull Request")
|
|
43
|
+
|
|
44
|
+
b = git.branch ()
|
|
45
|
+
base = git.base_branch ()
|
|
46
|
+
|
|
47
|
+
if b == base:
|
|
48
|
+
console.err (f"Cannot create PR from {base}")
|
|
49
|
+
console.hint ("imp branch <description>")
|
|
50
|
+
raise typer.Exit (1)
|
|
51
|
+
|
|
52
|
+
log = git.log_oneline (rev_range=f"{base}..{b}")
|
|
53
|
+
|
|
54
|
+
if not log:
|
|
55
|
+
console.err (f"No commits on {b}")
|
|
56
|
+
raise typer.Exit (1)
|
|
57
|
+
|
|
58
|
+
console.label ("Branch")
|
|
59
|
+
console.item (f"{b} → {base}")
|
|
60
|
+
console.out.print ()
|
|
61
|
+
|
|
62
|
+
console.items ("Commits", log)
|
|
63
|
+
|
|
64
|
+
d = git.diff_range (f"{base}..{b}", max_lines=ai.MAX_DIFF_LINES)
|
|
65
|
+
|
|
66
|
+
pr_content = console.spin (
|
|
67
|
+
"Thinking...",
|
|
68
|
+
ai.smart,
|
|
69
|
+
prompts.pr (b, log, d, whisper),
|
|
70
|
+
False,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
title, description = _parse_response (pr_content)
|
|
74
|
+
|
|
75
|
+
if not title:
|
|
76
|
+
console.warn ("Could not parse title, using branch name")
|
|
77
|
+
title = b
|
|
78
|
+
|
|
79
|
+
console.label ("Title")
|
|
80
|
+
console.item (title)
|
|
81
|
+
console.out.print ()
|
|
82
|
+
|
|
83
|
+
console.label ("Description")
|
|
84
|
+
console.divider ()
|
|
85
|
+
console.md (description)
|
|
86
|
+
console.divider ()
|
|
87
|
+
console.out.print ()
|
|
88
|
+
|
|
89
|
+
if not yes:
|
|
90
|
+
choice = console.choose ("Create PR?", [ "Yes", "Edit", "No" ])
|
|
91
|
+
|
|
92
|
+
if choice == "Edit":
|
|
93
|
+
edited = console.edit (f"{title}\n\n{description}")
|
|
94
|
+
lines = edited.splitlines ()
|
|
95
|
+
title = lines [0] if lines else title
|
|
96
|
+
description = "\n".join (lines [2:]) if len (lines) > 2 else description
|
|
97
|
+
elif choice == "No":
|
|
98
|
+
console.muted ("Cancelled")
|
|
99
|
+
raise typer.Exit (0)
|
|
100
|
+
|
|
101
|
+
if not git.has_upstream ():
|
|
102
|
+
console.spin ("Pushing to origin...", git.push, False, True, b)
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
result = subprocess.run (
|
|
106
|
+
[
|
|
107
|
+
"gh", "pr", "create",
|
|
108
|
+
"--title", title,
|
|
109
|
+
"--body", description,
|
|
110
|
+
"--base", base,
|
|
111
|
+
],
|
|
112
|
+
capture_output=True,
|
|
113
|
+
text=True,
|
|
114
|
+
check=True,
|
|
115
|
+
)
|
|
116
|
+
pr_url = result.stdout.strip ()
|
|
117
|
+
except subprocess.CalledProcessError:
|
|
118
|
+
console.err ("Failed to create PR")
|
|
119
|
+
raise typer.Exit (1) from None
|
|
120
|
+
|
|
121
|
+
console.out.print ()
|
|
122
|
+
console.success ("Created PR")
|
|
123
|
+
console.item (pr_url)
|
|
124
|
+
|
|
125
|
+
console.hint ("gh pr view --web to open in browser")
|
imp/commands/push.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from imp import console, git
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def push ():
|
|
9
|
+
"""Push commits to origin.
|
|
10
|
+
|
|
11
|
+
Pushes the current branch. Sets upstream tracking on first push.
|
|
12
|
+
Does not create tags, changelogs, or releases.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
git.require ()
|
|
16
|
+
git.require_clean ()
|
|
17
|
+
|
|
18
|
+
console.header ("Push")
|
|
19
|
+
|
|
20
|
+
b = git.branch ()
|
|
21
|
+
|
|
22
|
+
if not git.remote_exists ():
|
|
23
|
+
console.err ("No remote configured")
|
|
24
|
+
console.hint ("git remote add origin <url>")
|
|
25
|
+
raise typer.Exit (1)
|
|
26
|
+
|
|
27
|
+
ahead = 0
|
|
28
|
+
if git.has_upstream ():
|
|
29
|
+
git.fetch ()
|
|
30
|
+
ahead = git.count_ahead ()
|
|
31
|
+
|
|
32
|
+
if ahead == 0:
|
|
33
|
+
console.success ("Nothing to push")
|
|
34
|
+
raise typer.Exit (0)
|
|
35
|
+
|
|
36
|
+
console.item (f"{ahead} commits on {b}")
|
|
37
|
+
git.push ()
|
|
38
|
+
else:
|
|
39
|
+
console.item (f"Setting upstream for {b}")
|
|
40
|
+
git.push (set_upstream=True, target=b)
|
|
41
|
+
|
|
42
|
+
console.success ("Pushed to origin")
|
imp/commands/release.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import subprocess
|
|
3
|
+
from datetime import date
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from imp import console, git, version
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _rollback_release (
|
|
12
|
+
ver: str,
|
|
13
|
+
original_head: str,
|
|
14
|
+
changelog_path: Path,
|
|
15
|
+
original_changelog: str,
|
|
16
|
+
committed: bool,
|
|
17
|
+
):
|
|
18
|
+
console.warn ("Rolling back...")
|
|
19
|
+
git.tag_delete (f"v{ver}")
|
|
20
|
+
if committed:
|
|
21
|
+
git.reset (original_head, hard=True)
|
|
22
|
+
if original_changelog:
|
|
23
|
+
changelog_path.write_text (original_changelog)
|
|
24
|
+
elif changelog_path.is_file ():
|
|
25
|
+
changelog_path.unlink ()
|
|
26
|
+
console.err ("Release failed")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _write_changelog (path: Path, new_entry: str):
|
|
30
|
+
if path.is_file ():
|
|
31
|
+
content = path.read_text ()
|
|
32
|
+
|
|
33
|
+
lines = content.splitlines (keepends=True)
|
|
34
|
+
insert_at = None
|
|
35
|
+
for i, line in enumerate (lines):
|
|
36
|
+
if line.lstrip ().startswith ("## "):
|
|
37
|
+
insert_at = i
|
|
38
|
+
break
|
|
39
|
+
|
|
40
|
+
if insert_at is not None:
|
|
41
|
+
before = "".join (lines [:insert_at])
|
|
42
|
+
after = "".join (lines [insert_at:])
|
|
43
|
+
content = before + new_entry + "\n\n" + after
|
|
44
|
+
else:
|
|
45
|
+
content = content + "\n" + new_entry + "\n"
|
|
46
|
+
|
|
47
|
+
path.write_text (content)
|
|
48
|
+
else:
|
|
49
|
+
path.write_text (
|
|
50
|
+
f"# Changelog\n\n"
|
|
51
|
+
f"All notable changes to this project will be documented in this file.\n\n"
|
|
52
|
+
f"{new_entry}\n"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _squash_commits (tag: str, summary: str, changelog_path: str, count: int) -> bool:
|
|
57
|
+
can_squash = False
|
|
58
|
+
if tag:
|
|
59
|
+
if git.has_upstream ():
|
|
60
|
+
unpushed = git.log_oneline (rev_range="@{u}..HEAD")
|
|
61
|
+
if unpushed:
|
|
62
|
+
can_squash = True
|
|
63
|
+
else:
|
|
64
|
+
can_squash = True
|
|
65
|
+
|
|
66
|
+
if can_squash:
|
|
67
|
+
git.reset (tag, soft=True)
|
|
68
|
+
git.stage (all=True)
|
|
69
|
+
git.commit (summary)
|
|
70
|
+
console.success (f"Squashed {count} commits")
|
|
71
|
+
else:
|
|
72
|
+
git.add ([ str (changelog_path) ])
|
|
73
|
+
git.commit (summary)
|
|
74
|
+
if tag:
|
|
75
|
+
console.muted ("Commits already pushed, skipped squash")
|
|
76
|
+
|
|
77
|
+
return can_squash
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _push_release (ver: str, entry: str, can_squash: bool):
|
|
81
|
+
if git.has_upstream ():
|
|
82
|
+
if can_squash:
|
|
83
|
+
git.push (force_lease=True)
|
|
84
|
+
else:
|
|
85
|
+
git.push ()
|
|
86
|
+
else:
|
|
87
|
+
b = git.branch ()
|
|
88
|
+
git.push (set_upstream=True, target=b)
|
|
89
|
+
|
|
90
|
+
git.push (ref=f"v{ver}")
|
|
91
|
+
console.success ("Pushed to origin")
|
|
92
|
+
|
|
93
|
+
if shutil.which ("gh"):
|
|
94
|
+
try:
|
|
95
|
+
subprocess.run (
|
|
96
|
+
[
|
|
97
|
+
"gh", "release", "create",
|
|
98
|
+
f"v{ver}",
|
|
99
|
+
"--title", f"v{ver}",
|
|
100
|
+
"--notes", entry,
|
|
101
|
+
],
|
|
102
|
+
capture_output=True,
|
|
103
|
+
text=True,
|
|
104
|
+
check=True,
|
|
105
|
+
)
|
|
106
|
+
console.success ("Created GitHub release")
|
|
107
|
+
except subprocess.CalledProcessError:
|
|
108
|
+
console.muted ("GitHub release skipped (gh auth or repo issue)")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def release ():
|
|
112
|
+
"""Squash, changelog, tag, and push a release.
|
|
113
|
+
|
|
114
|
+
Collects commits since the last tag, lets you pick a semver bump,
|
|
115
|
+
generates a changelog entry, squashes unpushed commits into one, tags
|
|
116
|
+
the release, and optionally pushes with a GitHub release. Rolls back
|
|
117
|
+
automatically if anything fails.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
git.require ()
|
|
121
|
+
|
|
122
|
+
base = git.base_branch ()
|
|
123
|
+
current = git.branch ()
|
|
124
|
+
if current != base:
|
|
125
|
+
console.warn (f"Releasing from {current}, not {base}")
|
|
126
|
+
if not console.confirm ("Continue?"):
|
|
127
|
+
console.muted ("Cancelled")
|
|
128
|
+
raise typer.Exit (0)
|
|
129
|
+
|
|
130
|
+
git.require_clean ("imp commit first")
|
|
131
|
+
|
|
132
|
+
tag = git.last_tag ()
|
|
133
|
+
|
|
134
|
+
log = ""
|
|
135
|
+
if tag:
|
|
136
|
+
log = git.log_oneline (rev_range=f"{tag}..HEAD")
|
|
137
|
+
|
|
138
|
+
if not log:
|
|
139
|
+
log = git.log_oneline (count=20)
|
|
140
|
+
tag = ""
|
|
141
|
+
|
|
142
|
+
if not log:
|
|
143
|
+
console.muted ("No commits to release")
|
|
144
|
+
raise typer.Exit (0)
|
|
145
|
+
|
|
146
|
+
count = len (log.splitlines ())
|
|
147
|
+
|
|
148
|
+
console.header ("Release")
|
|
149
|
+
|
|
150
|
+
console.items (f"Commits since {tag or 'beginning'}", log)
|
|
151
|
+
|
|
152
|
+
highest = git.highest_tag ()
|
|
153
|
+
current = highest.lstrip ("v") if highest else "0.0.0"
|
|
154
|
+
if not current:
|
|
155
|
+
current = "0.0.0"
|
|
156
|
+
|
|
157
|
+
patch_ver = version.bump (current, "patch")
|
|
158
|
+
minor_ver = version.bump (current, "minor")
|
|
159
|
+
major_ver = version.bump (current, "major")
|
|
160
|
+
|
|
161
|
+
console.muted (f"Current: {current}")
|
|
162
|
+
console.out.print ()
|
|
163
|
+
|
|
164
|
+
choice = console.choose (
|
|
165
|
+
"Version bump",
|
|
166
|
+
[
|
|
167
|
+
f"patch {patch_ver}",
|
|
168
|
+
f"minor {minor_ver}",
|
|
169
|
+
f"major {major_ver}",
|
|
170
|
+
"custom",
|
|
171
|
+
"quit",
|
|
172
|
+
],
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if choice.startswith ("patch"):
|
|
176
|
+
new_version = patch_ver
|
|
177
|
+
elif choice.startswith ("minor"):
|
|
178
|
+
new_version = minor_ver
|
|
179
|
+
elif choice.startswith ("major"):
|
|
180
|
+
new_version = major_ver
|
|
181
|
+
elif choice == "custom":
|
|
182
|
+
new_version = console.prompt ("Version:", patch_ver)
|
|
183
|
+
else:
|
|
184
|
+
console.muted ("Cancelled")
|
|
185
|
+
raise typer.Exit (0)
|
|
186
|
+
|
|
187
|
+
if git.tag_exists (f"v{new_version}"):
|
|
188
|
+
console.err (f"Tag v{new_version} already exists")
|
|
189
|
+
console.hint (f"pick a different version, or: git tag -d v{new_version}")
|
|
190
|
+
raise typer.Exit (1)
|
|
191
|
+
|
|
192
|
+
if tag:
|
|
193
|
+
subjects = git.log_subjects (rev_range=f"{tag}..HEAD")
|
|
194
|
+
else:
|
|
195
|
+
subjects = git.log_subjects (count=count)
|
|
196
|
+
|
|
197
|
+
entry = version.changelog_from_commits (subjects)
|
|
198
|
+
|
|
199
|
+
console.label (f"v{new_version}")
|
|
200
|
+
console.divider ()
|
|
201
|
+
console.md (entry)
|
|
202
|
+
console.divider ()
|
|
203
|
+
console.out.print ()
|
|
204
|
+
|
|
205
|
+
choice = console.choose (
|
|
206
|
+
f"Release v{new_version}?",
|
|
207
|
+
[ "Yes", "Edit", "No" ],
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if choice == "Edit":
|
|
211
|
+
entry = console.edit (entry)
|
|
212
|
+
console.out.print ()
|
|
213
|
+
console.label (f"v{new_version} (edited)")
|
|
214
|
+
console.divider ()
|
|
215
|
+
console.md (entry)
|
|
216
|
+
console.divider ()
|
|
217
|
+
console.out.print ()
|
|
218
|
+
if not console.confirm (f"Release v{new_version}?"):
|
|
219
|
+
console.muted ("Cancelled")
|
|
220
|
+
raise typer.Exit (0)
|
|
221
|
+
elif choice == "No":
|
|
222
|
+
console.muted ("Cancelled")
|
|
223
|
+
raise typer.Exit (0)
|
|
224
|
+
|
|
225
|
+
will_push = console.confirm ("Push after release?")
|
|
226
|
+
|
|
227
|
+
if will_push and not git.remote_exists ():
|
|
228
|
+
console.err ("No remote configured")
|
|
229
|
+
console.hint ("git remote add origin <url>")
|
|
230
|
+
raise typer.Exit (1)
|
|
231
|
+
|
|
232
|
+
summary = f"chore: release v{new_version}"
|
|
233
|
+
today = date.today ().isoformat ()
|
|
234
|
+
new_entry = f"## [{new_version}] - {today}\n\n{entry}"
|
|
235
|
+
|
|
236
|
+
root = git.repo_root ()
|
|
237
|
+
changelog_path = Path (root) / "CHANGELOG.md"
|
|
238
|
+
|
|
239
|
+
original_head = git.rev_parse ("HEAD")
|
|
240
|
+
original_changelog = ""
|
|
241
|
+
if changelog_path.is_file ():
|
|
242
|
+
original_changelog = changelog_path.read_text ()
|
|
243
|
+
|
|
244
|
+
committed = False
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
_write_changelog (changelog_path, new_entry)
|
|
248
|
+
console.success ("Updated CHANGELOG.md")
|
|
249
|
+
|
|
250
|
+
can_squash = _squash_commits (tag, summary, changelog_path, count)
|
|
251
|
+
committed = True
|
|
252
|
+
|
|
253
|
+
git.tag (f"v{new_version}")
|
|
254
|
+
console.success (f"Tagged v{new_version}")
|
|
255
|
+
|
|
256
|
+
except (subprocess.CalledProcessError, OSError) as e:
|
|
257
|
+
msg = getattr (e, "stderr", "") or str (e)
|
|
258
|
+
console.err (f"Release failed: {msg.strip ()}")
|
|
259
|
+
_rollback_release (
|
|
260
|
+
new_version, original_head, changelog_path,
|
|
261
|
+
original_changelog, committed,
|
|
262
|
+
)
|
|
263
|
+
raise typer.Exit (1) from None
|
|
264
|
+
|
|
265
|
+
if will_push:
|
|
266
|
+
try:
|
|
267
|
+
_push_release (new_version, entry, can_squash)
|
|
268
|
+
except (subprocess.CalledProcessError, OSError) as e:
|
|
269
|
+
msg = getattr (e, "stderr", "") or str (e)
|
|
270
|
+
console.err (f"Push failed: {msg.strip ()}")
|
|
271
|
+
raise typer.Exit (1) from None
|
|
272
|
+
|
|
273
|
+
console.hint ("make changes, then imp commit")
|