- 保留每个提交的原日期,只重排时分秒到 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 补充导航与示例
123 lines
3.8 KiB
Go
123 lines
3.8 KiB
Go
package executor
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// rewriteFixture builds a fake git whose two commits c1 -> c2 live on main.
|
|
func rewriteFixture() *fakeGitRunner {
|
|
meta := func(hash, tree, parents string) map[string]gitResponse {
|
|
return map[string]gitResponse{
|
|
"log -1 --format=" + rawCommitFormat + " " + hash: {
|
|
out: strings.Join([]string{hash, tree, parents, "Odboy", "tianjun@odboy.cn", "Odboy", "tianjun@odboy.cn", "subject of " + hash}, "\t") + "\n",
|
|
},
|
|
"log -1 --format=%B " + hash: {out: "subject of " + hash + "\n\nbody line\n"},
|
|
}
|
|
}
|
|
|
|
responses := map[string]gitResponse{}
|
|
for _, entry := range []map[string]gitResponse{meta("c1", "tree1", "base"), meta("c2", "tree2", "c1")} {
|
|
for k, v := range entry {
|
|
responses[k] = v
|
|
}
|
|
}
|
|
// New commits are named deterministically by the tree they carry.
|
|
responses["commit-tree tree1 -p base -m subject of c1\n\nbody line"] = gitResponse{out: "n1\n"}
|
|
responses["commit-tree tree2 -p n1 -m subject of c2\n\nbody line"] = gitResponse{out: "n2\n"}
|
|
return &fakeGitRunner{responses: responses}
|
|
}
|
|
|
|
func TestRewriteChainRebuildsInOrder(t *testing.T) {
|
|
runner := rewriteFixture()
|
|
plan := retimePlan{
|
|
Commits: []retime{
|
|
{Hash: "c1", Short: "c1", NewTime: time.Date(2026, 10, 1, 21, 7, 43, 0, cst)},
|
|
{Hash: "c2", Short: "c2", NewTime: time.Date(2026, 10, 1, 21, 32, 11, 0, cst)},
|
|
},
|
|
Branch: "main",
|
|
Remote: "origin",
|
|
}
|
|
|
|
tip, rebuilt, err := rewriteChain(context.Background(), runner, plan)
|
|
if err != nil {
|
|
t.Fatalf("rewriteChain: %v", err)
|
|
}
|
|
if tip != "n2" {
|
|
t.Errorf("tip = %q, want n2", tip)
|
|
}
|
|
if len(rebuilt) != 2 || rebuilt[0].New != "n1" || rebuilt[1].New != "n2" {
|
|
t.Fatalf("rebuilt = %+v", rebuilt)
|
|
}
|
|
|
|
// The dates must be passed to git exactly as planned, with the original
|
|
// identities kept.
|
|
if len(runner.envs) != 2 {
|
|
t.Fatalf("expected env for two commits, got %d", len(runner.envs))
|
|
}
|
|
joined := strings.Join(runner.envs[0], " ")
|
|
for _, want := range []string{
|
|
"GIT_AUTHOR_NAME=Odboy",
|
|
"GIT_AUTHOR_EMAIL=tianjun@odboy.cn",
|
|
"GIT_AUTHOR_DATE=2026-10-01T21:07:43+08:00",
|
|
"GIT_COMMITTER_DATE=2026-10-01T21:07:43+08:00",
|
|
} {
|
|
if !strings.Contains(joined, want) {
|
|
t.Errorf("first commit env missing %q: %v", want, runner.envs[0])
|
|
}
|
|
}
|
|
if !strings.Contains(strings.Join(runner.envs[1], " "), "GIT_AUTHOR_DATE=2026-10-01T21:32:11+08:00") {
|
|
t.Errorf("second commit env = %v", runner.envs[1])
|
|
}
|
|
}
|
|
|
|
func TestRebuildCommitKeepsTreeParentsAndMessage(t *testing.T) {
|
|
var gotArgs []string
|
|
var gotEnv []string
|
|
runner := &fakeGitRunner{
|
|
responses: map[string]gitResponse{
|
|
"commit-tree T -p P1 -p P2 -m msg line 1\nline 2": {out: "newhash\n"},
|
|
},
|
|
}
|
|
orig := runner.RunEnv
|
|
_ = orig
|
|
|
|
meta := commitMeta{
|
|
Tree: "T", Parents: []string{"P1", "P2"},
|
|
AuthorName: "A", AuthorEmail: "a@e", CommitName: "C", CommitEmail: "c@e",
|
|
Body: "msg line 1\nline 2",
|
|
}
|
|
hash, err := rebuildCommit(context.Background(), runner, meta, time.Date(2026, 10, 1, 21, 0, 0, 0, cst))
|
|
if err != nil {
|
|
t.Fatalf("rebuildCommit: %v", err)
|
|
}
|
|
if hash != "newhash" {
|
|
t.Errorf("hash = %q", hash)
|
|
}
|
|
gotArgs = append(gotArgs, runner.calls...)
|
|
gotEnv = append(gotEnv, strings.Join(runner.envs[0], " "))
|
|
if !strings.Contains(gotEnv[0], "GIT_COMMITTER_EMAIL=c@e") {
|
|
t.Errorf("env = %v", gotEnv)
|
|
}
|
|
}
|
|
|
|
func TestMoveBranch(t *testing.T) {
|
|
runner := &fakeGitRunner{responses: map[string]gitResponse{
|
|
"update-ref refs/heads/main n2": {out: ""},
|
|
}}
|
|
if err := moveBranch(context.Background(), runner, "main", "n2"); err != nil {
|
|
t.Fatalf("moveBranch: %v", err)
|
|
}
|
|
if len(runner.calls) != 1 || runner.calls[0] != "update-ref refs/heads/main n2" {
|
|
t.Fatalf("calls = %v", runner.calls)
|
|
}
|
|
}
|
|
|
|
func TestRewriteChainEmptyPlan(t *testing.T) {
|
|
if _, _, err := rewriteChain(context.Background(), rewriteFixture(), retimePlan{}); err == nil {
|
|
t.Fatal("expected an error for an empty plan")
|
|
}
|
|
}
|