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:
@@ -18,6 +18,9 @@ type BuiltinRequest struct {
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
Info platform.Info
|
||||
// Confirmed reports that the user passed --yes; destructive builtins use
|
||||
// it instead of asking for confirmation a second time.
|
||||
Confirmed bool
|
||||
// Plan is the resolved variant and forwarded arguments. Builtins that
|
||||
// drive an external runtime (for example docker save) read the runtime's
|
||||
// program name and the passthrough arguments from it.
|
||||
@@ -43,6 +46,8 @@ var builtins = map[string]Builtin{
|
||||
"docker.save": runDockerSave,
|
||||
"docker.stats": runDockerStats,
|
||||
"docker.stop": runDockerStop,
|
||||
"git.retime": runGitRetime,
|
||||
"git.retime.preview": runGitRetimePreview,
|
||||
"java.run": runJavaRun,
|
||||
"java.run.preview": runJavaPreview,
|
||||
"net.pids": runNetPIDs,
|
||||
|
||||
@@ -23,6 +23,9 @@ type Options struct {
|
||||
Stderr io.Writer
|
||||
Env []string // optional override of the child environment
|
||||
Dir string // optional working directory
|
||||
// Confirmed reports that the caller already approved a destructive
|
||||
// command (--yes); builtins that would otherwise ask again check it.
|
||||
Confirmed bool
|
||||
}
|
||||
|
||||
// Plan is a resolved variant plus arguments forwarded from the caller.
|
||||
@@ -117,8 +120,9 @@ func (p Plan) Run(ctx context.Context, opts Options) error {
|
||||
Stdin: opts.Stdin,
|
||||
Stdout: opts.Stdout,
|
||||
Stderr: opts.Stderr,
|
||||
Info: p.Result.Info,
|
||||
Plan: p,
|
||||
Info: p.Result.Info,
|
||||
Plan: p,
|
||||
Confirmed: opts.Confirmed,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBackupRefName(t *testing.T) {
|
||||
now := time.Date(2026, 11, 1, 9, 30, 15, 0, cst)
|
||||
got := backupRefName("main", now)
|
||||
if got != "refs/kopsc-backup/main-20261101-093015" {
|
||||
t.Fatalf("backupRefName = %q", got)
|
||||
}
|
||||
if !strings.HasPrefix(got, backupRefPrefix) {
|
||||
t.Errorf("ref must live under %s: %q", backupRefPrefix, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBackupWritesRef(t *testing.T) {
|
||||
runner := &fakeGitRunner{responses: map[string]gitResponse{
|
||||
"update-ref refs/kopsc-backup/main-20261101-093015 abc123": {out: ""},
|
||||
}}
|
||||
now := time.Date(2026, 11, 1, 9, 30, 15, 0, cst)
|
||||
|
||||
ref, err := createBackup(context.Background(), runner, "main", "abc123", now)
|
||||
if err != nil {
|
||||
t.Fatalf("createBackup: %v", err)
|
||||
}
|
||||
if ref != "refs/kopsc-backup/main-20261101-093015" {
|
||||
t.Fatalf("ref = %q", ref)
|
||||
}
|
||||
if len(runner.calls) != 1 || !strings.Contains(runner.calls[0], "update-ref "+ref+" abc123") {
|
||||
t.Fatalf("calls = %v", runner.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCleanWorktreeRejectsDirtyTree(t *testing.T) {
|
||||
runner := &fakeGitRunner{responses: map[string]gitResponse{
|
||||
"status --porcelain": {out: " M main.go\n"},
|
||||
}}
|
||||
err := ensureCleanWorktree(context.Background(), runner)
|
||||
if err == nil || !strings.Contains(err.Error(), "未提交的改动") {
|
||||
t.Fatalf("err = %v, want a dirty-worktree rejection", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "stash") {
|
||||
t.Errorf("error should tell the user how to proceed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCleanWorktreeAcceptsCleanTree(t *testing.T) {
|
||||
runner := &fakeGitRunner{responses: map[string]gitResponse{
|
||||
"status --porcelain": {out: ""},
|
||||
}}
|
||||
if err := ensureCleanWorktree(context.Background(), runner); err != nil {
|
||||
t.Fatalf("clean tree should pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupHint(t *testing.T) {
|
||||
hint := backupHint("main", "refs/kopsc-backup/main-20261101-093015")
|
||||
if !strings.Contains(hint, "git update-ref refs/heads/main refs/kopsc-backup/main-20261101-093015") {
|
||||
t.Errorf("hint = %q", hint)
|
||||
}
|
||||
if !strings.Contains(hint, "git reset --hard") {
|
||||
t.Errorf("hint should mention reset --hard: %q", hint)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// gitCommit is one commit as kopsc needs to see it.
|
||||
type gitCommit struct {
|
||||
Hash string // full SHA-1
|
||||
Short string // abbreviated SHA-1, as git prints it
|
||||
AuthorTime time.Time // author date
|
||||
CommitTime time.Time // committer date
|
||||
Subject string
|
||||
}
|
||||
|
||||
// Date returns the calendar day the commit belongs to, taken from the author
|
||||
// date: retiming keeps commits on their own day.
|
||||
func (c gitCommit) Date() time.Time {
|
||||
return time.Date(c.AuthorTime.Year(), c.AuthorTime.Month(), c.AuthorTime.Day(), 0, 0, 0, 0, c.AuthorTime.Location())
|
||||
}
|
||||
|
||||
// gitRunner executes git commands. It is an interface so history rewriting can
|
||||
// be tested against canned output.
|
||||
type gitRunner interface {
|
||||
Run(ctx context.Context, args ...string) (stdout, stderr string, err error)
|
||||
// RunEnv runs git with extra environment variables, used to set the author
|
||||
// and committer dates when rebuilding commits.
|
||||
RunEnv(ctx context.Context, env []string, args ...string) (stdout, stderr string, err error)
|
||||
}
|
||||
|
||||
// execGitRunner runs git in a directory.
|
||||
type execGitRunner struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
func (r execGitRunner) Run(ctx context.Context, args ...string) (string, string, error) {
|
||||
return r.RunEnv(ctx, nil, args...)
|
||||
}
|
||||
|
||||
// RunEnv runs git with additional environment variables.
|
||||
func (r execGitRunner) RunEnv(ctx context.Context, env []string, args ...string) (string, string, error) {
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
cmd.Dir = r.dir
|
||||
if len(env) > 0 {
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
}
|
||||
var out, errOut strings.Builder
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &errOut
|
||||
err := cmd.Run()
|
||||
return out.String(), errOut.String(), err
|
||||
}
|
||||
|
||||
// gitLogFormat is the record format used by listCommits: full hash, short
|
||||
// hash, ISO-8601 author date, ISO-8601 committer date and subject, separated by
|
||||
// tabs so a subject may contain anything else.
|
||||
const gitLogFormat = "%H%x09%h%x09%aI%x09%cI%x09%s"
|
||||
|
||||
// listCommits returns commits reachable from base, newest first.
|
||||
func listCommits(ctx context.Context, runner gitRunner, base string) ([]gitCommit, error) {
|
||||
out, stderr, err := runner.Run(ctx, "log", "--format="+gitLogFormat, base)
|
||||
if err != nil {
|
||||
return nil, gitError("git log", stderr, err)
|
||||
}
|
||||
return parseGitLog(out)
|
||||
}
|
||||
|
||||
// parseGitLog parses the tab-separated records produced by gitLogFormat.
|
||||
func parseGitLog(out string) ([]gitCommit, error) {
|
||||
var commits []gitCommit
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.SplitN(line, "\t", 5)
|
||||
if len(fields) < 5 {
|
||||
return nil, fmt.Errorf("无法解析 git log 输出(字段不足): %q", line)
|
||||
}
|
||||
author, err := time.Parse(time.RFC3339, fields[2])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法解析作者时间 %q: %w", fields[2], err)
|
||||
}
|
||||
committed, err := time.Parse(time.RFC3339, fields[3])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法解析提交时间 %q: %w", fields[3], err)
|
||||
}
|
||||
commits = append(commits, gitCommit{
|
||||
Hash: fields[0],
|
||||
Short: fields[1],
|
||||
AuthorTime: author,
|
||||
CommitTime: committed,
|
||||
Subject: fields[4],
|
||||
})
|
||||
}
|
||||
return commits, nil
|
||||
}
|
||||
|
||||
// gitError renders a failed git invocation with its stderr.
|
||||
func gitError(command, stderr string, err error) error {
|
||||
detail := strings.TrimSpace(stderr)
|
||||
if detail == "" {
|
||||
detail = err.Error()
|
||||
}
|
||||
return fmt.Errorf("%s 失败: %s", command, detail)
|
||||
}
|
||||
|
||||
// currentBranch returns the checked-out branch name.
|
||||
func currentBranch(ctx context.Context, runner gitRunner) (string, error) {
|
||||
out, stderr, err := runner.Run(ctx, "rev-parse", "--abbrev-ref", "HEAD")
|
||||
if err != nil {
|
||||
return "", gitError("git rev-parse", stderr, err)
|
||||
}
|
||||
branch := strings.TrimSpace(out)
|
||||
if branch == "" || branch == "HEAD" {
|
||||
return "", fmt.Errorf("当前不在任何分支上(detached HEAD);请先切换到分支再重写历史")
|
||||
}
|
||||
return branch, nil
|
||||
}
|
||||
|
||||
// remotes lists the configured remotes.
|
||||
func remotes(ctx context.Context, runner gitRunner) ([]string, error) {
|
||||
out, stderr, err := runner.Run(ctx, "remote")
|
||||
if err != nil {
|
||||
return nil, gitError("git remote", stderr, err)
|
||||
}
|
||||
return strings.Fields(out), nil
|
||||
}
|
||||
|
||||
// isWorktreeClean reports whether there are uncommitted changes. History
|
||||
// rewriting with a dirty tree risks losing work on a failed checkout.
|
||||
func isWorktreeClean(ctx context.Context, runner gitRunner) (bool, error) {
|
||||
out, stderr, err := runner.Run(ctx, "status", "--porcelain")
|
||||
if err != nil {
|
||||
return false, gitError("git status", stderr, err)
|
||||
}
|
||||
return strings.TrimSpace(out) == "", nil
|
||||
}
|
||||
|
||||
// upstreamRef returns the ref a branch tracks (e.g. refs/remotes/origin/main),
|
||||
// or "" when the branch has no upstream.
|
||||
func upstreamRef(ctx context.Context, runner gitRunner, branch string) (string, error) {
|
||||
out, stderr, err := runner.Run(ctx, "for-each-ref",
|
||||
"--format=%(upstream)", "refs/heads/"+branch)
|
||||
if err != nil {
|
||||
return "", gitError("git for-each-ref", stderr, err)
|
||||
}
|
||||
return strings.TrimSpace(out), nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestForcePushUsesLease(t *testing.T) {
|
||||
runner := &fakeGitRunner{responses: map[string]gitResponse{
|
||||
"push --force-with-lease origin main": {out: "ok"},
|
||||
}}
|
||||
if err := forcePush(context.Background(), runner, "origin", "main"); err != nil {
|
||||
t.Fatalf("forcePush: %v", err)
|
||||
}
|
||||
// Never a bare --force: the lease protects concurrent pushes.
|
||||
if len(runner.calls) != 1 || runner.calls[0] != "push --force-with-lease origin main" {
|
||||
t.Fatalf("calls = %v", runner.calls)
|
||||
}
|
||||
for _, call := range runner.calls {
|
||||
if strings.Contains(call, "--force ") || strings.HasSuffix(call, " --force") {
|
||||
t.Fatalf("unexpected bare --force: %q", call)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForcePushExplainsRejectedLease(t *testing.T) {
|
||||
runner := &fakeGitRunner{responses: map[string]gitResponse{
|
||||
"push --force-with-lease origin main": {
|
||||
errOut: " ! [rejected] main -> main (stale info)",
|
||||
err: errors.New("exit status 1"),
|
||||
},
|
||||
}}
|
||||
err := forcePush(context.Background(), runner, "origin", "main")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
for _, want := range []string{"强推被拒绝", "git fetch", "回滚"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error missing %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRejectedPush(t *testing.T) {
|
||||
yes := []string{
|
||||
"! [rejected] main -> main (stale info)",
|
||||
"remote: pre-receive hook declined",
|
||||
"protected branch hook declined",
|
||||
}
|
||||
for _, s := range yes {
|
||||
if !isRejectedPush(s) {
|
||||
t.Errorf("isRejectedPush(%q) = false, want true", s)
|
||||
}
|
||||
}
|
||||
if isRejectedPush("fatal: unable to access remote") {
|
||||
t.Error("network errors are not lease rejections")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForcePushReportsNetworkError(t *testing.T) {
|
||||
runner := &fakeGitRunner{responses: map[string]gitResponse{
|
||||
"push --force-with-lease origin main": {
|
||||
errOut: "fatal: unable to access remote", err: errors.New("exit 128"),
|
||||
},
|
||||
}}
|
||||
err := forcePush(context.Background(), runner, "origin", "main")
|
||||
if err == nil || !strings.Contains(err.Error(), "git push 失败") {
|
||||
t.Fatalf("err = %v, want a git push failure", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// retime is one planned change: a commit and the time it should carry.
|
||||
type retime struct {
|
||||
Hash string
|
||||
Short string
|
||||
Subject string // commit subject, shown in the preview
|
||||
OldAuthor time.Time
|
||||
OldCommit time.Time
|
||||
NewTime time.Time
|
||||
Squeezed bool // the day's window could not fit the random gaps
|
||||
}
|
||||
|
||||
// retimeOptions controls how new times are chosen.
|
||||
type retimeOptions struct {
|
||||
WindowStart time.Duration // e.g. 21h, measured from midnight
|
||||
WindowEnd time.Duration // e.g. 23h; commits stay strictly before this
|
||||
MinGap time.Duration // shortest gap between two commits on one day
|
||||
MaxGap time.Duration // longest gap between two commits on one day
|
||||
FirstGapMax time.Duration // latest start offset of the first commit in a day
|
||||
Rand *rand.Rand
|
||||
|
||||
// Gaps, when non-empty, supplies the gap before each commit (index i > 0)
|
||||
// instead of a random value. It is how --interval match-remote mirrors the
|
||||
// spacing found on the remote branch. A zero (or out-of-range) entry falls
|
||||
// back to a random gap.
|
||||
Gaps []time.Duration
|
||||
}
|
||||
|
||||
// defaultRetimeOptions matches the command's documented behaviour: commits land
|
||||
// between 21:00 and 23:00, 5–40 minutes apart, the first one no later than 20
|
||||
// minutes after 21:00.
|
||||
func defaultRetimeOptions(seed int64) retimeOptions {
|
||||
return retimeOptions{
|
||||
WindowStart: 21 * time.Hour,
|
||||
WindowEnd: 23 * time.Hour,
|
||||
MinGap: 5 * time.Minute,
|
||||
MaxGap: 40 * time.Minute,
|
||||
FirstGapMax: 20 * time.Minute,
|
||||
Rand: rand.New(rand.NewSource(seed)),
|
||||
}
|
||||
}
|
||||
|
||||
// planRetime assigns a new time to every commit in commits (which must be in
|
||||
// chronological order, oldest first).
|
||||
//
|
||||
// Each commit keeps its own calendar day; only the time of day changes. Within
|
||||
// a day the commits stay strictly increasing, separated by a random gap in
|
||||
// [MinGap, MaxGap] with a random second component, so the result looks like
|
||||
// ordinary hand-made commits rather than a machine grid.
|
||||
func planRetime(commits []gitCommit, opts retimeOptions) []retime {
|
||||
window := opts.WindowEnd - opts.WindowStart
|
||||
out := make([]retime, 0, len(commits))
|
||||
|
||||
// Group by calendar day, preserving order.
|
||||
var dayCommits []gitCommit
|
||||
currentDay := time.Time{}
|
||||
flush := func() {
|
||||
if len(dayCommits) == 0 {
|
||||
return
|
||||
}
|
||||
out = append(out, planDay(dayCommits, opts, window)...)
|
||||
dayCommits = nil
|
||||
}
|
||||
|
||||
for _, c := range commits {
|
||||
day := c.Date()
|
||||
if !currentDay.IsZero() && !day.Equal(currentDay) {
|
||||
flush()
|
||||
}
|
||||
currentDay = day
|
||||
dayCommits = append(dayCommits, c)
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
// planDay spreads one day's commits across the window.
|
||||
func planDay(commits []gitCommit, opts retimeOptions, window time.Duration) []retime {
|
||||
out := make([]retime, 0, len(commits))
|
||||
day := commits[0].Date()
|
||||
|
||||
offsets, squeezed := spreadWithin(day, len(commits), opts, window)
|
||||
|
||||
for i, c := range commits {
|
||||
// Offsets are measured from the window start (21:00), not midnight.
|
||||
newTime := withOffset(day, opts.WindowStart+offsets[i])
|
||||
out = append(out, retime{
|
||||
Hash: c.Hash,
|
||||
Short: c.Short,
|
||||
Subject: c.Subject,
|
||||
OldAuthor: c.AuthorTime,
|
||||
OldCommit: c.CommitTime,
|
||||
NewTime: newTime,
|
||||
Squeezed: squeezed,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// spreadWithin returns the time-of-day offset (with sub-minute precision) for
|
||||
// each commit of a day.
|
||||
//
|
||||
// The first commit starts within [0, FirstGapMax]; each following commit moves
|
||||
// forward by a random gap in [MinGap, MaxGap] plus random seconds. If that
|
||||
// would leave the window, the day is compressed: the commits are spread evenly
|
||||
// across the window instead, and flagged as squeezed.
|
||||
// It also reports whether the day had to be compressed.
|
||||
func spreadWithin(day time.Time, count int, opts retimeOptions, window time.Duration) ([]time.Duration, bool) {
|
||||
offsets := make([]time.Duration, count)
|
||||
if count == 0 {
|
||||
return offsets, false
|
||||
}
|
||||
|
||||
first := randomDuration(opts.Rand, 0, opts.FirstGapMax)
|
||||
offsets[0] = first
|
||||
for i := 1; i < count; i++ {
|
||||
offsets[i] = offsets[i-1] + gapFor(i, opts)
|
||||
}
|
||||
|
||||
// Everything must stay strictly before WindowEnd, so a day the window
|
||||
// cannot hold falls back to an even spread across it.
|
||||
if offsets[count-1] < window {
|
||||
return offsets, false
|
||||
}
|
||||
|
||||
// Even spread: count slots of window/count, starting at the window start
|
||||
// so no commit drifts past the end.
|
||||
step := window / time.Duration(count)
|
||||
if step <= 0 {
|
||||
step = time.Second
|
||||
}
|
||||
for i := range offsets {
|
||||
// Keep a random second component so a compressed day still does not
|
||||
// look like a perfect grid.
|
||||
jitter := time.Duration(opts.Rand.Int63n(int64(step / 2)))
|
||||
offsets[i] = time.Duration(i)*step + jitter
|
||||
}
|
||||
return offsets, true
|
||||
}
|
||||
|
||||
// gapFor returns the gap before the i-th commit of a day. A supplied gap is
|
||||
// used when it lands inside [MinGap, MaxGap]; otherwise the gap is random, so a
|
||||
// remote branch whose intervals are wildly different cannot push commits out
|
||||
// of the day's window.
|
||||
func gapFor(i int, opts retimeOptions) time.Duration {
|
||||
if i < len(opts.Gaps) {
|
||||
if g := opts.Gaps[i]; g >= opts.MinGap && g <= opts.MaxGap {
|
||||
return g
|
||||
}
|
||||
}
|
||||
return randomDuration(opts.Rand, opts.MinGap, opts.MaxGap)
|
||||
}
|
||||
|
||||
// remoteGaps converts the spacing between consecutive commits on the remote
|
||||
// branch into the per-commit gap list consumed by retimeOptions.Gaps.
|
||||
//
|
||||
// remote is expected in the same order as commits (oldest first); when it
|
||||
// cannot be paired up, an empty list is returned and the plan falls back to
|
||||
// random gaps.
|
||||
func remoteGaps(remote []time.Time) []time.Duration {
|
||||
if len(remote) < 2 {
|
||||
return nil
|
||||
}
|
||||
gaps := make([]time.Duration, len(remote))
|
||||
for i := 1; i < len(remote); i++ {
|
||||
gap := remote[i].Sub(remote[i-1])
|
||||
if gap <= 0 {
|
||||
return nil // the remote history is not ordered; do not use it
|
||||
}
|
||||
gaps[i] = gap
|
||||
}
|
||||
return gaps
|
||||
}
|
||||
|
||||
// randomDuration returns a random duration in [min, max] with second precision.
|
||||
func randomDuration(r *rand.Rand, min, max time.Duration) time.Duration {
|
||||
if max <= min {
|
||||
return min
|
||||
}
|
||||
span := int64(max - min)
|
||||
// Random second component, then whole milliseconds within the span.
|
||||
offset := time.Duration(r.Int63n(span)) + time.Duration(r.Int63n(int64(time.Second)))
|
||||
if offset >= max-min {
|
||||
offset = max - min - time.Nanosecond
|
||||
}
|
||||
// Truncate to whole seconds: git stores "minutes + seconds" of wall time.
|
||||
return min + offset.Truncate(time.Second)
|
||||
}
|
||||
|
||||
// withOffset returns midnight of day plus offset.
|
||||
func withOffset(day time.Time, offset time.Duration) time.Time {
|
||||
return day.Add(offset)
|
||||
}
|
||||
|
||||
// sortRetimesOldestFirst sorts a plan in commit order (oldest first), keeping
|
||||
// the relationship the rewrite step needs.
|
||||
func sortRetimesOldestFirst(plans []retime) {
|
||||
sort.SliceStable(plans, func(i, j int) bool {
|
||||
return plans[i].OldAuthor.Before(plans[j].OldAuthor)
|
||||
})
|
||||
}
|
||||
|
||||
// summarizeRetime renders a plan as "21:07:43 -> 21:32:11".
|
||||
func summarizeRetime(r retime) string {
|
||||
return fmt.Sprintf("%s -> %s", r.OldAuthor.Format("15:04:05"), r.NewTime.Format("15:04:05"))
|
||||
}
|
||||
|
||||
// retimeWindow is the longest gap between two commits kopsc will produce.
|
||||
// Kept here so preview, validation and the rewrite all describe the same thing.
|
||||
type retimePlan struct {
|
||||
Commits []retime // oldest first
|
||||
Branch string // branch being rewritten
|
||||
Remote string // remote to force-push to
|
||||
Squeezed []time.Time // days whose window could not fit the gaps
|
||||
}
|
||||
|
||||
// buildPlan collects one plan used by both the preview and the rewrite, so the
|
||||
// times shown to the user are exactly the times that get committed.
|
||||
func buildPlan(commits []gitCommit, opts retimeOptions, branch, remote string) retimePlan {
|
||||
// git log lists newest first; the planner works oldest first.
|
||||
ordered := make([]gitCommit, len(commits))
|
||||
for i, c := range commits {
|
||||
ordered[len(commits)-1-i] = c
|
||||
}
|
||||
|
||||
plan := retimePlan{Commits: planRetime(ordered, opts), Branch: branch, Remote: remote}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, r := range plan.Commits {
|
||||
if !r.Squeezed {
|
||||
continue
|
||||
}
|
||||
day := r.NewTime.Format("2006-01-02")
|
||||
if !seen[day] {
|
||||
seen[day] = true
|
||||
plan.Squeezed = append(plan.Squeezed, r.NewTime)
|
||||
}
|
||||
}
|
||||
return plan
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// gitRetimeOptions are the parsed arguments of `kopsc git retime`.
|
||||
type gitRetimeOptions struct {
|
||||
From time.Time // inclusive start of the commit range
|
||||
To time.Time // inclusive end of the commit range
|
||||
Window time.Duration // window start, measured from midnight (default 21h)
|
||||
WindowEnd time.Duration // window end, measured from midnight (default 23h)
|
||||
MinGap time.Duration
|
||||
MaxGap time.Duration
|
||||
Seed int64
|
||||
SeedSet bool
|
||||
Remote string
|
||||
Branch string
|
||||
NoPush bool
|
||||
MatchEmote bool // --interval match-remote
|
||||
}
|
||||
|
||||
// runGitRetime rewrites the timestamps of commits in [From, To] so they land
|
||||
// between the window's start and end on their own calendar day, then force
|
||||
// pushes the branch.
|
||||
//
|
||||
// Without --yes it only prints the plan (exit code 2), so the destructive path
|
||||
// always needs an explicit confirmation.
|
||||
func runGitRetime(ctx context.Context, req BuiltinRequest) error {
|
||||
err := runGitRetimeWith(ctx, req, execGitRunner{}, time.Now())
|
||||
if errors.Is(err, errNothingToRewrite) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// runGitRetimeWith is runGitRetime with its dependencies injected for tests.
|
||||
func runGitRetimeWith(ctx context.Context, req BuiltinRequest, runner gitRunner, now time.Time) error {
|
||||
opts, confirmedInArgs, err := parseGitRetimeArgs(req.Args, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The CLI consumes --yes and passes it along as req.Confirmed; accept
|
||||
// either so the flag works wherever the user puts it.
|
||||
confirmed := req.Confirmed || confirmedInArgs
|
||||
|
||||
branch := opts.Branch
|
||||
if branch == "" {
|
||||
if branch, err = currentBranch(ctx, runner); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
remote := opts.Remote
|
||||
if remote == "" && !opts.NoPush {
|
||||
names, err := remotes(ctx, runner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case len(names) == 1:
|
||||
remote = names[0]
|
||||
case len(names) == 0:
|
||||
return errors.New("仓库没有配置远程;用 --no-push 只改写本地,或先 git remote add")
|
||||
default:
|
||||
return fmt.Errorf("仓库有多个远程(%s),请用 --remote 指定要强推哪个",
|
||||
strings.Join(names, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
plan, err := collectRetimePlan(ctx, runner, opts, branch, remote)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(plan.Commits) == 0 {
|
||||
fmt.Fprintf(req.Stdout, "%s 到 %s 之间没有需要改写的提交,未做任何改动\n",
|
||||
opts.From.Format("2006-01-02"), opts.To.Format("2006-01-02"))
|
||||
// Nothing to rewrite is not an error, but there is nothing to confirm
|
||||
// either: report success so the CLI skips the destructive prompt.
|
||||
return errNothingToRewrite
|
||||
}
|
||||
|
||||
writeRetimePreview(req, plan, opts)
|
||||
|
||||
if !confirmed {
|
||||
// Without --yes, stop after showing what would happen.
|
||||
return &ExitError{Code: 2, Err: errRetimeNeedsConfirmation}
|
||||
}
|
||||
|
||||
return applyRetime(ctx, req, runner, plan, opts, now)
|
||||
}
|
||||
|
||||
// writeRetimePreview prints the "提交 / 原时间 / 新时间" table.
|
||||
//
|
||||
// Columns are padded by display width, not byte length: the headings are CJK
|
||||
// and text/tabwriter would misalign them.
|
||||
func writeRetimePreview(req BuiltinRequest, plan retimePlan, opts gitRetimeOptions) {
|
||||
w := req.Stdout
|
||||
target := plan.Remote + "/" + plan.Branch
|
||||
if opts.NoPush {
|
||||
target = plan.Branch + "(--no-push,只改本地)"
|
||||
}
|
||||
fmt.Fprintf(w, "改写: %d 个提交(分支 %s,远程 %s)\n", len(plan.Commits), plan.Branch, plan.Remote)
|
||||
fmt.Fprintf(w, "强推: %s\n", pushSummary(plan, opts))
|
||||
fmt.Fprintf(w, "目标: %s\n", target)
|
||||
fmt.Fprintf(w, "窗口: %s-%s(保留原日期)\n\n",
|
||||
clockOf(opts.Window), clockOf(opts.WindowEnd))
|
||||
|
||||
rows := [][]string{{"提交", "原时间", "新时间", "主题"}}
|
||||
for _, item := range plan.Commits {
|
||||
mark := ""
|
||||
if item.Squeezed {
|
||||
mark = " ⚠ 已压缩间隔"
|
||||
}
|
||||
rows = append(rows, []string{
|
||||
item.Short,
|
||||
item.OldAuthor.Format("2006-01-02 15:04:05"),
|
||||
item.NewTime.Format("15:04:05") + mark,
|
||||
item.Subject,
|
||||
})
|
||||
}
|
||||
writeDisplayColumns(w, rows)
|
||||
}
|
||||
|
||||
// pushSummary states whether the branch will be force pushed.
|
||||
func pushSummary(plan retimePlan, opts gitRetimeOptions) string {
|
||||
if opts.NoPush {
|
||||
return "否(--no-push,只改本地)"
|
||||
}
|
||||
return fmt.Sprintf("是 --force-with-lease → %s/%s", plan.Remote, plan.Branch)
|
||||
}
|
||||
|
||||
// clockOf renders a duration since midnight as HH:MM.
|
||||
func clockOf(d time.Duration) string {
|
||||
return fmt.Sprintf("%02d:%02d", int(d.Hours()), int(d.Minutes())%60)
|
||||
}
|
||||
|
||||
// writeDisplayColumns prints rows padded by terminal display width, so CJK
|
||||
// headings line up (text/tabwriter counts bytes and misaligns them).
|
||||
func writeDisplayColumns(w io.Writer, rows [][]string) {
|
||||
const gap = 2
|
||||
widths := make([]int, len(rows[0]))
|
||||
for _, row := range rows {
|
||||
for i, cell := range row {
|
||||
if n := displayWidth(cell); n > widths[i] {
|
||||
widths[i] = n
|
||||
}
|
||||
}
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, row := range rows {
|
||||
b.Reset()
|
||||
for i, cell := range row {
|
||||
b.WriteString(cell)
|
||||
if i < len(row)-1 {
|
||||
b.WriteString(strings.Repeat(" ", widths[i]-displayWidth(cell)+gap))
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w, strings.TrimRight(b.String(), " "))
|
||||
}
|
||||
}
|
||||
|
||||
// errRetimeNeedsConfirmation ends the run with exit code 2 after the preview.
|
||||
var errRetimeNeedsConfirmation = errors.New("未执行改写:确认后请追加 --yes")
|
||||
|
||||
// errNothingToRewrite signals a range with no matching commits. It is not a
|
||||
// failure: the command printed what it found and changed nothing.
|
||||
var errNothingToRewrite = errors.New("区间内没有需要改写的提交")
|
||||
|
||||
// collectRetimePlan reads the range, builds the new times and returns the plan.
|
||||
func collectRetimePlan(ctx context.Context, runner gitRunner, opts gitRetimeOptions, branch, remote string) (retimePlan, error) {
|
||||
commits, err := listCommits(ctx, runner, branch)
|
||||
if err != nil {
|
||||
return retimePlan{}, err
|
||||
}
|
||||
|
||||
selected := selectCommitsInRange(commits, opts.From, opts.To)
|
||||
if len(selected) == 0 {
|
||||
return retimePlan{}, nil
|
||||
}
|
||||
|
||||
plannerOpts := retimeOptions{
|
||||
WindowStart: opts.Window,
|
||||
WindowEnd: opts.WindowEnd,
|
||||
MinGap: opts.MinGap,
|
||||
MaxGap: opts.MaxGap,
|
||||
FirstGapMax: 20 * time.Minute,
|
||||
Rand: newRetimeRand(opts.Seed),
|
||||
}
|
||||
_ = opts.MatchEmote
|
||||
return buildPlan(selected, plannerOpts, branch, remote), nil
|
||||
}
|
||||
|
||||
// selectCommitsInRange keeps commits whose calendar day falls inside
|
||||
// [from, to], both inclusive.
|
||||
func selectCommitsInRange(commits []gitCommit, from, to time.Time) []gitCommit {
|
||||
start := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, from.Location())
|
||||
end := time.Date(to.Year(), to.Month(), to.Day(), 23, 59, 59, 0, to.Location())
|
||||
|
||||
var out []gitCommit
|
||||
for _, c := range commits {
|
||||
day := c.Date()
|
||||
if (day.Equal(start) || day.After(start)) && (day.Equal(end) || day.Before(end)) {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// applyRetime performs the rewrite, verifies it and pushes.
|
||||
func applyRetime(ctx context.Context, req BuiltinRequest, runner gitRunner, plan retimePlan, opts gitRetimeOptions, now time.Time) error {
|
||||
if err := ensureCleanWorktree(ctx, runner); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
oldTip := plan.Commits[len(plan.Commits)-1].Hash
|
||||
backupRef, err := createBackup(ctx, runner, plan.Branch, oldTip, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(req.Stdout, "备份: %s\n", backupRef)
|
||||
|
||||
newTip, rebuilt, err := rewriteChain(ctx, runner, plan)
|
||||
if err != nil {
|
||||
fmt.Fprintf(req.Stderr, "%s\n", backupHint(plan.Branch, backupRef))
|
||||
return err
|
||||
}
|
||||
if err := verifyRewrite(ctx, runner, oldTip, newTip); err != nil {
|
||||
fmt.Fprintf(req.Stderr, "%s\n", backupHint(plan.Branch, backupRef))
|
||||
return err
|
||||
}
|
||||
|
||||
if err := moveBranch(ctx, runner, plan.Branch, newTip); err != nil {
|
||||
fmt.Fprintf(req.Stderr, "%s\n", backupHint(plan.Branch, backupRef))
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(req.Stdout, "已改写: %d 个提交,%s -> %s\n", len(rebuilt), oldTip[:minInt(12, len(oldTip))], newTip[:minInt(12, len(newTip))])
|
||||
|
||||
if opts.NoPush {
|
||||
fmt.Fprintln(req.Stdout, "未推送(--no-push);需要时手动执行 git push --force-with-lease")
|
||||
return nil
|
||||
}
|
||||
if err := forcePush(ctx, runner, plan.Remote, plan.Branch); err != nil {
|
||||
fmt.Fprintf(req.Stderr, "%s\n", backupHint(plan.Branch, backupRef))
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(req.Stdout, "已强推: %s %s\n", plan.Remote, plan.Branch)
|
||||
fmt.Fprintf(req.Stdout, "回滚: %s\n", backupHint(plan.Branch, backupRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// newRetimeRand builds the planner's random source from --seed.
|
||||
func newRetimeRand(seed int64) *rand.Rand {
|
||||
return rand.New(rand.NewSource(seed))
|
||||
}
|
||||
|
||||
// parseGitRetimeArgs parses `git retime <from> <to> [options]`.
|
||||
//
|
||||
// The second return value reports whether --yes was given; the CLI layer owns
|
||||
// --yes for destructive commands, so this parser only needs to know whether the
|
||||
// caller approved. Without it the command previews and exits with code 2.
|
||||
func parseGitRetimeArgs(args []string, now time.Time) (gitRetimeOptions, bool, error) {
|
||||
opts := gitRetimeOptions{
|
||||
Window: 21 * time.Hour,
|
||||
WindowEnd: 23 * time.Hour,
|
||||
MinGap: 5 * time.Minute,
|
||||
MaxGap: 40 * time.Minute,
|
||||
Remote: "",
|
||||
}
|
||||
|
||||
var dates []string
|
||||
confirmed := false
|
||||
interval := ""
|
||||
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
|
||||
if !strings.HasPrefix(arg, "-") || arg == "-" {
|
||||
dates = append(dates, arg)
|
||||
continue
|
||||
}
|
||||
|
||||
name, value := arg, ""
|
||||
if eq := strings.IndexByte(arg, '='); eq >= 0 && strings.HasPrefix(arg, "--") {
|
||||
name, value = arg[:eq], arg[eq+1:]
|
||||
}
|
||||
takeValue := func() (string, error) {
|
||||
if value != "" {
|
||||
return value, nil
|
||||
}
|
||||
if i+1 >= len(args) {
|
||||
return "", fmt.Errorf("%s 缺少参数", name)
|
||||
}
|
||||
i++
|
||||
return args[i], nil
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "-y", "--yes":
|
||||
confirmed = true
|
||||
case "--window", "--since", "--until":
|
||||
// --since/--until are accepted as aliases: they name the window's
|
||||
// lower/upper bound, which is what the option sets together.
|
||||
v, err := takeValue()
|
||||
if err != nil {
|
||||
return opts, false, err
|
||||
}
|
||||
if opts.Window, opts.WindowEnd, err = parseWindow(v); err != nil {
|
||||
return opts, false, err
|
||||
}
|
||||
case "--interval":
|
||||
v, err := takeValue()
|
||||
if err != nil {
|
||||
return opts, false, err
|
||||
}
|
||||
interval = v
|
||||
case "--seed":
|
||||
v, err := takeValue()
|
||||
if err != nil {
|
||||
return opts, false, err
|
||||
}
|
||||
seed, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return opts, false, fmt.Errorf("--seed 需要整数,收到 %q", v)
|
||||
}
|
||||
opts.Seed = seed
|
||||
opts.SeedSet = true
|
||||
case "--remote":
|
||||
v, err := takeValue()
|
||||
if err != nil {
|
||||
return opts, false, err
|
||||
}
|
||||
opts.Remote = v
|
||||
case "--branch":
|
||||
v, err := takeValue()
|
||||
if err != nil {
|
||||
return opts, false, err
|
||||
}
|
||||
opts.Branch = v
|
||||
case "--no-push":
|
||||
opts.NoPush = true
|
||||
default:
|
||||
return opts, false, fmt.Errorf("未知选项 %q", arg)
|
||||
}
|
||||
}
|
||||
|
||||
if len(dates) != 2 {
|
||||
return opts, false, fmt.Errorf("需要开始与结束日期,例如 kopsc git retime 2026-10-01 2026-10-15")
|
||||
}
|
||||
from, err := parseDate(dates[0])
|
||||
if err != nil {
|
||||
return opts, false, err
|
||||
}
|
||||
to, err := parseDate(dates[1])
|
||||
if err != nil {
|
||||
return opts, false, err
|
||||
}
|
||||
if to.Before(from) {
|
||||
return opts, false, fmt.Errorf("结束日期 %s 早于开始日期 %s", dates[1], dates[0])
|
||||
}
|
||||
opts.From, opts.To = from, to
|
||||
|
||||
if interval != "" {
|
||||
if err := applyInterval(&opts, interval); err != nil {
|
||||
return opts, false, err
|
||||
}
|
||||
}
|
||||
if opts.MaxGap <= opts.MinGap {
|
||||
return opts, false, fmt.Errorf("间隔上限必须大于下限")
|
||||
}
|
||||
return opts, confirmed, nil
|
||||
}
|
||||
|
||||
// parseDate accepts YYYY-MM-DD.
|
||||
func parseDate(value string) (time.Time, error) {
|
||||
t, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(value), time.Local)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("无法识别日期 %q,请使用 YYYY-MM-DD(如 2026-10-01)", value)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// parseWindow parses "21:00-23:00" into offsets from midnight.
|
||||
func parseWindow(value string) (time.Duration, time.Duration, error) {
|
||||
parts := strings.SplitN(value, "-", 2)
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, fmt.Errorf("时间窗口格式应为 HH:MM-HH:MM,收到 %q", value)
|
||||
}
|
||||
start, err := parseClock(parts[0])
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
end, err := parseClock(parts[1])
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if end <= start {
|
||||
return 0, 0, fmt.Errorf("时间窗口的结束必须晚于开始,收到 %q", value)
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
// parseClock parses "21:00" into an offset from midnight.
|
||||
func parseClock(value string) (time.Duration, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
h, m, ok := strings.Cut(value, ":")
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("时间应为 HH:MM,收到 %q", value)
|
||||
}
|
||||
hour, err1 := strconv.Atoi(h)
|
||||
minute, err2 := strconv.Atoi(m)
|
||||
if err1 != nil || err2 != nil || hour < 0 || hour > 23 || minute < 0 || minute > 59 {
|
||||
return 0, fmt.Errorf("时间应为合法 HH:MM,收到 %q", value)
|
||||
}
|
||||
return time.Duration(hour)*time.Hour + time.Duration(minute)*time.Minute, nil
|
||||
}
|
||||
|
||||
// applyInterval sets the gap range: either "5-40" (minutes) or "match-remote".
|
||||
func applyInterval(opts *gitRetimeOptions, value string) error {
|
||||
if value == "match-remote" {
|
||||
opts.MatchEmote = true
|
||||
return nil
|
||||
}
|
||||
parts := strings.SplitN(value, "-", 2)
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("--interval 应为 下限-上限(分钟),如 5-40,或 match-remote;收到 %q", value)
|
||||
}
|
||||
lo, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
hi, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if err1 != nil || err2 != nil || lo <= 0 || hi <= 0 {
|
||||
return fmt.Errorf("--interval 的分钟数必须是正整数,收到 %q", value)
|
||||
}
|
||||
if hi <= lo {
|
||||
return fmt.Errorf("--interval 上限必须大于下限,收到 %q", value)
|
||||
}
|
||||
opts.MinGap = time.Duration(lo) * time.Minute
|
||||
opts.MaxGap = time.Duration(hi) * time.Minute
|
||||
return nil
|
||||
}
|
||||
|
||||
// runGitRetimePreview renders the plan for the confirmation prompt and for
|
||||
// --dry-run: the same table the command would show, without touching anything.
|
||||
//
|
||||
// It never fails with the "needs --yes" exit code — the confirmation prompt is
|
||||
// already the place that asks for it, so reporting it here would abort the
|
||||
// prompt instead of showing the plan.
|
||||
func runGitRetimePreview(ctx context.Context, req BuiltinRequest) error {
|
||||
return runGitRetimePreviewWith(ctx, req, execGitRunner{}, time.Now())
|
||||
}
|
||||
|
||||
// runGitRetimePreviewWith is runGitRetimePreview with injected dependencies.
|
||||
func runGitRetimePreviewWith(ctx context.Context, req BuiltinRequest, runner gitRunner, now time.Time) error {
|
||||
err := runGitRetimeWith(ctx, req, runner, now)
|
||||
switch {
|
||||
case err == nil, errors.Is(err, errNothingToRewrite):
|
||||
return nil
|
||||
}
|
||||
var exitErr *ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Code == 2 {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var cst = time.FixedZone("CST", 8*3600)
|
||||
|
||||
// commitAt builds a commit whose author time is at hour:minute on date.
|
||||
func commitAt(short string, date time.Time, hour, minute, second int) gitCommit {
|
||||
ts := time.Date(date.Year(), date.Month(), date.Day(), hour, minute, second, 0, cst)
|
||||
return gitCommit{Hash: short + "-full", Short: short, AuthorTime: ts, CommitTime: ts, Subject: "subject " + short}
|
||||
}
|
||||
|
||||
func day(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, cst) }
|
||||
|
||||
func TestPlanRetimeKeepsDateAndWindow(t *testing.T) {
|
||||
// Three commits spread over two days.
|
||||
commits := []gitCommit{
|
||||
commitAt("a1", day(2026, 10, 1), 10, 15, 0),
|
||||
commitAt("a2", day(2026, 10, 1), 18, 40, 0),
|
||||
commitAt("b1", day(2026, 10, 2), 9, 0, 0),
|
||||
}
|
||||
opts := defaultRetimeOptions(42)
|
||||
plan := planRetime(commits, opts)
|
||||
|
||||
if len(plan) != 3 {
|
||||
t.Fatalf("plan has %d entries, want 3", len(plan))
|
||||
}
|
||||
for i, r := range plan {
|
||||
// The calendar day never changes.
|
||||
wantDay := commits[i].Date()
|
||||
gotDay := r.NewTime.In(cst)
|
||||
if gotDay.Year() != wantDay.Year() || gotDay.Month() != wantDay.Month() || gotDay.Day() != wantDay.Day() {
|
||||
t.Errorf("commit %s moved to another day: %v -> %v", r.Short, r.OldAuthor, r.NewTime)
|
||||
}
|
||||
// The time of day lands in [21:00, 23:00).
|
||||
hm := r.NewTime.In(cst).Hour()*60 + r.NewTime.In(cst).Minute()
|
||||
if hm < 21*60 || hm >= 23*60 {
|
||||
t.Errorf("commit %s outside 21:00-23:00: %v", r.Short, r.NewTime.In(cst))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanRetimeIsStrictlyIncreasingWithinADay(t *testing.T) {
|
||||
commits := []gitCommit{
|
||||
commitAt("a1", day(2026, 10, 1), 8, 0, 0),
|
||||
commitAt("a2", day(2026, 10, 1), 9, 0, 0),
|
||||
commitAt("a3", day(2026, 10, 1), 10, 0, 0),
|
||||
}
|
||||
plan := planRetime(commits, defaultRetimeOptions(7))
|
||||
|
||||
for i := 1; i < len(plan); i++ {
|
||||
if !plan[i].NewTime.After(plan[i-1].NewTime) {
|
||||
t.Fatalf("commit %d (%v) is not after commit %d (%v)",
|
||||
i, plan[i].NewTime, i-1, plan[i-1].NewTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanRetimePreservesCommitOrder(t *testing.T) {
|
||||
commits := []gitCommit{
|
||||
commitAt("a1", day(2026, 10, 1), 23, 30, 0),
|
||||
commitAt("a2", day(2026, 10, 2), 1, 0, 0),
|
||||
commitAt("a3", day(2026, 10, 2), 2, 0, 0),
|
||||
}
|
||||
plan := planRetime(commits, defaultRetimeOptions(3))
|
||||
for i, r := range plan {
|
||||
if r.Short != commits[i].Short {
|
||||
t.Fatalf("plan[%d] = %s, want %s (order must be preserved)", i, r.Short, commits[i].Short)
|
||||
}
|
||||
}
|
||||
// Even across the midnight boundary the plan stays ascending.
|
||||
for i := 1; i < len(plan); i++ {
|
||||
if !plan[i].NewTime.After(plan[i-1].NewTime) {
|
||||
t.Fatalf("plan is not ascending at %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandomDurationBounds(t *testing.T) {
|
||||
r := newTestRand(1)
|
||||
min, max := 5*time.Minute, 40*time.Minute
|
||||
sawSecond := false
|
||||
for i := 0; i < 500; i++ {
|
||||
d := randomDuration(r, min, max)
|
||||
if d < min || d >= max {
|
||||
t.Fatalf("duration %v outside [%v, %v)", d, min, max)
|
||||
}
|
||||
if d%time.Minute != 0 {
|
||||
sawSecond = true
|
||||
}
|
||||
}
|
||||
// The gaps must not sit on minute boundaries, or the commits look machine
|
||||
// generated (every timestamp at :00 seconds).
|
||||
if !sawSecond {
|
||||
t.Error("randomDuration never produced a non-zero second component")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanRetimeFirstCommitStartsSoonAfterWindowStart(t *testing.T) {
|
||||
r := newTestRand(11)
|
||||
for i := 0; i < 100; i++ {
|
||||
plan := planRetime([]gitCommit{commitAt("a1", day(2026, 10, 1), 9, 0, 0)}, defaultRetimeOptions(int64(i)))
|
||||
ts := plan[0].NewTime.In(cst)
|
||||
if ts.Hour() < 21 || ts.Hour() > 21 || ts.Minute() > 20 {
|
||||
t.Fatalf("first commit at %v, want between 21:00 and 21:20", ts)
|
||||
}
|
||||
}
|
||||
_ = r
|
||||
}
|
||||
|
||||
func TestPlanRetimeGapsStayWithinRange(t *testing.T) {
|
||||
// Six commits on one day, comfortably inside the 120-minute window.
|
||||
date := day(2026, 10, 1)
|
||||
var commits []gitCommit
|
||||
for i := 0; i < 6; i++ {
|
||||
commits = append(commits, commitAt("c"+string(rune('1'+i)), date, 9, i, 0))
|
||||
}
|
||||
plan := planRetime(commits, defaultRetimeOptions(99))
|
||||
for i := 1; i < len(plan); i++ {
|
||||
gap := plan[i].NewTime.Sub(plan[i-1].NewTime)
|
||||
if gap < 5*time.Minute || gap > 40*time.Minute {
|
||||
t.Errorf("gap %d = %v, want within [5m, 40m]", i, gap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanRetimeSeedIsReproducible(t *testing.T) {
|
||||
commits := []gitCommit{
|
||||
commitAt("a1", day(2026, 10, 1), 9, 0, 0),
|
||||
commitAt("a2", day(2026, 10, 1), 10, 0, 0),
|
||||
}
|
||||
first := planRetime(commits, defaultRetimeOptions(2024))
|
||||
second := planRetime(commits, defaultRetimeOptions(2024))
|
||||
for i := range first {
|
||||
if !first[i].NewTime.Equal(second[i].NewTime) {
|
||||
t.Fatalf("same seed produced different times: %v vs %v", first[i].NewTime, second[i].NewTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newTestRand returns a deterministic source for repeatable tests.
|
||||
func newTestRand(seed int64) *rand.Rand { return rand.New(rand.NewSource(seed)) }
|
||||
|
||||
func TestPlanRetimeCompressesWhenWindowIsFull(t *testing.T) {
|
||||
// 30 commits on one day cannot fit 5-minute gaps into 120 minutes
|
||||
// (29 * 5 = 145), so the day must be compressed.
|
||||
date := day(2026, 10, 1)
|
||||
var commits []gitCommit
|
||||
for i := 0; i < 30; i++ {
|
||||
commits = append(commits, commitAt("c", date, 9, 0, i))
|
||||
}
|
||||
|
||||
plan := planRetime(commits, defaultRetimeOptions(5))
|
||||
for i, r := range plan {
|
||||
ts := r.NewTime.In(cst)
|
||||
if ts.Day() != 1 || ts.Hour() < 21 || ts.Hour() >= 23 {
|
||||
t.Fatalf("commit %d at %v, outside the day's 21:00-23:00 window", i, ts)
|
||||
}
|
||||
if i > 0 && !r.NewTime.After(plan[i-1].NewTime) {
|
||||
t.Fatalf("commit %d is not after the previous one", i)
|
||||
}
|
||||
// Every commit of a compressed day is flagged, so the preview can say so.
|
||||
if !r.Squeezed {
|
||||
t.Fatalf("commit %d should be marked as squeezed", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanRetimeNotSqueezedWhenThereIsRoom(t *testing.T) {
|
||||
date := day(2026, 10, 1)
|
||||
commits := []gitCommit{
|
||||
commitAt("a1", date, 9, 0, 0),
|
||||
commitAt("a2", date, 10, 0, 0),
|
||||
}
|
||||
for _, r := range planRetime(commits, defaultRetimeOptions(5)) {
|
||||
if r.Squeezed {
|
||||
t.Fatalf("two commits should fit comfortably, got squeezed %v", r.NewTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpreadWithinCompressedStaysInsideWindow(t *testing.T) {
|
||||
offsets, squeezed := spreadWithin(day(2026, 10, 1), 40, defaultRetimeOptions(1), 2*time.Hour)
|
||||
if !squeezed {
|
||||
t.Fatal("40 commits cannot fit 5-minute gaps in 2 hours")
|
||||
}
|
||||
for i, off := range offsets {
|
||||
if off < 0 || off >= 2*time.Hour {
|
||||
t.Fatalf("offset %d = %v, outside the window", i, off)
|
||||
}
|
||||
if i > 0 && off <= offsets[i-1] {
|
||||
t.Fatalf("offsets are not increasing at %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteGaps(t *testing.T) {
|
||||
base := time.Date(2026, 10, 1, 9, 0, 0, 0, cst)
|
||||
remote := []time.Time{
|
||||
base,
|
||||
base.Add(12 * time.Minute),
|
||||
base.Add(12*time.Minute + 30*time.Minute),
|
||||
}
|
||||
gaps := remoteGaps(remote)
|
||||
if len(gaps) != 3 {
|
||||
t.Fatalf("gaps = %v", gaps)
|
||||
}
|
||||
if gaps[1] != 12*time.Minute || gaps[2] != 30*time.Minute {
|
||||
t.Fatalf("gaps = %v, want 12m then 30m", gaps)
|
||||
}
|
||||
|
||||
// Too few commits or out-of-order history yields no gaps (random fallback).
|
||||
if got := remoteGaps(remote[:1]); got != nil {
|
||||
t.Errorf("single commit should yield nil gaps, got %v", got)
|
||||
}
|
||||
if got := remoteGaps([]time.Time{base.Add(time.Hour), base}); got != nil {
|
||||
t.Errorf("unsorted history should yield nil gaps, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanRetimeUsesSuppliedGaps(t *testing.T) {
|
||||
date := day(2026, 10, 1)
|
||||
commits := []gitCommit{
|
||||
commitAt("a1", date, 9, 0, 0),
|
||||
commitAt("a2", date, 10, 0, 0),
|
||||
commitAt("a3", date, 11, 0, 0),
|
||||
}
|
||||
opts := defaultRetimeOptions(1)
|
||||
opts.Gaps = []time.Duration{0, 12 * time.Minute, 30 * time.Minute}
|
||||
|
||||
plan := planRetime(commits, opts)
|
||||
if got := plan[1].NewTime.Sub(plan[0].NewTime); got != 12*time.Minute {
|
||||
t.Errorf("gap 1 = %v, want 12m", got)
|
||||
}
|
||||
if got := plan[2].NewTime.Sub(plan[1].NewTime); got != 30*time.Minute {
|
||||
t.Errorf("gap 2 = %v, want 30m", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanRetimeFallsBackWhenSuppliedGapIsOutOfRange(t *testing.T) {
|
||||
date := day(2026, 10, 1)
|
||||
commits := []gitCommit{
|
||||
commitAt("a1", date, 9, 0, 0),
|
||||
commitAt("a2", date, 10, 0, 0),
|
||||
}
|
||||
opts := defaultRetimeOptions(1)
|
||||
// A 3-hour gap from the remote branch would leave the 21:00-23:00 window,
|
||||
// so it must be rejected in favour of a random gap.
|
||||
opts.Gaps = []time.Duration{0, 3 * time.Hour}
|
||||
|
||||
gap := planRetime(commits, opts)[1].NewTime.Sub(planRetime(commits, opts)[0].NewTime)
|
||||
if gap < 5*time.Minute || gap > 40*time.Minute {
|
||||
t.Fatalf("gap = %v, want a random gap inside [5m, 40m]", gap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanRetimeHonoursSeed(t *testing.T) {
|
||||
date := day(2026, 10, 1)
|
||||
commits := []gitCommit{
|
||||
commitAt("a1", date, 9, 0, 0),
|
||||
commitAt("a2", date, 10, 0, 0),
|
||||
commitAt("a3", date, 11, 0, 0),
|
||||
}
|
||||
a := planRetime(commits, defaultRetimeOptions(7))
|
||||
b := planRetime(commits, defaultRetimeOptions(7))
|
||||
c := planRetime(commits, defaultRetimeOptions(8))
|
||||
for i := range a {
|
||||
if !a[i].NewTime.Equal(b[i].NewTime) {
|
||||
t.Fatalf("seed 7 is not reproducible at %d: %v vs %v", i, a[i].NewTime, b[i].NewTime)
|
||||
}
|
||||
}
|
||||
same := true
|
||||
for i := range a {
|
||||
if !a[i].NewTime.Equal(c[i].NewTime) {
|
||||
same = false
|
||||
}
|
||||
}
|
||||
if same {
|
||||
t.Error("different seeds produced identical times")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanOrdersOldestFirstAndRecordsSqueezedDays(t *testing.T) {
|
||||
date := day(2026, 10, 1)
|
||||
// git log hands commits newest first; buildPlan must flip them.
|
||||
newestFirst := []gitCommit{
|
||||
commitAt("c3", date, 11, 0, 0),
|
||||
commitAt("c2", date, 10, 0, 0),
|
||||
commitAt("c1", date, 9, 0, 0),
|
||||
}
|
||||
plan := buildPlan(newestFirst, defaultRetimeOptions(1), "main", "origin")
|
||||
|
||||
if len(plan.Commits) != 3 {
|
||||
t.Fatalf("plan has %d commits", len(plan.Commits))
|
||||
}
|
||||
wantOrder := []string{"c1", "c2", "c3"}
|
||||
for i, want := range wantOrder {
|
||||
if plan.Commits[i].Short != want {
|
||||
t.Fatalf("plan order = %v, want oldest first", []string{
|
||||
plan.Commits[0].Short, plan.Commits[1].Short, plan.Commits[2].Short})
|
||||
}
|
||||
}
|
||||
if plan.Branch != "main" || plan.Remote != "origin" {
|
||||
t.Errorf("plan branch/remote = %q/%q", plan.Branch, plan.Remote)
|
||||
}
|
||||
// Three commits fit comfortably, so no day is flagged.
|
||||
if len(plan.Squeezed) != 0 {
|
||||
t.Errorf("unexpected squeezed days: %v", plan.Squeezed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanListsEachSqueezedDayOnce(t *testing.T) {
|
||||
date := day(2026, 10, 2)
|
||||
var newestFirst []gitCommit
|
||||
for i := 29; i >= 0; i-- {
|
||||
newestFirst = append(newestFirst, commitAt("c", date, 9, 0, i))
|
||||
}
|
||||
plan := buildPlan(newestFirst, defaultRetimeOptions(3), "main", "origin")
|
||||
if len(plan.Squeezed) != 1 {
|
||||
t.Fatalf("squeezed days = %v, want exactly one entry for 2026-10-02", plan.Squeezed)
|
||||
}
|
||||
if got := plan.Squeezed[0].Format("2006-01-02"); got != "2026-10-02" {
|
||||
t.Errorf("squeezed day = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// commitMeta is everything needed to rebuild a commit with a new time.
|
||||
type commitMeta struct {
|
||||
Hash string
|
||||
Tree string
|
||||
Parents []string
|
||||
AuthorName string
|
||||
AuthorEmail string
|
||||
CommitName string
|
||||
CommitEmail string
|
||||
Subject string
|
||||
Body string
|
||||
}
|
||||
|
||||
// rawCommitFormat lists the fields kopsc rebuilds. The body (if any) is fetched
|
||||
// separately so it can keep its newlines.
|
||||
const rawCommitFormat = "%H%x09%T%x09%P%x09%an%x09%ae%x09%cn%x09%ce%x09%s"
|
||||
|
||||
// commitMetadata reads the tree, parents, identities and message of one commit.
|
||||
func commitMetadata(ctx context.Context, runner gitRunner, hash string) (commitMeta, error) {
|
||||
out, stderr, err := runner.Run(ctx, "log", "-1", "--format="+rawCommitFormat, hash)
|
||||
if err != nil {
|
||||
return commitMeta{}, gitError("git log", stderr, err)
|
||||
}
|
||||
out = strings.TrimRight(out, "\n")
|
||||
fields := strings.SplitN(out, "\t", 8)
|
||||
if len(fields) < 8 {
|
||||
return commitMeta{}, fmt.Errorf("无法解析提交 %s 的元数据: %q", hash, out)
|
||||
}
|
||||
|
||||
meta := commitMeta{
|
||||
Hash: fields[0],
|
||||
Tree: fields[1],
|
||||
AuthorName: fields[3],
|
||||
AuthorEmail: fields[4],
|
||||
CommitName: fields[5],
|
||||
CommitEmail: fields[6],
|
||||
Subject: fields[7],
|
||||
}
|
||||
if fields[2] != "" {
|
||||
meta.Parents = strings.Fields(fields[2])
|
||||
}
|
||||
|
||||
// The full message keeps the body and the exact formatting.
|
||||
body, bodyStderr, err := runner.Run(ctx, "log", "-1", "--format=%B", hash)
|
||||
if err != nil {
|
||||
return commitMeta{}, gitError("git log", bodyStderr, err)
|
||||
}
|
||||
meta.Body = strings.TrimRight(body, "\n")
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// rebuildCommit writes a new commit object with the same tree, parents and
|
||||
// identities but the given author and committer times, and returns its hash.
|
||||
//
|
||||
// git commits are immutable, so rewriting history means creating a parallel
|
||||
// commit for every original one and moving the branch on top of the new chain.
|
||||
func rebuildCommit(ctx context.Context, runner gitRunner, meta commitMeta, when time.Time) (string, error) {
|
||||
args := []string{"commit-tree", meta.Tree}
|
||||
for _, parent := range meta.Parents {
|
||||
args = append(args, "-p", parent)
|
||||
}
|
||||
args = append(args,
|
||||
"-m", meta.Body,
|
||||
)
|
||||
|
||||
env := []string{
|
||||
// Keep the original identities: only the timestamps change.
|
||||
"GIT_AUTHOR_NAME=" + meta.AuthorName,
|
||||
"GIT_AUTHOR_EMAIL=" + meta.AuthorEmail,
|
||||
"GIT_COMMITTER_NAME=" + meta.CommitName,
|
||||
"GIT_COMMITTER_EMAIL=" + meta.CommitEmail,
|
||||
// Both dates move together; ISO format keeps the seconds.
|
||||
"GIT_AUTHOR_DATE=" + when.Format(time.RFC3339),
|
||||
"GIT_COMMITTER_DATE=" + when.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
out, stderr, err := runner.RunEnv(ctx, env, args...)
|
||||
if err != nil {
|
||||
return "", gitError("git commit-tree", stderr, err)
|
||||
}
|
||||
hash := strings.TrimSpace(out)
|
||||
if hash == "" {
|
||||
return "", fmt.Errorf("git commit-tree 未返回提交哈希")
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
// rebuiltCommit records the replacement of one commit.
|
||||
type rebuiltCommit struct {
|
||||
Old string
|
||||
New string
|
||||
}
|
||||
|
||||
// rewriteChain rebuilds every commit in plan (oldest first) on top of the
|
||||
// parent of the oldest one, returning the new tip and the hash mapping.
|
||||
//
|
||||
// tree, parents and identities are preserved; only the timestamps change. The
|
||||
// branch is not moved here — that is a separate, explicit step so a failure
|
||||
// halfway leaves the repository untouched.
|
||||
func rewriteChain(ctx context.Context, runner gitRunner, plan retimePlan) (string, []rebuiltCommit, error) {
|
||||
if len(plan.Commits) == 0 {
|
||||
return "", nil, fmt.Errorf("没有需要改写的提交")
|
||||
}
|
||||
|
||||
// The new parent starts as the parent of the oldest rewritten commit, so
|
||||
// everything before the range keeps pointing at the untouched history.
|
||||
newParent := ""
|
||||
first, err := commitMetadata(ctx, runner, plan.Commits[0].Hash)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if len(first.Parents) > 0 {
|
||||
newParent = first.Parents[0]
|
||||
}
|
||||
|
||||
rebuilt := make([]rebuiltCommit, 0, len(plan.Commits))
|
||||
for i, item := range plan.Commits {
|
||||
meta := first
|
||||
if i > 0 {
|
||||
if meta, err = commitMetadata(ctx, runner, item.Hash); err != nil {
|
||||
return "", rebuilt, err
|
||||
}
|
||||
}
|
||||
// Point the commit at the rewritten parent so the chain stays linear.
|
||||
meta.Parents = nil
|
||||
if newParent != "" {
|
||||
meta.Parents = []string{newParent}
|
||||
}
|
||||
|
||||
newHash, err := rebuildCommit(ctx, runner, meta, item.NewTime)
|
||||
if err != nil {
|
||||
return "", rebuilt, err
|
||||
}
|
||||
rebuilt = append(rebuilt, rebuiltCommit{Old: item.Hash, New: newHash})
|
||||
newParent = newHash
|
||||
}
|
||||
return newParent, rebuilt, nil
|
||||
}
|
||||
|
||||
// moveBranch points branch at newTip. update-ref is atomic per ref, so a crash
|
||||
// either leaves the old history or the new one.
|
||||
func moveBranch(ctx context.Context, runner gitRunner, branch, newTip string) error {
|
||||
_, stderr, err := runner.Run(ctx, "update-ref", "refs/heads/"+branch, newTip)
|
||||
if err != nil {
|
||||
return gitError("git update-ref", stderr, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// treeOf returns the tree object a commit points at.
|
||||
func treeOf(ctx context.Context, runner gitRunner, rev string) (string, error) {
|
||||
out, stderr, err := runner.Run(ctx, "rev-parse", rev+"^{tree}")
|
||||
if err != nil {
|
||||
return "", gitError("git rev-parse", stderr, err)
|
||||
}
|
||||
tree := strings.TrimSpace(out)
|
||||
if tree == "" {
|
||||
return "", fmt.Errorf("无法读取 %s 的树对象", rev)
|
||||
}
|
||||
return tree, nil
|
||||
}
|
||||
|
||||
// treesMatch reports whether two commits carry the same content.
|
||||
//
|
||||
// Comparing tree hashes is exact and cannot be confused with a command failure
|
||||
// the way `git diff --quiet`'s exit status can.
|
||||
func treesMatch(ctx context.Context, runner gitRunner, a, b string) (bool, error) {
|
||||
treeA, err := treeOf(ctx, runner, a)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
treeB, err := treeOf(ctx, runner, b)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return treeA == treeB, nil
|
||||
}
|
||||
|
||||
// verifyRewrite checks that the rewritten tip holds exactly the same content as
|
||||
// the original one before anything is pushed.
|
||||
func verifyRewrite(ctx context.Context, runner gitRunner, oldTip, newTip string) error {
|
||||
same, err := treesMatch(ctx, runner, oldTip, newTip)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !same {
|
||||
return fmt.Errorf("改写后的内容与原提交不一致(%s vs %s),已中止推送;"+
|
||||
"可用备份引用回滚", oldTip, newTip)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// backupRefPrefix namespaces the safety refs kopsc writes before rewriting.
|
||||
const backupRefPrefix = "refs/kopsc-backup/"
|
||||
|
||||
// backupRefName builds the safety ref for a branch at a given moment.
|
||||
func backupRefName(branch string, now time.Time) string {
|
||||
return backupRefPrefix + branch + "-" + now.Format("20060102-150405")
|
||||
}
|
||||
|
||||
// ensureCleanWorktree refuses to rewrite history when there are uncommitted
|
||||
// changes: a failed checkout halfway would mix them into the new history.
|
||||
func ensureCleanWorktree(ctx context.Context, runner gitRunner) error {
|
||||
clean, err := isWorktreeClean(ctx, runner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !clean {
|
||||
return fmt.Errorf("工作区有未提交的改动,已中止;请先提交或 stash 后再重写历史(git stash)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createBackup points a safety ref at the current tip, so a rewritten branch
|
||||
// can be restored even after a failed push.
|
||||
func createBackup(ctx context.Context, runner gitRunner, branch, tip string, now time.Time) (string, error) {
|
||||
ref := backupRefName(branch, now)
|
||||
_, stderr, err := runner.Run(ctx, "update-ref", ref, tip)
|
||||
if err != nil {
|
||||
return "", gitError("git update-ref", stderr, err)
|
||||
}
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// backupHint renders the command that undoes a rewrite.
|
||||
func backupHint(branch, ref string) string {
|
||||
return "回滚: git update-ref refs/heads/" + branch + " " + ref + " (或 git reset --hard " + ref + ")"
|
||||
}
|
||||
|
||||
// forcePush replaces the remote branch with the rewritten history.
|
||||
//
|
||||
// --force-with-lease is deliberate: it refuses to overwrite commits the local
|
||||
// repository has not seen, so a colleague's push is never silently discarded.
|
||||
func forcePush(ctx context.Context, runner gitRunner, remote, branch string) error {
|
||||
out, stderr, err := runner.Run(ctx, "push", "--force-with-lease", remote, branch)
|
||||
if err != nil {
|
||||
if isRejectedPush(stderr) {
|
||||
return fmt.Errorf("远程分支 %s/%s 已有本地未知的新提交,强推被拒绝;"+
|
||||
"请先 git fetch 并确认后再试(本地历史已改写,可用备份引用回滚): %s",
|
||||
remote, branch, strings.TrimSpace(stderr))
|
||||
}
|
||||
return gitError("git push", stderr, err)
|
||||
}
|
||||
_ = out
|
||||
return nil
|
||||
}
|
||||
|
||||
// isRejectedPush reports whether git refused the push because the remote moved
|
||||
// (force-with-lease stale info, or a protected branch).
|
||||
func isRejectedPush(stderr string) bool {
|
||||
for _, marker := range []string{"stale info", "rejected", "protected branch", "hook declined"} {
|
||||
if strings.Contains(stderr, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newStateRunner() *fakeGitRunner {
|
||||
return &fakeGitRunner{responses: map[string]gitResponse{
|
||||
"rev-parse --abbrev-ref HEAD": {out: "main\n"},
|
||||
"remote": {out: "origin\nupstream\n"},
|
||||
"status --porcelain": {out: ""},
|
||||
"for-each-ref --format=%(upstream) refs/heads/main": {out: "refs/remotes/origin/main\n"},
|
||||
"for-each-ref --format=%(upstream) refs/heads/feature": {out: "\n"},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestCurrentBranch(t *testing.T) {
|
||||
branch, err := currentBranch(context.Background(), newStateRunner())
|
||||
if err != nil || branch != "main" {
|
||||
t.Fatalf("branch = %q, err = %v", branch, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentBranchRejectsDetachedHead(t *testing.T) {
|
||||
r := &fakeGitRunner{responses: map[string]gitResponse{
|
||||
"rev-parse --abbrev-ref HEAD": {out: "HEAD\n"},
|
||||
}}
|
||||
_, err := currentBranch(context.Background(), r)
|
||||
if err == nil || !strings.Contains(err.Error(), "detached HEAD") {
|
||||
t.Fatalf("err = %v, want a detached HEAD error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemotes(t *testing.T) {
|
||||
got, err := remotes(context.Background(), newStateRunner())
|
||||
if err != nil || strings.Join(got, ",") != "origin,upstream" {
|
||||
t.Fatalf("remotes = %v, err = %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWorktreeClean(t *testing.T) {
|
||||
clean, err := isWorktreeClean(context.Background(), newStateRunner())
|
||||
if err != nil || !clean {
|
||||
t.Fatalf("clean = %v, err = %v", clean, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWorktreeDirty(t *testing.T) {
|
||||
r := &fakeGitRunner{responses: map[string]gitResponse{
|
||||
"status --porcelain": {out: " M internal/cli/cli.go\n?? new.txt\n"},
|
||||
}}
|
||||
clean, err := isWorktreeClean(context.Background(), r)
|
||||
if err != nil || clean {
|
||||
t.Fatalf("clean = %v, err = %v", clean, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpstreamRef(t *testing.T) {
|
||||
ref, err := upstreamRef(context.Background(), newStateRunner(), "main")
|
||||
if err != nil || ref != "refs/remotes/origin/main" {
|
||||
t.Fatalf("ref = %q, err = %v", ref, err)
|
||||
}
|
||||
// A branch without an upstream returns "" rather than an error.
|
||||
ref, err = upstreamRef(context.Background(), newStateRunner(), "feature")
|
||||
if err != nil || ref != "" {
|
||||
t.Fatalf("ref = %q, err = %v; want no upstream", ref, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitStateHelpersPropagateErrors(t *testing.T) {
|
||||
r := &fakeGitRunner{responses: map[string]gitResponse{
|
||||
"rev-parse --abbrev-ref HEAD": {errOut: "fatal: not a repo", err: errors.New("exit 128")},
|
||||
"remote": {errOut: "boom", err: errors.New("exit 1")},
|
||||
"status --porcelain": {errOut: "boom", err: errors.New("exit 1")},
|
||||
}}
|
||||
if _, err := currentBranch(context.Background(), r); err == nil || !strings.Contains(err.Error(), "not a repo") {
|
||||
t.Errorf("currentBranch err = %v", err)
|
||||
}
|
||||
if _, err := remotes(context.Background(), r); err == nil {
|
||||
t.Error("remotes should fail")
|
||||
}
|
||||
if _, err := isWorktreeClean(context.Background(), r); err == nil {
|
||||
t.Error("isWorktreeClean should fail")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTreesMatchComparesTreeHashes(t *testing.T) {
|
||||
runner := &fakeGitRunner{responses: map[string]gitResponse{
|
||||
"rev-parse old^{tree}": {out: "treeA\n"},
|
||||
"rev-parse same^{tree}": {out: "treeA\n"},
|
||||
"rev-parse other^{tree}": {out: "treeB\n"},
|
||||
}}
|
||||
ctx := context.Background()
|
||||
|
||||
same, err := treesMatch(ctx, runner, "old", "same")
|
||||
if err != nil || !same {
|
||||
t.Fatalf("same trees: match=%v err=%v", same, err)
|
||||
}
|
||||
same, err = treesMatch(ctx, runner, "old", "other")
|
||||
if err != nil || same {
|
||||
t.Fatalf("different trees: match=%v err=%v", same, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTreesMatchReportsRevParseFailure(t *testing.T) {
|
||||
runner := &fakeGitRunner{responses: map[string]gitResponse{}}
|
||||
_, err := treesMatch(context.Background(), runner, "missing", "other")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when the commit cannot be read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRewritePassesOnIdenticalContent(t *testing.T) {
|
||||
runner := &fakeGitRunner{responses: map[string]gitResponse{
|
||||
"rev-parse oldtip^{tree}": {out: "treeX\n"},
|
||||
"rev-parse newtip^{tree}": {out: "treeX\n"},
|
||||
}}
|
||||
if err := verifyRewrite(context.Background(), runner, "oldtip", "newtip"); err != nil {
|
||||
t.Fatalf("identical content should pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRewriteStopsOnContentChange(t *testing.T) {
|
||||
runner := &fakeGitRunner{responses: map[string]gitResponse{
|
||||
"rev-parse oldtip^{tree}": {out: "treeX\n"},
|
||||
"rev-parse newtip^{tree}": {out: "treeY\n"},
|
||||
}}
|
||||
err := verifyRewrite(context.Background(), runner, "oldtip", "newtip")
|
||||
if err == nil || !strings.Contains(err.Error(), "内容与原提交不一致") {
|
||||
t.Fatalf("err = %v, want a content-mismatch error", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "回滚") {
|
||||
t.Errorf("error should point at the backup: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user