Files
kenaito-kopsc/internal/executor/gitretime.go
T
odboy 1ae9488d8a feat: git retime 支持重排提交日期,新增 install.sh
- 新增 --start-date:按原顺序从指定日期起重排提交日期,默认每天一个
- 新增 --per-day 控制每天提交数、--day-gap 控制日期间隔;两者需配合
  --start-date 使用,否则报错提示
- 不给 --start-date 时保持原行为(保留原日期,只改时分秒)
- 预览在重排模式下显示完整的新日期时间,并说明日期规则
- install.sh:补 shebang 与可执行权限,构建后安装到 /usr/local/bin
- 文档:README.git.md 增加「重排日期」小节与选项表
2026-11-01 10:05:19 +08:00

283 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// StartDate, when set, re-dates the commits instead of keeping their
// original days: the selected commits are laid out in order starting at
// StartDate, stepping DayGap days per day and PerDay commits per day.
StartDate time.Time
DayGap int // days between consecutive commit days, at least 1
PerDay int // commits placed on one day, 0 means "unlimited" (one day only)
}
// 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))
// When a start date is given, days are assigned rather than inherited.
redate := !opts.StartDate.IsZero()
if redate && opts.DayGap < 1 {
opts.DayGap = 1
}
// Group by calendar day, preserving order.
var dayCommits []gitCommit
var day time.Time
flush := func() {
if len(dayCommits) == 0 {
return
}
out = append(out, planDay(day, dayCommits, opts, window)...)
dayCommits = nil
}
for _, c := range commits {
if redate {
// Start a new day whenever the current one is full.
if len(dayCommits) == 0 {
if !day.IsZero() {
day = day.AddDate(0, 0, opts.DayGap)
} else {
day = midnightOf(opts.StartDate)
}
}
if opts.PerDay > 0 && len(dayCommits) >= opts.PerDay {
flush()
day = day.AddDate(0, 0, opts.DayGap)
}
dayCommits = append(dayCommits, c)
continue
}
original := c.Date()
if !day.IsZero() && !original.Equal(day) {
flush()
}
day = original
dayCommits = append(dayCommits, c)
}
flush()
return out
}
// midnightOf returns the start of the calendar day of t, in t's location.
func midnightOf(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}
// planDay spreads one day's commits across the window.
func planDay(day time.Time, commits []gitCommit, opts retimeOptions, window time.Duration) []retime {
out := make([]retime, 0, len(commits))
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
}