mirror of
https://github.com/prdlk/gh-commit.git
synced 2026-09-16 23:16:25 +00:00
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.
225 lines
5.1 KiB
Go
225 lines
5.1 KiB
Go
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
|
|
}
|