diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..122fec7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,18 @@ +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 diff --git a/.gitignore b/.gitignore index 6d696ce..3837628 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ -/.omc -/__pycache__ +/gh-commit +dist/ diff --git a/README.md b/README.md index fda600e..8441405 100644 --- a/README.md +++ b/README.md @@ -1,91 +1,87 @@ # gh-commit -AI-powered scoped git commits. Groups changes by project area, generates commit messages with `crush run`, and pushes — all in one command. +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. ## 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) — non-interactive AI runner -- `GROQ_API_KEY` — authenticates the default Groq model - -By default, gh-commit uses Groq's `openai/gpt-oss-120b`. Override the model with `GH_COMMIT_CRUSH_MODEL`. - ## Usage ```sh -# Initialize scopes for your repo (uses Crush to analyze structure) -gh commit init - -# Commit changes grouped by scope -gh commit - -# Auto-confirm + auto-push -gh commit --auto --push - -# Manually refresh scopes after structural changes -gh commit refresh - -# Sync scopes as GitHub labels -gh commit sync +cd your-repo +gh commit init # generate scopes for this repo +gh commit # commit changes grouped by scope ``` -## How it works - -1. **`gh commit init`** — `crush run` analyzes your repo structure and generates scope definitions (e.g., `core → src/`, `docs → docs/, README.md`, `ci → .github/workflows/`) -2. **`gh commit`** — Groups dirty files by scope, sends each staged diff to `crush run`, and commits each group separately -3. **Auto-refresh** — Whenever your `.gitignore` changes, scopes are automatically regenerated before committing (a content hash of `.gitignore` is tracked per repo) -4. Remaining unscoped files are handled in a final pass -5. Unpushed commits are offered for push - -Scopes are stored in a local DuckDB database (`~/.local/share/gh-commit/gh-commit.db`) — no config files in your repo. - -### Why Crush? - -The former Mods roles now live directly in `smartcommit.py`, so gh-commit no longer depends on Mods configuration. Each generation invokes Crush's supported non-interactive mode with a self-contained prompt. - ## Commands -| Command | Description | -|---------|-------------| -| `gh commit` | Commit changes grouped by scope | -| `gh commit init` | Generate scopes for current repo | -| `gh commit refresh` | Update scopes from current structure | -| `gh commit sync` | Sync scopes → GitHub labels | -| `gh commit list` | List all configured repositories | -| `gh commit remove` | Remove current repo from database | -| `gh commit db-path` | Print database file path | -| `gh commit version` | Print version | -| `gh commit help` | Show help | +| 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 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 | +| `gh commit remove` | Delete the current repo from the database after confirmation | +| `gh commit db-path` | Print the database file path | +| `gh commit version` | Print `gh-commit ` | +| `gh commit help` | Help, including the DB path and environment variable docs | -## Flags - -| Flag | Description | -|------|-------------| -| `--auto` | Skip all confirmation prompts | -| `--push` | Auto-push after committing | +Flags: `--auto` (skip confirmations), `--push` (auto-push), `--model`, `--host`. ## Environment -| Variable | Description | -|----------|-------------| -| `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 when `.gitignore` changes | -| `GH_COMMIT_CRUSH_CMD` | Override the Crush command (default `crush`) | -| `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 | +| Variable | Default | Purpose | +|---|---|---| +| `GH_COMMIT_OLLAMA_HOST` | `http://localhost:11434` | Ollama server URL | +| `GH_COMMIT_MODEL` | `qwen3.5:2b` | Model tag | +| `GH_COMMIT_TIMEOUT` | `120` | Per-request timeout, seconds | +| `GH_COMMIT_AUTO` | `0` | `1` = skip all confirmations | +| `GH_COMMIT_PUSH` | `0` | `1` = auto-push after commits | +| `GH_COMMIT_NO_AUTO_REFRESH` | `0` | `1` = never auto-regenerate scopes | +| `GH_COMMIT_DEBUG` | `0` | `1` = print raw model output on parse failure | -## Migration +## Storage -Existing `.github/Repo.toml` or `.github/scopes.json` files are automatically detected and migrated to DuckDB on first run. +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.`). Databases from the old DuckDB-based Python version +are not migrated; rerun `gh commit init`. -## License +## Manual acceptance -MIT +```sh +mkdir /tmp/accept && cd /tmp/accept && git init +mkdir -p src docs +echo 'package app' > src/app.go +echo '# Docs' > docs/README.md +gh commit init # scopes generated and saved +echo '// change' >> src/app.go +echo 'More docs' >> docs/README.md +gh commit # one commit per scope +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 . +``` + +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). diff --git a/cmd/commit.go b/cmd/commit.go new file mode 100644 index 0000000..cb668d3 --- /dev/null +++ b/cmd/commit.go @@ -0,0 +1,224 @@ +package cmd + +import ( + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/prdlk/gh-commit/internal/gitx" + "github.com/prdlk/gh-commit/internal/ui" +) + +// runCommit is the root command: the full scoped-commit flow. +func runCommit(_ *cobra.Command, _ []string) error { + repoPath, err := requireGit() + if err != nil { + return err + } + repoName := filepath.Base(repoPath) + + migrateLegacy(repoPath) + + hasScopes, err := store.HasScopes(repoPath) + if err != nil { + return err + } + if !hasScopes { + ui.Println() + ui.WarnBoldf("⚠ No scopes configured for this repository") + ui.Println() + ui.Infof("gh-commit organizes commits by project areas (scopes).") + ui.Println() + if !ui.Confirm("Generate scopes now using Ollama?") { + ui.Println() + ui.Dimf("Run 'gh commit init' to configure scopes") + return errAborted + } + if err := ensureAI(); err != nil { + return err + } + if err := initFlow(repoPath); err != nil { + return err + } + } + + if err := ensureAI(); err != nil { + return err + } + + // Keep scopes aligned with the repo whenever .gitignore changes. + if err := maybeAutoRefreshScopes(repoPath, repoName); err != nil { + return err + } + + ui.Magentaf("Finding scopes with changes...") + changedFiles := gitx.ChangedFiles() + scopes, err := store.Scopes(repoPath) + if err != nil { + return err + } + + var scopesWithChanges []string + for _, name := range sortedScopeNames(scopes) { + if len(gitx.FilesInScope(changedFiles, scopes[name])) > 0 { + scopesWithChanges = append(scopesWithChanges, name) + } + } + + if len(scopesWithChanges) == 0 { + ui.Dimf("No scoped changes found") + } else { + ui.Infof("Scopes with changes: %s", strings.Join(scopesWithChanges, " ")) + ui.Println() + } + + for _, scope := range scopesWithChanges { + ui.MagentaBoldf("Processing scope: %s", scope) + ui.Infof(" Paths: %s", strings.Join(scopes[scope], ", ")) + // Re-read changed files so earlier commits are excluded. + scopeFiles := gitx.FilesInScope(gitx.ChangedFiles(), scopes[scope]) + if len(scopeFiles) == 0 { + ui.Dimf(" No files found in scope paths") + ui.Println() + continue + } + if _, err := commitGroup(scopeFiles, scope); err != nil { + return err + } + } + + gitx.ResetStaging() + + // Remaining files outside any scope. + if gitx.Git("status", "--porcelain") != "" { + ui.Warnf("Processing remaining files outside any scope...") + ui.Println() + + tracked := gitx.Lines(gitx.Git("diff", "--name-only")) + if len(tracked) > 0 { + ui.Magentaf("Tracked unstaged files:") + for _, f := range tracked { + ui.Dimf(" %s", f) + } + if ui.Confirm("Commit tracked unstaged files?") { + if _, err := commitGroup(tracked, ""); err != nil { + return err + } + } + } + + untracked := gitx.Lines(gitx.Git("ls-files", "--others", "--exclude-standard")) + if len(untracked) > 0 { + ui.Magentaf("Untracked files:") + for _, f := range untracked { + ui.Dimf(" %s", f) + } + if ui.Confirm("Commit untracked files?") { + if _, err := commitGroup(untracked, ""); err != nil { + return err + } + } + } + } + + // Final safety reset before the push check. + gitx.ResetStaging() + + // Push. + unpushed := gitx.UnpushedCommits() + if len(unpushed) > 0 { + ui.Println() + ui.MagentaBoldf("Unpushed Commits") + ui.Infof("%d commit(s) ready to push:", len(unpushed)) + ui.Println() + for _, line := range unpushed { + hash, rest, _ := strings.Cut(line, " ") + ui.Printf(" %s %s", ui.Bold(hash), rest) + } + ui.Println() + if cfg.push || ui.Confirm("Push commits to origin?") { + if err := pushToOrigin(); err != nil { + return err + } + } else { + ui.Dimf("Skipped push") + } + } else { + ui.Println() + ui.Dimf("No unpushed commits") + } + + ui.Println() + ui.SuccessBoldf("✓ Done!") + return nil +} + +// commitGroup stages files, generates a message, and commits them as one group. +func commitGroup(files []string, scope string) (bool, error) { + var clean []string + for _, f := range files { + if f != "" { + clean = append(clean, f) + } + } + if len(clean) == 0 { + return false, nil + } + + gitx.ResetStaging() + if err := gitx.StageFiles(clean); err != nil { + return false, err + } + diff := gitx.Git("diff", "--cached") + if diff == "" { + gitx.ResetStaging() + return false, nil + } + + var message string + ui.Spin("Generating commit message...", func() { + var err error + message, err = client.CommitMessage(diff, scope) + if err != nil { + ui.Errorf("Ollama failed to generate a commit message: %s", err) + message = "" + } + }) + if message == "" { + gitx.ResetStaging() + return false, nil + } + + ui.Panel(message) + label := scope + if label == "" { + label = "these changes" + } + if ui.Confirm("Commit " + label + "?") { + if err := gitx.Commit(message); err != nil { + return false, err + } + ui.Successf("✓ Committed %s", label) + ui.Println() + return true, nil + } + gitx.ResetStaging() + ui.Dimf(" Skipped %s", label) + ui.Println() + return false, nil +} + +// pushToOrigin pushes the current branch behind a spinner. +func pushToOrigin() error { + branch := gitx.CurrentBranch() + var err error + ui.Spin("Pushing to origin/"+branch+"...", func() { + err = gitx.Push(branch) + }) + if err != nil { + return err + } + ui.Successf("✓ Pushed to origin/%s", branch) + return nil +} diff --git a/cmd/initcmd.go b/cmd/initcmd.go new file mode 100644 index 0000000..acd4f71 --- /dev/null +++ b/cmd/initcmd.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/prdlk/gh-commit/internal/gitx" + "github.com/prdlk/gh-commit/internal/ui" +) + +var initCmd = &cobra.Command{ + Use: "init", + Short: "Generate scopes for this repository from its file tree", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + repoPath, err := requireGit() + if err != nil { + return err + } + if err := ensureAI(); err != nil { + return err + } + return initFlow(repoPath) + }, +} + +// initFlow generates and saves scopes for repoPath. The Ollama client must +// already be ready. +func initFlow(repoPath string) error { + repoName := filepath.Base(repoPath) + + // Check for legacy files. + tomlPath := filepath.Join(repoPath, ".github", "Repo.toml") + jsonPath := filepath.Join(repoPath, ".github", "scopes.json") + if fileExists(tomlPath) || fileExists(jsonPath) { + ui.Warnf("Found legacy config file(s)") + if ui.Confirm("Migrate to the gh-commit database?") { + if migrateLegacy(repoPath) { + ui.Successf("✓ Migration complete") + return nil + } + } + } + + hasScopes, err := store.HasScopes(repoPath) + if err != nil { + return err + } + if hasScopes { + ui.Warnf("⚠ Repository already configured") + if !ui.Confirm("Overwrite existing scopes?") { + ui.Dimf("Cancelled") + return nil + } + } + + ui.MagentaBoldf("Generating scopes for %s...", repoName) + ui.Println() + scopes := generateScopes(nil) + if scopes == nil { + return errAborted + } + + if err := store.SaveScopes(repoPath, repoName, scopes, gitx.GitignoreHash(repoPath)); err != nil { + return err + } + ui.Successf("✓ Saved scopes to database") + ui.Println() + ui.Magentaf("Generated scopes:") + displayScopes(scopes) + ui.Println() + ui.Dimf("Run 'gh commit' to use these scopes") + return nil +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func init() { + rootCmd.AddCommand(initCmd) +} diff --git a/cmd/list.go b/cmd/list.go new file mode 100644 index 0000000..f099bb3 --- /dev/null +++ b/cmd/list.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "fmt" + "strconv" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/spf13/cobra" + + "github.com/prdlk/gh-commit/internal/ui" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List all configured repositories", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + ui.MagentaBoldf("Repositories") + ui.Println() + + repos, err := store.ListRepos() + if err != nil { + return err + } + if len(repos) == 0 { + ui.Dimf("No repositories configured yet") + ui.Println() + ui.Infof("Run 'gh commit init' in a git repository to get started") + return nil + } + + t := table.New(). + Border(lipgloss.NormalBorder()). + Headers("Name", "Path", "Scopes"). + StyleFunc(func(row, col int) lipgloss.Style { + s := lipgloss.NewStyle().Padding(0, 1) + if col == 2 { + s = s.Align(lipgloss.Right) + } + if row == table.HeaderRow || col == 0 { + s = s.Bold(true) + } + return s + }) + for _, r := range repos { + t.Row(r.Name, r.Path, strconv.Itoa(r.ScopeCount)) + } + fmt.Println(t) + return nil + }, +} + +func init() { + rootCmd.AddCommand(listCmd) +} diff --git a/cmd/migrate.go b/cmd/migrate.go new file mode 100644 index 0000000..6518977 --- /dev/null +++ b/cmd/migrate.go @@ -0,0 +1,93 @@ +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "time" + + "github.com/BurntSushi/toml" + + "github.com/prdlk/gh-commit/internal/ai" + "github.com/prdlk/gh-commit/internal/gitx" + "github.com/prdlk/gh-commit/internal/ui" +) + +// archiveMigrated renames a consumed legacy file to *.migrated.. +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 +} diff --git a/cmd/misc.go b/cmd/misc.go new file mode 100644 index 0000000..04e0713 --- /dev/null +++ b/cmd/misc.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "fmt" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/prdlk/gh-commit/internal/db" + "github.com/prdlk/gh-commit/internal/ui" +) + +var removeCmd = &cobra.Command{ + Use: "remove", + Short: "Remove the current repository from the database", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + repoPath, err := requireGit() + if err != nil { + return err + } + hasScopes, err := store.HasScopes(repoPath) + if err != nil { + return err + } + if !hasScopes { + ui.Dimf("Repository not in database: %s", filepath.Base(repoPath)) + return nil + } + if ui.Confirm(fmt.Sprintf("Remove %s from database?", filepath.Base(repoPath))) { + if err := store.DeleteRepo(repoPath); err != nil { + return err + } + ui.Successf("✓ Removed %s", filepath.Base(repoPath)) + } else { + ui.Dimf("Cancelled") + } + return nil + }, +} + +var dbPathCmd = &cobra.Command{ + Use: "db-path", + Short: "Print the database file path", + Args: cobra.NoArgs, + Run: func(_ *cobra.Command, _ []string) { + fmt.Println(db.Path()) + }, +} + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the version", + Args: cobra.NoArgs, + Run: func(_ *cobra.Command, _ []string) { + fmt.Printf("gh-commit %s\n", version) + }, +} + +func init() { + rootCmd.AddCommand(removeCmd, dbPathCmd, versionCmd) +} diff --git a/cmd/refresh.go b/cmd/refresh.go new file mode 100644 index 0000000..72ec48a --- /dev/null +++ b/cmd/refresh.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/prdlk/gh-commit/internal/gitx" + "github.com/prdlk/gh-commit/internal/ui" +) + +var refreshCmd = &cobra.Command{ + Use: "refresh", + Short: "Regenerate scopes from the current repository structure", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + repoPath, err := requireGit() + if err != nil { + return err + } + repoName := filepath.Base(repoPath) + + hasScopes, err := store.HasScopes(repoPath) + if err != nil { + return err + } + if !hasScopes { + ui.Errorf("Repository not configured — run 'gh commit init' first") + return errAborted + } + if err := ensureAI(); err != nil { + return err + } + + existing, err := store.Scopes(repoPath) + if err != nil { + return err + } + ui.MagentaBoldf("Refreshing scopes for %s...", repoName) + ui.Println() + ui.Infof("Current scopes:") + displayScopes(existing) + ui.Println() + + if !ui.Confirm("Refresh scopes based on current structure?") { + ui.Dimf("Cancelled") + return nil + } + + scopes := generateScopes(existing) + if scopes == nil { + return errAborted + } + + ui.Println() + ui.Successf("✓ Generated updated scopes") + ui.Println() + ui.Magentaf("Updated scopes:") + displayScopes(scopes) + ui.Println() + + if ui.Confirm("Apply these changes?") { + if err := store.SaveScopes(repoPath, repoName, scopes, gitx.GitignoreHash(repoPath)); err != nil { + return err + } + ui.Successf("✓ Updated scopes") + } else { + ui.Dimf("Changes not applied") + } + return nil + }, +} + +func init() { + rootCmd.AddCommand(refreshCmd) +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..ad3258a --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,166 @@ +// Package cmd wires the cobra CLI: config resolution, database and Ollama +// client lifecycles, and one file per subcommand. +package cmd + +import ( + "errors" + "fmt" + "os" + "strconv" + "time" + + "github.com/spf13/cobra" + + "github.com/prdlk/gh-commit/internal/ai" + "github.com/prdlk/gh-commit/internal/db" + "github.com/prdlk/gh-commit/internal/gitx" + "github.com/prdlk/gh-commit/internal/ui" +) + +// errAborted signals exit code 1 after the failure has already been printed. +var errAborted = errors.New("aborted") + +type config struct { + host string + model string + timeout time.Duration + auto bool + push bool + noAutoRefresh bool + debug bool +} + +var ( + version = "dev" + cfg config + store *db.Store + client *ai.Client + + flagHost string + flagModel string + flagAuto bool + flagPush bool +) + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func envBool(key string) bool { return os.Getenv(key) == "1" } + +func longHelp() string { + return fmt.Sprintf(`AI-powered scoped git commits driven by a local Ollama model. + +Scopes map Conventional Commit scope names to path prefixes. They are +generated from the repository file tree by the model, stored in a local +SQLite database, and auto-regenerate whenever .gitignore changes. +Legacy .github/Repo.toml or scopes.json files are auto-migrated on first run. + +Database: + %s + +Environment: + GH_COMMIT_OLLAMA_HOST Ollama server URL (default http://localhost:11434) + GH_COMMIT_MODEL Model tag (default qwen3.5:2b) + GH_COMMIT_TIMEOUT Per-request timeout in seconds (default 120) + GH_COMMIT_AUTO=1 Skip all confirmation prompts + GH_COMMIT_PUSH=1 Auto-push after commits + GH_COMMIT_NO_AUTO_REFRESH=1 Don't auto-regenerate scopes on .gitignore change + GH_COMMIT_DEBUG=1 Print raw model output on parse failure`, db.Path()) +} + +var rootCmd = &cobra.Command{ + Use: "gh-commit", + Short: "AI-powered scoped git commits (Ollama)", + Long: longHelp(), + Args: cobra.NoArgs, + SilenceUsage: true, + SilenceErrors: true, + CompletionOptions: cobra.CompletionOptions{ + DisableDefaultCmd: true, + }, + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + cfg.host = envOr("GH_COMMIT_OLLAMA_HOST", "http://localhost:11434") + cfg.model = envOr("GH_COMMIT_MODEL", "qwen3.5:2b") + if flagHost != "" { + cfg.host = flagHost + } + if flagModel != "" { + cfg.model = flagModel + } + seconds := 120 + if v, err := strconv.Atoi(envOr("GH_COMMIT_TIMEOUT", "120")); err == nil && v > 0 { + seconds = v + } + cfg.timeout = time.Duration(seconds) * time.Second + cfg.auto = envBool("GH_COMMIT_AUTO") || flagAuto + cfg.push = envBool("GH_COMMIT_PUSH") || flagPush + cfg.noAutoRefresh = envBool("GH_COMMIT_NO_AUTO_REFRESH") + cfg.debug = envBool("GH_COMMIT_DEBUG") + ui.AutoConfirm = cfg.auto + + switch cmd.Name() { + case "version", "help": + return nil + } + s, err := db.Open() + if err != nil { + return err + } + store = s + if s.HadLegacyDuckDB { + ui.Warnf("Found a legacy DuckDB database (gh-commit.db) — it cannot be migrated; scopes will regenerate on 'gh commit init'") + } + return nil + }, + RunE: runCommit, +} + +// Execute runs the CLI, printing errors in red and exiting 1 on failure. +func Execute(v string) { + version = v + rootCmd.Version = v + rootCmd.SetVersionTemplate("gh-commit {{.Version}}\n") + err := rootCmd.Execute() + if store != nil { + _ = store.Close() + } + if err != nil { + if !errors.Is(err, errAborted) { + ui.Errorf("%s", err) + } + os.Exit(1) + } +} + +// requireGit asserts we're inside a git repo and returns its root path. +func requireGit() (string, error) { + if !gitx.IsRepo() { + ui.Errorf("Not in a git repository") + return "", errAborted + } + return gitx.RepoRoot(), nil +} + +// ensureAI builds the Ollama client and verifies server + model availability. +func ensureAI() error { + if client != nil { + return nil + } + c := ai.New(cfg.host, cfg.model, cfg.timeout) + if err := c.EnsureReady(); err != nil { + return err + } + client = c + return nil +} + +func init() { + rootCmd.PersistentFlags().BoolVar(&flagAuto, "auto", false, "skip all confirmation prompts") + rootCmd.PersistentFlags().BoolVar(&flagPush, "push", false, "auto-push after committing") + rootCmd.PersistentFlags().StringVar(&flagModel, "model", "", "override the Ollama model tag") + rootCmd.PersistentFlags().StringVar(&flagHost, "host", "", "override the Ollama server URL") +} diff --git a/cmd/scopes.go b/cmd/scopes.go new file mode 100644 index 0000000..b37d31a --- /dev/null +++ b/cmd/scopes.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "sort" + "strings" + + "github.com/prdlk/gh-commit/internal/ai" + "github.com/prdlk/gh-commit/internal/gitx" + "github.com/prdlk/gh-commit/internal/ui" +) + +// sortedScopeNames returns scope names in deterministic (sorted) order, +// matching the DB query ordering the Python tool iterated in. +func sortedScopeNames(scopes map[string][]string) []string { + names := make([]string, 0, len(scopes)) + for name := range scopes { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// displayScopes prints "• name: path, path" bullets, sorted by name. +func displayScopes(scopes map[string][]string) { + for _, name := range sortedScopeNames(scopes) { + ui.Printf(" %s %s: %s", + ui.Cyan("•"), ui.Bold(name), strings.Join(scopes[name], ", ")) + } +} + +// generateScopes builds the file tree, prompts the model, and parses the +// response. Failures are reported here; nil means "keep going without". +func generateScopes(existing map[string][]string) map[string][]string { + filetree := gitx.Filetree() + if filetree == "" { + ui.Errorf("No tracked or untracked files to analyze") + return nil + } + var out string + var err error + ui.Spin("Analyzing repository with Ollama...", func() { + out, err = client.ScopesRaw(filetree, existing) + }) + if err != nil { + ui.Errorf("Ollama failed: %s", err) + return nil + } + scopes := ai.ParseScopesResponse(out) + if scopes == nil { + ui.Errorf("Could not parse scopes from Ollama's response") + if cfg.debug { + ui.Dimf("%s", out) + } + } + return scopes +} + +// maybeAutoRefreshScopes regenerates scopes when .gitignore changed since the +// last save. First sighting snapshots a baseline without regenerating. +func maybeAutoRefreshScopes(repoPath, repoName string) error { + if cfg.noAutoRefresh { + return nil + } + current := gitx.GitignoreHash(repoPath) + stored, present, err := store.StoredGitignoreHash(repoPath) + if err != nil { + return err + } + if !present { + scopes, err := store.Scopes(repoPath) + if err != nil { + return err + } + return store.SaveScopes(repoPath, repoName, scopes, current) + } + if current == stored { + return nil + } + + ui.Warnf("↻ .gitignore changed — regenerating scopes with Ollama...") + existing, err := store.Scopes(repoPath) + if err != nil { + return err + } + scopes := generateScopes(existing) + if scopes == nil { + ui.Dimf(" Keeping existing scopes (regeneration failed)") + ui.Println() + return nil + } + if err := store.SaveScopes(repoPath, repoName, scopes, current); err != nil { + return err + } + ui.Successf("✓ Scopes updated:") + displayScopes(scopes) + ui.Println() + return nil +} diff --git a/cmd/sync.go b/cmd/sync.go new file mode 100644 index 0000000..d02c183 --- /dev/null +++ b/cmd/sync.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "crypto/md5" //nolint:gosec // non-cryptographic: stable label color derivation + "encoding/hex" + "fmt" + "os/exec" + "strings" + + "github.com/spf13/cobra" + + "github.com/prdlk/gh-commit/internal/ui" +) + +var syncCmd = &cobra.Command{ + Use: "sync", + Short: "Sync scopes to GitHub labels", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + repoPath, err := requireGit() + if err != nil { + return err + } + + hasScopes, err := store.HasScopes(repoPath) + if err != nil { + return err + } + if !hasScopes { + ui.Errorf("Repository not configured — run 'gh commit init' first") + return errAborted + } + + if _, err := exec.LookPath("gh"); err != nil { + ui.Errorf("Error: 'gh' command not found") + return errAborted + } + if exec.Command("gh", "repo", "view").Run() != nil { + ui.Errorf("Error: Not a GitHub repository or not authenticated") + return errAborted + } + + ui.MagentaBoldf("Syncing scopes → GitHub labels...") + ui.Println() + scopes, err := store.Scopes(repoPath) + if err != nil { + return err + } + + created, updated, failed := 0, 0, 0 + for _, name := range sortedScopeNames(scopes) { + desc := "Changes to: " + strings.Join(scopes[name], ", ") + sum := md5.Sum([]byte(name)) //nolint:gosec // see import note + color := hex.EncodeToString(sum[:])[:6] + + if exec.Command("gh", "label", "create", name, "--description", desc, "--color", color).Run() == nil { + ui.Printf(" %s Created: %s", ui.Green("✓"), name) + created++ + } else if exec.Command("gh", "label", "edit", name, "--description", desc, "--color", color).Run() == nil { + ui.Printf(" %s Updated: %s", ui.Yellow("↻"), name) + updated++ + } else { + ui.Printf(" %s Failed: %s", ui.Red("✗"), name) + failed++ + } + } + + ui.Println() + ui.SuccessBoldf("Sync complete! %s", fmt.Sprintf("Created: %d | Updated: %d | Failed: %d", created, updated, failed)) + return nil + }, +} + +func init() { + rootCmd.AddCommand(syncCmd) +} diff --git a/crush-provider/crush.json b/crush-provider/crush.json deleted file mode 100644 index 67a528a..0000000 --- a/crush-provider/crush.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "$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 - } - ] - } - } -} diff --git a/gh-commit b/gh-commit deleted file mode 100755 index 5304e44..0000000 --- a/gh-commit +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# gh-commit: AI-powered scoped git commits (Crush for scopes and messages) -# Install: gh extension install prdlk/gh-commit -# Requires: uv (https://docs.astral.sh/uv) and the Crush CLI -# (https://github.com/charmbracelet/crush) - -EXTENSION_DIR="$(cd "$(dirname "$0")" && pwd)" -SCRIPT="${EXTENSION_DIR}/smartcommit.py" - -if ! command -v uv &>/dev/null; then - echo "error: 'uv' is required but not installed" - echo "install: curl -LsSf https://astral.sh/uv/install.sh | sh" - exit 1 -fi - -if [[ -z "${GH_COMMIT_CRUSH_CMD:-}" ]] && ! command -v crush &>/dev/null; then - echo "warning: 'crush' not found — gh-commit uses 'crush run' for scopes and commit messages." - echo "install Crush (https://github.com/charmbracelet/crush), or set GH_COMMIT_CRUSH_CMD." -fi - -exec uv run --script "${SCRIPT}" "$@" diff --git a/gh-commit.tar.gz b/gh-commit.tar.gz deleted file mode 100644 index 0f12509..0000000 Binary files a/gh-commit.tar.gz and /dev/null differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d7459e1 --- /dev/null +++ b/go.mod @@ -0,0 +1,51 @@ +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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3816e2f --- /dev/null +++ b/go.sum @@ -0,0 +1,117 @@ +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= diff --git a/internal/ai/client.go b/internal/ai/client.go new file mode 100644 index 0000000..50c3516 --- /dev/null +++ b/internal/ai/client.go @@ -0,0 +1,194 @@ +// 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 +} diff --git a/internal/ai/parse.go b/internal/ai/parse.go new file mode 100644 index 0000000..5ef8777 --- /dev/null +++ b/internal/ai/parse.go @@ -0,0 +1,110 @@ +package ai + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" +) + +var ( + thinkRe = regexp.MustCompile(`(?s).*?`) + fenceOpenRe = regexp.MustCompile("^```[a-zA-Z]*\n") + fenceCloseRe = regexp.MustCompile("\n```$") + scopedMsgRe = regexp.MustCompile(`^\w+\([^)]+\): .+`) + plainMsgRe = regexp.MustCompile(`^\w+: .+`) +) + +// stripThink removes ... 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 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 +} diff --git a/internal/ai/parse_test.go b/internal/ai/parse_test.go new file mode 100644 index 0000000..48f2d20 --- /dev/null +++ b/internal/ai/parse_test.go @@ -0,0 +1,148 @@ +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", + "\nLet me reason about this diff...\n\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", "hmm", ""}, + } + 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) + } + } +} diff --git a/internal/ai/prompts.go b/internal/ai/prompts.go new file mode 100644 index 0000000..a16fe1d --- /dev/null +++ b/internal/ai/prompts.go @@ -0,0 +1,84 @@ +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: (): +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) +} diff --git a/internal/db/store.go b/internal/db/store.go new file mode 100644 index 0000000..ec6391f --- /dev/null +++ b/internal/db/store.go @@ -0,0 +1,246 @@ +// 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 +} diff --git a/internal/db/store_test.go b/internal/db/store_test.go new file mode 100644 index 0000000..c482edc --- /dev/null +++ b/internal/db/store_test.go @@ -0,0 +1,103 @@ +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) + } +} diff --git a/internal/diff/filter.go b/internal/diff/filter.go new file mode 100644 index 0000000..b9f0e7b --- /dev/null +++ b/internal/diff/filter.go @@ -0,0 +1,101 @@ +// 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") +} diff --git a/internal/diff/filter_test.go b/internal/diff/filter_test.go new file mode 100644 index 0000000..a3c63e0 --- /dev/null +++ b/internal/diff/filter_test.go @@ -0,0 +1,127 @@ +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) + } +} diff --git a/internal/gitx/git.go b/internal/gitx/git.go new file mode 100644 index 0000000..2512f38 --- /dev/null +++ b/internal/gitx/git.go @@ -0,0 +1,142 @@ +// 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[:]) +} diff --git a/internal/gitx/git_test.go b/internal/gitx/git_test.go new file mode 100644 index 0000000..d9912e3 --- /dev/null +++ b/internal/gitx/git_test.go @@ -0,0 +1,74 @@ +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) + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go new file mode 100644 index 0000000..f5f08a0 --- /dev/null +++ b/internal/ui/ui.go @@ -0,0 +1,113 @@ +// 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() +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..7c419a5 --- /dev/null +++ b/main.go @@ -0,0 +1,10 @@ +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) +} diff --git a/smartcommit.py b/smartcommit.py deleted file mode 100644 index d08f306..0000000 --- a/smartcommit.py +++ /dev/null @@ -1,975 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.11" -# dependencies = [ -# "duckdb>=1.0.0", -# "rich>=13.0.0", -# "questionary>=2.0.0", -# ] -# /// -"""gh-commit - AI-powered scoped git commits. - -Both AI steps run through `crush run`: scope generation sends the repository's -file tree with embedded scope-identification instructions, and commit-message -writing sends the staged diff with embedded Conventional Commits instructions. -Scopes live in a local DuckDB and auto-regenerate -whenever the repository's .gitignore changes. -""" - -import hashlib -import json -import os -import re -import subprocess -import sys -import tomllib -from dataclasses import dataclass, field -from datetime import datetime -from pathlib import Path -from typing import Optional - -import duckdb -import questionary -from rich.console import Console -from rich.panel import Panel -from rich.table import Table - -console = Console() - -# ── Config ──────────────────────────────────────────────────────────────────── - -DB_DIR = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "gh-commit" -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() -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" -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" - - -# ── Helpers ─────────────────────────────────────────────────────────────────── - -@dataclass -class Repo: - id: int - path: str - name: str - - -def run(cmd: list[str], capture: bool = True, check: bool = True) -> subprocess.CompletedProcess: - return subprocess.run(cmd, capture_output=capture, text=True, check=check) - - -def git(*args: str) -> str: - result = run(["git", *args], check=False) - return result.stdout.strip() if result.returncode == 0 else "" - - -def is_git_repo() -> bool: - return run(["git", "rev-parse", "--is-inside-work-tree"], check=False).returncode == 0 - - -def get_repo_root() -> Optional[Path]: - root = git("rev-parse", "--show-toplevel") - return Path(root) if root else None - - -def confirm(msg: str) -> bool: - if AUTO_CONFIRM: - return True - return questionary.confirm(msg, default=True).ask() or False - - -def require_git() -> Path: - """Assert we're in a git repo and return the root path.""" - if not is_git_repo(): - console.print("[red]Not in a git repository[/]") - sys.exit(1) - return get_repo_root() - - -# ── Crush CLI client ────────────────────────────────────────────────────────── - -class CrushError(RuntimeError): - """Raised when `crush run` fails to produce output.""" - - -def build_filetree(repo_path: Path) -> str: - """Repo-relative file listing respecting .gitignore (tracked + non-ignored).""" - tracked = git("ls-files") - untracked = git("ls-files", "--others", "--exclude-standard") - files = sorted({f for f in (tracked + "\n" + untracked).split("\n") if f}) - return "\n".join(files) - - -def crush_prompt(text: str, cwd: Path) -> str: - """Send `text` to `crush run` and return its reply.""" - 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, env=env, - cwd=str(cwd), timeout=CRUSH_TIMEOUT, - ) - except FileNotFoundError as e: - raise CrushError( - f"Could not run the Crush CLI ({CRUSH_CMD[0]!r} not found). " - "Install Crush, or set GH_COMMIT_CRUSH_CMD to a working command." - ) from e - except subprocess.TimeoutExpired as e: - raise CrushError(f"crush run timed out after {CRUSH_TIMEOUT}s") from e - if result.returncode != 0: - raise CrushError(result.stderr.strip() or f"crush run exited with {result.returncode}") - return result.stdout.strip() - - -# ── Database ────────────────────────────────────────────────────────────────── - -def init_db(): - DB_DIR.mkdir(parents=True, exist_ok=True) - conn = duckdb.connect(str(DB_PATH)) - conn.execute("CREATE SEQUENCE IF NOT EXISTS seq_repositories START 1") - conn.execute("CREATE SEQUENCE IF NOT EXISTS seq_scopes START 1") - conn.execute("CREATE SEQUENCE IF NOT EXISTS seq_scope_paths START 1") - conn.execute("CREATE SEQUENCE IF NOT EXISTS seq_github_labels START 1") - conn.execute(""" - CREATE TABLE IF NOT EXISTS repositories ( - id INTEGER DEFAULT nextval('seq_repositories') PRIMARY KEY, - path TEXT UNIQUE NOT NULL, - name TEXT NOT NULL, - gitignore_hash TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """) - conn.execute(""" - CREATE TABLE IF NOT EXISTS scopes ( - id INTEGER DEFAULT nextval('seq_scopes') PRIMARY KEY, - repo_id INTEGER NOT NULL, - name TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(repo_id, name) - ) - """) - conn.execute(""" - CREATE TABLE IF NOT EXISTS scope_paths ( - id INTEGER DEFAULT nextval('seq_scope_paths') PRIMARY KEY, - scope_id INTEGER NOT NULL, - path TEXT NOT NULL, - UNIQUE(scope_id, path) - ) - """) - conn.execute(""" - CREATE TABLE IF NOT EXISTS github_labels ( - id INTEGER DEFAULT nextval('seq_github_labels') PRIMARY KEY, - scope_id INTEGER NOT NULL, - label_name TEXT NOT NULL, - color TEXT, - synced_at TIMESTAMP, - UNIQUE(scope_id) - ) - """) - # Migrate older databases that predate gitignore tracking. - cols = { - row[0] - for row in conn.execute( - "SELECT column_name FROM information_schema.columns WHERE table_name = 'repositories'" - ).fetchall() - } - if "gitignore_hash" not in cols: - conn.execute("ALTER TABLE repositories ADD COLUMN gitignore_hash TEXT") - conn.close() - - -def get_db() -> duckdb.DuckDBPyConnection: - return duckdb.connect(str(DB_PATH)) - - -def repo_has_scopes(repo_path: str) -> bool: - conn = get_db() - result = conn.execute(""" - SELECT COUNT(*) FROM scopes s - JOIN repositories r ON s.repo_id = r.id - WHERE r.path = ? - """, [repo_path]).fetchone() - conn.close() - return result[0] > 0 if result else False - - -def get_repo_scopes(repo_path: str) -> dict[str, list[str]]: - conn = get_db() - result = conn.execute(""" - 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 - """, [repo_path]).fetchall() - conn.close() - scopes: dict[str, list[str]] = {} - for scope_name, path in result: - scopes.setdefault(scope_name, []).append(path) - return scopes - - -def _cascade_delete_repo(conn, repo_id: int): - """Delete all child records for a repository.""" - scope_ids = conn.execute("SELECT id FROM scopes WHERE repo_id = ?", [repo_id]).fetchall() - for (scope_id,) in scope_ids: - conn.execute("DELETE FROM scope_paths WHERE scope_id = ?", [scope_id]) - conn.execute("DELETE FROM github_labels WHERE scope_id = ?", [scope_id]) - conn.execute("DELETE FROM scopes WHERE repo_id = ?", [repo_id]) - - -def save_scopes(repo_path: str, repo_name: str, scopes: dict[str, list[str]]): - """Persist scopes for a repo and snapshot the current .gitignore hash.""" - conn = get_db() - result = conn.execute("SELECT id FROM repositories WHERE path = ?", [repo_path]).fetchone() - if result: - repo_id = result[0] - _cascade_delete_repo(conn, repo_id) - else: - conn.execute("INSERT INTO repositories (path, name) VALUES (?, ?)", [repo_path, repo_name]) - repo_id = conn.execute("SELECT id FROM repositories WHERE path = ?", [repo_path]).fetchone()[0] - - for scope_name, paths in scopes.items(): - conn.execute("INSERT INTO scopes (repo_id, name) VALUES (?, ?)", [repo_id, scope_name]) - scope_id = conn.execute( - "SELECT id FROM scopes WHERE repo_id = ? AND name = ?", - [repo_id, scope_name], - ).fetchone()[0] - for path in (paths if isinstance(paths, list) else [paths]): - conn.execute("INSERT INTO scope_paths (scope_id, path) VALUES (?, ?)", [scope_id, path]) - - conn.execute( - "UPDATE repositories SET updated_at = CURRENT_TIMESTAMP, gitignore_hash = ? WHERE id = ?", - [current_gitignore_hash(Path(repo_path)), repo_id], - ) - conn.close() - - -def get_stored_gitignore_hash(repo_path: str) -> Optional[str]: - conn = get_db() - row = conn.execute("SELECT gitignore_hash FROM repositories WHERE path = ?", [repo_path]).fetchone() - conn.close() - return row[0] if row else None - - -def list_repos() -> list[tuple]: - conn = get_db() - result = conn.execute(""" - SELECT r.name, r.path, COUNT(DISTINCT s.id) as scope_count, r.updated_at - 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 - """).fetchall() - conn.close() - return result - - -def delete_repo(repo_path: str): - conn = get_db() - result = conn.execute("SELECT id FROM repositories WHERE path = ?", [repo_path]).fetchone() - if result: - repo_id = result[0] - _cascade_delete_repo(conn, repo_id) - conn.execute("DELETE FROM repositories WHERE id = ?", [repo_id]) - conn.close() - - -# ── .gitignore tracking ───────────────────────────────────────────────────────── - -def current_gitignore_hash(repo_root: Path) -> str: - """SHA-256 of the repo's .gitignore, or "" when there is none.""" - gitignore = repo_root / ".gitignore" - if not gitignore.exists(): - return "" - return hashlib.sha256(gitignore.read_bytes()).hexdigest() - - -# ── Migration ───────────────────────────────────────────────────────────────── - -def migrate_toml(repo_path: Path, repo_name: str, toml_path: Path) -> bool: - console.print("[yellow]↻ Migrating .github/Repo.toml → DuckDB[/]") - try: - data = tomllib.loads(toml_path.read_text()) - save_scopes(str(repo_path), repo_name, data.get("scopes", {})) - backup = toml_path.with_suffix(f".toml.migrated.{datetime.now():%Y%m%d_%H%M%S}") - toml_path.rename(backup) - console.print(f"[dim] Archived: {backup}[/]") - return True - except Exception as e: - console.print(f"[red]Migration failed: {e}[/]") - return False - - -def migrate_json(repo_path: Path, repo_name: str, json_path: Path) -> bool: - console.print("[yellow]↻ Migrating .github/scopes.json → DuckDB[/]") - try: - data = json.loads(json_path.read_text()) - scopes: dict[str, list[str]] = {} - for item in data: - scopes.setdefault(item["scope"], []).append(item["path"]) - save_scopes(str(repo_path), repo_name, scopes) - backup = json_path.with_suffix(f".json.migrated.{datetime.now():%Y%m%d_%H%M%S}") - json_path.rename(backup) - console.print(f"[dim] Archived: {backup}[/]") - return True - except Exception as e: - console.print(f"[red]Migration failed: {e}[/]") - return False - - -def auto_migrate(repo_path: Path) -> bool: - repo_name = repo_path.name - toml_path = repo_path / ".github" / "Repo.toml" - if toml_path.exists(): - return migrate_toml(repo_path, repo_name, toml_path) - json_path = repo_path / ".github" / "scopes.json" - if json_path.exists(): - return migrate_json(repo_path, repo_name, json_path) - return False - - -# ── Diff filtering ──────────────────────────────────────────────────────────── - -LOCK_PATTERN = re.compile( - r"(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb|" - r"go\.sum|go\.mod|Cargo\.lock|poetry\.lock|composer\.lock|Gemfile\.lock|" - r".*\.min\.(js|css)|.*\.bundle\.js|.*\.map|" - r"dist/.*|build/.*|\.next/.*|node_modules/.*|vendor/.*|__pycache__/.*|\.pyc$|target/.*)" -) - -MAX_DIFF_LINES = 200 -MAX_JSON_LINES = 50 - - -def filter_diff(diff: str) -> str: - lines = [] - in_filtered = False - in_json = False - line_count = 0 - json_count = 0 - - for line in diff.split("\n"): - if line.startswith("diff --git"): - line_count = 0 - json_count = 0 - match = re.search(r"b/([^ ]+)", line) - filename = match.group(1) if match else "" - - if LOCK_PATTERN.search(filename): - in_filtered, in_json = True, False - lines.append(line) - continue - elif filename.endswith(".json"): - in_filtered, in_json = False, True - lines.append(line) - continue - else: - in_filtered, in_json = False, False - - if in_filtered: - if re.match(r"^(index|---|\+\+\+|@@)", line): - lines.append(line) - if line.startswith("@@"): - lines.append("[Generated/lock file - content filtered]") - in_filtered = False - continue - - if in_json: - if re.match(r"^(index|---|\+\+\+|@@)", line): - lines.append(line) - continue - if line.startswith(("+", "-")): - json_count += 1 - if json_count <= MAX_JSON_LINES: - lines.append(line) - elif json_count == MAX_JSON_LINES + 1: - lines.append(f"[... JSON truncated after {MAX_JSON_LINES} lines ...]") - else: - lines.append(line) - continue - - if line_count < MAX_DIFF_LINES: - lines.append(line) - line_count += 1 - elif line_count == MAX_DIFF_LINES: - lines.append(f"[... truncated after {MAX_DIFF_LINES} lines ...]") - line_count += 1 - - return "\n".join(lines) - - -# ── AI integration (Crush CLI) ──────────────────────────────────────────────── - -COMMIT_PROMPT = """\ -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: (): -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. - -{scope_hint}### Git Diff -{diff} -""" - -SCOPE_INSTRUCTIONS = """\ -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", ...], ...}}. -""" - -SCOPE_PROMPT_NEW = SCOPE_INSTRUCTIONS + """ - -Repository file tree (one repo-relative path per line): - -{filetree} -""" - -SCOPE_PROMPT_UPDATE = SCOPE_INSTRUCTIONS + """ - -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} -""" - - -def _extract_json_object(text: str) -> Optional[str]: - """Return the first balanced top-level {...} object found in text.""" - depth = 0 - start = None - for i, ch in enumerate(text): - if ch == "{": - if start is None: - start = i - depth += 1 - elif ch == "}" and start is not None: - depth -= 1 - if depth == 0: - return text[start:i + 1] - return None - - -def parse_scopes_response(text: str) -> Optional[dict[str, list[str]]]: - blob = _extract_json_object(text) - if not blob: - return None - try: - data = json.loads(blob) - except json.JSONDecodeError: - return None - if not isinstance(data, dict): - return None - scopes: dict[str, list[str]] = {} - for name, paths in data.items(): - if isinstance(paths, str): - scopes[str(name)] = [paths] - elif isinstance(paths, list): - scopes[str(name)] = [str(p) for p in paths if str(p).strip()] - return scopes or None - - -def generate_commit_message(diff: str, repo_path: Path, scope: Optional[str] = None) -> Optional[str]: - scope_hint = f"Use this scope: {scope}\n" if scope else "" - prompt = COMMIT_PROMPT.format(scope_hint=scope_hint, diff=filter_diff(diff)) - try: - message = crush_prompt(prompt, repo_path) - except CrushError as e: - console.print(f"[red]Crush failed to generate a commit message: {e}[/]") - return None - # Strip stray code fences if the model added them anyway. - message = re.sub(r"^```[a-zA-Z]*\n|\n```$", "", message.strip()).strip() - return message or None - - -def generate_scopes(repo_path: Path, existing: Optional[dict[str, list[str]]] = None) -> Optional[dict[str, list[str]]]: - filetree = build_filetree(repo_path) - if not filetree: - console.print("[red]No tracked or untracked files to analyze[/]") - return None - if existing: - prompt = SCOPE_PROMPT_UPDATE.format(existing=json.dumps(existing, indent=2), filetree=filetree) - else: - prompt = SCOPE_PROMPT_NEW.format(filetree=filetree) - try: - with console.status("[magenta]Analyzing repository with Crush...[/]"): - output = crush_prompt(prompt, repo_path) - except CrushError as e: - console.print(f"[red]Crush failed: {e}[/]") - return None - scopes = parse_scopes_response(output) - if not scopes: - console.print("[red]Could not parse scopes from Crush's response[/]") - if DEBUG: - console.print(f"[dim]{output}[/]") - return scopes - - -def display_scopes(scopes: dict[str, list[str]]): - for name, paths in sorted(scopes.items()): - console.print(f" [cyan]•[/] [bold]{name}[/]: {', '.join(paths)}") - - -# ── Git operations ──────────────────────────────────────────────────────────── - -def get_changed_files() -> list[str]: - staged = git("diff", "--cached", "--name-only").split("\n") - unstaged = git("diff", "--name-only").split("\n") - untracked = git("ls-files", "--others", "--exclude-standard").split("\n") - return sorted(set(f for f in staged + unstaged + untracked if f)) - - -def get_files_in_scope(files: list[str], paths: list[str]) -> list[str]: - matched = [] - for f in files: - if any(f.startswith(p) for p in paths): - matched.append(f) - return matched - - -def stage_files(files: list[str]): - if files: - subprocess.run(["git", "add", *files], check=True) - - -def reset_staging(): - subprocess.run(["git", "reset", "HEAD", "--", "."], capture_output=True) - - -def do_commit(message: str): - subprocess.run(["git", "commit", "-m", message], check=True) - - -def get_unpushed_commits() -> list[str]: - output = git("log", "--branches", "--not", "--remotes", "--oneline") - return [line for line in output.split("\n") if line] - - -def push_to_origin(): - branch = git("branch", "--show-current") - with console.status(f"[magenta]Pushing to origin/{branch}...[/]"): - subprocess.run(["git", "push", "origin", branch], check=True) - console.print(f"[green]✓ Pushed to origin/{branch}[/]") - - -def commit_group(repo_path: Path, files: list[str], scope: Optional[str] = None) -> bool: - """Stage `files`, generate a message, and commit them as one group.""" - files = [f for f in files if f] - if not files: - return False - - reset_staging() - stage_files(files) - diff = git("diff", "--cached") - if not diff: - reset_staging() - return False - - with console.status("[magenta]Generating commit message...[/]"): - message = generate_commit_message(diff, repo_path, scope) - if not message: - reset_staging() - return False - - console.print(Panel(message, border_style="magenta")) - label = scope or "these changes" - if confirm(f"Commit {label}?"): - do_commit(message) - console.print(f"[green]✓ Committed {label}[/]\n") - return True - reset_staging() - console.print(f"[dim] Skipped {label}[/]\n") - return False - - -# ── Scope auto-refresh ─────────────────────────────────────────────────────────── - -def maybe_auto_refresh_scopes(repo_path: Path, repo_name: str): - """Regenerate scopes automatically when .gitignore has changed since last save.""" - if NO_AUTO_REFRESH: - return - current = current_gitignore_hash(repo_path) - stored = get_stored_gitignore_hash(str(repo_path)) - # First time we've seen this repo's .gitignore — record a baseline, don't refresh. - if stored is None: - save_scopes(str(repo_path), repo_name, get_repo_scopes(str(repo_path))) - return - if current == stored: - return - - console.print("[yellow]↻ .gitignore changed — regenerating scopes with Crush...[/]") - existing = get_repo_scopes(str(repo_path)) - scopes = generate_scopes(repo_path, existing) - if not scopes: - console.print("[dim] Keeping existing scopes (regeneration failed)[/]\n") - return - save_scopes(str(repo_path), repo_name, scopes) - console.print("[green]✓ Scopes updated:[/]") - display_scopes(scopes) - console.print() - - -# ── Commands ────────────────────────────────────────────────────────────────── - -def cmd_version(): - print(f"gh-commit {VERSION}") - - -def cmd_help(): - console.print(f"[magenta bold]gh commit[/] [dim]v{VERSION}[/] — AI-powered scoped git commits (Crush)\n") - console.print("[cyan]Usage:[/]") - console.print(" gh commit Commit changes grouped by scope") - console.print(" gh commit --auto Auto-confirm all prompts") - console.print(" gh commit --push Auto-push after committing") - console.print(" gh commit --auto --push Both") - console.print(" gh commit init Generate scopes for this repo") - console.print(" gh commit refresh Update scopes from current structure") - console.print(" gh commit sync Sync scopes → GitHub labels") - console.print(" gh commit list List all configured repositories") - console.print(" gh commit remove Remove current repo from database") - console.print(" gh commit db-path Print database file path") - console.print(" gh commit version Print version") - console.print(" gh commit help Show this help\n") - console.print("[cyan]Database:[/]") - console.print(f" {DB_PATH}\n") - console.print("[cyan]Environment:[/]") - console.print(" GH_COMMIT_AUTO=1 Skip all confirmation prompts") - 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(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.[/]") - console.print("[cyan]Legacy .github/Repo.toml or scopes.json are auto-migrated on first run.[/]") - - -def cmd_db_path(): - print(DB_PATH) - - -def cmd_list(): - console.print("[magenta bold]Repositories[/]\n") - repos = list_repos() - if not repos: - console.print("[dim]No repositories configured yet[/]") - console.print("\n[cyan]Run 'gh commit init' in a git repository to get started[/]") - return - table = Table(show_header=True) - table.add_column("Name", style="bold") - table.add_column("Path") - table.add_column("Scopes", justify="right") - for name, path, scope_count, _ in repos: - table.add_row(name, path, str(scope_count)) - console.print(table) - - -def cmd_remove(): - repo_path = require_git() - if not repo_has_scopes(str(repo_path)): - console.print(f"[dim]Repository not in database: {repo_path.name}[/]") - return 0 - if confirm(f"Remove {repo_path.name} from database?"): - delete_repo(str(repo_path)) - console.print(f"[green]✓ Removed {repo_path.name}[/]") - else: - console.print("[dim]Cancelled[/]") - return 0 - - -def cmd_init(): - repo_path = require_git() - repo_name = repo_path.name - - # Check for legacy files - toml_path = repo_path / ".github" / "Repo.toml" - json_path = repo_path / ".github" / "scopes.json" - if toml_path.exists() or json_path.exists(): - console.print("[yellow]Found legacy config file(s)[/]") - if confirm("Migrate to DuckDB?"): - if auto_migrate(repo_path): - console.print("[green]✓ Migration complete[/]") - return 0 - - if repo_has_scopes(str(repo_path)): - console.print("[yellow]⚠ Repository already configured[/]") - if not confirm("Overwrite existing scopes?"): - console.print("[dim]Cancelled[/]") - return 0 - - console.print(f"[magenta bold]Generating scopes for {repo_name}...[/]\n") - scopes = generate_scopes(repo_path) - if not scopes: - return 1 - - save_scopes(str(repo_path), repo_name, scopes) - console.print("[green]✓ Saved scopes to database[/]\n") - console.print("[magenta]Generated scopes:[/]") - display_scopes(scopes) - console.print("\n[dim]Run 'gh commit' to use these scopes[/]") - return 0 - - -def cmd_refresh(): - repo_path = require_git() - repo_name = repo_path.name - - if not repo_has_scopes(str(repo_path)): - console.print("[red]Repository not configured — run 'gh commit init' first[/]") - return 1 - - existing_scopes = get_repo_scopes(str(repo_path)) - console.print(f"[magenta bold]Refreshing scopes for {repo_name}...[/]\n") - console.print("[cyan]Current scopes:[/]") - display_scopes(existing_scopes) - console.print() - - if not confirm("Refresh scopes based on current structure?"): - console.print("[dim]Cancelled[/]") - return 0 - - scopes = generate_scopes(repo_path, existing_scopes) - if not scopes: - return 1 - - console.print("\n[green]✓ Generated updated scopes[/]\n") - console.print("[magenta]Updated scopes:[/]") - display_scopes(scopes) - console.print() - - if confirm("Apply these changes?"): - save_scopes(str(repo_path), repo_name, scopes) - console.print("[green]✓ Updated scopes[/]") - else: - console.print("[dim]Changes not applied[/]") - return 0 - - -def cmd_sync(): - repo_path = require_git() - - if not repo_has_scopes(str(repo_path)): - console.print("[red]Repository not configured — run 'gh commit init' first[/]") - return 1 - - if subprocess.run(["which", "gh"], capture_output=True).returncode != 0: - console.print("[red]Error: 'gh' command not found[/]") - return 1 - if subprocess.run(["gh", "repo", "view"], capture_output=True).returncode != 0: - console.print("[red]Error: Not a GitHub repository or not authenticated[/]") - return 1 - - console.print("[magenta bold]Syncing scopes → GitHub labels...[/]\n") - scopes = get_repo_scopes(str(repo_path)) - created = updated = failed = 0 - - for scope_name, paths in scopes.items(): - desc = f"Changes to: {', '.join(paths)}" - color = hashlib.md5(scope_name.encode()).hexdigest()[:6] - - result = subprocess.run( - ["gh", "label", "create", scope_name, "--description", desc, "--color", color], - capture_output=True, - ) - if result.returncode == 0: - console.print(f" [green]✓[/] Created: {scope_name}") - created += 1 - else: - result = subprocess.run( - ["gh", "label", "edit", scope_name, "--description", desc, "--color", color], - capture_output=True, - ) - if result.returncode == 0: - console.print(f" [yellow]↻[/] Updated: {scope_name}") - updated += 1 - else: - console.print(f" [red]✗[/] Failed: {scope_name}") - failed += 1 - - console.print(f"\n[green bold]Sync complete![/] Created: {created} | Updated: {updated} | Failed: {failed}") - return 0 - - -def cmd_commit(): - repo_path = require_git() - repo_name = repo_path.name - - auto_migrate(repo_path) - - if not repo_has_scopes(str(repo_path)): - console.print("\n[yellow bold]⚠ No scopes configured for this repository[/]\n") - console.print("[cyan]gh-commit organizes commits by project areas (scopes).[/]\n") - if confirm("Generate scopes now using Crush?"): - if cmd_init() != 0: - return 1 - else: - console.print("\n[dim]Run 'gh commit init' to configure scopes[/]") - return 1 - - # Keep scopes aligned with the repo whenever .gitignore changes. - maybe_auto_refresh_scopes(repo_path, repo_name) - - console.print("[magenta]Finding scopes with changes...[/]") - changed_files = get_changed_files() - scopes = get_repo_scopes(str(repo_path)) - - scopes_with_changes = [ - name for name, paths in scopes.items() - if get_files_in_scope(changed_files, paths) - ] - - if not scopes_with_changes: - console.print("[dim]No scoped changes found[/]") - else: - console.print(f"[cyan]Scopes with changes: {' '.join(scopes_with_changes)}[/]\n") - - for scope in scopes_with_changes: - console.print(f"[magenta bold]Processing scope: {scope}[/]") - console.print(f"[cyan] Paths: {', '.join(scopes[scope])}[/]") - scope_files = get_files_in_scope(get_changed_files(), scopes[scope]) - if not scope_files: - console.print("[dim] No files found in scope paths[/]\n") - continue - commit_group(repo_path, scope_files, scope) - - reset_staging() - - # Remaining files outside any scope. - if git("status", "--porcelain"): - console.print("[yellow]Processing remaining files outside any scope...[/]\n") - - tracked = [f for f in git("diff", "--name-only").split("\n") if f] - if tracked: - console.print("[magenta]Tracked unstaged files:[/]") - for f in tracked: - console.print(f"[dim] {f}[/]") - if confirm("Commit tracked unstaged files?"): - commit_group(repo_path, tracked) - - untracked = [f for f in git("ls-files", "--others", "--exclude-standard").split("\n") if f] - if untracked: - console.print("[magenta]Untracked files:[/]") - for f in untracked: - console.print(f"[dim] {f}[/]") - if confirm("Commit untracked files?"): - commit_group(repo_path, untracked) - - # Push - unpushed = get_unpushed_commits() - if unpushed: - console.print("\n[magenta bold]Unpushed Commits[/]") - console.print(f"[cyan]{len(unpushed)} commit(s) ready to push:[/]\n") - for line in unpushed: - parts = line.split(" ", 1) - console.print(f" [bold]{parts[0]}[/] {parts[1] if len(parts) > 1 else ''}") - console.print() - if AUTO_PUSH or confirm("Push commits to origin?"): - push_to_origin() - else: - console.print("[dim]Skipped push[/]") - else: - console.print("\n[dim]No unpushed commits[/]") - - console.print("\n[green bold]✓ Done![/]") - return 0 - - -# ── Entrypoint ──────────────────────────────────────────────────────────────── - -COMMANDS = { - "init": cmd_init, - "refresh": cmd_refresh, - "sync": cmd_sync, - "list": cmd_list, - "remove": cmd_remove, - "db-path": cmd_db_path, - "version": cmd_version, - "help": cmd_help, -} - - -def main(): - global AUTO_CONFIRM, AUTO_PUSH - - args = sys.argv[1:] - - # Parse flags - while args and args[0].startswith("--"): - flag = args.pop(0) - if flag == "--auto": - AUTO_CONFIRM = True - elif flag == "--push": - AUTO_PUSH = True - elif flag in ("--help", "-h"): - cmd_help() - return - elif flag == "--version": - cmd_version() - return - else: - # Unknown flag — might be a legacy --init style command - legacy = flag.lstrip("-") - if legacy in COMMANDS: - args.insert(0, legacy) - break - console.print(f"[red]Unknown flag: {flag}[/]") - console.print("[dim]Use 'gh commit help' for usage[/]") - sys.exit(1) - - init_db() - - cmd = args[0] if args else None - if cmd is None: - sys.exit(cmd_commit()) - elif cmd in COMMANDS: - result = COMMANDS[cmd]() - if isinstance(result, int): - sys.exit(result) - else: - console.print(f"[red]Unknown command: {cmd}[/]") - console.print("[dim]Use 'gh commit help' for usage[/]") - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/smoke_test.go b/smoke_test.go new file mode 100644 index 0000000..a73db4c --- /dev/null +++ b/smoke_test.go @@ -0,0 +1,92 @@ +//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) +}