3 Commits
Author SHA1 Message Date
Prad Nukala 3086dac5d6 refactor: replace Go binary with uv-run smartcommit.py launcher
The extension is now a bash launcher that runs smartcommit.py via
uv run --script (Crush backend, DuckDB scopes). Removes the Go
implementation and the gh-extension-precompile workflow; the bundled
gh-commit.tar.gz now packages the script instead of a compiled binary.
2026-08-18 14:26:07 -04:00
Prad Nukala f1302fd1ec Revert "fix: provider"
This reverts commit 735229b150.
2026-08-17 18:06:54 -04:00
Prad Nukala ae36a7052d build: package v3.0.0 binary tarball
Replaces the old Python source tarball with the compiled linux/amd64
binary (CGO-free, stripped) plus README and LICENSE.
2026-08-15 19:12:44 -04:00
32 changed files with 1039 additions and 2701 deletions
-18
View File
@@ -1,18 +0,0 @@
name: release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: cli/gh-extension-precompile@v2
with:
go_version_file: go.mod
-2
View File
@@ -1,2 +0,0 @@
/gh-commit
dist/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Prad
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+36 -33
View File
@@ -1,21 +1,25 @@
# gh-commit # gh-commit
AI-powered scoped git commits as a GitHub CLI extension, driven by a local AI-powered scoped git commits as a GitHub CLI extension, driven by the
Ollama model. Scopes map Conventional Commit scope names to path prefixes. [Crush](https://github.com/charmbracelet/crush) CLI. Scopes map Conventional
They are generated from the repository file tree, stored in a local SQLite Commit scope names to path prefixes. They are generated from the repository
database, and auto-regenerate whenever `.gitignore` changes. Changed files are file tree, stored in a local DuckDB database, and auto-regenerate whenever
grouped by scope and committed one scope at a time with generated `.gitignore` changes. Changed files are grouped by scope and committed one
`type(scope): message` subjects. scope at a time with generated `type(scope): message` subjects.
## Install ## Install
```sh ```sh
# Prerequisites: git, a running Ollama server, and the model
ollama pull qwen3.5:2b
gh extension install prdlk/gh-commit gh extension install prdlk/gh-commit
``` ```
### Requirements
- [uv](https://docs.astral.sh/uv) — Python package runner (handles dependencies automatically)
- [Crush](https://github.com/charmbracelet/crush) — configured with an authenticated model provider
By default, gh-commit uses `openrouter/qwen/qwen3.6-27b`. Configure OpenRouter in Crush or override the model with `GH_COMMIT_CRUSH_MODEL`.
## Usage ## Usage
```sh ```sh
@@ -29,7 +33,7 @@ gh commit # commit changes grouped by scope
| Command | Behavior | | Command | Behavior |
|---|---| |---|---|
| `gh commit` | Full scoped-commit flow: group changed files by scope, generate a message per group, confirm, commit, offer to push | | `gh commit` | Full scoped-commit flow: group changed files by scope, generate a message per group, confirm, commit, offer to push |
| `gh commit init` | Generate scopes from the file tree via Ollama; migrates legacy configs; confirms overwrite | | `gh commit init` | Generate scopes from the file tree via Crush; migrates legacy configs; confirms overwrite |
| `gh commit refresh` | Show current scopes, regenerate with existing scopes as context, confirm apply | | `gh commit refresh` | Show current scopes, regenerate with existing scopes as context, confirm apply |
| `gh commit sync` | Create/update GitHub labels from scopes (color = first 6 hex chars of MD5 of the scope name) | | `gh commit sync` | Create/update GitHub labels from scopes (color = first 6 hex chars of MD5 of the scope name) |
| `gh commit list` | Table of configured repositories: name, path, scope count | | `gh commit list` | Table of configured repositories: name, path, scope count |
@@ -38,27 +42,26 @@ gh commit # commit changes grouped by scope
| `gh commit version` | Print `gh-commit <version>` | | `gh commit version` | Print `gh-commit <version>` |
| `gh commit help` | Help, including the DB path and environment variable docs | | `gh commit help` | Help, including the DB path and environment variable docs |
Flags: `--auto` (skip confirmations), `--push` (auto-push), `--model`, `--host`. Flags: `--auto` (skip confirmations), `--push` (auto-push).
## Environment ## Environment
| Variable | Default | Purpose | | Variable | Description |
|---|---|---| |----------|-------------|
| `GH_COMMIT_OLLAMA_HOST` | `http://localhost:11434` | Ollama server URL | | `GH_COMMIT_AUTO=1` | Skip all confirmation prompts |
| `GH_COMMIT_MODEL` | `qwen3.5:2b` | Model tag | | `GH_COMMIT_PUSH=1` | Auto-push after commits |
| `GH_COMMIT_TIMEOUT` | `120` | Per-request timeout, seconds | | `GH_COMMIT_NO_AUTO_REFRESH=1` | Don't auto-regenerate scopes when `.gitignore` changes |
| `GH_COMMIT_AUTO` | `0` | `1` = skip all confirmations | | `GH_COMMIT_CRUSH_CMD` | Override the Crush command (default `crush`) |
| `GH_COMMIT_PUSH` | `0` | `1` = auto-push after commits | | `GH_COMMIT_CRUSH_MODEL` | Override the Crush model (default `openrouter/qwen/qwen3.6-27b`) |
| `GH_COMMIT_NO_AUTO_REFRESH` | `0` | `1` = never auto-regenerate scopes | | `GH_COMMIT_CRUSH_TIMEOUT` | Per-prompt timeout in seconds (default `120`) |
| `GH_COMMIT_DEBUG` | `0` | `1` = print raw model output on parse failure | | `GH_COMMIT_DEBUG=1` | Show scope-response parse diagnostics |
## Storage ## Storage
Scopes live in `${XDG_DATA_HOME:-~/.local/share}/gh-commit/gh-commit.sqlite`. Scopes live in `${XDG_DATA_HOME:-~/.local/share}/gh-commit/gh-commit.db`
Legacy `.github/Repo.toml` and `.github/scopes.json` files are migrated (DuckDB). Legacy `.github/Repo.toml` and `.github/scopes.json` files are
automatically on first run (the source file is archived as migrated automatically on first run (the source file is archived as
`*.migrated.<timestamp>`). Databases from the old DuckDB-based Python version `*.migrated.<timestamp>`).
are not migrated; rerun `gh commit init`.
## Manual acceptance ## Manual acceptance
@@ -76,12 +79,12 @@ git log --format='%s' # verify type(scope): message subjects
## Development ## Development
```sh The extension is a bash launcher (`gh-commit`) that runs `smartcommit.py`
go test ./... # unit tests with `uv run --script`; uv resolves the inline dependencies (duckdb, rich,
go test -tags integration -run TestSmoke . # needs a running Ollama server questionary) automatically.
go build .
```
Releases are built by `cli/gh-extension-precompile` on `v*` tags for ```sh
linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, and windows/amd64 python -m py_compile smartcommit.py # syntax check
(`CGO_ENABLED=0`; the SQLite driver is pure Go). bash -n gh-commit # launcher check
./gh-commit version # smoke test
```
Binary file not shown.
-224
View File
@@ -1,224 +0,0 @@
package cmd
import (
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/prdlk/gh-commit/internal/gitx"
"github.com/prdlk/gh-commit/internal/ui"
)
// runCommit is the root command: the full scoped-commit flow.
func runCommit(_ *cobra.Command, _ []string) error {
repoPath, err := requireGit()
if err != nil {
return err
}
repoName := filepath.Base(repoPath)
migrateLegacy(repoPath)
hasScopes, err := store.HasScopes(repoPath)
if err != nil {
return err
}
if !hasScopes {
ui.Println()
ui.WarnBoldf("⚠ No scopes configured for this repository")
ui.Println()
ui.Infof("gh-commit organizes commits by project areas (scopes).")
ui.Println()
if !ui.Confirm("Generate scopes now using Ollama?") {
ui.Println()
ui.Dimf("Run 'gh commit init' to configure scopes")
return errAborted
}
if err := ensureAI(); err != nil {
return err
}
if err := initFlow(repoPath); err != nil {
return err
}
}
if err := ensureAI(); err != nil {
return err
}
// Keep scopes aligned with the repo whenever .gitignore changes.
if err := maybeAutoRefreshScopes(repoPath, repoName); err != nil {
return err
}
ui.Magentaf("Finding scopes with changes...")
changedFiles := gitx.ChangedFiles()
scopes, err := store.Scopes(repoPath)
if err != nil {
return err
}
var scopesWithChanges []string
for _, name := range sortedScopeNames(scopes) {
if len(gitx.FilesInScope(changedFiles, scopes[name])) > 0 {
scopesWithChanges = append(scopesWithChanges, name)
}
}
if len(scopesWithChanges) == 0 {
ui.Dimf("No scoped changes found")
} else {
ui.Infof("Scopes with changes: %s", strings.Join(scopesWithChanges, " "))
ui.Println()
}
for _, scope := range scopesWithChanges {
ui.MagentaBoldf("Processing scope: %s", scope)
ui.Infof(" Paths: %s", strings.Join(scopes[scope], ", "))
// Re-read changed files so earlier commits are excluded.
scopeFiles := gitx.FilesInScope(gitx.ChangedFiles(), scopes[scope])
if len(scopeFiles) == 0 {
ui.Dimf(" No files found in scope paths")
ui.Println()
continue
}
if _, err := commitGroup(scopeFiles, scope); err != nil {
return err
}
}
gitx.ResetStaging()
// Remaining files outside any scope.
if gitx.Git("status", "--porcelain") != "" {
ui.Warnf("Processing remaining files outside any scope...")
ui.Println()
tracked := gitx.Lines(gitx.Git("diff", "--name-only"))
if len(tracked) > 0 {
ui.Magentaf("Tracked unstaged files:")
for _, f := range tracked {
ui.Dimf(" %s", f)
}
if ui.Confirm("Commit tracked unstaged files?") {
if _, err := commitGroup(tracked, ""); err != nil {
return err
}
}
}
untracked := gitx.Lines(gitx.Git("ls-files", "--others", "--exclude-standard"))
if len(untracked) > 0 {
ui.Magentaf("Untracked files:")
for _, f := range untracked {
ui.Dimf(" %s", f)
}
if ui.Confirm("Commit untracked files?") {
if _, err := commitGroup(untracked, ""); err != nil {
return err
}
}
}
}
// Final safety reset before the push check.
gitx.ResetStaging()
// Push.
unpushed := gitx.UnpushedCommits()
if len(unpushed) > 0 {
ui.Println()
ui.MagentaBoldf("Unpushed Commits")
ui.Infof("%d commit(s) ready to push:", len(unpushed))
ui.Println()
for _, line := range unpushed {
hash, rest, _ := strings.Cut(line, " ")
ui.Printf(" %s %s", ui.Bold(hash), rest)
}
ui.Println()
if cfg.push || ui.Confirm("Push commits to origin?") {
if err := pushToOrigin(); err != nil {
return err
}
} else {
ui.Dimf("Skipped push")
}
} else {
ui.Println()
ui.Dimf("No unpushed commits")
}
ui.Println()
ui.SuccessBoldf("✓ Done!")
return nil
}
// commitGroup stages files, generates a message, and commits them as one group.
func commitGroup(files []string, scope string) (bool, error) {
var clean []string
for _, f := range files {
if f != "" {
clean = append(clean, f)
}
}
if len(clean) == 0 {
return false, nil
}
gitx.ResetStaging()
if err := gitx.StageFiles(clean); err != nil {
return false, err
}
diff := gitx.Git("diff", "--cached")
if diff == "" {
gitx.ResetStaging()
return false, nil
}
var message string
ui.Spin("Generating commit message...", func() {
var err error
message, err = client.CommitMessage(diff, scope)
if err != nil {
ui.Errorf("Ollama failed to generate a commit message: %s", err)
message = ""
}
})
if message == "" {
gitx.ResetStaging()
return false, nil
}
ui.Panel(message)
label := scope
if label == "" {
label = "these changes"
}
if ui.Confirm("Commit " + label + "?") {
if err := gitx.Commit(message); err != nil {
return false, err
}
ui.Successf("✓ Committed %s", label)
ui.Println()
return true, nil
}
gitx.ResetStaging()
ui.Dimf(" Skipped %s", label)
ui.Println()
return false, nil
}
// pushToOrigin pushes the current branch behind a spinner.
func pushToOrigin() error {
branch := gitx.CurrentBranch()
var err error
ui.Spin("Pushing to origin/"+branch+"...", func() {
err = gitx.Push(branch)
})
if err != nil {
return err
}
ui.Successf("✓ Pushed to origin/%s", branch)
return nil
}
-85
View File
@@ -1,85 +0,0 @@
package cmd
import (
"os"
"path/filepath"
"github.com/spf13/cobra"
"github.com/prdlk/gh-commit/internal/gitx"
"github.com/prdlk/gh-commit/internal/ui"
)
var initCmd = &cobra.Command{
Use: "init",
Short: "Generate scopes for this repository from its file tree",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error {
repoPath, err := requireGit()
if err != nil {
return err
}
if err := ensureAI(); err != nil {
return err
}
return initFlow(repoPath)
},
}
// initFlow generates and saves scopes for repoPath. The Ollama client must
// already be ready.
func initFlow(repoPath string) error {
repoName := filepath.Base(repoPath)
// Check for legacy files.
tomlPath := filepath.Join(repoPath, ".github", "Repo.toml")
jsonPath := filepath.Join(repoPath, ".github", "scopes.json")
if fileExists(tomlPath) || fileExists(jsonPath) {
ui.Warnf("Found legacy config file(s)")
if ui.Confirm("Migrate to the gh-commit database?") {
if migrateLegacy(repoPath) {
ui.Successf("✓ Migration complete")
return nil
}
}
}
hasScopes, err := store.HasScopes(repoPath)
if err != nil {
return err
}
if hasScopes {
ui.Warnf("⚠ Repository already configured")
if !ui.Confirm("Overwrite existing scopes?") {
ui.Dimf("Cancelled")
return nil
}
}
ui.MagentaBoldf("Generating scopes for %s...", repoName)
ui.Println()
scopes := generateScopes(nil)
if scopes == nil {
return errAborted
}
if err := store.SaveScopes(repoPath, repoName, scopes, gitx.GitignoreHash(repoPath)); err != nil {
return err
}
ui.Successf("✓ Saved scopes to database")
ui.Println()
ui.Magentaf("Generated scopes:")
displayScopes(scopes)
ui.Println()
ui.Dimf("Run 'gh commit' to use these scopes")
return nil
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func init() {
rootCmd.AddCommand(initCmd)
}
-56
View File
@@ -1,56 +0,0 @@
package cmd
import (
"fmt"
"strconv"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
"github.com/spf13/cobra"
"github.com/prdlk/gh-commit/internal/ui"
)
var listCmd = &cobra.Command{
Use: "list",
Short: "List all configured repositories",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error {
ui.MagentaBoldf("Repositories")
ui.Println()
repos, err := store.ListRepos()
if err != nil {
return err
}
if len(repos) == 0 {
ui.Dimf("No repositories configured yet")
ui.Println()
ui.Infof("Run 'gh commit init' in a git repository to get started")
return nil
}
t := table.New().
Border(lipgloss.NormalBorder()).
Headers("Name", "Path", "Scopes").
StyleFunc(func(row, col int) lipgloss.Style {
s := lipgloss.NewStyle().Padding(0, 1)
if col == 2 {
s = s.Align(lipgloss.Right)
}
if row == table.HeaderRow || col == 0 {
s = s.Bold(true)
}
return s
})
for _, r := range repos {
t.Row(r.Name, r.Path, strconv.Itoa(r.ScopeCount))
}
fmt.Println(t)
return nil
},
}
func init() {
rootCmd.AddCommand(listCmd)
}
-93
View File
@@ -1,93 +0,0 @@
package cmd
import (
"encoding/json"
"os"
"path/filepath"
"time"
"github.com/BurntSushi/toml"
"github.com/prdlk/gh-commit/internal/ai"
"github.com/prdlk/gh-commit/internal/gitx"
"github.com/prdlk/gh-commit/internal/ui"
)
// archiveMigrated renames a consumed legacy file to *.migrated.<timestamp>.
func archiveMigrated(path string) (string, error) {
backup := path + ".migrated." + time.Now().Format("20060102_150405")
return backup, os.Rename(path, backup)
}
// migrateTOML imports scopes from .github/Repo.toml.
func migrateTOML(repoPath, repoName, tomlPath string) bool {
ui.Warnf("↻ Migrating .github/Repo.toml → database")
var doc struct {
Scopes map[string]any `toml:"scopes"`
}
if _, err := toml.DecodeFile(tomlPath, &doc); err != nil {
ui.Errorf("Migration failed: %s", err)
return false
}
scopes := ai.CoerceScopeMap(doc.Scopes)
if scopes == nil {
scopes = map[string][]string{}
}
if err := store.SaveScopes(repoPath, repoName, scopes, gitx.GitignoreHash(repoPath)); err != nil {
ui.Errorf("Migration failed: %s", err)
return false
}
backup, err := archiveMigrated(tomlPath)
if err != nil {
ui.Errorf("Migration failed: %s", err)
return false
}
ui.Dimf(" Archived: %s", backup)
return true
}
// migrateJSON imports scopes from .github/scopes.json
// (an array of {"scope": ..., "path": ...} entries).
func migrateJSON(repoPath, repoName, jsonPath string) bool {
ui.Warnf("↻ Migrating .github/scopes.json → database")
data, err := os.ReadFile(jsonPath)
if err != nil {
ui.Errorf("Migration failed: %s", err)
return false
}
var entries []struct {
Scope string `json:"scope"`
Path string `json:"path"`
}
if err := json.Unmarshal(data, &entries); err != nil {
ui.Errorf("Migration failed: %s", err)
return false
}
scopes := map[string][]string{}
for _, e := range entries {
scopes[e.Scope] = append(scopes[e.Scope], e.Path)
}
if err := store.SaveScopes(repoPath, repoName, scopes, gitx.GitignoreHash(repoPath)); err != nil {
ui.Errorf("Migration failed: %s", err)
return false
}
backup, err := archiveMigrated(jsonPath)
if err != nil {
ui.Errorf("Migration failed: %s", err)
return false
}
ui.Dimf(" Archived: %s", backup)
return true
}
// migrateLegacy imports whichever legacy config file exists, if any.
func migrateLegacy(repoPath string) bool {
repoName := filepath.Base(repoPath)
if tomlPath := filepath.Join(repoPath, ".github", "Repo.toml"); fileExists(tomlPath) {
return migrateTOML(repoPath, repoName, tomlPath)
}
if jsonPath := filepath.Join(repoPath, ".github", "scopes.json"); fileExists(jsonPath) {
return migrateJSON(repoPath, repoName, jsonPath)
}
return false
}
-62
View File
@@ -1,62 +0,0 @@
package cmd
import (
"fmt"
"path/filepath"
"github.com/spf13/cobra"
"github.com/prdlk/gh-commit/internal/db"
"github.com/prdlk/gh-commit/internal/ui"
)
var removeCmd = &cobra.Command{
Use: "remove",
Short: "Remove the current repository from the database",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error {
repoPath, err := requireGit()
if err != nil {
return err
}
hasScopes, err := store.HasScopes(repoPath)
if err != nil {
return err
}
if !hasScopes {
ui.Dimf("Repository not in database: %s", filepath.Base(repoPath))
return nil
}
if ui.Confirm(fmt.Sprintf("Remove %s from database?", filepath.Base(repoPath))) {
if err := store.DeleteRepo(repoPath); err != nil {
return err
}
ui.Successf("✓ Removed %s", filepath.Base(repoPath))
} else {
ui.Dimf("Cancelled")
}
return nil
},
}
var dbPathCmd = &cobra.Command{
Use: "db-path",
Short: "Print the database file path",
Args: cobra.NoArgs,
Run: func(_ *cobra.Command, _ []string) {
fmt.Println(db.Path())
},
}
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print the version",
Args: cobra.NoArgs,
Run: func(_ *cobra.Command, _ []string) {
fmt.Printf("gh-commit %s\n", version)
},
}
func init() {
rootCmd.AddCommand(removeCmd, dbPathCmd, versionCmd)
}
-76
View File
@@ -1,76 +0,0 @@
package cmd
import (
"path/filepath"
"github.com/spf13/cobra"
"github.com/prdlk/gh-commit/internal/gitx"
"github.com/prdlk/gh-commit/internal/ui"
)
var refreshCmd = &cobra.Command{
Use: "refresh",
Short: "Regenerate scopes from the current repository structure",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error {
repoPath, err := requireGit()
if err != nil {
return err
}
repoName := filepath.Base(repoPath)
hasScopes, err := store.HasScopes(repoPath)
if err != nil {
return err
}
if !hasScopes {
ui.Errorf("Repository not configured — run 'gh commit init' first")
return errAborted
}
if err := ensureAI(); err != nil {
return err
}
existing, err := store.Scopes(repoPath)
if err != nil {
return err
}
ui.MagentaBoldf("Refreshing scopes for %s...", repoName)
ui.Println()
ui.Infof("Current scopes:")
displayScopes(existing)
ui.Println()
if !ui.Confirm("Refresh scopes based on current structure?") {
ui.Dimf("Cancelled")
return nil
}
scopes := generateScopes(existing)
if scopes == nil {
return errAborted
}
ui.Println()
ui.Successf("✓ Generated updated scopes")
ui.Println()
ui.Magentaf("Updated scopes:")
displayScopes(scopes)
ui.Println()
if ui.Confirm("Apply these changes?") {
if err := store.SaveScopes(repoPath, repoName, scopes, gitx.GitignoreHash(repoPath)); err != nil {
return err
}
ui.Successf("✓ Updated scopes")
} else {
ui.Dimf("Changes not applied")
}
return nil
},
}
func init() {
rootCmd.AddCommand(refreshCmd)
}
-166
View File
@@ -1,166 +0,0 @@
// Package cmd wires the cobra CLI: config resolution, database and Ollama
// client lifecycles, and one file per subcommand.
package cmd
import (
"errors"
"fmt"
"os"
"strconv"
"time"
"github.com/spf13/cobra"
"github.com/prdlk/gh-commit/internal/ai"
"github.com/prdlk/gh-commit/internal/db"
"github.com/prdlk/gh-commit/internal/gitx"
"github.com/prdlk/gh-commit/internal/ui"
)
// errAborted signals exit code 1 after the failure has already been printed.
var errAborted = errors.New("aborted")
type config struct {
host string
model string
timeout time.Duration
auto bool
push bool
noAutoRefresh bool
debug bool
}
var (
version = "dev"
cfg config
store *db.Store
client *ai.Client
flagHost string
flagModel string
flagAuto bool
flagPush bool
)
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func envBool(key string) bool { return os.Getenv(key) == "1" }
func longHelp() string {
return fmt.Sprintf(`AI-powered scoped git commits driven by a local Ollama model.
Scopes map Conventional Commit scope names to path prefixes. They are
generated from the repository file tree by the model, stored in a local
SQLite database, and auto-regenerate whenever .gitignore changes.
Legacy .github/Repo.toml or scopes.json files are auto-migrated on first run.
Database:
%s
Environment:
GH_COMMIT_OLLAMA_HOST Ollama server URL (default http://localhost:11434)
GH_COMMIT_MODEL Model tag (default qwen3.5:2b)
GH_COMMIT_TIMEOUT Per-request timeout in seconds (default 120)
GH_COMMIT_AUTO=1 Skip all confirmation prompts
GH_COMMIT_PUSH=1 Auto-push after commits
GH_COMMIT_NO_AUTO_REFRESH=1 Don't auto-regenerate scopes on .gitignore change
GH_COMMIT_DEBUG=1 Print raw model output on parse failure`, db.Path())
}
var rootCmd = &cobra.Command{
Use: "gh-commit",
Short: "AI-powered scoped git commits (Ollama)",
Long: longHelp(),
Args: cobra.NoArgs,
SilenceUsage: true,
SilenceErrors: true,
CompletionOptions: cobra.CompletionOptions{
DisableDefaultCmd: true,
},
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
cfg.host = envOr("GH_COMMIT_OLLAMA_HOST", "http://localhost:11434")
cfg.model = envOr("GH_COMMIT_MODEL", "qwen3.5:2b")
if flagHost != "" {
cfg.host = flagHost
}
if flagModel != "" {
cfg.model = flagModel
}
seconds := 120
if v, err := strconv.Atoi(envOr("GH_COMMIT_TIMEOUT", "120")); err == nil && v > 0 {
seconds = v
}
cfg.timeout = time.Duration(seconds) * time.Second
cfg.auto = envBool("GH_COMMIT_AUTO") || flagAuto
cfg.push = envBool("GH_COMMIT_PUSH") || flagPush
cfg.noAutoRefresh = envBool("GH_COMMIT_NO_AUTO_REFRESH")
cfg.debug = envBool("GH_COMMIT_DEBUG")
ui.AutoConfirm = cfg.auto
switch cmd.Name() {
case "version", "help":
return nil
}
s, err := db.Open()
if err != nil {
return err
}
store = s
if s.HadLegacyDuckDB {
ui.Warnf("Found a legacy DuckDB database (gh-commit.db) — it cannot be migrated; scopes will regenerate on 'gh commit init'")
}
return nil
},
RunE: runCommit,
}
// Execute runs the CLI, printing errors in red and exiting 1 on failure.
func Execute(v string) {
version = v
rootCmd.Version = v
rootCmd.SetVersionTemplate("gh-commit {{.Version}}\n")
err := rootCmd.Execute()
if store != nil {
_ = store.Close()
}
if err != nil {
if !errors.Is(err, errAborted) {
ui.Errorf("%s", err)
}
os.Exit(1)
}
}
// requireGit asserts we're inside a git repo and returns its root path.
func requireGit() (string, error) {
if !gitx.IsRepo() {
ui.Errorf("Not in a git repository")
return "", errAborted
}
return gitx.RepoRoot(), nil
}
// ensureAI builds the Ollama client and verifies server + model availability.
func ensureAI() error {
if client != nil {
return nil
}
c := ai.New(cfg.host, cfg.model, cfg.timeout)
if err := c.EnsureReady(); err != nil {
return err
}
client = c
return nil
}
func init() {
rootCmd.PersistentFlags().BoolVar(&flagAuto, "auto", false, "skip all confirmation prompts")
rootCmd.PersistentFlags().BoolVar(&flagPush, "push", false, "auto-push after committing")
rootCmd.PersistentFlags().StringVar(&flagModel, "model", "", "override the Ollama model tag")
rootCmd.PersistentFlags().StringVar(&flagHost, "host", "", "override the Ollama server URL")
}
-98
View File
@@ -1,98 +0,0 @@
package cmd
import (
"sort"
"strings"
"github.com/prdlk/gh-commit/internal/ai"
"github.com/prdlk/gh-commit/internal/gitx"
"github.com/prdlk/gh-commit/internal/ui"
)
// sortedScopeNames returns scope names in deterministic (sorted) order,
// matching the DB query ordering the Python tool iterated in.
func sortedScopeNames(scopes map[string][]string) []string {
names := make([]string, 0, len(scopes))
for name := range scopes {
names = append(names, name)
}
sort.Strings(names)
return names
}
// displayScopes prints "• name: path, path" bullets, sorted by name.
func displayScopes(scopes map[string][]string) {
for _, name := range sortedScopeNames(scopes) {
ui.Printf(" %s %s: %s",
ui.Cyan("•"), ui.Bold(name), strings.Join(scopes[name], ", "))
}
}
// generateScopes builds the file tree, prompts the model, and parses the
// response. Failures are reported here; nil means "keep going without".
func generateScopes(existing map[string][]string) map[string][]string {
filetree := gitx.Filetree()
if filetree == "" {
ui.Errorf("No tracked or untracked files to analyze")
return nil
}
var out string
var err error
ui.Spin("Analyzing repository with Ollama...", func() {
out, err = client.ScopesRaw(filetree, existing)
})
if err != nil {
ui.Errorf("Ollama failed: %s", err)
return nil
}
scopes := ai.ParseScopesResponse(out)
if scopes == nil {
ui.Errorf("Could not parse scopes from Ollama's response")
if cfg.debug {
ui.Dimf("%s", out)
}
}
return scopes
}
// maybeAutoRefreshScopes regenerates scopes when .gitignore changed since the
// last save. First sighting snapshots a baseline without regenerating.
func maybeAutoRefreshScopes(repoPath, repoName string) error {
if cfg.noAutoRefresh {
return nil
}
current := gitx.GitignoreHash(repoPath)
stored, present, err := store.StoredGitignoreHash(repoPath)
if err != nil {
return err
}
if !present {
scopes, err := store.Scopes(repoPath)
if err != nil {
return err
}
return store.SaveScopes(repoPath, repoName, scopes, current)
}
if current == stored {
return nil
}
ui.Warnf("↻ .gitignore changed — regenerating scopes with Ollama...")
existing, err := store.Scopes(repoPath)
if err != nil {
return err
}
scopes := generateScopes(existing)
if scopes == nil {
ui.Dimf(" Keeping existing scopes (regeneration failed)")
ui.Println()
return nil
}
if err := store.SaveScopes(repoPath, repoName, scopes, current); err != nil {
return err
}
ui.Successf("✓ Scopes updated:")
displayScopes(scopes)
ui.Println()
return nil
}
-76
View File
@@ -1,76 +0,0 @@
package cmd
import (
"crypto/md5" //nolint:gosec // non-cryptographic: stable label color derivation
"encoding/hex"
"fmt"
"os/exec"
"strings"
"github.com/spf13/cobra"
"github.com/prdlk/gh-commit/internal/ui"
)
var syncCmd = &cobra.Command{
Use: "sync",
Short: "Sync scopes to GitHub labels",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error {
repoPath, err := requireGit()
if err != nil {
return err
}
hasScopes, err := store.HasScopes(repoPath)
if err != nil {
return err
}
if !hasScopes {
ui.Errorf("Repository not configured — run 'gh commit init' first")
return errAborted
}
if _, err := exec.LookPath("gh"); err != nil {
ui.Errorf("Error: 'gh' command not found")
return errAborted
}
if exec.Command("gh", "repo", "view").Run() != nil {
ui.Errorf("Error: Not a GitHub repository or not authenticated")
return errAborted
}
ui.MagentaBoldf("Syncing scopes → GitHub labels...")
ui.Println()
scopes, err := store.Scopes(repoPath)
if err != nil {
return err
}
created, updated, failed := 0, 0, 0
for _, name := range sortedScopeNames(scopes) {
desc := "Changes to: " + strings.Join(scopes[name], ", ")
sum := md5.Sum([]byte(name)) //nolint:gosec // see import note
color := hex.EncodeToString(sum[:])[:6]
if exec.Command("gh", "label", "create", name, "--description", desc, "--color", color).Run() == nil {
ui.Printf(" %s Created: %s", ui.Green("✓"), name)
created++
} else if exec.Command("gh", "label", "edit", name, "--description", desc, "--color", color).Run() == nil {
ui.Printf(" %s Updated: %s", ui.Yellow("↻"), name)
updated++
} else {
ui.Printf(" %s Failed: %s", ui.Red("✗"), name)
failed++
}
}
ui.Println()
ui.SuccessBoldf("Sync complete! %s", fmt.Sprintf("Created: %d | Updated: %d | Failed: %d", created, updated, failed))
return nil
},
}
func init() {
rootCmd.AddCommand(syncCmd)
}
Executable
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# gh-commit launcher — runs the bundled Python implementation via uv.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if ! command -v uv >/dev/null 2>&1; then
echo "gh-commit: 'uv' is required (https://docs.astral.sh/uv)" >&2
exit 1
fi
exec uv run --script "$DIR/smartcommit.py" "$@"
BIN
View File
Binary file not shown.
-51
View File
@@ -1,51 +0,0 @@
module github.com/prdlk/gh-commit
go 1.23
require (
github.com/BurntSushi/toml v1.4.0
github.com/charmbracelet/huh v0.6.0
github.com/charmbracelet/huh/spinner v0.0.0-20240618200428-90406d79077d
github.com/charmbracelet/lipgloss v1.0.0
github.com/rozoomcool/go-ollama-sdk v0.0.0-20250620220025-710cf9a2c767
github.com/spf13/cobra v1.8.1
golang.org/x/term v0.27.0
modernc.org/sqlite v1.34.4
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.2.0 // indirect
github.com/charmbracelet/bubbles v0.20.0 // indirect
github.com/charmbracelet/bubbletea v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.4.2 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.18.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)
-117
View File
@@ -1,117 +0,0 @@
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c=
github.com/charmbracelet/bubbletea v1.1.0/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4=
github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8=
github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU=
github.com/charmbracelet/huh/spinner v0.0.0-20240618200428-90406d79077d h1:OpthCCWiHBSx6LTAYGGkN9OeuJrKzjobe0q12wO6BX0=
github.com/charmbracelet/huh/spinner v0.0.0-20240618200428-90406d79077d/go.mod h1:CrXBZnOWs3zpyppOZZS7lu2CpLq2jx6U5chL/frRG/E=
github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk=
github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rozoomcool/go-ollama-sdk v0.0.0-20250620220025-710cf9a2c767 h1:TBRPWLhZWwrRJhigDIaoxvkeov4vpWYV7ws/qW3by+s=
github.com/rozoomcool/go-ollama-sdk v0.0.0-20250620220025-710cf9a2c767/go.mod h1:vHEieQv2QDMhRRLpSG3Vmi7a0wdy8hY2F14U4jtVuk0=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8=
modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
-194
View File
@@ -1,194 +0,0 @@
// Package ai talks to a local Ollama server. The rozoomcool SDK is used for
// client construction and model pulls; generation goes through a raw
// /api/generate POST because the SDK's Generate cannot pass think, options,
// or keep_alive — the levers this tool depends on for speed.
package ai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
ollama "github.com/rozoomcool/go-ollama-sdk"
"github.com/prdlk/gh-commit/internal/diff"
"github.com/prdlk/gh-commit/internal/ui"
)
// Generation profiles: small budget for one-line commit messages, larger
// budget and context for scope JSON over big file trees.
const (
commitNumPredict = 96
commitNumCtx = 8192
scopeNumPredict = 1024
scopeNumCtx = 16384
keepAlive = "10m"
temperature = 0.2
topP = 0.9
)
// Client is a speed-tuned Ollama client for one model.
type Client struct {
host string
model string
timeout time.Duration
http *http.Client
sdk *ollama.OllamaClient
}
// New builds a client for host/model with a per-request timeout.
func New(host, model string, timeout time.Duration) *Client {
host = strings.TrimRight(host, "/")
return &Client{
host: host,
model: model,
timeout: timeout,
http: &http.Client{Timeout: timeout},
sdk: ollama.NewClient(host),
}
}
// Model returns the configured model tag.
func (c *Client) Model() string { return c.model }
type generateOptions struct {
Temperature float64 `json:"temperature"`
TopP float64 `json:"top_p"`
NumPredict int `json:"num_predict"`
NumCtx int `json:"num_ctx"`
}
type generateRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
Stream bool `json:"stream"`
Think bool `json:"think"`
KeepAlive string `json:"keep_alive"`
Options generateOptions `json:"options"`
}
type generateResponse struct {
Response string `json:"response"`
Error string `json:"error"`
}
// generate POSTs a raw /api/generate request with the speed profile applied.
func (c *Client) generate(prompt string, numPredict, numCtx int) (string, error) {
body, err := json.Marshal(generateRequest{
Model: c.model,
Prompt: prompt,
Stream: false,
Think: false,
KeepAlive: keepAlive,
Options: generateOptions{
Temperature: temperature,
TopP: topP,
NumPredict: numPredict,
NumCtx: numCtx,
},
})
if err != nil {
return "", err
}
ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.host+"/api/generate", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return "", fmt.Errorf("ollama request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
var out generateResponse
if err := json.Unmarshal(data, &out); err != nil {
return "", fmt.Errorf("ollama returned unexpected payload: %w", err)
}
if resp.StatusCode != http.StatusOK {
if out.Error != "" {
return "", fmt.Errorf("ollama: %s", out.Error)
}
return "", fmt.Errorf("ollama returned HTTP %d", resp.StatusCode)
}
// think:false is sent, but strip any reasoning block as defense in depth.
return stripThink(out.Response), nil
}
// CommitMessage filters diff, prompts the model, and returns a cleaned
// commit message ("" when the model produced nothing usable).
func (c *Client) CommitMessage(rawDiff, scope string) (string, error) {
prompt := buildCommitPrompt(diff.Filter(rawDiff), scope)
out, err := c.generate(prompt, commitNumPredict, commitNumCtx)
if err != nil {
return "", err
}
return CleanCommitMessage(out), nil
}
// ScopesRaw prompts the model for a scope mapping and returns the raw
// response text (callers parse it so they can show diagnostics on failure).
func (c *Client) ScopesRaw(filetree string, existing map[string][]string) (string, error) {
return c.generate(buildScopePrompt(filetree, existing), scopeNumPredict, scopeNumCtx)
}
type tagsResponse struct {
Models []struct {
Name string `json:"name"`
} `json:"models"`
}
// EnsureReady verifies the Ollama server is answering and the model is
// available locally, offering to pull it when missing.
func (c *Client) EnsureReady() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.host+"/api/tags", nil)
if err != nil {
return err
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("the Ollama server is not reachable at %s — start it with 'ollama serve'", c.host)
}
defer func() { _ = resp.Body.Close() }()
var tags tagsResponse
if err := json.NewDecoder(resp.Body).Decode(&tags); err != nil {
return fmt.Errorf("the Ollama server at %s gave an unexpected response — start it with 'ollama serve'", c.host)
}
for _, m := range tags.Models {
if m.Name == c.model || (!strings.Contains(c.model, ":") && m.Name == c.model+":latest") {
return nil
}
}
ui.Warnf("Model %s is not available locally", c.model)
if !ui.Confirm(fmt.Sprintf("Pull %s now?", c.model)) {
return fmt.Errorf("model %s is not available — run 'ollama pull %s'", c.model, c.model)
}
last := ""
if err := c.sdk.PullModel(c.model, func(status string) {
if status != "" && status != last {
ui.Dimf(" %s", status)
last = status
}
}); err != nil {
return fmt.Errorf("pulling %s: %w", c.model, err)
}
ui.Successf("✓ Pulled %s", c.model)
return nil
}
-110
View File
@@ -1,110 +0,0 @@
package ai
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
var (
thinkRe = regexp.MustCompile(`(?s)<think>.*?</think>`)
fenceOpenRe = regexp.MustCompile("^```[a-zA-Z]*\n")
fenceCloseRe = regexp.MustCompile("\n```$")
scopedMsgRe = regexp.MustCompile(`^\w+\([^)]+\): .+`)
plainMsgRe = regexp.MustCompile(`^\w+: .+`)
)
// stripThink removes <think>...</think> reasoning blocks.
func stripThink(text string) string {
return strings.TrimSpace(thinkRe.ReplaceAllString(text, ""))
}
// ExtractJSONObject returns the first balanced top-level {...} object in text,
// or "" when none is found.
func ExtractJSONObject(text string) string {
depth := 0
start := -1
for i := range len(text) {
switch text[i] {
case '{':
if start == -1 {
start = i
}
depth++
case '}':
if start != -1 {
depth--
if depth == 0 {
return text[start : i+1]
}
}
}
}
return ""
}
// CoerceScopeMap normalizes a decoded JSON/TOML mapping into scope -> paths.
// String values become single-element slices; empty paths are dropped.
// Returns nil when nothing usable remains.
func CoerceScopeMap(data map[string]any) map[string][]string {
scopes := map[string][]string{}
for name, paths := range data {
switch v := paths.(type) {
case string:
scopes[name] = []string{v}
case []any:
var out []string
for _, p := range v {
s := fmt.Sprint(p)
if strings.TrimSpace(s) != "" {
out = append(out, s)
}
}
scopes[name] = out
}
}
if len(scopes) == 0 {
return nil
}
return scopes
}
// ParseScopesResponse extracts and normalizes the scope mapping from a model
// response. Returns nil when no usable JSON object is present.
func ParseScopesResponse(text string) map[string][]string {
blob := ExtractJSONObject(text)
if blob == "" {
return nil
}
var data map[string]any
if err := json.Unmarshal([]byte(blob), &data); err != nil {
return nil
}
return CoerceScopeMap(data)
}
// CleanCommitMessage normalizes a raw model response into a commit message:
// trims, strips <think> blocks and code fences, prefers the first non-empty
// line when it matches conventional-commit shape, and otherwise falls back to
// the whole trimmed string (never blocks a commit on a regex).
func CleanCommitMessage(raw string) string {
msg := stripThink(strings.TrimSpace(raw))
msg = fenceOpenRe.ReplaceAllString(msg, "")
msg = fenceCloseRe.ReplaceAllString(msg, "")
msg = strings.TrimSpace(msg)
if msg == "" {
return ""
}
for _, line := range strings.Split(msg, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if scopedMsgRe.MatchString(line) || plainMsgRe.MatchString(line) {
return line
}
break
}
return msg
}
-148
View File
@@ -1,148 +0,0 @@
package ai
import (
"reflect"
"strings"
"testing"
)
func TestExtractJSONObject(t *testing.T) {
tests := []struct {
name, in, want string
}{
{"bare object", `{"a": 1}`, `{"a": 1}`},
{"leading prose", `Here you go: {"a": {"b": 2}} done`, `{"a": {"b": 2}}`},
{"nested braces balanced", `{"a": {"b": {"c": 3}}}`, `{"a": {"b": {"c": 3}}}`},
{"first object wins", `{"a": 1} {"b": 2}`, `{"a": 1}`},
{"no object", "no json here", ""},
{"unbalanced", `{"a": 1`, ""},
{"stray close before open", `} {"a": 1}`, `{"a": 1}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ExtractJSONObject(tt.in); got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
func TestParseScopesResponse(t *testing.T) {
tests := []struct {
name string
in string
want map[string][]string
}{
{
"array values",
`{"core": ["src/", "lib/"], "docs": ["README.md"]}`,
map[string][]string{"core": {"src/", "lib/"}, "docs": {"README.md"}},
},
{
"string value coerced to slice",
`{"cli": "cmd/"}`,
map[string][]string{"cli": {"cmd/"}},
},
{
"empty paths dropped",
`{"core": ["src/", "", " "]}`,
map[string][]string{"core": {"src/"}},
},
{
"surrounding prose ignored",
"Sure! Here are the scopes:\n{\"api\": [\"api/\"]}\nHope that helps.",
map[string][]string{"api": {"api/"}},
},
{"not an object", `["a", "b"]`, nil},
{"invalid json", `{"a": }`, nil},
{"empty object", `{}`, nil},
{"no json at all", "nothing", nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ParseScopesResponse(tt.in); !reflect.DeepEqual(got, tt.want) {
t.Errorf("got %#v, want %#v", got, tt.want)
}
})
}
}
func TestCleanCommitMessage(t *testing.T) {
tests := []struct {
name, in, want string
}{
{"clean scoped message", "feat(core): add thing", "feat(core): add thing"},
{"clean unscoped message", "chore: tidy", "chore: tidy"},
{"whitespace trimmed", " fix(db): close conn \n", "fix(db): close conn"},
{
"code fence stripped",
"```\nfeat(ui): add button\n```",
"feat(ui): add button",
},
{
"language fence stripped",
"```text\nfix(api): handle 404\n```",
"fix(api): handle 404",
},
{
"think block stripped",
"<think>\nLet me reason about this diff...\n</think>\nfeat(ai): wire ollama",
"feat(ai): wire ollama",
},
{
"rambling reduced to first valid line",
"feat(core): add parser\n\nThis commit introduces a parser.",
"feat(core): add parser",
},
{
"invalid message falls back to whole string",
"I could not produce a message",
"I could not produce a message",
},
{
"multiline invalid first line falls back to whole string",
"Here is the commit:\nnot conventional",
"Here is the commit:\nnot conventional",
},
{"empty input", "", ""},
{"only think block", "<think>hmm</think>", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := CleanCommitMessage(tt.in); got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
func TestBuildCommitPrompt(t *testing.T) {
p := buildCommitPrompt("DIFF_BODY", "core")
for _, want := range []string{"Use this scope: core", "DIFF_BODY", "Do not think. Answer immediately."} {
if !strings.Contains(p, want) {
t.Errorf("prompt missing %q", want)
}
}
if p2 := buildCommitPrompt("DIFF_BODY", ""); strings.Contains(p2, "Use this scope") {
t.Error("scope hint should be absent without a scope")
}
}
func TestBuildScopePrompt(t *testing.T) {
p := buildScopePrompt("a.go\nb.go", nil)
for _, want := range []string{"a.go\nb.go", `{"scope": ["path", ...], ...}`, "Do not think. Answer immediately."} {
if !strings.Contains(p, want) {
t.Errorf("new prompt missing %q", want)
}
}
if strings.Contains(p, "Existing scopes") {
t.Error("new prompt must not mention existing scopes")
}
p = buildScopePrompt("a.go", map[string][]string{"core": {"src/"}})
for _, want := range []string{"Existing scopes (JSON):", `"core"`, `"src/"`} {
if !strings.Contains(p, want) {
t.Errorf("update prompt missing %q", want)
}
}
}
-84
View File
@@ -1,84 +0,0 @@
package ai
import (
"encoding/json"
"strings"
)
// Prompt text is ported unchanged from the Python tool, plus one
// "Do not think" line per prompt as cheap insurance on small models.
const commitPrompt = `You are an expert conventional commit message writer.
Use one of these commit types: feat, fix, docs, style, refactor, breaking, test, perf, build, ci, chore, init.
Determine a precise commit message from the provided diff and scope.
The commit message must follow this format: <type>(<scope>): <description>
Examples:
- fix(core): resolve consensus timeout during high load
- feat(did): add WebAuthn biometric authentication support
- refactor(hway): extract Redis connection pooling to shared utility
- init(browser): setup client package for web API-specific logic
- perf(vault): optimize IPFS chunk size for large file uploads
- ci(actions): update GitHub Actions pipeline for parallel module testing
- docs(dwn): clarify data retention policies in API reference
- test(svc): add integration tests for domain verification flow
- breaking(ui): rename Button prop 'type' to 'variant'
- refactor(sdk): migrate off wallet implementation in favor of @sonr.io/enclave
- feat(react): create Enclave stateful hooks
Never explain anything. Return only the commit message.
Do not think. Answer immediately.
{scope_hint}### Git Diff
{diff}
`
const scopeInstructions = `You are a repository scope identifier for Conventional Commits.
You receive a repository file tree, one repo-relative path per line.
Map logical project areas to the repo-relative path prefixes (directories or files) they cover.
Scope names must read well in Conventional Commits, e.g. core, api, ui, cli, docs, tests, ci, config, scripts, deps.
Each scope maps to a JSON array of repo-relative path prefixes.
Group related paths together and cover the meaningful source areas.
Never create scopes for generated, vendored, or build-output paths.
If existing scopes are provided, keep the ones that still apply, drop scopes whose paths no longer exist, and add scopes for new areas.
Never explain anything or print your thoughts. Never include Markdown code blocks.
Only return a single JSON object of the form {"scope": ["path", ...], ...}.
Do not think. Answer immediately.
`
const scopePromptNew = scopeInstructions + `
Repository file tree (one repo-relative path per line):
{filetree}
`
const scopePromptUpdate = scopeInstructions + `
Existing scopes (JSON):
{existing}
The repository structure may have changed (e.g. its .gitignore was edited).
Updated repository file tree (one repo-relative path per line):
{filetree}
`
// buildCommitPrompt fills the commit prompt with an optional scope hint and
// the (already filtered) diff.
func buildCommitPrompt(filteredDiff, scope string) string {
hint := ""
if scope != "" {
hint = "Use this scope: " + scope + "\n"
}
return strings.NewReplacer("{scope_hint}", hint, "{diff}", filteredDiff).Replace(commitPrompt)
}
// buildScopePrompt fills the scope prompt; existing may be nil for first-time
// generation.
func buildScopePrompt(filetree string, existing map[string][]string) string {
if len(existing) == 0 {
return strings.Replace(scopePromptNew, "{filetree}", filetree, 1)
}
blob, _ := json.MarshalIndent(existing, "", " ")
return strings.NewReplacer(
"{existing}", string(blob),
"{filetree}", filetree,
).Replace(scopePromptUpdate)
}
-246
View File
@@ -1,246 +0,0 @@
// Package db persists per-repository scopes in a pure-Go SQLite database
// (modernc.org/sqlite, no CGO). The schema mirrors the old DuckDB layout,
// with foreign keys + ON DELETE CASCADE replacing the manual cascade.
package db
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"sort"
_ "modernc.org/sqlite"
)
const schema = `
CREATE TABLE IF NOT EXISTS repositories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
gitignore_hash TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS scopes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(repo_id, name)
);
CREATE TABLE IF NOT EXISTS scope_paths (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope_id INTEGER NOT NULL REFERENCES scopes(id) ON DELETE CASCADE,
path TEXT NOT NULL,
UNIQUE(scope_id, path)
);
CREATE TABLE IF NOT EXISTS github_labels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope_id INTEGER NOT NULL UNIQUE REFERENCES scopes(id) ON DELETE CASCADE,
label_name TEXT NOT NULL,
color TEXT,
synced_at TIMESTAMP
);
`
// Dir returns the data directory: ${XDG_DATA_HOME:-~/.local/share}/gh-commit.
func Dir() string {
base := os.Getenv("XDG_DATA_HOME")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
base = filepath.Join(home, ".local", "share")
}
return filepath.Join(base, "gh-commit")
}
// Path returns the SQLite database path.
func Path() string { return filepath.Join(Dir(), "gh-commit.sqlite") }
// RepoInfo is one row of the `list` table.
type RepoInfo struct {
Name string
Path string
ScopeCount int
}
// Store wraps the SQLite connection.
type Store struct {
db *sql.DB
// HadLegacyDuckDB is true when this Open created the database for the
// first time while an old DuckDB file was present next to it.
HadLegacyDuckDB bool
}
// Open creates the data directory, opens the database, and applies the schema.
func Open() (*Store, error) {
dir := Dir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("creating %s: %w", dir, err)
}
path := Path()
legacy := false
if _, err := os.Stat(path); os.IsNotExist(err) {
if _, err := os.Stat(filepath.Join(dir, "gh-commit.db")); err == nil {
legacy = true
}
}
dsn := "file:" + path + "?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)"
conn, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("opening %s: %w", path, err)
}
if _, err := conn.Exec(schema); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("initializing schema: %w", err)
}
return &Store{db: conn, HadLegacyDuckDB: legacy}, nil
}
// Close closes the underlying connection.
func (s *Store) Close() error { return s.db.Close() }
// HasScopes reports whether repoPath has any stored scopes.
func (s *Store) HasScopes(repoPath string) (bool, error) {
var n int
err := s.db.QueryRow(`
SELECT COUNT(*) FROM scopes s
JOIN repositories r ON s.repo_id = r.id
WHERE r.path = ?`, repoPath).Scan(&n)
return n > 0, err
}
// Scopes returns repoPath's scopes as name -> ordered path prefixes.
func (s *Store) Scopes(repoPath string) (map[string][]string, error) {
rows, err := s.db.Query(`
SELECT s.name, sp.path
FROM scopes s
JOIN repositories r ON s.repo_id = r.id
JOIN scope_paths sp ON sp.scope_id = s.id
WHERE r.path = ?
ORDER BY s.name, sp.path`, repoPath)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
scopes := map[string][]string{}
for rows.Next() {
var name, path string
if err := rows.Scan(&name, &path); err != nil {
return nil, err
}
scopes[name] = append(scopes[name], path)
}
return scopes, rows.Err()
}
// SaveScopes replaces repoPath's scopes and snapshots gitignoreHash.
func (s *Store) SaveScopes(repoPath, repoName string, scopes map[string][]string, gitignoreHash string) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck // no-op after commit
var repoID int64
err = tx.QueryRow(`SELECT id FROM repositories WHERE path = ?`, repoPath).Scan(&repoID)
switch err {
case nil:
// ON DELETE CASCADE removes scope_paths and github_labels.
if _, err := tx.Exec(`DELETE FROM scopes WHERE repo_id = ?`, repoID); err != nil {
return err
}
case sql.ErrNoRows:
res, err := tx.Exec(`INSERT INTO repositories (path, name) VALUES (?, ?)`, repoPath, repoName)
if err != nil {
return err
}
if repoID, err = res.LastInsertId(); err != nil {
return err
}
default:
return err
}
names := make([]string, 0, len(scopes))
for name := range scopes {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
res, err := tx.Exec(`INSERT INTO scopes (repo_id, name) VALUES (?, ?)`, repoID, name)
if err != nil {
return err
}
scopeID, err := res.LastInsertId()
if err != nil {
return err
}
for _, path := range scopes[name] {
if _, err := tx.Exec(
`INSERT OR IGNORE INTO scope_paths (scope_id, path) VALUES (?, ?)`,
scopeID, path,
); err != nil {
return err
}
}
}
if _, err := tx.Exec(
`UPDATE repositories SET updated_at = CURRENT_TIMESTAMP, gitignore_hash = ? WHERE id = ?`,
gitignoreHash, repoID,
); err != nil {
return err
}
return tx.Commit()
}
// StoredGitignoreHash returns the snapshotted hash for repoPath. present is
// false when the repo is unknown or the hash was never recorded.
func (s *Store) StoredGitignoreHash(repoPath string) (hash string, present bool, err error) {
var v sql.NullString
err = s.db.QueryRow(`SELECT gitignore_hash FROM repositories WHERE path = ?`, repoPath).Scan(&v)
if err == sql.ErrNoRows {
return "", false, nil
}
if err != nil {
return "", false, err
}
return v.String, v.Valid, nil
}
// ListRepos returns all repositories, most recently updated first.
func (s *Store) ListRepos() ([]RepoInfo, error) {
rows, err := s.db.Query(`
SELECT r.name, r.path, COUNT(DISTINCT s.id) AS scope_count
FROM repositories r
LEFT JOIN scopes s ON s.repo_id = r.id
GROUP BY r.id, r.name, r.path, r.updated_at
ORDER BY r.updated_at DESC`)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var repos []RepoInfo
for rows.Next() {
var r RepoInfo
if err := rows.Scan(&r.Name, &r.Path, &r.ScopeCount); err != nil {
return nil, err
}
repos = append(repos, r)
}
return repos, rows.Err()
}
// DeleteRepo removes repoPath and, via cascade, all of its children.
func (s *Store) DeleteRepo(repoPath string) error {
_, err := s.db.Exec(`DELETE FROM repositories WHERE path = ?`, repoPath)
return err
}
-103
View File
@@ -1,103 +0,0 @@
package db
import (
"reflect"
"testing"
)
func openTestStore(t *testing.T) *Store {
t.Helper()
t.Setenv("XDG_DATA_HOME", t.TempDir())
s, err := Open()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.Close() })
return s
}
func TestSaveAndReadScopes(t *testing.T) {
s := openTestStore(t)
scopes := map[string][]string{
"core": {"internal/", "main.go"},
"docs": {"README.md"},
}
if has, _ := s.HasScopes("/repo"); has {
t.Fatal("fresh store should have no scopes")
}
if err := s.SaveScopes("/repo", "repo", scopes, "hash1"); err != nil {
t.Fatal(err)
}
if has, _ := s.HasScopes("/repo"); !has {
t.Fatal("scopes should exist after save")
}
got, err := s.Scopes("/repo")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, scopes) {
t.Errorf("got %#v, want %#v", got, scopes)
}
hash, present, err := s.StoredGitignoreHash("/repo")
if err != nil || !present || hash != "hash1" {
t.Errorf("want (hash1, true), got (%q, %v, %v)", hash, present, err)
}
}
func TestSaveScopesReplacesExisting(t *testing.T) {
s := openTestStore(t)
if err := s.SaveScopes("/repo", "repo", map[string][]string{"old": {"a/"}}, "h1"); err != nil {
t.Fatal(err)
}
if err := s.SaveScopes("/repo", "repo", map[string][]string{"new": {"b/"}}, "h2"); err != nil {
t.Fatal(err)
}
got, _ := s.Scopes("/repo")
want := map[string][]string{"new": {"b/"}}
if !reflect.DeepEqual(got, want) {
t.Errorf("old scopes must be cascade-deleted; got %#v", got)
}
if hash, _, _ := s.StoredGitignoreHash("/repo"); hash != "h2" {
t.Errorf("hash not updated: %q", hash)
}
}
func TestDeleteRepoCascades(t *testing.T) {
s := openTestStore(t)
if err := s.SaveScopes("/repo", "repo", map[string][]string{"core": {"a/"}}, "h"); err != nil {
t.Fatal(err)
}
if err := s.DeleteRepo("/repo"); err != nil {
t.Fatal(err)
}
if has, _ := s.HasScopes("/repo"); has {
t.Error("scopes must be gone after repo delete")
}
var n int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM scope_paths`).Scan(&n); err != nil || n != 0 {
t.Errorf("scope_paths rows must cascade away, got %d (%v)", n, err)
}
}
func TestStoredGitignoreHashUnknownRepo(t *testing.T) {
s := openTestStore(t)
if _, present, err := s.StoredGitignoreHash("/nowhere"); err != nil || present {
t.Errorf("unknown repo: want (false, nil), got (%v, %v)", present, err)
}
}
func TestListRepos(t *testing.T) {
s := openTestStore(t)
if err := s.SaveScopes("/a", "a", map[string][]string{"x": {"x/"}, "y": {"y/"}}, ""); err != nil {
t.Fatal(err)
}
repos, err := s.ListRepos()
if err != nil {
t.Fatal(err)
}
if len(repos) != 1 || repos[0].Name != "a" || repos[0].ScopeCount != 2 {
t.Errorf("got %#v", repos)
}
}
-101
View File
@@ -1,101 +0,0 @@
// Package diff filters git diffs before they reach the model: lock/generated
// files lose their hunk bodies, JSON changes are capped, and every other file
// is truncated. Behavior is a verbatim port of the Python filter_diff.
package diff
import (
"fmt"
"regexp"
"strings"
)
var lockPattern = regexp.MustCompile(
`(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb|` +
`go\.sum|go\.mod|Cargo\.lock|poetry\.lock|composer\.lock|Gemfile\.lock|` +
`.*\.min\.(js|css)|.*\.bundle\.js|.*\.map|` +
`dist/.*|build/.*|\.next/.*|node_modules/.*|vendor/.*|__pycache__/.*|\.pyc$|target/.*)`,
)
var (
fileNameRe = regexp.MustCompile(`b/([^ ]+)`)
headerRe = regexp.MustCompile(`^(index|---|\+\+\+|@@)`)
)
const (
// MaxDiffLines caps the per-file line count for regular files.
MaxDiffLines = 200
// MaxJSONLines caps the +/- line count for JSON files.
MaxJSONLines = 50
)
// Filter rewrites a raw git diff for prompt consumption.
func Filter(diff string) string {
var lines []string
inFiltered, inJSON := false, false
lineCount, jsonCount := 0, 0
for _, line := range strings.Split(diff, "\n") {
if strings.HasPrefix(line, "diff --git") {
lineCount, jsonCount = 0, 0
filename := ""
if m := fileNameRe.FindStringSubmatch(line); m != nil {
filename = m[1]
}
switch {
case lockPattern.MatchString(filename):
inFiltered, inJSON = true, false
lines = append(lines, line)
continue
case strings.HasSuffix(filename, ".json"):
inFiltered, inJSON = false, true
lines = append(lines, line)
continue
default:
inFiltered, inJSON = false, false
}
}
// Lock/generated files: keep headers, replace each hunk body with a
// marker. (The Python original stopped filtering after the first @@,
// leaking hunk bodies; the spec requires bodies replaced, so filtering
// holds until the next "diff --git".)
if inFiltered {
if headerRe.MatchString(line) {
lines = append(lines, line)
if strings.HasPrefix(line, "@@") {
lines = append(lines, "[Generated/lock file - content filtered]")
}
}
continue
}
if inJSON {
if headerRe.MatchString(line) {
lines = append(lines, line)
continue
}
if strings.HasPrefix(line, "+") || strings.HasPrefix(line, "-") {
jsonCount++
if jsonCount <= MaxJSONLines {
lines = append(lines, line)
} else if jsonCount == MaxJSONLines+1 {
lines = append(lines, fmt.Sprintf("[... JSON truncated after %d lines ...]", MaxJSONLines))
}
} else {
lines = append(lines, line)
}
continue
}
if lineCount < MaxDiffLines {
lines = append(lines, line)
lineCount++
} else if lineCount == MaxDiffLines {
lines = append(lines, fmt.Sprintf("[... truncated after %d lines ...]", MaxDiffLines))
lineCount++
}
}
return strings.Join(lines, "\n")
}
-127
View File
@@ -1,127 +0,0 @@
package diff
import (
"fmt"
"strings"
"testing"
)
func diffHeader(file string) string {
return fmt.Sprintf("diff --git a/%s b/%s\nindex 111..222 100644\n--- a/%s\n+++ b/%s\n@@ -1,2 +1,2 @@",
file, file, file, file)
}
func TestFilter(t *testing.T) {
bigBody := make([]string, 0, 300)
for i := range 300 {
bigBody = append(bigBody, fmt.Sprintf("+line %d", i))
}
jsonBody := make([]string, 0, 80)
for i := range 80 {
jsonBody = append(jsonBody, fmt.Sprintf("+ \"key%d\": %d,", i, i))
}
tests := []struct {
name string
input string
wantContain []string
wantAbsent []string
}{
{
name: "small file passes through unchanged",
input: diffHeader("main.go") + "\n-old\n+new",
wantContain: []string{"-old", "+new"},
wantAbsent: []string{"truncated", "filtered"},
},
{
name: "lock file body replaced with marker",
input: diffHeader("package-lock.json") + "\n-\"old\": 1\n+\"new\": 2",
wantContain: []string{
"diff --git a/package-lock.json b/package-lock.json",
"@@ -1,2 +1,2 @@",
"[Generated/lock file - content filtered]",
},
wantAbsent: []string{`"old": 1`, `"new": 2`},
},
{
name: "go.sum matched by lock pattern",
input: diffHeader("go.sum") + "\n+github.com/x v1.0.0 h1:abc",
wantContain: []string{"[Generated/lock file - content filtered]"},
wantAbsent: []string{"h1:abc"},
},
{
name: "path prefix matched anywhere in name",
input: diffHeader("node_modules/foo.js") + "\n+secret",
wantContain: []string{"[Generated/lock file - content filtered]"},
wantAbsent: []string{"+secret"},
},
{
name: "json file capped at 50 +/- lines",
input: diffHeader("config.json") + "\n" + strings.Join(jsonBody, "\n"),
wantContain: []string{
`+ "key49": 49,`,
"[... JSON truncated after 50 lines ...]",
},
wantAbsent: []string{`"key50"`, `"key79"`},
},
{
name: "json context lines kept beyond the cap",
input: diffHeader("config.json") + "\n" + strings.Join(jsonBody, "\n") + "\n context line",
wantContain: []string{" context line"},
},
{
name: "regular file truncated at 200 lines",
input: diffHeader("big.go") + "\n" + strings.Join(bigBody, "\n"),
wantContain: []string{
"[... truncated after 200 lines ...]",
},
wantAbsent: []string{"+line 299"},
},
{
name: "counters reset per file",
input: diffHeader("big.go") + "\n" + strings.Join(bigBody, "\n") + "\n" +
diffHeader("small.go") + "\n+after",
wantContain: []string{"+after"},
},
{
name: "lock file followed by normal file",
input: diffHeader("yarn.lock") + "\n+lockline\n" +
diffHeader("app.ts") + "\n+visible",
wantContain: []string{"[Generated/lock file - content filtered]", "+visible"},
wantAbsent: []string{"+lockline"},
},
{
name: "empty diff stays empty",
input: "",
wantContain: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Filter(tt.input)
for _, want := range tt.wantContain {
if !strings.Contains(got, want) {
t.Errorf("output missing %q\noutput:\n%s", want, got)
}
}
for _, absent := range tt.wantAbsent {
if strings.Contains(got, absent) {
t.Errorf("output should not contain %q\noutput:\n%s", absent, got)
}
}
})
}
}
func TestFilterTruncationMarkerOnlyOnce(t *testing.T) {
var body []string
for i := range 400 {
body = append(body, fmt.Sprintf("+l%d", i))
}
got := Filter(diffHeader("big.go") + "\n" + strings.Join(body, "\n"))
if n := strings.Count(got, "[... truncated after 200 lines ...]"); n != 1 {
t.Errorf("want exactly 1 truncation marker, got %d", n)
}
}
-142
View File
@@ -1,142 +0,0 @@
// Package gitx wraps the git CLI. Read helpers swallow errors and return ""
// (mirroring the Python tool); mutating helpers pass git output through and
// return errors.
package gitx
import (
"crypto/sha256"
"encoding/hex"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)
// Git runs git with args and returns trimmed stdout, or "" on any failure.
func Git(args ...string) string {
out, err := exec.Command("git", args...).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// passthrough runs git with args, streaming output to the terminal.
func passthrough(args ...string) error {
cmd := exec.Command("git", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// IsRepo reports whether the working directory is inside a git work tree.
func IsRepo() bool {
return exec.Command("git", "rev-parse", "--is-inside-work-tree").Run() == nil
}
// RepoRoot returns the repository top-level path, or "" outside a repo.
func RepoRoot() string { return Git("rev-parse", "--show-toplevel") }
// Lines splits git output into non-empty lines.
func Lines(out string) []string {
var lines []string
for _, l := range strings.Split(out, "\n") {
if l != "" {
lines = append(lines, l)
}
}
return lines
}
// ChangedFiles returns staged + unstaged + untracked files, deduplicated and sorted.
func ChangedFiles() []string {
seen := map[string]struct{}{}
for _, out := range []string{
Git("diff", "--cached", "--name-only"),
Git("diff", "--name-only"),
Git("ls-files", "--others", "--exclude-standard"),
} {
for _, f := range Lines(out) {
seen[f] = struct{}{}
}
}
files := make([]string, 0, len(seen))
for f := range seen {
files = append(files, f)
}
sort.Strings(files)
return files
}
// FilesInScope returns the files whose path starts with any of the scope prefixes.
func FilesInScope(files, prefixes []string) []string {
var matched []string
for _, f := range files {
for _, p := range prefixes {
if strings.HasPrefix(f, p) {
matched = append(matched, f)
break
}
}
}
return matched
}
// StageFiles stages files with git add.
func StageFiles(files []string) error {
if len(files) == 0 {
return nil
}
return passthrough(append([]string{"add"}, files...)...)
}
// ResetStaging unstages everything (git reset HEAD -- .), ignoring errors.
func ResetStaging() {
_ = exec.Command("git", "reset", "HEAD", "--", ".").Run()
}
// Commit commits the staging area with message.
func Commit(message string) error { return passthrough("commit", "-m", message) }
// UnpushedCommits returns oneline entries for commits not on any remote.
func UnpushedCommits() []string {
return Lines(Git("log", "--branches", "--not", "--remotes", "--oneline"))
}
// CurrentBranch returns the checked-out branch name.
func CurrentBranch() string { return Git("branch", "--show-current") }
// Push pushes branch to origin.
func Push(branch string) error { return passthrough("push", "origin", branch) }
// Filetree returns a sorted, deduplicated repo-relative file listing
// (tracked + non-ignored untracked), one path per line.
func Filetree() string {
seen := map[string]struct{}{}
for _, out := range []string{
Git("ls-files"),
Git("ls-files", "--others", "--exclude-standard"),
} {
for _, f := range Lines(out) {
seen[f] = struct{}{}
}
}
files := make([]string, 0, len(seen))
for f := range seen {
files = append(files, f)
}
sort.Strings(files)
return strings.Join(files, "\n")
}
// GitignoreHash returns the SHA-256 hex digest of the repo's .gitignore,
// or "" when there is none.
func GitignoreHash(repoRoot string) string {
data, err := os.ReadFile(filepath.Join(repoRoot, ".gitignore"))
if err != nil {
return ""
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
-74
View File
@@ -1,74 +0,0 @@
package gitx
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func TestFilesInScope(t *testing.T) {
files := []string{
"cmd/root.go",
"cmd/list.go",
"internal/db/store.go",
"README.md",
"cmdlets/x.go",
}
tests := []struct {
name string
prefixes []string
want []string
}{
{"directory prefix", []string{"cmd/"}, []string{"cmd/root.go", "cmd/list.go"}},
{"bare prefix matches sibling dirs too", []string{"cmd"}, []string{"cmd/root.go", "cmd/list.go", "cmdlets/x.go"}},
{"exact file", []string{"README.md"}, []string{"README.md"}},
{"multiple prefixes no duplicates", []string{"cmd/", "cmd/root"}, []string{"cmd/root.go", "cmd/list.go"}},
{"no match", []string{"docs/"}, nil},
{"empty prefixes", nil, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := FilesInScope(files, tt.prefixes); !reflect.DeepEqual(got, tt.want) {
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}
func TestGitignoreHash(t *testing.T) {
dir := t.TempDir()
if got := GitignoreHash(dir); got != "" {
t.Errorf("missing .gitignore should hash to empty string, got %q", got)
}
path := filepath.Join(dir, ".gitignore")
if err := os.WriteFile(path, []byte("node_modules/\n"), 0o644); err != nil {
t.Fatal(err)
}
// sha256 of "node_modules/\n"
first := GitignoreHash(dir)
if len(first) != 64 {
t.Errorf("want 64-char hex digest, got %q", first)
}
if again := GitignoreHash(dir); again != first {
t.Error("hash must be deterministic")
}
if err := os.WriteFile(path, []byte("dist/\n"), 0o644); err != nil {
t.Fatal(err)
}
if changed := GitignoreHash(dir); changed == first {
t.Error("hash must change when content changes")
}
}
func TestLines(t *testing.T) {
if got := Lines("a\n\nb\n"); !reflect.DeepEqual(got, []string{"a", "b"}) {
t.Errorf("got %v", got)
}
if got := Lines(""); got != nil {
t.Errorf("empty output should yield nil, got %v", got)
}
}
-113
View File
@@ -1,113 +0,0 @@
// Package ui centralizes lipgloss styling, huh confirmations, and spinners so
// every command shares the same visual language as the original Python tool.
package ui
import (
"fmt"
"os"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/huh/spinner"
"github.com/charmbracelet/lipgloss"
"golang.org/x/term"
)
// AutoConfirm short-circuits every confirmation prompt (GH_COMMIT_AUTO / --auto).
var AutoConfirm bool
var (
red = lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
green = lipgloss.NewStyle().Foreground(lipgloss.Color("2"))
greenBold = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Bold(true)
yellow = lipgloss.NewStyle().Foreground(lipgloss.Color("3"))
yellowBold = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Bold(true)
magenta = lipgloss.NewStyle().Foreground(lipgloss.Color("5"))
magentaBold = lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Bold(true)
cyan = lipgloss.NewStyle().Foreground(lipgloss.Color("6"))
dim = lipgloss.NewStyle().Faint(true)
bold = lipgloss.NewStyle().Bold(true)
panelStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("5")).
Padding(0, 1)
)
// Println prints a blank line.
func Println() { fmt.Println() }
// Printf prints unstyled text with a trailing newline.
func Printf(format string, a ...any) { fmt.Printf(format+"\n", a...) }
// Errorf prints red text.
func Errorf(format string, a ...any) { fmt.Println(red.Render(fmt.Sprintf(format, a...))) }
// Successf prints green text.
func Successf(format string, a ...any) { fmt.Println(green.Render(fmt.Sprintf(format, a...))) }
// SuccessBoldf prints bold green text.
func SuccessBoldf(format string, a ...any) {
fmt.Println(greenBold.Render(fmt.Sprintf(format, a...)))
}
// Warnf prints yellow text.
func Warnf(format string, a ...any) { fmt.Println(yellow.Render(fmt.Sprintf(format, a...))) }
// WarnBoldf prints bold yellow text.
func WarnBoldf(format string, a ...any) { fmt.Println(yellowBold.Render(fmt.Sprintf(format, a...))) }
// Infof prints cyan text.
func Infof(format string, a ...any) { fmt.Println(cyan.Render(fmt.Sprintf(format, a...))) }
// Magentaf prints magenta text.
func Magentaf(format string, a ...any) { fmt.Println(magenta.Render(fmt.Sprintf(format, a...))) }
// MagentaBoldf prints bold magenta text.
func MagentaBoldf(format string, a ...any) {
fmt.Println(magentaBold.Render(fmt.Sprintf(format, a...)))
}
// Dimf prints faint text.
func Dimf(format string, a ...any) { fmt.Println(dim.Render(fmt.Sprintf(format, a...))) }
// Bold returns s rendered bold, for inline composition.
func Bold(s string) string { return bold.Render(s) }
// Panel prints text inside a magenta rounded-border panel.
func Panel(text string) { fmt.Println(panelStyle.Render(text)) }
// Cyan returns s rendered cyan, for inline composition.
func Cyan(s string) string { return cyan.Render(s) }
// Green returns s rendered green, for inline composition.
func Green(s string) string { return green.Render(s) }
// Yellow returns s rendered yellow, for inline composition.
func Yellow(s string) string { return yellow.Render(s) }
// Red returns s rendered red, for inline composition.
func Red(s string) string { return red.Render(s) }
// Confirm asks a yes/no question, defaulting to yes. AutoConfirm returns true
// immediately; a prompt failure (e.g. no TTY, Ctrl-C) counts as no.
func Confirm(msg string) bool {
if AutoConfirm {
return true
}
v := true
if err := huh.NewConfirm().Title(msg).Value(&v).Run(); err != nil {
return false
}
return v
}
// Spin runs fn behind a spinner titled title. Without a TTY (or in auto mode)
// it prints the title once and runs fn directly.
func Spin(title string, fn func()) {
if AutoConfirm || !term.IsTerminal(int(os.Stdout.Fd())) || !term.IsTerminal(int(os.Stdin.Fd())) {
Dimf("%s", title)
fn()
return
}
_ = spinner.New().Title(title).Action(fn).Run()
}
-10
View File
@@ -1,10 +0,0 @@
package main
import "github.com/prdlk/gh-commit/cmd"
// version is injected at release time via -ldflags "-X main.version=...".
var version = "dev"
func main() {
cmd.Execute(version)
}
+970
View File
@@ -0,0 +1,970 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "duckdb>=1.0.0",
# "rich>=13.0.0",
# "questionary>=2.0.0",
# ]
# ///
"""gh-commit - AI-powered scoped git commits.
Both AI steps run through `crush run`: scope generation sends the repository's
file tree with embedded scope-identification instructions, and commit-message
writing sends the staged diff with embedded Conventional Commits instructions.
Scopes live in a local DuckDB and auto-regenerate
whenever the repository's .gitignore changes.
"""
import hashlib
import json
import os
import re
import subprocess
import sys
import tomllib
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Optional
import duckdb
import questionary
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
console = Console()
# ── Config ────────────────────────────────────────────────────────────────────
DB_DIR = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "gh-commit"
DB_PATH = DB_DIR / "gh-commit.db"
# Crush handles both AI steps in non-interactive mode. The instructions formerly
# stored as mods roles are embedded below so this tool is self-contained.
CRUSH_CMD = os.environ.get("GH_COMMIT_CRUSH_CMD", "crush").split()
CRUSH_MODEL = os.environ.get("GH_COMMIT_CRUSH_MODEL", "openrouter/qwen/qwen3.6-27b")
CRUSH_TIMEOUT = int(os.environ.get("GH_COMMIT_CRUSH_TIMEOUT", "120"))
AUTO_CONFIRM = os.environ.get("GH_COMMIT_AUTO", "0") == "1"
AUTO_PUSH = os.environ.get("GH_COMMIT_PUSH", "0") == "1"
NO_AUTO_REFRESH = os.environ.get("GH_COMMIT_NO_AUTO_REFRESH", "0") == "1"
DEBUG = os.environ.get("GH_COMMIT_DEBUG", "0") == "1"
VERSION = "5.0.0"
# ── Helpers ───────────────────────────────────────────────────────────────────
@dataclass
class Repo:
id: int
path: str
name: str
def run(cmd: list[str], capture: bool = True, check: bool = True) -> subprocess.CompletedProcess:
return subprocess.run(cmd, capture_output=capture, text=True, check=check)
def git(*args: str) -> str:
result = run(["git", *args], check=False)
return result.stdout.strip() if result.returncode == 0 else ""
def is_git_repo() -> bool:
return run(["git", "rev-parse", "--is-inside-work-tree"], check=False).returncode == 0
def get_repo_root() -> Optional[Path]:
root = git("rev-parse", "--show-toplevel")
return Path(root) if root else None
def confirm(msg: str) -> bool:
if AUTO_CONFIRM:
return True
return questionary.confirm(msg, default=True).ask() or False
def require_git() -> Path:
"""Assert we're in a git repo and return the root path."""
if not is_git_repo():
console.print("[red]Not in a git repository[/]")
sys.exit(1)
return get_repo_root()
# ── Crush CLI client ──────────────────────────────────────────────────────────
class CrushError(RuntimeError):
"""Raised when `crush run` fails to produce output."""
def build_filetree(repo_path: Path) -> str:
"""Repo-relative file listing respecting .gitignore (tracked + non-ignored)."""
tracked = git("ls-files")
untracked = git("ls-files", "--others", "--exclude-standard")
files = sorted({f for f in (tracked + "\n" + untracked).split("\n") if f})
return "\n".join(files)
def crush_prompt(text: str, cwd: Path) -> str:
"""Send `text` to `crush run` and return its reply."""
cmd = [*CRUSH_CMD, "run", "--quiet"]
if CRUSH_MODEL:
cmd += ["--model", CRUSH_MODEL]
try:
result = subprocess.run(
cmd, input=text, capture_output=True, text=True,
cwd=str(cwd), timeout=CRUSH_TIMEOUT,
)
except FileNotFoundError as e:
raise CrushError(
f"Could not run the Crush CLI ({CRUSH_CMD[0]!r} not found). "
"Install Crush, or set GH_COMMIT_CRUSH_CMD to a working command."
) from e
except subprocess.TimeoutExpired as e:
raise CrushError(f"crush run timed out after {CRUSH_TIMEOUT}s") from e
if result.returncode != 0:
raise CrushError(result.stderr.strip() or f"crush run exited with {result.returncode}")
return result.stdout.strip()
# ── Database ──────────────────────────────────────────────────────────────────
def init_db():
DB_DIR.mkdir(parents=True, exist_ok=True)
conn = duckdb.connect(str(DB_PATH))
conn.execute("CREATE SEQUENCE IF NOT EXISTS seq_repositories START 1")
conn.execute("CREATE SEQUENCE IF NOT EXISTS seq_scopes START 1")
conn.execute("CREATE SEQUENCE IF NOT EXISTS seq_scope_paths START 1")
conn.execute("CREATE SEQUENCE IF NOT EXISTS seq_github_labels START 1")
conn.execute("""
CREATE TABLE IF NOT EXISTS repositories (
id INTEGER DEFAULT nextval('seq_repositories') PRIMARY KEY,
path TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
gitignore_hash TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS scopes (
id INTEGER DEFAULT nextval('seq_scopes') PRIMARY KEY,
repo_id INTEGER NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(repo_id, name)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS scope_paths (
id INTEGER DEFAULT nextval('seq_scope_paths') PRIMARY KEY,
scope_id INTEGER NOT NULL,
path TEXT NOT NULL,
UNIQUE(scope_id, path)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS github_labels (
id INTEGER DEFAULT nextval('seq_github_labels') PRIMARY KEY,
scope_id INTEGER NOT NULL,
label_name TEXT NOT NULL,
color TEXT,
synced_at TIMESTAMP,
UNIQUE(scope_id)
)
""")
# Migrate older databases that predate gitignore tracking.
cols = {
row[0]
for row in conn.execute(
"SELECT column_name FROM information_schema.columns WHERE table_name = 'repositories'"
).fetchall()
}
if "gitignore_hash" not in cols:
conn.execute("ALTER TABLE repositories ADD COLUMN gitignore_hash TEXT")
conn.close()
def get_db() -> duckdb.DuckDBPyConnection:
return duckdb.connect(str(DB_PATH))
def repo_has_scopes(repo_path: str) -> bool:
conn = get_db()
result = conn.execute("""
SELECT COUNT(*) FROM scopes s
JOIN repositories r ON s.repo_id = r.id
WHERE r.path = ?
""", [repo_path]).fetchone()
conn.close()
return result[0] > 0 if result else False
def get_repo_scopes(repo_path: str) -> dict[str, list[str]]:
conn = get_db()
result = conn.execute("""
SELECT s.name, sp.path
FROM scopes s
JOIN repositories r ON s.repo_id = r.id
JOIN scope_paths sp ON sp.scope_id = s.id
WHERE r.path = ?
ORDER BY s.name, sp.path
""", [repo_path]).fetchall()
conn.close()
scopes: dict[str, list[str]] = {}
for scope_name, path in result:
scopes.setdefault(scope_name, []).append(path)
return scopes
def _cascade_delete_repo(conn, repo_id: int):
"""Delete all child records for a repository."""
scope_ids = conn.execute("SELECT id FROM scopes WHERE repo_id = ?", [repo_id]).fetchall()
for (scope_id,) in scope_ids:
conn.execute("DELETE FROM scope_paths WHERE scope_id = ?", [scope_id])
conn.execute("DELETE FROM github_labels WHERE scope_id = ?", [scope_id])
conn.execute("DELETE FROM scopes WHERE repo_id = ?", [repo_id])
def save_scopes(repo_path: str, repo_name: str, scopes: dict[str, list[str]]):
"""Persist scopes for a repo and snapshot the current .gitignore hash."""
conn = get_db()
result = conn.execute("SELECT id FROM repositories WHERE path = ?", [repo_path]).fetchone()
if result:
repo_id = result[0]
_cascade_delete_repo(conn, repo_id)
else:
conn.execute("INSERT INTO repositories (path, name) VALUES (?, ?)", [repo_path, repo_name])
repo_id = conn.execute("SELECT id FROM repositories WHERE path = ?", [repo_path]).fetchone()[0]
for scope_name, paths in scopes.items():
conn.execute("INSERT INTO scopes (repo_id, name) VALUES (?, ?)", [repo_id, scope_name])
scope_id = conn.execute(
"SELECT id FROM scopes WHERE repo_id = ? AND name = ?",
[repo_id, scope_name],
).fetchone()[0]
for path in (paths if isinstance(paths, list) else [paths]):
conn.execute("INSERT INTO scope_paths (scope_id, path) VALUES (?, ?)", [scope_id, path])
conn.execute(
"UPDATE repositories SET updated_at = CURRENT_TIMESTAMP, gitignore_hash = ? WHERE id = ?",
[current_gitignore_hash(Path(repo_path)), repo_id],
)
conn.close()
def get_stored_gitignore_hash(repo_path: str) -> Optional[str]:
conn = get_db()
row = conn.execute("SELECT gitignore_hash FROM repositories WHERE path = ?", [repo_path]).fetchone()
conn.close()
return row[0] if row else None
def list_repos() -> list[tuple]:
conn = get_db()
result = conn.execute("""
SELECT r.name, r.path, COUNT(DISTINCT s.id) as scope_count, r.updated_at
FROM repositories r
LEFT JOIN scopes s ON s.repo_id = r.id
GROUP BY r.id, r.name, r.path, r.updated_at
ORDER BY r.updated_at DESC
""").fetchall()
conn.close()
return result
def delete_repo(repo_path: str):
conn = get_db()
result = conn.execute("SELECT id FROM repositories WHERE path = ?", [repo_path]).fetchone()
if result:
repo_id = result[0]
_cascade_delete_repo(conn, repo_id)
conn.execute("DELETE FROM repositories WHERE id = ?", [repo_id])
conn.close()
# ── .gitignore tracking ─────────────────────────────────────────────────────────
def current_gitignore_hash(repo_root: Path) -> str:
"""SHA-256 of the repo's .gitignore, or "" when there is none."""
gitignore = repo_root / ".gitignore"
if not gitignore.exists():
return ""
return hashlib.sha256(gitignore.read_bytes()).hexdigest()
# ── Migration ─────────────────────────────────────────────────────────────────
def migrate_toml(repo_path: Path, repo_name: str, toml_path: Path) -> bool:
console.print("[yellow]↻ Migrating .github/Repo.toml → DuckDB[/]")
try:
data = tomllib.loads(toml_path.read_text())
save_scopes(str(repo_path), repo_name, data.get("scopes", {}))
backup = toml_path.with_suffix(f".toml.migrated.{datetime.now():%Y%m%d_%H%M%S}")
toml_path.rename(backup)
console.print(f"[dim] Archived: {backup}[/]")
return True
except Exception as e:
console.print(f"[red]Migration failed: {e}[/]")
return False
def migrate_json(repo_path: Path, repo_name: str, json_path: Path) -> bool:
console.print("[yellow]↻ Migrating .github/scopes.json → DuckDB[/]")
try:
data = json.loads(json_path.read_text())
scopes: dict[str, list[str]] = {}
for item in data:
scopes.setdefault(item["scope"], []).append(item["path"])
save_scopes(str(repo_path), repo_name, scopes)
backup = json_path.with_suffix(f".json.migrated.{datetime.now():%Y%m%d_%H%M%S}")
json_path.rename(backup)
console.print(f"[dim] Archived: {backup}[/]")
return True
except Exception as e:
console.print(f"[red]Migration failed: {e}[/]")
return False
def auto_migrate(repo_path: Path) -> bool:
repo_name = repo_path.name
toml_path = repo_path / ".github" / "Repo.toml"
if toml_path.exists():
return migrate_toml(repo_path, repo_name, toml_path)
json_path = repo_path / ".github" / "scopes.json"
if json_path.exists():
return migrate_json(repo_path, repo_name, json_path)
return False
# ── Diff filtering ────────────────────────────────────────────────────────────
LOCK_PATTERN = re.compile(
r"(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb|"
r"go\.sum|go\.mod|Cargo\.lock|poetry\.lock|composer\.lock|Gemfile\.lock|"
r".*\.min\.(js|css)|.*\.bundle\.js|.*\.map|"
r"dist/.*|build/.*|\.next/.*|node_modules/.*|vendor/.*|__pycache__/.*|\.pyc$|target/.*)"
)
MAX_DIFF_LINES = 200
MAX_JSON_LINES = 50
def filter_diff(diff: str) -> str:
lines = []
in_filtered = False
in_json = False
line_count = 0
json_count = 0
for line in diff.split("\n"):
if line.startswith("diff --git"):
line_count = 0
json_count = 0
match = re.search(r"b/([^ ]+)", line)
filename = match.group(1) if match else ""
if LOCK_PATTERN.search(filename):
in_filtered, in_json = True, False
lines.append(line)
continue
elif filename.endswith(".json"):
in_filtered, in_json = False, True
lines.append(line)
continue
else:
in_filtered, in_json = False, False
if in_filtered:
if re.match(r"^(index|---|\+\+\+|@@)", line):
lines.append(line)
if line.startswith("@@"):
lines.append("[Generated/lock file - content filtered]")
in_filtered = False
continue
if in_json:
if re.match(r"^(index|---|\+\+\+|@@)", line):
lines.append(line)
continue
if line.startswith(("+", "-")):
json_count += 1
if json_count <= MAX_JSON_LINES:
lines.append(line)
elif json_count == MAX_JSON_LINES + 1:
lines.append(f"[... JSON truncated after {MAX_JSON_LINES} lines ...]")
else:
lines.append(line)
continue
if line_count < MAX_DIFF_LINES:
lines.append(line)
line_count += 1
elif line_count == MAX_DIFF_LINES:
lines.append(f"[... truncated after {MAX_DIFF_LINES} lines ...]")
line_count += 1
return "\n".join(lines)
# ── AI integration (Crush CLI) ────────────────────────────────────────────────
COMMIT_PROMPT = """\
You are an expert conventional commit message writer.
Use one of these commit types: feat, fix, docs, style, refactor, breaking, test, perf, build, ci, chore, init.
Determine a precise commit message from the provided diff and scope.
The commit message must follow this format: <type>(<scope>): <description>
Examples:
- fix(core): resolve consensus timeout during high load
- feat(did): add WebAuthn biometric authentication support
- refactor(hway): extract Redis connection pooling to shared utility
- init(browser): setup client package for web API-specific logic
- perf(vault): optimize IPFS chunk size for large file uploads
- ci(actions): update GitHub Actions pipeline for parallel module testing
- docs(dwn): clarify data retention policies in API reference
- test(svc): add integration tests for domain verification flow
- breaking(ui): rename Button prop 'type' to 'variant'
- refactor(sdk): migrate off wallet implementation in favor of @sonr.io/enclave
- feat(react): create Enclave stateful hooks
Never explain anything. Return only the commit message.
{scope_hint}### Git Diff
{diff}
"""
SCOPE_INSTRUCTIONS = """\
You are a repository scope identifier for Conventional Commits.
You receive a repository file tree, one repo-relative path per line.
Map logical project areas to the repo-relative path prefixes (directories or files) they cover.
Scope names must read well in Conventional Commits, e.g. core, api, ui, cli, docs, tests, ci, config, scripts, deps.
Each scope maps to a JSON array of repo-relative path prefixes.
Group related paths together and cover the meaningful source areas.
Never create scopes for generated, vendored, or build-output paths.
If existing scopes are provided, keep the ones that still apply, drop scopes whose paths no longer exist, and add scopes for new areas.
Never explain anything or print your thoughts. Never include Markdown code blocks.
Only return a single JSON object of the form {{"scope": ["path", ...], ...}}.
"""
SCOPE_PROMPT_NEW = SCOPE_INSTRUCTIONS + """
Repository file tree (one repo-relative path per line):
{filetree}
"""
SCOPE_PROMPT_UPDATE = SCOPE_INSTRUCTIONS + """
Existing scopes (JSON):
{existing}
The repository structure may have changed (e.g. its .gitignore was edited).
Updated repository file tree (one repo-relative path per line):
{filetree}
"""
def _extract_json_object(text: str) -> Optional[str]:
"""Return the first balanced top-level {...} object found in text."""
depth = 0
start = None
for i, ch in enumerate(text):
if ch == "{":
if start is None:
start = i
depth += 1
elif ch == "}" and start is not None:
depth -= 1
if depth == 0:
return text[start:i + 1]
return None
def parse_scopes_response(text: str) -> Optional[dict[str, list[str]]]:
blob = _extract_json_object(text)
if not blob:
return None
try:
data = json.loads(blob)
except json.JSONDecodeError:
return None
if not isinstance(data, dict):
return None
scopes: dict[str, list[str]] = {}
for name, paths in data.items():
if isinstance(paths, str):
scopes[str(name)] = [paths]
elif isinstance(paths, list):
scopes[str(name)] = [str(p) for p in paths if str(p).strip()]
return scopes or None
def generate_commit_message(diff: str, repo_path: Path, scope: Optional[str] = None) -> Optional[str]:
scope_hint = f"Use this scope: {scope}\n" if scope else ""
prompt = COMMIT_PROMPT.format(scope_hint=scope_hint, diff=filter_diff(diff))
try:
message = crush_prompt(prompt, repo_path)
except CrushError as e:
console.print(f"[red]Crush failed to generate a commit message: {e}[/]")
return None
# Strip stray code fences if the model added them anyway.
message = re.sub(r"^```[a-zA-Z]*\n|\n```$", "", message.strip()).strip()
return message or None
def generate_scopes(repo_path: Path, existing: Optional[dict[str, list[str]]] = None) -> Optional[dict[str, list[str]]]:
filetree = build_filetree(repo_path)
if not filetree:
console.print("[red]No tracked or untracked files to analyze[/]")
return None
if existing:
prompt = SCOPE_PROMPT_UPDATE.format(existing=json.dumps(existing, indent=2), filetree=filetree)
else:
prompt = SCOPE_PROMPT_NEW.format(filetree=filetree)
try:
with console.status("[magenta]Analyzing repository with Crush...[/]"):
output = crush_prompt(prompt, repo_path)
except CrushError as e:
console.print(f"[red]Crush failed: {e}[/]")
return None
scopes = parse_scopes_response(output)
if not scopes:
console.print("[red]Could not parse scopes from Crush's response[/]")
if DEBUG:
console.print(f"[dim]{output}[/]")
return scopes
def display_scopes(scopes: dict[str, list[str]]):
for name, paths in sorted(scopes.items()):
console.print(f" [cyan]•[/] [bold]{name}[/]: {', '.join(paths)}")
# ── Git operations ────────────────────────────────────────────────────────────
def get_changed_files() -> list[str]:
staged = git("diff", "--cached", "--name-only").split("\n")
unstaged = git("diff", "--name-only").split("\n")
untracked = git("ls-files", "--others", "--exclude-standard").split("\n")
return sorted(set(f for f in staged + unstaged + untracked if f))
def get_files_in_scope(files: list[str], paths: list[str]) -> list[str]:
matched = []
for f in files:
if any(f.startswith(p) for p in paths):
matched.append(f)
return matched
def stage_files(files: list[str]):
if files:
subprocess.run(["git", "add", *files], check=True)
def reset_staging():
subprocess.run(["git", "reset", "HEAD", "--", "."], capture_output=True)
def do_commit(message: str):
subprocess.run(["git", "commit", "-m", message], check=True)
def get_unpushed_commits() -> list[str]:
output = git("log", "--branches", "--not", "--remotes", "--oneline")
return [line for line in output.split("\n") if line]
def push_to_origin():
branch = git("branch", "--show-current")
with console.status(f"[magenta]Pushing to origin/{branch}...[/]"):
subprocess.run(["git", "push", "origin", branch], check=True)
console.print(f"[green]✓ Pushed to origin/{branch}[/]")
def commit_group(repo_path: Path, files: list[str], scope: Optional[str] = None) -> bool:
"""Stage `files`, generate a message, and commit them as one group."""
files = [f for f in files if f]
if not files:
return False
reset_staging()
stage_files(files)
diff = git("diff", "--cached")
if not diff:
reset_staging()
return False
with console.status("[magenta]Generating commit message...[/]"):
message = generate_commit_message(diff, repo_path, scope)
if not message:
reset_staging()
return False
console.print(Panel(message, border_style="magenta"))
label = scope or "these changes"
if confirm(f"Commit {label}?"):
do_commit(message)
console.print(f"[green]✓ Committed {label}[/]\n")
return True
reset_staging()
console.print(f"[dim] Skipped {label}[/]\n")
return False
# ── Scope auto-refresh ───────────────────────────────────────────────────────────
def maybe_auto_refresh_scopes(repo_path: Path, repo_name: str):
"""Regenerate scopes automatically when .gitignore has changed since last save."""
if NO_AUTO_REFRESH:
return
current = current_gitignore_hash(repo_path)
stored = get_stored_gitignore_hash(str(repo_path))
# First time we've seen this repo's .gitignore — record a baseline, don't refresh.
if stored is None:
save_scopes(str(repo_path), repo_name, get_repo_scopes(str(repo_path)))
return
if current == stored:
return
console.print("[yellow]↻ .gitignore changed — regenerating scopes with Crush...[/]")
existing = get_repo_scopes(str(repo_path))
scopes = generate_scopes(repo_path, existing)
if not scopes:
console.print("[dim] Keeping existing scopes (regeneration failed)[/]\n")
return
save_scopes(str(repo_path), repo_name, scopes)
console.print("[green]✓ Scopes updated:[/]")
display_scopes(scopes)
console.print()
# ── Commands ──────────────────────────────────────────────────────────────────
def cmd_version():
print(f"gh-commit {VERSION}")
def cmd_help():
console.print(f"[magenta bold]gh commit[/] [dim]v{VERSION}[/] — AI-powered scoped git commits (Crush)\n")
console.print("[cyan]Usage:[/]")
console.print(" gh commit Commit changes grouped by scope")
console.print(" gh commit --auto Auto-confirm all prompts")
console.print(" gh commit --push Auto-push after committing")
console.print(" gh commit --auto --push Both")
console.print(" gh commit init Generate scopes for this repo")
console.print(" gh commit refresh Update scopes from current structure")
console.print(" gh commit sync Sync scopes → GitHub labels")
console.print(" gh commit list List all configured repositories")
console.print(" gh commit remove Remove current repo from database")
console.print(" gh commit db-path Print database file path")
console.print(" gh commit version Print version")
console.print(" gh commit help Show this help\n")
console.print("[cyan]Database:[/]")
console.print(f" {DB_PATH}\n")
console.print("[cyan]Environment:[/]")
console.print(" GH_COMMIT_AUTO=1 Skip all confirmation prompts")
console.print(" GH_COMMIT_PUSH=1 Auto-push after commits")
console.print(" GH_COMMIT_NO_AUTO_REFRESH=1 Don't auto-regenerate scopes on .gitignore change")
console.print(" GH_COMMIT_CRUSH_CMD=... Override the Crush command")
console.print(" GH_COMMIT_CRUSH_MODEL=... Override the Crush model (default: openrouter/qwen/qwen3.6-27b)")
console.print(" GH_COMMIT_CRUSH_TIMEOUT=... Per-prompt timeout in seconds (default: 120)")
console.print(" GH_COMMIT_DEBUG=1 Show parse diagnostics\n")
console.print("[cyan]Scopes auto-refresh whenever .gitignore changes.[/]")
console.print("[cyan]Legacy .github/Repo.toml or scopes.json are auto-migrated on first run.[/]")
def cmd_db_path():
print(DB_PATH)
def cmd_list():
console.print("[magenta bold]Repositories[/]\n")
repos = list_repos()
if not repos:
console.print("[dim]No repositories configured yet[/]")
console.print("\n[cyan]Run 'gh commit init' in a git repository to get started[/]")
return
table = Table(show_header=True)
table.add_column("Name", style="bold")
table.add_column("Path")
table.add_column("Scopes", justify="right")
for name, path, scope_count, _ in repos:
table.add_row(name, path, str(scope_count))
console.print(table)
def cmd_remove():
repo_path = require_git()
if not repo_has_scopes(str(repo_path)):
console.print(f"[dim]Repository not in database: {repo_path.name}[/]")
return 0
if confirm(f"Remove {repo_path.name} from database?"):
delete_repo(str(repo_path))
console.print(f"[green]✓ Removed {repo_path.name}[/]")
else:
console.print("[dim]Cancelled[/]")
return 0
def cmd_init():
repo_path = require_git()
repo_name = repo_path.name
# Check for legacy files
toml_path = repo_path / ".github" / "Repo.toml"
json_path = repo_path / ".github" / "scopes.json"
if toml_path.exists() or json_path.exists():
console.print("[yellow]Found legacy config file(s)[/]")
if confirm("Migrate to DuckDB?"):
if auto_migrate(repo_path):
console.print("[green]✓ Migration complete[/]")
return 0
if repo_has_scopes(str(repo_path)):
console.print("[yellow]⚠ Repository already configured[/]")
if not confirm("Overwrite existing scopes?"):
console.print("[dim]Cancelled[/]")
return 0
console.print(f"[magenta bold]Generating scopes for {repo_name}...[/]\n")
scopes = generate_scopes(repo_path)
if not scopes:
return 1
save_scopes(str(repo_path), repo_name, scopes)
console.print("[green]✓ Saved scopes to database[/]\n")
console.print("[magenta]Generated scopes:[/]")
display_scopes(scopes)
console.print("\n[dim]Run 'gh commit' to use these scopes[/]")
return 0
def cmd_refresh():
repo_path = require_git()
repo_name = repo_path.name
if not repo_has_scopes(str(repo_path)):
console.print("[red]Repository not configured — run 'gh commit init' first[/]")
return 1
existing_scopes = get_repo_scopes(str(repo_path))
console.print(f"[magenta bold]Refreshing scopes for {repo_name}...[/]\n")
console.print("[cyan]Current scopes:[/]")
display_scopes(existing_scopes)
console.print()
if not confirm("Refresh scopes based on current structure?"):
console.print("[dim]Cancelled[/]")
return 0
scopes = generate_scopes(repo_path, existing_scopes)
if not scopes:
return 1
console.print("\n[green]✓ Generated updated scopes[/]\n")
console.print("[magenta]Updated scopes:[/]")
display_scopes(scopes)
console.print()
if confirm("Apply these changes?"):
save_scopes(str(repo_path), repo_name, scopes)
console.print("[green]✓ Updated scopes[/]")
else:
console.print("[dim]Changes not applied[/]")
return 0
def cmd_sync():
repo_path = require_git()
if not repo_has_scopes(str(repo_path)):
console.print("[red]Repository not configured — run 'gh commit init' first[/]")
return 1
if subprocess.run(["which", "gh"], capture_output=True).returncode != 0:
console.print("[red]Error: 'gh' command not found[/]")
return 1
if subprocess.run(["gh", "repo", "view"], capture_output=True).returncode != 0:
console.print("[red]Error: Not a GitHub repository or not authenticated[/]")
return 1
console.print("[magenta bold]Syncing scopes → GitHub labels...[/]\n")
scopes = get_repo_scopes(str(repo_path))
created = updated = failed = 0
for scope_name, paths in scopes.items():
desc = f"Changes to: {', '.join(paths)}"
color = hashlib.md5(scope_name.encode()).hexdigest()[:6]
result = subprocess.run(
["gh", "label", "create", scope_name, "--description", desc, "--color", color],
capture_output=True,
)
if result.returncode == 0:
console.print(f" [green]✓[/] Created: {scope_name}")
created += 1
else:
result = subprocess.run(
["gh", "label", "edit", scope_name, "--description", desc, "--color", color],
capture_output=True,
)
if result.returncode == 0:
console.print(f" [yellow]↻[/] Updated: {scope_name}")
updated += 1
else:
console.print(f" [red]✗[/] Failed: {scope_name}")
failed += 1
console.print(f"\n[green bold]Sync complete![/] Created: {created} | Updated: {updated} | Failed: {failed}")
return 0
def cmd_commit():
repo_path = require_git()
repo_name = repo_path.name
auto_migrate(repo_path)
if not repo_has_scopes(str(repo_path)):
console.print("\n[yellow bold]⚠ No scopes configured for this repository[/]\n")
console.print("[cyan]gh-commit organizes commits by project areas (scopes).[/]\n")
if confirm("Generate scopes now using Crush?"):
if cmd_init() != 0:
return 1
else:
console.print("\n[dim]Run 'gh commit init' to configure scopes[/]")
return 1
# Keep scopes aligned with the repo whenever .gitignore changes.
maybe_auto_refresh_scopes(repo_path, repo_name)
console.print("[magenta]Finding scopes with changes...[/]")
changed_files = get_changed_files()
scopes = get_repo_scopes(str(repo_path))
scopes_with_changes = [
name for name, paths in scopes.items()
if get_files_in_scope(changed_files, paths)
]
if not scopes_with_changes:
console.print("[dim]No scoped changes found[/]")
else:
console.print(f"[cyan]Scopes with changes: {' '.join(scopes_with_changes)}[/]\n")
for scope in scopes_with_changes:
console.print(f"[magenta bold]Processing scope: {scope}[/]")
console.print(f"[cyan] Paths: {', '.join(scopes[scope])}[/]")
scope_files = get_files_in_scope(get_changed_files(), scopes[scope])
if not scope_files:
console.print("[dim] No files found in scope paths[/]\n")
continue
commit_group(repo_path, scope_files, scope)
reset_staging()
# Remaining files outside any scope.
if git("status", "--porcelain"):
console.print("[yellow]Processing remaining files outside any scope...[/]\n")
tracked = [f for f in git("diff", "--name-only").split("\n") if f]
if tracked:
console.print("[magenta]Tracked unstaged files:[/]")
for f in tracked:
console.print(f"[dim] {f}[/]")
if confirm("Commit tracked unstaged files?"):
commit_group(repo_path, tracked)
untracked = [f for f in git("ls-files", "--others", "--exclude-standard").split("\n") if f]
if untracked:
console.print("[magenta]Untracked files:[/]")
for f in untracked:
console.print(f"[dim] {f}[/]")
if confirm("Commit untracked files?"):
commit_group(repo_path, untracked)
# Push
unpushed = get_unpushed_commits()
if unpushed:
console.print("\n[magenta bold]Unpushed Commits[/]")
console.print(f"[cyan]{len(unpushed)} commit(s) ready to push:[/]\n")
for line in unpushed:
parts = line.split(" ", 1)
console.print(f" [bold]{parts[0]}[/] {parts[1] if len(parts) > 1 else ''}")
console.print()
if AUTO_PUSH or confirm("Push commits to origin?"):
push_to_origin()
else:
console.print("[dim]Skipped push[/]")
else:
console.print("\n[dim]No unpushed commits[/]")
console.print("\n[green bold]✓ Done![/]")
return 0
# ── Entrypoint ────────────────────────────────────────────────────────────────
COMMANDS = {
"init": cmd_init,
"refresh": cmd_refresh,
"sync": cmd_sync,
"list": cmd_list,
"remove": cmd_remove,
"db-path": cmd_db_path,
"version": cmd_version,
"help": cmd_help,
}
def main():
global AUTO_CONFIRM, AUTO_PUSH
args = sys.argv[1:]
# Parse flags
while args and args[0].startswith("--"):
flag = args.pop(0)
if flag == "--auto":
AUTO_CONFIRM = True
elif flag == "--push":
AUTO_PUSH = True
elif flag in ("--help", "-h"):
cmd_help()
return
elif flag == "--version":
cmd_version()
return
else:
# Unknown flag — might be a legacy --init style command
legacy = flag.lstrip("-")
if legacy in COMMANDS:
args.insert(0, legacy)
break
console.print(f"[red]Unknown flag: {flag}[/]")
console.print("[dim]Use 'gh commit help' for usage[/]")
sys.exit(1)
init_db()
cmd = args[0] if args else None
if cmd is None:
sys.exit(cmd_commit())
elif cmd in COMMANDS:
result = COMMANDS[cmd]()
if isinstance(result, int):
sys.exit(result)
else:
console.print(f"[red]Unknown command: {cmd}[/]")
console.print("[dim]Use 'gh commit help' for usage[/]")
sys.exit(1)
if __name__ == "__main__":
main()
-92
View File
@@ -1,92 +0,0 @@
//go:build integration
// Integration smoke test: builds the binary, then runs init + commit in a
// temp git repo against a live Ollama server. Skipped automatically when the
// server (or the model) is unavailable.
//
// go test -tags integration -run TestSmoke -v .
package main
import (
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func ollamaHost() string {
if h := os.Getenv("GH_COMMIT_OLLAMA_HOST"); h != "" {
return h
}
return "http://localhost:11434"
}
func run(t *testing.T, dir string, env []string, name string, args ...string) string {
t.Helper()
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), env...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("%s %v failed: %v\n%s", name, args, err, out)
}
return string(out)
}
func TestSmoke(t *testing.T) {
client := &http.Client{Timeout: 2 * time.Second}
if _, err := client.Get(ollamaHost() + "/api/tags"); err != nil {
t.Skipf("Ollama not reachable at %s: %v", ollamaHost(), err)
}
bin := filepath.Join(t.TempDir(), "gh-commit")
run(t, ".", nil, "go", "build", "-o", bin, ".")
repo := t.TempDir()
env := []string{
"GH_COMMIT_AUTO=1",
"XDG_DATA_HOME=" + t.TempDir(),
"GIT_CONFIG_GLOBAL=/dev/null",
"GIT_CONFIG_SYSTEM=/dev/null",
"GIT_AUTHOR_NAME=smoke", "GIT_AUTHOR_EMAIL=smoke@test",
"GIT_COMMITTER_NAME=smoke", "GIT_COMMITTER_EMAIL=smoke@test",
}
run(t, repo, env, "git", "init", "-q")
origin := t.TempDir()
run(t, origin, env, "git", "init", "-q", "--bare")
run(t, repo, env, "git", "remote", "add", "origin", origin)
writeFile := func(rel, content string) {
t.Helper()
path := filepath.Join(repo, rel)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
writeFile("src/app.go", "package app\n\nfunc Run() {}\n")
writeFile("docs/README.md", "# Smoke\n")
out := run(t, repo, env, bin, "init")
if !strings.Contains(out, "Saved scopes to database") {
t.Fatalf("init did not save scopes:\n%s", out)
}
writeFile("src/app.go", "package app\n\nfunc Run() { println(\"changed\") }\n")
out = run(t, repo, env, bin)
if !strings.Contains(out, "✓ Done!") {
t.Fatalf("commit flow did not complete:\n%s", out)
}
log := run(t, repo, env, "git", "log", "--format=%s")
if strings.TrimSpace(log) == "" {
t.Fatalf("no commits created; gh-commit output:\n%s", out)
}
t.Logf("commits:\n%s", log)
}