mirror of
https://github.com/prdlk/gh-commit.git
synced 2026-09-16 23:16:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ab62dd347 | ||
|
|
d81fc02693 | ||
|
|
0acee0a694 | ||
|
|
3086dac5d6 |
@@ -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
@@ -1,2 +1 @@
|
||||
/gh-commit
|
||||
dist/
|
||||
/__pycache__
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" "$@"
|
||||
Binary file not shown.
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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.[/]")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user