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
289 changes: 168 additions & 121 deletions cmds/fastcommitcmd/ai.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"
"fmt"
"os"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -33,102 +32,72 @@ func runAICommit(ctx context.Context, flags *flagOptions) error {
res := utils.PreGitPush(ctx)
if res != "" {
if shouldPullDueToRemoteUpdate(res) {
err := gitPull()
if err != nil {
if gitconflict.HasConflicts(ctx, "") {
handleMergeConflict(ctx)
} else {
os.Exit(1)
}
} else {
informUserToAmendAndPush()
}
return handlePushRejected(ctx)
}
}

if flags.fastCommit {
isDirty := utils.IsDirty().Unwrap()
if !isDirty {
return nil
}

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))
return runFastCommit(ctx, flags)
}
return runNormalCommit(ctx, flags, params)
}

msg = strings.TrimSpace(tap.Text(ctx, tap.TextOptions{
Message: "git message(update or enter):",
InitialValue: msg,
DefaultValue: msg,
Placeholder: "update or enter",
}))
func runFastCommit(ctx context.Context, flags *flagOptions) error {
isDirty := utils.IsDirty().Unwrap()
if !isDirty {
return nil
}

if msg == "" {
return nil
}
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))

repoRoot := mustRepoRoot()
repoCfg, _ := repoconfig.Load(repoRoot)
if err := enforceRepoPolicy(repoCfg, currentBranch(), msg, flags.skipPolicy); err != nil {
return err
}
warnRepoPolicy(repoCfg, currentBranch(), msg)
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
}

assert.Must(utils.ShellExec(ctx, "git", "add", "-A"))
res := utils.ShellExecOutput(ctx, "git", "status").Unwrap()
repoRoot := mustRepoRoot()
repoCfg, _ := repoconfig.Load(repoRoot)
if err := enforceRepoPolicy(repoCfg, currentBranch(), msg, flags.skipPolicy); err != nil {
return err
}
warnRepoPolicy(repoCfg, currentBranch(), msg)

if err := runPreCommitCheck(ctx, mustRepoRoot(), flags.skipCheck); err != nil {
return err
}
assert.Must(utils.ShellExec(ctx, "git", "add", "-A"))
status := utils.ShellExecOutput(ctx, "git", "status").Unwrap()

if !flags.amend {
assert.Must(utils.ShellExec(ctx, "git", "commit", "-m", strconv.Quote(msg)))
} else {
if strings.Contains(preMsg, prefixMsg) && !strings.Contains(res, `(use "git commit" to conclude merge)`) {
assert.Must(utils.ShellExec(ctx, "git", "commit", "--amend", "--no-edit", "-m", strconv.Quote(msg)))
} else {
assert.Must(utils.ShellExec(ctx, "git", "commit", "-m", strconv.Quote(msg)))
}
}
if err := runPreCommitCheck(ctx, repoRoot, flags.skipCheck); err != nil {
return err
}

