diff --git a/bootstrap/boot.go b/bootstrap/boot.go index dc2b8ad..4558ec5 100644 --- a/bootstrap/boot.go +++ b/bootstrap/boot.go @@ -1,145 +1,61 @@ package bootstrap import ( - "context" - _ "embed" - "fmt" "log/slog" "os" - "sort" - "github.com/adrg/xdg" _ "github.com/adrg/xdg" _ "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/x/term" "github.com/pubgo/dix" "github.com/pubgo/dix/dix_internal" + "github.com/pubgo/fastcommit/cmds/fastcommit" "github.com/pubgo/fastcommit/cmds/tagcmd" "github.com/pubgo/fastcommit/cmds/versioncmd" + "github.com/pubgo/fastcommit/configs" "github.com/pubgo/fastcommit/utils" "github.com/pubgo/funk/assert" "github.com/pubgo/funk/config" - "github.com/pubgo/funk/env" "github.com/pubgo/funk/pathutil" "github.com/pubgo/funk/recovery" - "github.com/pubgo/funk/running" - "github.com/pubgo/funk/version" + "github.com/pubgo/funk/typex" "github.com/rs/zerolog" - "github.com/sashabaranov/go-openai" _ "github.com/sashabaranov/go-openai" - "github.com/urfave/cli/v3" + "gopkg.in/yaml.v3" ) -var configPath = assert.Exit1(xdg.ConfigFile("fastcommit/config.yaml")) - -//go:embed default.yaml -var defaultConfig []byte - func Main() { defer recovery.Exit() - var branchName = string(assert.Exit1(utils.ShellOutput("git", "rev-parse", "--abbrev-ref", "HEAD"))) - slog.Info("config path", "path", configPath) - if pathutil.IsNotExist(configPath) { - assert.Must(os.WriteFile(configPath, defaultConfig, 0644)) - } + typex.DoBlock(func() { + if pathutil.IsNotExist(configPath) { + assert.Must(os.WriteFile(configPath, defaultConfig, 0644)) + return + } - config.SetConfigPath(configPath) + var cfg ConfigProvider + config.LoadFromPath(&cfg, configPath) + var defaultCfg ConfigProvider + assert.Exit(yaml.Unmarshal(defaultConfig, &defaultCfg)) + if cfg.Version == nil || cfg.Version.Name == "" || defaultCfg.Version.Name != cfg.Version.Name { + assert.Exit(os.WriteFile(configPath, defaultConfig, 0644)) + } + }) + + config.SetConfigPath(configPath) dix_internal.SetLogLevel(zerolog.InfoLevel) + var di = dix.New(dix.WithValuesNull()) di.Provide(versioncmd.New) - di.Provide(tagcmd.New) - di.Provide(config.Load[Config]) - di.Provide(utils.NewOpenaiClient) - - di.Inject(func(cmd []*cli.Command) { - app := &cli.Command{ - Name: "fastcommit", - Suggest: true, - UseShortOptionHandling: true, - ShellComplete: cli.DefaultAppComplete, - Usage: "Intelligent generation of git commit message", - Version: version.Version(), - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "config", - Aliases: []string{"c"}, - Usage: "config file path", - Value: config.GetConfigPath(), - Persistent: true, - Sources: cli.EnvVars(env.Key("fast_commit_config")), - Action: func(ctx context.Context, command *cli.Command, s string) error { - config.SetConfigPath(s) - return nil - }, - }, - &cli.BoolFlag{ - Name: "debug", - Usage: "enable debug mode", - Persistent: true, - Value: running.IsDebug, - Destination: &running.IsDebug, - Sources: cli.EnvVars(env.Key("debug"), env.Key("enable_debug")), - }, - }, - Commands: cmd, - Action: func(ctx context.Context, command *cli.Command) error { - if utils.IsHelp() { - return cli.ShowAppHelp(command) - } - - if !term.IsTerminal(os.Stdin.Fd()) { - return nil - } - - generatePrompt := utils.GeneratePrompt("en", 50, utils.ConventionalCommitType) - - repoPath := assert.Must1(utils.AssertGitRepo()) - slog.Info("Git repository root", "path", repoPath) - - assert.Exit(utils.Shell("git", "add", "--update").Run()) - - diff := assert.Must1(utils.GetStagedDiff(nil)) - - client := dix.Inject(di, new(struct { - *utils.OpenaiClient - })) - resp, err := client.Client.CreateChatCompletion( - context.Background(), - openai.ChatCompletionRequest{ - Model: client.Cfg.Model, - Messages: []openai.ChatCompletionMessage{ - { - Role: openai.ChatMessageRoleSystem, - Content: generatePrompt, - }, - { - Role: openai.ChatMessageRoleUser, - Content: diff["diff"].(string), - }, - }, - }, - ) - - if err != nil { - fmt.Printf("ChatCompletion error: %v\n", err) - } - - if len(resp.Choices) == 0 { - return nil - } - - msg := resp.Choices[0].Message.Content - assert.Must(utils.Shell("git", "commit", "-m", fmt.Sprintf("'%s'", msg)).Run()) - assert.Must(utils.Shell("git", "push", "origin", branchName).Run()) - - return nil - }, + di.Provide(func() *configs.Config { + return &configs.Config{ + BranchName: branchName, } - - sort.Sort(cli.FlagsByName(app.Flags)) - assert.Must(app.Run(utils.Context(), os.Args)) }) + di.Provide(tagcmd.New) + di.Provide(config.Load[ConfigProvider]) + di.Provide(utils.NewOpenaiClient) + di.Provide(fastcommit.New) + di.Inject(func(cmd *fastcommit.Command) { cmd.Run() }) } diff --git a/bootstrap/config.go b/bootstrap/config.go index d3f518c..c0986fa 100644 --- a/bootstrap/config.go +++ b/bootstrap/config.go @@ -1,7 +1,21 @@ package bootstrap -import "github.com/pubgo/fastcommit/utils" +import ( + _ "embed" -type Config struct { + "github.com/adrg/xdg" + "github.com/pubgo/fastcommit/configs" + "github.com/pubgo/fastcommit/utils" + "github.com/pubgo/funk/assert" +) + +type ConfigProvider struct { + Version *configs.Version `yaml:"version"` OpenaiConfig *utils.OpenaiConfig `yaml:"openai"` } + +var configPath = assert.Exit1(xdg.ConfigFile("fastcommit/config.yaml")) +var branchName = assert.Exit1(utils.RunOutput("git", "rev-parse", "--abbrev-ref", "HEAD")) + +//go:embed default.yaml +var defaultConfig []byte diff --git a/bootstrap/default.yaml b/bootstrap/default.yaml index c9586a6..8104ce8 100644 --- a/bootstrap/default.yaml +++ b/bootstrap/default.yaml @@ -1,3 +1,5 @@ +version: + name: "v0.0.1" openai: api_key: ${OPENAI_API_KEY} base_url: ${OPENAI_BASE_URL:-"https://api.deepseek.com/v1"} diff --git a/cmds/fastcommit/cmd.go b/cmds/fastcommit/cmd.go index 6e12604..5a681dc 100644 --- a/cmds/fastcommit/cmd.go +++ b/cmds/fastcommit/cmd.go @@ -1 +1,133 @@ package fastcommit + +import ( + "context" + "fmt" + "log/slog" + "os" + "sort" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/term" + "github.com/pubgo/dix" + "github.com/pubgo/fastcommit/configs" + "github.com/pubgo/fastcommit/utils" + "github.com/pubgo/funk/assert" + "github.com/pubgo/funk/env" + "github.com/pubgo/funk/errors" + "github.com/pubgo/funk/recovery" + "github.com/pubgo/funk/running" + "github.com/pubgo/funk/version" + "github.com/sashabaranov/go-openai" + "github.com/urfave/cli/v3" +) + +type Params struct { + Di *dix.Dix + Cmd []*cli.Command + Cfg *configs.Config + OpenaiClient *utils.OpenaiClient +} + +func New(params Params) *Command { + app := &cli.Command{ + Name: "fastcommit", + Suggest: true, + UseShortOptionHandling: true, + ShellComplete: cli.DefaultAppComplete, + Usage: "Intelligent generation of git commit message", + Version: version.Version(), + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "debug", + Usage: "enable debug mode", + Persistent: true, + Value: running.IsDebug, + Destination: &running.IsDebug, + Sources: cli.EnvVars(env.Key("debug"), env.Key("enable_debug")), + }, + }, + Commands: params.Cmd, + Action: func(ctx context.Context, command *cli.Command) error { + var cfg = params.Cfg + if utils.IsHelp() { + return cli.ShowAppHelp(command) + } + + if !term.IsTerminal(os.Stdin.Fd()) { + return nil + } + + generatePrompt := utils.GeneratePrompt("en", 50, utils.ConventionalCommitType) + + repoPath := assert.Must1(utils.AssertGitRepo()) + slog.Info("Git repository root", "path", repoPath) + + assert.Exit(utils.RunShell("git", "add", "--update")) + + diff := assert.Must1(utils.GetStagedDiff(nil)) + if diff == nil { + return nil + } + + if len(diff.Files) == 0 { + return nil + } + + slog.Info(utils.GetDetectedMessage(diff.Files)) + + resp, err := params.OpenaiClient.Client.CreateChatCompletion( + context.Background(), + openai.ChatCompletionRequest{ + Model: params.OpenaiClient.Cfg.Model, + Messages: []openai.ChatCompletionMessage{ + { + Role: openai.ChatMessageRoleSystem, + Content: generatePrompt, + }, + { + Role: openai.ChatMessageRoleUser, + Content: diff.Diff, + }, + }, + }, + ) + + if err != nil { + slog.Error("failed to call openai", "err", err) + return errors.WrapCaller(err) + } + + if len(resp.Choices) == 0 { + return nil + } + + msg := resp.Choices[0].Message.Content + fmt.Println(msg) + var p1 = tea.NewProgram(InitialTextInputModel(msg)) + mm := assert.Must1(p1.Run()).(model2) + if mm.isExit() { + return nil + } + + msg = mm.Value() + assert.Must(utils.RunShell("git", "commit", "-m", fmt.Sprintf("'%s'", msg))) + assert.Must(utils.RunShell("git", "push", "origin", cfg.BranchName)) + + return nil + }, + } + + sort.Sort(cli.FlagsByName(app.Flags)) + return &Command{cmd: app} +} + +type Command struct { + cmd *cli.Command +} + +func (c *Command) Run() { + defer recovery.Exit() + sort.Sort(cli.FlagsByName(c.cmd.Flags)) + assert.Exit(c.cmd.Run(utils.Context(), os.Args)) +} diff --git a/cmds/fastcommit/ui.go b/cmds/fastcommit/ui.go new file mode 100644 index 0000000..d95f57d --- /dev/null +++ b/cmds/fastcommit/ui.go @@ -0,0 +1,75 @@ +package fastcommit + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + semver "github.com/hashicorp/go-version" +) + +type model2 struct { + textInput textinput.Model + exit bool +} + +// sanitizeInput verifies that an input text string gets validated +func sanitizeInput(input string) error { + _, err := semver.NewSemver(input) + return err +} + +func InitialTextInputModel(data string) model2 { + ti := textinput.New() + ti.Focus() + ti.Prompt = "" + ti.CharLimit = len(data) + 20 + ti.Width = len(data) + 20 + ti.Validate = sanitizeInput + ti.SetValue(data) + + return model2{ + textInput: ti, + } +} + +// Init is called at the beginning of a textinput step +// and sets the cursor to blink +func (m model2) Init() tea.Cmd { + return textinput.Blink +} + +// Update is called when "things happen", it checks for the users text input, +// and for Ctrl+C or Esc to close the program. +func (m model2) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + return m, tea.Quit + case tea.KeyCtrlC, tea.KeyEsc: + m.exit = true + return m, tea.Quit + } + } + + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd +} + +// View is called to draw the textinput step +func (m model2) View() string { + return fmt.Sprintf( + "git message: %s\n", + m.textInput.View(), + ) +} + +func (m model2) Value() string { + return m.textInput.Value() +} +func (m model2) isExit() bool { + return m.exit +} diff --git a/cmds/tagcmd/cmd.go b/cmds/tagcmd/cmd.go index a86cfc9..8e3f196 100644 --- a/cmds/tagcmd/cmd.go +++ b/cmds/tagcmd/cmd.go @@ -2,8 +2,15 @@ package tagcmd import ( "context" + "fmt" + "log/slog" + "strings" + tea "github.com/charmbracelet/bubbletea" + semver "github.com/hashicorp/go-version" "github.com/pubgo/fastcommit/utils" + "github.com/pubgo/funk/assert" + "github.com/pubgo/funk/errors" "github.com/pubgo/funk/recovery" "github.com/urfave/cli/v3" ) @@ -13,8 +20,23 @@ func New() *cli.Command { Name: "tag", Action: func(ctx context.Context, command *cli.Command) error { defer recovery.Exit() - ver := utils.GetNextTag("alpha") - utils.GitTag(ver.Original()) + var p = tea.NewProgram(initialModel()) + m := assert.Must1(p.Run()).(model) + ver := utils.GetNextTag(m.selected) + if m.selected == "release" { + ver = ver.Core() + } + + tagName := "v" + strings.TrimPrefix(ver.Original(), "v") + var p1 = tea.NewProgram(InitialTextInputModel(tagName)) + m1 := assert.Must1(p1.Run()).(model2) + tagName = m1.Value() + _, err := semver.NewVersion(tagName) + if err != nil { + return errors.Format("tag name is not valid: %s", tagName) + } + slog.Info(fmt.Sprintf("selected tag: %s", tagName)) + utils.GitPushTag(tagName) return nil }, } diff --git a/cmds/tagcmd/ui.go b/cmds/tagcmd/ui.go new file mode 100644 index 0000000..23fd653 --- /dev/null +++ b/cmds/tagcmd/ui.go @@ -0,0 +1,172 @@ +package tagcmd + +import ( + "fmt" + "log/slog" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + semver "github.com/hashicorp/go-version" +) + +type model struct { + cursor int + choices []string + selected string + length int +} + +func initialModel() model { + choices := []string{"alpha", "beta", "release"} + return model{ + choices: choices, + length: len(choices), + } +} + +func (m model) Init() tea.Cmd { return nil } + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC: + return m, tea.Quit + case tea.KeyUp, tea.KeyLeft, tea.KeyDown, tea.KeyRight: + m.cursor++ + case tea.KeyEnter: + m.selected = m.choices[m.cursor%m.length] + return m, tea.Quit + default: + slog.Error("unknown key", "key", msg.String()) + return m, tea.Quit + } + } + + return m, nil +} + +func (m model) View() string { + s := "Please Select Pre Tag:\n" + + for i, choice := range m.choices { + cursor := " " + if m.cursor%m.length == i { + cursor = ">" + } + + s += fmt.Sprintf("%s %s\n", cursor, choice) + } + + return s +} + +type model1 struct { + spinner spinner.Model + quitting bool + err error +} + +func InitialModelNew() model1 { + s := spinner.New() + s.Spinner = spinner.Line + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + return model1{spinner: s} +} + +func (m model1) Init() tea.Cmd { + return m.spinner.Tick +} + +func (m model1) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "esc", "ctrl+c": + m.quitting = true + return m, tea.Quit + default: + return m, nil + } + + default: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } +} + +func (m model1) View() string { + + if m.err != nil { + return m.err.Error() + } + str := fmt.Sprintf("%s Preparing...", m.spinner.View()) + if m.quitting { + return str + "\n" + } + return str +} + +type model2 struct { + textInput textinput.Model +} + +// sanitizeInput verifies that an input text string gets validated +func sanitizeInput(input string) error { + _, err := semver.NewSemver(input) + return err +} + +func InitialTextInputModel(data string) model2 { + ti := textinput.New() + ti.Focus() + ti.Prompt = "" + ti.CharLimit = 156 + ti.Width = 20 + ti.Validate = sanitizeInput + ti.SetValue(data) + + return model2{ + textInput: ti, + } +} + +// Init is called at the beginning of a textinput step +// and sets the cursor to blink +func (m model2) Init() tea.Cmd { + return textinput.Blink +} + +// Update is called when "things happen", it checks for the users text input, +// and for Ctrl+C or Esc to close the program. +func (m model2) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + return m, tea.Quit + case tea.KeyCtrlC, tea.KeyEsc: + return m, tea.Quit + } + } + + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd +} + +// View is called to draw the textinput step +func (m model2) View() string { + return fmt.Sprintf( + "tag: %s\n", + m.textInput.View(), + ) +} + +func (m model2) Value() string { + return m.textInput.Value() +} diff --git a/configs/config.go b/configs/config.go new file mode 100644 index 0000000..6fea082 --- /dev/null +++ b/configs/config.go @@ -0,0 +1,9 @@ +package configs + +type Config struct { + BranchName string +} + +type Version struct { + Name string `yaml:"name"` +} diff --git a/go.mod b/go.mod index 982e9f5..017a89b 100644 --- a/go.mod +++ b/go.mod @@ -4,37 +4,42 @@ go 1.23 require ( github.com/adrg/xdg v0.5.3 + github.com/bitfield/script v0.24.0 + github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.2.4 + github.com/charmbracelet/lipgloss v1.0.0 github.com/charmbracelet/x/term v0.2.1 github.com/hashicorp/go-version v1.7.0 - github.com/pubgo/dix v0.3.19 + github.com/pubgo/dix v0.3.20-alpha.1 github.com/pubgo/funk v0.5.64-alpha.1 github.com/rs/zerolog v1.33.0 github.com/samber/lo v1.47.0 github.com/sashabaranov/go-openai v1.36.1 github.com/urfave/cli/v3 v3.0.0-alpha9.0.20240717192922-127cf54fac9f + gopkg.in/yaml.v3 v3.0.1 ) require ( dario.cat/mergo v1.0.0 // indirect github.com/a8m/envsubst v1.3.0 // indirect github.com/alecthomas/repr v0.4.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/lipgloss v1.0.0 // indirect github.com/charmbracelet/x/ansi v0.4.5 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/ettle/strcase v0.2.0 // indirect github.com/expr-lang/expr v1.16.9 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/golang/protobuf v1.5.4 // indirect + github.com/itchyny/gojq v0.12.13 // indirect + github.com/itchyny/timefmt-go v0.1.5 // indirect github.com/joho/godotenv v1.5.1 // indirect github.com/k0kubun/pp/v3 v3.2.0 // indirect - github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // 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.15 // indirect + github.com/mattn/go-runewidth v0.0.16 // 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.2 // indirect @@ -55,5 +60,5 @@ require ( google.golang.org/grpc v1.66.1 // indirect google.golang.org/protobuf v1.34.3-0.20240816073751-94ecbc261689 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + mvdan.cc/sh/v3 v3.7.0 // indirect ) diff --git a/go.sum b/go.sum index b627f35..8458688 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,14 @@ github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +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/bitfield/script v0.24.0 h1:ic0Tbx+2AgRtkGGIcUyr+Un60vu4WXvqFrCSumf+T7M= +github.com/bitfield/script v0.24.0/go.mod h1:fv+6x4OzVsRs6qAlc7wiGq8fq1b5orhtQdtW0dwjUHI= +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.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE= github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM= github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= @@ -17,7 +23,6 @@ github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoC github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= @@ -26,6 +31,8 @@ github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= github.com/expr-lang/expr v1.16.9 h1:WUAzmR0JNI9JCiF0/ewwHB1gmcGw5wW7nWt8gc6PpCI= github.com/expr-lang/expr v1.16.9/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx3GhA= +github.com/frankban/quicktest v1.14.5/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -35,6 +42,10 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/itchyny/gojq v0.12.13 h1:IxyYlHYIlspQHHTE0f3cJF0NKDMfajxViuhBLnHd/QU= +github.com/itchyny/gojq v0.12.13/go.mod h1:JzwzAqenfhrPUuwbmEz3nu3JQmFLlQTQMUcOdnu/Sf4= +github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE= +github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/k0kubun/pp/v3 v3.2.0 h1:h33hNTZ9nVFNP3u2Fsgz8JXiF5JINoZfFq4SvKJwNcs= @@ -56,8 +67,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE 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.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +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/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= @@ -69,8 +80,8 @@ github.com/phuslu/goid v1.0.0/go.mod h1:txc2fUIdrdnn+v9Vq+QpiPQ3dnrXEchjoVDgic+r github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/pubgo/dix v0.3.19 h1:a4RRmljw7ePUvc9uJKqXXs6JpYL8s3VqPP2NV2vxI9U= -github.com/pubgo/dix v0.3.19/go.mod h1:J0PyuMm7mW6ZrN13l9xz0rtKbzliREFyvb7fuaGYyVw= +github.com/pubgo/dix v0.3.20-alpha.1 h1:f+X5TDLEGm6gcamRzLhE1AkF2sv7iMDORs6wWFHuen4= +github.com/pubgo/dix v0.3.20-alpha.1/go.mod h1:J0PyuMm7mW6ZrN13l9xz0rtKbzliREFyvb7fuaGYyVw= github.com/pubgo/funk v0.5.64-alpha.1 h1:RPS5k4c7qcjN3VT42p7O9sJIL7Nv0gu4ZwQi5M4fIIY= github.com/pubgo/funk v0.5.64-alpha.1/go.mod h1:VMNDJsIFOSMUZ2PRfyIyUQkDpfnFnOYZelFa1r/dmgs= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -116,6 +127,8 @@ golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE= +golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg= google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 h1:hjSy6tcFQZ171igDaN5QHOw2n6vx40juYbC/x67CEhc= google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:qpvKtACPCQhAdu3PyQgV4l3LMXZEtft7y8QcarRsp9I= google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= @@ -129,3 +142,5 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +mvdan.cc/sh/v3 v3.7.0 h1:lSTjdP/1xsddtaKfGg7Myu7DnlHItd3/M2tomOcNNBg= +mvdan.cc/sh/v3 v3.7.0/go.mod h1:K2gwkaesF/D7av7Kxl0HbF5kGOd2ArupNTX3X44+8l8= diff --git a/main.go b/main.go index 2c129b6..60d4f2e 100644 --- a/main.go +++ b/main.go @@ -1,8 +1,6 @@ package main -import ( - "github.com/pubgo/fastcommit/bootstrap" -) +import "github.com/pubgo/fastcommit/bootstrap" func main() { bootstrap.Main() diff --git a/utils/git.go b/utils/git.go index 1761c91..e568dfc 100644 --- a/utils/git.go +++ b/utils/git.go @@ -2,9 +2,6 @@ package utils import ( "fmt" - "log/slog" - "os" - "os/exec" "strings" "github.com/pubgo/funk/assert" @@ -22,14 +19,12 @@ func (e *KnownError) Error() string { // AssertGitRepo 检查当前目录是否是 Git 仓库 func AssertGitRepo() (string, error) { - cmd := exec.Command("git", "rev-parse", "--show-toplevel") - output, err := cmd.Output() - + output, err := RunOutput("git", "rev-parse", "--show-toplevel") if err != nil { return "", &KnownError{Message: "The current directory must be a Git repository!"} } - return strings.TrimSpace(string(output)), nil + return strings.TrimSpace(output), nil } // ExcludeFromDiff 生成 Git 排除路径的格式 @@ -37,32 +32,35 @@ func ExcludeFromDiff(path string) string { return fmt.Sprintf(":(exclude)%s", path) } +type GetStagedDiffRsp struct { + Files []string `json:"files"` + Diff string `json:"diff"` +} + // GetStagedDiff 获取暂存区的差异 -func GetStagedDiff(excludeFiles []string) (map[string]interface{}, error) { - diffCached := []string{"diff", "--cached", "--diff-algorithm=minimal"} +func GetStagedDiff(excludeFiles []string) (*GetStagedDiffRsp, error) { + diffCached := []string{"git", "diff", "--cached", "--diff-algorithm=minimal"} // 获取暂存区文件的名称 - cmdFiles := exec.Command("git", append(diffCached, append([]string{"--name-only"}, excludeFiles...)...)...) - filesOutput, err := cmdFiles.Output() + filesOutput, err := RunOutput(append(diffCached, append([]string{"--name-only"}, excludeFiles...)...)...) if err != nil { return nil, errors.WrapCaller(err) } - files := strings.Split(strings.TrimSpace(string(filesOutput)), "\n") + files := strings.Split(strings.TrimSpace(filesOutput), "\n") if len(files) == 0 || files[0] == "" { - return nil, nil + return new(GetStagedDiffRsp), nil } // 获取暂存区的完整差异 - cmdDiff := exec.Command("git", append(diffCached, excludeFiles...)...) - diffOutput, err := cmdDiff.Output() + diffOutput, err := RunOutput(append(diffCached, excludeFiles...)...) if err != nil { - return nil, err + return nil, errors.WrapCaller(err) } - return map[string]interface{}{ - "files": files, - "diff": strings.TrimSpace(string(diffOutput)), + return &GetStagedDiffRsp{ + Files: files, + Diff: strings.TrimSpace(diffOutput), }, nil } @@ -73,27 +71,10 @@ func GetDetectedMessage(files []string) string { if fileCount > 1 { pluralSuffix = "s" } - return fmt.Sprintf("Detected %d staged file%s", fileCount, pluralSuffix) -} - -func Shell(args ...string) *exec.Cmd { - shell := strings.Join(args, " ") - slog.Info(shell) - cmd := exec.Command("/bin/sh", "-c", shell) - cmd.Env = os.Environ() - cmd.Stdout = os.Stdout - cmd.Stdin = os.Stdin - cmd.Stderr = os.Stderr - return cmd -} - -func ShellOutput(args ...string) ([]byte, error) { - cmd := Shell(args...) - cmd.Stdout = nil - return cmd.Output() + return fmt.Sprintf("Detected %d staged file%s\n%s", fileCount, pluralSuffix, strings.Join(files, "\n")) } -func GitTag(ver string) { - assert.Must(Shell("git", "tag", ver).Run()) - assert.Must(Shell("git", "push", "origin", ver).Run()) +func GitPushTag(ver string) { + assert.Exit(RunShell("git", "tag", ver)) + assert.Exit(RunShell("git", "push", "origin", ver)) } diff --git a/utils/util.go b/utils/util.go index 7c1981d..c16df6f 100644 --- a/utils/util.go +++ b/utils/util.go @@ -3,20 +3,23 @@ package utils import ( "context" "fmt" + "log/slog" "os" "os/signal" "strconv" "strings" "syscall" - "github.com/pubgo/funk/typex" + "github.com/bitfield/script" semver "github.com/hashicorp/go-version" "github.com/pubgo/funk/assert" + "github.com/pubgo/funk/errors" + "github.com/pubgo/funk/typex" "github.com/samber/lo" ) func GetGitTags() []*semver.Version { - var tagText = strings.TrimSpace(string(assert.Exit1(ShellOutput("git", "tag")))) + var tagText = strings.TrimSpace(assert.Exit1(RunOutput("git", "tag"))) var tags = strings.Split(tagText, "\n") var versions = make([]*semver.Version, 0, len(tags)) @@ -66,7 +69,13 @@ func GetGitMaxTag(tags []*semver.Version) *semver.Version { maxVer = tag } - return maxVer + ver := lo.MaxBy(tags, func(a *semver.Version, b *semver.Version) bool { return a.Compare(b) > 0 }) + if ver.Core().GreaterThan(maxVer) { + return ver.Core() + } + + var segments = maxVer.Segments() + return semver.Must(semver.NewVersion(fmt.Sprintf("v%d.%d.%d", segments[0], segments[1], segments[2]+1))) } func UsageDesc(format string, args ...interface{}) string { @@ -96,3 +105,22 @@ func IsHelp() bool { } return false } + +func RunShell(args ...string) error { + result, err := RunOutput(args...) + if err != nil { + return errors.WrapCaller(err) + } + result = strings.TrimSpace(result) + if result != "" { + slog.Info(result) + } + + return nil +} + +func RunOutput(args ...string) (string, error) { + var shell = strings.Join(args, " ") + slog.Info(shell) + return script.Exec(strings.Join(args, " ")).String() +}