diff --git a/README.git.md b/README.git.md index ba2d713..8cfc375 100644 --- a/README.git.md +++ b/README.git.md @@ -23,8 +23,8 @@ kopsc git retime 2026-10-01 2026-10-15 --window 20:00-22:30 # 换一个时间 kopsc git retime 2026-10-01 2026-10-15 --seed 42 # 固定随机种子,结果可复现 kopsc git retime 2026-10-01 2026-10-15 --no-push --yes # 只改本地,不推送 -# 连日期一起重排:从 2026-11-01 起,每天 2 个提交,日期间隔 3 天 -kopsc git retime 2026-10-01 2026-10-15 --start-date 2026-11-01 --per-day 2 --day-gap 3 +# 连日期一起重排:把提交均匀分布到 2026-11-01 至 2026-11-15 +kopsc git retime 2026-10-01 2026-10-15 --start-date 2026-11-01 --end-date 2026-11-15 ``` ### 时间怎么分配 @@ -39,29 +39,32 @@ kopsc git retime 2026-10-01 2026-10-15 --start-date 2026-11-01 --per-day 2 --day - 如果某天提交太多、21:00–23:00 放不下所有间隔,会**自动压紧为窗口内均匀分布**, 预览表中该行标注「⚠ 已压缩间隔」。 -### 重排日期(--start-date) +### 重排日期(--start-date / --end-date) -加 `--start-date <日期>` 后,范围内的提交按原顺序**重新分配日期**,从该日开始: +加 `--start-date` 后,范围内的提交按原顺序**重新分配日期**,均匀铺在 +`[--start-date, --end-date]` 之间(不给 `--end-date` 时就只有起始那一天)。 ```bash -kopsc git retime 2026-10-01 2026-10-15 --start-date 2026-11-01 # 每天一个,连续日期 -kopsc git retime 2026-10-01 2026-10-15 --start-date 2026-11-01 --per-day 3 # 每天最多 3 个 -kopsc git retime 2026-10-01 2026-10-15 --start-date 2026-11-01 --day-gap 2 # 日期间隔 2 天 +kopsc git retime 2026-10-01 2026-10-15 --start-date 2026-11-01 # 全部集中在 11-01 +kopsc git retime 2026-10-01 2026-10-15 --start-date 2026-11-01 --end-date 2026-11-15 # 铺满 15 天 ``` ```text -窗口: 21:00-23:00(从 2026-11-15 起重排,每天 1 个,间隔 2 天) +窗口: 21:00-23:00(在 2026-11-01 至 2026-11-15 之间均匀分布) 提交 原时间 新时间 主题 -f89cdd9 2026-10-01 01:00:00 2026-11-15 21:09:21 c1 -e06e019 2026-10-01 02:00:00 2026-11-17 21:03:30 c2 -99f29b6 2026-10-01 03:00:00 2026-11-19 21:03:20 c3 +f89cdd9 2026-10-01 01:00:00 2026-11-01 21:18:12 c1 +e06e019 2026-10-01 02:00:00 2026-11-03 21:09:49 c2 +99f29b6 2026-10-01 03:00:00 2026-11-06 21:13:52 c3 +f25f7c3 2026-10-01 04:00:00 2026-11-08 21:06:54 c4 +8abca35 2026-10-01 05:00:00 2026-11-11 21:02:31 c5 +bea6552 2026-10-01 06:00:00 2026-11-13 21:10:34 c6 ``` -- 不给 `--per-day` 时**每天一个**提交;给了则一天最多放这么多个,放满换到下一个日期。 -- `--day-gap` 是相邻两个提交日期间隔的天数,默认 1(连续日期)。 -- 重排后每条提交仍落在该日的 21:00–23:00 内,同日多条严格递增。 -- `--per-day` / `--day-gap` 必须配合 `--start-date`,否则报错提示。 +- **按比例铺开**:第 i 个提交(从 0 开始)落在第 `i × 天数 ÷ 提交数` 天。 + 提交远少于天数时也是均匀铺满区间,不会全部堆在第一天。 +- 同一天放多个提交时,它们仍按顺序落在该日 21:00–23:00 之间,严格递增。 +- `--end-date` 必须配合 `--start-date`,且不能早于 `--start-date`。 ### 预览示例 @@ -106,9 +109,8 @@ git reset --hard refs/kopsc-backup/main-20261101-093000 | `--seed N` | 随机种子,同一批提交用同一种子得到相同结果 | | `--remote <名称>` | 强推到哪个远程,默认取唯一远程 | | `--branch <名称>` | 改写的分支,默认当前分支 | -| `--start-date ` | 重排日期:从该日起按顺序重新分配提交日期(默认保留原日期) | -| `--per-day N` | 重排时每天最多放 N 个提交,默认 1;需配合 `--start-date` | -| `--day-gap N` | 重排时相邻提交日期间隔 N 天,默认 1;需配合 `--start-date` | +| `--start-date ` | 重排日期的起始日(默认保留每个提交的原日期) | +| `--end-date ` | 重排的截止日期,提交均匀分布到 `[--start-date, --end-date]`;不给则只用起始那一天 | | `--no-push` | 只改写本地,不推送 | | `-y`, `--yes` | 确认执行(没有它只预览) | diff --git a/README.md b/README.md index 21e7cf7..fa3d8b6 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ kopsc java run --jdk 17 --print-flags app.jar # 只看最终 java 命令行 kopsc git retime 2026-10-01 2026-10-15 # 预览:把该范围提交时间改到 21:00-23:00 kopsc git retime 2026-10-01 2026-10-15 --yes # 确认后改写并强推(会改写历史!) -kopsc git retime 2026-10-01 2026-10-15 --start-date 2026-11-01 --per-day 2 # 连日期一起重排 +kopsc git retime 2026-10-01 2026-10-15 --start-date 2026-11-01 --end-date 2026-11-15 # 重排到该区间 kopsc list # 全部命令及当前平台可用性 kopsc explain docker.clearsafe # 匹配过程、全部候选及各自被选中/拒绝的原因 diff --git a/internal/executor/gitretime.go b/internal/executor/gitretime.go index 63ca31f..92835e3 100644 --- a/internal/executor/gitretime.go +++ b/internal/executor/gitretime.go @@ -34,11 +34,10 @@ type retimeOptions struct { Gaps []time.Duration // StartDate, when set, re-dates the commits instead of keeping their - // original days: the selected commits are laid out in order starting at - // StartDate, stepping DayGap days per day and PerDay commits per day. + // original days: the commits are spread evenly across [StartDate, EndDate] + // (EndDate defaults to StartDate), in order. StartDate time.Time - DayGap int // days between consecutive commit days, at least 1 - PerDay int // commits placed on one day, 0 means "unlimited" (one day only) + EndDate time.Time } // defaultRetimeOptions matches the command's documented behaviour: commits land @@ -67,9 +66,8 @@ func planRetime(commits []gitCommit, opts retimeOptions) []retime { out := make([]retime, 0, len(commits)) // When a start date is given, days are assigned rather than inherited. - redate := !opts.StartDate.IsZero() - if redate && opts.DayGap < 1 { - opts.DayGap = 1 + if !opts.StartDate.IsZero() { + return planRetimeInRange(commits, opts, window) } // Group by calendar day, preserving order. @@ -84,23 +82,6 @@ func planRetime(commits []gitCommit, opts retimeOptions) []retime { } for _, c := range commits { - if redate { - // Start a new day whenever the current one is full. - if len(dayCommits) == 0 { - if !day.IsZero() { - day = day.AddDate(0, 0, opts.DayGap) - } else { - day = midnightOf(opts.StartDate) - } - } - if opts.PerDay > 0 && len(dayCommits) >= opts.PerDay { - flush() - day = day.AddDate(0, 0, opts.DayGap) - } - dayCommits = append(dayCommits, c) - continue - } - original := c.Date() if !day.IsZero() && !original.Equal(day) { flush() @@ -112,6 +93,55 @@ func planRetime(commits []gitCommit, opts retimeOptions) []retime { return out } +// planRetimeInRange spreads commits evenly across [StartDate, EndDate]. +// +// Day assignment is proportional rather than "fill the first day first": the +// i-th commit of n lands on day floor(i * days / n), so a handful of commits +// over a long range is spread across it instead of bunching at the start. +// Commits sharing a day keep their order and land between 21:00 and 23:00. +func planRetimeInRange(commits []gitCommit, opts retimeOptions, window time.Duration) []retime { + if len(commits) == 0 { + return nil + } + start := midnightOf(opts.StartDate) + end := start + if !opts.EndDate.IsZero() { + end = midnightOf(opts.EndDate) + } + days := int(end.Sub(start).Hours()/24) + 1 + if days < 1 { + days = 1 + } + total := len(commits) + + // Group consecutive commits that map to the same day. + out := make([]retime, 0, total) + var group []gitCommit + groupDay := -1 + flush := func() { + if len(group) == 0 { + return + } + day := start.AddDate(0, 0, groupDay) + out = append(out, planDay(day, group, opts, window)...) + group = nil + } + + for i, c := range commits { + day := i * days / total + if day >= days { + day = days - 1 + } + if groupDay >= 0 && day != groupDay { + flush() + } + groupDay = day + group = append(group, c) + } + flush() + return out +} + // midnightOf returns the start of the calendar day of t, in t's location. func midnightOf(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) diff --git a/internal/executor/gitretime_cmd.go b/internal/executor/gitretime_cmd.go index aab4e17..6f82a09 100644 --- a/internal/executor/gitretime_cmd.go +++ b/internal/executor/gitretime_cmd.go @@ -29,8 +29,7 @@ type gitRetimeOptions struct { // Redating: when StartDate is set, the selected commits are re-dated in // order starting there instead of keeping their original days. StartDate time.Time - DayGap int - PerDay int + EndDate time.Time // defaults to StartDate when only the start is given } // runGitRetime rewrites the timestamps of commits in [From, To] so they land @@ -118,13 +117,12 @@ func writeRetimePreview(req BuiltinRequest, plan retimePlan, opts gitRetimeOptio fmt.Fprintf(w, "目标: %s\n", target) dateRule := "保留原日期" if !opts.StartDate.IsZero() { - dateRule = "从 " + opts.StartDate.Format("2006-01-02") + " 起重排" - if opts.PerDay > 0 { - dateRule += fmt.Sprintf(",每天 %d 个", opts.PerDay) - } - if opts.DayGap > 1 { - dateRule += fmt.Sprintf(",间隔 %d 天", opts.DayGap) + 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) @@ -218,8 +216,7 @@ func collectRetimePlan(ctx context.Context, runner gitRunner, opts gitRetimeOpti FirstGapMax: 20 * time.Minute, Rand: newRetimeRand(opts.Seed), StartDate: opts.StartDate, - DayGap: opts.DayGap, - PerDay: opts.PerDay, + EndDate: opts.EndDate, } _ = opts.MatchEmote return buildPlan(selected, plannerOpts, branch, remote), nil @@ -388,26 +385,14 @@ func parseGitRetimeArgs(args []string, now time.Time) (gitRetimeOptions, bool, e if opts.StartDate, err = parseDate(v); err != nil { return opts, false, err } - case "--day-gap": + case "--end-date": v, err := takeValue() if err != nil { return opts, false, err } - n, err := strconv.Atoi(v) - if err != nil || n < 1 { - return opts, false, fmt.Errorf("--day-gap 需要大于 0 的整数(天数),收到 %q", v) - } - opts.DayGap = n - case "--per-day": - v, err := takeValue() - if err != nil { + if opts.EndDate, err = parseDate(v); err != nil { return opts, false, err } - n, err := strconv.Atoi(v) - if err != nil || n < 1 { - return opts, false, fmt.Errorf("--per-day 需要大于 0 的整数(每天提交数),收到 %q", v) - } - opts.PerDay = n default: return opts, false, fmt.Errorf("未知选项 %q", arg) } @@ -437,16 +422,16 @@ func parseGitRetimeArgs(args []string, now time.Time) (gitRetimeOptions, bool, e if opts.MaxGap <= opts.MinGap { return opts, false, fmt.Errorf("间隔上限必须大于下限") } - // --day-gap / --per-day only make sense together with --start-date. + // --end-date only makes sense together with --start-date, and must not + // end before it starts. if opts.StartDate.IsZero() { - if opts.DayGap > 0 || opts.PerDay > 0 { - return opts, false, fmt.Errorf("--day-gap 与 --per-day 需要配合 --start-date 使用" + + if !opts.EndDate.IsZero() { + return opts, false, fmt.Errorf("--end-date 需要配合 --start-date 使用" + "(不给 --start-date 时保留每个提交的原日期)") } - opts.DayGap = 1 - } else if opts.DayGap < 1 { - // Re-dating defaults to consecutive days. - opts.DayGap = 1 + } 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 } diff --git a/internal/executor/gitretime_cmd_test.go b/internal/executor/gitretime_cmd_test.go index b7b5a14..94fc1c5 100644 --- a/internal/executor/gitretime_cmd_test.go +++ b/internal/executor/gitretime_cmd_test.go @@ -428,14 +428,16 @@ func TestParseGitRetimeArgsRedate(t *testing.T) { opts, _, err := parseGitRetimeArgs([]string{ "2026-10-01", "2026-10-15", "--start-date", "2026-11-01", - "--per-day", "2", - "--day-gap", "3", + "--end-date", "2026-11-15", }, time.Now()) if err != nil { t.Fatalf("parse: %v", err) } - if opts.StartDate.Format("2006-01-02") != "2026-11-01" || opts.PerDay != 2 || opts.DayGap != 3 { - t.Fatalf("opts = %+v", opts) + if opts.StartDate.Format("2006-01-02") != "2026-11-01" { + t.Errorf("StartDate = %v", opts.StartDate) + } + if opts.EndDate.Format("2006-01-02") != "2026-11-15" { + t.Errorf("EndDate = %v", opts.EndDate) } } @@ -444,11 +446,10 @@ func TestParseGitRetimeArgsRedateValidation(t *testing.T) { args []string want string }{ - {[]string{"2026-10-01", "2026-10-02", "--per-day", "2"}, "需要配合 --start-date"}, - {[]string{"2026-10-01", "2026-10-02", "--day-gap", "2"}, "需要配合 --start-date"}, + {[]string{"2026-10-01", "2026-10-02", "--end-date", "2026-11-15"}, "需要配合 --start-date"}, {[]string{"2026-10-01", "2026-10-02", "--start-date", "2026/11/01"}, "YYYY-MM-DD"}, - {[]string{"2026-10-01", "2026-10-02", "--start-date", "2026-11-01", "--per-day", "0"}, "大于 0"}, - {[]string{"2026-10-01", "2026-10-02", "--start-date", "2026-11-01", "--day-gap", "x"}, "大于 0"}, + {[]string{"2026-10-01", "2026-10-02", "--start-date", "2026/11/01", "--end-date", "2026-11-15"}, "YYYY-MM-DD"}, + {[]string{"2026-10-01", "2026-10-02", "--start-date", "2026-11-15", "--end-date", "2026-11-01"}, "早于 --start-date"}, } for _, tc := range tests { _, _, err := parseGitRetimeArgs(tc.args, time.Now()) @@ -458,12 +459,12 @@ func TestParseGitRetimeArgsRedateValidation(t *testing.T) { } } -func TestParseGitRetimeArgsDefaultDayGap(t *testing.T) { +func TestParseGitRetimeArgsEndDateOptional(t *testing.T) { opts, _, err := parseGitRetimeArgs([]string{"2026-10-01", "2026-10-02", "--start-date", "2026-11-01"}, time.Now()) if err != nil { t.Fatalf("parse: %v", err) } - if opts.DayGap != 1 { - t.Errorf("DayGap = %d, want the default 1", opts.DayGap) + if !opts.EndDate.IsZero() { + t.Errorf("EndDate = %v, want the zero value (single start day)", opts.EndDate) } } diff --git a/internal/executor/gitretime_test.go b/internal/executor/gitretime_test.go index c263894..f400620 100644 --- a/internal/executor/gitretime_test.go +++ b/internal/executor/gitretime_test.go @@ -328,76 +328,85 @@ func TestBuildPlanListsEachSqueezedDayOnce(t *testing.T) { } } -func TestPlanRetimeRedateStartingFromDay(t *testing.T) { - // Three commits originally on 2026-10-01, re-dated from 2026-11-01 with one - // commit per day. +func TestPlanRetimeInRangeSpreadsEvenly(t *testing.T) { + // 6 commits over 3 days: 2 commits per day, in order. date := day(2026, 10, 1) - commits := []gitCommit{ - commitAt("c1", date, 9, 0, 0), - commitAt("c2", date, 10, 0, 0), - commitAt("c3", date, 11, 0, 0), + var commits []gitCommit + for i := 0; i < 6; i++ { + commits = append(commits, commitAt("c", date, 9, i, 0)) } opts := defaultRetimeOptions(3) opts.StartDate = day(2026, 11, 1) - opts.PerDay = 1 - opts.DayGap = 1 + opts.EndDate = day(2026, 11, 3) plan := planRetime(commits, opts) - if len(plan) != 3 { - t.Fatalf("plan has %d entries", len(plan)) - } - for i, want := range []string{"2026-11-01", "2026-11-02", "2026-11-03"} { - got := plan[i].NewTime.In(cst).Format("2006-01-02") - if got != want { - t.Errorf("commit %d re-dated to %s, want %s", i+1, got, want) + wantDays := []string{"2026-11-01", "2026-11-01", "2026-11-02", "2026-11-02", "2026-11-03", "2026-11-03"} + for i, want := range wantDays { + if got := plan[i].NewTime.In(cst).Format("2006-01-02"); got != want { + t.Errorf("commit %d on %s, want %s", i, got, want) } if h := plan[i].NewTime.In(cst).Hour(); h < 21 || h > 22 { - t.Errorf("commit %d at hour %d, want 21-22", i+1, h) + t.Errorf("commit %d at hour %d, want 21-22", i, h) } } } -func TestPlanRetimeRedateHonoursDayGap(t *testing.T) { +func TestPlanRetimeInRangeDistributesRemainder(t *testing.T) { + // 7 commits over 3 days: 3, 2, 2 from the earliest day. + date := day(2026, 10, 1) + var commits []gitCommit + for i := 0; i < 7; i++ { + commits = append(commits, commitAt("c", date, 9, i, 0)) + } + opts := defaultRetimeOptions(4) + opts.StartDate = day(2026, 11, 1) + opts.EndDate = day(2026, 11, 3) + + plan := planRetime(commits, opts) + counts := map[string]int{} + for _, r := range plan { + counts[r.NewTime.In(cst).Format("2006-01-02")]++ + } + if counts["2026-11-01"] != 3 || counts["2026-11-02"] != 2 || counts["2026-11-03"] != 2 { + t.Fatalf("distribution = %v, want 3/2/2", counts) + } +} + +func TestPlanRetimeInRangeSpreadsFewCommitsAcrossTheRange(t *testing.T) { + // 2 commits over 4 days must not bunch on day one: they land on the first + // and the third day (floor(i*days/total)). date := day(2026, 10, 1) commits := []gitCommit{ commitAt("c1", date, 9, 0, 0), commitAt("c2", date, 10, 0, 0), } - opts := defaultRetimeOptions(4) + opts := defaultRetimeOptions(5) opts.StartDate = day(2026, 11, 1) - opts.PerDay = 1 - opts.DayGap = 3 + opts.EndDate = day(2026, 11, 4) plan := planRetime(commits, opts) - if got := plan[1].NewTime.In(cst).Format("2006-01-02"); got != "2026-11-04" { - t.Fatalf("second commit on %s, want 2026-11-04 (gap 3 days)", got) + want := []string{"2026-11-01", "2026-11-03"} + for i, w := range want { + if got := plan[i].NewTime.In(cst).Format("2006-01-02"); got != w { + t.Errorf("commit %d on %s, want %s", i, got, w) + } } } -func TestPlanRetimeRedatePacksSeveralPerDay(t *testing.T) { +func TestPlanRetimeInRangeSingleDayWithoutEndDate(t *testing.T) { date := day(2026, 10, 1) - var commits []gitCommit - for i := 0; i < 4; i++ { - commits = append(commits, commitAt("c", date, 9, i, 0)) + commits := []gitCommit{ + commitAt("c1", date, 9, 0, 0), + commitAt("c2", date, 10, 0, 0), } - opts := defaultRetimeOptions(5) - opts.StartDate = day(2026, 11, 1) - opts.PerDay = 2 - opts.DayGap = 1 + opts := defaultRetimeOptions(6) + opts.StartDate = day(2026, 11, 7) plan := planRetime(commits, opts) - firstDay := plan[0].NewTime.In(cst).Format("2006-01-02") - secondOfFirstDay := plan[1].NewTime.In(cst).Format("2006-01-02") - thirdDay := plan[2].NewTime.In(cst).Format("2006-01-02") - if firstDay != "2026-11-01" || secondOfFirstDay != "2026-11-01" { - t.Fatalf("first two commits should share 2026-11-01, got %s and %s", firstDay, secondOfFirstDay) - } - if thirdDay != "2026-11-02" { - t.Fatalf("third commit on %s, want 2026-11-02", thirdDay) - } - // Within a day the times stay ordered. - if !plan[1].NewTime.After(plan[0].NewTime) { - t.Fatalf("same-day commits are not ordered: %v, %v", plan[0].NewTime, plan[1].NewTime) + for i, r := range plan { + if got := r.NewTime.In(cst).Format("2006-01-02"); got != "2026-11-07" { + t.Errorf("commit %d on %s, want the single start day", i, got) + } } } diff --git a/internal/registry/builtin.go b/internal/registry/builtin.go index 2534b25..dd182ed 100644 --- a/internal/registry/builtin.go +++ b/internal/registry/builtin.go @@ -487,8 +487,8 @@ func Builtin() *Registry { Summary: "把指定日期范围的提交时间改到 21:00-23:00 并强推", Action: "读取本地分支在给定日期范围内(含两端)的提交,保留各自的原日期," + "把时分秒重排到 21:00-23:00 之间:同一日期内相邻提交严格递增,间隔取 5-40 分钟的随机值(秒位随机)," + - "也可用 --interval match-remote 参照远程分支的间隔;加 --start-date 时改为按顺序重排日期" + - "(--per-day 控制每天几个、--day-gap 控制日期间隔)。随后重建这段历史并以 --force-with-lease 强推。" + + "也可用 --interval match-remote 参照远程分支的间隔;加 --start-date 时改为按顺序重排日期," + + "均匀铺在 [--start-date, --end-date] 之间。随后重建这段历史并以 --force-with-lease 强推。" + "改动前自动创建 refs/kopsc-backup/<分支>-<时间戳> 备份引用,工作区有未提交改动时拒绝执行;" + "默认只打印预览表并以退出码 2 结束,必须显式加 --yes 才会改写并推送", Usage: "<开始日期> <结束日期> [选项]",