if err := ensurePushPolicy(mustRepoRoot(), utils.GetBranchName(), flags.overridePolicy); err != nil {
if flags.amend && strings.Contains(preMsg, prefixMsg) && !strings.Contains(status, `(use "git commit" to conclude merge)`) {
if err := utils.GitCommit(ctx, msg, "--amend"); err != nil {
return err
}
res = utils.GitPush(ctx, "--force-with-lease", "origin", utils.GetBranchName())
if shouldPullDueToRemoteUpdate(res) {
err := gitPull()
if err != nil {
if gitconflict.HasConflicts(ctx, "") {
handleMergeConflict(ctx)
} else {
os.Exit(1)
}
} else {
informUserToAmendAndPush()
}
} else {
if err := utils.GitCommit(ctx, msg); err != nil {
return err
}
return nil
}

prefixMsg := fmt.Sprintf("chore: quick update %s", utils.GetBranchName())
targetCommit := getFirstNonPrefixCommit(ctx, prefixMsg)

if targetCommit != "" {
assert.Must(utils.ShellExec(ctx, "git", "reset", "--soft", targetCommit))
} else {
commitsToSquash := getCommitsToSquash(ctx, prefixMsg)
if len(commitsToSquash) > 0 {
parentCommit := getParentCommit(ctx, commitsToSquash[0])
if parentCommit != "" {
assert.Must(utils.ShellExec(ctx, "git", "reset", "--soft", parentCommit))
} else {
assert.Must(utils.ShellExec(ctx, "git", "reset", "--soft", "HEAD~"+strconv.Itoa(len(commitsToSquash))))
}
}
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
}

// Stage tracked modifications/deletions and new untracked files (respects .gitignore).
// Previously used `git add --update`, which silently skipped new files.
func runNormalCommit(ctx context.Context, flags *flagOptions, params cmdParams) error {
// Stage first, check, then AI — soft-reset squash happens only after checks succeed.
if utils.IsDirty().Unwrap() {
assert.Must(utils.ShellExec(ctx, "git", "add", "-A"))
}
Expand Down Expand Up @@ -162,6 +131,8 @@ func runAICommit(ctx context.Context, flags *flagOptions) error {
s.Prefix = "generate git message: "
})
s.Start()
defer s.Stop()

locale := "en"
maxLength := 50
if repoCfg.Commit.Locale != "" {
Expand All @@ -175,15 +146,71 @@ func runAICommit(ctx context.Context, flags *flagOptions) error {
repoCfg.Commit.Types,
)

aiCtx, aiCancel := context.WithTimeout(ctx, 45*time.Second)
defer aiCancel()

aiDiff, compactStats := aiprovider.CompactDiffForAI(diffResult.Diff)
if compactStats.Truncated {
log.Warn().
Int("original_bytes", compactStats.OriginalBytes).
Int("compact_bytes", compactStats.CompactBytes).
Int("files", compactStats.FileCount).
Int("kept", compactStats.KeptFiles).
Int("skipped", compactStats.SkippedFiles).
Msg("diff too large for AI; sending abbreviated patch")
}

useCandidates := shouldUseCandidates(flags, repoCfg, params)
var msg string
msg, err := pickCommitMessage(ctx, aiCtx, params, flags, useCandidates, generatePrompt, aiDiff, diffResult.Diff, s)
if err != nil {
return err
}
if msg == "" {
return nil
}

if err := enforceRepoPolicy(repoCfg, currentBranch(), msg, flags.skipPolicy); err != nil {
return err
}
warnRepoPolicy(repoCfg, currentBranch(), msg)

if err := squashQuickUpdates(ctx); err != nil {
return err
}
if utils.IsDirty().Unwrap() {
assert.Must(utils.ShellExec(ctx, "git", "add", "-A"))
}

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())
if flags.showPrompt && !useCandidates {
fmt.Println("\n" + generatePrompt + "\n")
}
log.Info().Str("message", msg).Bool("candidates", useCandidates).Msg("commit message generated")
workflow.PrintRecommendations(os.Stdout, "commit")
return nil
}

func pickCommitMessage(
ctx, aiCtx context.Context,
params cmdParams,
flags *flagOptions,
useCandidates bool,
generatePrompt, aiDiff, fullDiff string,
s *spinner.Spinner,
) (string, error) {
if useCandidates {
candidates, err := aiprovider.GenerateCommitCandidates(ctx, params.AI, diffResult.Diff)
candidates, err := aiprovider.GenerateCommitCandidates(aiCtx, params.AI, aiDiff)
s.Stop()
if err != nil {
log.Err(err).Msg("failed to generate commit candidates")
log.Warn().Err(err).Msg("AI candidates failed or timed out; using rule-based options")
}
if hint := aiprovider.BreakingChangeHint(diffResult.Diff); hint != "" {
if hint := aiprovider.BreakingChangeHint(fullDiff); hint != "" {
log.Warn().Msg(hint)
fmt.Println(hint)
}
Expand All @@ -196,60 +223,80 @@ func runAICommit(ctx context.Context, flags *flagOptions) error {
})
}
if len(options) == 0 {
return nil
return "", nil
}
selected := tap.Select[string](ctx, tap.SelectOptions[string]{
Message: "Pick a commit message:",
Options: options,
})
msg = strings.TrimSpace(selected)
} else {
aiResp, err := params.AI.Complete(ctx, aiprovider.CompleteRequest{
System: generatePrompt,
User: diffResult.Diff,
})
s.Stop()
return strings.TrimSpace(selected), nil
}

if err != nil {
aiResp, err := params.AI.Complete(aiCtx, aiprovider.CompleteRequest{
System: generatePrompt,
User: aiDiff,
})
s.Stop()
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(aiCtx.Err(), context.DeadlineExceeded) {
log.Warn().Msg("AI timed out; falling back to rule-based commit message")
aiResp = aiprovider.CompleteResponse{
Text: aiprovider.CommitMessageFromDiff(fullDiff),
Provider: "rule-fallback",
Fallback: true,
}
} else {
log.Err(err).Msg("failed to generate commit message")
return errors.WrapCaller(err)
}

if aiResp.Fallback {
log.Warn().Str("provider", aiResp.Provider).Msg("using rule-based commit message fallback (AI unavailable)")
}
if hint := aiprovider.BreakingChangeHint(diffResult.Diff); hint != "" {
log.Warn().Msg(hint)
fmt.Println(hint)
return "", errors.WrapCaller(err)
}
}

msg = strings.TrimSpace(tap.Text(ctx, tap.TextOptions{
Message: "git message(update or enter):",
InitialValue: aiResp.Text,
DefaultValue: aiResp.Text,
Placeholder: "update or enter",
}))
if aiResp.Fallback {
log.Warn().Str("provider", aiResp.Provider).Msg("using rule-based commit message fallback (AI unavailable)")
}
if msg == "" {
return nil
if hint := aiprovider.BreakingChangeHint(fullDiff); hint != "" {
log.Warn().Msg(hint)
fmt.Println(hint)
}

if err := enforceRepoPolicy(repoCfg, currentBranch(), msg, flags.skipPolicy); err != nil {
return err
msg := strings.TrimSpace(tap.Text(ctx, tap.TextOptions{
Message: "git message(update or enter):",
InitialValue: aiResp.Text,
DefaultValue: aiResp.Text,
Placeholder: "update or enter",
}))
return msg, nil
}

func squashQuickUpdates(ctx context.Context) error {
prefixMsg := fmt.Sprintf("chore: quick update %s", utils.GetBranchName())
targetCommit := getFirstNonPrefixCommit(ctx, prefixMsg)
if targetCommit != "" {
return utils.ShellExec(ctx, "git", "reset", "--soft", targetCommit)
}
warnRepoPolicy(repoCfg, currentBranch(), msg)

assert.Must(utils.ShellExec(ctx, "git", "commit", "-m", strconv.Quote(msg)))
if err := ensurePushPolicy(repoRoot, utils.GetBranchName(), flags.overridePolicy); err != nil {
return err
commitsToSquash := getCommitsToSquash(ctx, prefixMsg)
if len(commitsToSquash) == 0 {
return nil
}
utils.GitPush(ctx, "--force-with-lease", "origin", utils.GetBranchName())
if flags.showPrompt && !useCandidates {
fmt.Println("\n" + generatePrompt + "\n")
parentCommit := getParentCommit(ctx, commitsToSquash[0])
if parentCommit != "" {
return utils.ShellExec(ctx, "git", "reset", "--soft", parentCommit)
}
log.Info().Str("message", msg).Bool("candidates", useCandidates).Msg("commit message generated")
workflow.PrintRecommendations(os.Stdout, "commit")
return nil
return utils.ShellExec(ctx, "git", "reset", "--soft", "HEAD~"+fmt.Sprint(len(commitsToSquash)))
}

func handlePushRejected(ctx context.Context) error {
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 and pull failed: %w", err)
}
informUserToAmendAndPush()
return fmt.Errorf("push rejected; pulled remote changes — amend and push again")
}

func mustRepoRoot() string {
Expand Down
Loading
Loading