diff --git a/cmds/fastcommitcmd/ai.go b/cmds/fastcommitcmd/ai.go index 20c20f4..ba9f3e6 100644 --- a/cmds/fastcommitcmd/ai.go +++ b/cmds/fastcommitcmd/ai.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "os" - "strconv" "strings" "time" @@ -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")) } @@ -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 != "" { @@ -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) } @@ -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 { diff --git a/cmds/fastcommitcmd/cmd.go b/cmds/fastcommitcmd/cmd.go index bbc5470..a4e3d66 100644 --- a/cmds/fastcommitcmd/cmd.go +++ b/cmds/fastcommitcmd/cmd.go @@ -181,17 +181,22 @@ func New() *redant.Command { return app } -// getFirstNonPrefixCommit 获取第一个没有prefixMsg的提交ID +// getFirstNonPrefixCommit returns the first non-quick-update commit only when +// HEAD (and consecutive newer commits) are quick-update commits that should be squashed. +// Returns "" when there is nothing to squash (avoids a no-op `git reset --soft HEAD`). func getFirstNonPrefixCommit(ctx context.Context, prefixMsg string) string { - // 获取当前分支最近的提交列表,找到第一个不是prefixMsg开头的提交 branchName := utils.GetBranchName() - cmd := exec.CommandContext(ctx, "git", "log", branchName, "--oneline", "--pretty=format:%H %s", "-20") // 增加到20个提交以确保找到 + cmd := exec.CommandContext(ctx, "git", "log", branchName, "--oneline", "--pretty=format:%H %s", "-20") output, err := cmd.Output() if err != nil { return "" } + return findSquashBase(strings.Split(strings.TrimSpace(string(output)), "\n"), prefixMsg) +} - lines := strings.Split(strings.TrimSpace(string(output)), "\n") +// findSquashBase parses `git log --pretty=format:%H %s` lines and returns the squash base. +func findSquashBase(lines []string, prefixMsg string) string { + sawPrefix := false for _, line := range lines { line = strings.TrimSpace(line) if line == "" { @@ -206,13 +211,15 @@ func getFirstNonPrefixCommit(ctx context.Context, prefixMsg string) string { commitHash := parts[0] commitMsg := parts[1] - // 如果提交消息不以prefixMsg开头,返回这个提交的hash - if !strings.HasPrefix(commitMsg, prefixMsg) { + if strings.HasPrefix(commitMsg, prefixMsg) { + sawPrefix = true + continue + } + if sawPrefix { return commitHash } + return "" } - - // 如果所有提交都以prefixMsg开头,返回空字符串 return "" } @@ -358,6 +365,6 @@ func informUserToAmendAndPush() { fmt.Println(" git push --force-with-lease") fmt.Println("----------------------------------------") - fmt.Println("\nPress Enter after you're done...") + fmt.Println("\nPress Enter to continue (conflict helpers finished)...") _, _ = bufio.NewReader(os.Stdin).ReadBytes('\n') } diff --git a/cmds/fastcommitcmd/squash_test.go b/cmds/fastcommitcmd/squash_test.go new file mode 100644 index 0000000..fd94937 --- /dev/null +++ b/cmds/fastcommitcmd/squash_test.go @@ -0,0 +1,37 @@ +package fastcommitcmd + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFindSquashBase(t *testing.T) { + prefix := "chore: quick update main" + + t.Run("no quick updates returns empty", func(t *testing.T) { + got := findSquashBase([]string{ + "aaa feat: add feature", + "bbb fix: bug", + }, prefix) + require.Empty(t, got) + }) + + t.Run("squash onto first non-prefix", func(t *testing.T) { + got := findSquashBase([]string{ + "c1 chore: quick update main at 2026-01-01", + "c2 chore: quick update main at 2026-01-02", + "c3 feat: real work", + "c4 chore: older", + }, prefix) + require.Equal(t, "c3", got) + }) + + t.Run("all prefix returns empty", func(t *testing.T) { + got := findSquashBase([]string{ + "c1 chore: quick update main at a", + "c2 chore: quick update main at b", + }, prefix) + require.Empty(t, got) + }) +} diff --git a/cmds/pullcmd/cmd.go b/cmds/pullcmd/cmd.go index 5363735..dc0b254 100644 --- a/cmds/pullcmd/cmd.go +++ b/cmds/pullcmd/cmd.go @@ -121,6 +121,11 @@ func getUpstreamRef() (string, error) { } func hardSyncCurrentBranch(ctx context.Context, branch string) error { + if utils.IsDirty().Unwrap() { + log.Warn().Msg("working tree is dirty; --hard will discard local uncommitted changes") + fmt.Println("warning: working tree has uncommitted changes; --hard will discard them") + } + upstream := fmt.Sprintf("origin/%s", branch) if up, err := getUpstreamRef(); err == nil && up != "" { upstream = up @@ -230,6 +235,6 @@ func informUserToAmendAndPush() { fmt.Println(" git push --force-with-lease") fmt.Println("----------------------------------------") - fmt.Println("\nPress Enter after you're done...") + fmt.Println("\nPress Enter to continue (conflict helpers finished)...") _, _ = bufio.NewReader(os.Stdin).ReadBytes('\n') } diff --git a/docs/features.md b/docs/features.md index 6ca32c0..1b0ad1f 100644 --- a/docs/features.md +++ b/docs/features.md @@ -54,6 +54,11 @@ - 支持 `--amend`、`--fast`、`--candidates`、`--single`、`--skip-check`、`--skip-policy`、`--override-policy` - 默认三选一(`~/.config/fastgit/config.yaml` 中 `commit.candidates_default: true`;`.fastgit/commit.yaml` 可覆盖) - 提交前自动 `git add -A`(含新建未跟踪文件;仍尊重 `.gitignore`) +- AI 生成有超时(约 45s),超时后回退规则消息,避免 spinner 卡死 +- 大 diff 自动压缩后再送给 AI(跳过 lock/二进制,限制体积),避免请求爆掉 +- 仅在存在 `chore: quick update` 连续提交时才 soft-reset 合并;且先 check/AI,再 squash,避免失败后留下半成品状态 +- `git commit -m` 走直接 exec,避免 shell 引号把带 `'`/`"` 的 message 弄坏 +- `pull --hard` 在脏工作区会明确警告将丢弃未提交改动 - 提交前默认运行 `check run --staged-only`(可用 `--skip-check` 跳过) - `.fastgit/policy.yaml` 中 `enforce: true` 时,分支名/commit message 违规将阻断提交 - 读取 `.fastgit/commit.yaml`(locale、max_length、require_scope) diff --git a/pkg/aiprovider/candidates.go b/pkg/aiprovider/candidates.go index e96e748..2c8fe5d 100644 --- a/pkg/aiprovider/candidates.go +++ b/pkg/aiprovider/candidates.go @@ -2,6 +2,7 @@ package aiprovider import ( "context" + "errors" "fmt" "regexp" "strings" @@ -27,6 +28,7 @@ var candidateLinePattern = regexp.MustCompile(`^(SHORT|MEDIUM|CONVENTIONAL):\s*( // GenerateCommitCandidates asks the provider for 3 commit message options. func GenerateCommitCandidates(ctx context.Context, provider Provider, diff string) ([]CommitCandidate, error) { + diff, _ = CompactDiffForAI(diff) if provider == nil || !provider.Available() { return ruleCommitCandidates(diff), nil } @@ -36,6 +38,9 @@ func GenerateCommitCandidates(ctx context.Context, provider Provider, diff strin User: diff, }) if err != nil || strings.TrimSpace(resp.Text) == "" { + if err != nil && (ctx.Err() == context.DeadlineExceeded || errors.Is(err, context.DeadlineExceeded)) { + return ruleCommitCandidates(diff), fmt.Errorf("ai timed out: %w", err) + } return ruleCommitCandidates(diff), err } diff --git a/pkg/aiprovider/chain.go b/pkg/aiprovider/chain.go index eb78ef4..f317074 100644 --- a/pkg/aiprovider/chain.go +++ b/pkg/aiprovider/chain.go @@ -27,7 +27,7 @@ func (c *Chain) Available() bool { return true } } - return len(c.providers) > 0 + return false } func (c *Chain) Complete(ctx context.Context, req CompleteRequest) (CompleteResponse, error) { diff --git a/pkg/aiprovider/chain_test.go b/pkg/aiprovider/chain_test.go new file mode 100644 index 0000000..07827ff --- /dev/null +++ b/pkg/aiprovider/chain_test.go @@ -0,0 +1,16 @@ +package aiprovider + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestChainAvailableRequiresRealProvider(t *testing.T) { + unavailable := &OpenAIProvider{} + chain := NewChain(unavailable) + require.False(t, chain.Available()) + + chain = NewChain(unavailable, NewRuleFallback()) + require.True(t, chain.Available()) +} diff --git a/pkg/aiprovider/diff.go b/pkg/aiprovider/diff.go new file mode 100644 index 0000000..3174cbd --- /dev/null +++ b/pkg/aiprovider/diff.go @@ -0,0 +1,178 @@ +package aiprovider + +import ( + "fmt" + "path/filepath" + "strings" +) + +const ( + defaultMaxDiffChars = 24_000 + defaultMaxFileChars = 4_000 + defaultMaxFiles = 40 +) + +// DiffCompactStats describes how a diff was reduced for AI prompts. +type DiffCompactStats struct { + OriginalBytes int + CompactBytes int + FileCount int + KeptFiles int + SkippedFiles int + Truncated bool +} + +// CompactDiffForAI shrinks a git diff so commit/review prompts stay within model limits. +// It keeps a file list summary and truncated hunks, skipping lockfiles and binary patches. +func CompactDiffForAI(diff string) (string, DiffCompactStats) { + diff = strings.TrimSpace(diff) + stats := DiffCompactStats{OriginalBytes: len(diff)} + if diff == "" { + return "", stats + } + if len(diff) <= defaultMaxDiffChars && !looksLikeHugeSingleFile(diff) && !hasSkippableDiffPaths(diff) { + stats.CompactBytes = len(diff) + stats.FileCount = countDiffFiles(diff) + stats.KeptFiles = stats.FileCount + return diff, stats + } + + sections := splitDiffSections(diff) + stats.FileCount = len(sections) + + var kept []string + var omitted []string + var b strings.Builder + fmt.Fprintf(&b, "Staged changes summary (%d files). Diff abbreviated for AI.\n\n", len(sections)) + + for _, section := range sections { + path := diffSectionPath(section) + if path == "" { + path = "(unknown)" + } + if shouldSkipDiffPath(path) || isBinaryDiffSection(section) { + omitted = append(omitted, path+" (skipped)") + continue + } + if len(kept) >= defaultMaxFiles { + omitted = append(omitted, path) + continue + } + + body := section + if len(body) > defaultMaxFileChars { + body = body[:defaultMaxFileChars] + "\n... (file diff truncated)\n" + stats.Truncated = true + } + if b.Len()+len(body)+1 > defaultMaxDiffChars { + omitted = append(omitted, path) + stats.Truncated = true + continue + } + if b.Len() > 0 && !strings.HasSuffix(b.String(), "\n") { + b.WriteByte('\n') + } + b.WriteString(body) + if !strings.HasSuffix(body, "\n") { + b.WriteByte('\n') + } + kept = append(kept, path) + } + + stats.KeptFiles = len(kept) + stats.SkippedFiles = len(omitted) + if len(omitted) > 0 { + stats.Truncated = true + b.WriteString("\nOmitted files:\n") + limit := len(omitted) + if limit > 30 { + limit = 30 + } + for _, name := range omitted[:limit] { + b.WriteString("- ") + b.WriteString(name) + b.WriteByte('\n') + } + if len(omitted) > limit { + fmt.Fprintf(&b, "- ... and %d more\n", len(omitted)-limit) + } + } + + out := strings.TrimSpace(b.String()) + stats.CompactBytes = len(out) + if stats.CompactBytes < stats.OriginalBytes { + stats.Truncated = true + } + return out, stats +} + +func hasSkippableDiffPaths(diff string) bool { + for _, section := range splitDiffSections(diff) { + path := diffSectionPath(section) + if shouldSkipDiffPath(path) || isBinaryDiffSection(section) { + return true + } + } + return false +} + +func looksLikeHugeSingleFile(diff string) bool { + return countDiffFiles(diff) <= 1 && len(diff) > defaultMaxFileChars +} + +func countDiffFiles(diff string) int { + return strings.Count(diff, "diff --git ") +} + +func splitDiffSections(diff string) []string { + parts := strings.Split(diff, "diff --git ") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + out = append(out, "diff --git "+part) + } + return out +} + +func diffSectionPath(section string) string { + // diff --git a/path b/path + line, _, _ := strings.Cut(section, "\n") + fields := strings.Fields(line) + if len(fields) >= 4 { + path := strings.TrimPrefix(fields[3], "b/") + if path != "" { + return path + } + } + if len(fields) >= 3 { + return strings.TrimPrefix(fields[2], "a/") + } + return "" +} + +func isBinaryDiffSection(section string) bool { + lower := strings.ToLower(section) + return strings.Contains(lower, "binary files ") || + strings.Contains(lower, "git binary patch") +} + +func shouldSkipDiffPath(path string) bool { + base := strings.ToLower(filepath.Base(path)) + switch base { + case "go.sum", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "composer.lock", "poetry.lock", "cargo.lock": + return true + } + if strings.HasSuffix(base, ".min.js") || strings.HasSuffix(base, ".min.css") { + return true + } + if strings.HasSuffix(base, ".map") || strings.HasSuffix(base, ".wasm") { + return true + } + if strings.Contains(path, "node_modules/") || strings.Contains(path, "vendor/") { + return true + } + return false +} diff --git a/pkg/aiprovider/diff_test.go b/pkg/aiprovider/diff_test.go new file mode 100644 index 0000000..669dfa9 --- /dev/null +++ b/pkg/aiprovider/diff_test.go @@ -0,0 +1,43 @@ +package aiprovider + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCompactDiffForAISmallUnchanged(t *testing.T) { + diff := "diff --git a/a.go b/a.go\n+++ b/a.go\n+package a\n" + out, stats := CompactDiffForAI(diff) + require.Equal(t, strings.TrimSpace(diff), out) + require.False(t, stats.Truncated) +} + +func TestCompactDiffForAITruncatesLarge(t *testing.T) { + var b strings.Builder + for i := 0; i < 60; i++ { + name := fmt.Sprintf("pkg/file_%02d.go", i) + fmt.Fprintf(&b, "diff --git a/%s b/%s\n", name, name) + b.WriteString(strings.Repeat("+line content for testing truncation\n", 200)) + } + out, stats := CompactDiffForAI(b.String()) + require.True(t, stats.Truncated) + require.LessOrEqual(t, len(out), defaultMaxDiffChars+4000) + require.Contains(t, out, "abbreviated") + require.Greater(t, stats.SkippedFiles, 0) + require.LessOrEqual(t, stats.KeptFiles, defaultMaxFiles) +} + +func TestCompactDiffForAISkipsLockfiles(t *testing.T) { + diff := strings.Join([]string{ + "diff --git a/go.sum b/go.sum\n+++ b/go.sum\n+" + strings.Repeat("h1:abc\n", 100), + "diff --git a/main.go b/main.go\n+++ b/main.go\n+package main\n", + }, "\n") + out, stats := CompactDiffForAI(diff) + require.NotContains(t, out, strings.Repeat("h1:abc", 20)) + require.Contains(t, out, "main.go") + require.Contains(t, out, "go.sum (skipped)") + require.Equal(t, 1, stats.KeptFiles) +} diff --git a/utils/git.go b/utils/git.go index 092fac4..52f083a 100644 --- a/utils/git.go +++ b/utils/git.go @@ -714,6 +714,25 @@ func GitPull(ctx context.Context, args ...string) (r result.Error) { return r } +// GitCommit creates a commit with the given message without shell quoting. +func GitCommit(ctx context.Context, message string, extraArgs ...string) error { + message = strings.TrimSpace(message) + if message == "" { + return fmt.Errorf("commit message is empty") + } + args := append([]string{"commit"}, extraArgs...) + args = append(args, "-m", message) + cmd := exec.CommandContext(ctx, "git", args...) + out, err := cmd.CombinedOutput() + if len(out) > 0 { + log.Info().Msgf("shell result: \n%s\n", strings.TrimSpace(string(out))) + } + if err != nil { + return fmt.Errorf("git commit failed: %w\n%s", err, strings.TrimSpace(string(out))) + } + return nil +} + func GitBranchSetUpstream(ctx context.Context, branch string) (r result.Error) { ShellExecOutput(ctx, "git", "branch", "--set-upstream-to=origin/"+branch, branch).Throw(&r) return r diff --git a/utils/git_commit_test.go b/utils/git_commit_test.go new file mode 100644 index 0000000..9068227 --- /dev/null +++ b/utils/git_commit_test.go @@ -0,0 +1,45 @@ +package utils + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGitCommitRejectsEmptyMessage(t *testing.T) { + err := GitCommit(context.Background(), " ") + require.Error(t, err) +} + +func TestGitCommitCreatesCommit(t *testing.T) { + repo := t.TempDir() + run := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = repo + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + } + run("init") + run("config", "user.email", "test@example.com") + run("config", "user.name", "test") + require.NoError(t, os.WriteFile(filepath.Join(repo, "a.txt"), []byte("hello\n"), 0o644)) + run("add", "a.txt") + + cwd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(repo)) + t.Cleanup(func() { _ = os.Chdir(cwd) }) + + require.NoError(t, GitCommit(context.Background(), `feat: handle "quotes" and it's fine`)) + cmd := exec.Command("git", "log", "-1", "--pretty=%B") + cmd.Dir = repo + out, err := cmd.Output() + require.NoError(t, err) + require.Contains(t, string(out), `feat: handle "quotes" and it's fine`) +} diff --git a/utils/openai.go b/utils/openai.go index d1d6f06..849af52 100644 --- a/utils/openai.go +++ b/utils/openai.go @@ -1,6 +1,11 @@ package utils -import "github.com/sashabaranov/go-openai" +import ( + "net/http" + "time" + + "github.com/sashabaranov/go-openai" +) type OpenaiClient struct { Client *openai.Client @@ -13,9 +18,12 @@ type OpenaiConfig struct { Model string `yaml:"model"` } +const defaultOpenAITimeout = 45 * time.Second + func NewOpenaiClient(cfg *OpenaiConfig) *OpenaiClient { var openaiCfg = openai.DefaultConfig(cfg.ApiKey) openaiCfg.BaseURL = cfg.BaseURL + openaiCfg.HTTPClient = &http.Client{Timeout: defaultOpenAITimeout} return &OpenaiClient{ Client: openai.NewClientWithConfig(openaiCfg), Cfg: cfg,