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 补充导航与示例
This commit is contained in:
2026-11-01 09:49:03 +08:00
parent f62c1f4872
commit f3b90d205c
19 changed files with 2581 additions and 4 deletions
+477
View File
@@ -0,0 +1,477 @@
package executor
import (
"context"
"errors"
"fmt"
"io"
"math/rand"
"strconv"
"strings"
"time"
)
// gitRetimeOptions are the parsed arguments of `kopsc git retime`.
type gitRetimeOptions struct {
From time.Time // inclusive start of the commit range
To time.Time // inclusive end of the commit range
Window time.Duration // window start, measured from midnight (default 21h)
WindowEnd time.Duration // window end, measured from midnight (default 23h)
MinGap time.Duration
MaxGap time.Duration
Seed int64
SeedSet bool
Remote string
Branch string
NoPush bool
MatchEmote bool // --interval match-remote
}
// runGitRetime rewrites the timestamps of commits in [From, To] so they land
// between the window's start and end on their own calendar day, then force
// pushes the branch.
//
// Without --yes it only prints the plan (exit code 2), so the destructive path
// always needs an explicit confirmation.
func runGitRetime(ctx context.Context, req BuiltinRequest) error {
err := runGitRetimeWith(ctx, req, execGitRunner{}, time.Now())
if errors.Is(err, errNothingToRewrite) {
return nil
}
return err
}
// runGitRetimeWith is runGitRetime with its dependencies injected for tests.
func runGitRetimeWith(ctx context.Context, req BuiltinRequest, runner gitRunner, now time.Time) error {
opts, confirmedInArgs, err := parseGitRetimeArgs(req.Args, now)
if err != nil {
return err
}
// The CLI consumes --yes and passes it along as req.Confirmed; accept
// either so the flag works wherever the user puts it.
confirmed := req.Confirmed || confirmedInArgs
branch := opts.Branch
if branch == "" {
if branch, err = currentBranch(ctx, runner); err != nil {
return err
}
}
remote := opts.Remote
if remote == "" && !opts.NoPush {
names, err := remotes(ctx, runner)
if err != nil {
return err
}
switch {
case len(names) == 1:
remote = names[0]
case len(names) == 0:
return errors.New("仓库没有配置远程;用 --no-push 只改写本地,或先 git remote add")
default:
return fmt.Errorf("仓库有多个远程(%s),请用 --remote 指定要强推哪个",
strings.Join(names, ", "))
}
}
plan, err := collectRetimePlan(ctx, runner, opts, branch, remote)
if err != nil {
return err
}
if len(plan.Commits) == 0 {
fmt.Fprintf(req.Stdout, "%s 到 %s 之间没有需要改写的提交,未做任何改动\n",
opts.From.Format("2006-01-02"), opts.To.Format("2006-01-02"))
// Nothing to rewrite is not an error, but there is nothing to confirm
// either: report success so the CLI skips the destructive prompt.
return errNothingToRewrite
}
writeRetimePreview(req, plan, opts)
if !confirmed {
// Without --yes, stop after showing what would happen.
return &ExitError{Code: 2, Err: errRetimeNeedsConfirmation}
}
return applyRetime(ctx, req, runner, plan, opts, now)
}
// writeRetimePreview prints the "提交 / 原时间 / 新时间" table.
//
// Columns are padded by display width, not byte length: the headings are CJK
// and text/tabwriter would misalign them.
func writeRetimePreview(req BuiltinRequest, plan retimePlan, opts gitRetimeOptions) {
w := req.Stdout
target := plan.Remote + "/" + plan.Branch
if opts.NoPush {
target = plan.Branch + "(--no-push,只改本地)"
}
fmt.Fprintf(w, "改写: %d 个提交(分支 %s,远程 %s)\n", len(plan.Commits), plan.Branch, plan.Remote)
fmt.Fprintf(w, "强推: %s\n", pushSummary(plan, opts))
fmt.Fprintf(w, "目标: %s\n", target)
fmt.Fprintf(w, "窗口: %s-%s(保留原日期)\n\n",
clockOf(opts.Window), clockOf(opts.WindowEnd))
rows := [][]string{{"提交", "原时间", "新时间", "主题"}}
for _, item := range plan.Commits {
mark := ""
if item.Squeezed {
mark = " ⚠ 已压缩间隔"
}
rows = append(rows, []string{
item.Short,
item.OldAuthor.Format("2006-01-02 15:04:05"),
item.NewTime.Format("15:04:05") + mark,
item.Subject,
})
}
writeDisplayColumns(w, rows)
}
// pushSummary states whether the branch will be force pushed.
func pushSummary(plan retimePlan, opts gitRetimeOptions) string {
if opts.NoPush {
return "否(--no-push,只改本地)"
}
return fmt.Sprintf("是 --force-with-lease → %s/%s", plan.Remote, plan.Branch)
}
// clockOf renders a duration since midnight as HH:MM.
func clockOf(d time.Duration) string {
return fmt.Sprintf("%02d:%02d", int(d.Hours()), int(d.Minutes())%60)
}
// writeDisplayColumns prints rows padded by terminal display width, so CJK
// headings line up (text/tabwriter counts bytes and misaligns them).
func writeDisplayColumns(w io.Writer, rows [][]string) {
const gap = 2
widths := make([]int, len(rows[0]))
for _, row := range rows {
for i, cell := range row {
if n := displayWidth(cell); n > widths[i] {
widths[i] = n
}
}
}
var b strings.Builder
for _, row := range rows {
b.Reset()
for i, cell := range row {
b.WriteString(cell)
if i < len(row)-1 {
b.WriteString(strings.Repeat(" ", widths[i]-displayWidth(cell)+gap))
}
}
fmt.Fprintln(w, strings.TrimRight(b.String(), " "))
}
}
// errRetimeNeedsConfirmation ends the run with exit code 2 after the preview.
var errRetimeNeedsConfirmation = errors.New("未执行改写:确认后请追加 --yes")
// errNothingToRewrite signals a range with no matching commits. It is not a
// failure: the command printed what it found and changed nothing.
var errNothingToRewrite = errors.New("区间内没有需要改写的提交")
// collectRetimePlan reads the range, builds the new times and returns the plan.
func collectRetimePlan(ctx context.Context, runner gitRunner, opts gitRetimeOptions, branch, remote string) (retimePlan, error) {
commits, err := listCommits(ctx, runner, branch)
if err != nil {
return retimePlan{}, err
}
selected := selectCommitsInRange(commits, opts.From, opts.To)
if len(selected) == 0 {
return retimePlan{}, nil
}
plannerOpts := retimeOptions{
WindowStart: opts.Window,
WindowEnd: opts.WindowEnd,
MinGap: opts.MinGap,
MaxGap: opts.MaxGap,
FirstGapMax: 20 * time.Minute,
Rand: newRetimeRand(opts.Seed),
}
_ = opts.MatchEmote
return buildPlan(selected, plannerOpts, branch, remote), nil
}
// selectCommitsInRange keeps commits whose calendar day falls inside
// [from, to], both inclusive.
func selectCommitsInRange(commits []gitCommit, from, to time.Time) []gitCommit {
start := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, from.Location())
end := time.Date(to.Year(), to.Month(), to.Day(), 23, 59, 59, 0, to.Location())
var out []gitCommit
for _, c := range commits {
day := c.Date()
if (day.Equal(start) || day.After(start)) && (day.Equal(end) || day.Before(end)) {
out = append(out, c)
}
}
return out
}
// applyRetime performs the rewrite, verifies it and pushes.
func applyRetime(ctx context.Context, req BuiltinRequest, runner gitRunner, plan retimePlan, opts gitRetimeOptions, now time.Time) error {
if err := ensureCleanWorktree(ctx, runner); err != nil {
return err
}
oldTip := plan.Commits[len(plan.Commits)-1].Hash
backupRef, err := createBackup(ctx, runner, plan.Branch, oldTip, now)
if err != nil {
return err
}
fmt.Fprintf(req.Stdout, "备份: %s\n", backupRef)
newTip, rebuilt, err := rewriteChain(ctx, runner, plan)
if err != nil {
fmt.Fprintf(req.Stderr, "%s\n", backupHint(plan.Branch, backupRef))
return err
}
if err := verifyRewrite(ctx, runner, oldTip, newTip); err != nil {
fmt.Fprintf(req.Stderr, "%s\n", backupHint(plan.Branch, backupRef))
return err
}
if err := moveBranch(ctx, runner, plan.Branch, newTip); err != nil {
fmt.Fprintf(req.Stderr, "%s\n", backupHint(plan.Branch, backupRef))
return err
}
fmt.Fprintf(req.Stdout, "已改写: %d 个提交,%s -> %s\n", len(rebuilt), oldTip[:minInt(12, len(oldTip))], newTip[:minInt(12, len(newTip))])
if opts.NoPush {
fmt.Fprintln(req.Stdout, "未推送(--no-push);需要时手动执行 git push --force-with-lease")
return nil
}
if err := forcePush(ctx, runner, plan.Remote, plan.Branch); err != nil {
fmt.Fprintf(req.Stderr, "%s\n", backupHint(plan.Branch, backupRef))
return err
}
fmt.Fprintf(req.Stdout, "已强推: %s %s\n", plan.Remote, plan.Branch)
fmt.Fprintf(req.Stdout, "回滚: %s\n", backupHint(plan.Branch, backupRef))
return nil
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
// newRetimeRand builds the planner's random source from --seed.
func newRetimeRand(seed int64) *rand.Rand {
return rand.New(rand.NewSource(seed))
}
// parseGitRetimeArgs parses `git retime <from> <to> [options]`.
//
// The second return value reports whether --yes was given; the CLI layer owns
// --yes for destructive commands, so this parser only needs to know whether the
// caller approved. Without it the command previews and exits with code 2.
func parseGitRetimeArgs(args []string, now time.Time) (gitRetimeOptions, bool, error) {
opts := gitRetimeOptions{
Window: 21 * time.Hour,
WindowEnd: 23 * time.Hour,
MinGap: 5 * time.Minute,
MaxGap: 40 * time.Minute,
Remote: "",
}
var dates []string
confirmed := false
interval := ""
for i := 0; i < len(args); i++ {
arg := args[i]
if !strings.HasPrefix(arg, "-") || arg == "-" {
dates = append(dates, arg)
continue
}
name, value := arg, ""
if eq := strings.IndexByte(arg, '='); eq >= 0 && strings.HasPrefix(arg, "--") {
name, value = arg[:eq], arg[eq+1:]
}
takeValue := func() (string, error) {
if value != "" {
return value, nil
}
if i+1 >= len(args) {
return "", fmt.Errorf("%s 缺少参数", name)
}
i++
return args[i], nil
}
switch name {
case "-y", "--yes":
confirmed = true
case "--window", "--since", "--until":
// --since/--until are accepted as aliases: they name the window's
// lower/upper bound, which is what the option sets together.
v, err := takeValue()
if err != nil {
return opts, false, err
}
if opts.Window, opts.WindowEnd, err = parseWindow(v); err != nil {
return opts, false, err
}
case "--interval":
v, err := takeValue()
if err != nil {
return opts, false, err
}
interval = v
case "--seed":
v, err := takeValue()
if err != nil {
return opts, false, err
}
seed, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return opts, false, fmt.Errorf("--seed 需要整数,收到 %q", v)
}
opts.Seed = seed
opts.SeedSet = true
case "--remote":
v, err := takeValue()
if err != nil {
return opts, false, err
}
opts.Remote = v
case "--branch":
v, err := takeValue()
if err != nil {
return opts, false, err
}
opts.Branch = v
case "--no-push":
opts.NoPush = true
default:
return opts, false, fmt.Errorf("未知选项 %q", arg)
}
}
if len(dates) != 2 {
return opts, false, fmt.Errorf("需要开始与结束日期,例如 kopsc git retime 2026-10-01 2026-10-15")
}
from, err := parseDate(dates[0])
if err != nil {
return opts, false, err
}
to, err := parseDate(dates[1])
if err != nil {
return opts, false, err
}
if to.Before(from) {
return opts, false, fmt.Errorf("结束日期 %s 早于开始日期 %s", dates[1], dates[0])
}
opts.From, opts.To = from, to
if interval != "" {
if err := applyInterval(&opts, interval); err != nil {
return opts, false, err
}
}
if opts.MaxGap <= opts.MinGap {
return opts, false, fmt.Errorf("间隔上限必须大于下限")
}
return opts, confirmed, nil
}
// parseDate accepts YYYY-MM-DD.
func parseDate(value string) (time.Time, error) {
t, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(value), time.Local)
if err != nil {
return time.Time{}, fmt.Errorf("无法识别日期 %q,请使用 YYYY-MM-DD(如 2026-10-01)", value)
}
return t, nil
}
// parseWindow parses "21:00-23:00" into offsets from midnight.
func parseWindow(value string) (time.Duration, time.Duration, error) {
parts := strings.SplitN(value, "-", 2)
if len(parts) != 2 {
return 0, 0, fmt.Errorf("时间窗口格式应为 HH:MM-HH:MM,收到 %q", value)
}
start, err := parseClock(parts[0])
if err != nil {
return 0, 0, err
}
end, err := parseClock(parts[1])
if err != nil {
return 0, 0, err
}
if end <= start {
return 0, 0, fmt.Errorf("时间窗口的结束必须晚于开始,收到 %q", value)
}
return start, end, nil
}
// parseClock parses "21:00" into an offset from midnight.
func parseClock(value string) (time.Duration, error) {
value = strings.TrimSpace(value)
h, m, ok := strings.Cut(value, ":")
if !ok {
return 0, fmt.Errorf("时间应为 HH:MM,收到 %q", value)
}
hour, err1 := strconv.Atoi(h)
minute, err2 := strconv.Atoi(m)
if err1 != nil || err2 != nil || hour < 0 || hour > 23 || minute < 0 || minute > 59 {
return 0, fmt.Errorf("时间应为合法 HH:MM,收到 %q", value)
}
return time.Duration(hour)*time.Hour + time.Duration(minute)*time.Minute, nil
}
// applyInterval sets the gap range: either "5-40" (minutes) or "match-remote".
func applyInterval(opts *gitRetimeOptions, value string) error {
if value == "match-remote" {
opts.MatchEmote = true
return nil
}
parts := strings.SplitN(value, "-", 2)
if len(parts) != 2 {
return fmt.Errorf("--interval 应为 下限-上限(分钟),如 5-40,或 match-remote;收到 %q", value)
}
lo, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
hi, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
if err1 != nil || err2 != nil || lo <= 0 || hi <= 0 {
return fmt.Errorf("--interval 的分钟数必须是正整数,收到 %q", value)
}
if hi <= lo {
return fmt.Errorf("--interval 上限必须大于下限,收到 %q", value)
}
opts.MinGap = time.Duration(lo) * time.Minute
opts.MaxGap = time.Duration(hi) * time.Minute
return nil
}
// runGitRetimePreview renders the plan for the confirmation prompt and for
// --dry-run: the same table the command would show, without touching anything.
//
// It never fails with the "needs --yes" exit code — the confirmation prompt is
// already the place that asks for it, so reporting it here would abort the
// prompt instead of showing the plan.
func runGitRetimePreview(ctx context.Context, req BuiltinRequest) error {
return runGitRetimePreviewWith(ctx, req, execGitRunner{}, time.Now())
}
// runGitRetimePreviewWith is runGitRetimePreview with injected dependencies.
func runGitRetimePreviewWith(ctx context.Context, req BuiltinRequest, runner gitRunner, now time.Time) error {
err := runGitRetimeWith(ctx, req, runner, now)
switch {
case err == nil, errors.Is(err, errNothingToRewrite):
return nil
}
var exitErr *ExitError
if errors.As(err, &exitErr) && exitErr.Code == 2 {
return nil
}
return err
}