- 新增 --end-date:提交按比例铺满 [start-date, end-date],第 i 个提交 落在第 i×天数÷提交数 天,提交远少于天数时也不会堆在第一天 - 移除 --per-day / --day-gap:原先的“每天一个、日期顺延”语义不直观 - 只给 --start-date 时集中在该日;--end-date 需配合 --start-date 且不早于它 - 同一天的多条提交仍按顺序落在 21:00-23:00 之间 - 文档与预览文案同步为区间语义
529 lines
16 KiB
Go
529 lines
16 KiB
Go
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
|
||
}
|