Files
kenaito-kopsc/internal/executor/executor.go
T
odboy f3b90d205c feat: 新增 git retime,把指定日期范围的提交时间改到 21:00-23:00 并强推
- 保留每个提交的原日期,只重排时分秒到 21:00-23:00;同日严格递增,
  间隔 5-40 分钟且秒位随机;窗口不足时自动压紧并在预览中标注
- 支持 --window/--since/--until、--interval(含 match-remote)、--seed、
  --remote、--branch、--no-push
- 安全措施:默认只预览(退出码 2)、脏工作区拒绝、改写前创建
  refs/kopsc-backup/<分支>-<时间戳> 备份、推送前比对 tree 哈希、
  只用 --force-with-lease
- BuiltinRequest 增加 Confirmed,使内置命令能获知 CLI 已确认 --yes
- 文档:新增 README.git.md,主 README 补充导航与示例
2026-11-01 09:49:03 +08:00

233 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package executor turns a resolved variant into a process, with dry-run
// support and faithful stdio/exit-code passthrough.
package executor
import (
"context"
"errors"
"fmt"
"io"
"os/exec"
"runtime"
"strings"
"unicode"
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/resolve"
)
// Options controls how a Plan is executed.
type Options struct {
DryRun bool
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
Env []string // optional override of the child environment
Dir string // optional working directory
// Confirmed reports that the caller already approved a destructive
// command (--yes); builtins that would otherwise ask again check it.
Confirmed bool
}
// Plan is a resolved variant plus arguments forwarded from the caller.
type Plan struct {
Result resolve.Result
ExtraArgs []string
}
// Argv returns the final argument vector: the variant's argv followed by the
// forwarded arguments.
func (p Plan) Argv() []string {
argv := make([]string, 0, len(p.Result.Variant.Argv)+len(p.ExtraArgs))
argv = append(argv, p.Result.Variant.Argv...)
argv = append(argv, p.ExtraArgs...)
return argv
}
// Display renders the command line the plan would run.
func (p Plan) Display() string {
if p.Result.Variant.Builtin != "" {
parts := append([]string{"(内置)", p.Result.Variant.Builtin}, p.ExtraArgs...)
return strings.Join(parts, " ")
}
argv := p.Argv()
if p.Result.Variant.Shell {
flag := "-c"
if runtime.GOOS == "windows" {
flag = "/c"
}
return shellName() + " " + flag + " " + quoteForShell(strings.Join(argv, " "), runtime.GOOS)
}
return strings.Join(argv, " ")
}
// PreviewBuiltin returns the builtin advertised by the resolved command for
// listing what it would touch, or "" when the command has none.
func (p Plan) PreviewBuiltin() string {
return p.Result.Command.ConfirmBuiltin
}
// Explain writes why this variant was chosen and what will run.
func (p Plan) Explain(w io.Writer) {
if w == nil {
return
}
variant := p.Result.Variant
fmt.Fprintf(w, "将执行: %s\n", p.Display())
fmt.Fprintf(w, "平台: %s\n", resolve.DescribeInfo(p.Result.Info))
switch {
case variant.Builtin != "":
fmt.Fprintf(w, "方式: 内置实现(%s,不启动外部进程)\n", variant.Builtin)
case variant.Shell:
fmt.Fprintf(w, "方式: shell (%s)\n", shellName())
default:
fmt.Fprintf(w, "方式: 直接执行\n")
}
note := variant.Note
if note == "" {
note = "无备注"
}
fmt.Fprintf(w, "命中: 变体 #%d(%d 分)%s\n", p.Result.VariantIdx, p.Result.Score, note)
if p.Result.VariantIdx >= 0 && p.Result.VariantIdx < len(p.Result.Candidates) {
fmt.Fprintf(w, "依据: %s\n", p.Result.Candidates[p.Result.VariantIdx].Reason)
}
if len(p.ExtraArgs) > 0 {
fmt.Fprintf(w, "透传: %s\n", strings.Join(p.ExtraArgs, " "))
}
}
// Run executes the plan. With DryRun it only prints the explanation. A child
// exiting non-zero is reported as *ExitError so callers can preserve the code.
func (p Plan) Run(ctx context.Context, opts Options) error {
if opts.Stdout == nil {
opts.Stdout = io.Discard
}
if opts.Stderr == nil {
opts.Stderr = io.Discard
}
if opts.DryRun {
fmt.Fprintln(opts.Stdout, "[dry-run] 未执行任何命令")
p.Explain(opts.Stdout)
return nil
}
if variant := p.Result.Variant; variant.Builtin != "" {
fn, ok := LookupBuiltin(variant.Builtin)
if !ok {
return fmt.Errorf("未注册的内置动作 %q", variant.Builtin)
}
return fn(ctx, BuiltinRequest{
Args: p.ExtraArgs,
Stdin: opts.Stdin,
Stdout: opts.Stdout,
Stderr: opts.Stderr,
Info: p.Result.Info,
Plan: p,
Confirmed: opts.Confirmed,
})
}
name, args := p.command()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdin = opts.Stdin
cmd.Stdout = opts.Stdout
cmd.Stderr = opts.Stderr
cmd.Env = opts.Env
cmd.Dir = opts.Dir
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return &ExitError{Code: exitErr.ExitCode(), Err: err}
}
return fmt.Errorf("执行 %q 失败: %w", name, err)
}
return nil
}
func (p Plan) command() (string, []string) {
argv := p.Argv()
if !p.Result.Variant.Shell {
return argv[0], argv[1:]
}
shell := shellName()
if runtime.GOOS == "windows" {
return shell, []string{"/c", joinForShell(argv, runtime.GOOS)}
}
return shell, []string{"-c", joinForShell(argv, runtime.GOOS)}
}
func shellName() string {
if runtime.GOOS == "windows" {
return "cmd"
}
return "sh"
}
// joinForShell joins argv with platform-appropriate quoting.
func joinForShell(argv []string, osName string) string {
parts := make([]string, len(argv))
for i, arg := range argv {
parts[i] = quoteForShell(arg, osName)
}
return strings.Join(parts, " ")
}
// quoteForShell leaves safe arguments untouched and quotes the rest.
func quoteForShell(arg, osName string) string {
if osName == "windows" {
if arg != "" && !strings.ContainsAny(arg, " \t\"&|<>^%") {
return arg
}
return `"` + strings.ReplaceAll(arg, `"`, `\"`) + `"`
}
if arg != "" && strings.IndexFunc(arg, unsafeShellRune) < 0 {
return arg
}
return "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'"
}
func unsafeShellRune(r rune) bool {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
return false
}
return !strings.ContainsRune("-_.:/=@+,", r)
}
// ExitError carries the child process exit code.
type ExitError struct {
Code int
Err error
}
func (e *ExitError) Error() string {
// When the error carries its own explanation (for example a timeout that
// lists what is still running), show it instead of the bare status code.
if e.Err != nil {
if msg := e.Err.Error(); msg != "" && !isExitStatus(msg) {
return msg
}
}
return fmt.Sprintf("命令以状态码 %d 退出", e.Code)
}
// isExitStatus reports whether msg is just an os/exec exit status, which adds
// nothing to kopsc's own wording.
func isExitStatus(msg string) bool {
return strings.HasPrefix(msg, "exit status ") || strings.HasPrefix(msg, "signal: ")
}
func (e *ExitError) Unwrap() error { return e.Err }
// ExitCode maps an error to the process exit code kopsc should return.
func ExitCode(err error) int {
if err == nil {
return 0
}
var exitErr *ExitError
if errors.As(err, &exitErr) {
return exitErr.Code
}
return 1
}