mirror of
https://github.com/prdlk/gh-commit.git
synced 2026-09-16 23:16:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9647c854c9 |
@@ -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
|
||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
/.omc
|
/gh-commit
|
||||||
/__pycache__
|
dist/
|
||||||
|
|||||||
@@ -1,91 +1,87 @@
|
|||||||
# gh-commit
|
# 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
|
## Install
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
# Prerequisites: git, a running Ollama server, and the model
|
||||||
|
ollama pull qwen3.5:2b
|
||||||
|
|
||||||
gh extension install prdlk/gh-commit
|
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
|
## Usage
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Initialize scopes for your repo (uses Crush to analyze structure)
|
cd your-repo
|
||||||
gh commit init
|
gh commit init # generate scopes for this repo
|
||||||
|
gh commit # commit changes grouped by scope
|
||||||
# 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
## Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Behavior |
|
||||||
|---------|-------------|
|
|---|---|
|
||||||
| `gh commit` | Commit changes grouped by scope |
|
| `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 for current repo |
|
| `gh commit init` | Generate scopes from the file tree via Ollama; migrates legacy configs; confirms overwrite |
|
||||||
| `gh commit refresh` | Update scopes from current structure |
|
| `gh commit refresh` | Show current scopes, regenerate with existing scopes as context, confirm apply |
|
||||||
| `gh commit sync` | Sync scopes → GitHub labels |
|
| `gh commit sync` | Create/update GitHub labels from scopes (color = first 6 hex chars of MD5 of the scope name) |
|
||||||
| `gh commit list` | List all configured repositories |
|
| `gh commit list` | Table of configured repositories: name, path, scope count |
|
||||||
| `gh commit remove` | Remove current repo from database |
|
| `gh commit remove` | Delete the current repo from the database after confirmation |
|
||||||
| `gh commit db-path` | Print database file path |
|
| `gh commit db-path` | Print the database file path |
|
||||||
| `gh commit version` | Print version |
|
| `gh commit version` | Print `gh-commit <version>` |
|
||||||
| `gh commit help` | Show help |
|
| `gh commit help` | Help, including the DB path and environment variable docs |
|
||||||
|
|
||||||
## Flags
|
Flags: `--auto` (skip confirmations), `--push` (auto-push), `--model`, `--host`.
|
||||||
|
|
||||||
| Flag | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `--auto` | Skip all confirmation prompts |
|
|
||||||
| `--push` | Auto-push after committing |
|
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Default | Purpose |
|
||||||
|----------|-------------|
|
|---|---|---|
|
||||||
| `GH_COMMIT_AUTO=1` | Skip all confirmation prompts |
|
| `GH_COMMIT_OLLAMA_HOST` | `http://localhost:11434` | Ollama server URL |
|
||||||
| `GH_COMMIT_PUSH=1` | Auto-push after commits |
|
| `GH_COMMIT_MODEL` | `qwen3.5:2b` | Model tag |
|
||||||
| `GH_COMMIT_NO_AUTO_REFRESH=1` | Don't auto-regenerate scopes when `.gitignore` changes |
|
| `GH_COMMIT_TIMEOUT` | `120` | Per-request timeout, seconds |
|
||||||
| `GH_COMMIT_CRUSH_CMD` | Override the Crush command (default `crush`) |
|
| `GH_COMMIT_AUTO` | `0` | `1` = skip all confirmations |
|
||||||
| `GH_COMMIT_CRUSH_MODEL` | Override the Crush model (default `groq/openai/gpt-oss-120b`) |
|
| `GH_COMMIT_PUSH` | `0` | `1` = auto-push after commits |
|
||||||
| `GH_COMMIT_CRUSH_TIMEOUT` | Per-prompt timeout in seconds (default `120`) |
|
| `GH_COMMIT_NO_AUTO_REFRESH` | `0` | `1` = never auto-regenerate scopes |
|
||||||
| `GH_COMMIT_DEBUG=1` | Show scope-response parse diagnostics |
|
| `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.<timestamp>`). 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).
|
||||||
|
|||||||
+224
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+56
@@ -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)
|
||||||
|
}
|
||||||
@@ -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.<timestamp>.
|
||||||
|
func archiveMigrated(path string) (string, error) {
|
||||||
|
backup := path + ".migrated." + time.Now().Format("20060102_150405")
|
||||||
|
return backup, os.Rename(path, backup)
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateTOML imports scopes from .github/Repo.toml.
|
||||||
|
func migrateTOML(repoPath, repoName, tomlPath string) bool {
|
||||||
|
ui.Warnf("↻ Migrating .github/Repo.toml → database")
|
||||||
|
var doc struct {
|
||||||
|
Scopes map[string]any `toml:"scopes"`
|
||||||
|
}
|
||||||
|
if _, err := toml.DecodeFile(tomlPath, &doc); err != nil {
|
||||||
|
ui.Errorf("Migration failed: %s", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
scopes := ai.CoerceScopeMap(doc.Scopes)
|
||||||
|
if scopes == nil {
|
||||||
|
scopes = map[string][]string{}
|
||||||
|
}
|
||||||
|
if err := store.SaveScopes(repoPath, repoName, scopes, gitx.GitignoreHash(repoPath)); err != nil {
|
||||||
|
ui.Errorf("Migration failed: %s", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
backup, err := archiveMigrated(tomlPath)
|
||||||
|
if err != nil {
|
||||||
|
ui.Errorf("Migration failed: %s", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ui.Dimf(" Archived: %s", backup)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateJSON imports scopes from .github/scopes.json
|
||||||
|
// (an array of {"scope": ..., "path": ...} entries).
|
||||||
|
func migrateJSON(repoPath, repoName, jsonPath string) bool {
|
||||||
|
ui.Warnf("↻ Migrating .github/scopes.json → database")
|
||||||
|
data, err := os.ReadFile(jsonPath)
|
||||||
|
if err != nil {
|
||||||
|
ui.Errorf("Migration failed: %s", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var entries []struct {
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &entries); err != nil {
|
||||||
|
ui.Errorf("Migration failed: %s", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
scopes := map[string][]string{}
|
||||||
|
for _, e := range entries {
|
||||||
|
scopes[e.Scope] = append(scopes[e.Scope], e.Path)
|
||||||
|
}
|
||||||
|
if err := store.SaveScopes(repoPath, repoName, scopes, gitx.GitignoreHash(repoPath)); err != nil {
|
||||||
|
ui.Errorf("Migration failed: %s", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
backup, err := archiveMigrated(jsonPath)
|
||||||
|
if err != nil {
|
||||||
|
ui.Errorf("Migration failed: %s", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ui.Dimf(" Archived: %s", backup)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateLegacy imports whichever legacy config file exists, if any.
|
||||||
|
func migrateLegacy(repoPath string) bool {
|
||||||
|
repoName := filepath.Base(repoPath)
|
||||||
|
if tomlPath := filepath.Join(repoPath, ".github", "Repo.toml"); fileExists(tomlPath) {
|
||||||
|
return migrateTOML(repoPath, repoName, tomlPath)
|
||||||
|
}
|
||||||
|
if jsonPath := filepath.Join(repoPath, ".github", "scopes.json"); fileExists(jsonPath) {
|
||||||
|
return migrateJSON(repoPath, repoName, jsonPath)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
+62
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+166
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+76
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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}" "$@"
|
|
||||||
Binary file not shown.
@@ -0,0 +1,51 @@
|
|||||||
|
module github.com/prdlk/gh-commit
|
||||||
|
|
||||||
|
go 1.23
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/BurntSushi/toml v1.4.0
|
||||||
|
github.com/charmbracelet/huh v0.6.0
|
||||||
|
github.com/charmbracelet/huh/spinner v0.0.0-20240618200428-90406d79077d
|
||||||
|
github.com/charmbracelet/lipgloss v1.0.0
|
||||||
|
github.com/rozoomcool/go-ollama-sdk v0.0.0-20250620220025-710cf9a2c767
|
||||||
|
github.com/spf13/cobra v1.8.1
|
||||||
|
golang.org/x/term v0.27.0
|
||||||
|
modernc.org/sqlite v1.34.4
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/catppuccin/go v0.2.0 // indirect
|
||||||
|
github.com/charmbracelet/bubbles v0.20.0 // indirect
|
||||||
|
github.com/charmbracelet/bubbletea v1.1.0 // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.4.2 // indirect
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
|
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
golang.org/x/sync v0.8.0 // indirect
|
||||||
|
golang.org/x/sys v0.28.0 // indirect
|
||||||
|
golang.org/x/text v0.18.0 // indirect
|
||||||
|
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||||
|
modernc.org/libc v1.55.3 // indirect
|
||||||
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
|
modernc.org/memory v1.8.0 // indirect
|
||||||
|
modernc.org/strutil v1.2.0 // indirect
|
||||||
|
modernc.org/token v1.1.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
|
||||||
|
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
|
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||||
|
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||||
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||||
|
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
|
||||||
|
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||||
|
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
|
||||||
|
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
|
||||||
|
github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c=
|
||||||
|
github.com/charmbracelet/bubbletea v1.1.0/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4=
|
||||||
|
github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8=
|
||||||
|
github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU=
|
||||||
|
github.com/charmbracelet/huh/spinner v0.0.0-20240618200428-90406d79077d h1:OpthCCWiHBSx6LTAYGGkN9OeuJrKzjobe0q12wO6BX0=
|
||||||
|
github.com/charmbracelet/huh/spinner v0.0.0-20240618200428-90406d79077d/go.mod h1:CrXBZnOWs3zpyppOZZS7lu2CpLq2jx6U5chL/frRG/E=
|
||||||
|
github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
|
||||||
|
github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
|
||||||
|
github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk=
|
||||||
|
github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||||
|
github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
|
||||||
|
github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||||
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||||
|
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
|
||||||
|
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/rozoomcool/go-ollama-sdk v0.0.0-20250620220025-710cf9a2c767 h1:TBRPWLhZWwrRJhigDIaoxvkeov4vpWYV7ws/qW3by+s=
|
||||||
|
github.com/rozoomcool/go-ollama-sdk v0.0.0-20250620220025-710cf9a2c767/go.mod h1:vHEieQv2QDMhRRLpSG3Vmi7a0wdy8hY2F14U4jtVuk0=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||||
|
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||||
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||||
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||||
|
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||||
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||||
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
|
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
|
||||||
|
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||||
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||||
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||||
|
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||||
|
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||||
|
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||||
|
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||||
|
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||||
|
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||||
|
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||||
|
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||||
|
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||||
|
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||||
|
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||||
|
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||||
|
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||||
|
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||||
|
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
|
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||||
|
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||||
|
modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8=
|
||||||
|
modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=
|
||||||
|
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||||
|
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// Package ai talks to a local Ollama server. The rozoomcool SDK is used for
|
||||||
|
// client construction and model pulls; generation goes through a raw
|
||||||
|
// /api/generate POST because the SDK's Generate cannot pass think, options,
|
||||||
|
// or keep_alive — the levers this tool depends on for speed.
|
||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
ollama "github.com/rozoomcool/go-ollama-sdk"
|
||||||
|
|
||||||
|
"github.com/prdlk/gh-commit/internal/diff"
|
||||||
|
"github.com/prdlk/gh-commit/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generation profiles: small budget for one-line commit messages, larger
|
||||||
|
// budget and context for scope JSON over big file trees.
|
||||||
|
const (
|
||||||
|
commitNumPredict = 96
|
||||||
|
commitNumCtx = 8192
|
||||||
|
scopeNumPredict = 1024
|
||||||
|
scopeNumCtx = 16384
|
||||||
|
keepAlive = "10m"
|
||||||
|
temperature = 0.2
|
||||||
|
topP = 0.9
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is a speed-tuned Ollama client for one model.
|
||||||
|
type Client struct {
|
||||||
|
host string
|
||||||
|
model string
|
||||||
|
timeout time.Duration
|
||||||
|
http *http.Client
|
||||||
|
sdk *ollama.OllamaClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a client for host/model with a per-request timeout.
|
||||||
|
func New(host, model string, timeout time.Duration) *Client {
|
||||||
|
host = strings.TrimRight(host, "/")
|
||||||
|
return &Client{
|
||||||
|
host: host,
|
||||||
|
model: model,
|
||||||
|
timeout: timeout,
|
||||||
|
http: &http.Client{Timeout: timeout},
|
||||||
|
sdk: ollama.NewClient(host),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model returns the configured model tag.
|
||||||
|
func (c *Client) Model() string { return c.model }
|
||||||
|
|
||||||
|
type generateOptions struct {
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
TopP float64 `json:"top_p"`
|
||||||
|
NumPredict int `json:"num_predict"`
|
||||||
|
NumCtx int `json:"num_ctx"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type generateRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
Think bool `json:"think"`
|
||||||
|
KeepAlive string `json:"keep_alive"`
|
||||||
|
Options generateOptions `json:"options"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type generateResponse struct {
|
||||||
|
Response string `json:"response"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// generate POSTs a raw /api/generate request with the speed profile applied.
|
||||||
|
func (c *Client) generate(prompt string, numPredict, numCtx int) (string, error) {
|
||||||
|
body, err := json.Marshal(generateRequest{
|
||||||
|
Model: c.model,
|
||||||
|
Prompt: prompt,
|
||||||
|
Stream: false,
|
||||||
|
Think: false,
|
||||||
|
KeepAlive: keepAlive,
|
||||||
|
Options: generateOptions{
|
||||||
|
Temperature: temperature,
|
||||||
|
TopP: topP,
|
||||||
|
NumPredict: numPredict,
|
||||||
|
NumCtx: numCtx,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.host+"/api/generate", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ollama request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var out generateResponse
|
||||||
|
if err := json.Unmarshal(data, &out); err != nil {
|
||||||
|
return "", fmt.Errorf("ollama returned unexpected payload: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
if out.Error != "" {
|
||||||
|
return "", fmt.Errorf("ollama: %s", out.Error)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("ollama returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// think:false is sent, but strip any reasoning block as defense in depth.
|
||||||
|
return stripThink(out.Response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitMessage filters diff, prompts the model, and returns a cleaned
|
||||||
|
// commit message ("" when the model produced nothing usable).
|
||||||
|
func (c *Client) CommitMessage(rawDiff, scope string) (string, error) {
|
||||||
|
prompt := buildCommitPrompt(diff.Filter(rawDiff), scope)
|
||||||
|
out, err := c.generate(prompt, commitNumPredict, commitNumCtx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return CleanCommitMessage(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScopesRaw prompts the model for a scope mapping and returns the raw
|
||||||
|
// response text (callers parse it so they can show diagnostics on failure).
|
||||||
|
func (c *Client) ScopesRaw(filetree string, existing map[string][]string) (string, error) {
|
||||||
|
return c.generate(buildScopePrompt(filetree, existing), scopeNumPredict, scopeNumCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
type tagsResponse struct {
|
||||||
|
Models []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"models"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureReady verifies the Ollama server is answering and the model is
|
||||||
|
// available locally, offering to pull it when missing.
|
||||||
|
func (c *Client) EnsureReady() error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.host+"/api/tags", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("the Ollama server is not reachable at %s — start it with 'ollama serve'", c.host)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
var tags tagsResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&tags); err != nil {
|
||||||
|
return fmt.Errorf("the Ollama server at %s gave an unexpected response — start it with 'ollama serve'", c.host)
|
||||||
|
}
|
||||||
|
for _, m := range tags.Models {
|
||||||
|
if m.Name == c.model || (!strings.Contains(c.model, ":") && m.Name == c.model+":latest") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.Warnf("Model %s is not available locally", c.model)
|
||||||
|
if !ui.Confirm(fmt.Sprintf("Pull %s now?", c.model)) {
|
||||||
|
return fmt.Errorf("model %s is not available — run 'ollama pull %s'", c.model, c.model)
|
||||||
|
}
|
||||||
|
last := ""
|
||||||
|
if err := c.sdk.PullModel(c.model, func(status string) {
|
||||||
|
if status != "" && status != last {
|
||||||
|
ui.Dimf(" %s", status)
|
||||||
|
last = status
|
||||||
|
}
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("pulling %s: %w", c.model, err)
|
||||||
|
}
|
||||||
|
ui.Successf("✓ Pulled %s", c.model)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
thinkRe = regexp.MustCompile(`(?s)<think>.*?</think>`)
|
||||||
|
fenceOpenRe = regexp.MustCompile("^```[a-zA-Z]*\n")
|
||||||
|
fenceCloseRe = regexp.MustCompile("\n```$")
|
||||||
|
scopedMsgRe = regexp.MustCompile(`^\w+\([^)]+\): .+`)
|
||||||
|
plainMsgRe = regexp.MustCompile(`^\w+: .+`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// stripThink removes <think>...</think> reasoning blocks.
|
||||||
|
func stripThink(text string) string {
|
||||||
|
return strings.TrimSpace(thinkRe.ReplaceAllString(text, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractJSONObject returns the first balanced top-level {...} object in text,
|
||||||
|
// or "" when none is found.
|
||||||
|
func ExtractJSONObject(text string) string {
|
||||||
|
depth := 0
|
||||||
|
start := -1
|
||||||
|
for i := range len(text) {
|
||||||
|
switch text[i] {
|
||||||
|
case '{':
|
||||||
|
if start == -1 {
|
||||||
|
start = i
|
||||||
|
}
|
||||||
|
depth++
|
||||||
|
case '}':
|
||||||
|
if start != -1 {
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
return text[start : i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// CoerceScopeMap normalizes a decoded JSON/TOML mapping into scope -> paths.
|
||||||
|
// String values become single-element slices; empty paths are dropped.
|
||||||
|
// Returns nil when nothing usable remains.
|
||||||
|
func CoerceScopeMap(data map[string]any) map[string][]string {
|
||||||
|
scopes := map[string][]string{}
|
||||||
|
for name, paths := range data {
|
||||||
|
switch v := paths.(type) {
|
||||||
|
case string:
|
||||||
|
scopes[name] = []string{v}
|
||||||
|
case []any:
|
||||||
|
var out []string
|
||||||
|
for _, p := range v {
|
||||||
|
s := fmt.Sprint(p)
|
||||||
|
if strings.TrimSpace(s) != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scopes[name] = out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(scopes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return scopes
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseScopesResponse extracts and normalizes the scope mapping from a model
|
||||||
|
// response. Returns nil when no usable JSON object is present.
|
||||||
|
func ParseScopesResponse(text string) map[string][]string {
|
||||||
|
blob := ExtractJSONObject(text)
|
||||||
|
if blob == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(blob), &data); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return CoerceScopeMap(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanCommitMessage normalizes a raw model response into a commit message:
|
||||||
|
// trims, strips <think> blocks and code fences, prefers the first non-empty
|
||||||
|
// line when it matches conventional-commit shape, and otherwise falls back to
|
||||||
|
// the whole trimmed string (never blocks a commit on a regex).
|
||||||
|
func CleanCommitMessage(raw string) string {
|
||||||
|
msg := stripThink(strings.TrimSpace(raw))
|
||||||
|
msg = fenceOpenRe.ReplaceAllString(msg, "")
|
||||||
|
msg = fenceCloseRe.ReplaceAllString(msg, "")
|
||||||
|
msg = strings.TrimSpace(msg)
|
||||||
|
if msg == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(msg, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if scopedMsgRe.MatchString(line) || plainMsgRe.MatchString(line) {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
"<think>\nLet me reason about this diff...\n</think>\nfeat(ai): wire ollama",
|
||||||
|
"feat(ai): wire ollama",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rambling reduced to first valid line",
|
||||||
|
"feat(core): add parser\n\nThis commit introduces a parser.",
|
||||||
|
"feat(core): add parser",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"invalid message falls back to whole string",
|
||||||
|
"I could not produce a message",
|
||||||
|
"I could not produce a message",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"multiline invalid first line falls back to whole string",
|
||||||
|
"Here is the commit:\nnot conventional",
|
||||||
|
"Here is the commit:\nnot conventional",
|
||||||
|
},
|
||||||
|
{"empty input", "", ""},
|
||||||
|
{"only think block", "<think>hmm</think>", ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := CleanCommitMessage(tt.in); got != tt.want {
|
||||||
|
t.Errorf("got %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCommitPrompt(t *testing.T) {
|
||||||
|
p := buildCommitPrompt("DIFF_BODY", "core")
|
||||||
|
for _, want := range []string{"Use this scope: core", "DIFF_BODY", "Do not think. Answer immediately."} {
|
||||||
|
if !strings.Contains(p, want) {
|
||||||
|
t.Errorf("prompt missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p2 := buildCommitPrompt("DIFF_BODY", ""); strings.Contains(p2, "Use this scope") {
|
||||||
|
t.Error("scope hint should be absent without a scope")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildScopePrompt(t *testing.T) {
|
||||||
|
p := buildScopePrompt("a.go\nb.go", nil)
|
||||||
|
for _, want := range []string{"a.go\nb.go", `{"scope": ["path", ...], ...}`, "Do not think. Answer immediately."} {
|
||||||
|
if !strings.Contains(p, want) {
|
||||||
|
t.Errorf("new prompt missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(p, "Existing scopes") {
|
||||||
|
t.Error("new prompt must not mention existing scopes")
|
||||||
|
}
|
||||||
|
|
||||||
|
p = buildScopePrompt("a.go", map[string][]string{"core": {"src/"}})
|
||||||
|
for _, want := range []string{"Existing scopes (JSON):", `"core"`, `"src/"`} {
|
||||||
|
if !strings.Contains(p, want) {
|
||||||
|
t.Errorf("update prompt missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: <type>(<scope>): <description>
|
||||||
|
Examples:
|
||||||
|
- fix(core): resolve consensus timeout during high load
|
||||||
|
- feat(did): add WebAuthn biometric authentication support
|
||||||
|
- refactor(hway): extract Redis connection pooling to shared utility
|
||||||
|
- init(browser): setup client package for web API-specific logic
|
||||||
|
- perf(vault): optimize IPFS chunk size for large file uploads
|
||||||
|
- ci(actions): update GitHub Actions pipeline for parallel module testing
|
||||||
|
- docs(dwn): clarify data retention policies in API reference
|
||||||
|
- test(svc): add integration tests for domain verification flow
|
||||||
|
- breaking(ui): rename Button prop 'type' to 'variant'
|
||||||
|
- refactor(sdk): migrate off wallet implementation in favor of @sonr.io/enclave
|
||||||
|
- feat(react): create Enclave stateful hooks
|
||||||
|
Never explain anything. Return only the commit message.
|
||||||
|
Do not think. Answer immediately.
|
||||||
|
|
||||||
|
{scope_hint}### Git Diff
|
||||||
|
{diff}
|
||||||
|
`
|
||||||
|
|
||||||
|
const scopeInstructions = `You are a repository scope identifier for Conventional Commits.
|
||||||
|
You receive a repository file tree, one repo-relative path per line.
|
||||||
|
Map logical project areas to the repo-relative path prefixes (directories or files) they cover.
|
||||||
|
Scope names must read well in Conventional Commits, e.g. core, api, ui, cli, docs, tests, ci, config, scripts, deps.
|
||||||
|
Each scope maps to a JSON array of repo-relative path prefixes.
|
||||||
|
Group related paths together and cover the meaningful source areas.
|
||||||
|
Never create scopes for generated, vendored, or build-output paths.
|
||||||
|
If existing scopes are provided, keep the ones that still apply, drop scopes whose paths no longer exist, and add scopes for new areas.
|
||||||
|
Never explain anything or print your thoughts. Never include Markdown code blocks.
|
||||||
|
Only return a single JSON object of the form {"scope": ["path", ...], ...}.
|
||||||
|
Do not think. Answer immediately.
|
||||||
|
`
|
||||||
|
|
||||||
|
const scopePromptNew = scopeInstructions + `
|
||||||
|
Repository file tree (one repo-relative path per line):
|
||||||
|
|
||||||
|
{filetree}
|
||||||
|
`
|
||||||
|
|
||||||
|
const scopePromptUpdate = scopeInstructions + `
|
||||||
|
Existing scopes (JSON):
|
||||||
|
{existing}
|
||||||
|
|
||||||
|
The repository structure may have changed (e.g. its .gitignore was edited).
|
||||||
|
Updated repository file tree (one repo-relative path per line):
|
||||||
|
|
||||||
|
{filetree}
|
||||||
|
`
|
||||||
|
|
||||||
|
// buildCommitPrompt fills the commit prompt with an optional scope hint and
|
||||||
|
// the (already filtered) diff.
|
||||||
|
func buildCommitPrompt(filteredDiff, scope string) string {
|
||||||
|
hint := ""
|
||||||
|
if scope != "" {
|
||||||
|
hint = "Use this scope: " + scope + "\n"
|
||||||
|
}
|
||||||
|
return strings.NewReplacer("{scope_hint}", hint, "{diff}", filteredDiff).Replace(commitPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildScopePrompt fills the scope prompt; existing may be nil for first-time
|
||||||
|
// generation.
|
||||||
|
func buildScopePrompt(filetree string, existing map[string][]string) string {
|
||||||
|
if len(existing) == 0 {
|
||||||
|
return strings.Replace(scopePromptNew, "{filetree}", filetree, 1)
|
||||||
|
}
|
||||||
|
blob, _ := json.MarshalIndent(existing, "", " ")
|
||||||
|
return strings.NewReplacer(
|
||||||
|
"{existing}", string(blob),
|
||||||
|
"{filetree}", filetree,
|
||||||
|
).Replace(scopePromptUpdate)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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[:])
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
-975
@@ -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: <type>(<scope>): <description>
|
|
||||||
Examples:
|
|
||||||
- fix(core): resolve consensus timeout during high load
|
|
||||||
- feat(did): add WebAuthn biometric authentication support
|
|
||||||
- refactor(hway): extract Redis connection pooling to shared utility
|
|
||||||
- init(browser): setup client package for web API-specific logic
|
|
||||||
- perf(vault): optimize IPFS chunk size for large file uploads
|
|
||||||
- ci(actions): update GitHub Actions pipeline for parallel module testing
|
|
||||||
- docs(dwn): clarify data retention policies in API reference
|
|
||||||
- test(svc): add integration tests for domain verification flow
|
|
||||||
- breaking(ui): rename Button prop 'type' to 'variant'
|
|
||||||
- refactor(sdk): migrate off wallet implementation in favor of @sonr.io/enclave
|
|
||||||
- feat(react): create Enclave stateful hooks
|
|
||||||
Never explain anything. Return only the commit message.
|
|
||||||
|
|
||||||
{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()
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user