From 9d474bb3dcdca9ac24a9dcf7084c99355f8a3b0f Mon Sep 17 00:00:00 2001 From: Odboy Date: Tue, 3 Nov 2026 23:32:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20git.squash=20?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E6=8F=90=E4=BA=A4=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/executor/builtin.go | 5 ++ internal/executor/gitsquash.go | 120 +++++++++++++++++++++++++++++ internal/registry/builtin.go | 91 ++++++++++++++++++++++ internal/registry/registry_test.go | 2 +- 4 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 internal/executor/gitsquash.go diff --git a/internal/executor/builtin.go b/internal/executor/builtin.go index 11ed816..cf8a317 100644 --- a/internal/executor/builtin.go +++ b/internal/executor/builtin.go @@ -61,6 +61,11 @@ var builtins = map[string]Builtin{ "git.retime": runGitRetime, "git.retime.preview": runGitRetimePreview, "git.time": runGitTime, + "git.squash": runGitSquash, + "git.undo": runGitUndo, + "git.clean": runGitClean, + "git.sync": runGitSync, + "git.discard": runGitDiscard, "java.run": runJavaRun, "java.run.preview": runJavaPreview, "log.last": runLogLast, diff --git a/internal/executor/gitsquash.go b/internal/executor/gitsquash.go new file mode 100644 index 0000000..893fc7b --- /dev/null +++ b/internal/executor/gitsquash.go @@ -0,0 +1,120 @@ +package executor + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" +) + +// gitSquashOptions are the parsed arguments of `kopsc git squash`. +type gitSquashOptions struct { + Count int + Message string +} + +// runGitSquash merges the last N commits into one. +func runGitSquash(ctx context.Context, req BuiltinRequest) error { + return runGitSquashWith(ctx, req, execGitRunner{}) +} + +func runGitSquashWith(ctx context.Context, req BuiltinRequest, runner gitRunner) error { + opts, confirmed, err := parseGitSquashArgs(req.Args) + if err != nil { + return err + } + confirmed = confirmed || req.Confirmed + + branch, err := currentBranch(ctx, runner) + if err != nil { + return err + } + + // Get the last N commits for preview + logOut, _, err := runner.Run(ctx, "log", "-n", strconv.Itoa(opts.Count), "--format=%h %s") + if err != nil { + return fmt.Errorf("获取提交历史失败: %w", err) + } + + commits := strings.Split(strings.TrimSpace(logOut), "\n") + if len(commits) < opts.Count { + return fmt.Errorf("提交历史不足 %d 个(当前只有 %d 个)", opts.Count, len(commits)) + } + + // Show preview + fmt.Fprintf(req.Stdout, "合并最近 %d 个提交为一个\n\n", opts.Count) + fmt.Fprintf(req.Stdout, "分支: %s\n\n", branch) + fmt.Fprintln(req.Stdout, "将被合并的提交:") + for i, c := range commits[:opts.Count] { + fmt.Fprintf(req.Stdout, " %d. %s\n", i+1, c) + } + + // Determine commit message + msg := opts.Message + if msg == "" { + // Use the first (oldest) commit's message + parts := strings.SplitN(commits[opts.Count-1], " ", 2) + if len(parts) == 2 { + msg = parts[1] + } + } + fmt.Fprintf(req.Stdout, "\n新提交消息: %s\n", msg) + + if !confirmed { + fmt.Fprintf(req.Stdout, "\n这是破坏性操作,会改变提交历史。加 --yes 确认执行。\n") + return &ExitError{Code: 2, Err: errors.New("需要确认")} + } + + // Execute squash: reset --soft HEAD~N then commit + if _, stderr, err := runner.Run(ctx, "reset", "--soft", fmt.Sprintf("HEAD~%d", opts.Count)); err != nil { + return fmt.Errorf("reset 失败: %w\n%s", err, stderr) + } + + if _, stderr, err := runner.Run(ctx, "commit", "-m", msg); err != nil { + return fmt.Errorf("提交失败: %w\n%s", err, stderr) + } + + fmt.Fprintf(req.Stdout, "\n✓ 已将 %d 个提交合并为一个\n", opts.Count) + return nil +} + +func parseGitSquashArgs(args []string) (gitSquashOptions, bool, error) { + var opts gitSquashOptions + var confirmed bool + var countStr string + + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--yes" || arg == "-y": + confirmed = true + case arg == "--message" || arg == "-m": + if i+1 >= len(args) { + return opts, false, fmt.Errorf("-m 缺少提交消息") + } + i++ + opts.Message = args[i] + case strings.HasPrefix(arg, "-"): + return opts, false, fmt.Errorf("未知选项 %q", arg) + default: + if countStr == "" { + countStr = arg + } else { + return opts, false, fmt.Errorf("多余的参数 %q", arg) + } + } + } + + if countStr == "" { + return opts, false, fmt.Errorf("缺少数量参数;用法: kopsc git squash <数量>") + } + + count, err := strconv.Atoi(countStr) + if err != nil || count < 2 { + return opts, false, fmt.Errorf("数量必须是大于 1 的整数") + } + opts.Count = count + + return opts, confirmed, nil +} diff --git a/internal/registry/builtin.go b/internal/registry/builtin.go index a29b71f..abf57b4 100644 --- a/internal/registry/builtin.go +++ b/internal/registry/builtin.go @@ -542,6 +542,87 @@ func Builtin() *Registry { }, }, }, + { + ID: "git.squash", + Group: "git", + Summary: "合并最近 N 个提交为一个", + Action: "将最近 N 个提交合并为一个新提交,清理提交历史。" + + "默认使用第一个提交的消息,可用 -m 指定新消息。" + + "这是破坏性操作,需要 --yes 确认", + Usage: "<数量> [-m 消息]", + MinArgs: 1, + RequiresYes: true, + Variants: []Variant{ + { + Match: Match{OS: "linux"}, + Builtin: "git.squash", + Note: "Linux 内置实现(调用本机 git)", + }, + { + Match: Match{OS: "darwin"}, + Builtin: "git.squash", + Note: "macOS 内置实现(调用本机 git)", + }, + { + Match: Match{OS: "windows"}, + Builtin: "git.squash", + Note: "Windows 内置实现(调用本机 git)", + }, + }, + }, + { + ID: "git.undo", + Group: "git", + Summary: "撤销最后一次提交", + Action: "撤销最后一次提交。默认保留改动到暂存区(--soft)," + + "加 --hard 完全丢弃改动。需要 --yes 确认", + Usage: "[--hard]", + Variants: threePlatformGitVariants("git.undo"), + }, + { + ID: "git.clean", + Group: "git", + Summary: "清理未跟踪的文件和目录", + Action: "删除工作区中未跟踪的文件和目录。" + + "默认先预览,加 --yes 确认删除,--dry-run 只看不删", + Usage: "[--dry-run]", + Variants: threePlatformGitVariants("git.clean"), + }, + { + ID: "git.sync", + Group: "git", + Summary: "同步远程仓库", + Action: "同步当前分支到远程。自动识别远程分支(main/master)," + + "执行 fetch 后 rebase 或 fast-forward", + Usage: "", + Variants: threePlatformGitVariants("git.sync"), + }, + { + ID: "git.discard", + Group: "git", + Summary: "放弃本地所有改动,以远程为主", + Action: "丢弃所有本地改动(包括未提交的、新增的),重置到远程分支。" + + "自动识别远程分支(main/master)。这是破坏性操作,需要 --yes 确认", + Usage: "", + RequiresYes: true, + Variants: []Variant{ + { + Match: Match{OS: "linux"}, + Builtin: "git.discard", + Note: "Linux 内置实现(调用本机 git)", + }, + { + Match: Match{OS: "darwin"}, + Builtin: "git.discard", + Note: "macOS 内置实现(调用本机 git)", + }, + { + Match: Match{OS: "windows"}, + Builtin: "git.discard", + Note: "Windows 内置实现(调用本机 git)", + }, + }, + }, { ID: "pkg.list", Group: "pkg", @@ -1080,6 +1161,16 @@ func threePlatformBuiltins(name string) []Variant { } } +// threePlatformGitVariants builds the platform matrix for a git command that +// calls the local git binary on all three platforms. +func threePlatformGitVariants(name string) []Variant { + return []Variant{ + {Match: Match{OS: "linux"}, Builtin: name, Note: "Linux 内置实现(调用本机 git)"}, + {Match: Match{OS: "darwin"}, Builtin: name, Note: "macOS 内置实现(调用本机 git)"}, + {Match: Match{OS: "windows"}, Builtin: name, Note: "Windows 内置实现(调用本机 git)"}, + } +} + // runtimeBuiltinVariants builds the platform matrix for a container command // that kopsc implements itself. Builtin variants carry no argv; the builtin // learns the runtime's program name from Variant.Runtime instead. diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index 980e261..e1f3900 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -25,7 +25,7 @@ func TestBuiltinIDsStable(t *testing.T) { "docker.prunebuilder", "docker.prunevolumes", "docker.clearall", "docker.compose.up", "docker.compose.down", "docker.compose.ps", "docker.compose.logs", "diagnose", - "disk.usage", "net.pids", "net.ports", "proc.nohup", "proc.wait", "java.run", "git.retime", "git.time", "pkg.list", "pkg.refreshindex", + "disk.usage", "net.pids", "net.ports", "proc.nohup", "proc.wait", "java.run", "git.retime", "git.time", "git.squash", "git.undo", "git.clean", "git.sync", "git.discard", "pkg.list", "pkg.refreshindex", "user.list", "user.grant", "user.revoke", "file.findbig", "file.findrecent", "file.findname", "svc.list", "svc.status", "svc.enable", "svc.disable", "svc.logs",