4 Commits
Author SHA1 Message Date
Prad Nukala 2ab62dd347 fix 2026-08-18 14:33:34 -04:00
Prad Nukala d81fc02693 fix 2026-08-18 14:33:30 -04:00
Prad Nukala 0acee0a694 fix: restore Groq default model with bundled crush provider
openrouter/qwen/qwen3.6-27b is not resolvable in Crush; the qwen model
only exists on Groq. Re-apply the 735229b provider fix: default to
groq/openai/gpt-oss-120b (verified live on Groq /openai/v1/models) via
the bundled crush-provider config, applied through CRUSH_GLOBAL_CONFIG
only when the default model is in use.
2026-08-18 14:32:41 -04:00
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
32 changed files with 64 additions and 2699 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
+1 -2
View File
@@ -1,2 +1 @@
/gh-commit
dist/
/__pycache__
+24 -27
View File
@@ -1,27 +1,25 @@
# gh-commit
AI-powered scoped git commits as a GitHub CLI extension, driven by a local
Ollama model. Scopes map Conventional Commit scope names to path prefixes.
They are generated from the repository file tree, stored in a local SQLite
database, and auto-regenerate whenever `.gitignore` changes. Changed files are
grouped by scope and committed one scope at a time with generated
`type(scope): message` subjects.
AI-powered scoped git commits as a GitHub CLI extension, driven by the
[Crush](https://github.com/charmbracelet/crush) CLI. Scopes map Conventional
Commit scope names to path prefixes. They are generated from the repository
file tree, stored in a local DuckDB database, and auto-regenerate whenever
`.gitignore` changes. Changed files are grouped by scope and committed one
scope at a time with generated `type(scope): message` subjects.
## Install
```sh
# Prerequisites: git, a running Ollama server, and the model
ollama pull qwen3.5:2b
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
- [Crush](https://github.com/charmbracelet/crush) — non-interactive AI runner
- `GROQ_API_KEY` — authenticates the default Groq model
By default, gh-commit uses `openrouter/qwen/qwen3.6-27b`. Configure OpenRouter in Crush or override the model with `GH_COMMIT_CRUSH_MODEL`.
By default, gh-commit uses Groq's `openai/gpt-oss-120b` via the bundled `crush-provider` config. Override the model with `GH_COMMIT_CRUSH_MODEL`.
## Usage
@@ -36,7 +34,7 @@ gh commit # commit changes grouped by scope
| 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 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 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 |
@@ -45,7 +43,7 @@ gh commit # commit changes grouped by scope
| `gh commit version` | Print `gh-commit <version>` |
| `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
@@ -55,17 +53,16 @@ Flags: `--auto` (skip confirmations), `--push` (auto-push), `--model`, `--host`.
| `GH_COMMIT_PUSH=1` | Auto-push after commits |
| `GH_COMMIT_NO_AUTO_REFRESH=1` | Don't auto-regenerate scopes when `.gitignore` changes |
| `GH_COMMIT_CRUSH_CMD` | Override the Crush command (default `crush`) |
| `GH_COMMIT_CRUSH_MODEL` | Override the Crush model (default `openrouter/qwen/qwen3.6-27b`) |
| `GH_COMMIT_CRUSH_MODEL` | Override the Crush model (default `groq/openai/gpt-oss-120b`) |
| `GH_COMMIT_CRUSH_TIMEOUT` | Per-prompt timeout in seconds (default `120`) |
| `GH_COMMIT_DEBUG=1` | Show scope-response parse diagnostics |
## Storage
Scopes live in `${XDG_DATA_HOME:-~/.local/share}/gh-commit/gh-commit.sqlite`.
Legacy `.github/Repo.toml` and `.github/scopes.json` files are migrated
automatically on first run (the source file is archived as
`*.migrated.<timestamp>`). Databases from the old DuckDB-based Python version
are not migrated; rerun `gh commit init`.
Scopes live in `${XDG_DATA_HOME:-~/.local/share}/gh-commit/gh-commit.db`
(DuckDB). Legacy `.github/Repo.toml` and `.github/scopes.json` files are
migrated automatically on first run (the source file is archived as
`*.migrated.<timestamp>`).
## Manual acceptance
@@ -83,12 +80,12 @@ git log --format='%s' # verify type(scope): message subjects
## Development
```sh
go test ./... # unit tests
go test -tags integration -run TestSmoke . # needs a running Ollama server
go build .
```
The extension is a bash launcher (`gh-commit`) that runs `smartcommit.py`
with `uv run --script`; uv resolves the inline dependencies (duckdb, rich,
questionary) automatically.
Releases are built by `cli/gh-extension-precompile` on `v*` tags for
linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, and windows/amd64
(`CGO_ENABLED=0`; the SQLite driver is pure Go).
```sh
python -m py_compile smartcommit.py # syntax check
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)
}
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://charm.land/crush.json",
"providers": {
"groq": {
"type": "openai-compat",
"base_url": "https://api.groq.com/openai/v1",
"api_key": "$GROQ_API_KEY",
"models": [
{
"id": "openai/gpt-oss-120b",
"name": "GPT-OSS 120B on Groq",
"context_window": 131072,
"default_max_tokens": 8192
}
]
}
}
}
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)
}
+9 -4
View File
@@ -44,7 +44,9 @@ 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")
DEFAULT_CRUSH_MODEL = "groq/openai/gpt-oss-120b"
CRUSH_MODEL = os.environ.get("GH_COMMIT_CRUSH_MODEL", DEFAULT_CRUSH_MODEL)
CRUSH_CONFIG_DIR = Path(__file__).with_name("crush-provider")
CRUSH_TIMEOUT = int(os.environ.get("GH_COMMIT_CRUSH_TIMEOUT", "120"))
AUTO_CONFIRM = os.environ.get("GH_COMMIT_AUTO", "0") == "1"
@@ -52,7 +54,7 @@ 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 = "2.0.0"
VERSION = "5.0.1"
# ── Helpers ───────────────────────────────────────────────────────────────────
@@ -115,9 +117,12 @@ def crush_prompt(text: str, cwd: Path) -> str:
cmd = [*CRUSH_CMD, "run", "--quiet"]
if CRUSH_MODEL:
cmd += ["--model", CRUSH_MODEL]
env = os.environ.copy()
if CRUSH_MODEL == DEFAULT_CRUSH_MODEL:
env.setdefault("CRUSH_GLOBAL_CONFIG", str(CRUSH_CONFIG_DIR))
try:
result = subprocess.run(
cmd, input=text, capture_output=True, text=True,
cmd, input=text, capture_output=True, text=True, env=env,
cwd=str(cwd), timeout=CRUSH_TIMEOUT,
)
except FileNotFoundError as e:
@@ -672,7 +677,7 @@ def cmd_help():
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(f" GH_COMMIT_CRUSH_MODEL=... Override the Crush model (default: {DEFAULT_CRUSH_MODEL})")
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.[/]")
-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)
}