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

- 保留每个提交的原日期,只重排时分秒到 21:00-23:00;同日严格递增,
  间隔 5-40 分钟且秒位随机;窗口不足时自动压紧并在预览中标注
- 支持 --window/--since/--until、--interval(含 match-remote)、--seed、
  --remote、--branch、--no-push
- 安全措施:默认只预览(退出码 2)、脏工作区拒绝、改写前创建
  refs/kopsc-backup/<分支>-<时间戳> 备份、推送前比对 tree 哈希、
  只用 --force-with-lease
- BuiltinRequest 增加 Confirmed,使内置命令能获知 CLI 已确认 --yes
- 文档:新增 README.git.md,主 README 补充导航与示例
This commit is contained in:
2026-11-01 09:49:03 +08:00
parent f62c1f4872
commit f3b90d205c
19 changed files with 2581 additions and 4 deletions
+248
View File
@@ -0,0 +1,248 @@
package executor
import (
"fmt"
"math/rand"
"sort"
"time"
)
// retime is one planned change: a commit and the time it should carry.
type retime struct {
Hash string
Short string
Subject string // commit subject, shown in the preview
OldAuthor time.Time
OldCommit time.Time
NewTime time.Time
Squeezed bool // the day's window could not fit the random gaps
}
// retimeOptions controls how new times are chosen.
type retimeOptions struct {
WindowStart time.Duration // e.g. 21h, measured from midnight
WindowEnd time.Duration // e.g. 23h; commits stay strictly before this
MinGap time.Duration // shortest gap between two commits on one day
MaxGap time.Duration // longest gap between two commits on one day
FirstGapMax time.Duration // latest start offset of the first commit in a day
Rand *rand.Rand
// Gaps, when non-empty, supplies the gap before each commit (index i > 0)
// instead of a random value. It is how --interval match-remote mirrors the
// spacing found on the remote branch. A zero (or out-of-range) entry falls
// back to a random gap.
Gaps []time.Duration
}
// defaultRetimeOptions matches the command's documented behaviour: commits land
// between 21:00 and 23:00, 5–40 minutes apart, the first one no later than 20
// minutes after 21:00.
func defaultRetimeOptions(seed int64) retimeOptions {
return retimeOptions{
WindowStart: 21 * time.Hour,
WindowEnd: 23 * time.Hour,
MinGap: 5 * time.Minute,
MaxGap: 40 * time.Minute,
FirstGapMax: 20 * time.Minute,
Rand: rand.New(rand.NewSource(seed)),
}
}
// planRetime assigns a new time to every commit in commits (which must be in
// chronological order, oldest first).
//
// Each commit keeps its own calendar day; only the time of day changes. Within
// a day the commits stay strictly increasing, separated by a random gap in
// [MinGap, MaxGap] with a random second component, so the result looks like
// ordinary hand-made commits rather than a machine grid.
func planRetime(commits []gitCommit, opts retimeOptions) []retime {
window := opts.WindowEnd - opts.WindowStart
out := make([]retime, 0, len(commits))
// Group by calendar day, preserving order.
var dayCommits []gitCommit
currentDay := time.Time{}
flush := func() {
if len(dayCommits) == 0 {
return
}
out = append(out, planDay(dayCommits, opts, window)...)
dayCommits = nil
}
for _, c := range commits {
day := c.Date()
if !currentDay.IsZero() && !day.Equal(currentDay) {
flush()
}
currentDay = day
dayCommits = append(dayCommits, c)
}
flush()
return out
}
// planDay spreads one day's commits across the window.
func planDay(commits []gitCommit, opts retimeOptions, window time.Duration) []retime {
out := make([]retime, 0, len(commits))
day := commits[0].Date()
offsets, squeezed := spreadWithin(day, len(commits), opts, window)
for i, c := range commits {
// Offsets are measured from the window start (21:00), not midnight.
newTime := withOffset(day, opts.WindowStart+offsets[i])
out = append(out, retime{
Hash: c.Hash,
Short: c.Short,
Subject: c.Subject,
OldAuthor: c.AuthorTime,
OldCommit: c.CommitTime,
NewTime: newTime,
Squeezed: squeezed,
})
}
return out
}
// spreadWithin returns the time-of-day offset (with sub-minute precision) for
// each commit of a day.
//
// The first commit starts within [0, FirstGapMax]; each following commit moves
// forward by a random gap in [MinGap, MaxGap] plus random seconds. If that
// would leave the window, the day is compressed: the commits are spread evenly
// across the window instead, and flagged as squeezed.
// It also reports whether the day had to be compressed.
func spreadWithin(day time.Time, count int, opts retimeOptions, window time.Duration) ([]time.Duration, bool) {
offsets := make([]time.Duration, count)
if count == 0 {
return offsets, false
}
first := randomDuration(opts.Rand, 0, opts.FirstGapMax)
offsets[0] = first
for i := 1; i < count; i++ {
offsets[i] = offsets[i-1] + gapFor(i, opts)
}
// Everything must stay strictly before WindowEnd, so a day the window
// cannot hold falls back to an even spread across it.
if offsets[count-1] < window {
return offsets, false
}
// Even spread: count slots of window/count, starting at the window start
// so no commit drifts past the end.
step := window / time.Duration(count)
if step <= 0 {
step = time.Second
}
for i := range offsets {
// Keep a random second component so a compressed day still does not
// look like a perfect grid.
jitter := time.Duration(opts.Rand.Int63n(int64(step / 2)))
offsets[i] = time.Duration(i)*step + jitter
}
return offsets, true
}
// gapFor returns the gap before the i-th commit of a day. A supplied gap is
// used when it lands inside [MinGap, MaxGap]; otherwise the gap is random, so a
// remote branch whose intervals are wildly different cannot push commits out
// of the day's window.
func gapFor(i int, opts retimeOptions) time.Duration {
if i < len(opts.Gaps) {
if g := opts.Gaps[i]; g >= opts.MinGap && g <= opts.MaxGap {
return g
}
}
return randomDuration(opts.Rand, opts.MinGap, opts.MaxGap)
}
// remoteGaps converts the spacing between consecutive commits on the remote
// branch into the per-commit gap list consumed by retimeOptions.Gaps.
//
// remote is expected in the same order as commits (oldest first); when it
// cannot be paired up, an empty list is returned and the plan falls back to
// random gaps.
func remoteGaps(remote []time.Time) []time.Duration {
if len(remote) < 2 {
return nil
}
gaps := make([]time.Duration, len(remote))
for i := 1; i < len(remote); i++ {
gap := remote[i].Sub(remote[i-1])
if gap <= 0 {
return nil // the remote history is not ordered; do not use it
}
gaps[i] = gap
}
return gaps
}
// randomDuration returns a random duration in [min, max] with second precision.
func randomDuration(r *rand.Rand, min, max time.Duration) time.Duration {
if max <= min {
return min
}
span := int64(max - min)
// Random second component, then whole milliseconds within the span.
offset := time.Duration(r.Int63n(span)) + time.Duration(r.Int63n(int64(time.Second)))
if offset >= max-min {
offset = max - min - time.Nanosecond
}
// Truncate to whole seconds: git stores "minutes + seconds" of wall time.
return min + offset.Truncate(time.Second)
}
// withOffset returns midnight of day plus offset.
func withOffset(day time.Time, offset time.Duration) time.Time {
return day.Add(offset)
}
// sortRetimesOldestFirst sorts a plan in commit order (oldest first), keeping
// the relationship the rewrite step needs.
func sortRetimesOldestFirst(plans []retime) {
sort.SliceStable(plans, func(i, j int) bool {
return plans[i].OldAuthor.Before(plans[j].OldAuthor)
})
}
// summarizeRetime renders a plan as "21:07:43 -> 21:32:11".
func summarizeRetime(r retime) string {
return fmt.Sprintf("%s -> %s", r.OldAuthor.Format("15:04:05"), r.NewTime.Format("15:04:05"))
}
// retimeWindow is the longest gap between two commits kopsc will produce.
// Kept here so preview, validation and the rewrite all describe the same thing.
type retimePlan struct {
Commits []retime // oldest first
Branch string // branch being rewritten
Remote string // remote to force-push to
Squeezed []time.Time // days whose window could not fit the gaps
}
// buildPlan collects one plan used by both the preview and the rewrite, so the
// times shown to the user are exactly the times that get committed.
func buildPlan(commits []gitCommit, opts retimeOptions, branch, remote string) retimePlan {
// git log lists newest first; the planner works oldest first.
ordered := make([]gitCommit, len(commits))
for i, c := range commits {
ordered[len(commits)-1-i] = c
}
plan := retimePlan{Commits: planRetime(ordered, opts), Branch: branch, Remote: remote}
seen := map[string]bool{}
for _, r := range plan.Commits {
if !r.Squeezed {
continue
}
day := r.NewTime.Format("2006-01-02")
if !seen[day] {
seen[day] = true
plan.Squeezed = append(plan.Squeezed, r.NewTime)
}
}
return plan
}