diff --git a/args.go b/args.go deleted file mode 100644 index db7de32..0000000 --- a/args.go +++ /dev/null @@ -1,13 +0,0 @@ -package main - -func splitStringsInto2(args []string, fn func(string) bool) ([]string, []string) { - var a, b []string - for _, arg := range args { - if fn(arg) { - a = append(a, arg) - } else { - b = append(b, arg) - } - } - return a, b -} diff --git a/command.go b/command/command.go similarity index 68% rename from command.go rename to command/command.go index bef1834..287d33d 100644 --- a/command.go +++ b/command/command.go @@ -1,4 +1,4 @@ -package main +package command import ( "strings" @@ -10,7 +10,7 @@ type Command struct { Output string } -func newCommand(args []string) *Command { +func NewCommand(args []string) *Command { envs, commandArgs := splitArgsToEnvsAndCommand(args) return &Command{ Envs: envs, @@ -20,12 +20,13 @@ func newCommand(args []string) *Command { func splitArgsToEnvsAndCommand(args []string) ([]string, []string) { equalNotFound := false - return splitStringsInto2(args, func(arg string) bool { + var a, b []string + for _, arg := range args { if !equalNotFound && strings.Contains(arg, "=") { - return true + a = append(a, arg) } else { - equalNotFound = true - return false + b = append(b, arg) } - }) + } + return a, b } diff --git a/command_test.go b/command/command_test.go similarity index 97% rename from command_test.go rename to command/command_test.go index eb38c62..dc1b682 100644 --- a/command_test.go +++ b/command/command_test.go @@ -1,4 +1,4 @@ -package main +package command import ( "fmt" diff --git a/runner.go b/command/runner.go similarity index 75% rename from runner.go rename to command/runner.go index e040d34..a4b9404 100644 --- a/runner.go +++ b/command/runner.go @@ -1,4 +1,4 @@ -package main +package command type Runner interface { Run(c *Command) error diff --git a/standard_runner.go b/command/standard_runner.go similarity index 94% rename from standard_runner.go rename to command/standard_runner.go index 9b027e3..2b4bd38 100644 --- a/standard_runner.go +++ b/command/standard_runner.go @@ -1,4 +1,4 @@ -package main +package command import ( "bytes" @@ -13,7 +13,7 @@ type StandardRunner struct { var _ Runner = (*StandardRunner)(nil) -func newStandardRunner(debugLog bool) *StandardRunner { +func NewStandardRunner(debugLog bool) *StandardRunner { return &StandardRunner{ debugLog: debugLog, } diff --git a/tmux_runner.go b/command/tmux_runner.go similarity index 98% rename from tmux_runner.go rename to command/tmux_runner.go index 304c4bc..551ea81 100644 --- a/tmux_runner.go +++ b/command/tmux_runner.go @@ -1,4 +1,4 @@ -package main +package command import ( "bytes" @@ -25,7 +25,7 @@ type TmuxRunner struct { var _ Runner = (*TmuxRunner)(nil) -func newTmuxRunner(debugLog bool) *TmuxRunner { +func NewTmuxRunner(debugLog bool) *TmuxRunner { doneStringPrefix := "git-exec-done" return &TmuxRunner{ session: "git-exec-session", diff --git a/commit_message.go b/core/commit_message.go similarity index 91% rename from commit_message.go rename to core/commit_message.go index 1d683cf..1622aba 100644 --- a/commit_message.go +++ b/core/commit_message.go @@ -1,10 +1,12 @@ -package main +package core import ( "bytes" "fmt" "strings" "text/template" + + "github.com/akm/git-exec/command" ) type commitMessage struct { @@ -17,7 +19,7 @@ type commitMessage struct { Body string } -func newCommitMessage(command *Command, options *Options) *commitMessage { +func newCommitMessage(command *command.Command, options *Options) *commitMessage { argParts := make([]string, len(command.Args)) for i, arg := range command.Args { if strings.Contains(arg, " ") && !(strings.HasPrefix(arg, "'") && strings.HasSuffix(arg, "'")) { diff --git a/commit_message_test.go b/core/commit_message_test.go similarity index 96% rename from commit_message_test.go rename to core/commit_message_test.go index c35d34a..af90947 100644 --- a/commit_message_test.go +++ b/core/commit_message_test.go @@ -1,9 +1,11 @@ -package main +package core import ( "os" "testing" + "github.com/akm/git-exec/command" + "github.com/stretchr/testify/assert" ) @@ -87,7 +89,7 @@ func TestCommitMessage(t *testing.T) { getLocation, bkGetLocation = func() (string, error) { return ptn.location, nil }, getLocation defer func() { getLocation = bkGetLocation }() - command := &Command{Envs: ptn.envs, Args: ptn.args, Output: ptn.output} + command := &command.Command{Envs: ptn.envs, Args: ptn.args, Output: ptn.output} commitMsg := newCommitMessage(command, newOptions()) actual, err := commitMsg.Build() diff --git a/core/help.go b/core/help.go new file mode 100644 index 0000000..0893485 --- /dev/null +++ b/core/help.go @@ -0,0 +1,30 @@ +package core + +import ( + "fmt" + "strings" + + "github.com/akm/git-exec/opts" +) + +func Help() { + firstLine := `Usage: git-exec [options ...] [key=value ...] [args ...]` + examples := `Examples: +* Specify environment variables. + git exec FOO=fooooo make args1 args2 + +* Use shell to work with redirect operator. + git exec /bin/bash -c 'echo "foo" >> README.md' + +* Use interactive mode for command which requires input such as "npx sv create" for SvelteKit. + git exec -i npx sv create my-app +` + optionItems, envVarItems := opts.HelpItemsAndEnvVarMappings[Options](defaultOptions, optionTypes) + + options := "Options:\n" + strings.Join(optionItems, "\n") + envVars := "Environment variable mapping:\n" + strings.Join(envVarItems, "\n") + + // git-exec は よりも前に 複数のキーと値の組み合わせを指定可能で、 + // 以後は 複数の引数を指定可能です。 + fmt.Println(firstLine + "\n\n" + options + "\n\n" + envVars + "\n\n" + examples) +} diff --git a/location.go b/core/location.go similarity index 98% rename from location.go rename to core/location.go index 7ec9e78..e98ad61 100644 --- a/location.go +++ b/core/location.go @@ -1,4 +1,4 @@ -package main +package core import ( "os" diff --git a/core/options.go b/core/options.go new file mode 100644 index 0000000..acbb548 --- /dev/null +++ b/core/options.go @@ -0,0 +1,87 @@ +package core + +import ( + "github.com/akm/git-exec/git" + "github.com/akm/git-exec/opts" +) + +type Options struct { + Help bool + Version bool + Directory string + Emoji string + Prompt string + Template string + + git.GuardOptions + + DebugLog bool + Interactive bool +} + +var defaultOptions = &Options{ + Help: false, + Version: false, + Directory: "", + Emoji: "🤖", + Prompt: "$", + Template: `{{.Emoji}} [{{.Location}}] {{.Prompt}} {{.Command}}`, + DebugLog: false, + Interactive: false, +} + +var optionTypes = func() []*opts.Definition[Options] { + f := opts.NewFactory[Options]("GIT_EXEC_") + + return []*opts.Definition[Options]{ + f.String("-C", "--directory", "Specify the directory where the command is executed.", + func(o *Options) string { return o.Directory }, + func(o *Options, v string) { o.Directory = v }, + ).WithoutEnv(), + f.String("-e", "--emoji", "Specify the emoji used in commit message.", + func(o *Options) string { return o.Emoji }, + func(o *Options, v string) { o.Emoji = v }, + ), + f.String("-p", "--prompt", "Specify the prompt used in commit message.", + func(o *Options) string { return o.Prompt }, + func(o *Options, v string) { o.Prompt = v }, + ), + f.String("-t", "--template", "Specify the template to build commit message.", + func(o *Options) string { return o.Template }, + func(o *Options, v string) { o.Template = v }, + ), + + f.Bool("", "--skip-guard", "Skip the guard check for uncommitted changes and untracked files before executing command.", + func(o *Options) bool { return o.SkipGuard }, + func(o *Options) { o.SkipGuard = true }, + ), + f.Bool("", "--skip-guard-uncommitted-changes", "Skip the guard check for uncommitted changes before executing command.", + func(o *Options) bool { return o.SkipGuardUncommittedChanges }, + func(o *Options) { o.SkipGuardUncommittedChanges = true }, + ), + f.Bool("", "--skip-guard-untracked-files", "Skip the guard check for untracked files before executing command.", + func(o *Options) bool { return o.SkipGuardUntrackedFiles }, + func(o *Options) { o.SkipGuardUntrackedFiles = true }, + ), + + f.Bool("-D", "--debug-log", "Output debug log.", + func(o *Options) bool { return o.DebugLog }, + func(o *Options) { o.DebugLog = true }, + ), + f.Bool("-i", "--interactive", "Interactive mode for command which requires input. tmux is required to use.", + func(o *Options) bool { return o.Interactive }, + func(o *Options) { o.Interactive = true }, + ), + + f.Bool("-h", "--help", "Show this message.", nil, func(o *Options) { o.Help = true }).WithoutEnv(), + f.Bool("-v", "--version", "Show version.", nil, func(o *Options) { o.Version = true }).WithoutEnv(), + } +}() + +func newOptions() *Options { + return opts.NewOptions(optionTypes, defaultOptions) +} + +func ParseOptions(args []string) (*Options, []string, error) { + return opts.Parse(defaultOptions, optionTypes, args...) +} diff --git a/options_test.go b/core/options_test.go similarity index 98% rename from options_test.go rename to core/options_test.go index 1ca1d44..d71cd0f 100644 --- a/options_test.go +++ b/core/options_test.go @@ -1,4 +1,4 @@ -package main +package core import ( "fmt" @@ -189,7 +189,7 @@ func TestParseOptions(t *testing.T) { defer func() { os.Setenv(key, envBackup) }() } - options, commandArgs, err := parseOptions(ptn.args) + options, commandArgs, err := ParseOptions(ptn.args) assert.Equal(t, ptn.options, options) assert.Equal(t, ptn.commandArgs, commandArgs) if ptn.error == "" { diff --git a/core/run.go b/core/run.go new file mode 100644 index 0000000..2ea8cb5 --- /dev/null +++ b/core/run.go @@ -0,0 +1,91 @@ +package core + +import ( + "fmt" + "log/slog" + "os" + + "github.com/akm/git-exec/command" + "github.com/akm/git-exec/git" +) + +func Run(options *Options, commandArgs []string) error { + var guardMessage string + if guardResult, err := git.Guard(&options.GuardOptions); err != nil { + return err + } else if guardResult != nil { + if guardResult.Skipped { + guardMessage = guardResult.Format() + fmt.Fprintf(os.Stderr, "Guard skipped: %s\n", guardMessage) + } else { + return fmt.Errorf("Quit processing because %s", guardResult.Format()) + } + } + + cmd := command.NewCommand(commandArgs) + var runner command.Runner + if options.Interactive { + runner = command.NewTmuxRunner(options.DebugLog) + } else { + runner = command.NewStandardRunner(options.DebugLog) + } + + var commitMessage *commitMessage + if err := changeDir((options.Directory), func() error { + if err := runner.Run(cmd); err != nil { + slog.Error("Command execution failed", "error", err) + return fmt.Errorf("Command execution failed: %+v\n%s", err, cmd.Output) + } + commitMessage = newCommitMessage(cmd, options) + return nil + }); err != nil { + return err + } + + if err := git.Add(); err != nil { + return err + } + + if guardMessage != "" { + commitMessage.Body = guardMessage + "\n\n" + commitMessage.Body + } + + // 3. "git commit" を以下のオプションと標準力を指定して実行する。 + msg, err := commitMessage.Build() + if err != nil { + return fmt.Errorf("Failed to build commit message: %+v", err) + } + + if err := git.Commit(msg); err != nil { + return err + } + + return nil +} + +func changeDir(dir string, cb func() error) (rerr error) { + if dir == "" { + return cb() + } + var origDir string + if dir != "" { + { + var err error + origDir, err = os.Getwd() + if err != nil { + return fmt.Errorf("Failed to get current directory: %s", err.Error()) + } + } + if err := os.Chdir(dir); err != nil { + return fmt.Errorf("Failed to change directory: %s", err.Error()) + } + } + if origDir != "" { + defer func() { + if err := os.Chdir(origDir); err != nil { + rerr = fmt.Errorf("Failed to change directory: %s", err.Error()) + } + }() + } + return cb() +} diff --git a/add.go b/git/add.go similarity index 78% rename from add.go rename to git/add.go index fee2af6..6b3df27 100644 --- a/add.go +++ b/git/add.go @@ -1,16 +1,16 @@ -package main +package git import ( "fmt" "os/exec" ) -func add() error { - uncommittedChanges, err := uncommittedChanges() +func Add() error { + uncommittedChanges, err := UncommittedChanges() if err != nil { return fmt.Errorf("git diff failed: %+v", err) } - untrackedFiles, err := untrackedFiles() + untrackedFiles, err := UntrackedFiles() if err != nil { return fmt.Errorf("git ls-files failed: %+v", err) } diff --git a/commit.go b/git/commit.go similarity index 52% rename from commit.go rename to git/commit.go index 21116ce..5db742f 100644 --- a/commit.go +++ b/git/commit.go @@ -1,4 +1,4 @@ -package main +package git import ( "bytes" @@ -6,13 +6,7 @@ import ( "os/exec" ) -func commit(commitMessage *commitMessage) error { - // 3. "git commit" を以下のオプションと標準力を指定して実行する。 - msg, err := commitMessage.Build() - if err != nil { - return fmt.Errorf("Failed to build commit message: %+v", err) - } - +func Commit(msg string) error { // See https://tracpath.com/docs/git-commit/ commitCmd := exec.Command("git", "commit", "--file", "-") commitCmd.Stdin = bytes.NewBufferString(msg) diff --git a/diff.go b/git/diff.go similarity index 79% rename from diff.go rename to git/diff.go index e7cf36b..8865aad 100644 --- a/diff.go +++ b/git/diff.go @@ -1,11 +1,11 @@ -package main +package git import ( "bytes" "os/exec" ) -func uncommittedChanges() (string, error) { +func UncommittedChanges() (string, error) { output, err := exec.Command("git", "diff").CombinedOutput() if err != nil { return "", err @@ -13,7 +13,7 @@ func uncommittedChanges() (string, error) { return string(bytes.TrimSpace(output)), nil } -func untrackedFiles() (string, error) { +func UntrackedFiles() (string, error) { cmd := exec.Command("git", "ls-files", "--others", "--exclude-standard") output, err := cmd.Output() if err != nil { diff --git a/git/guard.go b/git/guard.go new file mode 100644 index 0000000..f66f6e6 --- /dev/null +++ b/git/guard.go @@ -0,0 +1,68 @@ +package git + +import ( + "fmt" + "strings" +) + +type GuardResult struct { + UncommittedChanges string + UntrackedFiles string + Skipped bool +} + +func (g *GuardResult) Message() string { + var r string + if len(g.UncommittedChanges) > 0 && len(g.UntrackedFiles) > 0 { + r = "There are uncommitted changes and untracked files" + } else if len(g.UntrackedFiles) > 0 { + r = "There are untracked files" + } else { + r = "There are uncommitted changes" + } + if g.Skipped { + r += " but guard was skipped by options" + } + return r +} + +func (g *GuardResult) Format() string { + parts := []string{g.Message()} + if len(g.UncommittedChanges) > 0 { + parts = append(parts, fmt.Sprintf("Uncommitted changes:\n%s", g.UncommittedChanges)) + } + if len(g.UntrackedFiles) > 0 { + parts = append(parts, fmt.Sprintf("Untracked files:\n%s", g.UntrackedFiles)) + } + return strings.Join(parts, "\n\n") +} + +type GuardOptions struct { + SkipGuard bool + SkipGuardUncommittedChanges bool + SkipGuardUntrackedFiles bool +} + +func Guard(opts *GuardOptions) (*GuardResult, error) { + diff, err := UncommittedChanges() + if err != nil { + return nil, err + } + + untrackedFiles, err := UntrackedFiles() + if err != nil { + return nil, err + } + + if len(diff) == 0 && len(untrackedFiles) == 0 { + return nil, nil + } + + return &GuardResult{ + UncommittedChanges: diff, + UntrackedFiles: untrackedFiles, + Skipped: opts.SkipGuard || + (opts.SkipGuardUncommittedChanges && len(diff) > 0) || + (opts.SkipGuardUntrackedFiles && len(untrackedFiles) > 0), + }, nil +} diff --git a/guard.go b/guard.go deleted file mode 100644 index c756b80..0000000 --- a/guard.go +++ /dev/null @@ -1,62 +0,0 @@ -package main - -import ( - "fmt" - "strings" -) - -type guardResult struct { - uncommittedChanges string - untrackedFiles string - skipped bool -} - -func (g *guardResult) Message() string { - var r string - if len(g.uncommittedChanges) > 0 && len(g.untrackedFiles) > 0 { - r = "There are uncommitted changes and untracked files" - } else if len(g.untrackedFiles) > 0 { - r = "There are untracked files" - } else { - r = "There are uncommitted changes" - } - if g.skipped { - r += " but guard was skipped by options" - } - return r -} - -func (g *guardResult) Format() string { - parts := []string{g.Message()} - if len(g.uncommittedChanges) > 0 { - parts = append(parts, fmt.Sprintf("Uncommitted changes:\n%s", g.uncommittedChanges)) - } - if len(g.untrackedFiles) > 0 { - parts = append(parts, fmt.Sprintf("Untracked files:\n%s", g.untrackedFiles)) - } - return strings.Join(parts, "\n\n") -} - -func guard(opts *Options) (*guardResult, error) { - diff, err := uncommittedChanges() - if err != nil { - return nil, err - } - - untrackedFiles, err := untrackedFiles() - if err != nil { - return nil, err - } - - if len(diff) == 0 && len(untrackedFiles) == 0 { - return nil, nil - } - - return &guardResult{ - uncommittedChanges: diff, - untrackedFiles: untrackedFiles, - skipped: opts.SkipGuard || - (opts.SkipGuardUncommittedChanges && len(diff) > 0) || - (opts.SkipGuardUntrackedFiles && len(untrackedFiles) > 0), - }, nil -} diff --git a/help.go b/help.go deleted file mode 100644 index 31b602a..0000000 --- a/help.go +++ /dev/null @@ -1,93 +0,0 @@ -package main - -import ( - "fmt" - "strings" -) - -func help() { - firstLine := `Usage: git-exec [options ...] [key=value ...] [args ...]` - examples := `Examples: -* Specify environment variables. - git exec FOO=fooooo make args1 args2 - -* Use shell to work with redirect operator. - git exec /bin/bash -c 'echo "foo" >> README.md' - -* Use interactive mode for command which requires input such as "npx sv create" for SvelteKit. - git exec -i npx sv create my-app -` - indent := " " - optionItems := make([]string, len(optionTypes)) - maxLongNameLength := 0 - for _, opt := range optionTypes { - if maxLongNameLength < len(opt.LongName) { - maxLongNameLength = len(opt.LongName) - } - } - - envVarItems := []string{} - longNameFormat := "%-" + fmt.Sprintf("%ds", maxLongNameLength) - for i, opt := range optionTypes { - var item string - if opt.ShortName == "" { - item = fmt.Sprintf("%s "+longNameFormat, indent, opt.LongName) - } else { - item = fmt.Sprintf("%s%s, "+longNameFormat, indent, opt.ShortName, opt.LongName) - } - item += " " + optionMessageMap[opt.LongName] - if defaultGetter, ok := defaultValueGetterMap[opt.LongName]; ok { - item += fmt.Sprintf(" (default: %s)", defaultGetter()) - } - - optionItems[i] = item - if !opt.WithoutEnv { - envVarItems = append(envVarItems, fmt.Sprintf(longNameFormat+" %s", opt.LongName, opt.envKey())) - } - } - options := "Options:\n" + strings.Join(optionItems, "\n") - envVars := "Environment variable mapping:\n" + strings.Join(envVarItems, "\n") - - // git-exec は よりも前に 複数のキーと値の組み合わせを指定可能で、 - // 以後は 複数の引数を指定可能です。 - fmt.Println(firstLine + "\n\n" + options + "\n\n" + envVars + "\n\n" + examples) -} - -var optionMessageMap = map[string]string{ - "--help": "Show this message.", - "--version": "Show version.", - "--directory": "Specify the directory where the command is executed.", - "--emoji": "Specify the emoji used in commit message.", - "--prompt": "Specify the prompt used in commit message.", - "--template": "Specify the template to build commit message.", - "--skip-guard": "Skip the guard check for uncommitted changes and untracked files before executing command.", - "--skip-guard-uncommitted-changes": "Skip the guard check for uncommitted changes before executing command.", - "--skip-guard-untracked-files": "Skip the guard check for untracked files before executing command.", - "--debug-log": "Output debug log.", - "--interactive": "Interactive mode for command which requires input. tmux is required to use.", -} - -var defaultValueGetterMap = func() map[string]func() string { - boolToString := func(b bool) string { - if b { - return "true" - } else { - return "false" - } - } - quote := func(s string) string { - return fmt.Sprintf("%q", s) - } - - o := defaultOptions - return map[string]func() string{ - "--directory": func() string { return quote(o.Directory) }, - "--emoji": func() string { return quote(o.Emoji) }, - "--prompt": func() string { return quote(o.Prompt) }, - "--template": func() string { return quote(o.Template) }, - // skip guard - "--skip-guard": func() string { return boolToString(o.SkipGuard) }, - "--skip-guard-uncommitted-changes": func() string { return boolToString(o.SkipGuardUncommittedChanges) }, - "--skip-guard-untracked-files": func() string { return boolToString(o.SkipGuardUntrackedFiles) }, - } -}() diff --git a/main.go b/main.go index 8f7ff9f..321778a 100644 --- a/main.go +++ b/main.go @@ -2,23 +2,24 @@ package main import ( "fmt" - "log/slog" "os" "path/filepath" + + "github.com/akm/git-exec/core" ) func main() { if len(os.Args) < 2 { - help() + core.Help() os.Exit(1) } - options, commandArgs, err := parseOptions(os.Args[1:]) + options, commandArgs, err := core.ParseOptions(os.Args[1:]) if err != nil { fmt.Fprintf(os.Stderr, "Failed to parse arguments: %s\n", err.Error()) } if options.Help { - help() + core.Help() os.Exit(0) } else if options.Version { if len(commandArgs) == 0 { @@ -29,83 +30,8 @@ func main() { } } - if err := process(options, commandArgs); err != nil { + if err := core.Run(options, commandArgs); err != nil { fmt.Fprintln(os.Stderr, err.Error()) os.Exit(1) } } - -func process(options *Options, commandArgs []string) error { - var guardMessage string - if guardResult, err := guard(options); err != nil { - return err - } else if guardResult != nil { - if guardResult.skipped { - guardMessage = guardResult.Format() - fmt.Fprintf(os.Stderr, "Guard skipped: %s\n", guardMessage) - } else { - return fmt.Errorf("Quit processing because %s", guardResult.Format()) - } - } - - command := newCommand(commandArgs) - var runner Runner - if options.Interactive { - runner = newTmuxRunner(options.DebugLog) - } else { - runner = newStandardRunner(options.DebugLog) - } - - var commitMessage *commitMessage - if err := changeDir((options.Directory), func() error { - if err := runner.Run(command); err != nil { - slog.Error("Command execution failed", "error", err) - return fmt.Errorf("Command execution failed: %+v\n%s", err, command.Output) - } - commitMessage = newCommitMessage(command, options) - return nil - }); err != nil { - return err - } - - if err := add(); err != nil { - return err - } - - if guardMessage != "" { - commitMessage.Body = guardMessage + "\n\n" + commitMessage.Body - } - - if err := commit(commitMessage); err != nil { - return err - } - - return nil -} - -func changeDir(dir string, cb func() error) (rerr error) { - if dir == "" { - return cb() - } - var origDir string - if dir != "" { - { - var err error - origDir, err = os.Getwd() - if err != nil { - return fmt.Errorf("Failed to get current directory: %s", err.Error()) - } - } - if err := os.Chdir(dir); err != nil { - return fmt.Errorf("Failed to change directory: %s", err.Error()) - } - } - if origDir != "" { - defer func() { - if err := os.Chdir(origDir); err != nil { - rerr = fmt.Errorf("Failed to change directory: %s", err.Error()) - } - }() - } - return cb() -} diff --git a/options.go b/options.go deleted file mode 100644 index 63b47ad..0000000 --- a/options.go +++ /dev/null @@ -1,152 +0,0 @@ -package main - -import ( - "fmt" - "os" - "strings" -) - -type Options struct { - Help bool - Version bool - Directory string - Emoji string - Prompt string - Template string - - SkipGuard bool - SkipGuardUncommittedChanges bool - SkipGuardUntrackedFiles bool - - DebugLog bool - Interactive bool -} - -func newOptions() *Options { - defaultOptionsCopy := *defaultOptions - r := &defaultOptionsCopy - for _, opt := range optionTypes { - if opt.WithoutEnv { - continue - } - if v := os.Getenv(opt.envKey()); v != "" { - opt.setValue(r, v) - } - } - return r -} - -type OptionType struct { - ShortName string - LongName string - HasValue bool - SetFunc func(*Options, string) - WithoutEnv bool -} - -func newOptionType(shortName, longName string, hasValue bool, setFunc func(*Options, string)) *OptionType { - return &OptionType{ - ShortName: shortName, - LongName: longName, - HasValue: hasValue, - SetFunc: setFunc, - } -} - -func (o *OptionType) setValue(opts *Options, value string) { - o.SetFunc(opts, value) -} - -const envKeyPrefix = "GIT_EXEC_" - -func (o *OptionType) envKey() string { - return envKeyPrefix + strings.ToUpper(strings.ReplaceAll(strings.TrimLeft(o.LongName, "-"), "-", "_")) -} - -func (o *OptionType) withoutEnv() *OptionType { - o.WithoutEnv = true - return o -} - -var defaultOptions = &Options{ - Help: false, - Version: false, - Directory: "", - Emoji: "🤖", - Prompt: "$", - Template: `{{.Emoji}} [{{.Location}}] {{.Prompt}} {{.Command}}`, - DebugLog: false, - Interactive: false, -} - -var ( - optDirectory = newOptionType("-C", "--directory", true, func(o *Options, v string) { o.Directory = v }).withoutEnv() - optEmoji = newOptionType("-e", "--emoji", true, func(o *Options, v string) { o.Emoji = v }) - optPrompt = newOptionType("-p", "--prompt", true, func(o *Options, v string) { o.Prompt = v }) - optTemplate = newOptionType("-t", "--template", true, func(o *Options, v string) { o.Template = v }) - - optSkipGuard = newOptionType("", "--skip-guard", false, func(o *Options, _ string) { o.SkipGuard = true }) - optSkipGuardUncommittedChanges = newOptionType("", "--skip-guard-uncommitted-changes", false, func(o *Options, _ string) { o.SkipGuardUncommittedChanges = true }) - optSkipGuardUntrackedFiles = newOptionType("", "--skip-guard-untracked-files", false, func(o *Options, _ string) { o.SkipGuardUntrackedFiles = true }) - - optDebugLog = newOptionType("-D", "--debug-log", false, func(o *Options, _ string) { o.DebugLog = true }) - optInteractive = newOptionType("-i", "--interactive", false, func(o *Options, _ string) { o.Interactive = true }) - - optHelp = newOptionType("-h", "--help", false, func(o *Options, _ string) { o.Help = true }).withoutEnv() - optVersion = newOptionType("-v", "--version", false, func(o *Options, _ string) { o.Version = true }).withoutEnv() -) - -var optionTypes = []*OptionType{ - optDirectory, - optEmoji, - optPrompt, - optTemplate, - optSkipGuard, - optSkipGuardUncommittedChanges, - optSkipGuardUntrackedFiles, - optDebugLog, - optInteractive, - optHelp, - optVersion, -} - -var optionKeyMap = func() map[string]*OptionType { - m := map[string]*OptionType{} - for _, opt := range optionTypes { - m[opt.ShortName] = opt - m[opt.LongName] = opt - } - return m -}() - -func parseOptions(args []string) (*Options, []string, error) { - options := newOptions() - commandArgs := []string{} - inOptions := true - var pendingOptionType *OptionType - for _, arg := range args { - if pendingOptionType != nil { - pendingOptionType.setValue(options, arg) - pendingOptionType = nil - continue - } - if inOptions && strings.HasPrefix(arg, "-") { - optionType, ok := optionKeyMap[arg] - if !ok { - return nil, nil, fmt.Errorf("Unknown option: %s", arg) - } - if optionType.HasValue { - pendingOptionType = optionType - } else { - optionType.setValue(options, "") - } - } else { - inOptions = false - commandArgs = append(commandArgs, arg) - } - } - if pendingOptionType != nil { - return nil, nil, fmt.Errorf("no value given for option %s", pendingOptionType.LongName) - } - return options, commandArgs, nil -} diff --git a/opts/definition.go b/opts/definition.go new file mode 100644 index 0000000..78cdb98 --- /dev/null +++ b/opts/definition.go @@ -0,0 +1,52 @@ +package opts + +import "strings" + +type Definition[T any] struct { + envKeyPrefix string + ShortName string + LongName string + HasValue bool + Setter func(*T, string) + getter func(*T) string + help string + withoutEnv bool +} + +func NewDefinition[T any](envKeyPrefix, shortName, longName string, hasValue bool, setFunc func(*T, string)) *Definition[T] { + return &Definition[T]{ + envKeyPrefix: envKeyPrefix, + ShortName: shortName, + LongName: longName, + HasValue: hasValue, + Setter: setFunc, + } +} + +func (o *Definition[_]) EnvKey() string { + return o.envKeyPrefix + strings.ToUpper(strings.ReplaceAll(strings.TrimLeft(o.LongName, "-"), "-", "_")) +} + +func (o *Definition[T]) WithoutEnv() *Definition[T] { + o.withoutEnv = true + return o +} +func (o *Definition[_]) GetWithoutEnv() bool { + return o.withoutEnv +} + +func (o *Definition[T]) Getter(getter func(*T) string) *Definition[T] { + o.getter = getter + return o +} +func (o *Definition[T]) GetGetter() func(*T) string { + return o.getter +} + +func (o *Definition[T]) Help(help string) *Definition[T] { + o.help = help + return o +} +func (o *Definition[T]) GetHelp() string { + return o.help +} diff --git a/opts/factory.go b/opts/factory.go new file mode 100644 index 0000000..c5c656e --- /dev/null +++ b/opts/factory.go @@ -0,0 +1,43 @@ +package opts + +import "fmt" + +type Factory[T any] struct { + envKeyPrefix string + + boolToString func(bool) string + quote func(string) string +} + +func NewFactory[T any](envKeyPrefix string) *Factory[T] { + return &Factory[T]{ + envKeyPrefix: envKeyPrefix, + boolToString: func(b bool) string { + if b { + return "true" + } else { + return "false" + } + }, + quote: func(s string) string { return fmt.Sprintf("%q", s) }, + } +} + +func (f *Factory[T]) Bool(shortName, longName string, help string, getter func(*T) bool, setter func(*T)) *Definition[T] { + var actualGetter func(o *T) string + if getter != nil { + actualGetter = func(o *T) string { return f.boolToString(getter(o)) } + } + return NewDefinition(f.envKeyPrefix, shortName, longName, false, + func(o *T, v string) { setter(o) }). + Getter(actualGetter).Help(help) +} + +func (f *Factory[T]) String(shortName, longName string, help string, getter func(*T) string, setter func(*T, string)) *Definition[T] { + var actualGetter func(o *T) string + if getter != nil { + actualGetter = func(o *T) string { return f.quote(getter(o)) } + } + return NewDefinition(f.envKeyPrefix, shortName, longName, true, setter). + Getter(actualGetter).Help(help) +} diff --git a/opts/help.go b/opts/help.go new file mode 100644 index 0000000..c217039 --- /dev/null +++ b/opts/help.go @@ -0,0 +1,36 @@ +package opts + +import "fmt" + +func HelpItemsAndEnvVarMappings[T any](defaultOptions *T, defs []*Definition[T]) ([]string, []string) { + optionItems := make([]string, len(defs)) + envVarItems := []string{} + + maxLongNameLength := 0 + for _, opt := range defs { + if maxLongNameLength < len(opt.LongName) { + maxLongNameLength = len(opt.LongName) + } + } + indent := " " + + longNameFormat := "%-" + fmt.Sprintf("%ds", maxLongNameLength) + for i, opt := range defs { + var item string + if opt.ShortName == "" { + item = fmt.Sprintf("%s "+longNameFormat, indent, opt.LongName) + } else { + item = fmt.Sprintf("%s%s, "+longNameFormat, indent, opt.ShortName, opt.LongName) + } + item += " " + opt.GetHelp() + if getter := opt.GetGetter(); getter != nil { + item += fmt.Sprintf(" (default: %s)", getter(defaultOptions)) + } + optionItems[i] = item + + if !opt.GetWithoutEnv() { + envVarItems = append(envVarItems, fmt.Sprintf(longNameFormat+" %s", opt.LongName, opt.EnvKey())) + } + } + return optionItems, envVarItems +} diff --git a/opts/parse.go b/opts/parse.go new file mode 100644 index 0000000..bad7b47 --- /dev/null +++ b/opts/parse.go @@ -0,0 +1,63 @@ +package opts + +import ( + "fmt" + "os" + "strings" +) + +func Parse[T any](defualtOptions *T, defs []*Definition[T], args ...string) (*T, []string, error) { + options := NewOptions(defs, defualtOptions) + commandArgs := []string{} + inOptions := true + optionKeyMap := buildKeyMap(defs) + var pendingOptionType *Definition[T] + for _, arg := range args { + if pendingOptionType != nil { + pendingOptionType.Setter(options, arg) + pendingOptionType = nil + continue + } + if inOptions && strings.HasPrefix(arg, "-") { + optionType, ok := optionKeyMap[arg] + if !ok { + return nil, nil, fmt.Errorf("Unknown option: %s", arg) + } + if optionType.HasValue { + pendingOptionType = optionType + } else { + optionType.Setter(options, "") + } + } else { + inOptions = false + commandArgs = append(commandArgs, arg) + } + } + if pendingOptionType != nil { + return nil, nil, fmt.Errorf("no value given for option %s", pendingOptionType.LongName) + } + return options, commandArgs, nil +} + +func NewOptions[T any](defs []*Definition[T], defaultOptions *T) *T { + copy := *defaultOptions + r := © + for _, opt := range defs { + if opt.GetWithoutEnv() { + continue + } + if v := os.Getenv(opt.EnvKey()); v != "" { + opt.Setter(r, v) + } + } + return r +} + +func buildKeyMap[T any](defs []*Definition[T]) map[string]*Definition[T] { + m := make(map[string]*Definition[T], len(defs)) + for _, def := range defs { + m[def.LongName] = def + m[def.ShortName] = def + } + return m +} diff --git a/version.go b/version.go index 627e791..f1b6067 100644 --- a/version.go +++ b/version.go @@ -2,7 +2,7 @@ package main import "fmt" -const Version = "0.1.1" +const Version = "0.1.2" func showVersion() { fmt.Println(Version)