gitforge-cli 0.1.0__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.
- gitforge/__init__.py +4 -0
- gitforge/__main__.py +8 -0
- gitforge/changelog.py +101 -0
- gitforge/cli.py +530 -0
- gitforge/git.py +255 -0
- gitforge/templates.py +67 -0
- gitforge_cli-0.1.0.dist-info/METADATA +130 -0
- gitforge_cli-0.1.0.dist-info/RECORD +12 -0
- gitforge_cli-0.1.0.dist-info/WHEEL +5 -0
- gitforge_cli-0.1.0.dist-info/entry_points.txt +2 -0
- gitforge_cli-0.1.0.dist-info/licenses/LICENSE +22 -0
- gitforge_cli-0.1.0.dist-info/top_level.txt +1 -0
gitforge/__init__.py
ADDED
gitforge/__main__.py
ADDED
gitforge/changelog.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Changelog extraction and rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .git import git_text
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class CommitEntry:
|
|
13
|
+
"""A Git commit prepared for display."""
|
|
14
|
+
|
|
15
|
+
full_hash: str
|
|
16
|
+
short_hash: str
|
|
17
|
+
subject: str
|
|
18
|
+
author: str
|
|
19
|
+
date: str
|
|
20
|
+
group: str
|
|
21
|
+
|
|
22
|
+
def as_dict(self) -> dict[str, str]:
|
|
23
|
+
return asdict(self)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
GROUP_LABELS = {
|
|
27
|
+
"feat": "Features",
|
|
28
|
+
"fix": "Fixes",
|
|
29
|
+
"docs": "Documentation",
|
|
30
|
+
"test": "Tests",
|
|
31
|
+
"perf": "Performance",
|
|
32
|
+
"refactor": "Refactors",
|
|
33
|
+
"build": "Build",
|
|
34
|
+
"ci": "CI",
|
|
35
|
+
"other": "Other",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def classify_subject(subject: str) -> str:
|
|
40
|
+
"""Classify a commit using conventional-commit style prefixes."""
|
|
41
|
+
|
|
42
|
+
prefix = subject.split(":", 1)[0].lower()
|
|
43
|
+
prefix = prefix.split("(", 1)[0]
|
|
44
|
+
return prefix if prefix in GROUP_LABELS else "other"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_commits(
|
|
48
|
+
repo: str | Path,
|
|
49
|
+
*,
|
|
50
|
+
rev_range: str | None = None,
|
|
51
|
+
max_count: int = 100,
|
|
52
|
+
) -> list[CommitEntry]:
|
|
53
|
+
"""Load commits from git log."""
|
|
54
|
+
|
|
55
|
+
pretty = "%H%x1f%h%x1f%s%x1f%an%x1f%ad%x1e"
|
|
56
|
+
args = ["log", f"--max-count={max_count}", "--date=short", f"--pretty=format:{pretty}"]
|
|
57
|
+
if rev_range:
|
|
58
|
+
args.insert(1, rev_range)
|
|
59
|
+
output = git_text(args, repo)
|
|
60
|
+
commits: list[CommitEntry] = []
|
|
61
|
+
for record in output.split("\x1e"):
|
|
62
|
+
record = record.strip()
|
|
63
|
+
if not record:
|
|
64
|
+
continue
|
|
65
|
+
parts = record.split("\x1f")
|
|
66
|
+
if len(parts) != 5:
|
|
67
|
+
continue
|
|
68
|
+
full_hash, short_hash, subject, author, date = parts
|
|
69
|
+
commits.append(
|
|
70
|
+
CommitEntry(
|
|
71
|
+
full_hash=full_hash,
|
|
72
|
+
short_hash=short_hash,
|
|
73
|
+
subject=subject,
|
|
74
|
+
author=author,
|
|
75
|
+
date=date,
|
|
76
|
+
group=classify_subject(subject),
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
return commits
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def render_markdown(commits: list[CommitEntry], title: str = "Changelog") -> str:
|
|
83
|
+
"""Render commits as grouped Markdown."""
|
|
84
|
+
|
|
85
|
+
if not commits:
|
|
86
|
+
return f"## {title}\n\nNo commits found for the selected range.\n"
|
|
87
|
+
|
|
88
|
+
lines = [f"## {title}", ""]
|
|
89
|
+
for group, label in GROUP_LABELS.items():
|
|
90
|
+
group_commits = [commit for commit in commits if commit.group == group]
|
|
91
|
+
if not group_commits:
|
|
92
|
+
continue
|
|
93
|
+
lines.append(f"### {label}")
|
|
94
|
+
for commit in group_commits:
|
|
95
|
+
lines.append(
|
|
96
|
+
f"- `{commit.short_hash}` {commit.subject} "
|
|
97
|
+
f"({commit.author}, {commit.date})"
|
|
98
|
+
)
|
|
99
|
+
lines.append("")
|
|
100
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
101
|
+
|
gitforge/cli.py
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
"""Command line interface for GitForge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import wraps
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
from rich import box
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.markdown import Markdown
|
|
15
|
+
from rich.panel import Panel
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .changelog import load_commits, render_markdown
|
|
20
|
+
from .git import (
|
|
21
|
+
GitForgeError,
|
|
22
|
+
branch_exists,
|
|
23
|
+
current_branch,
|
|
24
|
+
get_status,
|
|
25
|
+
git_available,
|
|
26
|
+
git_text,
|
|
27
|
+
is_repo,
|
|
28
|
+
list_merged_branches,
|
|
29
|
+
origin_url,
|
|
30
|
+
repo_root,
|
|
31
|
+
run_git,
|
|
32
|
+
safe_git,
|
|
33
|
+
tag_exists,
|
|
34
|
+
upstream_branch,
|
|
35
|
+
)
|
|
36
|
+
from .templates import available_templates, render_templates
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
console = Console()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def handle_errors(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
43
|
+
"""Convert internal errors into clean Click errors."""
|
|
44
|
+
|
|
45
|
+
@wraps(func)
|
|
46
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
47
|
+
try:
|
|
48
|
+
return func(*args, **kwargs)
|
|
49
|
+
except ValueError as exc:
|
|
50
|
+
raise click.ClickException(str(exc)) from exc
|
|
51
|
+
except GitForgeError as exc:
|
|
52
|
+
raise click.ClickException(str(exc)) from exc
|
|
53
|
+
|
|
54
|
+
return wrapper
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def repo_option(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
58
|
+
"""Add a common repository option to a command."""
|
|
59
|
+
|
|
60
|
+
return click.option(
|
|
61
|
+
"--repo",
|
|
62
|
+
type=click.Path(file_okay=False, path_type=Path),
|
|
63
|
+
default=Path("."),
|
|
64
|
+
show_default=True,
|
|
65
|
+
help="Repository path.",
|
|
66
|
+
)(func)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def status_word(ok: bool, warn: bool = False) -> str:
|
|
70
|
+
"""Return a styled check status."""
|
|
71
|
+
|
|
72
|
+
if ok:
|
|
73
|
+
return "[green]PASS[/green]"
|
|
74
|
+
if warn:
|
|
75
|
+
return "[yellow]WARN[/yellow]"
|
|
76
|
+
return "[red]FAIL[/red]"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def slugify(value: str, *, max_length: int = 54) -> str:
|
|
80
|
+
"""Convert text into a Git-safe branch slug."""
|
|
81
|
+
|
|
82
|
+
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
|
83
|
+
slug = re.sub(r"-{2,}", "-", slug)
|
|
84
|
+
if not slug:
|
|
85
|
+
raise ValueError("branch title must contain at least one letter or number")
|
|
86
|
+
return slug[:max_length].strip("-")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def make_branch_name(prefix: str, ticket: str | None, title: str) -> str:
|
|
90
|
+
"""Compose a branch name from user inputs."""
|
|
91
|
+
|
|
92
|
+
cleaned_prefix = slugify(prefix, max_length=24)
|
|
93
|
+
pieces = []
|
|
94
|
+
if ticket:
|
|
95
|
+
pieces.append(slugify(ticket, max_length=24))
|
|
96
|
+
pieces.append(slugify(title))
|
|
97
|
+
return f"{cleaned_prefix}/{'-'.join(pieces)}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def print_command_output(output: str | None) -> None:
|
|
101
|
+
"""Print command output when Git returned any."""
|
|
102
|
+
|
|
103
|
+
if output:
|
|
104
|
+
console.print(output)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
108
|
+
@click.version_option(__version__, prog_name="gitforge")
|
|
109
|
+
def main() -> None:
|
|
110
|
+
"""Automate everyday Git workflows from one practical CLI."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@main.command("status")
|
|
114
|
+
@repo_option
|
|
115
|
+
@click.option("--json", "as_json", is_flag=True, help="Print machine-readable JSON.")
|
|
116
|
+
@handle_errors
|
|
117
|
+
def status_command(repo: Path, as_json: bool) -> None:
|
|
118
|
+
"""Show branch, dirty-state, remote, and upstream health."""
|
|
119
|
+
|
|
120
|
+
status = get_status(repo)
|
|
121
|
+
if as_json:
|
|
122
|
+
click.echo(json.dumps(status.as_dict(), indent=2))
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
table = Table(title="Repository Status", box=box.SIMPLE_HEAVY)
|
|
126
|
+
table.add_column("Field", style="cyan", no_wrap=True)
|
|
127
|
+
table.add_column("Value")
|
|
128
|
+
table.add_row("Root", status.root)
|
|
129
|
+
table.add_row("Branch", status.branch)
|
|
130
|
+
table.add_row("Commit", status.commit or "none")
|
|
131
|
+
table.add_row("Upstream", status.upstream or "not configured")
|
|
132
|
+
table.add_row("Ahead / Behind", f"{status.ahead} / {status.behind}")
|
|
133
|
+
table.add_row("Staged", str(status.staged))
|
|
134
|
+
table.add_row("Unstaged", str(status.unstaged))
|
|
135
|
+
table.add_row("Untracked", str(status.untracked))
|
|
136
|
+
table.add_row("Conflicts", str(status.conflicts))
|
|
137
|
+
table.add_row("Origin", status.remote or "not configured")
|
|
138
|
+
table.add_row("Last tag", status.last_tag or "none")
|
|
139
|
+
console.print(table)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@main.command()
|
|
143
|
+
@repo_option
|
|
144
|
+
@click.option("--json", "as_json", is_flag=True, help="Print machine-readable JSON.")
|
|
145
|
+
@handle_errors
|
|
146
|
+
def doctor(repo: Path, as_json: bool) -> None:
|
|
147
|
+
"""Check whether a repository is ready for daily workflow automation."""
|
|
148
|
+
|
|
149
|
+
checks: list[dict[str, str]] = []
|
|
150
|
+
checks.append(
|
|
151
|
+
{
|
|
152
|
+
"check": "git executable",
|
|
153
|
+
"status": "pass" if git_available() else "fail",
|
|
154
|
+
"detail": "available on PATH" if git_available() else "missing from PATH",
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
repo_ready = is_repo(repo)
|
|
159
|
+
checks.append(
|
|
160
|
+
{
|
|
161
|
+
"check": "repository",
|
|
162
|
+
"status": "pass" if repo_ready else "fail",
|
|
163
|
+
"detail": str(repo.resolve()) if repo_ready else "not inside a Git work tree",
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if repo_ready:
|
|
168
|
+
root = repo_root(repo)
|
|
169
|
+
name = safe_git(["config", "--get", "user.name"], root)
|
|
170
|
+
email = safe_git(["config", "--get", "user.email"], root)
|
|
171
|
+
remote = origin_url(root)
|
|
172
|
+
upstream = upstream_branch(root)
|
|
173
|
+
status = get_status(root)
|
|
174
|
+
checks.extend(
|
|
175
|
+
[
|
|
176
|
+
{
|
|
177
|
+
"check": "user.name",
|
|
178
|
+
"status": "pass" if name else "warn",
|
|
179
|
+
"detail": name or "not configured locally or globally",
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
"check": "user.email",
|
|
183
|
+
"status": "pass" if email else "warn",
|
|
184
|
+
"detail": email or "not configured locally or globally",
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
"check": "origin remote",
|
|
188
|
+
"status": "pass" if remote else "warn",
|
|
189
|
+
"detail": remote or "remote.origin.url not configured",
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
"check": "upstream",
|
|
193
|
+
"status": "pass" if upstream else "warn",
|
|
194
|
+
"detail": upstream or "current branch has no upstream",
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
"check": "merge conflicts",
|
|
198
|
+
"status": "pass" if status.conflicts == 0 else "fail",
|
|
199
|
+
"detail": f"{status.conflicts} conflict marker(s) in Git status",
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
"check": "working tree",
|
|
203
|
+
"status": "pass" if not status.is_dirty else "warn",
|
|
204
|
+
"detail": "clean" if not status.is_dirty else "contains local changes",
|
|
205
|
+
},
|
|
206
|
+
]
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if as_json:
|
|
210
|
+
click.echo(json.dumps(checks, indent=2))
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
table = Table(title="Repository Doctor", box=box.SIMPLE_HEAVY)
|
|
214
|
+
table.add_column("Check", style="cyan")
|
|
215
|
+
table.add_column("Status", justify="center")
|
|
216
|
+
table.add_column("Detail")
|
|
217
|
+
for item in checks:
|
|
218
|
+
state = item["status"]
|
|
219
|
+
table.add_row(
|
|
220
|
+
item["check"],
|
|
221
|
+
status_word(state == "pass", warn=state == "warn"),
|
|
222
|
+
item["detail"],
|
|
223
|
+
)
|
|
224
|
+
console.print(table)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@main.command()
|
|
228
|
+
@repo_option
|
|
229
|
+
@click.argument("title")
|
|
230
|
+
@click.option("--prefix", default="feature", show_default=True, help="Branch prefix.")
|
|
231
|
+
@click.option("--ticket", help="Optional ticket or issue id to include.")
|
|
232
|
+
@click.option("--from-ref", default="HEAD", show_default=True, help="Start point.")
|
|
233
|
+
@click.option("--checkout/--no-checkout", default=True, show_default=True, help="Switch to the branch after creating it.")
|
|
234
|
+
@click.option("--reuse", is_flag=True, help="Switch to an existing branch instead of failing.")
|
|
235
|
+
@handle_errors
|
|
236
|
+
def branch(
|
|
237
|
+
repo: Path,
|
|
238
|
+
title: str,
|
|
239
|
+
prefix: str,
|
|
240
|
+
ticket: str | None,
|
|
241
|
+
from_ref: str,
|
|
242
|
+
checkout: bool,
|
|
243
|
+
reuse: bool,
|
|
244
|
+
) -> None:
|
|
245
|
+
"""Create a consistently named workflow branch."""
|
|
246
|
+
|
|
247
|
+
root = repo_root(repo)
|
|
248
|
+
name = make_branch_name(prefix, ticket, title)
|
|
249
|
+
exists = branch_exists(root, name)
|
|
250
|
+
if exists and not reuse:
|
|
251
|
+
raise GitForgeError(f"branch already exists: {name}")
|
|
252
|
+
|
|
253
|
+
if exists and reuse:
|
|
254
|
+
if checkout:
|
|
255
|
+
print_command_output(git_text(["switch", name], root))
|
|
256
|
+
console.print(Panel.fit(f"[green]Using existing branch[/green]\n{name}"))
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
command = ["switch", "-c", name, from_ref] if checkout else ["branch", name, from_ref]
|
|
260
|
+
print_command_output(git_text(command, root))
|
|
261
|
+
console.print(Panel.fit(f"[green]Created branch[/green]\n{name}"))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@main.command()
|
|
265
|
+
@repo_option
|
|
266
|
+
@click.option("-m", "--message", required=True, help="Commit message.")
|
|
267
|
+
@click.option("--all/--tracked", "include_untracked", default=True, show_default=True, help="Stage all changes or only tracked files.")
|
|
268
|
+
@click.option("--allow-empty", is_flag=True, help="Allow creating an empty commit.")
|
|
269
|
+
@click.option("--push", is_flag=True, help="Push the current branch after committing.")
|
|
270
|
+
@click.option("--dry-run", is_flag=True, help="Show what would be committed.")
|
|
271
|
+
@handle_errors
|
|
272
|
+
def snapshot(
|
|
273
|
+
repo: Path,
|
|
274
|
+
message: str,
|
|
275
|
+
include_untracked: bool,
|
|
276
|
+
allow_empty: bool,
|
|
277
|
+
push: bool,
|
|
278
|
+
dry_run: bool,
|
|
279
|
+
) -> None:
|
|
280
|
+
"""Stage changes and create a commit with guardrails."""
|
|
281
|
+
|
|
282
|
+
root = repo_root(repo)
|
|
283
|
+
stage_args = ["add", "-A"] if include_untracked else ["add", "-u"]
|
|
284
|
+
|
|
285
|
+
if dry_run:
|
|
286
|
+
console.print(Panel.fit("[yellow]Dry run[/yellow]\nNo files were staged or committed."))
|
|
287
|
+
print_command_output(git_text(["status", "--short"], root))
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
git_text(stage_args, root)
|
|
291
|
+
status = get_status(root)
|
|
292
|
+
if status.staged == 0 and not allow_empty:
|
|
293
|
+
raise GitForgeError("no staged changes to commit; use --allow-empty to force a commit")
|
|
294
|
+
|
|
295
|
+
commit_args = ["commit", "-m", message]
|
|
296
|
+
if allow_empty:
|
|
297
|
+
commit_args.insert(1, "--allow-empty")
|
|
298
|
+
print_command_output(git_text(commit_args, root))
|
|
299
|
+
|
|
300
|
+
if push:
|
|
301
|
+
branch_name = current_branch(root)
|
|
302
|
+
upstream = upstream_branch(root)
|
|
303
|
+
if upstream:
|
|
304
|
+
print_command_output(git_text(["push"], root))
|
|
305
|
+
else:
|
|
306
|
+
if origin_url(root) is None:
|
|
307
|
+
raise GitForgeError("cannot push because origin remote is not configured")
|
|
308
|
+
print_command_output(git_text(["push", "-u", "origin", branch_name], root))
|
|
309
|
+
|
|
310
|
+
console.print("[green]Snapshot complete[/green]")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@main.command()
|
|
314
|
+
@repo_option
|
|
315
|
+
@click.option("--remote", default="origin", show_default=True, help="Remote to fetch and push.")
|
|
316
|
+
@click.option("--rebase/--merge", "use_rebase", default=True, show_default=True, help="Pull strategy.")
|
|
317
|
+
@click.option("--autostash", is_flag=True, help="Use git pull --autostash for local changes.")
|
|
318
|
+
@click.option("--push/--no-push", "do_push", default=True, show_default=True, help="Push after pulling.")
|
|
319
|
+
@handle_errors
|
|
320
|
+
def sync(repo: Path, remote: str, use_rebase: bool, autostash: bool, do_push: bool) -> None:
|
|
321
|
+
"""Fetch, pull, and optionally push the current branch."""
|
|
322
|
+
|
|
323
|
+
root = repo_root(repo)
|
|
324
|
+
branch_name = current_branch(root)
|
|
325
|
+
if branch_name.startswith("detached@") or branch_name == "unborn":
|
|
326
|
+
raise GitForgeError("sync requires a named branch")
|
|
327
|
+
|
|
328
|
+
print_command_output(git_text(["fetch", "--prune", remote], root))
|
|
329
|
+
|
|
330
|
+
upstream = upstream_branch(root)
|
|
331
|
+
if upstream:
|
|
332
|
+
pull_args = ["pull", "--rebase" if use_rebase else "--no-rebase"]
|
|
333
|
+
if autostash:
|
|
334
|
+
pull_args.append("--autostash")
|
|
335
|
+
print_command_output(git_text(pull_args, root))
|
|
336
|
+
else:
|
|
337
|
+
console.print("[yellow]No upstream configured; skipping pull.[/yellow]")
|
|
338
|
+
|
|
339
|
+
if do_push:
|
|
340
|
+
if upstream:
|
|
341
|
+
print_command_output(git_text(["push"], root))
|
|
342
|
+
else:
|
|
343
|
+
print_command_output(git_text(["push", "-u", remote, branch_name], root))
|
|
344
|
+
console.print("[green]Sync complete[/green]")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@main.command()
|
|
348
|
+
@repo_option
|
|
349
|
+
@click.argument("version")
|
|
350
|
+
@click.option("-m", "--message", help="Annotated tag message.")
|
|
351
|
+
@click.option("--push", is_flag=True, help="Push the tag to origin.")
|
|
352
|
+
@click.option("--dry-run", is_flag=True, help="Show the tag operation without changing the repo.")
|
|
353
|
+
@click.option("--allow-dirty", is_flag=True, help="Allow tagging with uncommitted changes.")
|
|
354
|
+
@handle_errors
|
|
355
|
+
def release(
|
|
356
|
+
repo: Path,
|
|
357
|
+
version: str,
|
|
358
|
+
message: str | None,
|
|
359
|
+
push: bool,
|
|
360
|
+
dry_run: bool,
|
|
361
|
+
allow_dirty: bool,
|
|
362
|
+
) -> None:
|
|
363
|
+
"""Create an annotated release tag."""
|
|
364
|
+
|
|
365
|
+
root = repo_root(repo)
|
|
366
|
+
tag = version if version.startswith("v") else f"v{version}"
|
|
367
|
+
if not re.match(r"^v?\d+\.\d+\.\d+([-.][0-9A-Za-z.]+)?$", version):
|
|
368
|
+
raise GitForgeError("version must look like 1.2.3, v1.2.3, or include a prerelease suffix")
|
|
369
|
+
if tag_exists(root, tag):
|
|
370
|
+
raise GitForgeError(f"tag already exists: {tag}")
|
|
371
|
+
status = get_status(root)
|
|
372
|
+
if status.is_dirty and not allow_dirty:
|
|
373
|
+
raise GitForgeError("working tree is not clean; commit changes or use --allow-dirty")
|
|
374
|
+
|
|
375
|
+
tag_message = message or f"Release {tag}"
|
|
376
|
+
if dry_run:
|
|
377
|
+
console.print(Panel.fit(f"[yellow]Dry run[/yellow]\nWould create annotated tag {tag}"))
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
print_command_output(git_text(["tag", "-a", tag, "-m", tag_message], root))
|
|
381
|
+
if push:
|
|
382
|
+
print_command_output(git_text(["push", "origin", tag], root))
|
|
383
|
+
console.print(Panel.fit(f"[green]Release tag created[/green]\n{tag}"))
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@main.command()
|
|
387
|
+
@repo_option
|
|
388
|
+
@click.option("--since", help="Start at this tag or commit, ending at HEAD.")
|
|
389
|
+
@click.option("--range", "rev_range", help="Explicit git revision range, such as v1.0.0..HEAD.")
|
|
390
|
+
@click.option("--max-count", default=100, show_default=True, type=click.IntRange(1, 1000), help="Maximum commits to read.")
|
|
391
|
+
@click.option("--output", type=click.Path(dir_okay=False, path_type=Path), help="Write Markdown changelog to a file.")
|
|
392
|
+
@click.option("--json", "as_json", is_flag=True, help="Print machine-readable JSON.")
|
|
393
|
+
@handle_errors
|
|
394
|
+
def changelog(
|
|
395
|
+
repo: Path,
|
|
396
|
+
since: str | None,
|
|
397
|
+
rev_range: str | None,
|
|
398
|
+
max_count: int,
|
|
399
|
+
output: Path | None,
|
|
400
|
+
as_json: bool,
|
|
401
|
+
) -> None:
|
|
402
|
+
"""Generate a grouped changelog from commit history."""
|
|
403
|
+
|
|
404
|
+
root = repo_root(repo)
|
|
405
|
+
selected_range = rev_range or (f"{since}..HEAD" if since else None)
|
|
406
|
+
commits = load_commits(root, rev_range=selected_range, max_count=max_count)
|
|
407
|
+
|
|
408
|
+
if as_json:
|
|
409
|
+
click.echo(json.dumps([commit.as_dict() for commit in commits], indent=2))
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
markdown = render_markdown(commits)
|
|
413
|
+
if output:
|
|
414
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
415
|
+
output.write_text(markdown, encoding="utf-8")
|
|
416
|
+
console.print(f"[green]Wrote changelog:[/green] {output}")
|
|
417
|
+
console.print(Markdown(markdown))
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
@main.command()
|
|
421
|
+
@repo_option
|
|
422
|
+
@click.argument("base")
|
|
423
|
+
@click.argument("head", required=False, default="HEAD")
|
|
424
|
+
@click.option("--json", "as_json", is_flag=True, help="Print machine-readable JSON.")
|
|
425
|
+
@handle_errors
|
|
426
|
+
def compare(repo: Path, base: str, head: str, as_json: bool) -> None:
|
|
427
|
+
"""Compare two refs with commit and file-change summaries."""
|
|
428
|
+
|
|
429
|
+
root = repo_root(repo)
|
|
430
|
+
commit_lines = git_text(["log", "--left-right", "--cherry-pick", "--oneline", f"{base}...{head}"], root)
|
|
431
|
+
file_lines = git_text(["diff", "--name-status", f"{base}...{head}"], root)
|
|
432
|
+
commits_left = [line[1:].strip() for line in commit_lines.splitlines() if line.startswith("<")]
|
|
433
|
+
commits_right = [line[1:].strip() for line in commit_lines.splitlines() if line.startswith(">")]
|
|
434
|
+
files = [line.split(maxsplit=1) for line in file_lines.splitlines() if line.strip()]
|
|
435
|
+
|
|
436
|
+
payload = {
|
|
437
|
+
"base": base,
|
|
438
|
+
"head": head,
|
|
439
|
+
"base_only_commits": commits_left,
|
|
440
|
+
"head_only_commits": commits_right,
|
|
441
|
+
"changed_files": [{"status": item[0], "path": item[1] if len(item) > 1 else ""} for item in files],
|
|
442
|
+
}
|
|
443
|
+
if as_json:
|
|
444
|
+
click.echo(json.dumps(payload, indent=2))
|
|
445
|
+
return
|
|
446
|
+
|
|
447
|
+
summary = Table(title=f"Compare {base}...{head}", box=box.SIMPLE_HEAVY)
|
|
448
|
+
summary.add_column("Metric", style="cyan")
|
|
449
|
+
summary.add_column("Count", justify="right")
|
|
450
|
+
summary.add_row("Commits only in base", str(len(commits_left)))
|
|
451
|
+
summary.add_row("Commits only in head", str(len(commits_right)))
|
|
452
|
+
summary.add_row("Changed files", str(len(files)))
|
|
453
|
+
console.print(summary)
|
|
454
|
+
|
|
455
|
+
if files:
|
|
456
|
+
file_table = Table(title="Changed Files", box=box.SIMPLE)
|
|
457
|
+
file_table.add_column("Status", style="cyan", no_wrap=True)
|
|
458
|
+
file_table.add_column("Path")
|
|
459
|
+
for item in payload["changed_files"]:
|
|
460
|
+
file_table.add_row(item["status"], item["path"])
|
|
461
|
+
console.print(file_table)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
@main.command()
|
|
465
|
+
@repo_option
|
|
466
|
+
@click.option("--apply", "apply_changes", is_flag=True, help="Delete branches instead of only listing them.")
|
|
467
|
+
@click.option("--protected", default="main,master,develop,dev,trunk", show_default=True, help="Comma-separated branches never to delete.")
|
|
468
|
+
@click.option("--prune", is_flag=True, help="Run git fetch --prune before checking merged branches.")
|
|
469
|
+
@handle_errors
|
|
470
|
+
def cleanup(repo: Path, apply_changes: bool, protected: str, prune: bool) -> None:
|
|
471
|
+
"""List or delete local branches already merged into HEAD."""
|
|
472
|
+
|
|
473
|
+
root = repo_root(repo)
|
|
474
|
+
if prune:
|
|
475
|
+
print_command_output(git_text(["fetch", "--prune"], root))
|
|
476
|
+
|
|
477
|
+
current = current_branch(root)
|
|
478
|
+
protected_names = {item.strip() for item in protected.split(",") if item.strip()}
|
|
479
|
+
candidates = [
|
|
480
|
+
branch_name
|
|
481
|
+
for branch_name in list_merged_branches(root)
|
|
482
|
+
if branch_name != current and branch_name not in protected_names
|
|
483
|
+
]
|
|
484
|
+
|
|
485
|
+
table = Table(title="Merged Branch Cleanup", box=box.SIMPLE_HEAVY)
|
|
486
|
+
table.add_column("Branch", style="cyan")
|
|
487
|
+
table.add_column("Action")
|
|
488
|
+
for branch_name in candidates:
|
|
489
|
+
if apply_changes:
|
|
490
|
+
git_text(["branch", "-d", branch_name], root)
|
|
491
|
+
table.add_row(branch_name, "deleted")
|
|
492
|
+
else:
|
|
493
|
+
table.add_row(branch_name, "would delete")
|
|
494
|
+
if not candidates:
|
|
495
|
+
table.add_row("-", "nothing to clean")
|
|
496
|
+
console.print(table)
|
|
497
|
+
if not apply_changes:
|
|
498
|
+
console.print("[yellow]Dry run. Add --apply to delete listed branches.[/yellow]")
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
@main.command("ignore")
|
|
502
|
+
@repo_option
|
|
503
|
+
@click.argument("templates", nargs=-1)
|
|
504
|
+
@click.option("--list", "list_templates", is_flag=True, help="List available templates.")
|
|
505
|
+
@handle_errors
|
|
506
|
+
def ignore_command(repo: Path, templates: tuple[str, ...], list_templates: bool) -> None:
|
|
507
|
+
"""Append useful snippets to .gitignore."""
|
|
508
|
+
|
|
509
|
+
if list_templates:
|
|
510
|
+
table = Table(title="Available .gitignore Templates", box=box.SIMPLE_HEAVY)
|
|
511
|
+
table.add_column("Template", style="cyan")
|
|
512
|
+
for name in available_templates():
|
|
513
|
+
table.add_row(name)
|
|
514
|
+
console.print(table)
|
|
515
|
+
return
|
|
516
|
+
|
|
517
|
+
if not templates:
|
|
518
|
+
raise GitForgeError("provide at least one template or use --list")
|
|
519
|
+
|
|
520
|
+
root = repo_root(repo)
|
|
521
|
+
gitignore = root / ".gitignore"
|
|
522
|
+
block = render_templates(list(templates))
|
|
523
|
+
existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
|
|
524
|
+
separator = "\n\n" if existing.strip() else ""
|
|
525
|
+
gitignore.write_text(existing.rstrip() + separator + block, encoding="utf-8")
|
|
526
|
+
console.print(f"[green]Updated[/green] {gitignore}")
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
if __name__ == "__main__":
|
|
530
|
+
main()
|
gitforge/git.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Small, typed wrappers around the Git executable."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from typing import Iterable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GitForgeError(RuntimeError):
|
|
13
|
+
"""Base exception for user-facing GitForge errors."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GitCommandError(GitForgeError):
|
|
17
|
+
"""Raised when a git command fails."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
command: Iterable[str],
|
|
22
|
+
cwd: Path,
|
|
23
|
+
returncode: int,
|
|
24
|
+
stdout: str,
|
|
25
|
+
stderr: str,
|
|
26
|
+
) -> None:
|
|
27
|
+
self.command = list(command)
|
|
28
|
+
self.cwd = cwd
|
|
29
|
+
self.returncode = returncode
|
|
30
|
+
self.stdout = stdout.strip()
|
|
31
|
+
self.stderr = stderr.strip()
|
|
32
|
+
message = self.stderr or self.stdout or f"git exited with {returncode}"
|
|
33
|
+
super().__init__(f"{' '.join(self.command)} failed in {cwd}: {message}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class RepoStatus:
|
|
38
|
+
"""A compact view of repository state."""
|
|
39
|
+
|
|
40
|
+
root: str
|
|
41
|
+
branch: str
|
|
42
|
+
commit: str | None
|
|
43
|
+
upstream: str | None
|
|
44
|
+
ahead: int
|
|
45
|
+
behind: int
|
|
46
|
+
staged: int
|
|
47
|
+
unstaged: int
|
|
48
|
+
untracked: int
|
|
49
|
+
conflicts: int
|
|
50
|
+
remote: str | None
|
|
51
|
+
last_tag: str | None
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def is_dirty(self) -> bool:
|
|
55
|
+
return any((self.staged, self.unstaged, self.untracked, self.conflicts))
|
|
56
|
+
|
|
57
|
+
def as_dict(self) -> dict[str, object]:
|
|
58
|
+
data = asdict(self)
|
|
59
|
+
data["is_dirty"] = self.is_dirty
|
|
60
|
+
return data
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def git_available() -> bool:
|
|
64
|
+
"""Return true when the Git executable can be found."""
|
|
65
|
+
|
|
66
|
+
return shutil.which("git") is not None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def normalize_path(path: str | Path) -> Path:
|
|
70
|
+
"""Normalize a user-provided repository path."""
|
|
71
|
+
|
|
72
|
+
resolved = Path(path).expanduser().resolve()
|
|
73
|
+
if not resolved.exists():
|
|
74
|
+
raise GitForgeError(f"path does not exist: {resolved}")
|
|
75
|
+
return resolved
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def run_git(
|
|
79
|
+
args: Iterable[str],
|
|
80
|
+
cwd: str | Path = ".",
|
|
81
|
+
*,
|
|
82
|
+
check: bool = True,
|
|
83
|
+
input_text: str | None = None,
|
|
84
|
+
) -> subprocess.CompletedProcess[str]:
|
|
85
|
+
"""Run a git command and return the completed process."""
|
|
86
|
+
|
|
87
|
+
if not git_available():
|
|
88
|
+
raise GitForgeError("git executable was not found on PATH")
|
|
89
|
+
|
|
90
|
+
workdir = normalize_path(cwd)
|
|
91
|
+
command = ["git", *list(args)]
|
|
92
|
+
process = subprocess.run(
|
|
93
|
+
command,
|
|
94
|
+
cwd=str(workdir),
|
|
95
|
+
input=input_text,
|
|
96
|
+
capture_output=True,
|
|
97
|
+
text=True,
|
|
98
|
+
)
|
|
99
|
+
if check and process.returncode != 0:
|
|
100
|
+
raise GitCommandError(
|
|
101
|
+
command=command,
|
|
102
|
+
cwd=workdir,
|
|
103
|
+
returncode=process.returncode,
|
|
104
|
+
stdout=process.stdout,
|
|
105
|
+
stderr=process.stderr,
|
|
106
|
+
)
|
|
107
|
+
return process
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def git_text(args: Iterable[str], cwd: str | Path = ".", *, check: bool = True) -> str:
|
|
111
|
+
"""Run git and return stripped stdout."""
|
|
112
|
+
|
|
113
|
+
return run_git(args, cwd, check=check).stdout.strip()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def safe_git(args: Iterable[str], cwd: str | Path = ".") -> str | None:
|
|
117
|
+
"""Run git and return stripped stdout, or None on failure."""
|
|
118
|
+
|
|
119
|
+
process = run_git(args, cwd, check=False)
|
|
120
|
+
if process.returncode != 0:
|
|
121
|
+
return None
|
|
122
|
+
output = process.stdout.strip()
|
|
123
|
+
return output or None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def repo_root(path: str | Path = ".") -> Path:
|
|
127
|
+
"""Return the root directory for a Git repository."""
|
|
128
|
+
|
|
129
|
+
root = safe_git(["rev-parse", "--show-toplevel"], path)
|
|
130
|
+
if root is None:
|
|
131
|
+
raise GitForgeError(f"not a git repository: {Path(path).expanduser().resolve()}")
|
|
132
|
+
return Path(root).resolve()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def is_repo(path: str | Path = ".") -> bool:
|
|
136
|
+
"""Return true if path is inside a Git work tree."""
|
|
137
|
+
|
|
138
|
+
return safe_git(["rev-parse", "--is-inside-work-tree"], path) == "true"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def current_branch(path: str | Path = ".") -> str:
|
|
142
|
+
"""Return the current branch, or a detached-head label."""
|
|
143
|
+
|
|
144
|
+
branch = safe_git(["branch", "--show-current"], path)
|
|
145
|
+
if branch:
|
|
146
|
+
return branch
|
|
147
|
+
commit = safe_git(["rev-parse", "--short", "HEAD"], path)
|
|
148
|
+
if commit:
|
|
149
|
+
return f"detached@{commit}"
|
|
150
|
+
return "unborn"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def current_commit(path: str | Path = ".") -> str | None:
|
|
154
|
+
"""Return the short current commit hash if one exists."""
|
|
155
|
+
|
|
156
|
+
return safe_git(["rev-parse", "--short", "HEAD"], path)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def upstream_branch(path: str | Path = ".") -> str | None:
|
|
160
|
+
"""Return the configured upstream branch."""
|
|
161
|
+
|
|
162
|
+
return safe_git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], path)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def origin_url(path: str | Path = ".") -> str | None:
|
|
166
|
+
"""Return origin URL when configured."""
|
|
167
|
+
|
|
168
|
+
return safe_git(["config", "--get", "remote.origin.url"], path)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def last_tag(path: str | Path = ".") -> str | None:
|
|
172
|
+
"""Return the closest reachable tag."""
|
|
173
|
+
|
|
174
|
+
return safe_git(["describe", "--tags", "--abbrev=0"], path)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def branch_exists(path: str | Path, branch: str) -> bool:
|
|
178
|
+
"""Return true when a local branch exists."""
|
|
179
|
+
|
|
180
|
+
return safe_git(["rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"], path) is not None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def tag_exists(path: str | Path, tag: str) -> bool:
|
|
184
|
+
"""Return true when a tag exists."""
|
|
185
|
+
|
|
186
|
+
return safe_git(["rev-parse", "--verify", "--quiet", f"refs/tags/{tag}"], path) is not None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def parse_porcelain(output: str) -> tuple[int, int, int, int]:
|
|
190
|
+
"""Return staged, unstaged, untracked, conflict counts from porcelain status."""
|
|
191
|
+
|
|
192
|
+
staged = unstaged = untracked = conflicts = 0
|
|
193
|
+
conflict_codes = {"DD", "AU", "UD", "UA", "DU", "AA", "UU"}
|
|
194
|
+
for raw_line in output.splitlines():
|
|
195
|
+
if not raw_line:
|
|
196
|
+
continue
|
|
197
|
+
code = raw_line[:2]
|
|
198
|
+
if code == "??":
|
|
199
|
+
untracked += 1
|
|
200
|
+
continue
|
|
201
|
+
if code in conflict_codes:
|
|
202
|
+
conflicts += 1
|
|
203
|
+
if code[0] not in (" ", "?"):
|
|
204
|
+
staged += 1
|
|
205
|
+
if code[1] not in (" ", "?"):
|
|
206
|
+
unstaged += 1
|
|
207
|
+
return staged, unstaged, untracked, conflicts
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def ahead_behind(path: str | Path = ".") -> tuple[int, int]:
|
|
211
|
+
"""Return ahead and behind counts against upstream."""
|
|
212
|
+
|
|
213
|
+
if upstream_branch(path) is None:
|
|
214
|
+
return 0, 0
|
|
215
|
+
counts = safe_git(["rev-list", "--left-right", "--count", "HEAD...@{u}"], path)
|
|
216
|
+
if not counts:
|
|
217
|
+
return 0, 0
|
|
218
|
+
left, right = counts.split()[:2]
|
|
219
|
+
return int(left), int(right)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def get_status(path: str | Path = ".") -> RepoStatus:
|
|
223
|
+
"""Collect repository status in one dataclass."""
|
|
224
|
+
|
|
225
|
+
root = repo_root(path)
|
|
226
|
+
porcelain = git_text(["status", "--porcelain=v1"], root)
|
|
227
|
+
staged, unstaged, untracked, conflicts = parse_porcelain(porcelain)
|
|
228
|
+
ahead, behind = ahead_behind(root)
|
|
229
|
+
return RepoStatus(
|
|
230
|
+
root=str(root),
|
|
231
|
+
branch=current_branch(root),
|
|
232
|
+
commit=current_commit(root),
|
|
233
|
+
upstream=upstream_branch(root),
|
|
234
|
+
ahead=ahead,
|
|
235
|
+
behind=behind,
|
|
236
|
+
staged=staged,
|
|
237
|
+
unstaged=unstaged,
|
|
238
|
+
untracked=untracked,
|
|
239
|
+
conflicts=conflicts,
|
|
240
|
+
remote=origin_url(root),
|
|
241
|
+
last_tag=last_tag(root),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def list_merged_branches(path: str | Path = ".") -> list[str]:
|
|
246
|
+
"""Return local branches already merged into HEAD."""
|
|
247
|
+
|
|
248
|
+
output = git_text(["branch", "--merged"], path)
|
|
249
|
+
branches: list[str] = []
|
|
250
|
+
for line in output.splitlines():
|
|
251
|
+
name = line.replace("*", "", 1).strip()
|
|
252
|
+
if name:
|
|
253
|
+
branches.append(name)
|
|
254
|
+
return branches
|
|
255
|
+
|
gitforge/templates.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Built-in .gitignore snippets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
IGNORE_TEMPLATES = {
|
|
7
|
+
"python": """# Python
|
|
8
|
+
__pycache__/
|
|
9
|
+
*.py[cod]
|
|
10
|
+
*.egg-info/
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
.venv/
|
|
15
|
+
venv/
|
|
16
|
+
build/
|
|
17
|
+
dist/
|
|
18
|
+
""",
|
|
19
|
+
"node": """# Node
|
|
20
|
+
node_modules/
|
|
21
|
+
npm-debug.log*
|
|
22
|
+
yarn-debug.log*
|
|
23
|
+
yarn-error.log*
|
|
24
|
+
.next/
|
|
25
|
+
dist/
|
|
26
|
+
coverage/
|
|
27
|
+
""",
|
|
28
|
+
"macos": """# macOS
|
|
29
|
+
.DS_Store
|
|
30
|
+
.AppleDouble
|
|
31
|
+
.LSOverride
|
|
32
|
+
""",
|
|
33
|
+
"vscode": """# VS Code
|
|
34
|
+
.vscode/*
|
|
35
|
+
!.vscode/extensions.json
|
|
36
|
+
!.vscode/settings.json
|
|
37
|
+
""",
|
|
38
|
+
"jetbrains": """# JetBrains
|
|
39
|
+
.idea/
|
|
40
|
+
*.iml
|
|
41
|
+
""",
|
|
42
|
+
"logs": """# Logs
|
|
43
|
+
*.log
|
|
44
|
+
logs/
|
|
45
|
+
""",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def available_templates() -> list[str]:
|
|
50
|
+
"""Return the names of available ignore templates."""
|
|
51
|
+
|
|
52
|
+
return sorted(IGNORE_TEMPLATES)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def render_templates(names: list[str]) -> str:
|
|
56
|
+
"""Render selected templates into a single block."""
|
|
57
|
+
|
|
58
|
+
unknown = sorted(set(names) - set(IGNORE_TEMPLATES))
|
|
59
|
+
if unknown:
|
|
60
|
+
valid = ", ".join(available_templates())
|
|
61
|
+
raise ValueError(f"unknown template(s): {', '.join(unknown)}. Available: {valid}")
|
|
62
|
+
|
|
63
|
+
blocks = ["# Added by gitforge"]
|
|
64
|
+
for name in names:
|
|
65
|
+
blocks.append(IGNORE_TEMPLATES[name].strip())
|
|
66
|
+
return "\n\n".join(blocks).rstrip() + "\n"
|
|
67
|
+
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gitforge-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A practical Git workflow automation tool for branches, snapshots, releases, and repository hygiene.
|
|
5
|
+
Author-email: shazeus <efeborazan07@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/shazeus/gitforge-cli
|
|
8
|
+
Project-URL: Repository, https://github.com/shazeus/gitforge-cli
|
|
9
|
+
Project-URL: Issues, https://github.com/shazeus/gitforge-cli/issues
|
|
10
|
+
Keywords: git,cli,workflow,automation,release,changelog
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: click>=8.1
|
|
27
|
+
Requires-Dist: rich>=13.7
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
<p align="center">
|
|
33
|
+
<h1 align="center">GitForge</h1>
|
|
34
|
+
<p align="center">Practical Git workflow automation for branches, snapshots, releases, and repository hygiene.</p>
|
|
35
|
+
<p align="center">
|
|
36
|
+
<a href="https://pypi.org/project/gitforge-cli/"><img alt="PyPI" src="https://img.shields.io/pypi/v/gitforge-cli.svg"></a>
|
|
37
|
+
<a href="https://pypi.org/project/gitforge-cli/"><img alt="Python" src="https://img.shields.io/pypi/pyversions/gitforge-cli.svg"></a>
|
|
38
|
+
<a href="https://github.com/shazeus/gitforge-cli/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/shazeus/gitforge-cli.svg"></a>
|
|
39
|
+
<a href="https://github.com/shazeus/gitforge-cli/stargazers"><img alt="Stars" src="https://img.shields.io/github/stars/shazeus/gitforge-cli.svg?style=social"></a>
|
|
40
|
+
</p>
|
|
41
|
+
</p>
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
GitForge is a terminal-first Git workflow assistant for developers who want repeatable branch naming, safer commits, fast repository health checks, release tags, changelogs, branch cleanup, and ref comparisons without memorizing a pile of shell snippets. It uses the real Git executable, prints clean Rich tables, and keeps every operation explicit so it fits normal local and CI workflows.
|
|
46
|
+
|
|
47
|
+
- **Repository health checks** - inspect branch, upstream, dirty state, remotes, conflicts, and local Git identity.
|
|
48
|
+
- **Branch automation** - turn ticket titles into consistent branch names and optionally switch to them.
|
|
49
|
+
- **Snapshot commits** - stage tracked or all files, commit with guardrails, and optionally push.
|
|
50
|
+
- **Release tagging** - create annotated semantic-version tags with dirty-tree protection.
|
|
51
|
+
- **Changelog generation** - group commit history into Markdown or JSON output.
|
|
52
|
+
- **Cleanup and compare tools** - remove merged branches safely and summarize ref differences.
|
|
53
|
+
|
|
54
|
+
## Installation
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install gitforge-cli
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The installed console command is:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
gitforge --help
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Usage
|
|
67
|
+
|
|
68
|
+
Inspect a repository:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
gitforge status --repo .
|
|
72
|
+
gitforge doctor --repo .
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Create a workflow branch:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
gitforge branch "Add OAuth callback validation" --ticket AUTH-42 --prefix feature
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Commit a snapshot:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
gitforge snapshot -m "Implement OAuth callback validation"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Generate a changelog:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
gitforge changelog --since v0.1.0 --output CHANGELOG.md
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Create a release tag:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
gitforge release 1.2.0 --push
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Commands
|
|
100
|
+
|
|
101
|
+
| Command | Description | Example |
|
|
102
|
+
| --- | --- | --- |
|
|
103
|
+
| `gitforge status` | Show branch, upstream, dirty state, remote, and last tag. | `gitforge status --json` |
|
|
104
|
+
| `gitforge doctor` | Check Git availability, repo state, identity, remote, upstream, and conflicts. | `gitforge doctor` |
|
|
105
|
+
| `gitforge branch <title>` | Create a normalized branch name from a ticket/title. | `gitforge branch "Fix login" --ticket WEB-7` |
|
|
106
|
+
| `gitforge snapshot` | Stage changes and commit with safety checks. | `gitforge snapshot -m "Fix login"` |
|
|
107
|
+
| `gitforge sync` | Fetch, pull with rebase or merge, and optionally push. | `gitforge sync --autostash` |
|
|
108
|
+
| `gitforge release <version>` | Create an annotated release tag. | `gitforge release 1.0.0` |
|
|
109
|
+
| `gitforge changelog` | Render commit history as grouped Markdown or JSON. | `gitforge changelog --max-count 25` |
|
|
110
|
+
| `gitforge compare <base> [head]` | Compare two refs by unique commits and changed files. | `gitforge compare main HEAD` |
|
|
111
|
+
| `gitforge cleanup` | List or delete merged local branches. | `gitforge cleanup --apply` |
|
|
112
|
+
| `gitforge ignore` | Append built-in `.gitignore` templates. | `gitforge ignore python macos` |
|
|
113
|
+
|
|
114
|
+
## Configuration
|
|
115
|
+
|
|
116
|
+
GitForge intentionally uses standard Git configuration instead of its own config file.
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
git config user.name "Your Name"
|
|
120
|
+
git config user.email "you@example.com"
|
|
121
|
+
git remote add origin git@github.com:you/project.git
|
|
122
|
+
git branch --set-upstream-to origin/main main
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Most commands accept `--repo` so they can operate on another checkout without changing directories.
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT License. See [LICENSE](LICENSE).
|
|
130
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
gitforge/__init__.py,sha256=I-8wASZCo9pdZOUfvL5Ldr2PIGzV66HvI4v17nmFbLg,57
|
|
2
|
+
gitforge/__main__.py,sha256=2ki6bVTO98sPwQ45HdDBv4xupTPN1OJUGMUMHxh3UgU,112
|
|
3
|
+
gitforge/changelog.py,sha256=JDZtlrxTpt9vahWA187dQEFJoYOIDkgS-Q6kUpT6roo,2659
|
|
4
|
+
gitforge/cli.py,sha256=O9HT8btFsw6a2NaLVRKoDk_Y04qXpxlXIZx44I7RvU0,19137
|
|
5
|
+
gitforge/git.py,sha256=1AfaYssUXDOr5dGm0PXtKBbuCoFuGOh1sAafm883QV8,7274
|
|
6
|
+
gitforge/templates.py,sha256=pNFxBr3eIk25k-nacp_LJCCwer-0K8xzeoGsm96Lg14,1204
|
|
7
|
+
gitforge_cli-0.1.0.dist-info/licenses/LICENSE,sha256=8HmUM0xaQoOvbP-GXsiGksa4MLaHRzZ5F-I-syTXt5c,1065
|
|
8
|
+
gitforge_cli-0.1.0.dist-info/METADATA,sha256=7Cj75kqXvVTnXJrYhFzzjz4rSf4_gP2eUm9ks9MY-nI,5268
|
|
9
|
+
gitforge_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
gitforge_cli-0.1.0.dist-info/entry_points.txt,sha256=OZP7AiAMba3B-lOCCXx1jnM3tKlecnd-6b7ovaphYYA,47
|
|
11
|
+
gitforge_cli-0.1.0.dist-info/top_level.txt,sha256=OlGVDBN_LBdvLsNaeGrSd5BpWYI1O3tR_BstvULOkdQ,9
|
|
12
|
+
gitforge_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 shazeus
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gitforge
|