- 新增 --end-date:提交按比例铺满 [start-date, end-date],第 i 个提交 落在第 i×天数÷提交数 天,提交远少于天数时也不会堆在第一天 - 移除 --per-day / --day-gap:原先的“每天一个、日期顺延”语义不直观 - 只给 --start-date 时集中在该日;--end-date 需配合 --start-date 且不早于它 - 同一天的多条提交仍按顺序落在 21:00-23:00 之间 - 文档与预览文案同步为区间语义
471 lines
15 KiB
Go
471 lines
15 KiB
Go
package executor
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
func TestParseGitRetimeArgsDefaults(t *testing.T) {
|
||
now := time.Date(2026, 11, 1, 9, 0, 0, 0, cst)
|
||
opts, confirmed, err := parseGitRetimeArgs([]string{"2026-10-01", "2026-10-15"}, now)
|
||
if err != nil {
|
||
t.Fatalf("parse: %v", err)
|
||
}
|
||
if confirmed {
|
||
t.Error("--yes must not be implied")
|
||
}
|
||
if opts.Window != 21*time.Hour || opts.WindowEnd != 23*time.Hour {
|
||
t.Errorf("window = %v-%v, want 21:00-23:00", opts.Window, opts.WindowEnd)
|
||
}
|
||
if opts.MinGap != 5*time.Minute || opts.MaxGap != 40*time.Minute {
|
||
t.Errorf("gaps = %v-%v, want 5m-40m", opts.MinGap, opts.MaxGap)
|
||
}
|
||
if opts.From.Format("2006-01-02") != "2026-10-01" || opts.To.Format("2006-01-02") != "2026-10-15" {
|
||
t.Errorf("range = %v..%v", opts.From, opts.To)
|
||
}
|
||
}
|
||
|
||
func TestParseGitRetimeArgsRequiresTwoDates(t *testing.T) {
|
||
now := time.Now()
|
||
for _, args := range [][]string{
|
||
nil,
|
||
{"2026-10-01"},
|
||
{"2026-10-01", "2026-10-02", "2026-10-03"},
|
||
} {
|
||
if _, _, err := parseGitRetimeArgs(args, now); err == nil ||
|
||
!strings.Contains(err.Error(), "需要开始与结束日期") {
|
||
t.Errorf("args %v: err = %v", args, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestParseGitRetimeArgsRejectsBadDates(t *testing.T) {
|
||
now := time.Now()
|
||
if _, _, err := parseGitRetimeArgs([]string{"2026/10/01", "2026-10-15"}, now); err == nil ||
|
||
!strings.Contains(err.Error(), "YYYY-MM-DD") {
|
||
t.Errorf("err = %v, want a date-format error", err)
|
||
}
|
||
if _, _, err := parseGitRetimeArgs([]string{"2026-10-15", "2026-10-01"}, now); err == nil ||
|
||
!strings.Contains(err.Error(), "早于") {
|
||
t.Errorf("err = %v, want a reversed-range error", err)
|
||
}
|
||
}
|
||
|
||
func TestParseGitRetimeArgsOptions(t *testing.T) {
|
||
now := time.Now()
|
||
args := []string{
|
||
"2026-10-01", "2026-10-15",
|
||
"--window", "20:00-22:30",
|
||
"--interval", "10-25",
|
||
"--seed", "42",
|
||
"--remote", "upstream",
|
||
"--branch", "release",
|
||
"--no-push",
|
||
"--yes",
|
||
}
|
||
opts, confirmed, err := parseGitRetimeArgs(args, now)
|
||
if err != nil {
|
||
t.Fatalf("parse: %v", err)
|
||
}
|
||
if !confirmed {
|
||
t.Error("--yes should be recognised")
|
||
}
|
||
if opts.Window != 20*time.Hour || opts.WindowEnd != 22*time.Hour+30*time.Minute {
|
||
t.Errorf("window = %v-%v", opts.Window, opts.WindowEnd)
|
||
}
|
||
if opts.MinGap != 10*time.Minute || opts.MaxGap != 25*time.Minute {
|
||
t.Errorf("gaps = %v-%v", opts.MinGap, opts.MaxGap)
|
||
}
|
||
if opts.Seed != 42 || !opts.SeedSet {
|
||
t.Errorf("seed = %d", opts.Seed)
|
||
}
|
||
if opts.Remote != "upstream" || opts.Branch != "release" || !opts.NoPush {
|
||
t.Errorf("opts = %+v", opts)
|
||
}
|
||
}
|
||
|
||
func TestParseInterval(t *testing.T) {
|
||
opts := gitRetimeOptions{}
|
||
if err := applyInterval(&opts, "match-remote"); err != nil || !opts.MatchEmote {
|
||
t.Fatalf("match-remote: %v %+v", err, opts)
|
||
}
|
||
opts = gitRetimeOptions{}
|
||
if err := applyInterval(&opts, "3-15"); err != nil || opts.MinGap != 3*time.Minute || opts.MaxGap != 15*time.Minute {
|
||
t.Fatalf("3-15: %v %+v", err, opts)
|
||
}
|
||
for _, bad := range []string{"15-3", "0-10", "abc", "5"} {
|
||
if err := applyInterval(&gitRetimeOptions{}, bad); err == nil {
|
||
t.Errorf("applyInterval(%q) should fail", bad)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestParseWindow(t *testing.T) {
|
||
start, end, err := parseWindow("21:00-23:00")
|
||
if err != nil || start != 21*time.Hour || end != 23*time.Hour {
|
||
t.Fatalf("parseWindow = %v, %v, %v", start, end, err)
|
||
}
|
||
for _, bad := range []string{"21:00", "25:00-26:00", "23:00-21:00", "21:00-21:00"} {
|
||
if _, _, err := parseWindow(bad); err == nil {
|
||
t.Errorf("parseWindow(%q) should fail", bad)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestParseGitRetimeArgsRejectsUnknownOption(t *testing.T) {
|
||
if _, _, err := parseGitRetimeArgs([]string{"2026-10-01", "2026-10-02", "--nope"}, time.Now()); err == nil {
|
||
t.Fatal("unknown option should fail")
|
||
}
|
||
}
|
||
|
||
func TestParseGitRetimeArgsWindowAliases(t *testing.T) {
|
||
now := time.Now()
|
||
for _, flag := range []string{"--window", "--since", "--until"} {
|
||
opts, _, err := parseGitRetimeArgs([]string{"2026-10-01", "2026-10-02", flag, "20:00-22:00"}, now)
|
||
if err != nil {
|
||
t.Fatalf("%s: %v", flag, err)
|
||
}
|
||
if opts.Window != 20*time.Hour || opts.WindowEnd != 22*time.Hour {
|
||
t.Errorf("%s: window = %v-%v", flag, opts.Window, opts.WindowEnd)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestParseGitRetimeArgsAllOptionsTogether(t *testing.T) {
|
||
// Every documented option must survive parsing together.
|
||
args := []string{
|
||
"2026-10-01", "2026-10-15",
|
||
"--since", "21:00-23:00",
|
||
"--interval", "5-40",
|
||
"--seed", "1234",
|
||
"--remote", "origin",
|
||
"--branch", "main",
|
||
"--no-push",
|
||
"--yes",
|
||
}
|
||
opts, confirmed, err := parseGitRetimeArgs(args, time.Now())
|
||
if err != nil {
|
||
t.Fatalf("parse: %v", err)
|
||
}
|
||
if !confirmed || !opts.NoPush || opts.Remote != "origin" || opts.Branch != "main" {
|
||
t.Fatalf("opts = %+v confirmed=%v", opts, confirmed)
|
||
}
|
||
if opts.Seed != 1234 || opts.MinGap != 5*time.Minute || opts.MaxGap != 40*time.Minute {
|
||
t.Fatalf("opts = %+v", opts)
|
||
}
|
||
}
|
||
|
||
// previewFixture fakes a repository with two commits on 2026-10-01.
|
||
func previewFixture() *fakeGitRunner {
|
||
metaFormat := func(hash, tree, parents, subject string) map[string]gitResponse {
|
||
return map[string]gitResponse{
|
||
"log -1 --format=" + rawCommitFormat + " " + hash: {
|
||
out: strings.Join([]string{hash, tree, parents, "Odboy", "t@e.cn", "Odboy", "t@e.cn", subject}, "\t") + "\n",
|
||
},
|
||
"log -1 --format=%B " + hash: {out: subject + "\n"},
|
||
}
|
||
}
|
||
responses := map[string]gitResponse{}
|
||
for _, m := range []map[string]gitResponse{
|
||
metaFormat("h2", "t2", "h1", "second"),
|
||
metaFormat("h1", "t1", "", "first"),
|
||
} {
|
||
for k, v := range m {
|
||
responses[k] = v
|
||
}
|
||
}
|
||
// git log lists newest first.
|
||
responses["log --format="+gitLogFormat+" main"] = gitResponse{
|
||
out: "h2\t2222222\t2026-10-01T22:10:00+08:00\t2026-10-01T22:10:00+08:00\tsecond\n" +
|
||
"h1\t1111111\t2026-10-01T10:00:00+08:00\t2026-10-01T10:00:00+08:00\tfirst\n",
|
||
}
|
||
responses["rev-parse --abbrev-ref HEAD"] = gitResponse{out: "main\n"}
|
||
responses["remote"] = gitResponse{out: "origin\n"}
|
||
return &fakeGitRunner{responses: responses}
|
||
}
|
||
|
||
func TestRetimeWithoutYesOnlyPreviews(t *testing.T) {
|
||
runner := previewFixture()
|
||
var out bytes.Buffer
|
||
err := runGitRetimeWith(context.Background(), BuiltinRequest{
|
||
Args: []string{"2026-10-01", "2026-10-01", "--seed", "1"},
|
||
Stdout: &out,
|
||
Stderr: &out,
|
||
}, runner, time.Date(2026, 11, 1, 9, 0, 0, 0, cst))
|
||
|
||
if err == nil {
|
||
t.Fatal("expected exit code 2 without --yes")
|
||
}
|
||
if code := ExitCode(err); code != 2 {
|
||
t.Fatalf("exit code = %d, want 2", code)
|
||
}
|
||
|
||
text := out.String()
|
||
for _, want := range []string{"目标: origin/main", "窗口: 21:00-23:00", "提交", "原时间", "新时间", "主题", "1111111", "2222222"} {
|
||
if !strings.Contains(text, want) {
|
||
t.Errorf("preview missing %q:\n%s", want, text)
|
||
}
|
||
}
|
||
// Nothing may be touched without --yes.
|
||
for _, call := range runner.calls {
|
||
if strings.HasPrefix(call, "commit-tree") || strings.HasPrefix(call, "update-ref") ||
|
||
strings.HasPrefix(call, "push") {
|
||
t.Fatalf("preview must not modify the repo, but ran: %s", call)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestRetimePreviewAlignsColumns(t *testing.T) {
|
||
runner := previewFixture()
|
||
var out bytes.Buffer
|
||
_ = runGitRetimeWith(context.Background(), BuiltinRequest{
|
||
Args: []string{"2026-10-01", "2026-10-01", "--seed", "1"},
|
||
Stdout: &out,
|
||
Stderr: &out,
|
||
}, runner, time.Now())
|
||
|
||
lines := strings.Split(out.String(), "\n")
|
||
var header string
|
||
var rows []string
|
||
for _, line := range lines {
|
||
if strings.HasPrefix(line, "提交") {
|
||
header = line
|
||
} else if strings.HasPrefix(line, "1111") || strings.HasPrefix(line, "2222") {
|
||
rows = append(rows, line)
|
||
}
|
||
}
|
||
if header == "" || len(rows) != 2 {
|
||
t.Fatalf("unexpected preview:\n%s", out.String())
|
||
}
|
||
// The "原时间" column must start at the same display column everywhere:
|
||
// the heading is 6 columns wide (three CJK runes), the data 19.
|
||
want := columnStartOf(header, "原时间")
|
||
for _, row := range rows {
|
||
// The original time is "YYYY-MM-DD HH:MM:SS": date and clock are two
|
||
// whitespace-separated fields, so match the date field instead.
|
||
var stamp string
|
||
for _, field := range strings.Fields(row) {
|
||
if len(field) == 10 && field[4] == '-' && field[7] == '-' {
|
||
stamp = field
|
||
break
|
||
}
|
||
}
|
||
if stamp == "" {
|
||
t.Fatalf("no timestamp in row: %q", row)
|
||
}
|
||
if got := columnStartOf(row, stamp); got != want {
|
||
t.Errorf("row starts at %d, header at %d:\n%s\n%s", got, want, header, row)
|
||
}
|
||
}
|
||
}
|
||
|
||
// columnStartOf returns the display column where cell begins in line.
|
||
func columnStartOf(line, cell string) int {
|
||
i := strings.Index(line, cell)
|
||
if i < 0 {
|
||
return -1
|
||
}
|
||
return displayWidth(line[:i])
|
||
}
|
||
|
||
func TestRetimeNoCommitsInRange(t *testing.T) {
|
||
offline := func(ctx context.Context, args ...string) (string, string, error) {
|
||
return "", "", fmt.Errorf("empty range must not run git: %v", args)
|
||
}
|
||
_ = offline
|
||
runner := previewFixture()
|
||
var out bytes.Buffer
|
||
// The preview path treats an empty range as "nothing to do", not a failure.
|
||
err := runGitRetimePreviewWith(context.Background(), BuiltinRequest{
|
||
Args: []string{"2026-12-01", "2026-12-31"},
|
||
Stdout: &out,
|
||
Stderr: &out,
|
||
}, runner, time.Now())
|
||
if err != nil {
|
||
t.Fatalf("empty range should succeed: %v", err)
|
||
}
|
||
if !strings.Contains(out.String(), "没有需要改写的提交") {
|
||
t.Errorf("output = %q", out.String())
|
||
}
|
||
}
|
||
|
||
func TestRetimePreviewListsRewriteScope(t *testing.T) {
|
||
runner := previewFixture()
|
||
var out bytes.Buffer
|
||
_ = runGitRetimeWith(context.Background(), BuiltinRequest{
|
||
Args: []string{"2026-10-01", "2026-10-01", "--seed", "1"},
|
||
Stdout: &out,
|
||
Stderr: &out,
|
||
}, runner, time.Now())
|
||
|
||
text := out.String()
|
||
for _, want := range []string{
|
||
"改写: 2 个提交(分支 main,远程 origin)",
|
||
"强推: 是 --force-with-lease → origin/main",
|
||
"窗口: 21:00-23:00",
|
||
} {
|
||
if !strings.Contains(text, want) {
|
||
t.Errorf("preview missing %q:\n%s", want, text)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestRetimePreviewStatesNoPush(t *testing.T) {
|
||
runner := previewFixture()
|
||
var out bytes.Buffer
|
||
_ = runGitRetimeWith(context.Background(), BuiltinRequest{
|
||
Args: []string{"2026-10-01", "2026-10-01", "--no-push", "--seed", "1"},
|
||
Stdout: &out,
|
||
Stderr: &out,
|
||
}, runner, time.Now())
|
||
|
||
if !strings.Contains(out.String(), "强推: 否(--no-push,只改本地)") {
|
||
t.Errorf("preview should say nothing is pushed:\n%s", out.String())
|
||
}
|
||
}
|
||
|
||
func TestGitRetimePreviewSwallowsConfirmationExit(t *testing.T) {
|
||
// The confirmation prompt calls the preview builtin; a code-2 result would
|
||
// abort the prompt instead of showing the plan.
|
||
runner := previewFixture()
|
||
_ = runner
|
||
err := runGitRetimePreview(context.Background(), BuiltinRequest{
|
||
Args: []string{"not-a-date", "2026-10-01"},
|
||
Stdout: &bytes.Buffer{},
|
||
Stderr: &bytes.Buffer{},
|
||
})
|
||
if err == nil {
|
||
t.Fatal("a real parse error must still surface")
|
||
}
|
||
}
|
||
|
||
// TestRetimeWithYesRewritesAndPushes walks the whole flow with --yes: backup,
|
||
// rewrite, verify, move the branch, force push.
|
||
func TestRetimeWithYesRewritesAndPushes(t *testing.T) {
|
||
runner := previewFixture()
|
||
// Content check succeeds (same tree), branch move and push succeed.
|
||
runner.responses["rev-parse h2^{tree}"] = fixtureResp("tree-final\n")
|
||
runner.responses["rev-parse N2^{tree}"] = fixtureResp("tree-final\n")
|
||
runner.responses["commit-tree t1 -m first"] = fixtureResp("N1\n")
|
||
runner.responses["commit-tree t2 -p N1 -m second"] = fixtureResp("N2\n")
|
||
runner.responses["update-ref refs/heads/main N2"] = fixtureResp()
|
||
runner.responses["push --force-with-lease origin main"] = fixtureResp()
|
||
runner.responses["status --porcelain"] = fixtureResp()
|
||
// The backup ref is named after the injected clock.
|
||
runner.responses["update-ref refs/kopsc-backup/main-20261101-093000 h2"] = fixtureResp()
|
||
|
||
var out bytes.Buffer
|
||
err := runGitRetimeWith(context.Background(), BuiltinRequest{
|
||
Args: []string{"2026-10-01", "2026-10-01", "--seed", "1", "--yes"},
|
||
Stdout: &out,
|
||
Stderr: &out,
|
||
}, runner, time.Date(2026, 11, 1, 9, 30, 0, 0, cst))
|
||
if err != nil {
|
||
t.Fatalf("retime --yes: %v\n%s", err, out.String())
|
||
}
|
||
|
||
text := out.String()
|
||
for _, want := range []string{"备份: refs/kopsc-backup/main-20261101-093000", "已改写: 2 个提交", "已强推: origin main", "回滚:"} {
|
||
if !strings.Contains(text, want) {
|
||
t.Errorf("output missing %q:\n%s", want, text)
|
||
}
|
||
}
|
||
|
||
// The whole sequence must have happened, in order.
|
||
seen := func(prefix string) bool {
|
||
for _, call := range runner.calls {
|
||
if strings.HasPrefix(call, prefix) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
for _, prefix := range []string{
|
||
"update-ref refs/kopsc-backup/", "commit-tree", "rev-parse h2^{tree}",
|
||
"update-ref refs/heads/main", "push --force-with-lease origin main",
|
||
} {
|
||
if !seen(prefix) {
|
||
t.Errorf("no %q call; calls = %v", prefix, runner.calls)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestRetimeRefusesDirtyWorktree stops before touching anything.
|
||
func TestRetimeRefusesDirtyWorktree(t *testing.T) {
|
||
runner := previewFixture()
|
||
runner.responses["status --porcelain"] = fixtureResp(" M main.go\n")
|
||
|
||
var out bytes.Buffer
|
||
err := runGitRetimeWith(context.Background(), BuiltinRequest{
|
||
Args: []string{"2026-10-01", "2026-10-01", "--yes"},
|
||
Stdout: &out,
|
||
Stderr: &out,
|
||
}, runner, time.Now())
|
||
if err == nil || !strings.Contains(err.Error(), "未提交的改动") {
|
||
t.Fatalf("err = %v, want a dirty-worktree rejection", err)
|
||
}
|
||
for _, call := range runner.calls {
|
||
if strings.HasPrefix(call, "commit-tree") || strings.HasPrefix(call, "push") {
|
||
t.Fatalf("dirty worktree must not rewrite: %s", call)
|
||
}
|
||
}
|
||
}
|
||
|
||
// fixtureResp builds a canned successful git response.
|
||
func fixtureResp(args ...string) gitResponse {
|
||
r := gitResponse{}
|
||
if len(args) > 0 {
|
||
r.out = args[0]
|
||
}
|
||
return r
|
||
}
|
||
|
||
func TestParseGitRetimeArgsRedate(t *testing.T) {
|
||
opts, _, err := parseGitRetimeArgs([]string{
|
||
"2026-10-01", "2026-10-15",
|
||
"--start-date", "2026-11-01",
|
||
"--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" {
|
||
t.Errorf("StartDate = %v", opts.StartDate)
|
||
}
|
||
if opts.EndDate.Format("2006-01-02") != "2026-11-15" {
|
||
t.Errorf("EndDate = %v", opts.EndDate)
|
||
}
|
||
}
|
||
|
||
func TestParseGitRetimeArgsRedateValidation(t *testing.T) {
|
||
tests := []struct {
|
||
args []string
|
||
want string
|
||
}{
|
||
{[]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", "--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())
|
||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||
t.Errorf("args %v: err = %v, want %q", tc.args, err, tc.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
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.EndDate.IsZero() {
|
||
t.Errorf("EndDate = %v, want the zero value (single start day)", opts.EndDate)
|
||
}
|
||
}
|