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 ADDED
@@ -0,0 +1,4 @@
1
+ """GitForge package metadata."""
2
+
3
+ __version__ = "0.1.0"
4
+
gitforge/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Run GitForge with ``python -m gitforge``."""
2
+
3
+ from .cli import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ main()
8
+
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gitforge = gitforge.cli:main
@@ -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