Files
kenaito-kopsc/internal/executor/gitretime_cmd_test.go
T
odboy 1ae9488d8a feat: git retime 支持重排提交日期,新增 install.sh
- 新增 --start-date:按原顺序从指定日期起重排提交日期,默认每天一个
- 新增 --per-day 控制每天提交数、--day-gap 控制日期间隔;两者需配合
  --start-date 使用,否则报错提示
- 不给 --start-date 时保持原行为(保留原日期,只改时分秒)
- 预览在重排模式下显示完整的新日期时间,并说明日期规则
- install.sh:补 shebang 与可执行权限,构建后安装到 /usr/local/bin
- 文档:README.git.md 增加「重排日期」小节与选项表
2026-11-01 10:05:19 +08:00

470 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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",
"--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)
}
}