Files
odboy f3b90d205c 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 补充导航与示例
2026-11-01 09:49:03 +08:00

155 lines
5.0 KiB
Go

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
}