feat: git retime 支持重排提交日期,新增 install.sh

- 新增 --start-date:按原顺序从指定日期起重排提交日期,默认每天一个
- 新增 --per-day 控制每天提交数、--day-gap 控制日期间隔;两者需配合
  --start-date 使用,否则报错提示
- 不给 --start-date 时保持原行为(保留原日期,只改时分秒)
- 预览在重排模式下显示完整的新日期时间,并说明日期规则
- install.sh:补 shebang 与可执行权限,构建后安装到 /usr/local/bin
- 文档:README.git.md 增加「重排日期」小节与选项表
This commit is contained in:
2026-11-01 10:05:19 +08:00
parent f3b90d205c
commit 1ae9488d8a
10 changed files with 291 additions and 18 deletions
+4 -4
View File
@@ -116,10 +116,10 @@ func (p Plan) Run(ctx context.Context, opts Options) error {
return fmt.Errorf("未注册的内置动作 %q", variant.Builtin)
}
return fn(ctx, BuiltinRequest{
Args: p.ExtraArgs,
Stdin: opts.Stdin,
Stdout: opts.Stdout,
Stderr: opts.Stderr,
Args: p.ExtraArgs,
Stdin: opts.Stdin,
Stdout: opts.Stdout,
Stderr: opts.Stderr,
Info: p.Result.Info,
Plan: p,
Confirmed: opts.Confirmed,
+41 -7
View File
@@ -32,6 +32,13 @@ type retimeOptions struct {
// 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
@@ -59,33 +66,60 @@ 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
currentDay := time.Time{}
var day time.Time
flush := func() {
if len(dayCommits) == 0 {
return
}
out = append(out, planDay(dayCommits, opts, window)...)
out = append(out, planDay(day, dayCommits, opts, window)...)
dayCommits = nil
}
for _, c := range commits {
day := c.Date()
if !currentDay.IsZero() && !day.Equal(currentDay) {
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()
}
currentDay = day
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(commits []gitCommit, opts retimeOptions, window time.Duration) []retime {
func planDay(day time.Time, 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)
+69 -3
View File
@@ -25,6 +25,12 @@ type gitRetimeOptions struct {
Branch string
NoPush bool
MatchEmote bool // --interval match-remote
// 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
}
// runGitRetime rewrites the timestamps of commits in [From, To] so they land
@@ -110,8 +116,18 @@ func writeRetimePreview(req BuiltinRequest, plan retimePlan, opts gitRetimeOptio
fmt.Fprintf(w, "改写: %d 个提交(分支 %s,远程 %s)\n", len(plan.Commits), plan.Branch, plan.Remote)
fmt.Fprintf(w, "强推: %s\n", pushSummary(plan, opts))
fmt.Fprintf(w, "目标: %s\n", target)
fmt.Fprintf(w, "窗口: %s-%s(保留原日期)\n\n",
clockOf(opts.Window), clockOf(opts.WindowEnd))
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)
}
}
fmt.Fprintf(w, "窗口: %s-%s(%s)\n\n",
clockOf(opts.Window), clockOf(opts.WindowEnd), dateRule)
rows := [][]string{{"提交", "原时间", "新时间", "主题"}}
for _, item := range plan.Commits {
@@ -122,13 +138,21 @@ func writeRetimePreview(req BuiltinRequest, plan retimePlan, opts gitRetimeOptio
rows = append(rows, []string{
item.Short,
item.OldAuthor.Format("2006-01-02 15:04:05"),
item.NewTime.Format("15:04:05") + mark,
newTimeCell(item, opts) + mark,
item.Subject,
})
}
writeDisplayColumns(w, rows)
}
// newTimeCell shows the new time, with its date when dates are reassigned.
func newTimeCell(item retime, opts gitRetimeOptions) string {
if opts.StartDate.IsZero() {
return item.NewTime.Format("15:04:05")
}
return item.NewTime.Format("2006-01-02 15:04:05")
}
// pushSummary states whether the branch will be force pushed.
func pushSummary(plan retimePlan, opts gitRetimeOptions) string {
if opts.NoPush {
@@ -193,6 +217,9 @@ func collectRetimePlan(ctx context.Context, runner gitRunner, opts gitRetimeOpti
MaxGap: opts.MaxGap,
FirstGapMax: 20 * time.Minute,
Rand: newRetimeRand(opts.Seed),
StartDate: opts.StartDate,
DayGap: opts.DayGap,
PerDay: opts.PerDay,
}
_ = opts.MatchEmote
return buildPlan(selected, plannerOpts, branch, remote), nil
@@ -353,6 +380,34 @@ func parseGitRetimeArgs(args []string, now time.Time) (gitRetimeOptions, bool, e
opts.Branch = v
case "--no-push":
opts.NoPush = true
case "--start-date":
v, err := takeValue()
if err != nil {
return opts, false, err
}
if opts.StartDate, err = parseDate(v); err != nil {
return opts, false, err
}
case "--day-gap":
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 {
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)
}
@@ -382,6 +437,17 @@ 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.
if opts.StartDate.IsZero() {
if opts.DayGap > 0 || opts.PerDay > 0 {
return opts, false, fmt.Errorf("--day-gap 与 --per-day 需要配合 --start-date 使用" +
"(不给 --start-date 时保留每个提交的原日期)")
}
opts.DayGap = 1
} else if opts.DayGap < 1 {
// Re-dating defaults to consecutive days.
opts.DayGap = 1
}
return opts, confirmed, nil
}
+44
View File
@@ -423,3 +423,47 @@ func fixtureResp(args ...string) gitResponse {
}
return r
}
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",
}, 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)
}
}
func TestParseGitRetimeArgsRedateValidation(t *testing.T) {
tests := []struct {
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", "--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"},
}
for _, tc := range tests {
_, _, err := parseGitRetimeArgs(tc.args, time.Now())
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("args %v: err = %v, want %q", tc.args, err, tc.want)
}
}
}
func TestParseGitRetimeArgsDefaultDayGap(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)
}
}
+84
View File
@@ -327,3 +327,87 @@ func TestBuildPlanListsEachSqueezedDayOnce(t *testing.T) {
t.Errorf("squeezed day = %q", got)
}
}
func TestPlanRetimeRedateStartingFromDay(t *testing.T) {
// Three commits originally on 2026-10-01, re-dated from 2026-11-01 with one
// commit per day.
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),
}
opts := defaultRetimeOptions(3)
opts.StartDate = day(2026, 11, 1)
opts.PerDay = 1
opts.DayGap = 1
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)
}
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)
}
}
}
func TestPlanRetimeRedateHonoursDayGap(t *testing.T) {
date := day(2026, 10, 1)
commits := []gitCommit{
commitAt("c1", date, 9, 0, 0),
commitAt("c2", date, 10, 0, 0),
}
opts := defaultRetimeOptions(4)
opts.StartDate = day(2026, 11, 1)
opts.PerDay = 1
opts.DayGap = 3
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)
}
}
func TestPlanRetimeRedatePacksSeveralPerDay(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))
}
opts := defaultRetimeOptions(5)
opts.StartDate = day(2026, 11, 1)
opts.PerDay = 2
opts.DayGap = 1
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)
}
}
func TestPlanRetimeWithoutStartDateKeepsOriginalDays(t *testing.T) {
commits := []gitCommit{
commitAt("c1", day(2026, 10, 1), 9, 0, 0),
commitAt("c2", day(2026, 10, 5), 9, 0, 0),
}
plan := planRetime(commits, defaultRetimeOptions(1))
if a, b := plan[0].NewTime.In(cst).Format("2006-01-02"), plan[1].NewTime.In(cst).Format("2006-01-02"); a != "2026-10-01" || b != "2026-10-05" {
t.Fatalf("original days changed: %s, %s", a, b)
}
}