From a581dcb859afef914f4e3203b781b3685d3e6f25 Mon Sep 17 00:00:00 2001 From: barry Date: Wed, 12 Aug 2026 21:38:31 +0800 Subject: [PATCH] fix(commit): make commit fully automatic end-to-end Default flow now stages, checks, generates a message, commits, and pushes without interactive prompts. Add progress output, lighter pre-commit checks, push timeout/retry, and optional --edit/--candidates flags. Co-authored-by: Cursor --- cmds/checkcmd/config.go | 13 +++ cmds/checkcmd/run_test.go | 9 ++ cmds/checkcmd/runner.go | 6 ++ cmds/fastcommitcmd/ai.go | 117 ++++++++++++++++--------- cmds/fastcommitcmd/check.go | 17 +++- cmds/fastcommitcmd/cmd.go | 24 +++-- configs/default.yaml | 2 +- pkg/aiprovider/candidates_pick.go | 20 +++++ pkg/aiprovider/candidates_pick_test.go | 23 +++++ pkg/repoconfig/config.go | 2 +- utils/util.go | 21 ++++- 11 files changed, 194 insertions(+), 60 deletions(-) create mode 100644 pkg/aiprovider/candidates_pick.go create mode 100644 pkg/aiprovider/candidates_pick_test.go diff --git a/cmds/checkcmd/config.go b/cmds/checkcmd/config.go index c565ea1..f0ef014 100644 --- a/cmds/checkcmd/config.go +++ b/cmds/checkcmd/config.go @@ -115,6 +115,19 @@ func InitConfigTemplate(repoRoot string) (string, error) { return path, nil } +// ForCommit returns a lighter pipeline for the commit flow: fmt + vet + lint + secrets. +// Full test suite remains available via `fastgit check run`. +func ForCommit(cfg Config) Config { + steps := make([]Step, 0, len(cfg.Steps)) + for _, step := range cfg.Steps { + if step.Name == "test" { + continue + } + steps = append(steps, step) + } + return Config{Steps: steps} +} + // LoadConfig loads `.fastgit/check.yaml` or returns defaults. func LoadConfig(repoRoot string) Config { cfg := DefaultConfig() diff --git a/cmds/checkcmd/run_test.go b/cmds/checkcmd/run_test.go index 94bd75b..c8451a0 100644 --- a/cmds/checkcmd/run_test.go +++ b/cmds/checkcmd/run_test.go @@ -41,6 +41,15 @@ func TestDefaultConfigHasExpectedSteps(t *testing.T) { require.Equal(t, []string{"fmt", "vet", "test", "lint", "secrets"}, names) } +func TestForCommitSkipsTestStep(t *testing.T) { + cfg := ForCommit(DefaultConfig()) + names := make([]string, 0, len(cfg.Steps)) + for _, step := range cfg.Steps { + names = append(names, step.Name) + } + require.Equal(t, []string{"fmt", "vet", "lint", "secrets"}, names) +} + func TestRunDryRunDoesNotFailOnOptionalMissing(t *testing.T) { repo := t.TempDir() initGitRepo(t, repo) diff --git a/cmds/checkcmd/runner.go b/cmds/checkcmd/runner.go index c204ab7..7613a6a 100644 --- a/cmds/checkcmd/runner.go +++ b/cmds/checkcmd/runner.go @@ -39,7 +39,13 @@ func Run(ctx context.Context, cfg Config, opts RunOptions) ([]StepResult, error) var results []StepResult for _, step := range cfg.Steps { + if !opts.DryRun { + fmt.Fprintf(os.Stderr, "check: %s...\n", step.Name) + } result := runStep(ctx, step, opts, stagedFiles) + if !opts.DryRun && result.Skipped { + fmt.Fprintf(os.Stderr, "check: %s skipped (%s)\n", step.Name, result.Reason) + } results = append(results, result) if result.Err != nil && !result.Skipped { return results, result.Err diff --git a/cmds/fastcommitcmd/ai.go b/cmds/fastcommitcmd/ai.go index ba9f3e6..e23aefd 100644 --- a/cmds/fastcommitcmd/ai.go +++ b/cmds/fastcommitcmd/ai.go @@ -29,13 +29,6 @@ func runAICommit(ctx context.Context, flags *flagOptions) error { utils.LogConfigAndBranch() - res := utils.PreGitPush(ctx) - if res != "" { - if shouldPullDueToRemoteUpdate(res) { - return handlePushRejected(ctx) - } - } - if flags.fastCommit { return runFastCommit(ctx, flags) } @@ -51,15 +44,16 @@ func runFastCommit(ctx context.Context, flags *flagOptions) error { preMsg := strings.TrimSpace(utils.ShellExecOutput(ctx, "git", "log", "-1", "--pretty=%B").Unwrap()) prefixMsg := fmt.Sprintf("chore: quick update %s", utils.GetBranchName()) msg := fmt.Sprintf("%s at %s", prefixMsg, time.Now().Format(time.DateTime)) - - msg = strings.TrimSpace(tap.Text(ctx, tap.TextOptions{ - Message: "git message(update or enter):", - InitialValue: msg, - DefaultValue: msg, - Placeholder: "update or enter", - })) - if msg == "" { - return nil + if flags.edit { + msg = strings.TrimSpace(tap.Text(ctx, tap.TextOptions{ + Message: "git message(update or enter):", + InitialValue: msg, + DefaultValue: msg, + Placeholder: "update or enter", + })) + if msg == "" { + return nil + } } repoRoot := mustRepoRoot() @@ -89,21 +83,20 @@ func runFastCommit(ctx context.Context, flags *flagOptions) error { if err := ensurePushPolicy(repoRoot, utils.GetBranchName(), flags.overridePolicy); err != nil { return err } - pushOut := utils.GitPush(ctx, "--force-with-lease", "origin", utils.GetBranchName()) - if shouldPullDueToRemoteUpdate(pushOut) { - return handlePushRejected(ctx) - } - return nil + fmt.Fprintln(os.Stderr, "→ pushing to remote...") + return finishPush(ctx) } func runNormalCommit(ctx context.Context, flags *flagOptions, params cmdParams) error { // Stage first, check, then AI — soft-reset squash happens only after checks succeed. + fmt.Fprintln(os.Stderr, "→ staging changes...") if utils.IsDirty().Unwrap() { assert.Must(utils.ShellExec(ctx, "git", "add", "-A")) } diffResult := utils.GetStagedDiff(ctx).Unwrap() if diffResult == nil || len(diffResult.Files) == 0 { + fmt.Fprintln(os.Stderr, "→ nothing to commit") return nil } @@ -130,6 +123,7 @@ func runNormalCommit(ctx context.Context, flags *flagOptions, params cmdParams) s := spinner.New(spinner.CharSets[35], 100*time.Millisecond, func(s *spinner.Spinner) { s.Prefix = "generate git message: " }) + fmt.Fprintln(os.Stderr, "→ generating commit message (timeout ~45s)...") s.Start() defer s.Stop() @@ -181,13 +175,18 @@ func runNormalCommit(ctx context.Context, flags *flagOptions, params cmdParams) assert.Must(utils.ShellExec(ctx, "git", "add", "-A")) } + fmt.Fprintln(os.Stderr, "→ committing...") if err := utils.GitCommit(ctx, msg); err != nil { return err } if err := ensurePushPolicy(repoRoot, utils.GetBranchName(), flags.overridePolicy); err != nil { return err } - utils.GitPush(ctx, "--force-with-lease", "origin", utils.GetBranchName()) + fmt.Fprintf(os.Stderr, "→ commit message: %s\n", msg) + fmt.Fprintln(os.Stderr, "→ pushing to remote...") + if err := finishPush(ctx); err != nil { + return err + } if flags.showPrompt && !useCandidates { fmt.Println("\n" + generatePrompt + "\n") } @@ -225,11 +224,25 @@ func pickCommitMessage( if len(options) == 0 { return "", nil } - selected := tap.Select[string](ctx, tap.SelectOptions[string]{ - Message: "Pick a commit message:", - Options: options, - }) - return strings.TrimSpace(selected), nil + if flags != nil && flags.candidates { + fmt.Fprintln(os.Stderr, "→ pick a commit message (↑/↓ to move, Enter to confirm):") + selected := tap.Select[string](ctx, tap.SelectOptions[string]{ + Message: "Pick a commit message:", + Options: options, + }) + return strings.TrimSpace(selected), nil + } + msg := aiprovider.AutoPickCandidate(candidates) + fmt.Fprintf(os.Stderr, "→ commit message: %s\n", msg) + if flags != nil && flags.edit { + msg = strings.TrimSpace(tap.Text(ctx, tap.TextOptions{ + Message: "git message(update or enter):", + InitialValue: msg, + DefaultValue: msg, + Placeholder: "update or enter", + })) + } + return msg, nil } aiResp, err := params.AI.Complete(aiCtx, aiprovider.CompleteRequest{ @@ -259,15 +272,31 @@ func pickCommitMessage( fmt.Println(hint) } - msg := strings.TrimSpace(tap.Text(ctx, tap.TextOptions{ - Message: "git message(update or enter):", - InitialValue: aiResp.Text, - DefaultValue: aiResp.Text, - Placeholder: "update or enter", - })) + msg := strings.TrimSpace(aiResp.Text) + fmt.Fprintf(os.Stderr, "→ commit message: %s\n", msg) + if flags != nil && flags.edit { + msg = strings.TrimSpace(tap.Text(ctx, tap.TextOptions{ + Message: "git message(update or enter):", + InitialValue: msg, + DefaultValue: msg, + Placeholder: "update or enter", + })) + } return msg, nil } +func finishPush(ctx context.Context) error { + pushOut := utils.GitPush(ctx, "--force-with-lease", "origin", utils.GetBranchName()) + if shouldPullDueToRemoteUpdate(pushOut) { + return handlePushRejected(ctx) + } + if strings.Contains(pushOut, "timed out") { + return fmt.Errorf("push failed: %s", pushOut) + } + fmt.Fprintln(os.Stderr, "→ done") + return nil +} + func squashQuickUpdates(ctx context.Context) error { prefixMsg := fmt.Sprintf("chore: quick update %s", utils.GetBranchName()) targetCommit := getFirstNonPrefixCommit(ctx, prefixMsg) @@ -287,16 +316,29 @@ func squashQuickUpdates(ctx context.Context) error { } func handlePushRejected(ctx context.Context) error { + fmt.Fprintln(os.Stderr, "→ remote changed, pulling...") err := gitPull() if err != nil { if gitconflict.HasConflicts(ctx, "") { handleMergeConflict(ctx) - return fmt.Errorf("push rejected; resolve conflicts then retry commit/push") + return fmt.Errorf("push rejected; resolve conflicts then retry commit") } return fmt.Errorf("push rejected and pull failed: %w", err) } - informUserToAmendAndPush() - return fmt.Errorf("push rejected; pulled remote changes — amend and push again") + if gitconflict.HasConflicts(ctx, "") { + handleMergeConflict(ctx) + return fmt.Errorf("push rejected; resolve conflicts then retry commit") + } + fmt.Fprintln(os.Stderr, "→ retrying push...") + pushOut := utils.GitPush(ctx, "--force-with-lease", "origin", utils.GetBranchName()) + if shouldPullDueToRemoteUpdate(pushOut) { + return fmt.Errorf("push still rejected after pull; resolve manually and push again") + } + if strings.Contains(pushOut, "timed out") { + return fmt.Errorf("push failed after pull: %s", pushOut) + } + fmt.Fprintln(os.Stderr, "→ done") + return nil } func mustRepoRoot() string { @@ -308,9 +350,6 @@ func mustRepoRoot() string { } func shouldUseCandidates(flags *flagOptions, repoCfg repoconfig.Bundle, params cmdParams) bool { - if flags != nil && flags.single { - return false - } if flags != nil && flags.candidates { return true } diff --git a/cmds/fastcommitcmd/check.go b/cmds/fastcommitcmd/check.go index 708fe73..2bf6577 100644 --- a/cmds/fastcommitcmd/check.go +++ b/cmds/fastcommitcmd/check.go @@ -3,23 +3,36 @@ package fastcommitcmd import ( "context" "fmt" + "os" + "time" "github.com/pubgo/fastgit/cmds/checkcmd" "github.com/pubgo/fastgit/pkg/repoconfig" ) +const preCommitCheckTimeout = 10 * time.Minute + func runPreCommitCheck(ctx context.Context, repoRoot string, skip bool) error { if skip { return nil } - cfg := checkcmd.LoadConfig(repoRoot) - _, err := checkcmd.Run(ctx, cfg, checkcmd.RunOptions{ + + fmt.Fprintln(os.Stderr, "→ running pre-commit check...") + checkCtx, cancel := context.WithTimeout(ctx, preCommitCheckTimeout) + defer cancel() + + cfg := checkcmd.ForCommit(checkcmd.LoadConfig(repoRoot)) + _, err := checkcmd.Run(checkCtx, cfg, checkcmd.RunOptions{ StagedOnly: true, RepoRoot: repoRoot, }) if err != nil { + if checkCtx.Err() == context.DeadlineExceeded { + return fmt.Errorf("pre-commit check timed out after %s\nhint: fix slow tests, or use --skip-check to bypass", preCommitCheckTimeout) + } return fmt.Errorf("pre-commit check failed: %w\nhint: fix issues, or use --skip-check to bypass", err) } + fmt.Fprintln(os.Stderr, "→ pre-commit check passed") return nil } diff --git a/cmds/fastcommitcmd/cmd.go b/cmds/fastcommitcmd/cmd.go index a4e3d66..7ed6c73 100644 --- a/cmds/fastcommitcmd/cmd.go +++ b/cmds/fastcommitcmd/cmd.go @@ -1,7 +1,6 @@ package fastcommitcmd import ( - "bufio" "context" "fmt" "os" @@ -23,7 +22,7 @@ type flagOptions struct { fastCommit bool amend bool candidates bool - single bool + edit bool skipCheck bool skipPolicy bool overridePolicy bool @@ -44,7 +43,7 @@ func New() *redant.Command { app := &redant.Command{ Use: "commit", - Short: "Intelligent generation of git commit message", + Short: "Stage, check, generate message, commit, and push", Children: []*redant.Command{ { Use: "ai", @@ -67,13 +66,13 @@ func New() *redant.Command { }, { Flag: "candidates", - Description: "Generate 3 commit message candidates to pick from.", + Description: "Interactively pick from 3 AI-generated commit messages.", Value: redant.BoolOf(&flags.candidates), }, { - Flag: "single", - Description: "Generate a single commit message (skip multi-candidate picker).", - Value: redant.BoolOf(&flags.single), + Flag: "edit", + Description: "Edit the generated commit message before committing.", + Value: redant.BoolOf(&flags.edit), }, { Flag: "skip-check", @@ -131,13 +130,13 @@ func New() *redant.Command { }, { Flag: "candidates", - Description: "Generate 3 commit message candidates to pick from.", + Description: "Interactively pick from 3 AI-generated commit messages.", Value: redant.BoolOf(&flags.candidates), }, { - Flag: "single", - Description: "Generate a single commit message (skip multi-candidate picker).", - Value: redant.BoolOf(&flags.single), + Flag: "edit", + Description: "Edit the generated commit message before committing.", + Value: redant.BoolOf(&flags.edit), }, { Flag: "skip-check", @@ -364,7 +363,4 @@ func informUserToAmendAndPush() { fmt.Println(" git commit --amend") fmt.Println(" git push --force-with-lease") fmt.Println("----------------------------------------") - - fmt.Println("\nPress Enter to continue (conflict helpers finished)...") - _, _ = bufio.NewReader(os.Stdin).ReadBytes('\n') } diff --git a/configs/default.yaml b/configs/default.yaml index a00fb2d..816aa05 100644 --- a/configs/default.yaml +++ b/configs/default.yaml @@ -6,7 +6,7 @@ openai: model: ${OPENAI_MODEL} commit: gen_version: ${FASTGIT_GEN_VERSION} - candidates_default: true + candidates_default: false copilot: permission_mode: ask diff --git a/pkg/aiprovider/candidates_pick.go b/pkg/aiprovider/candidates_pick.go new file mode 100644 index 0000000..414eef0 --- /dev/null +++ b/pkg/aiprovider/candidates_pick.go @@ -0,0 +1,20 @@ +package aiprovider + +import "strings" + +// AutoPickCandidate chooses the best commit message without user interaction. +func AutoPickCandidate(candidates []CommitCandidate) string { + for _, c := range candidates { + if strings.EqualFold(strings.TrimSpace(c.Style), "conventional") { + if msg := strings.TrimSpace(c.Message); msg != "" { + return msg + } + } + } + for _, c := range candidates { + if msg := strings.TrimSpace(c.Message); msg != "" { + return msg + } + } + return "" +} diff --git a/pkg/aiprovider/candidates_pick_test.go b/pkg/aiprovider/candidates_pick_test.go new file mode 100644 index 0000000..e00b344 --- /dev/null +++ b/pkg/aiprovider/candidates_pick_test.go @@ -0,0 +1,23 @@ +package aiprovider + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAutoPickCandidatePrefersConventional(t *testing.T) { + msg := AutoPickCandidate([]CommitCandidate{ + {Style: "short", Message: "short msg"}, + {Style: "conventional", Message: "feat: add thing"}, + {Style: "medium", Message: "medium message"}, + }) + require.Equal(t, "feat: add thing", msg) +} + +func TestAutoPickCandidateFallbackFirst(t *testing.T) { + msg := AutoPickCandidate([]CommitCandidate{ + {Style: "short", Message: "short msg"}, + }) + require.Equal(t, "short msg", msg) +} diff --git a/pkg/repoconfig/config.go b/pkg/repoconfig/config.go index ad9ddca..09e144e 100644 --- a/pkg/repoconfig/config.go +++ b/pkg/repoconfig/config.go @@ -250,7 +250,7 @@ sensitive_paths: const defaultCommitYAML = `locale: en max_length: 72 require_scope: false -candidates_default: true +candidates_default: false types: - feat - fix diff --git a/utils/util.go b/utils/util.go index 4fb80ba..db72176 100644 --- a/utils/util.go +++ b/utils/util.go @@ -168,18 +168,33 @@ func IsHelp() bool { return false } +const defaultGitPushTimeout = 2 * time.Minute + func GitPush(ctx context.Context, args ...string) string { + pushCtx, cancel := context.WithTimeout(ctx, defaultGitPushTimeout) + defer cancel() + now := time.Now() args = append([]string{"git", "push"}, args...) - output := result.Async(func() result.Result[string] { return ShellExecOutput(ctx, args...) }) + output := result.Async(func() result.Result[string] { return ShellExecOutput(pushCtx, args...) }) time.Sleep(time.Millisecond * 20) spin := spinner.New(spinner.CharSets[35], 100*time.Millisecond, func(s *spinner.Spinner) { s.Prefix = strings.Join(args, " ") + ":" }) spin.Start() - res := output.Await(ctx).Unwrap() + awaited := output.Await(pushCtx) spin.Stop() + + if awaited.IsErr() { + err := awaited.Err() + if errors.Is(err, context.DeadlineExceeded) || pushCtx.Err() == context.DeadlineExceeded { + return fmt.Sprintf("push timed out after %s", defaultGitPushTimeout) + } + return err.Error() + } + + res := awaited.Unwrap() if res != "" { log.Info().Str("dur", time.Since(now).String()).Msgf("shell result: \n%s\n", res) } @@ -201,7 +216,7 @@ func ShellExec(ctx context.Context, args ...string) (err error) { func ShellExecOutput(ctx context.Context, args ...string) (r result.Result[string]) { defer result.Recovery(&r, func(err error) error { if exitErr, ok := errors.AsA[exec.ExitError](err); ok && exitErr.String() == "signal: interrupt" { - os.Exit(1) + return fmt.Errorf("signal: interrupt") } return err