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:
@@ -0,0 +1,122 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user