feat: rewrite gh-commit in Go with local Ollama backend

Replaces the Python/Crush/DuckDB implementation with a CGO-free Go binary:
cobra CLI, modernc.org/sqlite storage, huh/lipgloss UI, and speed-tuned raw
/api/generate calls (think:false, keep_alive, capped num_predict) against a
local qwen3.5:2b. Ships as a gh extension via cli/gh-extension-precompile.
This commit is contained in:
t
2026-08-15 19:10:16 -04:00
parent 735229b150
commit 9647c854c9
31 changed files with 2731 additions and 1085 deletions
+224
View File
@@ -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
}
+85
View File
@@ -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
View File
@@ -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)
}
+93
View File
@@ -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
View File
@@ -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)
}
+76
View File
@@ -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
View File
@@ -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")
}
+98
View File
@@ -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
View File
@@ -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)
}