@@ -48,6 +48,9 @@ command so it can try to run it on its own or ask you to run it.
go install git.secluded.site/formatted-commit@latest
```
+Check for upgrades with `formatted-commit upgrade`, then apply with
+`formatted-commit upgrade -a`.
+
Copy/paste this into wherever you tell your models how to write commits. For
Crush, that might be `~/.config/crush/CRUSH.md` or `./CRUSH.md` in a repo. For
[my Crush fork](https://git.secluded.site/crush) and Amp, that's
@@ -118,13 +121,10 @@ $ formatted-commit -h
USAGE
-
- formatted-commit [--flags]
-
+ formatted-commit [command] [--flags]
EXAMPLES
-
# With co-author
formatted-commit -t feat -m "do a thing" -T "Crush <crush@charm.land>"
@@ -142,20 +142,26 @@ $ formatted-commit -h
formatted-commit -t refactor -s "web/git-bug" -m "fancy shmancy" \
-b "Had to do a weird thing because..."
+ # Check for upgrades
+ formatted-commit upgrade
+
+ # Then apply
+ formatted-commit upgrade -a
COMMANDS
- help [command] Help about any command
+ help [command] Help about any command
+ upgrade [--flags] Check for and apply updates
FLAGS
- -a --amend Amend the previous commit (optional)
- -b --body Commit body (optional)
- -B --breaking Mark as breaking change (optional)
- -h --help Help for formatted-commit
- -m --message Commit message (required)
- -s --scope Commit scope (optional)
- -T --trailer Trailer in 'Sentence-case-key: value' format (optional, repeatable)
- -t --type Commit type (required)
- -v --version Version for formatted-commit
+ -a --amend Amend the previous commit (optional)
+ -b --body Commit body (optional)
+ -B --breaking Mark as breaking change (optional)
+ -h --help Help for formatted-commit
+ -m --message Commit message (required)
+ -s --scope Commit scope (optional)
+ -T --trailer Trailer in 'Sentence-case-key: value' format (optional, repeatable)
+ -t --type Commit type (required)
+ -v --version Version for formatted-commit
```
@@ -0,0 +1,224 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "runtime/debug"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/spinner"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/huh"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/spf13/cobra"
+ "golang.org/x/mod/semver"
+ "golang.org/x/term"
+)
+
+type moduleInfo struct {
+ Version string `json:"Version"`
+ Time string `json:"Time"`
+}
+
+var (
+ successStyle = lipgloss.NewStyle().Foreground(lipgloss.CompleteAdaptiveColor{
+ Light: lipgloss.CompleteColor{TrueColor: "#00AA00", ANSI256: "34", ANSI: "2"},
+ Dark: lipgloss.CompleteColor{TrueColor: "#00FF00", ANSI256: "46", ANSI: "10"},
+ }).Bold(true)
+ warnStyle = lipgloss.NewStyle().Foreground(lipgloss.CompleteAdaptiveColor{
+ Light: lipgloss.CompleteColor{TrueColor: "#AA5500", ANSI256: "166", ANSI: "3"},
+ Dark: lipgloss.CompleteColor{TrueColor: "#FFAA00", ANSI256: "214", ANSI: "11"},
+ }).Bold(true)
+ errorStyle = lipgloss.NewStyle().Foreground(lipgloss.CompleteAdaptiveColor{
+ Light: lipgloss.CompleteColor{TrueColor: "#AA0000", ANSI256: "124", ANSI: "1"},
+ Dark: lipgloss.CompleteColor{TrueColor: "#FF5555", ANSI256: "203", ANSI: "9"},
+ }).Bold(true)
+ chipStyle = lipgloss.NewStyle().Padding(0, 1).Bold(true).
+ Background(lipgloss.CompleteAdaptiveColor{
+ Light: lipgloss.CompleteColor{TrueColor: "#F2F2FF", ANSI256: "195", ANSI: "7"},
+ Dark: lipgloss.CompleteColor{TrueColor: "#5A4A8A", ANSI256: "98", ANSI: "5"},
+ })
+ check = "✓"
+ cross = "✗"
+ arrow = "→"
+)
+
+var upgradeCmd = &cobra.Command{
+ Use: "upgrade",
+ Short: "Check for and apply updates",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ apply, _ := cmd.Flags().GetBool("apply")
+ return runUpgrade(apply)
+ },
+}
+
+func init() {
+ upgradeCmd.Flags().BoolP("apply", "a", false, "apply the upgrade")
+ rootCmd.AddCommand(upgradeCmd)
+}
+
+func runUpgrade(apply bool) error {
+ info, ok := debug.ReadBuildInfo()
+ if !ok {
+ return fmt.Errorf("unable to read build info")
+ }
+
+ if !strings.HasPrefix(info.Main.Path, "git.secluded.site/") {
+ return fmt.Errorf("this binary was not installed from git.secluded.site, cannot upgrade")
+ }
+
+ currentVersion := info.Main.Version
+ isDevel := currentVersion == "" || currentVersion == "(devel)"
+
+ fmt.Println("Checking for updates…")
+
+ latestVersion, err := getLatestVersion(info.Main.Path)
+ if err != nil {
+ return fmt.Errorf("failed to check for updates: %w", err)
+ }
+
+ if !isDevel {
+ cmp := semver.Compare(currentVersion, latestVersion)
+ if cmp == 0 {
+ if apply {
+ return fmt.Errorf("already up-to-date")
+ }
+ fmt.Printf("%s %s\n", successStyle.Render(check), "Already up to date")
+ return nil
+ } else if cmp > 0 {
+ fmt.Printf("%s %s %s (remote %s)\n",
+ warnStyle.Render("!"),
+ "Local version is newer",
+ chipStyle.Render(currentVersion),
+ latestVersion)
+ return nil
+ }
+ }
+
+ if isDevel {
+ fmt.Println(warnStyle.Render("Dev build detected."))
+ fmt.Printf("Latest: %s\n", chipStyle.Render(latestVersion))
+ } else {
+ fmt.Printf("Update available: %s %s %s\n",
+ chipStyle.Render(currentVersion),
+ arrow,
+ chipStyle.Render(latestVersion))
+ }
+
+ if !apply {
+ fmt.Println("\nRun with --apply to upgrade")
+ return nil
+ }
+
+ var confirm bool
+ prompt := huh.NewConfirm().
+ Title(fmt.Sprintf("Upgrade to %s?", latestVersion)).
+ Value(&confirm)
+
+ if err := prompt.Run(); err != nil {
+ return err
+ }
+
+ if !confirm {
+ fmt.Println("Upgrade cancelled")
+ return nil
+ }
+
+ if err := performUpgrade(info.Main.Path, latestVersion); err != nil {
+ fmt.Printf("%s %s\n", errorStyle.Render(cross), "Upgrade failed")
+ return err
+ }
+
+ fmt.Printf("%s %s %s\n",
+ successStyle.Render(check),
+ "Upgraded to",
+ chipStyle.Render(latestVersion))
+ return nil
+}
+
+func getLatestVersion(modulePath string) (string, error) {
+ cmd := exec.Command("go", "list", "-m", "-json", modulePath+"@latest")
+ output, err := cmd.Output()
+ if err != nil {
+ return "", err
+ }
+
+ var info moduleInfo
+ if err := json.Unmarshal(output, &info); err != nil {
+ return "", err
+ }
+
+ return info.Version, nil
+}
+
+func performUpgrade(modulePath, version string) error {
+ if !term.IsTerminal(int(os.Stdout.Fd())) {
+ fmt.Println("Installing…")
+ return runGoInstall(modulePath)
+ }
+
+ m := spinnerModel{
+ spinner: spinner.New(spinner.WithSpinner(spinner.Dot)),
+ text: fmt.Sprintf("Installing %s…", chipStyle.Render(version)),
+ run: func() error { return runGoInstall(modulePath) },
+ }
+
+ if _, err := tea.NewProgram(m).Run(); err != nil {
+ return err
+ }
+ return m.err
+}
+
+func runGoInstall(modulePath string) error {
+ cmd := exec.Command("go", "install", modulePath+"@latest")
+ var stderr bytes.Buffer
+ cmd.Stdout = io.Discard
+ cmd.Stderr = &stderr
+
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("%w\n%s", err, strings.TrimSpace(stderr.String()))
+ }
+ return nil
+}
+
+type installMsg struct{ err error }
+
+type spinnerModel struct {
+ spinner spinner.Model
+ text string
+ err error
+ run func() error
+ done bool
+}
+
+func (m spinnerModel) Init() tea.Cmd {
+ return tea.Batch(m.spinner.Tick, func() tea.Msg {
+ return installMsg{err: m.run()}
+ })
+}
+
+func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case spinner.TickMsg:
+ var cmd tea.Cmd
+ m.spinner, cmd = m.spinner.Update(msg)
+ return m, cmd
+ case installMsg:
+ m.err = msg.err
+ m.done = true
+ return m, tea.Quit
+ default:
+ return m, nil
+ }
+}
+
+func (m spinnerModel) View() string {
+ if m.done {
+ return ""
+ }
+ return fmt.Sprintf("%s %s", m.spinner.View(), m.text)
+}