diff --git a/README.git.md b/README.git.md index ba7d696..ba2d713 100644 --- a/README.git.md +++ b/README.git.md @@ -22,10 +22,15 @@ kopsc git retime 2026-10-01 2026-10-15 --interval 10-25 # 间隔改为 10-2 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 ``` ### 时间怎么分配 +默认**保留每个提交的原日期**,只改时分秒;加 `--start-date` 才会重排日期(见下)。 + - 每个提交落在**自己原来的日期**上,日期不变。 - 同一天内的提交按原顺序**严格递增**。 - 相邻提交间隔是 **5–40 分钟**的随机值,**秒位也随机**(如 `21:07:43` → `21:32:11`), @@ -34,6 +39,30 @@ kopsc git retime 2026-10-01 2026-10-15 --no-push --yes # 只改本地, - 如果某天提交太多、21:00–23:00 放不下所有间隔,会**自动压紧为窗口内均匀分布**, 预览表中该行标注「⚠ 已压缩间隔」。 +### 重排日期(--start-date) + +加 `--start-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 天 +``` + +```text +窗口: 21:00-23:00(从 2026-11-15 起重排,每天 1 个,间隔 2 天) + +提交 原时间 新时间 主题 +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 +``` + +- 不给 `--per-day` 时**每天一个**提交;给了则一天最多放这么多个,放满换到下一个日期。 +- `--day-gap` 是相邻两个提交日期间隔的天数,默认 1(连续日期)。 +- 重排后每条提交仍落在该日的 21:00–23:00 内,同日多条严格递增。 +- `--per-day` / `--day-gap` 必须配合 `--start-date`,否则报错提示。 + ### 预览示例 ```text @@ -77,6 +106,9 @@ 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` | | `--no-push` | 只改写本地,不推送 | | `-y`, `--yes` | 确认执行(没有它只预览) | diff --git a/README.md b/README.md index 9b78c3b..21e7cf7 100644 --- a/README.md +++ b/README.md @@ -97,6 +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 list # 全部命令及当前平台可用性 kopsc explain docker.clearsafe # 匹配过程、全部候选及各自被选中/拒绝的原因 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..903dcbc --- /dev/null +++ b/install.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -e + +# 构建 kopsc 并安装到 /usr/local/bin,供本机直接使用。 +cd "$(dirname "$0")" + +go build -o bin/kopsc ./cmd/kopsc +mv ./bin/kopsc /usr/local/bin +chmod +x /usr/local/bin/kopsc + +echo "已安装: /usr/local/bin/kopsc" diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 7b741e7..447e724 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -311,9 +311,9 @@ func (a *App) newLeaf(cmd *registry.Command, name string) *cobra.Command { return plan.Run(context.Background(), executor.Options{ DryRun: dryRun, Confirmed: yes, - Stdin: a.Stdin, - Stdout: a.Stdout, - Stderr: a.Stderr, + Stdin: a.Stdin, + Stdout: a.Stdout, + Stderr: a.Stderr, }) }, } diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 84c29db..3b2e76b 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -116,10 +116,10 @@ func (p Plan) Run(ctx context.Context, opts Options) error { return fmt.Errorf("未注册的内置动作 %q", variant.Builtin) } return fn(ctx, BuiltinRequest{ - Args: p.ExtraArgs, - Stdin: opts.Stdin, - Stdout: opts.Stdout, - Stderr: opts.Stderr, + Args: p.ExtraArgs, + Stdin: opts.Stdin, + Stdout: opts.Stdout, + Stderr: opts.Stderr, Info: p.Result.Info, Plan: p, Confirmed: opts.Confirmed, diff --git a/internal/executor/gitretime.go b/internal/executor/gitretime.go index ab26164..63ca31f 100644 --- a/internal/executor/gitretime.go +++ b/internal/executor/gitretime.go @@ -32,6 +32,13 @@ type retimeOptions struct { // spacing found on the remote branch. A zero (or out-of-range) entry falls // back to a random gap. 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. + 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) } // defaultRetimeOptions matches the command's documented behaviour: commits land @@ -59,33 +66,60 @@ func planRetime(commits []gitCommit, opts retimeOptions) []retime { window := opts.WindowEnd - opts.WindowStart 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 + } + // Group by calendar day, preserving order. var dayCommits []gitCommit - currentDay := time.Time{} + var day time.Time flush := func() { if len(dayCommits) == 0 { return } - out = append(out, planDay(dayCommits, opts, window)...) + out = append(out, planDay(day, dayCommits, opts, window)...) dayCommits = nil } for _, c := range commits { - day := c.Date() - if !currentDay.IsZero() && !day.Equal(currentDay) { + 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() } - currentDay = day + day = original dayCommits = append(dayCommits, 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()) +} + // planDay spreads one day's commits across the window. -func planDay(commits []gitCommit, opts retimeOptions, window time.Duration) []retime { +func planDay(day time.Time, commits []gitCommit, opts retimeOptions, window time.Duration) []retime { out := make([]retime, 0, len(commits)) - day := commits[0].Date() offsets, squeezed := spreadWithin(day, len(commits), opts, window) diff --git a/internal/executor/gitretime_cmd.go b/internal/executor/gitretime_cmd.go index 9ddf066..aab4e17 100644 --- a/internal/executor/gitretime_cmd.go +++ b/internal/executor/gitretime_cmd.go @@ -25,6 +25,12 @@ type gitRetimeOptions struct { 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 + DayGap int + PerDay int } // runGitRetime rewrites the timestamps of commits in [From, To] so they land @@ -110,8 +116,18 @@ func writeRetimePreview(req BuiltinRequest, plan retimePlan, opts gitRetimeOptio 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)) + 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) + } + } + fmt.Fprintf(w, "窗口: %s-%s(%s)\n\n", + clockOf(opts.Window), clockOf(opts.WindowEnd), dateRule) rows := [][]string{{"提交", "原时间", "新时间", "主题"}} for _, item := range plan.Commits { @@ -122,13 +138,21 @@ func writeRetimePreview(req BuiltinRequest, plan retimePlan, opts gitRetimeOptio rows = append(rows, []string{ item.Short, item.OldAuthor.Format("2006-01-02 15:04:05"), - item.NewTime.Format("15:04:05") + mark, + 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 { @@ -193,6 +217,9 @@ func collectRetimePlan(ctx context.Context, runner gitRunner, opts gitRetimeOpti MaxGap: opts.MaxGap, FirstGapMax: 20 * time.Minute, Rand: newRetimeRand(opts.Seed), + StartDate: opts.StartDate, + DayGap: opts.DayGap, + PerDay: opts.PerDay, } _ = opts.MatchEmote return buildPlan(selected, plannerOpts, branch, remote), nil @@ -353,6 +380,34 @@ func parseGitRetimeArgs(args []string, now time.Time) (gitRetimeOptions, bool, e 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 "--day-gap": + 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 { + 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) } @@ -382,6 +437,17 @@ 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. + if opts.StartDate.IsZero() { + if opts.DayGap > 0 || opts.PerDay > 0 { + return opts, false, fmt.Errorf("--day-gap 与 --per-day 需要配合 --start-date 使用" + + "(不给 --start-date 时保留每个提交的原日期)") + } + opts.DayGap = 1 + } else if opts.DayGap < 1 { + // Re-dating defaults to consecutive days. + opts.DayGap = 1 + } return opts, confirmed, nil } diff --git a/internal/executor/gitretime_cmd_test.go b/internal/executor/gitretime_cmd_test.go index 2ae98ca..b7b5a14 100644 --- a/internal/executor/gitretime_cmd_test.go +++ b/internal/executor/gitretime_cmd_test.go @@ -423,3 +423,47 @@ func fixtureResp(args ...string) gitResponse { } return r } + +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", + }, 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) + } +} + +func TestParseGitRetimeArgsRedateValidation(t *testing.T) { + tests := []struct { + 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", "--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"}, + } + for _, tc := range tests { + _, _, err := parseGitRetimeArgs(tc.args, time.Now()) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("args %v: err = %v, want %q", tc.args, err, tc.want) + } + } +} + +func TestParseGitRetimeArgsDefaultDayGap(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) + } +} diff --git a/internal/executor/gitretime_test.go b/internal/executor/gitretime_test.go index e010321..c263894 100644 --- a/internal/executor/gitretime_test.go +++ b/internal/executor/gitretime_test.go @@ -327,3 +327,87 @@ func TestBuildPlanListsEachSqueezedDayOnce(t *testing.T) { t.Errorf("squeezed day = %q", got) } } + +func TestPlanRetimeRedateStartingFromDay(t *testing.T) { + // Three commits originally on 2026-10-01, re-dated from 2026-11-01 with one + // commit per day. + 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), + } + opts := defaultRetimeOptions(3) + opts.StartDate = day(2026, 11, 1) + opts.PerDay = 1 + opts.DayGap = 1 + + 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) + } + 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) + } + } +} + +func TestPlanRetimeRedateHonoursDayGap(t *testing.T) { + date := day(2026, 10, 1) + commits := []gitCommit{ + commitAt("c1", date, 9, 0, 0), + commitAt("c2", date, 10, 0, 0), + } + opts := defaultRetimeOptions(4) + opts.StartDate = day(2026, 11, 1) + opts.PerDay = 1 + opts.DayGap = 3 + + 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) + } +} + +func TestPlanRetimeRedatePacksSeveralPerDay(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)) + } + opts := defaultRetimeOptions(5) + opts.StartDate = day(2026, 11, 1) + opts.PerDay = 2 + opts.DayGap = 1 + + 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) + } +} + +func TestPlanRetimeWithoutStartDateKeepsOriginalDays(t *testing.T) { + commits := []gitCommit{ + commitAt("c1", day(2026, 10, 1), 9, 0, 0), + commitAt("c2", day(2026, 10, 5), 9, 0, 0), + } + plan := planRetime(commits, defaultRetimeOptions(1)) + if a, b := plan[0].NewTime.In(cst).Format("2006-01-02"), plan[1].NewTime.In(cst).Format("2006-01-02"); a != "2026-10-01" || b != "2026-10-05" { + t.Fatalf("original days changed: %s, %s", a, b) + } +} diff --git a/internal/registry/builtin.go b/internal/registry/builtin.go index 5a1b1f5..2534b25 100644 --- a/internal/registry/builtin.go +++ b/internal/registry/builtin.go @@ -487,7 +487,8 @@ func Builtin() *Registry { Summary: "把指定日期范围的提交时间改到 21:00-23:00 并强推", Action: "读取本地分支在给定日期范围内(含两端)的提交,保留各自的原日期," + "把时分秒重排到 21:00-23:00 之间:同一日期内相邻提交严格递增,间隔取 5-40 分钟的随机值(秒位随机)," + - "也可用 --interval match-remote 参照远程分支的间隔。随后重建这段历史并以 --force-with-lease 强推。" + + "也可用 --interval match-remote 参照远程分支的间隔;加 --start-date 时改为按顺序重排日期" + + "(--per-day 控制每天几个、--day-gap 控制日期间隔)。随后重建这段历史并以 --force-with-lease 强推。" + "改动前自动创建 refs/kopsc-backup/<分支>-<时间戳> 备份引用,工作区有未提交改动时拒绝执行;" + "默认只打印预览表并以退出码 2 结束,必须显式加 --yes 才会改写并推送", Usage: "<开始日期> <结束日期> [选项]",