From 9647c854c948fc651a6061f86598228e18c9e39a Mon Sep 17 00:00:00 2001 From: t Date: Sat, 15 Aug 2026 19:10:16 -0400 Subject: [PATCH] feat: rewrite gh-commit in Go with local Ollama backend Replaces the Python/Crush/DuckDB implementation with a CGO-free Go binary: cobra CLI, modernc.org/sqlite storage, huh/lipgloss UI, and speed-tuned raw /api/generate calls (think:false, keep_alive, capped num_predict) against a local qwen3.5:2b. Ships as a gh extension via cli/gh-extension-precompile. --- .github/workflows/release.yml | 18 + .gitignore | 4 +- README.md | 130 +++-- cmd/commit.go | 224 ++++++++ cmd/initcmd.go | 85 +++ cmd/list.go | 56 ++ cmd/migrate.go | 93 ++++ cmd/misc.go | 62 +++ cmd/refresh.go | 76 +++ cmd/root.go | 166 ++++++ cmd/scopes.go | 98 ++++ cmd/sync.go | 76 +++ crush-provider/crush.json | 18 - gh-commit | 23 - gh-commit.tar.gz | Bin 12435 -> 0 bytes go.mod | 51 ++ go.sum | 117 ++++ internal/ai/client.go | 194 +++++++ internal/ai/parse.go | 110 ++++ internal/ai/parse_test.go | 148 ++++++ internal/ai/prompts.go | 84 +++ internal/db/store.go | 246 +++++++++ internal/db/store_test.go | 103 ++++ internal/diff/filter.go | 101 ++++ internal/diff/filter_test.go | 127 +++++ internal/gitx/git.go | 142 +++++ internal/gitx/git_test.go | 74 +++ internal/ui/ui.go | 113 ++++ main.go | 10 + smartcommit.py | 975 ---------------------------------- smoke_test.go | 92 ++++ 31 files changed, 2731 insertions(+), 1085 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 cmd/commit.go create mode 100644 cmd/initcmd.go create mode 100644 cmd/list.go create mode 100644 cmd/migrate.go create mode 100644 cmd/misc.go create mode 100644 cmd/refresh.go create mode 100644 cmd/root.go create mode 100644 cmd/scopes.go create mode 100644 cmd/sync.go delete mode 100644 crush-provider/crush.json delete mode 100755 gh-commit delete mode 100644 gh-commit.tar.gz create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/ai/client.go create mode 100644 internal/ai/parse.go create mode 100644 internal/ai/parse_test.go create mode 100644 internal/ai/prompts.go create mode 100644 internal/db/store.go create mode 100644 internal/db/store_test.go create mode 100644 internal/diff/filter.go create mode 100644 internal/diff/filter_test.go create mode 100644 internal/gitx/git.go create mode 100644 internal/gitx/git_test.go create mode 100644 internal/ui/ui.go create mode 100644 main.go delete mode 100644 smartcommit.py create mode 100644 smoke_test.go 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 0f12509465de3b39c3e6891879c04c6953e8841c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12435 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

N8XMTvY9Sj72 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.*?`) + 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) +}