- 保留每个提交的原日期,只重排时分秒到 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 补充导航与示例
267 lines
8.6 KiB
Go
267 lines
8.6 KiB
Go
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
|
||
}
|