Files
kenaito-kopsc/internal/executor/gitretime_cmd_test.go
T
odboy f3b90d205c 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 补充导航与示例
2026-11-01 09:49:03 +08:00

426 lines
13 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
}