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:
2026-11-01 09:49:03 +08:00
parent f62c1f4872
commit f3b90d205c
19 changed files with 2581 additions and 4 deletions
+134
View File
@@ -0,0 +1,134 @@
package executor
import (
"context"
"errors"
"strings"
"testing"
"time"
)
// gitResponse is one canned git result.
type gitResponse struct {
out, errOut string
err error
}
// fakeGitRunner answers git invocations from a table keyed by the joined args.
type fakeGitRunner struct {
responses map[string]gitResponse
calls []string
envs []([]string)
}
func (f *fakeGitRunner) Run(ctx context.Context, args ...string) (string, string, error) {
return f.RunEnv(ctx, nil, args...)
}
// RunEnv records the environment it was given so tests can assert on the
// author/committer dates passed to git commit-tree.
func (f *fakeGitRunner) RunEnv(ctx context.Context, env []string, args ...string) (string, string, error) {
key := strings.Join(args, " ")
f.calls = append(f.calls, key)
if len(env) > 0 {
f.envs = append(f.envs, env)
}
if r, ok := f.responses[key]; ok {
return r.out, r.errOut, r.err
}
return "", "unexpected git call: " + key, errors.New("exit status 1")
}
func TestParseGitLog(t *testing.T) {
out := "aaaa1111\t4f2a1b0\t2026-10-01T21:07:43+08:00\t2026-10-01T21:07:43+08:00\tfeat: 初始提交\n" +
"bbbb2222\t9c6f072\t2026-10-02T22:32:11+08:00\t2026-10-02T22:33:00+08:00\tdocs: 补充说明\n"
commits, err := parseGitLog(out)
if err != nil {
t.Fatalf("parseGitLog: %v", err)
}
if len(commits) != 2 {
t.Fatalf("parsed %d commits, want 2", len(commits))
}
first := commits[0]
if first.Hash != "aaaa1111" || first.Short != "4f2a1b0" || first.Subject != "feat: 初始提交" {
t.Errorf("first = %+v", first)
}
// Times keep their seconds, which the retiming relies on.
if first.AuthorTime.Second() != 43 || first.AuthorTime.Hour() != 21 || first.AuthorTime.Minute() != 7 {
t.Errorf("author time lost precision: %v", first.AuthorTime)
}
if commits[1].CommitTime.After(commits[1].AuthorTime) != true {
t.Errorf("committer time not parsed: %v", commits[1].CommitTime)
}
}
func TestParseGitLogSkipsTrailingBlankLine(t *testing.T) {
out := "aaaa\t4f2\t2026-10-01T21:07:43+08:00\t2026-10-01T21:07:43+08:00\tsubject\n\n"
commits, err := parseGitLog(out)
if err != nil || len(commits) != 1 {
t.Fatalf("commits = %v, err = %v", commits, err)
}
}
func TestParseGitLogKeepsTabsInSubject(t *testing.T) {
// SplitN with limit 5 keeps anything the subject contains, tabs included.
out := "aaaa\t4f2\t2026-10-01T21:00:00+08:00\t2026-10-01T21:00:00+08:00\tsub\twith\ttabs\n"
commits, err := parseGitLog(out)
if err != nil {
t.Fatal(err)
}
if commits[0].Subject != "sub\twith\ttabs" {
t.Errorf("subject = %q", commits[0].Subject)
}
}
func TestParseGitLogRejectsBrokenInput(t *testing.T) {
if _, err := parseGitLog("not-enough-fields\n"); err == nil {
t.Error("expected an error for a short record")
}
bad := "a\tb\tnot-a-time\t2026-10-01T21:00:00+08:00\ts\n"
if _, err := parseGitLog(bad); err == nil || !strings.Contains(err.Error(), "作者时间") {
t.Errorf("err = %v, want an author-time error", err)
}
}
func TestGitCommitDate(t *testing.T) {
loc := time.FixedZone("CST", 8*3600)
c := gitCommit{AuthorTime: time.Date(2026, 10, 5, 21, 7, 43, 0, loc)}
d := c.Date()
if d.Hour() != 0 || d.Minute() != 0 || d.Day() != 5 || d.Month() != time.October || d.Year() != 2026 {
t.Fatalf("Date() = %v", d)
}
if d.Location() != loc {
t.Errorf("Date() lost the original time zone: %v", d.Location())
}
}
func TestListCommitsRunsGitLog(t *testing.T) {
runner := &fakeGitRunner{responses: map[string]gitResponse{
"log --format=" + gitLogFormat + " main": {
out: "aaaa\t4f2\t2026-10-01T21:00:00+08:00\t2026-10-01T21:00:00+08:00\tsubject\n",
},
}}
commits, err := listCommits(context.Background(), runner, "main")
if err != nil {
t.Fatalf("listCommits: %v", err)
}
if len(commits) != 1 || commits[0].Short != "4f2" {
t.Fatalf("commits = %+v", commits)
}
}
func TestListCommitsReportsGitFailure(t *testing.T) {
runner := &fakeGitRunner{responses: map[string]gitResponse{
"log --format=" + gitLogFormat + " main": {
errOut: "fatal: not a git repository", err: errors.New("exit status 128"),
},
}}
_, err := listCommits(context.Background(), runner, "main")
if err == nil || !strings.Contains(err.Error(), "not a git repository") {
t.Fatalf("err = %v, want git's stderr", err)
}
}