mirror of
https://github.com/prdlk/gh-commit.git
synced 2026-09-16 23:16:25 +00:00
feat: rewrite gh-commit in Go with local Ollama backend
Replaces the Python/Crush/DuckDB implementation with a CGO-free Go binary: cobra CLI, modernc.org/sqlite storage, huh/lipgloss UI, and speed-tuned raw /api/generate calls (think:false, keep_alive, capped num_predict) against a local qwen3.5:2b. Ships as a gh extension via cli/gh-extension-precompile.
This commit is contained in:
+224
@@ -0,0 +1,224 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
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
@@ -0,0 +1,56 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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
@@ -0,0 +1,62 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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
@@ -0,0 +1,166 @@
|
||||
// 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")
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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
@@ -0,0 +1,76 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user