feat: git retime 改为在 --start-date/--end-date 区间内均匀分布提交
- 新增 --end-date:提交按比例铺满 [start-date, end-date],第 i 个提交 落在第 i×天数÷提交数 天,提交远少于天数时也不会堆在第一天 - 移除 --per-day / --day-gap:原先的“每天一个、日期顺延”语义不直观 - 只给 --start-date 时集中在该日;--end-date 需配合 --start-date 且不早于它 - 同一天的多条提交仍按顺序落在 21:00-23:00 之间 - 文档与预览文案同步为区间语义
This commit is contained in:
@@ -34,11 +34,10 @@ type retimeOptions struct {
|
||||
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.
|
||||
// original days: the commits are spread evenly across [StartDate, EndDate]
|
||||
// (EndDate defaults to StartDate), in order.
|
||||
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)
|
||||
EndDate time.Time
|
||||
}
|
||||
|
||||
// defaultRetimeOptions matches the command's documented behaviour: commits land
|
||||
@@ -67,9 +66,8 @@ func planRetime(commits []gitCommit, opts retimeOptions) []retime {
|
||||
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
|
||||
if !opts.StartDate.IsZero() {
|
||||
return planRetimeInRange(commits, opts, window)
|
||||
}
|
||||
|
||||
// Group by calendar day, preserving order.
|
||||
@@ -84,23 +82,6 @@ func planRetime(commits []gitCommit, opts retimeOptions) []retime {
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -112,6 +93,55 @@ func planRetime(commits []gitCommit, opts retimeOptions) []retime {
|
||||
return out
|
||||
}
|
||||
|
||||
// planRetimeInRange spreads commits evenly across [StartDate, EndDate].
|
||||
//
|
||||
// Day assignment is proportional rather than "fill the first day first": the
|
||||
// i-th commit of n lands on day floor(i * days / n), so a handful of commits
|
||||
// over a long range is spread across it instead of bunching at the start.
|
||||
// Commits sharing a day keep their order and land between 21:00 and 23:00.
|
||||
func planRetimeInRange(commits []gitCommit, opts retimeOptions, window time.Duration) []retime {
|
||||
if len(commits) == 0 {
|
||||
return nil
|
||||
}
|
||||
start := midnightOf(opts.StartDate)
|
||||
end := start
|
||||
if !opts.EndDate.IsZero() {
|
||||
end = midnightOf(opts.EndDate)
|
||||
}
|
||||
days := int(end.Sub(start).Hours()/24) + 1
|
||||
if days < 1 {
|
||||
days = 1
|
||||
}
|
||||
total := len(commits)
|
||||
|
||||
// Group consecutive commits that map to the same day.
|
||||
out := make([]retime, 0, total)
|
||||
var group []gitCommit
|
||||
groupDay := -1
|
||||
flush := func() {
|
||||
if len(group) == 0 {
|
||||
return
|
||||
}
|
||||
day := start.AddDate(0, 0, groupDay)
|
||||
out = append(out, planDay(day, group, opts, window)...)
|
||||
group = nil
|
||||
}
|
||||
|
||||
for i, c := range commits {
|
||||
day := i * days / total
|
||||
if day >= days {
|
||||
day = days - 1
|
||||
}
|
||||
if groupDay >= 0 && day != groupDay {
|
||||
flush()
|
||||
}
|
||||
groupDay = day
|
||||
group = append(group, 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())
|
||||
|
||||
@@ -29,8 +29,7 @@ type gitRetimeOptions struct {
|
||||
// Redating: when StartDate is set, the selected commits are re-dated in
|
||||
// order starting there instead of keeping their original days.
|
||||
StartDate time.Time
|
||||
DayGap int
|
||||
PerDay int
|
||||
EndDate time.Time // defaults to StartDate when only the start is given
|
||||
}
|
||||
|
||||
// runGitRetime rewrites the timestamps of commits in [From, To] so they land
|
||||
@@ -118,13 +117,12 @@ func writeRetimePreview(req BuiltinRequest, plan retimePlan, opts gitRetimeOptio
|
||||
fmt.Fprintf(w, "目标: %s\n", target)
|
||||
dateRule := "保留原日期"
|
||||
if !opts.StartDate.IsZero() {
|
||||
dateRule = "从 " + opts.StartDate.Format("2006-01-02") + " 起重排"
|
||||
if opts.PerDay > 0 {
|
||||
dateRule += fmt.Sprintf(",每天 %d 个", opts.PerDay)
|
||||
}
|
||||
if opts.DayGap > 1 {
|
||||
dateRule += fmt.Sprintf(",间隔 %d 天", opts.DayGap)
|
||||
end := opts.EndDate
|
||||
if end.IsZero() {
|
||||
end = opts.StartDate
|
||||
}
|
||||
dateRule = fmt.Sprintf("在 %s 至 %s 之间均匀分布",
|
||||
opts.StartDate.Format("2006-01-02"), end.Format("2006-01-02"))
|
||||
}
|
||||
fmt.Fprintf(w, "窗口: %s-%s(%s)\n\n",
|
||||
clockOf(opts.Window), clockOf(opts.WindowEnd), dateRule)
|
||||
@@ -218,8 +216,7 @@ func collectRetimePlan(ctx context.Context, runner gitRunner, opts gitRetimeOpti
|
||||
FirstGapMax: 20 * time.Minute,
|
||||
Rand: newRetimeRand(opts.Seed),
|
||||
StartDate: opts.StartDate,
|
||||
DayGap: opts.DayGap,
|
||||
PerDay: opts.PerDay,
|
||||
EndDate: opts.EndDate,
|
||||
}
|
||||
_ = opts.MatchEmote
|
||||
return buildPlan(selected, plannerOpts, branch, remote), nil
|
||||
@@ -388,26 +385,14 @@ func parseGitRetimeArgs(args []string, now time.Time) (gitRetimeOptions, bool, e
|
||||
if opts.StartDate, err = parseDate(v); err != nil {
|
||||
return opts, false, err
|
||||
}
|
||||
case "--day-gap":
|
||||
case "--end-date":
|
||||
v, err := takeValue()
|
||||
if err != nil {
|
||||
return opts, false, err
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 1 {
|
||||
return opts, false, fmt.Errorf("--day-gap 需要大于 0 的整数(天数),收到 %q", v)
|
||||
}
|
||||
opts.DayGap = n
|
||||
case "--per-day":
|
||||
v, err := takeValue()
|
||||
if err != nil {
|
||||
if opts.EndDate, err = parseDate(v); err != nil {
|
||||
return opts, false, err
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 1 {
|
||||
return opts, false, fmt.Errorf("--per-day 需要大于 0 的整数(每天提交数),收到 %q", v)
|
||||
}
|
||||
opts.PerDay = n
|
||||
default:
|
||||
return opts, false, fmt.Errorf("未知选项 %q", arg)
|
||||
}
|
||||
@@ -437,16 +422,16 @@ func parseGitRetimeArgs(args []string, now time.Time) (gitRetimeOptions, bool, e
|
||||
if opts.MaxGap <= opts.MinGap {
|
||||
return opts, false, fmt.Errorf("间隔上限必须大于下限")
|
||||
}
|
||||
// --day-gap / --per-day only make sense together with --start-date.
|
||||
// --end-date only makes sense together with --start-date, and must not
|
||||
// end before it starts.
|
||||
if opts.StartDate.IsZero() {
|
||||
if opts.DayGap > 0 || opts.PerDay > 0 {
|
||||
return opts, false, fmt.Errorf("--day-gap 与 --per-day 需要配合 --start-date 使用" +
|
||||
if !opts.EndDate.IsZero() {
|
||||
return opts, false, fmt.Errorf("--end-date 需要配合 --start-date 使用" +
|
||||
"(不给 --start-date 时保留每个提交的原日期)")
|
||||
}
|
||||
opts.DayGap = 1
|
||||
} else if opts.DayGap < 1 {
|
||||
// Re-dating defaults to consecutive days.
|
||||
opts.DayGap = 1
|
||||
} else if !opts.EndDate.IsZero() && opts.EndDate.Before(opts.StartDate) {
|
||||
return opts, false, fmt.Errorf("--end-date %s 早于 --start-date %s",
|
||||
opts.EndDate.Format("2006-01-02"), opts.StartDate.Format("2006-01-02"))
|
||||
}
|
||||
return opts, confirmed, nil
|
||||
}
|
||||
|
||||
@@ -428,14 +428,16 @@ func TestParseGitRetimeArgsRedate(t *testing.T) {
|
||||
opts, _, err := parseGitRetimeArgs([]string{
|
||||
"2026-10-01", "2026-10-15",
|
||||
"--start-date", "2026-11-01",
|
||||
"--per-day", "2",
|
||||
"--day-gap", "3",
|
||||
"--end-date", "2026-11-15",
|
||||
}, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if opts.StartDate.Format("2006-01-02") != "2026-11-01" || opts.PerDay != 2 || opts.DayGap != 3 {
|
||||
t.Fatalf("opts = %+v", opts)
|
||||
if opts.StartDate.Format("2006-01-02") != "2026-11-01" {
|
||||
t.Errorf("StartDate = %v", opts.StartDate)
|
||||
}
|
||||
if opts.EndDate.Format("2006-01-02") != "2026-11-15" {
|
||||
t.Errorf("EndDate = %v", opts.EndDate)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,11 +446,10 @@ func TestParseGitRetimeArgsRedateValidation(t *testing.T) {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{[]string{"2026-10-01", "2026-10-02", "--per-day", "2"}, "需要配合 --start-date"},
|
||||
{[]string{"2026-10-01", "2026-10-02", "--day-gap", "2"}, "需要配合 --start-date"},
|
||||
{[]string{"2026-10-01", "2026-10-02", "--end-date", "2026-11-15"}, "需要配合 --start-date"},
|
||||
{[]string{"2026-10-01", "2026-10-02", "--start-date", "2026/11/01"}, "YYYY-MM-DD"},
|
||||
{[]string{"2026-10-01", "2026-10-02", "--start-date", "2026-11-01", "--per-day", "0"}, "大于 0"},
|
||||
{[]string{"2026-10-01", "2026-10-02", "--start-date", "2026-11-01", "--day-gap", "x"}, "大于 0"},
|
||||
{[]string{"2026-10-01", "2026-10-02", "--start-date", "2026/11/01", "--end-date", "2026-11-15"}, "YYYY-MM-DD"},
|
||||
{[]string{"2026-10-01", "2026-10-02", "--start-date", "2026-11-15", "--end-date", "2026-11-01"}, "早于 --start-date"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
_, _, err := parseGitRetimeArgs(tc.args, time.Now())
|
||||
@@ -458,12 +459,12 @@ func TestParseGitRetimeArgsRedateValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGitRetimeArgsDefaultDayGap(t *testing.T) {
|
||||
func TestParseGitRetimeArgsEndDateOptional(t *testing.T) {
|
||||
opts, _, err := parseGitRetimeArgs([]string{"2026-10-01", "2026-10-02", "--start-date", "2026-11-01"}, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if opts.DayGap != 1 {
|
||||
t.Errorf("DayGap = %d, want the default 1", opts.DayGap)
|
||||
if !opts.EndDate.IsZero() {
|
||||
t.Errorf("EndDate = %v, want the zero value (single start day)", opts.EndDate)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,76 +328,85 @@ func TestBuildPlanListsEachSqueezedDayOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanRetimeRedateStartingFromDay(t *testing.T) {
|
||||
// Three commits originally on 2026-10-01, re-dated from 2026-11-01 with one
|
||||
// commit per day.
|
||||
func TestPlanRetimeInRangeSpreadsEvenly(t *testing.T) {
|
||||
// 6 commits over 3 days: 2 commits per day, in order.
|
||||
date := day(2026, 10, 1)
|
||||
commits := []gitCommit{
|
||||
commitAt("c1", date, 9, 0, 0),
|
||||
commitAt("c2", date, 10, 0, 0),
|
||||
commitAt("c3", date, 11, 0, 0),
|
||||
var commits []gitCommit
|
||||
for i := 0; i < 6; i++ {
|
||||
commits = append(commits, commitAt("c", date, 9, i, 0))
|
||||
}
|
||||
opts := defaultRetimeOptions(3)
|
||||
opts.StartDate = day(2026, 11, 1)
|
||||
opts.PerDay = 1
|
||||
opts.DayGap = 1
|
||||
opts.EndDate = day(2026, 11, 3)
|
||||
|
||||
plan := planRetime(commits, opts)
|
||||
if len(plan) != 3 {
|
||||
t.Fatalf("plan has %d entries", len(plan))
|
||||
}
|
||||
for i, want := range []string{"2026-11-01", "2026-11-02", "2026-11-03"} {
|
||||
got := plan[i].NewTime.In(cst).Format("2006-01-02")
|
||||
if got != want {
|
||||
t.Errorf("commit %d re-dated to %s, want %s", i+1, got, want)
|
||||
wantDays := []string{"2026-11-01", "2026-11-01", "2026-11-02", "2026-11-02", "2026-11-03", "2026-11-03"}
|
||||
for i, want := range wantDays {
|
||||
if got := plan[i].NewTime.In(cst).Format("2006-01-02"); got != want {
|
||||
t.Errorf("commit %d on %s, want %s", i, got, want)
|
||||
}
|
||||
if h := plan[i].NewTime.In(cst).Hour(); h < 21 || h > 22 {
|
||||
t.Errorf("commit %d at hour %d, want 21-22", i+1, h)
|
||||
t.Errorf("commit %d at hour %d, want 21-22", i, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanRetimeRedateHonoursDayGap(t *testing.T) {
|
||||
func TestPlanRetimeInRangeDistributesRemainder(t *testing.T) {
|
||||
// 7 commits over 3 days: 3, 2, 2 from the earliest day.
|
||||
date := day(2026, 10, 1)
|
||||
var commits []gitCommit
|
||||
for i := 0; i < 7; i++ {
|
||||
commits = append(commits, commitAt("c", date, 9, i, 0))
|
||||
}
|
||||
opts := defaultRetimeOptions(4)
|
||||
opts.StartDate = day(2026, 11, 1)
|
||||
opts.EndDate = day(2026, 11, 3)
|
||||
|
||||
plan := planRetime(commits, opts)
|
||||
counts := map[string]int{}
|
||||
for _, r := range plan {
|
||||
counts[r.NewTime.In(cst).Format("2006-01-02")]++
|
||||
}
|
||||
if counts["2026-11-01"] != 3 || counts["2026-11-02"] != 2 || counts["2026-11-03"] != 2 {
|
||||
t.Fatalf("distribution = %v, want 3/2/2", counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanRetimeInRangeSpreadsFewCommitsAcrossTheRange(t *testing.T) {
|
||||
// 2 commits over 4 days must not bunch on day one: they land on the first
|
||||
// and the third day (floor(i*days/total)).
|
||||
date := day(2026, 10, 1)
|
||||
commits := []gitCommit{
|
||||
commitAt("c1", date, 9, 0, 0),
|
||||
commitAt("c2", date, 10, 0, 0),
|
||||
}
|
||||
opts := defaultRetimeOptions(4)
|
||||
opts := defaultRetimeOptions(5)
|
||||
opts.StartDate = day(2026, 11, 1)
|
||||
opts.PerDay = 1
|
||||
opts.DayGap = 3
|
||||
opts.EndDate = day(2026, 11, 4)
|
||||
|
||||
plan := planRetime(commits, opts)
|
||||
if got := plan[1].NewTime.In(cst).Format("2006-01-02"); got != "2026-11-04" {
|
||||
t.Fatalf("second commit on %s, want 2026-11-04 (gap 3 days)", got)
|
||||
want := []string{"2026-11-01", "2026-11-03"}
|
||||
for i, w := range want {
|
||||
if got := plan[i].NewTime.In(cst).Format("2006-01-02"); got != w {
|
||||
t.Errorf("commit %d on %s, want %s", i, got, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanRetimeRedatePacksSeveralPerDay(t *testing.T) {
|
||||
func TestPlanRetimeInRangeSingleDayWithoutEndDate(t *testing.T) {
|
||||
date := day(2026, 10, 1)
|
||||
var commits []gitCommit
|
||||
for i := 0; i < 4; i++ {
|
||||
commits = append(commits, commitAt("c", date, 9, i, 0))
|
||||
commits := []gitCommit{
|
||||
commitAt("c1", date, 9, 0, 0),
|
||||
commitAt("c2", date, 10, 0, 0),
|
||||
}
|
||||
opts := defaultRetimeOptions(5)
|
||||
opts.StartDate = day(2026, 11, 1)
|
||||
opts.PerDay = 2
|
||||
opts.DayGap = 1
|
||||
opts := defaultRetimeOptions(6)
|
||||
opts.StartDate = day(2026, 11, 7)
|
||||
|
||||
plan := planRetime(commits, opts)
|
||||
firstDay := plan[0].NewTime.In(cst).Format("2006-01-02")
|
||||
secondOfFirstDay := plan[1].NewTime.In(cst).Format("2006-01-02")
|
||||
thirdDay := plan[2].NewTime.In(cst).Format("2006-01-02")
|
||||
if firstDay != "2026-11-01" || secondOfFirstDay != "2026-11-01" {
|
||||
t.Fatalf("first two commits should share 2026-11-01, got %s and %s", firstDay, secondOfFirstDay)
|
||||
}
|
||||
if thirdDay != "2026-11-02" {
|
||||
t.Fatalf("third commit on %s, want 2026-11-02", thirdDay)
|
||||
}
|
||||
// Within a day the times stay ordered.
|
||||
if !plan[1].NewTime.After(plan[0].NewTime) {
|
||||
t.Fatalf("same-day commits are not ordered: %v, %v", plan[0].NewTime, plan[1].NewTime)
|
||||
for i, r := range plan {
|
||||
if got := r.NewTime.In(cst).Format("2006-01-02"); got != "2026-11-07" {
|
||||
t.Errorf("commit %d on %s, want the single start day", i, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user