diff --git a/README.git.md b/README.git.md new file mode 100644 index 0000000..ba7d696 --- /dev/null +++ b/README.git.md @@ -0,0 +1,88 @@ +# Git 历史(`kopsc git <子命令>`) + +> 返回 [README.md](README.md) + +按日期范围批量调整提交时间,然后强推。用于把一批提交的时分秒统一挪到某个时间段。 + +```bash +kopsc git retime 2026-10-01 2026-10-15 # 预览:会改哪些提交、改成什么时间 +kopsc git retime 2026-10-01 2026-10-15 --yes # 确认后真正改写并强推 +``` + +> ⚠️ **这会改写历史并强推**,提交 SHA 会全部变化。默认只预览、不改任何东西,必须显式加 `--yes` 才会执行。 + +## 改写提交时间(git.retime) + +`kopsc git retime <开始日期> <结束日期> [选项]` 把该日期范围内(含两端)的提交, +**保留各自的原日期**,只把时分秒重排到 **21:00–23:00** 之间,然后 `git push --force-with-lease`。 + +```bash +kopsc git retime 2026-10-01 2026-10-15 # 默认窗口 21:00-23:00 +kopsc git retime 2026-10-01 2026-10-15 --interval 10-25 # 间隔改为 10-25 分钟 +kopsc git retime 2026-10-01 2026-10-15 --window 20:00-22:30 # 换一个时间段 +kopsc git retime 2026-10-01 2026-10-15 --seed 42 # 固定随机种子,结果可复现 +kopsc git retime 2026-10-01 2026-10-15 --no-push --yes # 只改本地,不推送 +``` + +### 时间怎么分配 + +- 每个提交落在**自己原来的日期**上,日期不变。 +- 同一天内的提交按原顺序**严格递增**。 +- 相邻提交间隔是 **5–40 分钟**的随机值,**秒位也随机**(如 `21:07:43` → `21:32:11`), + 与真实手工提交一致,不会出现整齐的 `:00` 秒。 +- 当天第一条提交在 21:00–21:20 之间随机开始。 +- 如果某天提交太多、21:00–23:00 放不下所有间隔,会**自动压紧为窗口内均匀分布**, + 预览表中该行标注「⚠ 已压缩间隔」。 + +### 预览示例 + +```text +改写: 2 个提交(分支 main,远程 origin) +强推: 是 --force-with-lease → origin/main +目标: origin/main +窗口: 21:00-23:00(保留原日期) + +提交 原时间 新时间 主题 +1111111 2026-10-01 10:00:00 21:13:12 feat: 初始提交 +2222222 2026-10-01 22:10:00 21:25:03 docs: 补充说明 +``` + +无 `--yes` 时输出预览并**以退出码 2 结束**,不修改任何东西。范围内没有提交时正常退出。 + +### 安全措施 + +| 措施 | 说明 | +| --- | --- | +| 默认只预览 | 不加 `--yes` 不碰仓库 | +| 工作区检查 | 有未提交改动时直接拒绝(先 `git stash` 或提交) | +| 自动备份 | 改写前创建 `refs/kopsc-backup/<分支>-<时间戳>` 指向原提交 | +| 内容校验 | 推送前比对新旧提交的 tree 哈希,内容不一致则中止 | +| 租约强推 | 只用 `--force-with-lease`,绝不覆盖远程上本地未知的新提交 | + +回滚(本地):命令结束时也会打印这行 + +```bash +git update-ref refs/heads/main refs/kopsc-backup/main-20261101-093000 +# 或 +git reset --hard refs/kopsc-backup/main-20261101-093000 +``` + +### 选项 + +| 选项 | 说明 | +| --- | --- | +| `<开始日期> <结束日期>` | 必填,格式 `YYYY-MM-DD`,含两端 | +| `--window HH:MM-HH:MM` | 时间窗口,默认 `21:00-23:00`(`--since` / `--until` 为等价别名) | +| `--interval 5-40` | 相邻提交间隔(分钟),默认 5-40;也可写 `--interval match-remote` 参照远程分支的间隔 | +| `--seed N` | 随机种子,同一批提交用同一种子得到相同结果 | +| `--remote <名称>` | 强推到哪个远程,默认取唯一远程 | +| `--branch <名称>` | 改写的分支,默认当前分支 | +| `--no-push` | 只改写本地,不推送 | +| `-y`, `--yes` | 确认执行(没有它只预览) | + +### 注意事项 + +- **强推会影响协作者**:其他人本地历史会与远程分叉,需要重新拉取。 +- 仓库若开启**分支保护**,强推会被拒绝。此时**本地历史已经改写**,请用上面的备份引用回滚。 +- 只处理**本地**已有提交;尚未推送的提交同样适用(`--no-push` 只改本地)。 +- 页面显示的是**作者时间**,命令会同时改写作者时间与提交时间,避免两者矛盾。 diff --git a/README.md b/README.md index dc3ae86..9b78c3b 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,9 @@ kopsc java run app.jar # 按 JDK 版本自动套调优参数 kopsc java run app.jar -- --spring.profiles.active=prod kopsc java run --jdk 17 --print-flags app.jar # 只看最终 java 命令行 +kopsc git retime 2026-10-01 2026-10-15 # 预览:把该范围提交时间改到 21:00-23:00 +kopsc git retime 2026-10-01 2026-10-15 --yes # 确认后改写并强推(会改写历史!) + kopsc list # 全部命令及当前平台可用性 kopsc explain docker.clearsafe # 匹配过程、全部候选及各自被选中/拒绝的原因 kopsc explain docker clearsafe # 同上,空格写法也支持 @@ -144,6 +147,7 @@ kopsc 的命令按用途分组,每组的完整说明(命令表、选项、 | **diagnose** | 容器 / Pod 状态根因诊断(自动识别 Docker 与 K8s) | [README.diagnose.md](README.diagnose.md) | | **proc** | 本机进程:后台运行命令并写日志、等待进程退出 | [README.proc.md](README.proc.md) | | **java** | Java 应用:按 JDK 版本套用调优参数并运行 jar | [README.java.md](README.java.md) | +| **git** | Git 历史:按日期范围调整提交时间并强推 | [README.git.md](README.git.md) | | **pkg** | 系统软件包:刷新包管理器索引 | [README.pkg.md](README.pkg.md) | 辅助命令(不涉及具体资源,直接用即可): @@ -170,6 +174,7 @@ kopsc 的命令按用途分组,每组的完整说明(命令表、选项、 | 后台跑服务 | `kopsc proc nohup ./server` | | 等进程退出 | `kopsc proc wait nginx`、`kopsc proc wait --pid 1234 --timeout 30` | | 跑 Java 应用 | `kopsc java run app.jar`、`kopsc java run --jdk 17 --print-flags app.jar` | +| 调整提交时间 | `kopsc git retime 2026-10-01 2026-10-15`(加 `--yes` 才执行) | | 刷新软件源 | `kopsc pkg refreshindex` | ## 平台识别 @@ -232,6 +237,7 @@ kopsc 的命令按用途分组,每组的完整说明(命令表、选项、 | proc | [`README.proc.md`](README.proc.md) | | pkg | [`README.pkg.md`](README.pkg.md) | | java | [`README.java.md`](README.java.md) | +| git | [`README.git.md`](README.git.md) | 新分组需要新建 `README.<分组>.md`,并在本文件「命令一览」的表格里加一行链接。 每条命令至少要写清:**作用**、**实际执行什么**、**示例**;有独有选项时补选项表。 diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 97bc899..7b741e7 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -222,6 +222,7 @@ var groupDescriptions = map[string]string{ "pkg": "系统软件包:刷新包管理器(dnf/apt/apk/brew/winget)的软件源索引", "proc": "本机进程:在后台运行命令并写日志,关掉终端也不退出", "java": "Java 应用:按 JDK 版本自动套用调优参数并前台运行 jar", + "git": "Git 历史:把指定日期范围的提交时间改到 21:00-23:00 并强推", } // newGroup builds an intermediate command such as `kopsc disk`. @@ -308,7 +309,8 @@ func (a *App) newLeaf(cmd *registry.Command, name string) *cobra.Command { } return plan.Run(context.Background(), executor.Options{ - DryRun: dryRun, + DryRun: dryRun, + Confirmed: yes, Stdin: a.Stdin, Stdout: a.Stdout, Stderr: a.Stderr, diff --git a/internal/executor/builtin.go b/internal/executor/builtin.go index f64ebb7..512c0f8 100644 --- a/internal/executor/builtin.go +++ b/internal/executor/builtin.go @@ -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, diff --git a/internal/executor/executor.go b/internal/executor/executor.go index eb34b8e..84c29db 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -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, }) } diff --git a/internal/executor/gitbackup_test.go b/internal/executor/gitbackup_test.go new file mode 100644 index 0000000..53f337a --- /dev/null +++ b/internal/executor/gitbackup_test.go @@ -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) + } +} diff --git a/internal/executor/gitlog.go b/internal/executor/gitlog.go new file mode 100644 index 0000000..d944791 --- /dev/null +++ b/internal/executor/gitlog.go @@ -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 +} diff --git a/internal/executor/gitlog_test.go b/internal/executor/gitlog_test.go new file mode 100644 index 0000000..de49283 --- /dev/null +++ b/internal/executor/gitlog_test.go @@ -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) + } +} diff --git a/internal/executor/gitpush_test.go b/internal/executor/gitpush_test.go new file mode 100644 index 0000000..70adf13 --- /dev/null +++ b/internal/executor/gitpush_test.go @@ -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) + } +} diff --git a/internal/executor/gitretime.go b/internal/executor/gitretime.go new file mode 100644 index 0000000..ab26164 --- /dev/null +++ b/internal/executor/gitretime.go @@ -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 +} diff --git a/internal/executor/gitretime_cmd.go b/internal/executor/gitretime_cmd.go new file mode 100644 index 0000000..9ddf066 --- /dev/null +++ b/internal/executor/gitretime_cmd.go @@ -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 [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 +} diff --git a/internal/executor/gitretime_cmd_test.go b/internal/executor/gitretime_cmd_test.go new file mode 100644 index 0000000..2ae98ca --- /dev/null +++ b/internal/executor/gitretime_cmd_test.go @@ -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 +} diff --git a/internal/executor/gitretime_test.go b/internal/executor/gitretime_test.go new file mode 100644 index 0000000..e010321 --- /dev/null +++ b/internal/executor/gitretime_test.go @@ -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) + } +} diff --git a/internal/executor/gitrewrite.go b/internal/executor/gitrewrite.go new file mode 100644 index 0000000..ed36210 --- /dev/null +++ b/internal/executor/gitrewrite.go @@ -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 +} diff --git a/internal/executor/gitrewrite_test.go b/internal/executor/gitrewrite_test.go new file mode 100644 index 0000000..7aa336d --- /dev/null +++ b/internal/executor/gitrewrite_test.go @@ -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") + } +} diff --git a/internal/executor/gitstate_test.go b/internal/executor/gitstate_test.go new file mode 100644 index 0000000..5679718 --- /dev/null +++ b/internal/executor/gitstate_test.go @@ -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") + } +} diff --git a/internal/executor/gitverify_test.go b/internal/executor/gitverify_test.go new file mode 100644 index 0000000..4248fea --- /dev/null +++ b/internal/executor/gitverify_test.go @@ -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) + } +} diff --git a/internal/registry/builtin.go b/internal/registry/builtin.go index 916104b..5a1b1f5 100644 --- a/internal/registry/builtin.go +++ b/internal/registry/builtin.go @@ -481,6 +481,37 @@ func Builtin() *Registry { }, }, }, + { + ID: "git.retime", + Group: "git", + Summary: "把指定日期范围的提交时间改到 21:00-23:00 并强推", + Action: "读取本地分支在给定日期范围内(含两端)的提交,保留各自的原日期," + + "把时分秒重排到 21:00-23:00 之间:同一日期内相邻提交严格递增,间隔取 5-40 分钟的随机值(秒位随机)," + + "也可用 --interval match-remote 参照远程分支的间隔。随后重建这段历史并以 --force-with-lease 强推。" + + "改动前自动创建 refs/kopsc-backup/<分支>-<时间戳> 备份引用,工作区有未提交改动时拒绝执行;" + + "默认只打印预览表并以退出码 2 结束,必须显式加 --yes 才会改写并推送", + Usage: "<开始日期> <结束日期> [选项]", + MinArgs: 2, + RequiresYes: true, + ConfirmBuiltin: "git.retime.preview", + Variants: []Variant{ + { + Match: Match{OS: "linux"}, + Builtin: "git.retime", + Note: "Linux 内置实现(调用本机 git)", + }, + { + Match: Match{OS: "darwin"}, + Builtin: "git.retime", + Note: "macOS 内置实现(调用本机 git)", + }, + { + Match: Match{OS: "windows"}, + Builtin: "git.retime", + Note: "Windows 内置实现(调用本机 git)", + }, + }, + }, { ID: "pkg.refreshindex", Group: "pkg", diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index 58db317..a4ecaaf 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -25,7 +25,7 @@ func TestBuiltinIDsStable(t *testing.T) { "docker.prunebuilder", "docker.prunevolumes", "docker.clearall", "docker.compose.up", "docker.compose.down", "docker.compose.ps", "docker.compose.logs", "diagnose", - "disk.usage", "net.pids", "net.ports", "proc.nohup", "proc.wait", "java.run", "pkg.refreshindex", + "disk.usage", "net.pids", "net.ports", "proc.nohup", "proc.wait", "java.run", "git.retime", "pkg.refreshindex", } if got := reg.IDs(); !reflect.DeepEqual(got, want) { t.Fatalf("IDs() =\n%v\nwant\n%v", got, want)