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
+329
View File
@@ -0,0 +1,329 @@
package executor
import (
"math/rand"
"testing"
"time"
)
var cst = time.FixedZone("CST", 8*3600)
// commitAt builds a commit whose author time is at hour:minute on date.
func commitAt(short string, date time.Time, hour, minute, second int) gitCommit {
ts := time.Date(date.Year(), date.Month(), date.Day(), hour, minute, second, 0, cst)
return gitCommit{Hash: short + "-full", Short: short, AuthorTime: ts, CommitTime: ts, Subject: "subject " + short}
}
func day(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, cst) }
func TestPlanRetimeKeepsDateAndWindow(t *testing.T) {
// Three commits spread over two days.
commits := []gitCommit{
commitAt("a1", day(2026, 10, 1), 10, 15, 0),
commitAt("a2", day(2026, 10, 1), 18, 40, 0),
commitAt("b1", day(2026, 10, 2), 9, 0, 0),
}
opts := defaultRetimeOptions(42)
plan := planRetime(commits, opts)
if len(plan) != 3 {
t.Fatalf("plan has %d entries, want 3", len(plan))
}
for i, r := range plan {
// The calendar day never changes.
wantDay := commits[i].Date()
gotDay := r.NewTime.In(cst)
if gotDay.Year() != wantDay.Year() || gotDay.Month() != wantDay.Month() || gotDay.Day() != wantDay.Day() {
t.Errorf("commit %s moved to another day: %v -> %v", r.Short, r.OldAuthor, r.NewTime)
}
// The time of day lands in [21:00, 23:00).
hm := r.NewTime.In(cst).Hour()*60 + r.NewTime.In(cst).Minute()
if hm < 21*60 || hm >= 23*60 {
t.Errorf("commit %s outside 21:00-23:00: %v", r.Short, r.NewTime.In(cst))
}
}
}
func TestPlanRetimeIsStrictlyIncreasingWithinADay(t *testing.T) {
commits := []gitCommit{
commitAt("a1", day(2026, 10, 1), 8, 0, 0),
commitAt("a2", day(2026, 10, 1), 9, 0, 0),
commitAt("a3", day(2026, 10, 1), 10, 0, 0),
}
plan := planRetime(commits, defaultRetimeOptions(7))
for i := 1; i < len(plan); i++ {
if !plan[i].NewTime.After(plan[i-1].NewTime) {
t.Fatalf("commit %d (%v) is not after commit %d (%v)",
i, plan[i].NewTime, i-1, plan[i-1].NewTime)
}
}
}
func TestPlanRetimePreservesCommitOrder(t *testing.T) {
commits := []gitCommit{
commitAt("a1", day(2026, 10, 1), 23, 30, 0),
commitAt("a2", day(2026, 10, 2), 1, 0, 0),
commitAt("a3", day(2026, 10, 2), 2, 0, 0),
}
plan := planRetime(commits, defaultRetimeOptions(3))
for i, r := range plan {
if r.Short != commits[i].Short {
t.Fatalf("plan[%d] = %s, want %s (order must be preserved)", i, r.Short, commits[i].Short)
}
}
// Even across the midnight boundary the plan stays ascending.
for i := 1; i < len(plan); i++ {
if !plan[i].NewTime.After(plan[i-1].NewTime) {
t.Fatalf("plan is not ascending at %d", i)
}
}
}
func TestRandomDurationBounds(t *testing.T) {
r := newTestRand(1)
min, max := 5*time.Minute, 40*time.Minute
sawSecond := false
for i := 0; i < 500; i++ {
d := randomDuration(r, min, max)
if d < min || d >= max {
t.Fatalf("duration %v outside [%v, %v)", d, min, max)
}
if d%time.Minute != 0 {
sawSecond = true
}
}
// The gaps must not sit on minute boundaries, or the commits look machine
// generated (every timestamp at :00 seconds).
if !sawSecond {
t.Error("randomDuration never produced a non-zero second component")
}
}
func TestPlanRetimeFirstCommitStartsSoonAfterWindowStart(t *testing.T) {
r := newTestRand(11)
for i := 0; i < 100; i++ {
plan := planRetime([]gitCommit{commitAt("a1", day(2026, 10, 1), 9, 0, 0)}, defaultRetimeOptions(int64(i)))
ts := plan[0].NewTime.In(cst)
if ts.Hour() < 21 || ts.Hour() > 21 || ts.Minute() > 20 {
t.Fatalf("first commit at %v, want between 21:00 and 21:20", ts)
}
}
_ = r
}
func TestPlanRetimeGapsStayWithinRange(t *testing.T) {
// Six commits on one day, comfortably inside the 120-minute window.
date := day(2026, 10, 1)
var commits []gitCommit
for i := 0; i < 6; i++ {
commits = append(commits, commitAt("c"+string(rune('1'+i)), date, 9, i, 0))
}
plan := planRetime(commits, defaultRetimeOptions(99))
for i := 1; i < len(plan); i++ {
gap := plan[i].NewTime.Sub(plan[i-1].NewTime)
if gap < 5*time.Minute || gap > 40*time.Minute {
t.Errorf("gap %d = %v, want within [5m, 40m]", i, gap)
}
}
}
func TestPlanRetimeSeedIsReproducible(t *testing.T) {
commits := []gitCommit{
commitAt("a1", day(2026, 10, 1), 9, 0, 0),
commitAt("a2", day(2026, 10, 1), 10, 0, 0),
}
first := planRetime(commits, defaultRetimeOptions(2024))
second := planRetime(commits, defaultRetimeOptions(2024))
for i := range first {
if !first[i].NewTime.Equal(second[i].NewTime) {
t.Fatalf("same seed produced different times: %v vs %v", first[i].NewTime, second[i].NewTime)
}
}
}
// newTestRand returns a deterministic source for repeatable tests.
func newTestRand(seed int64) *rand.Rand { return rand.New(rand.NewSource(seed)) }
func TestPlanRetimeCompressesWhenWindowIsFull(t *testing.T) {
// 30 commits on one day cannot fit 5-minute gaps into 120 minutes
// (29 * 5 = 145), so the day must be compressed.
date := day(2026, 10, 1)
var commits []gitCommit
for i := 0; i < 30; i++ {
commits = append(commits, commitAt("c", date, 9, 0, i))
}
plan := planRetime(commits, defaultRetimeOptions(5))
for i, r := range plan {
ts := r.NewTime.In(cst)
if ts.Day() != 1 || ts.Hour() < 21 || ts.Hour() >= 23 {
t.Fatalf("commit %d at %v, outside the day's 21:00-23:00 window", i, ts)
}
if i > 0 && !r.NewTime.After(plan[i-1].NewTime) {
t.Fatalf("commit %d is not after the previous one", i)
}
// Every commit of a compressed day is flagged, so the preview can say so.
if !r.Squeezed {
t.Fatalf("commit %d should be marked as squeezed", i)
}
}
}
func TestPlanRetimeNotSqueezedWhenThereIsRoom(t *testing.T) {
date := day(2026, 10, 1)
commits := []gitCommit{
commitAt("a1", date, 9, 0, 0),
commitAt("a2", date, 10, 0, 0),
}
for _, r := range planRetime(commits, defaultRetimeOptions(5)) {
if r.Squeezed {
t.Fatalf("two commits should fit comfortably, got squeezed %v", r.NewTime)
}
}
}
func TestSpreadWithinCompressedStaysInsideWindow(t *testing.T) {
offsets, squeezed := spreadWithin(day(2026, 10, 1), 40, defaultRetimeOptions(1), 2*time.Hour)
if !squeezed {
t.Fatal("40 commits cannot fit 5-minute gaps in 2 hours")
}
for i, off := range offsets {
if off < 0 || off >= 2*time.Hour {
t.Fatalf("offset %d = %v, outside the window", i, off)
}
if i > 0 && off <= offsets[i-1] {
t.Fatalf("offsets are not increasing at %d", i)
}
}
}
func TestRemoteGaps(t *testing.T) {
base := time.Date(2026, 10, 1, 9, 0, 0, 0, cst)
remote := []time.Time{
base,
base.Add(12 * time.Minute),
base.Add(12*time.Minute + 30*time.Minute),
}
gaps := remoteGaps(remote)
if len(gaps) != 3 {
t.Fatalf("gaps = %v", gaps)
}
if gaps[1] != 12*time.Minute || gaps[2] != 30*time.Minute {
t.Fatalf("gaps = %v, want 12m then 30m", gaps)
}
// Too few commits or out-of-order history yields no gaps (random fallback).
if got := remoteGaps(remote[:1]); got != nil {
t.Errorf("single commit should yield nil gaps, got %v", got)
}
if got := remoteGaps([]time.Time{base.Add(time.Hour), base}); got != nil {
t.Errorf("unsorted history should yield nil gaps, got %v", got)
}
}
func TestPlanRetimeUsesSuppliedGaps(t *testing.T) {
date := day(2026, 10, 1)
commits := []gitCommit{
commitAt("a1", date, 9, 0, 0),
commitAt("a2", date, 10, 0, 0),
commitAt("a3", date, 11, 0, 0),
}
opts := defaultRetimeOptions(1)
opts.Gaps = []time.Duration{0, 12 * time.Minute, 30 * time.Minute}
plan := planRetime(commits, opts)
if got := plan[1].NewTime.Sub(plan[0].NewTime); got != 12*time.Minute {
t.Errorf("gap 1 = %v, want 12m", got)
}
if got := plan[2].NewTime.Sub(plan[1].NewTime); got != 30*time.Minute {
t.Errorf("gap 2 = %v, want 30m", got)
}
}
func TestPlanRetimeFallsBackWhenSuppliedGapIsOutOfRange(t *testing.T) {
date := day(2026, 10, 1)
commits := []gitCommit{
commitAt("a1", date, 9, 0, 0),
commitAt("a2", date, 10, 0, 0),
}
opts := defaultRetimeOptions(1)
// A 3-hour gap from the remote branch would leave the 21:00-23:00 window,
// so it must be rejected in favour of a random gap.
opts.Gaps = []time.Duration{0, 3 * time.Hour}
gap := planRetime(commits, opts)[1].NewTime.Sub(planRetime(commits, opts)[0].NewTime)
if gap < 5*time.Minute || gap > 40*time.Minute {
t.Fatalf("gap = %v, want a random gap inside [5m, 40m]", gap)
}
}
func TestPlanRetimeHonoursSeed(t *testing.T) {
date := day(2026, 10, 1)
commits := []gitCommit{
commitAt("a1", date, 9, 0, 0),
commitAt("a2", date, 10, 0, 0),
commitAt("a3", date, 11, 0, 0),
}
a := planRetime(commits, defaultRetimeOptions(7))
b := planRetime(commits, defaultRetimeOptions(7))
c := planRetime(commits, defaultRetimeOptions(8))
for i := range a {
if !a[i].NewTime.Equal(b[i].NewTime) {
t.Fatalf("seed 7 is not reproducible at %d: %v vs %v", i, a[i].NewTime, b[i].NewTime)
}
}
same := true
for i := range a {
if !a[i].NewTime.Equal(c[i].NewTime) {
same = false
}
}
if same {
t.Error("different seeds produced identical times")
}
}
func TestBuildPlanOrdersOldestFirstAndRecordsSqueezedDays(t *testing.T) {
date := day(2026, 10, 1)
// git log hands commits newest first; buildPlan must flip them.
newestFirst := []gitCommit{
commitAt("c3", date, 11, 0, 0),
commitAt("c2", date, 10, 0, 0),
commitAt("c1", date, 9, 0, 0),
}
plan := buildPlan(newestFirst, defaultRetimeOptions(1), "main", "origin")
if len(plan.Commits) != 3 {
t.Fatalf("plan has %d commits", len(plan.Commits))
}
wantOrder := []string{"c1", "c2", "c3"}
for i, want := range wantOrder {
if plan.Commits[i].Short != want {
t.Fatalf("plan order = %v, want oldest first", []string{
plan.Commits[0].Short, plan.Commits[1].Short, plan.Commits[2].Short})
}
}
if plan.Branch != "main" || plan.Remote != "origin" {
t.Errorf("plan branch/remote = %q/%q", plan.Branch, plan.Remote)
}
// Three commits fit comfortably, so no day is flagged.
if len(plan.Squeezed) != 0 {
t.Errorf("unexpected squeezed days: %v", plan.Squeezed)
}
}
func TestBuildPlanListsEachSqueezedDayOnce(t *testing.T) {
date := day(2026, 10, 2)
var newestFirst []gitCommit
for i := 29; i >= 0; i-- {
newestFirst = append(newestFirst, commitAt("c", date, 9, 0, i))
}
plan := buildPlan(newestFirst, defaultRetimeOptions(3), "main", "origin")
if len(plan.Squeezed) != 1 {
t.Fatalf("squeezed days = %v, want exactly one entry for 2026-10-02", plan.Squeezed)
}
if got := plan.Squeezed[0].Format("2006-01-02"); got != "2026-10-02" {
t.Errorf("squeezed day = %q", got)
}
}