diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 122fec7..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: release - -on: - push: - tags: - - "v*" - -permissions: - contents: write - -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: cli/gh-extension-precompile@v2 - with: - go_version_file: go.mod diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 3837628..0000000 --- a/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/gh-commit -dist/ diff --git a/README.md b/README.md index 20cd538..53955e0 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,15 @@ # gh-commit -AI-powered scoped git commits as a GitHub CLI extension, driven by a local -Ollama model. Scopes map Conventional Commit scope names to path prefixes. -They are generated from the repository file tree, stored in a local SQLite -database, and auto-regenerate whenever `.gitignore` changes. Changed files are -grouped by scope and committed one scope at a time with generated -`type(scope): message` subjects. +AI-powered scoped git commits as a GitHub CLI extension, driven by the +[Crush](https://github.com/charmbracelet/crush) CLI. Scopes map Conventional +Commit scope names to path prefixes. They are generated from the repository +file tree, stored in a local DuckDB database, and auto-regenerate whenever +`.gitignore` changes. Changed files are grouped by scope and committed one +scope at a time with generated `type(scope): message` subjects. ## Install ```sh -# Prerequisites: git, a running Ollama server, and the model -ollama pull qwen3.5:2b - gh extension install prdlk/gh-commit ``` @@ -36,7 +33,7 @@ gh commit # commit changes grouped by scope | Command | Behavior | |---|---| | `gh commit` | Full scoped-commit flow: group changed files by scope, generate a message per group, confirm, commit, offer to push | -| `gh commit init` | Generate scopes from the file tree via Ollama; migrates legacy configs; confirms overwrite | +| `gh commit init` | Generate scopes from the file tree via Crush; migrates legacy configs; confirms overwrite | | `gh commit refresh` | Show current scopes, regenerate with existing scopes as context, confirm apply | | `gh commit sync` | Create/update GitHub labels from scopes (color = first 6 hex chars of MD5 of the scope name) | | `gh commit list` | Table of configured repositories: name, path, scope count | @@ -45,7 +42,7 @@ gh commit # commit changes grouped by scope | `gh commit version` | Print `gh-commit ` | | `gh commit help` | Help, including the DB path and environment variable docs | -Flags: `--auto` (skip confirmations), `--push` (auto-push), `--model`, `--host`. +Flags: `--auto` (skip confirmations), `--push` (auto-push). ## Environment @@ -61,11 +58,10 @@ Flags: `--auto` (skip confirmations), `--push` (auto-push), `--model`, `--host`. ## Storage -Scopes live in `${XDG_DATA_HOME:-~/.local/share}/gh-commit/gh-commit.sqlite`. -Legacy `.github/Repo.toml` and `.github/scopes.json` files are migrated -automatically on first run (the source file is archived as -`*.migrated.`). Databases from the old DuckDB-based Python version -are not migrated; rerun `gh commit init`. +Scopes live in `${XDG_DATA_HOME:-~/.local/share}/gh-commit/gh-commit.db` +(DuckDB). Legacy `.github/Repo.toml` and `.github/scopes.json` files are +migrated automatically on first run (the source file is archived as +`*.migrated.`). ## Manual acceptance @@ -83,12 +79,12 @@ git log --format='%s' # verify type(scope): message subjects ## Development -```sh -go test ./... # unit tests -go test -tags integration -run TestSmoke . # needs a running Ollama server -go build . -``` +The extension is a bash launcher (`gh-commit`) that runs `smartcommit.py` +with `uv run --script`; uv resolves the inline dependencies (duckdb, rich, +questionary) automatically. -Releases are built by `cli/gh-extension-precompile` on `v*` tags for -linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, and windows/amd64 -(`CGO_ENABLED=0`; the SQLite driver is pure Go). +```sh +python -m py_compile smartcommit.py # syntax check +bash -n gh-commit # launcher check +./gh-commit version # smoke test +``` diff --git a/__pycache__/smartcommit.cpython-314.pyc b/__pycache__/smartcommit.cpython-314.pyc new file mode 100644 index 0000000..9f2f402 Binary files /dev/null and b/__pycache__/smartcommit.cpython-314.pyc differ diff --git a/cmd/commit.go b/cmd/commit.go deleted file mode 100644 index cb668d3..0000000 --- a/cmd/commit.go +++ /dev/null @@ -1,224 +0,0 @@ -package cmd - -import ( - "path/filepath" - "strings" - - "github.com/spf13/cobra" - - "github.com/prdlk/gh-commit/internal/gitx" - "github.com/prdlk/gh-commit/internal/ui" -) - -// runCommit is the root command: the full scoped-commit flow. -func runCommit(_ *cobra.Command, _ []string) error { - repoPath, err := requireGit() - if err != nil { - return err - } - repoName := filepath.Base(repoPath) - - migrateLegacy(repoPath) - - hasScopes, err := store.HasScopes(repoPath) - if err != nil { - return err - } - if !hasScopes { - ui.Println() - ui.WarnBoldf("⚠ No scopes configured for this repository") - ui.Println() - ui.Infof("gh-commit organizes commits by project areas (scopes).") - ui.Println() - if !ui.Confirm("Generate scopes now using Ollama?") { - ui.Println() - ui.Dimf("Run 'gh commit init' to configure scopes") - return errAborted - } - if err := ensureAI(); err != nil { - return err - } - if err := initFlow(repoPath); err != nil { - return err - } - } - - if err := ensureAI(); err != nil { - return err - } - - // Keep scopes aligned with the repo whenever .gitignore changes. - if err := maybeAutoRefreshScopes(repoPath, repoName); err != nil { - return err - } - - ui.Magentaf("Finding scopes with changes...") - changedFiles := gitx.ChangedFiles() - scopes, err := store.Scopes(repoPath) - if err != nil { - return err - } - - var scopesWithChanges []string - for _, name := range sortedScopeNames(scopes) { - if len(gitx.FilesInScope(changedFiles, scopes[name])) > 0 { - scopesWithChanges = append(scopesWithChanges, name) - } - } - - if len(scopesWithChanges) == 0 { - ui.Dimf("No scoped changes found") - } else { - ui.Infof("Scopes with changes: %s", strings.Join(scopesWithChanges, " ")) - ui.Println() - } - - for _, scope := range scopesWithChanges { - ui.MagentaBoldf("Processing scope: %s", scope) - ui.Infof(" Paths: %s", strings.Join(scopes[scope], ", ")) - // Re-read changed files so earlier commits are excluded. - scopeFiles := gitx.FilesInScope(gitx.ChangedFiles(), scopes[scope]) - if len(scopeFiles) == 0 { - ui.Dimf(" No files found in scope paths") - ui.Println() - continue - } - if _, err := commitGroup(scopeFiles, scope); err != nil { - return err - } - } - - gitx.ResetStaging() - - // Remaining files outside any scope. - if gitx.Git("status", "--porcelain") != "" { - ui.Warnf("Processing remaining files outside any scope...") - ui.Println() - - tracked := gitx.Lines(gitx.Git("diff", "--name-only")) - if len(tracked) > 0 { - ui.Magentaf("Tracked unstaged files:") - for _, f := range tracked { - ui.Dimf(" %s", f) - } - if ui.Confirm("Commit tracked unstaged files?") { - if _, err := commitGroup(tracked, ""); err != nil { - return err - } - } - } - - untracked := gitx.Lines(gitx.Git("ls-files", "--others", "--exclude-standard")) - if len(untracked) > 0 { - ui.Magentaf("Untracked files:") - for _, f := range untracked { - ui.Dimf(" %s", f) - } - if ui.Confirm("Commit untracked files?") { - if _, err := commitGroup(untracked, ""); err != nil { - return err - } - } - } - } - - // Final safety reset before the push check. - gitx.ResetStaging() - - // Push. - unpushed := gitx.UnpushedCommits() - if len(unpushed) > 0 { - ui.Println() - ui.MagentaBoldf("Unpushed Commits") - ui.Infof("%d commit(s) ready to push:", len(unpushed)) - ui.Println() - for _, line := range unpushed { - hash, rest, _ := strings.Cut(line, " ") - ui.Printf(" %s %s", ui.Bold(hash), rest) - } - ui.Println() - if cfg.push || ui.Confirm("Push commits to origin?") { - if err := pushToOrigin(); err != nil { - return err - } - } else { - ui.Dimf("Skipped push") - } - } else { - ui.Println() - ui.Dimf("No unpushed commits") - } - - ui.Println() - ui.SuccessBoldf("✓ Done!") - return nil -} - -// commitGroup stages files, generates a message, and commits them as one group. -func commitGroup(files []string, scope string) (bool, error) { - var clean []string - for _, f := range files { - if f != "" { - clean = append(clean, f) - } - } - if len(clean) == 0 { - return false, nil - } - - gitx.ResetStaging() - if err := gitx.StageFiles(clean); err != nil { - return false, err - } - diff := gitx.Git("diff", "--cached") - if diff == "" { - gitx.ResetStaging() - return false, nil - } - - var message string - ui.Spin("Generating commit message...", func() { - var err error - message, err = client.CommitMessage(diff, scope) - if err != nil { - ui.Errorf("Ollama failed to generate a commit message: %s", err) - message = "" - } - }) - if message == "" { - gitx.ResetStaging() - return false, nil - } - - ui.Panel(message) - label := scope - if label == "" { - label = "these changes" - } - if ui.Confirm("Commit " + label + "?") { - if err := gitx.Commit(message); err != nil { - return false, err - } - ui.Successf("✓ Committed %s", label) - ui.Println() - return true, nil - } - gitx.ResetStaging() - ui.Dimf(" Skipped %s", label) - ui.Println() - return false, nil -} - -// pushToOrigin pushes the current branch behind a spinner. -func pushToOrigin() error { - branch := gitx.CurrentBranch() - var err error - ui.Spin("Pushing to origin/"+branch+"...", func() { - err = gitx.Push(branch) - }) - if err != nil { - return err - } - ui.Successf("✓ Pushed to origin/%s", branch) - return nil -} diff --git a/cmd/initcmd.go b/cmd/initcmd.go deleted file mode 100644 index acd4f71..0000000 --- a/cmd/initcmd.go +++ /dev/null @@ -1,85 +0,0 @@ -package cmd - -import ( - "os" - "path/filepath" - - "github.com/spf13/cobra" - - "github.com/prdlk/gh-commit/internal/gitx" - "github.com/prdlk/gh-commit/internal/ui" -) - -var initCmd = &cobra.Command{ - Use: "init", - Short: "Generate scopes for this repository from its file tree", - Args: cobra.NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { - repoPath, err := requireGit() - if err != nil { - return err - } - if err := ensureAI(); err != nil { - return err - } - return initFlow(repoPath) - }, -} - -// initFlow generates and saves scopes for repoPath. The Ollama client must -// already be ready. -func initFlow(repoPath string) error { - repoName := filepath.Base(repoPath) - - // Check for legacy files. - tomlPath := filepath.Join(repoPath, ".github", "Repo.toml") - jsonPath := filepath.Join(repoPath, ".github", "scopes.json") - if fileExists(tomlPath) || fileExists(jsonPath) { - ui.Warnf("Found legacy config file(s)") - if ui.Confirm("Migrate to the gh-commit database?") { - if migrateLegacy(repoPath) { - ui.Successf("✓ Migration complete") - return nil - } - } - } - - hasScopes, err := store.HasScopes(repoPath) - if err != nil { - return err - } - if hasScopes { - ui.Warnf("⚠ Repository already configured") - if !ui.Confirm("Overwrite existing scopes?") { - ui.Dimf("Cancelled") - return nil - } - } - - ui.MagentaBoldf("Generating scopes for %s...", repoName) - ui.Println() - scopes := generateScopes(nil) - if scopes == nil { - return errAborted - } - - if err := store.SaveScopes(repoPath, repoName, scopes, gitx.GitignoreHash(repoPath)); err != nil { - return err - } - ui.Successf("✓ Saved scopes to database") - ui.Println() - ui.Magentaf("Generated scopes:") - displayScopes(scopes) - ui.Println() - ui.Dimf("Run 'gh commit' to use these scopes") - return nil -} - -func fileExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - -func init() { - rootCmd.AddCommand(initCmd) -} diff --git a/cmd/list.go b/cmd/list.go deleted file mode 100644 index f099bb3..0000000 --- a/cmd/list.go +++ /dev/null @@ -1,56 +0,0 @@ -package cmd - -import ( - "fmt" - "strconv" - - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/lipgloss/table" - "github.com/spf13/cobra" - - "github.com/prdlk/gh-commit/internal/ui" -) - -var listCmd = &cobra.Command{ - Use: "list", - Short: "List all configured repositories", - Args: cobra.NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { - ui.MagentaBoldf("Repositories") - ui.Println() - - repos, err := store.ListRepos() - if err != nil { - return err - } - if len(repos) == 0 { - ui.Dimf("No repositories configured yet") - ui.Println() - ui.Infof("Run 'gh commit init' in a git repository to get started") - return nil - } - - t := table.New(). - Border(lipgloss.NormalBorder()). - Headers("Name", "Path", "Scopes"). - StyleFunc(func(row, col int) lipgloss.Style { - s := lipgloss.NewStyle().Padding(0, 1) - if col == 2 { - s = s.Align(lipgloss.Right) - } - if row == table.HeaderRow || col == 0 { - s = s.Bold(true) - } - return s - }) - for _, r := range repos { - t.Row(r.Name, r.Path, strconv.Itoa(r.ScopeCount)) - } - fmt.Println(t) - return nil - }, -} - -func init() { - rootCmd.AddCommand(listCmd) -} diff --git a/cmd/migrate.go b/cmd/migrate.go deleted file mode 100644 index 6518977..0000000 --- a/cmd/migrate.go +++ /dev/null @@ -1,93 +0,0 @@ -package cmd - -import ( - "encoding/json" - "os" - "path/filepath" - "time" - - "github.com/BurntSushi/toml" - - "github.com/prdlk/gh-commit/internal/ai" - "github.com/prdlk/gh-commit/internal/gitx" - "github.com/prdlk/gh-commit/internal/ui" -) - -// archiveMigrated renames a consumed legacy file to *.migrated.. -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 deleted file mode 100644 index 04e0713..0000000 --- a/cmd/misc.go +++ /dev/null @@ -1,62 +0,0 @@ -package cmd - -import ( - "fmt" - "path/filepath" - - "github.com/spf13/cobra" - - "github.com/prdlk/gh-commit/internal/db" - "github.com/prdlk/gh-commit/internal/ui" -) - -var removeCmd = &cobra.Command{ - Use: "remove", - Short: "Remove the current repository from the database", - Args: cobra.NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { - repoPath, err := requireGit() - if err != nil { - return err - } - hasScopes, err := store.HasScopes(repoPath) - if err != nil { - return err - } - if !hasScopes { - ui.Dimf("Repository not in database: %s", filepath.Base(repoPath)) - return nil - } - if ui.Confirm(fmt.Sprintf("Remove %s from database?", filepath.Base(repoPath))) { - if err := store.DeleteRepo(repoPath); err != nil { - return err - } - ui.Successf("✓ Removed %s", filepath.Base(repoPath)) - } else { - ui.Dimf("Cancelled") - } - return nil - }, -} - -var dbPathCmd = &cobra.Command{ - Use: "db-path", - Short: "Print the database file path", - Args: cobra.NoArgs, - Run: func(_ *cobra.Command, _ []string) { - fmt.Println(db.Path()) - }, -} - -var versionCmd = &cobra.Command{ - Use: "version", - Short: "Print the version", - Args: cobra.NoArgs, - Run: func(_ *cobra.Command, _ []string) { - fmt.Printf("gh-commit %s\n", version) - }, -} - -func init() { - rootCmd.AddCommand(removeCmd, dbPathCmd, versionCmd) -} diff --git a/cmd/refresh.go b/cmd/refresh.go deleted file mode 100644 index 72ec48a..0000000 --- a/cmd/refresh.go +++ /dev/null @@ -1,76 +0,0 @@ -package cmd - -import ( - "path/filepath" - - "github.com/spf13/cobra" - - "github.com/prdlk/gh-commit/internal/gitx" - "github.com/prdlk/gh-commit/internal/ui" -) - -var refreshCmd = &cobra.Command{ - Use: "refresh", - Short: "Regenerate scopes from the current repository structure", - Args: cobra.NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { - repoPath, err := requireGit() - if err != nil { - return err - } - repoName := filepath.Base(repoPath) - - hasScopes, err := store.HasScopes(repoPath) - if err != nil { - return err - } - if !hasScopes { - ui.Errorf("Repository not configured — run 'gh commit init' first") - return errAborted - } - if err := ensureAI(); err != nil { - return err - } - - existing, err := store.Scopes(repoPath) - if err != nil { - return err - } - ui.MagentaBoldf("Refreshing scopes for %s...", repoName) - ui.Println() - ui.Infof("Current scopes:") - displayScopes(existing) - ui.Println() - - if !ui.Confirm("Refresh scopes based on current structure?") { - ui.Dimf("Cancelled") - return nil - } - - scopes := generateScopes(existing) - if scopes == nil { - return errAborted - } - - ui.Println() - ui.Successf("✓ Generated updated scopes") - ui.Println() - ui.Magentaf("Updated scopes:") - displayScopes(scopes) - ui.Println() - - if ui.Confirm("Apply these changes?") { - if err := store.SaveScopes(repoPath, repoName, scopes, gitx.GitignoreHash(repoPath)); err != nil { - return err - } - ui.Successf("✓ Updated scopes") - } else { - ui.Dimf("Changes not applied") - } - return nil - }, -} - -func init() { - rootCmd.AddCommand(refreshCmd) -} diff --git a/cmd/root.go b/cmd/root.go deleted file mode 100644 index ad3258a..0000000 --- a/cmd/root.go +++ /dev/null @@ -1,166 +0,0 @@ -// Package cmd wires the cobra CLI: config resolution, database and Ollama -// client lifecycles, and one file per subcommand. -package cmd - -import ( - "errors" - "fmt" - "os" - "strconv" - "time" - - "github.com/spf13/cobra" - - "github.com/prdlk/gh-commit/internal/ai" - "github.com/prdlk/gh-commit/internal/db" - "github.com/prdlk/gh-commit/internal/gitx" - "github.com/prdlk/gh-commit/internal/ui" -) - -// errAborted signals exit code 1 after the failure has already been printed. -var errAborted = errors.New("aborted") - -type config struct { - host string - model string - timeout time.Duration - auto bool - push bool - noAutoRefresh bool - debug bool -} - -var ( - version = "dev" - cfg config - store *db.Store - client *ai.Client - - flagHost string - flagModel string - flagAuto bool - flagPush bool -) - -func envOr(key, def string) string { - if v := os.Getenv(key); v != "" { - return v - } - return def -} - -func envBool(key string) bool { return os.Getenv(key) == "1" } - -func longHelp() string { - return fmt.Sprintf(`AI-powered scoped git commits driven by a local Ollama model. - -Scopes map Conventional Commit scope names to path prefixes. They are -generated from the repository file tree by the model, stored in a local -SQLite database, and auto-regenerate whenever .gitignore changes. -Legacy .github/Repo.toml or scopes.json files are auto-migrated on first run. - -Database: - %s - -Environment: - GH_COMMIT_OLLAMA_HOST Ollama server URL (default http://localhost:11434) - GH_COMMIT_MODEL Model tag (default qwen3.5:2b) - GH_COMMIT_TIMEOUT Per-request timeout in seconds (default 120) - GH_COMMIT_AUTO=1 Skip all confirmation prompts - GH_COMMIT_PUSH=1 Auto-push after commits - GH_COMMIT_NO_AUTO_REFRESH=1 Don't auto-regenerate scopes on .gitignore change - GH_COMMIT_DEBUG=1 Print raw model output on parse failure`, db.Path()) -} - -var rootCmd = &cobra.Command{ - Use: "gh-commit", - Short: "AI-powered scoped git commits (Ollama)", - Long: longHelp(), - Args: cobra.NoArgs, - SilenceUsage: true, - SilenceErrors: true, - CompletionOptions: cobra.CompletionOptions{ - DisableDefaultCmd: true, - }, - PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { - cfg.host = envOr("GH_COMMIT_OLLAMA_HOST", "http://localhost:11434") - cfg.model = envOr("GH_COMMIT_MODEL", "qwen3.5:2b") - if flagHost != "" { - cfg.host = flagHost - } - if flagModel != "" { - cfg.model = flagModel - } - seconds := 120 - if v, err := strconv.Atoi(envOr("GH_COMMIT_TIMEOUT", "120")); err == nil && v > 0 { - seconds = v - } - cfg.timeout = time.Duration(seconds) * time.Second - cfg.auto = envBool("GH_COMMIT_AUTO") || flagAuto - cfg.push = envBool("GH_COMMIT_PUSH") || flagPush - cfg.noAutoRefresh = envBool("GH_COMMIT_NO_AUTO_REFRESH") - cfg.debug = envBool("GH_COMMIT_DEBUG") - ui.AutoConfirm = cfg.auto - - switch cmd.Name() { - case "version", "help": - return nil - } - s, err := db.Open() - if err != nil { - return err - } - store = s - if s.HadLegacyDuckDB { - ui.Warnf("Found a legacy DuckDB database (gh-commit.db) — it cannot be migrated; scopes will regenerate on 'gh commit init'") - } - return nil - }, - RunE: runCommit, -} - -// Execute runs the CLI, printing errors in red and exiting 1 on failure. -func Execute(v string) { - version = v - rootCmd.Version = v - rootCmd.SetVersionTemplate("gh-commit {{.Version}}\n") - err := rootCmd.Execute() - if store != nil { - _ = store.Close() - } - if err != nil { - if !errors.Is(err, errAborted) { - ui.Errorf("%s", err) - } - os.Exit(1) - } -} - -// requireGit asserts we're inside a git repo and returns its root path. -func requireGit() (string, error) { - if !gitx.IsRepo() { - ui.Errorf("Not in a git repository") - return "", errAborted - } - return gitx.RepoRoot(), nil -} - -// ensureAI builds the Ollama client and verifies server + model availability. -func ensureAI() error { - if client != nil { - return nil - } - c := ai.New(cfg.host, cfg.model, cfg.timeout) - if err := c.EnsureReady(); err != nil { - return err - } - client = c - return nil -} - -func init() { - rootCmd.PersistentFlags().BoolVar(&flagAuto, "auto", false, "skip all confirmation prompts") - rootCmd.PersistentFlags().BoolVar(&flagPush, "push", false, "auto-push after committing") - rootCmd.PersistentFlags().StringVar(&flagModel, "model", "", "override the Ollama model tag") - rootCmd.PersistentFlags().StringVar(&flagHost, "host", "", "override the Ollama server URL") -} diff --git a/cmd/scopes.go b/cmd/scopes.go deleted file mode 100644 index b37d31a..0000000 --- a/cmd/scopes.go +++ /dev/null @@ -1,98 +0,0 @@ -package cmd - -import ( - "sort" - "strings" - - "github.com/prdlk/gh-commit/internal/ai" - "github.com/prdlk/gh-commit/internal/gitx" - "github.com/prdlk/gh-commit/internal/ui" -) - -// sortedScopeNames returns scope names in deterministic (sorted) order, -// matching the DB query ordering the Python tool iterated in. -func sortedScopeNames(scopes map[string][]string) []string { - names := make([]string, 0, len(scopes)) - for name := range scopes { - names = append(names, name) - } - sort.Strings(names) - return names -} - -// displayScopes prints "• name: path, path" bullets, sorted by name. -func displayScopes(scopes map[string][]string) { - for _, name := range sortedScopeNames(scopes) { - ui.Printf(" %s %s: %s", - ui.Cyan("•"), ui.Bold(name), strings.Join(scopes[name], ", ")) - } -} - -// generateScopes builds the file tree, prompts the model, and parses the -// response. Failures are reported here; nil means "keep going without". -func generateScopes(existing map[string][]string) map[string][]string { - filetree := gitx.Filetree() - if filetree == "" { - ui.Errorf("No tracked or untracked files to analyze") - return nil - } - var out string - var err error - ui.Spin("Analyzing repository with Ollama...", func() { - out, err = client.ScopesRaw(filetree, existing) - }) - if err != nil { - ui.Errorf("Ollama failed: %s", err) - return nil - } - scopes := ai.ParseScopesResponse(out) - if scopes == nil { - ui.Errorf("Could not parse scopes from Ollama's response") - if cfg.debug { - ui.Dimf("%s", out) - } - } - return scopes -} - -// maybeAutoRefreshScopes regenerates scopes when .gitignore changed since the -// last save. First sighting snapshots a baseline without regenerating. -func maybeAutoRefreshScopes(repoPath, repoName string) error { - if cfg.noAutoRefresh { - return nil - } - current := gitx.GitignoreHash(repoPath) - stored, present, err := store.StoredGitignoreHash(repoPath) - if err != nil { - return err - } - if !present { - scopes, err := store.Scopes(repoPath) - if err != nil { - return err - } - return store.SaveScopes(repoPath, repoName, scopes, current) - } - if current == stored { - return nil - } - - ui.Warnf("↻ .gitignore changed — regenerating scopes with Ollama...") - existing, err := store.Scopes(repoPath) - if err != nil { - return err - } - scopes := generateScopes(existing) - if scopes == nil { - ui.Dimf(" Keeping existing scopes (regeneration failed)") - ui.Println() - return nil - } - if err := store.SaveScopes(repoPath, repoName, scopes, current); err != nil { - return err - } - ui.Successf("✓ Scopes updated:") - displayScopes(scopes) - ui.Println() - return nil -} diff --git a/cmd/sync.go b/cmd/sync.go deleted file mode 100644 index d02c183..0000000 --- a/cmd/sync.go +++ /dev/null @@ -1,76 +0,0 @@ -package cmd - -import ( - "crypto/md5" //nolint:gosec // non-cryptographic: stable label color derivation - "encoding/hex" - "fmt" - "os/exec" - "strings" - - "github.com/spf13/cobra" - - "github.com/prdlk/gh-commit/internal/ui" -) - -var syncCmd = &cobra.Command{ - Use: "sync", - Short: "Sync scopes to GitHub labels", - Args: cobra.NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { - repoPath, err := requireGit() - if err != nil { - return err - } - - hasScopes, err := store.HasScopes(repoPath) - if err != nil { - return err - } - if !hasScopes { - ui.Errorf("Repository not configured — run 'gh commit init' first") - return errAborted - } - - if _, err := exec.LookPath("gh"); err != nil { - ui.Errorf("Error: 'gh' command not found") - return errAborted - } - if exec.Command("gh", "repo", "view").Run() != nil { - ui.Errorf("Error: Not a GitHub repository or not authenticated") - return errAborted - } - - ui.MagentaBoldf("Syncing scopes → GitHub labels...") - ui.Println() - scopes, err := store.Scopes(repoPath) - if err != nil { - return err - } - - created, updated, failed := 0, 0, 0 - for _, name := range sortedScopeNames(scopes) { - desc := "Changes to: " + strings.Join(scopes[name], ", ") - sum := md5.Sum([]byte(name)) //nolint:gosec // see import note - color := hex.EncodeToString(sum[:])[:6] - - if exec.Command("gh", "label", "create", name, "--description", desc, "--color", color).Run() == nil { - ui.Printf(" %s Created: %s", ui.Green("✓"), name) - created++ - } else if exec.Command("gh", "label", "edit", name, "--description", desc, "--color", color).Run() == nil { - ui.Printf(" %s Updated: %s", ui.Yellow("↻"), name) - updated++ - } else { - ui.Printf(" %s Failed: %s", ui.Red("✗"), name) - failed++ - } - } - - ui.Println() - ui.SuccessBoldf("Sync complete! %s", fmt.Sprintf("Created: %d | Updated: %d | Failed: %d", created, updated, failed)) - return nil - }, -} - -func init() { - rootCmd.AddCommand(syncCmd) -} diff --git a/gh-commit b/gh-commit new file mode 100755 index 0000000..47387ea --- /dev/null +++ b/gh-commit @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# gh-commit launcher — runs the bundled Python implementation via uv. +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if ! command -v uv >/dev/null 2>&1; then + echo "gh-commit: 'uv' is required (https://docs.astral.sh/uv)" >&2 + exit 1 +fi + +exec uv run --script "$DIR/smartcommit.py" "$@" diff --git a/gh-commit.tar.gz b/gh-commit.tar.gz index e849bc1..2ada48b 100644 Binary files a/gh-commit.tar.gz and b/gh-commit.tar.gz differ diff --git a/go.mod b/go.mod deleted file mode 100644 index d7459e1..0000000 --- a/go.mod +++ /dev/null @@ -1,51 +0,0 @@ -module github.com/prdlk/gh-commit - -go 1.23 - -require ( - github.com/BurntSushi/toml v1.4.0 - github.com/charmbracelet/huh v0.6.0 - github.com/charmbracelet/huh/spinner v0.0.0-20240618200428-90406d79077d - github.com/charmbracelet/lipgloss v1.0.0 - github.com/rozoomcool/go-ollama-sdk v0.0.0-20250620220025-710cf9a2c767 - github.com/spf13/cobra v1.8.1 - golang.org/x/term v0.27.0 - modernc.org/sqlite v1.34.4 -) - -require ( - github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/catppuccin/go v0.2.0 // indirect - github.com/charmbracelet/bubbles v0.20.0 // indirect - github.com/charmbracelet/bubbletea v1.1.0 // indirect - github.com/charmbracelet/x/ansi v0.4.2 // indirect - github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect - github.com/charmbracelet/x/term v0.2.0 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect - github.com/ncruces/go-strftime v0.1.9 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/spf13/pflag v1.0.5 // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/text v0.18.0 // indirect - modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect - modernc.org/libc v1.55.3 // indirect - modernc.org/mathutil v1.6.0 // indirect - modernc.org/memory v1.8.0 // indirect - modernc.org/strutil v1.2.0 // indirect - modernc.org/token v1.1.0 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 3816e2f..0000000 --- a/go.sum +++ /dev/null @@ -1,117 +0,0 @@ -github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= -github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= -github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= -github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= -github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= -github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= -github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= -github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c= -github.com/charmbracelet/bubbletea v1.1.0/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4= -github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8= -github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU= -github.com/charmbracelet/huh/spinner v0.0.0-20240618200428-90406d79077d h1:OpthCCWiHBSx6LTAYGGkN9OeuJrKzjobe0q12wO6BX0= -github.com/charmbracelet/huh/spinner v0.0.0-20240618200428-90406d79077d/go.mod h1:CrXBZnOWs3zpyppOZZS7lu2CpLq2jx6U5chL/frRG/E= -github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= -github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= -github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk= -github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= -github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= -github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= -github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= -github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= -github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= -github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg= -github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ= -github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rozoomcool/go-ollama-sdk v0.0.0-20250620220025-710cf9a2c767 h1:TBRPWLhZWwrRJhigDIaoxvkeov4vpWYV7ws/qW3by+s= -github.com/rozoomcool/go-ollama-sdk v0.0.0-20250620220025-710cf9a2c767/go.mod h1:vHEieQv2QDMhRRLpSG3Vmi7a0wdy8hY2F14U4jtVuk0= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= -modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= -modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= -modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= -modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= -modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= -modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= -modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= -modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= -modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= -modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= -modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= -modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= -modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= -modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= -modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= -modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= -modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= -modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= -modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= -modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8= -modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk= -modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= -modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/ai/client.go b/internal/ai/client.go deleted file mode 100644 index 50c3516..0000000 --- a/internal/ai/client.go +++ /dev/null @@ -1,194 +0,0 @@ -// Package ai talks to a local Ollama server. The rozoomcool SDK is used for -// client construction and model pulls; generation goes through a raw -// /api/generate POST because the SDK's Generate cannot pass think, options, -// or keep_alive — the levers this tool depends on for speed. -package ai - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" - - ollama "github.com/rozoomcool/go-ollama-sdk" - - "github.com/prdlk/gh-commit/internal/diff" - "github.com/prdlk/gh-commit/internal/ui" -) - -// Generation profiles: small budget for one-line commit messages, larger -// budget and context for scope JSON over big file trees. -const ( - commitNumPredict = 96 - commitNumCtx = 8192 - scopeNumPredict = 1024 - scopeNumCtx = 16384 - keepAlive = "10m" - temperature = 0.2 - topP = 0.9 -) - -// Client is a speed-tuned Ollama client for one model. -type Client struct { - host string - model string - timeout time.Duration - http *http.Client - sdk *ollama.OllamaClient -} - -// New builds a client for host/model with a per-request timeout. -func New(host, model string, timeout time.Duration) *Client { - host = strings.TrimRight(host, "/") - return &Client{ - host: host, - model: model, - timeout: timeout, - http: &http.Client{Timeout: timeout}, - sdk: ollama.NewClient(host), - } -} - -// Model returns the configured model tag. -func (c *Client) Model() string { return c.model } - -type generateOptions struct { - Temperature float64 `json:"temperature"` - TopP float64 `json:"top_p"` - NumPredict int `json:"num_predict"` - NumCtx int `json:"num_ctx"` -} - -type generateRequest struct { - Model string `json:"model"` - Prompt string `json:"prompt"` - Stream bool `json:"stream"` - Think bool `json:"think"` - KeepAlive string `json:"keep_alive"` - Options generateOptions `json:"options"` -} - -type generateResponse struct { - Response string `json:"response"` - Error string `json:"error"` -} - -// generate POSTs a raw /api/generate request with the speed profile applied. -func (c *Client) generate(prompt string, numPredict, numCtx int) (string, error) { - body, err := json.Marshal(generateRequest{ - Model: c.model, - Prompt: prompt, - Stream: false, - Think: false, - KeepAlive: keepAlive, - Options: generateOptions{ - Temperature: temperature, - TopP: topP, - NumPredict: numPredict, - NumCtx: numCtx, - }, - }) - if err != nil { - return "", err - } - - ctx, cancel := context.WithTimeout(context.Background(), c.timeout) - defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.host+"/api/generate", bytes.NewReader(body)) - if err != nil { - return "", err - } - req.Header.Set("Content-Type", "application/json") - - resp, err := c.http.Do(req) - if err != nil { - return "", fmt.Errorf("ollama request failed: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - data, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - var out generateResponse - if err := json.Unmarshal(data, &out); err != nil { - return "", fmt.Errorf("ollama returned unexpected payload: %w", err) - } - if resp.StatusCode != http.StatusOK { - if out.Error != "" { - return "", fmt.Errorf("ollama: %s", out.Error) - } - return "", fmt.Errorf("ollama returned HTTP %d", resp.StatusCode) - } - // think:false is sent, but strip any reasoning block as defense in depth. - return stripThink(out.Response), nil -} - -// CommitMessage filters diff, prompts the model, and returns a cleaned -// commit message ("" when the model produced nothing usable). -func (c *Client) CommitMessage(rawDiff, scope string) (string, error) { - prompt := buildCommitPrompt(diff.Filter(rawDiff), scope) - out, err := c.generate(prompt, commitNumPredict, commitNumCtx) - if err != nil { - return "", err - } - return CleanCommitMessage(out), nil -} - -// ScopesRaw prompts the model for a scope mapping and returns the raw -// response text (callers parse it so they can show diagnostics on failure). -func (c *Client) ScopesRaw(filetree string, existing map[string][]string) (string, error) { - return c.generate(buildScopePrompt(filetree, existing), scopeNumPredict, scopeNumCtx) -} - -type tagsResponse struct { - Models []struct { - Name string `json:"name"` - } `json:"models"` -} - -// EnsureReady verifies the Ollama server is answering and the model is -// available locally, offering to pull it when missing. -func (c *Client) EnsureReady() error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.host+"/api/tags", nil) - if err != nil { - return err - } - resp, err := c.http.Do(req) - if err != nil { - return fmt.Errorf("the Ollama server is not reachable at %s — start it with 'ollama serve'", c.host) - } - defer func() { _ = resp.Body.Close() }() - - var tags tagsResponse - if err := json.NewDecoder(resp.Body).Decode(&tags); err != nil { - return fmt.Errorf("the Ollama server at %s gave an unexpected response — start it with 'ollama serve'", c.host) - } - for _, m := range tags.Models { - if m.Name == c.model || (!strings.Contains(c.model, ":") && m.Name == c.model+":latest") { - return nil - } - } - - ui.Warnf("Model %s is not available locally", c.model) - if !ui.Confirm(fmt.Sprintf("Pull %s now?", c.model)) { - return fmt.Errorf("model %s is not available — run 'ollama pull %s'", c.model, c.model) - } - last := "" - if err := c.sdk.PullModel(c.model, func(status string) { - if status != "" && status != last { - ui.Dimf(" %s", status) - last = status - } - }); err != nil { - return fmt.Errorf("pulling %s: %w", c.model, err) - } - ui.Successf("✓ Pulled %s", c.model) - return nil -} diff --git a/internal/ai/parse.go b/internal/ai/parse.go deleted file mode 100644 index 5ef8777..0000000 --- a/internal/ai/parse.go +++ /dev/null @@ -1,110 +0,0 @@ -package ai - -import ( - "encoding/json" - "fmt" - "regexp" - "strings" -) - -var ( - thinkRe = regexp.MustCompile(`(?s).*?`) - fenceOpenRe = regexp.MustCompile("^```[a-zA-Z]*\n") - fenceCloseRe = regexp.MustCompile("\n```$") - scopedMsgRe = regexp.MustCompile(`^\w+\([^)]+\): .+`) - plainMsgRe = regexp.MustCompile(`^\w+: .+`) -) - -// stripThink removes ... reasoning blocks. -func stripThink(text string) string { - return strings.TrimSpace(thinkRe.ReplaceAllString(text, "")) -} - -// ExtractJSONObject returns the first balanced top-level {...} object in text, -// or "" when none is found. -func ExtractJSONObject(text string) string { - depth := 0 - start := -1 - for i := range len(text) { - switch text[i] { - case '{': - if start == -1 { - start = i - } - depth++ - case '}': - if start != -1 { - depth-- - if depth == 0 { - return text[start : i+1] - } - } - } - } - return "" -} - -// CoerceScopeMap normalizes a decoded JSON/TOML mapping into scope -> paths. -// String values become single-element slices; empty paths are dropped. -// Returns nil when nothing usable remains. -func CoerceScopeMap(data map[string]any) map[string][]string { - scopes := map[string][]string{} - for name, paths := range data { - switch v := paths.(type) { - case string: - scopes[name] = []string{v} - case []any: - var out []string - for _, p := range v { - s := fmt.Sprint(p) - if strings.TrimSpace(s) != "" { - out = append(out, s) - } - } - scopes[name] = out - } - } - if len(scopes) == 0 { - return nil - } - return scopes -} - -// ParseScopesResponse extracts and normalizes the scope mapping from a model -// response. Returns nil when no usable JSON object is present. -func ParseScopesResponse(text string) map[string][]string { - blob := ExtractJSONObject(text) - if blob == "" { - return nil - } - var data map[string]any - if err := json.Unmarshal([]byte(blob), &data); err != nil { - return nil - } - return CoerceScopeMap(data) -} - -// CleanCommitMessage normalizes a raw model response into a commit message: -// trims, strips blocks and code fences, prefers the first non-empty -// line when it matches conventional-commit shape, and otherwise falls back to -// the whole trimmed string (never blocks a commit on a regex). -func CleanCommitMessage(raw string) string { - msg := stripThink(strings.TrimSpace(raw)) - msg = fenceOpenRe.ReplaceAllString(msg, "") - msg = fenceCloseRe.ReplaceAllString(msg, "") - msg = strings.TrimSpace(msg) - if msg == "" { - return "" - } - for _, line := range strings.Split(msg, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - if scopedMsgRe.MatchString(line) || plainMsgRe.MatchString(line) { - return line - } - break - } - return msg -} diff --git a/internal/ai/parse_test.go b/internal/ai/parse_test.go deleted file mode 100644 index 48f2d20..0000000 --- a/internal/ai/parse_test.go +++ /dev/null @@ -1,148 +0,0 @@ -package ai - -import ( - "reflect" - "strings" - "testing" -) - -func TestExtractJSONObject(t *testing.T) { - tests := []struct { - name, in, want string - }{ - {"bare object", `{"a": 1}`, `{"a": 1}`}, - {"leading prose", `Here you go: {"a": {"b": 2}} done`, `{"a": {"b": 2}}`}, - {"nested braces balanced", `{"a": {"b": {"c": 3}}}`, `{"a": {"b": {"c": 3}}}`}, - {"first object wins", `{"a": 1} {"b": 2}`, `{"a": 1}`}, - {"no object", "no json here", ""}, - {"unbalanced", `{"a": 1`, ""}, - {"stray close before open", `} {"a": 1}`, `{"a": 1}`}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := ExtractJSONObject(tt.in); got != tt.want { - t.Errorf("got %q, want %q", got, tt.want) - } - }) - } -} - -func TestParseScopesResponse(t *testing.T) { - tests := []struct { - name string - in string - want map[string][]string - }{ - { - "array values", - `{"core": ["src/", "lib/"], "docs": ["README.md"]}`, - map[string][]string{"core": {"src/", "lib/"}, "docs": {"README.md"}}, - }, - { - "string value coerced to slice", - `{"cli": "cmd/"}`, - map[string][]string{"cli": {"cmd/"}}, - }, - { - "empty paths dropped", - `{"core": ["src/", "", " "]}`, - map[string][]string{"core": {"src/"}}, - }, - { - "surrounding prose ignored", - "Sure! Here are the scopes:\n{\"api\": [\"api/\"]}\nHope that helps.", - map[string][]string{"api": {"api/"}}, - }, - {"not an object", `["a", "b"]`, nil}, - {"invalid json", `{"a": }`, nil}, - {"empty object", `{}`, nil}, - {"no json at all", "nothing", nil}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := ParseScopesResponse(tt.in); !reflect.DeepEqual(got, tt.want) { - t.Errorf("got %#v, want %#v", got, tt.want) - } - }) - } -} - -func TestCleanCommitMessage(t *testing.T) { - tests := []struct { - name, in, want string - }{ - {"clean scoped message", "feat(core): add thing", "feat(core): add thing"}, - {"clean unscoped message", "chore: tidy", "chore: tidy"}, - {"whitespace trimmed", " fix(db): close conn \n", "fix(db): close conn"}, - { - "code fence stripped", - "```\nfeat(ui): add button\n```", - "feat(ui): add button", - }, - { - "language fence stripped", - "```text\nfix(api): handle 404\n```", - "fix(api): handle 404", - }, - { - "think block stripped", - "\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 deleted file mode 100644 index a16fe1d..0000000 --- a/internal/ai/prompts.go +++ /dev/null @@ -1,84 +0,0 @@ -package ai - -import ( - "encoding/json" - "strings" -) - -// Prompt text is ported unchanged from the Python tool, plus one -// "Do not think" line per prompt as cheap insurance on small models. - -const commitPrompt = `You are an expert conventional commit message writer. -Use one of these commit types: feat, fix, docs, style, refactor, breaking, test, perf, build, ci, chore, init. -Determine a precise commit message from the provided diff and scope. -The commit message must follow this format: (): -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 deleted file mode 100644 index ec6391f..0000000 --- a/internal/db/store.go +++ /dev/null @@ -1,246 +0,0 @@ -// Package db persists per-repository scopes in a pure-Go SQLite database -// (modernc.org/sqlite, no CGO). The schema mirrors the old DuckDB layout, -// with foreign keys + ON DELETE CASCADE replacing the manual cascade. -package db - -import ( - "database/sql" - "fmt" - "os" - "path/filepath" - "sort" - - _ "modernc.org/sqlite" -) - -const schema = ` -CREATE TABLE IF NOT EXISTS repositories ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - path TEXT UNIQUE NOT NULL, - name TEXT NOT NULL, - gitignore_hash TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); -CREATE TABLE IF NOT EXISTS scopes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, - name TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(repo_id, name) -); -CREATE TABLE IF NOT EXISTS scope_paths ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - scope_id INTEGER NOT NULL REFERENCES scopes(id) ON DELETE CASCADE, - path TEXT NOT NULL, - UNIQUE(scope_id, path) -); -CREATE TABLE IF NOT EXISTS github_labels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - scope_id INTEGER NOT NULL UNIQUE REFERENCES scopes(id) ON DELETE CASCADE, - label_name TEXT NOT NULL, - color TEXT, - synced_at TIMESTAMP -); -` - -// Dir returns the data directory: ${XDG_DATA_HOME:-~/.local/share}/gh-commit. -func Dir() string { - base := os.Getenv("XDG_DATA_HOME") - if base == "" { - home, err := os.UserHomeDir() - if err != nil { - home = "." - } - base = filepath.Join(home, ".local", "share") - } - return filepath.Join(base, "gh-commit") -} - -// Path returns the SQLite database path. -func Path() string { return filepath.Join(Dir(), "gh-commit.sqlite") } - -// RepoInfo is one row of the `list` table. -type RepoInfo struct { - Name string - Path string - ScopeCount int -} - -// Store wraps the SQLite connection. -type Store struct { - db *sql.DB - // HadLegacyDuckDB is true when this Open created the database for the - // first time while an old DuckDB file was present next to it. - HadLegacyDuckDB bool -} - -// Open creates the data directory, opens the database, and applies the schema. -func Open() (*Store, error) { - dir := Dir() - if err := os.MkdirAll(dir, 0o755); err != nil { - return nil, fmt.Errorf("creating %s: %w", dir, err) - } - path := Path() - - legacy := false - if _, err := os.Stat(path); os.IsNotExist(err) { - if _, err := os.Stat(filepath.Join(dir, "gh-commit.db")); err == nil { - legacy = true - } - } - - dsn := "file:" + path + "?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)" - conn, err := sql.Open("sqlite", dsn) - if err != nil { - return nil, fmt.Errorf("opening %s: %w", path, err) - } - if _, err := conn.Exec(schema); err != nil { - _ = conn.Close() - return nil, fmt.Errorf("initializing schema: %w", err) - } - return &Store{db: conn, HadLegacyDuckDB: legacy}, nil -} - -// Close closes the underlying connection. -func (s *Store) Close() error { return s.db.Close() } - -// HasScopes reports whether repoPath has any stored scopes. -func (s *Store) HasScopes(repoPath string) (bool, error) { - var n int - err := s.db.QueryRow(` - SELECT COUNT(*) FROM scopes s - JOIN repositories r ON s.repo_id = r.id - WHERE r.path = ?`, repoPath).Scan(&n) - return n > 0, err -} - -// Scopes returns repoPath's scopes as name -> ordered path prefixes. -func (s *Store) Scopes(repoPath string) (map[string][]string, error) { - rows, err := s.db.Query(` - SELECT s.name, sp.path - FROM scopes s - JOIN repositories r ON s.repo_id = r.id - JOIN scope_paths sp ON sp.scope_id = s.id - WHERE r.path = ? - ORDER BY s.name, sp.path`, repoPath) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - - scopes := map[string][]string{} - for rows.Next() { - var name, path string - if err := rows.Scan(&name, &path); err != nil { - return nil, err - } - scopes[name] = append(scopes[name], path) - } - return scopes, rows.Err() -} - -// SaveScopes replaces repoPath's scopes and snapshots gitignoreHash. -func (s *Store) SaveScopes(repoPath, repoName string, scopes map[string][]string, gitignoreHash string) error { - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() //nolint:errcheck // no-op after commit - - var repoID int64 - err = tx.QueryRow(`SELECT id FROM repositories WHERE path = ?`, repoPath).Scan(&repoID) - switch err { - case nil: - // ON DELETE CASCADE removes scope_paths and github_labels. - if _, err := tx.Exec(`DELETE FROM scopes WHERE repo_id = ?`, repoID); err != nil { - return err - } - case sql.ErrNoRows: - res, err := tx.Exec(`INSERT INTO repositories (path, name) VALUES (?, ?)`, repoPath, repoName) - if err != nil { - return err - } - if repoID, err = res.LastInsertId(); err != nil { - return err - } - default: - return err - } - - names := make([]string, 0, len(scopes)) - for name := range scopes { - names = append(names, name) - } - sort.Strings(names) - for _, name := range names { - res, err := tx.Exec(`INSERT INTO scopes (repo_id, name) VALUES (?, ?)`, repoID, name) - if err != nil { - return err - } - scopeID, err := res.LastInsertId() - if err != nil { - return err - } - for _, path := range scopes[name] { - if _, err := tx.Exec( - `INSERT OR IGNORE INTO scope_paths (scope_id, path) VALUES (?, ?)`, - scopeID, path, - ); err != nil { - return err - } - } - } - - if _, err := tx.Exec( - `UPDATE repositories SET updated_at = CURRENT_TIMESTAMP, gitignore_hash = ? WHERE id = ?`, - gitignoreHash, repoID, - ); err != nil { - return err - } - return tx.Commit() -} - -// StoredGitignoreHash returns the snapshotted hash for repoPath. present is -// false when the repo is unknown or the hash was never recorded. -func (s *Store) StoredGitignoreHash(repoPath string) (hash string, present bool, err error) { - var v sql.NullString - err = s.db.QueryRow(`SELECT gitignore_hash FROM repositories WHERE path = ?`, repoPath).Scan(&v) - if err == sql.ErrNoRows { - return "", false, nil - } - if err != nil { - return "", false, err - } - return v.String, v.Valid, nil -} - -// ListRepos returns all repositories, most recently updated first. -func (s *Store) ListRepos() ([]RepoInfo, error) { - rows, err := s.db.Query(` - SELECT r.name, r.path, COUNT(DISTINCT s.id) AS scope_count - FROM repositories r - LEFT JOIN scopes s ON s.repo_id = r.id - GROUP BY r.id, r.name, r.path, r.updated_at - ORDER BY r.updated_at DESC`) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - - var repos []RepoInfo - for rows.Next() { - var r RepoInfo - if err := rows.Scan(&r.Name, &r.Path, &r.ScopeCount); err != nil { - return nil, err - } - repos = append(repos, r) - } - return repos, rows.Err() -} - -// DeleteRepo removes repoPath and, via cascade, all of its children. -func (s *Store) DeleteRepo(repoPath string) error { - _, err := s.db.Exec(`DELETE FROM repositories WHERE path = ?`, repoPath) - return err -} diff --git a/internal/db/store_test.go b/internal/db/store_test.go deleted file mode 100644 index c482edc..0000000 --- a/internal/db/store_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package db - -import ( - "reflect" - "testing" -) - -func openTestStore(t *testing.T) *Store { - t.Helper() - t.Setenv("XDG_DATA_HOME", t.TempDir()) - s, err := Open() - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = s.Close() }) - return s -} - -func TestSaveAndReadScopes(t *testing.T) { - s := openTestStore(t) - scopes := map[string][]string{ - "core": {"internal/", "main.go"}, - "docs": {"README.md"}, - } - - if has, _ := s.HasScopes("/repo"); has { - t.Fatal("fresh store should have no scopes") - } - if err := s.SaveScopes("/repo", "repo", scopes, "hash1"); err != nil { - t.Fatal(err) - } - if has, _ := s.HasScopes("/repo"); !has { - t.Fatal("scopes should exist after save") - } - got, err := s.Scopes("/repo") - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(got, scopes) { - t.Errorf("got %#v, want %#v", got, scopes) - } - - hash, present, err := s.StoredGitignoreHash("/repo") - if err != nil || !present || hash != "hash1" { - t.Errorf("want (hash1, true), got (%q, %v, %v)", hash, present, err) - } -} - -func TestSaveScopesReplacesExisting(t *testing.T) { - s := openTestStore(t) - if err := s.SaveScopes("/repo", "repo", map[string][]string{"old": {"a/"}}, "h1"); err != nil { - t.Fatal(err) - } - if err := s.SaveScopes("/repo", "repo", map[string][]string{"new": {"b/"}}, "h2"); err != nil { - t.Fatal(err) - } - got, _ := s.Scopes("/repo") - want := map[string][]string{"new": {"b/"}} - if !reflect.DeepEqual(got, want) { - t.Errorf("old scopes must be cascade-deleted; got %#v", got) - } - if hash, _, _ := s.StoredGitignoreHash("/repo"); hash != "h2" { - t.Errorf("hash not updated: %q", hash) - } -} - -func TestDeleteRepoCascades(t *testing.T) { - s := openTestStore(t) - if err := s.SaveScopes("/repo", "repo", map[string][]string{"core": {"a/"}}, "h"); err != nil { - t.Fatal(err) - } - if err := s.DeleteRepo("/repo"); err != nil { - t.Fatal(err) - } - if has, _ := s.HasScopes("/repo"); has { - t.Error("scopes must be gone after repo delete") - } - var n int - if err := s.db.QueryRow(`SELECT COUNT(*) FROM scope_paths`).Scan(&n); err != nil || n != 0 { - t.Errorf("scope_paths rows must cascade away, got %d (%v)", n, err) - } -} - -func TestStoredGitignoreHashUnknownRepo(t *testing.T) { - s := openTestStore(t) - if _, present, err := s.StoredGitignoreHash("/nowhere"); err != nil || present { - t.Errorf("unknown repo: want (false, nil), got (%v, %v)", present, err) - } -} - -func TestListRepos(t *testing.T) { - s := openTestStore(t) - if err := s.SaveScopes("/a", "a", map[string][]string{"x": {"x/"}, "y": {"y/"}}, ""); err != nil { - t.Fatal(err) - } - repos, err := s.ListRepos() - if err != nil { - t.Fatal(err) - } - if len(repos) != 1 || repos[0].Name != "a" || repos[0].ScopeCount != 2 { - t.Errorf("got %#v", repos) - } -} diff --git a/internal/diff/filter.go b/internal/diff/filter.go deleted file mode 100644 index b9f0e7b..0000000 --- a/internal/diff/filter.go +++ /dev/null @@ -1,101 +0,0 @@ -// Package diff filters git diffs before they reach the model: lock/generated -// files lose their hunk bodies, JSON changes are capped, and every other file -// is truncated. Behavior is a verbatim port of the Python filter_diff. -package diff - -import ( - "fmt" - "regexp" - "strings" -) - -var lockPattern = regexp.MustCompile( - `(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb|` + - `go\.sum|go\.mod|Cargo\.lock|poetry\.lock|composer\.lock|Gemfile\.lock|` + - `.*\.min\.(js|css)|.*\.bundle\.js|.*\.map|` + - `dist/.*|build/.*|\.next/.*|node_modules/.*|vendor/.*|__pycache__/.*|\.pyc$|target/.*)`, -) - -var ( - fileNameRe = regexp.MustCompile(`b/([^ ]+)`) - headerRe = regexp.MustCompile(`^(index|---|\+\+\+|@@)`) -) - -const ( - // MaxDiffLines caps the per-file line count for regular files. - MaxDiffLines = 200 - // MaxJSONLines caps the +/- line count for JSON files. - MaxJSONLines = 50 -) - -// Filter rewrites a raw git diff for prompt consumption. -func Filter(diff string) string { - var lines []string - inFiltered, inJSON := false, false - lineCount, jsonCount := 0, 0 - - for _, line := range strings.Split(diff, "\n") { - if strings.HasPrefix(line, "diff --git") { - lineCount, jsonCount = 0, 0 - filename := "" - if m := fileNameRe.FindStringSubmatch(line); m != nil { - filename = m[1] - } - - switch { - case lockPattern.MatchString(filename): - inFiltered, inJSON = true, false - lines = append(lines, line) - continue - case strings.HasSuffix(filename, ".json"): - inFiltered, inJSON = false, true - lines = append(lines, line) - continue - default: - inFiltered, inJSON = false, false - } - } - - // Lock/generated files: keep headers, replace each hunk body with a - // marker. (The Python original stopped filtering after the first @@, - // leaking hunk bodies; the spec requires bodies replaced, so filtering - // holds until the next "diff --git".) - if inFiltered { - if headerRe.MatchString(line) { - lines = append(lines, line) - if strings.HasPrefix(line, "@@") { - lines = append(lines, "[Generated/lock file - content filtered]") - } - } - continue - } - - if inJSON { - if headerRe.MatchString(line) { - lines = append(lines, line) - continue - } - if strings.HasPrefix(line, "+") || strings.HasPrefix(line, "-") { - jsonCount++ - if jsonCount <= MaxJSONLines { - lines = append(lines, line) - } else if jsonCount == MaxJSONLines+1 { - lines = append(lines, fmt.Sprintf("[... JSON truncated after %d lines ...]", MaxJSONLines)) - } - } else { - lines = append(lines, line) - } - continue - } - - if lineCount < MaxDiffLines { - lines = append(lines, line) - lineCount++ - } else if lineCount == MaxDiffLines { - lines = append(lines, fmt.Sprintf("[... truncated after %d lines ...]", MaxDiffLines)) - lineCount++ - } - } - - return strings.Join(lines, "\n") -} diff --git a/internal/diff/filter_test.go b/internal/diff/filter_test.go deleted file mode 100644 index a3c63e0..0000000 --- a/internal/diff/filter_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package diff - -import ( - "fmt" - "strings" - "testing" -) - -func diffHeader(file string) string { - return fmt.Sprintf("diff --git a/%s b/%s\nindex 111..222 100644\n--- a/%s\n+++ b/%s\n@@ -1,2 +1,2 @@", - file, file, file, file) -} - -func TestFilter(t *testing.T) { - bigBody := make([]string, 0, 300) - for i := range 300 { - bigBody = append(bigBody, fmt.Sprintf("+line %d", i)) - } - - jsonBody := make([]string, 0, 80) - for i := range 80 { - jsonBody = append(jsonBody, fmt.Sprintf("+ \"key%d\": %d,", i, i)) - } - - tests := []struct { - name string - input string - wantContain []string - wantAbsent []string - }{ - { - name: "small file passes through unchanged", - input: diffHeader("main.go") + "\n-old\n+new", - wantContain: []string{"-old", "+new"}, - wantAbsent: []string{"truncated", "filtered"}, - }, - { - name: "lock file body replaced with marker", - input: diffHeader("package-lock.json") + "\n-\"old\": 1\n+\"new\": 2", - wantContain: []string{ - "diff --git a/package-lock.json b/package-lock.json", - "@@ -1,2 +1,2 @@", - "[Generated/lock file - content filtered]", - }, - wantAbsent: []string{`"old": 1`, `"new": 2`}, - }, - { - name: "go.sum matched by lock pattern", - input: diffHeader("go.sum") + "\n+github.com/x v1.0.0 h1:abc", - wantContain: []string{"[Generated/lock file - content filtered]"}, - wantAbsent: []string{"h1:abc"}, - }, - { - name: "path prefix matched anywhere in name", - input: diffHeader("node_modules/foo.js") + "\n+secret", - wantContain: []string{"[Generated/lock file - content filtered]"}, - wantAbsent: []string{"+secret"}, - }, - { - name: "json file capped at 50 +/- lines", - input: diffHeader("config.json") + "\n" + strings.Join(jsonBody, "\n"), - wantContain: []string{ - `+ "key49": 49,`, - "[... JSON truncated after 50 lines ...]", - }, - wantAbsent: []string{`"key50"`, `"key79"`}, - }, - { - name: "json context lines kept beyond the cap", - input: diffHeader("config.json") + "\n" + strings.Join(jsonBody, "\n") + "\n context line", - wantContain: []string{" context line"}, - }, - { - name: "regular file truncated at 200 lines", - input: diffHeader("big.go") + "\n" + strings.Join(bigBody, "\n"), - wantContain: []string{ - "[... truncated after 200 lines ...]", - }, - wantAbsent: []string{"+line 299"}, - }, - { - name: "counters reset per file", - input: diffHeader("big.go") + "\n" + strings.Join(bigBody, "\n") + "\n" + - diffHeader("small.go") + "\n+after", - wantContain: []string{"+after"}, - }, - { - name: "lock file followed by normal file", - input: diffHeader("yarn.lock") + "\n+lockline\n" + - diffHeader("app.ts") + "\n+visible", - wantContain: []string{"[Generated/lock file - content filtered]", "+visible"}, - wantAbsent: []string{"+lockline"}, - }, - { - name: "empty diff stays empty", - input: "", - wantContain: []string{}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := Filter(tt.input) - for _, want := range tt.wantContain { - if !strings.Contains(got, want) { - t.Errorf("output missing %q\noutput:\n%s", want, got) - } - } - for _, absent := range tt.wantAbsent { - if strings.Contains(got, absent) { - t.Errorf("output should not contain %q\noutput:\n%s", absent, got) - } - } - }) - } -} - -func TestFilterTruncationMarkerOnlyOnce(t *testing.T) { - var body []string - for i := range 400 { - body = append(body, fmt.Sprintf("+l%d", i)) - } - got := Filter(diffHeader("big.go") + "\n" + strings.Join(body, "\n")) - if n := strings.Count(got, "[... truncated after 200 lines ...]"); n != 1 { - t.Errorf("want exactly 1 truncation marker, got %d", n) - } -} diff --git a/internal/gitx/git.go b/internal/gitx/git.go deleted file mode 100644 index 2512f38..0000000 --- a/internal/gitx/git.go +++ /dev/null @@ -1,142 +0,0 @@ -// Package gitx wraps the git CLI. Read helpers swallow errors and return "" -// (mirroring the Python tool); mutating helpers pass git output through and -// return errors. -package gitx - -import ( - "crypto/sha256" - "encoding/hex" - "os" - "os/exec" - "path/filepath" - "sort" - "strings" -) - -// Git runs git with args and returns trimmed stdout, or "" on any failure. -func Git(args ...string) string { - out, err := exec.Command("git", args...).Output() - if err != nil { - return "" - } - return strings.TrimSpace(string(out)) -} - -// passthrough runs git with args, streaming output to the terminal. -func passthrough(args ...string) error { - cmd := exec.Command("git", args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} - -// IsRepo reports whether the working directory is inside a git work tree. -func IsRepo() bool { - return exec.Command("git", "rev-parse", "--is-inside-work-tree").Run() == nil -} - -// RepoRoot returns the repository top-level path, or "" outside a repo. -func RepoRoot() string { return Git("rev-parse", "--show-toplevel") } - -// Lines splits git output into non-empty lines. -func Lines(out string) []string { - var lines []string - for _, l := range strings.Split(out, "\n") { - if l != "" { - lines = append(lines, l) - } - } - return lines -} - -// ChangedFiles returns staged + unstaged + untracked files, deduplicated and sorted. -func ChangedFiles() []string { - seen := map[string]struct{}{} - for _, out := range []string{ - Git("diff", "--cached", "--name-only"), - Git("diff", "--name-only"), - Git("ls-files", "--others", "--exclude-standard"), - } { - for _, f := range Lines(out) { - seen[f] = struct{}{} - } - } - files := make([]string, 0, len(seen)) - for f := range seen { - files = append(files, f) - } - sort.Strings(files) - return files -} - -// FilesInScope returns the files whose path starts with any of the scope prefixes. -func FilesInScope(files, prefixes []string) []string { - var matched []string - for _, f := range files { - for _, p := range prefixes { - if strings.HasPrefix(f, p) { - matched = append(matched, f) - break - } - } - } - return matched -} - -// StageFiles stages files with git add. -func StageFiles(files []string) error { - if len(files) == 0 { - return nil - } - return passthrough(append([]string{"add"}, files...)...) -} - -// ResetStaging unstages everything (git reset HEAD -- .), ignoring errors. -func ResetStaging() { - _ = exec.Command("git", "reset", "HEAD", "--", ".").Run() -} - -// Commit commits the staging area with message. -func Commit(message string) error { return passthrough("commit", "-m", message) } - -// UnpushedCommits returns oneline entries for commits not on any remote. -func UnpushedCommits() []string { - return Lines(Git("log", "--branches", "--not", "--remotes", "--oneline")) -} - -// CurrentBranch returns the checked-out branch name. -func CurrentBranch() string { return Git("branch", "--show-current") } - -// Push pushes branch to origin. -func Push(branch string) error { return passthrough("push", "origin", branch) } - -// Filetree returns a sorted, deduplicated repo-relative file listing -// (tracked + non-ignored untracked), one path per line. -func Filetree() string { - seen := map[string]struct{}{} - for _, out := range []string{ - Git("ls-files"), - Git("ls-files", "--others", "--exclude-standard"), - } { - for _, f := range Lines(out) { - seen[f] = struct{}{} - } - } - files := make([]string, 0, len(seen)) - for f := range seen { - files = append(files, f) - } - sort.Strings(files) - return strings.Join(files, "\n") -} - -// GitignoreHash returns the SHA-256 hex digest of the repo's .gitignore, -// or "" when there is none. -func GitignoreHash(repoRoot string) string { - data, err := os.ReadFile(filepath.Join(repoRoot, ".gitignore")) - if err != nil { - return "" - } - sum := sha256.Sum256(data) - return hex.EncodeToString(sum[:]) -} diff --git a/internal/gitx/git_test.go b/internal/gitx/git_test.go deleted file mode 100644 index d9912e3..0000000 --- a/internal/gitx/git_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package gitx - -import ( - "os" - "path/filepath" - "reflect" - "testing" -) - -func TestFilesInScope(t *testing.T) { - files := []string{ - "cmd/root.go", - "cmd/list.go", - "internal/db/store.go", - "README.md", - "cmdlets/x.go", - } - tests := []struct { - name string - prefixes []string - want []string - }{ - {"directory prefix", []string{"cmd/"}, []string{"cmd/root.go", "cmd/list.go"}}, - {"bare prefix matches sibling dirs too", []string{"cmd"}, []string{"cmd/root.go", "cmd/list.go", "cmdlets/x.go"}}, - {"exact file", []string{"README.md"}, []string{"README.md"}}, - {"multiple prefixes no duplicates", []string{"cmd/", "cmd/root"}, []string{"cmd/root.go", "cmd/list.go"}}, - {"no match", []string{"docs/"}, nil}, - {"empty prefixes", nil, nil}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := FilesInScope(files, tt.prefixes); !reflect.DeepEqual(got, tt.want) { - t.Errorf("got %v, want %v", got, tt.want) - } - }) - } -} - -func TestGitignoreHash(t *testing.T) { - dir := t.TempDir() - - if got := GitignoreHash(dir); got != "" { - t.Errorf("missing .gitignore should hash to empty string, got %q", got) - } - - path := filepath.Join(dir, ".gitignore") - if err := os.WriteFile(path, []byte("node_modules/\n"), 0o644); err != nil { - t.Fatal(err) - } - // sha256 of "node_modules/\n" - first := GitignoreHash(dir) - if len(first) != 64 { - t.Errorf("want 64-char hex digest, got %q", first) - } - if again := GitignoreHash(dir); again != first { - t.Error("hash must be deterministic") - } - - if err := os.WriteFile(path, []byte("dist/\n"), 0o644); err != nil { - t.Fatal(err) - } - if changed := GitignoreHash(dir); changed == first { - t.Error("hash must change when content changes") - } -} - -func TestLines(t *testing.T) { - if got := Lines("a\n\nb\n"); !reflect.DeepEqual(got, []string{"a", "b"}) { - t.Errorf("got %v", got) - } - if got := Lines(""); got != nil { - t.Errorf("empty output should yield nil, got %v", got) - } -} diff --git a/internal/ui/ui.go b/internal/ui/ui.go deleted file mode 100644 index f5f08a0..0000000 --- a/internal/ui/ui.go +++ /dev/null @@ -1,113 +0,0 @@ -// Package ui centralizes lipgloss styling, huh confirmations, and spinners so -// every command shares the same visual language as the original Python tool. -package ui - -import ( - "fmt" - "os" - - "github.com/charmbracelet/huh" - "github.com/charmbracelet/huh/spinner" - "github.com/charmbracelet/lipgloss" - "golang.org/x/term" -) - -// AutoConfirm short-circuits every confirmation prompt (GH_COMMIT_AUTO / --auto). -var AutoConfirm bool - -var ( - red = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) - green = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) - greenBold = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Bold(true) - yellow = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) - yellowBold = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Bold(true) - magenta = lipgloss.NewStyle().Foreground(lipgloss.Color("5")) - magentaBold = lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Bold(true) - cyan = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) - dim = lipgloss.NewStyle().Faint(true) - bold = lipgloss.NewStyle().Bold(true) - - panelStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("5")). - Padding(0, 1) -) - -// Println prints a blank line. -func Println() { fmt.Println() } - -// Printf prints unstyled text with a trailing newline. -func Printf(format string, a ...any) { fmt.Printf(format+"\n", a...) } - -// Errorf prints red text. -func Errorf(format string, a ...any) { fmt.Println(red.Render(fmt.Sprintf(format, a...))) } - -// Successf prints green text. -func Successf(format string, a ...any) { fmt.Println(green.Render(fmt.Sprintf(format, a...))) } - -// SuccessBoldf prints bold green text. -func SuccessBoldf(format string, a ...any) { - fmt.Println(greenBold.Render(fmt.Sprintf(format, a...))) -} - -// Warnf prints yellow text. -func Warnf(format string, a ...any) { fmt.Println(yellow.Render(fmt.Sprintf(format, a...))) } - -// WarnBoldf prints bold yellow text. -func WarnBoldf(format string, a ...any) { fmt.Println(yellowBold.Render(fmt.Sprintf(format, a...))) } - -// Infof prints cyan text. -func Infof(format string, a ...any) { fmt.Println(cyan.Render(fmt.Sprintf(format, a...))) } - -// Magentaf prints magenta text. -func Magentaf(format string, a ...any) { fmt.Println(magenta.Render(fmt.Sprintf(format, a...))) } - -// MagentaBoldf prints bold magenta text. -func MagentaBoldf(format string, a ...any) { - fmt.Println(magentaBold.Render(fmt.Sprintf(format, a...))) -} - -// Dimf prints faint text. -func Dimf(format string, a ...any) { fmt.Println(dim.Render(fmt.Sprintf(format, a...))) } - -// Bold returns s rendered bold, for inline composition. -func Bold(s string) string { return bold.Render(s) } - -// Panel prints text inside a magenta rounded-border panel. -func Panel(text string) { fmt.Println(panelStyle.Render(text)) } - -// Cyan returns s rendered cyan, for inline composition. -func Cyan(s string) string { return cyan.Render(s) } - -// Green returns s rendered green, for inline composition. -func Green(s string) string { return green.Render(s) } - -// Yellow returns s rendered yellow, for inline composition. -func Yellow(s string) string { return yellow.Render(s) } - -// Red returns s rendered red, for inline composition. -func Red(s string) string { return red.Render(s) } - -// Confirm asks a yes/no question, defaulting to yes. AutoConfirm returns true -// immediately; a prompt failure (e.g. no TTY, Ctrl-C) counts as no. -func Confirm(msg string) bool { - if AutoConfirm { - return true - } - v := true - if err := huh.NewConfirm().Title(msg).Value(&v).Run(); err != nil { - return false - } - return v -} - -// Spin runs fn behind a spinner titled title. Without a TTY (or in auto mode) -// it prints the title once and runs fn directly. -func Spin(title string, fn func()) { - if AutoConfirm || !term.IsTerminal(int(os.Stdout.Fd())) || !term.IsTerminal(int(os.Stdin.Fd())) { - Dimf("%s", title) - fn() - return - } - _ = spinner.New().Title(title).Action(fn).Run() -} diff --git a/main.go b/main.go deleted file mode 100644 index 7c419a5..0000000 --- a/main.go +++ /dev/null @@ -1,10 +0,0 @@ -package main - -import "github.com/prdlk/gh-commit/cmd" - -// version is injected at release time via -ldflags "-X main.version=...". -var version = "dev" - -func main() { - cmd.Execute(version) -} diff --git a/smartcommit.py b/smartcommit.py index 1d0acd5..20e345a 100644 --- a/smartcommit.py +++ b/smartcommit.py @@ -52,7 +52,7 @@ AUTO_PUSH = os.environ.get("GH_COMMIT_PUSH", "0") == "1" NO_AUTO_REFRESH = os.environ.get("GH_COMMIT_NO_AUTO_REFRESH", "0") == "1" DEBUG = os.environ.get("GH_COMMIT_DEBUG", "0") == "1" -VERSION = "2.0.0" +VERSION = "5.0.0" # ── Helpers ─────────────────────────────────────────────────────────────────── diff --git a/smoke_test.go b/smoke_test.go deleted file mode 100644 index a73db4c..0000000 --- a/smoke_test.go +++ /dev/null @@ -1,92 +0,0 @@ -//go:build integration - -// Integration smoke test: builds the binary, then runs init + commit in a -// temp git repo against a live Ollama server. Skipped automatically when the -// server (or the model) is unavailable. -// -// go test -tags integration -run TestSmoke -v . -package main - -import ( - "net/http" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" -) - -func ollamaHost() string { - if h := os.Getenv("GH_COMMIT_OLLAMA_HOST"); h != "" { - return h - } - return "http://localhost:11434" -} - -func run(t *testing.T, dir string, env []string, name string, args ...string) string { - t.Helper() - cmd := exec.Command(name, args...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), env...) - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("%s %v failed: %v\n%s", name, args, err, out) - } - return string(out) -} - -func TestSmoke(t *testing.T) { - client := &http.Client{Timeout: 2 * time.Second} - if _, err := client.Get(ollamaHost() + "/api/tags"); err != nil { - t.Skipf("Ollama not reachable at %s: %v", ollamaHost(), err) - } - - bin := filepath.Join(t.TempDir(), "gh-commit") - run(t, ".", nil, "go", "build", "-o", bin, ".") - - repo := t.TempDir() - env := []string{ - "GH_COMMIT_AUTO=1", - "XDG_DATA_HOME=" + t.TempDir(), - "GIT_CONFIG_GLOBAL=/dev/null", - "GIT_CONFIG_SYSTEM=/dev/null", - "GIT_AUTHOR_NAME=smoke", "GIT_AUTHOR_EMAIL=smoke@test", - "GIT_COMMITTER_NAME=smoke", "GIT_COMMITTER_EMAIL=smoke@test", - } - run(t, repo, env, "git", "init", "-q") - origin := t.TempDir() - run(t, origin, env, "git", "init", "-q", "--bare") - run(t, repo, env, "git", "remote", "add", "origin", origin) - - writeFile := func(rel, content string) { - t.Helper() - path := filepath.Join(repo, rel) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - } - writeFile("src/app.go", "package app\n\nfunc Run() {}\n") - writeFile("docs/README.md", "# Smoke\n") - - out := run(t, repo, env, bin, "init") - if !strings.Contains(out, "Saved scopes to database") { - t.Fatalf("init did not save scopes:\n%s", out) - } - - writeFile("src/app.go", "package app\n\nfunc Run() { println(\"changed\") }\n") - - out = run(t, repo, env, bin) - if !strings.Contains(out, "✓ Done!") { - t.Fatalf("commit flow did not complete:\n%s", out) - } - - log := run(t, repo, env, "git", "log", "--format=%s") - if strings.TrimSpace(log) == "" { - t.Fatalf("no commits created; gh-commit output:\n%s", out) - } - t.Logf("commits:\n%s", log) -}