Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions cmds/checkcmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
9 changes: 9 additions & 0 deletions cmds/checkcmd/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions cmds/checkcmd/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 78 additions & 39 deletions cmds/fastcommitcmd/ai.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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()
Expand Down Expand Up @@ -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
}

Expand All @@ -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()

Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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
}
Expand Down
17 changes: 15 additions & 2 deletions cmds/fastcommitcmd/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
24 changes: 10 additions & 14 deletions cmds/fastcommitcmd/cmd.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package fastcommitcmd

import (
"bufio"
"context"
"fmt"
"os"
Expand All @@ -23,7 +22,7 @@ type flagOptions struct {
fastCommit bool
amend bool
candidates bool
single bool
edit bool
skipCheck bool
skipPolicy bool
overridePolicy bool
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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')
}
2 changes: 1 addition & 1 deletion configs/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ openai:
model: ${OPENAI_MODEL}
commit:
gen_version: ${FASTGIT_GEN_VERSION}
candidates_default: true
candidates_default: false

copilot:
permission_mode: ask
Expand Down
20 changes: 20 additions & 0 deletions pkg/aiprovider/candidates_pick.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
Loading
Loading