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,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
|
||||
}
|
||||
Reference in New Issue
Block a user