githelp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ # Python build / packaging artifacts
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .eggs/
8
+
9
+ # Virtual envs
10
+ .venv/
11
+ venv/
12
+ env/
13
+
14
+ # Tooling caches
15
+ .mypy_cache/
16
+ .pytest_cache/
17
+ .ruff_cache/
githelp-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dheeraj Pai
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.
githelp-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: githelp
3
+ Version: 0.1.0
4
+ Summary: githelp - a tiny git helper (gg/ggi): commit without quotes, and add .gitignore templates from github/gitignore
5
+ Project-URL: Homepage, https://github.com/rosaboyle/githelp
6
+ Project-URL: Repository, https://github.com/rosaboyle/githelp
7
+ Project-URL: Issues, https://github.com/rosaboyle/githelp/issues
8
+ Author-email: Dheeraj Pai <dheeraj.pai@leanmcp.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: cli,commit,git,gitignore
12
+ Classifier: Environment :: Console
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Version Control :: Git
16
+ Requires-Python: >=3.9
17
+ Requires-Dist: httpx==0.28.1
18
+ Requires-Dist: questionary==2.1.1
19
+ Requires-Dist: rich==15.0.0
20
+ Description-Content-Type: text/markdown
21
+
22
+ # githelp
23
+
24
+ A tiny git helper published as [`githelp`](https://pypi.org/project/githelp/). It
25
+ installs two commands: **`gg`** and **`ggi`**.
26
+
27
+ ```
28
+ pip install githelp
29
+ ```
30
+
31
+ - Repository: <https://github.com/rosaboyle/githelp>
32
+ - PyPI: <https://pypi.org/project/githelp/>
33
+
34
+ ---
35
+
36
+ ## `gg` — commit without quotes
37
+
38
+ Type your message straight after `gg`, no quotes:
39
+
40
+ ```
41
+ gg fix the login bug and update the readme
42
+ ```
43
+
44
+ That runs, in order:
45
+
46
+ ```
47
+ git add .
48
+ git commit -m "fix the login bug and update the readme"
49
+ git push
50
+ ```
51
+
52
+ It **warns you and asks "are you sure?"** before committing when:
53
+
54
+ - more than **100 files** are staged, or
55
+ - more than **100,000 changed lines** are staged.
56
+
57
+ If you decline, it aborts (your files stay staged). If neither threshold is
58
+ crossed, it just commits and pushes. If the current branch has no upstream, it
59
+ pushes with `--set-upstream origin <branch>` automatically.
60
+
61
+ ```
62
+ gg --help # usage
63
+ ```
64
+
65
+ ## `ggi` — add a .gitignore template
66
+
67
+ Templates are fetched **live** from
68
+ [`github/gitignore`](https://github.com/github/gitignore) through the GitHub API,
69
+ so they are always the current upstream versions (nothing is bundled or stale).
70
+
71
+ ```
72
+ ggi # interactive: type to fuzzy-search the full list, then pick one
73
+ ggi Python # add a named template directly
74
+ ggi --help # usage
75
+ ```
76
+
77
+ The selected template is appended to your repository's `.gitignore` inside a
78
+ clearly marked block, so it is easy to find and is never added twice.
79
+
80
+ > Tip: set `GITHUB_TOKEN` (or `GH_TOKEN`) in your environment to avoid GitHub's
81
+ > unauthenticated API rate limit.
82
+
83
+ ---
84
+
85
+ ## Install from source / develop
86
+
87
+ ```bash
88
+ git clone https://github.com/rosaboyle/githelp
89
+ cd githelp
90
+ pip install -e .
91
+ ```
92
+
93
+ ## Dependencies
94
+
95
+ Pinned to the latest releases:
96
+
97
+ | Package | Version |
98
+ |--------------|---------|
99
+ | httpx | 0.28.1 |
100
+ | questionary | 2.1.1 |
101
+ | rich | 15.0.0 |
102
+
103
+ Requires Python 3.9+.
104
+
105
+ ---
106
+
107
+ ## Publishing
108
+
109
+ A helper script is provided: [`publish.sh`](./publish.sh).
110
+
111
+ ```bash
112
+ ./publish.sh # build + upload to PyPI
113
+ ./publish.sh --test # upload to TestPyPI instead
114
+ ./publish.sh --check # build only, validate with twine, do not upload
115
+ ```
116
+
117
+ Manual equivalent:
118
+
119
+ ```bash
120
+ python -m pip install --upgrade build twine
121
+ python -m build
122
+ twine check dist/*
123
+ twine upload dist/*
124
+ ```
125
+
126
+ Authenticate to PyPI with an API token: create one at
127
+ <https://pypi.org/manage/account/token/> and either set `TWINE_USERNAME=__token__`
128
+ and `TWINE_PASSWORD=<your-token>`, or let twine prompt you.
129
+
130
+ ## License
131
+
132
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,111 @@
1
+ # githelp
2
+
3
+ A tiny git helper published as [`githelp`](https://pypi.org/project/githelp/). It
4
+ installs two commands: **`gg`** and **`ggi`**.
5
+
6
+ ```
7
+ pip install githelp
8
+ ```
9
+
10
+ - Repository: <https://github.com/rosaboyle/githelp>
11
+ - PyPI: <https://pypi.org/project/githelp/>
12
+
13
+ ---
14
+
15
+ ## `gg` — commit without quotes
16
+
17
+ Type your message straight after `gg`, no quotes:
18
+
19
+ ```
20
+ gg fix the login bug and update the readme
21
+ ```
22
+
23
+ That runs, in order:
24
+
25
+ ```
26
+ git add .
27
+ git commit -m "fix the login bug and update the readme"
28
+ git push
29
+ ```
30
+
31
+ It **warns you and asks "are you sure?"** before committing when:
32
+
33
+ - more than **100 files** are staged, or
34
+ - more than **100,000 changed lines** are staged.
35
+
36
+ If you decline, it aborts (your files stay staged). If neither threshold is
37
+ crossed, it just commits and pushes. If the current branch has no upstream, it
38
+ pushes with `--set-upstream origin <branch>` automatically.
39
+
40
+ ```
41
+ gg --help # usage
42
+ ```
43
+
44
+ ## `ggi` — add a .gitignore template
45
+
46
+ Templates are fetched **live** from
47
+ [`github/gitignore`](https://github.com/github/gitignore) through the GitHub API,
48
+ so they are always the current upstream versions (nothing is bundled or stale).
49
+
50
+ ```
51
+ ggi # interactive: type to fuzzy-search the full list, then pick one
52
+ ggi Python # add a named template directly
53
+ ggi --help # usage
54
+ ```
55
+
56
+ The selected template is appended to your repository's `.gitignore` inside a
57
+ clearly marked block, so it is easy to find and is never added twice.
58
+
59
+ > Tip: set `GITHUB_TOKEN` (or `GH_TOKEN`) in your environment to avoid GitHub's
60
+ > unauthenticated API rate limit.
61
+
62
+ ---
63
+
64
+ ## Install from source / develop
65
+
66
+ ```bash
67
+ git clone https://github.com/rosaboyle/githelp
68
+ cd githelp
69
+ pip install -e .
70
+ ```
71
+
72
+ ## Dependencies
73
+
74
+ Pinned to the latest releases:
75
+
76
+ | Package | Version |
77
+ |--------------|---------|
78
+ | httpx | 0.28.1 |
79
+ | questionary | 2.1.1 |
80
+ | rich | 15.0.0 |
81
+
82
+ Requires Python 3.9+.
83
+
84
+ ---
85
+
86
+ ## Publishing
87
+
88
+ A helper script is provided: [`publish.sh`](./publish.sh).
89
+
90
+ ```bash
91
+ ./publish.sh # build + upload to PyPI
92
+ ./publish.sh --test # upload to TestPyPI instead
93
+ ./publish.sh --check # build only, validate with twine, do not upload
94
+ ```
95
+
96
+ Manual equivalent:
97
+
98
+ ```bash
99
+ python -m pip install --upgrade build twine
100
+ python -m build
101
+ twine check dist/*
102
+ twine upload dist/*
103
+ ```
104
+
105
+ Authenticate to PyPI with an API token: create one at
106
+ <https://pypi.org/manage/account/token/> and either set `TWINE_USERNAME=__token__`
107
+ and `TWINE_PASSWORD=<your-token>`, or let twine prompt you.
108
+
109
+ ## License
110
+
111
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # publish.sh - build and publish `githelp` to PyPI.
4
+ #
5
+ # Usage:
6
+ # ./publish.sh Build and upload to PyPI (production).
7
+ # ./publish.sh --test Build and upload to TestPyPI.
8
+ # ./publish.sh --check Build and validate only (no upload).
9
+ #
10
+ # Auth: set TWINE_USERNAME=__token__ and TWINE_PASSWORD=<pypi-token>,
11
+ # or let twine prompt you interactively.
12
+ #
13
+ set -euo pipefail
14
+
15
+ # Always run from the directory this script lives in.
16
+ cd "$(dirname "$0")"
17
+
18
+ TARGET="pypi"
19
+ for arg in "$@"; do
20
+ case "$arg" in
21
+ --test) TARGET="testpypi" ;;
22
+ --check) TARGET="check" ;;
23
+ -h|--help)
24
+ sed -n '2,14p' "$0" | sed 's/^# \{0,1\}//'
25
+ exit 0
26
+ ;;
27
+ *)
28
+ echo "Unknown option: $arg" >&2
29
+ exit 2
30
+ ;;
31
+ esac
32
+ done
33
+
34
+ # Pick a python launcher.
35
+ PY="python3"
36
+ command -v "$PY" >/dev/null 2>&1 || PY="python"
37
+
38
+ echo ">> Installing/upgrading build tooling..."
39
+ "$PY" -m pip install --upgrade build twine >/dev/null
40
+
41
+ echo ">> Cleaning old build artifacts..."
42
+ rm -rf dist build ./*.egg-info src/*.egg-info
43
+
44
+ echo ">> Building sdist and wheel..."
45
+ "$PY" -m build
46
+
47
+ echo ">> Validating artifacts with twine..."
48
+ "$PY" -m twine check dist/*
49
+
50
+ case "$TARGET" in
51
+ check)
52
+ echo ">> --check only: built and validated, not uploading."
53
+ echo ">> Artifacts:"
54
+ ls -1 dist
55
+ ;;
56
+ testpypi)
57
+ echo ">> Uploading to TestPyPI..."
58
+ "$PY" -m twine upload --repository testpypi dist/*
59
+ echo ">> Done. Try: pip install -i https://test.pypi.org/simple/ githelp"
60
+ ;;
61
+ pypi)
62
+ echo ">> Uploading to PyPI..."
63
+ "$PY" -m twine upload dist/*
64
+ echo ">> Done. Try: pip install githelp"
65
+ ;;
66
+ esac
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "githelp"
7
+ version = "0.1.0"
8
+ description = "githelp - a tiny git helper (gg/ggi): commit without quotes, and add .gitignore templates from github/gitignore"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Dheeraj Pai", email = "dheeraj.pai@leanmcp.com" }]
13
+ keywords = ["git", "cli", "commit", "gitignore"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Environment :: Console",
18
+ "Topic :: Software Development :: Version Control :: Git",
19
+ ]
20
+ dependencies = [
21
+ "httpx==0.28.1",
22
+ "questionary==2.1.1",
23
+ "rich==15.0.0",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/rosaboyle/githelp"
28
+ Repository = "https://github.com/rosaboyle/githelp"
29
+ Issues = "https://github.com/rosaboyle/githelp/issues"
30
+
31
+ [project.scripts]
32
+ gg = "githelp.cli:gg_main"
33
+ ggi = "githelp.cli:ggi_main"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/githelp"]
@@ -0,0 +1,3 @@
1
+ """gg - a tiny git helper."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,203 @@
1
+ """Command-line entry points for `gg` and `ggi`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+
8
+ import questionary
9
+ from rich.console import Console
10
+
11
+ from . import gitignore, gitutil
12
+ from .gitutil import GitError
13
+
14
+ console = Console()
15
+ err = Console(stderr=True)
16
+
17
+ FILE_WARN = 100 # warn when staging more than this many files
18
+ LINE_WARN = 100_000 # warn when staging more than this many changed lines
19
+
20
+
21
+ # --------------------------------------------------------------------------- #
22
+ # gg : add . + commit (no quotes needed) + push
23
+ # --------------------------------------------------------------------------- #
24
+
25
+ GG_USAGE = """\
26
+ [bold]gg[/bold] - stage everything, commit, and push in one shot.
27
+
28
+ [bold]Usage:[/bold]
29
+ gg <your commit message with no quotes>
30
+
31
+ [bold]Example:[/bold]
32
+ gg fix the login bug and update readme
33
+
34
+ Runs: git add . -> git commit -m "<your message>" -> git push
35
+ Warns before committing if more than %d files or %s changed lines are staged.\
36
+ """ % (FILE_WARN, f"{LINE_WARN:,}")
37
+
38
+
39
+ def gg_main() -> None:
40
+ sys.exit(_gg(sys.argv[1:]))
41
+
42
+
43
+ def _gg(args: list[str]) -> int:
44
+ if not args or args[0] in ("-h", "--help"):
45
+ console.print(GG_USAGE)
46
+ return 0 if args else 1
47
+
48
+ message = " ".join(args).strip()
49
+ if not message:
50
+ err.print("[red]A commit message is required.[/red]")
51
+ return 1
52
+
53
+ try:
54
+ if not gitutil.is_inside_repo():
55
+ err.print("[red]Not inside a git repository.[/red]")
56
+ return 1
57
+
58
+ gitutil.run_git(["add", "."])
59
+ stats = gitutil.staged_stats()
60
+
61
+ if stats.files == 0:
62
+ console.print("[yellow]Nothing to commit - working tree is clean.[/yellow]")
63
+ return 0
64
+
65
+ console.print(
66
+ f"Staged [bold]{stats.files}[/bold] file(s), "
67
+ f"[bold]{stats.lines:,}[/bold] changed line(s)."
68
+ )
69
+
70
+ if stats.files > FILE_WARN and not _confirm(
71
+ f"That is more than {FILE_WARN} files. Are you sure you want to commit?"
72
+ ):
73
+ console.print("[yellow]Aborted. (Files are still staged.)[/yellow]")
74
+ return 1
75
+
76
+ if stats.lines > LINE_WARN and not _confirm(
77
+ f"That is more than {LINE_WARN:,} changed lines. Are you sure?"
78
+ ):
79
+ console.print("[yellow]Aborted. (Files are still staged.)[/yellow]")
80
+ return 1
81
+
82
+ gitutil.run_git(["commit", "-m", message])
83
+ console.print(f'[green]Committed:[/green] "{message}"')
84
+
85
+ _push()
86
+ return 0
87
+
88
+ except GitError as exc:
89
+ err.print(f"[red]{exc}[/red]")
90
+ return 1
91
+
92
+
93
+ def _push() -> None:
94
+ """Push the current branch, setting upstream automatically if needed."""
95
+ proc = gitutil.run_git(["push"], check=False)
96
+ if proc.returncode == 0:
97
+ console.print("[green]Pushed.[/green]")
98
+ return
99
+
100
+ stderr = (proc.stderr or "").lower()
101
+ branch = gitutil.current_branch()
102
+ if "no upstream" in stderr and branch:
103
+ console.print("[yellow]No upstream set - pushing with --set-upstream origin.[/yellow]")
104
+ up = gitutil.run_git(["push", "--set-upstream", "origin", branch], check=False)
105
+ if up.returncode == 0:
106
+ console.print("[green]Pushed.[/green]")
107
+ return
108
+ raise GitError((up.stderr or up.stdout or "git push failed.").strip())
109
+
110
+ raise GitError((proc.stderr or proc.stdout or "git push failed.").strip())
111
+
112
+
113
+ # --------------------------------------------------------------------------- #
114
+ # ggi : add a .gitignore template from github/gitignore
115
+ # --------------------------------------------------------------------------- #
116
+
117
+ GGI_USAGE = """\
118
+ [bold]ggi[/bold] - add a .gitignore template (live from github/gitignore).
119
+
120
+ [bold]Usage:[/bold]
121
+ ggi # interactive: pick a template by typing/searching
122
+ ggi Python # add a named template directly
123
+
124
+ Appends the chosen template to your repository's .gitignore.\
125
+ """
126
+
127
+
128
+ def ggi_main() -> None:
129
+ sys.exit(_ggi(sys.argv[1:]))
130
+
131
+
132
+ def _ggi(args: list[str]) -> int:
133
+ if args and args[0] in ("-h", "--help"):
134
+ console.print(GGI_USAGE)
135
+ return 0
136
+
137
+ try:
138
+ available = gitignore.list_templates()
139
+ except gitignore.GitignoreError as exc:
140
+ err.print(f"[red]{exc}[/red]")
141
+ return 1
142
+
143
+ if args:
144
+ chosen = gitignore.resolve_name(args[0], available)
145
+ if chosen is None:
146
+ err.print(
147
+ f"[red]Unknown template {args[0]!r}.[/red] "
148
+ "Run [bold]ggi[/bold] with no arguments to browse the list."
149
+ )
150
+ return 1
151
+ else:
152
+ chosen = questionary.autocomplete(
153
+ "Which .gitignore template do you want to add?",
154
+ choices=available,
155
+ match_middle=True,
156
+ ignore_case=True,
157
+ ).ask()
158
+ if not chosen:
159
+ console.print("[yellow]Cancelled.[/yellow]")
160
+ return 1
161
+ chosen = gitignore.resolve_name(chosen, available)
162
+ if chosen is None:
163
+ err.print("[red]That is not a valid template name.[/red]")
164
+ return 1
165
+
166
+ try:
167
+ name, source = gitignore.fetch_template(chosen)
168
+ except gitignore.GitignoreError as exc:
169
+ err.print(f"[red]{exc}[/red]")
170
+ return 1
171
+
172
+ target = os.path.join(gitutil.repo_root() or os.getcwd(), ".gitignore")
173
+ marker = f"# >>> {name}.gitignore (added by gg) >>>"
174
+
175
+ existing = ""
176
+ if os.path.exists(target):
177
+ with open(target, "r", encoding="utf-8") as fh:
178
+ existing = fh.read()
179
+
180
+ if marker in existing:
181
+ console.print(
182
+ f"[yellow]{name} is already present in {target} - nothing to do.[/yellow]"
183
+ )
184
+ return 0
185
+
186
+ block = f"{marker}\n{source.rstrip()}\n# <<< {name}.gitignore <<<\n"
187
+ prefix = "" if (not existing or existing.endswith("\n")) else "\n"
188
+ with open(target, "a", encoding="utf-8") as fh:
189
+ if existing and not existing.endswith("\n\n"):
190
+ fh.write(prefix + "\n")
191
+ fh.write(block)
192
+
193
+ console.print(f"[green]Added {name} template to[/green] {target}")
194
+ return 0
195
+
196
+
197
+ # --------------------------------------------------------------------------- #
198
+ # helpers
199
+ # --------------------------------------------------------------------------- #
200
+
201
+ def _confirm(question: str) -> bool:
202
+ answer = questionary.confirm(question, default=False).ask()
203
+ return bool(answer)
@@ -0,0 +1,61 @@
1
+ """Fetch .gitignore templates live from github/gitignore via the GitHub API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ import httpx
8
+
9
+ API_LIST = "https://api.github.com/gitignore/templates"
10
+ API_TEMPLATE = "https://api.github.com/gitignore/templates/{name}"
11
+ TIMEOUT = 15.0
12
+
13
+
14
+ class GitignoreError(RuntimeError):
15
+ """Raised when a template cannot be listed or fetched."""
16
+
17
+
18
+ def _headers() -> dict[str, str]:
19
+ headers = {
20
+ "Accept": "application/vnd.github+json",
21
+ "X-GitHub-Api-Version": "2022-11-28",
22
+ }
23
+ # Use a token if present to avoid the unauthenticated rate limit.
24
+ token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
25
+ if token:
26
+ headers["Authorization"] = f"Bearer {token}"
27
+ return headers
28
+
29
+
30
+ def list_templates() -> list[str]:
31
+ """Return the list of available template names (e.g. 'Python', 'Node')."""
32
+ try:
33
+ resp = httpx.get(API_LIST, headers=_headers(), timeout=TIMEOUT)
34
+ resp.raise_for_status()
35
+ return list(resp.json())
36
+ except httpx.HTTPError as exc:
37
+ raise GitignoreError(f"Could not fetch the template list: {exc}") from exc
38
+
39
+
40
+ def fetch_template(name: str) -> tuple[str, str]:
41
+ """Fetch one template. Returns (canonical_name, source_text)."""
42
+ try:
43
+ resp = httpx.get(
44
+ API_TEMPLATE.format(name=name), headers=_headers(), timeout=TIMEOUT
45
+ )
46
+ if resp.status_code == 404:
47
+ raise GitignoreError(f"No gitignore template named {name!r}.")
48
+ resp.raise_for_status()
49
+ data = resp.json()
50
+ return data["name"], data["source"]
51
+ except httpx.HTTPError as exc:
52
+ raise GitignoreError(f"Could not fetch template {name!r}: {exc}") from exc
53
+
54
+
55
+ def resolve_name(name: str, available: list[str]) -> str | None:
56
+ """Match user input to an available template, case-insensitively."""
57
+ lowered = name.strip().lower()
58
+ for candidate in available:
59
+ if candidate.lower() == lowered:
60
+ return candidate
61
+ return None
@@ -0,0 +1,71 @@
1
+ """Thin wrappers around the `git` command-line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from dataclasses import dataclass
7
+
8
+
9
+ class GitError(RuntimeError):
10
+ """Raised when a git command fails or git is unavailable."""
11
+
12
+
13
+ def run_git(args: list[str], check: bool = True) -> subprocess.CompletedProcess:
14
+ """Run a git command, capturing stdout/stderr as text."""
15
+ try:
16
+ proc = subprocess.run(
17
+ ["git", *args],
18
+ capture_output=True,
19
+ text=True,
20
+ )
21
+ except FileNotFoundError as exc: # git not installed
22
+ raise GitError("git is not installed or not on your PATH.") from exc
23
+
24
+ if check and proc.returncode != 0:
25
+ message = (proc.stderr or proc.stdout or "").strip()
26
+ raise GitError(message or f"git {' '.join(args)} failed.")
27
+ return proc
28
+
29
+
30
+ def is_inside_repo() -> bool:
31
+ proc = run_git(["rev-parse", "--is-inside-work-tree"], check=False)
32
+ return proc.returncode == 0 and proc.stdout.strip() == "true"
33
+
34
+
35
+ def repo_root() -> str | None:
36
+ proc = run_git(["rev-parse", "--show-toplevel"], check=False)
37
+ if proc.returncode == 0:
38
+ return proc.stdout.strip()
39
+ return None
40
+
41
+
42
+ def current_branch() -> str | None:
43
+ proc = run_git(["rev-parse", "--abbrev-ref", "HEAD"], check=False)
44
+ if proc.returncode == 0:
45
+ branch = proc.stdout.strip()
46
+ return branch if branch and branch != "HEAD" else None
47
+ return None
48
+
49
+
50
+ @dataclass
51
+ class StagedStats:
52
+ files: int
53
+ lines: int # added + removed across all staged files
54
+
55
+
56
+ def staged_stats() -> StagedStats:
57
+ """Summarise what is currently staged using `git diff --cached --numstat`."""
58
+ proc = run_git(["diff", "--cached", "--numstat"])
59
+ files = 0
60
+ lines = 0
61
+ for raw in proc.stdout.splitlines():
62
+ parts = raw.split("\t")
63
+ if len(parts) < 3:
64
+ continue
65
+ added, removed = parts[0], parts[1]
66
+ files += 1
67
+ if added.isdigit():
68
+ lines += int(added)
69
+ if removed.isdigit():
70
+ lines += int(removed)
71
+ return StagedStats(files=files, lines=lines)
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env bash
2
+ # Smoke test for githelp in a throwaway git repo.
3
+ # Run this yourself: bash githelp/workspace/smoke_test.sh
4
+ set -euo pipefail
5
+
6
+ # 1) Create an isolated, disposable repo in a temp dir.
7
+ TMP="$(mktemp -d)"
8
+ echo "Using temp repo: $TMP"
9
+ cd "$TMP"
10
+ git init -q
11
+ git config user.email "test@example.com"
12
+ git config user.name "gg test"
13
+
14
+ # 2) ggi: add the Python .gitignore template (non-interactive form).
15
+ echo "--- ggi Python ---"
16
+ ggi Python
17
+ echo "--- .gitignore now contains: ---"
18
+ head -n 5 .gitignore || true
19
+
20
+ # 3) gg: small commit (should NOT warn). No remote, so push will fail loudly
21
+ # at the end - that is expected for this local-only test.
22
+ echo "--- gg (small commit) ---"
23
+ echo "hello" > hello.txt
24
+ gg add hello world this is a no quotes commit || true
25
+
26
+ echo
27
+ echo "Done. Inspect with: cd $TMP && git log --oneline && cat .gitignore"
28
+ echo "(The push step is expected to fail here because there is no remote.)"