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
|
||||
}
|
||||
Reference in New Issue
Block a user