Files
odboy 84aa91ba7f feat: git retime 改为在 --start-date/--end-date 区间内均匀分布提交
- 新增 --end-date:提交按比例铺满 [start-date, end-date],第 i 个提交
  落在第 i×天数÷提交数 天,提交远少于天数时也不会堆在第一天
- 移除 --per-day / --day-gap:原先的“每天一个、日期顺延”语义不直观
- 只给 --start-date 时集中在该日;--end-date 需配合 --start-date 且不早于它
- 同一天的多条提交仍按顺序落在 21:00-23:00 之间
- 文档与预览文案同步为区间语义
2026-11-01 10:11:39 +08:00

529 lines
16 KiB
Go
Raw Permalink 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
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
// Redating: when StartDate is set, the selected commits are re-dated in
// order starting there instead of keeping their original days.
StartDate time.Time
EndDate time.Time // defaults to StartDate when only the start is given
}
// 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)
dateRule := "保留原日期"
if !opts.StartDate.IsZero() {
end := opts.EndDate
if end.IsZero() {
end = opts.StartDate
}
dateRule = fmt.Sprintf("在 %s 至 %s 之间均匀分布",
opts.StartDate.Format("2006-01-02"), end.Format("2006-01-02"))
}
fmt.Fprintf(w, "窗口: %s-%s(%s)\n\n",
clockOf(opts.Window), clockOf(opts.WindowEnd), dateRule)
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"),
newTimeCell(item, opts) + mark,
item.Subject,
})
}
writeDisplayColumns(w, rows)
}
// newTimeCell shows the new time, with its date when dates are reassigned.
func newTimeCell(item retime, opts gitRetimeOptions) string {
if opts.StartDate.IsZero() {
return item.NewTime.Format("15:04:05")
}
return item.NewTime.Format("2006-01-02 15:04:05")
}
// 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),
StartDate: opts.StartDate,
EndDate: opts.EndDate,
}
_ = 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
case "--start-date":
v, err := takeValue()
if err != nil {
return opts, false, err
}
if opts.StartDate, err = parseDate(v); err != nil {
return opts, false, err
}
case "--end-date":
v, err := takeValue()
if err != nil {
return opts, false, err
}
if opts.EndDate, err = parseDate(v); err != nil {
return opts, false, err
}
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("间隔上限必须大于下限")
}
// --end-date only makes sense together with --start-date, and must not
// end before it starts.
if opts.StartDate.IsZero() {
if !opts.EndDate.IsZero() {
return opts, false, fmt.Errorf("--end-date 需要配合 --start-date 使用" +
"(不给 --start-date 时保留每个提交的原日期)")
}
} else if !opts.EndDate.IsZero() && opts.EndDate.Before(opts.StartDate) {
return opts, false, fmt.Errorf("--end-date %s 早于 --start-date %s",
opts.EndDate.Format("2006-01-02"), opts.StartDate.Format("2006-01-02"))
}
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
}