feat: 新增 git retime,把指定日期范围的提交时间改到 21:00-23:00 并强推

- 保留每个提交的原日期,只重排时分秒到 21:00-23:00;同日严格递增,
  间隔 5-40 分钟且秒位随机;窗口不足时自动压紧并在预览中标注
- 支持 --window/--since/--until、--interval(含 match-remote)、--seed、
  --remote、--branch、--no-push
- 安全措施:默认只预览(退出码 2)、脏工作区拒绝、改写前创建
  refs/kopsc-backup/<分支>-<时间戳> 备份、推送前比对 tree 哈希、
  只用 --force-with-lease
- BuiltinRequest 增加 Confirmed,使内置命令能获知 CLI 已确认 --yes
- 文档:新增 README.git.md,主 README 补充导航与示例
This commit is contained in:
2026-11-01 09:49:03 +08:00
parent f62c1f4872
commit f3b90d205c
19 changed files with 2581 additions and 4 deletions
+5
View File
@@ -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,
+6 -2
View File
@@ -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,
})
}
+69
View File
@@ -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)
}
}
+154
View File
@@ -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
}
+134
View File
@@ -0,0 +1,134 @@
package executor
import (
"context"
"errors"
"strings"
"testing"
"time"
)
// gitResponse is one canned git result.
type gitResponse struct {
out, errOut string
err error
}
// fakeGitRunner answers git invocations from a table keyed by the joined args.
type fakeGitRunner struct {
responses map[string]gitResponse
calls []string
envs []([]string)
}
func (f *fakeGitRunner) Run(ctx context.Context, args ...string) (string, string, error) {
return f.RunEnv(ctx, nil, args...)
}
// RunEnv records the environment it was given so tests can assert on the
// author/committer dates passed to git commit-tree.
func (f *fakeGitRunner) RunEnv(ctx context.Context, env []string, args ...string) (string, string, error) {
key := strings.Join(args, " ")
f.calls = append(f.calls, key)
if len(env) > 0 {
f.envs = append(f.envs, env)
}
if r, ok := f.responses[key]; ok {
return r.out, r.errOut, r.err
}
return "", "unexpected git call: " + key, errors.New("exit status 1")
}
func TestParseGitLog(t *testing.T) {
out := "aaaa1111\t4f2a1b0\t2026-10-01T21:07:43+08:00\t2026-10-01T21:07:43+08:00\tfeat: 初始提交\n" +
"bbbb2222\t9c6f072\t2026-10-02T22:32:11+08:00\t2026-10-02T22:33:00+08:00\tdocs: 补充说明\n"
commits, err := parseGitLog(out)
if err != nil {
t.Fatalf("parseGitLog: %v", err)
}
if len(commits) != 2 {
t.Fatalf("parsed %d commits, want 2", len(commits))
}
first := commits[0]
if first.Hash != "aaaa1111" || first.Short != "4f2a1b0" || first.Subject != "feat: 初始提交" {
t.Errorf("first = %+v", first)
}
// Times keep their seconds, which the retiming relies on.
if first.AuthorTime.Second() != 43 || first.AuthorTime.Hour() != 21 || first.AuthorTime.Minute() != 7 {
t.Errorf("author time lost precision: %v", first.AuthorTime)
}
if commits[1].CommitTime.After(commits[1].AuthorTime) != true {
t.Errorf("committer time not parsed: %v", commits[1].CommitTime)
}
}
func TestParseGitLogSkipsTrailingBlankLine(t *testing.T) {
out := "aaaa\t4f2\t2026-10-01T21:07:43+08:00\t2026-10-01T21:07:43+08:00\tsubject\n\n"
commits, err := parseGitLog(out)
if err != nil || len(commits) != 1 {
t.Fatalf("commits = %v, err = %v", commits, err)
}
}
func TestParseGitLogKeepsTabsInSubject(t *testing.T) {
// SplitN with limit 5 keeps anything the subject contains, tabs included.
out := "aaaa\t4f2\t2026-10-01T21:00:00+08:00\t2026-10-01T21:00:00+08:00\tsub\twith\ttabs\n"
commits, err := parseGitLog(out)
if err != nil {
t.Fatal(err)
}
if commits[0].Subject != "sub\twith\ttabs" {
t.Errorf("subject = %q", commits[0].Subject)
}
}
func TestParseGitLogRejectsBrokenInput(t *testing.T) {
if _, err := parseGitLog("not-enough-fields\n"); err == nil {
t.Error("expected an error for a short record")
}
bad := "a\tb\tnot-a-time\t2026-10-01T21:00:00+08:00\ts\n"
if _, err := parseGitLog(bad); err == nil || !strings.Contains(err.Error(), "作者时间") {
t.Errorf("err = %v, want an author-time error", err)
}
}
func TestGitCommitDate(t *testing.T) {
loc := time.FixedZone("CST", 8*3600)
c := gitCommit{AuthorTime: time.Date(2026, 10, 5, 21, 7, 43, 0, loc)}
d := c.Date()
if d.Hour() != 0 || d.Minute() != 0 || d.Day() != 5 || d.Month() != time.October || d.Year() != 2026 {
t.Fatalf("Date() = %v", d)
}
if d.Location() != loc {
t.Errorf("Date() lost the original time zone: %v", d.Location())
}
}
func TestListCommitsRunsGitLog(t *testing.T) {
runner := &fakeGitRunner{responses: map[string]gitResponse{
"log --format=" + gitLogFormat + " main": {
out: "aaaa\t4f2\t2026-10-01T21:00:00+08:00\t2026-10-01T21:00:00+08:00\tsubject\n",
},
}}
commits, err := listCommits(context.Background(), runner, "main")
if err != nil {
t.Fatalf("listCommits: %v", err)
}
if len(commits) != 1 || commits[0].Short != "4f2" {
t.Fatalf("commits = %+v", commits)
}
}
func TestListCommitsReportsGitFailure(t *testing.T) {
runner := &fakeGitRunner{responses: map[string]gitResponse{
"log --format=" + gitLogFormat + " main": {
errOut: "fatal: not a git repository", err: errors.New("exit status 128"),
},
}}
_, err := listCommits(context.Background(), runner, "main")
if err == nil || !strings.Contains(err.Error(), "not a git repository") {
t.Fatalf("err = %v, want git's stderr", err)
}
}
+72
View File
@@ -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)
}
}
+248
View File
@@ -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
}
+477
View File
@@ -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
}
+425
View File
@@ -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
}
+329
View File
@@ -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)
}
}
+266
View File
@@ -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
}
+122
View File
@@ -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")
}
}
+88
View File
@@ -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")
}
}
+57
View File
@@ -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)
}
}