feat: 新增 git retime,把指定日期范围的提交时间改到 21:00-23:00 并强推
- 保留每个提交的原日期,只重排时分秒到 21:00-23:00;同日严格递增, 间隔 5-40 分钟且秒位随机;窗口不足时自动压紧并在预览中标注 - 支持 --window/--since/--until、--interval(含 match-remote)、--seed、 --remote、--branch、--no-push - 安全措施:默认只预览(退出码 2)、脏工作区拒绝、改写前创建 refs/kopsc-backup/<分支>-<时间戳> 备份、推送前比对 tree 哈希、 只用 --force-with-lease - BuiltinRequest 增加 Confirmed,使内置命令能获知 CLI 已确认 --yes - 文档:新增 README.git.md,主 README 补充导航与示例
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user