diff --git a/internal/daemon/config.go b/internal/daemon/config.go index c7815c5..f0672b8 100644 --- a/internal/daemon/config.go +++ b/internal/daemon/config.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "time" "github.com/BurntSushi/toml" "github.com/kuchmenko/workspace/internal/auth" @@ -17,6 +18,36 @@ type WorkspaceEntry struct { // each tick. Pointer so we can distinguish "unset" (default true) from // an explicit false written to daemon.toml. AutoBootstrap *bool `toml:"auto_bootstrap,omitempty"` + // PushCooldown coalesces consecutive auto-sync commits of workspace.toml + // into a single squashed commit. Empty string → default 1h; literal "0" + // (or any zero-valued duration like "0s") disables coalescing — legacy + // behavior, push every commit immediately. Parsed via time.ParseDuration; + // unparseable values fall back to the default. + PushCooldown string `toml:"push_cooldown,omitempty"` +} + +// DefaultPushCooldown is the daemon's default coalescing window when the +// workspace entry does not override it. One hour keeps git history almost +// free of auto-sync noise on machines that edit workspace.toml frequently, +// while still bounding the worst-case "my change isn't on origin yet" gap. +const DefaultPushCooldown = time.Hour + +// ResolvedPushCooldown returns the duration parsed from PushCooldown, with +// DefaultPushCooldown as the fallback for empty/invalid values. The literal +// "0" is honored as "disable coalescing" — time.ParseDuration rejects a bare +// "0" because it has no unit, so this is the natural way to spell it. +func (w WorkspaceEntry) ResolvedPushCooldown() time.Duration { + if w.PushCooldown == "" { + return DefaultPushCooldown + } + if w.PushCooldown == "0" { + return 0 + } + d, err := time.ParseDuration(w.PushCooldown) + if err != nil { + return DefaultPushCooldown + } + return d } // AutoBootstrapEnabled reports whether auto-clone of missing projects is on. diff --git a/internal/daemon/config_test.go b/internal/daemon/config_test.go new file mode 100644 index 0000000..a20c107 --- /dev/null +++ b/internal/daemon/config_test.go @@ -0,0 +1,42 @@ +package daemon_test + +import ( + "testing" + "time" + + "github.com/kuchmenko/workspace/internal/daemon" +) + +func TestResolvedPushCooldown(t *testing.T) { + cases := []struct { + name string + in string + want time.Duration + }{ + // Empty → default. The common case: a workspace entry written before + // push_cooldown existed must get the new coalescing behavior for free. + {"unset", "", daemon.DefaultPushCooldown}, + + // Literal "0" disables coalescing. time.ParseDuration rejects a bare + // "0" (no unit) so this is the dedicated short-circuit — the + // regression that motivated this test. + {"bare zero", "0", 0}, + {"zero seconds", "0s", 0}, + + {"valid", "30m", 30 * time.Minute}, + {"valid hours", "2h", 2 * time.Hour}, + + // Garbage falls back to the default rather than silently dropping + // the daemon to no-coalesce. Surprises here would be hard to debug + // (the symptom is just "history is noisy again"). + {"garbage", "not-a-duration", daemon.DefaultPushCooldown}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := daemon.WorkspaceEntry{PushCooldown: tc.in}.ResolvedPushCooldown() + if got != tc.want { + t.Fatalf("ResolvedPushCooldown(%q) = %s, want %s", tc.in, got, tc.want) + } + }) + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 3bdd376..e89c735 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -181,6 +181,7 @@ func (d *Daemon) startWorkspace(ws WorkspaceEntry) { r := NewReconciler(ws.Root, interval, d.logger) r.SetAutoBootstrap(ws.AutoBootstrapEnabled()) + r.SetPushCooldown(ws.ResolvedPushCooldown()) d.reconcilers[ws.Root] = r d.wg.Add(1) diff --git a/internal/daemon/reconciler.go b/internal/daemon/reconciler.go index 0af0b8a..d35180b 100644 --- a/internal/daemon/reconciler.go +++ b/internal/daemon/reconciler.go @@ -39,6 +39,14 @@ type Reconciler struct { // autoBootstrap controls whether the daemon clones missing projects on // each tick. Default true; set false via daemon.toml to disable. autoBootstrap bool + + // pushCooldown coalesces consecutive auto-sync commits of workspace.toml + // into a single squashed commit. While the most recent local commit is + // our own auto-sync and younger than this duration, syncTOML amends + // further dirty changes into it and defers the push. Zero disables + // coalescing (push immediately after each commit) — that's the safe + // default for `ws sync`, while the daemon opts in via SetPushCooldown. + pushCooldown time.Duration } type backoffState struct { @@ -74,6 +82,16 @@ func (r *Reconciler) SetAutoBootstrap(v bool) { r.autoBootstrap = v } +// SetPushCooldown configures how long a local auto-sync commit may be amended +// before it must be pushed. Zero disables amend+defer (push every commit +// immediately). Negative values are clamped to zero. +func (r *Reconciler) SetPushCooldown(d time.Duration) { + if d < 0 { + d = 0 + } + r.pushCooldown = d +} + // Run starts the reconciler loop. It performs an immediate tick at startup // (closing the "I just got back to my machine" gap) and then ticks on the // configured interval until quit is closed. diff --git a/internal/daemon/toml.go b/internal/daemon/toml.go index ccbb58a..887987a 100644 --- a/internal/daemon/toml.go +++ b/internal/daemon/toml.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "path/filepath" + "time" "github.com/kuchmenko/workspace/internal/conflict" "github.com/kuchmenko/workspace/internal/git" @@ -63,17 +64,37 @@ func (r *Reconciler) syncTOML() (bool, error) { } // Commit dirty changes first so the rest of the matrix only deals with - // committed state. + // committed state. When HEAD is already an unpushed auto-sync commit from + // this host, amend into it instead of stacking another one — see the + // pushCooldown design note in reconciler.go. + autoSyncMsg := fmt.Sprintf("ws: auto-sync workspace.toml from %s", machineHostname()) if localDirty { if err := git.Add(repoRoot, relFile); err != nil { return false, fmt.Errorf("git add: %w", err) } - host := machineHostname() - msg := fmt.Sprintf("ws: auto-sync workspace.toml from %s", host) - if err := git.Commit(repoRoot, msg); err != nil { - return false, fmt.Errorf("git commit: %w", err) + headMsg, _ := git.LastCommitMessage(repoRoot) + if ahead > 0 && headMsg == autoSyncMsg { + // If the staged tree now matches HEAD's parent, the held commit's + // net change has been undone (e.g. a favorite toggled on then off + // inside the cooldown). git refuses an amend that produces an + // empty diff vs parent; without this branch we'd return an error + // every subsequent tick and leave workspace.toml staged forever. + // Drop the held commit instead — the right history outcome is + // "no commit at all". + if err := runIn(repoRoot, "git", "diff", "--cached", "--quiet", "HEAD~1"); err == nil { + if err := runIn(repoRoot, "git", "reset", "--mixed", "HEAD~1"); err != nil { + return false, fmt.Errorf("drop empty held auto-sync: %w", err) + } + ahead-- + } else if err := runIn(repoRoot, "git", "commit", "--amend", "--no-edit"); err != nil { + return false, fmt.Errorf("git commit --amend: %w", err) + } + } else { + if err := git.Commit(repoRoot, autoSyncMsg); err != nil { + return false, fmt.Errorf("git commit: %w", err) + } + ahead++ } - ahead++ } // Re-evaluate behind in case fetch happened pre-commit. @@ -88,9 +109,14 @@ func (r *Reconciler) syncTOML() (bool, error) { _ = r.clearTOMLConflicts() } - // Push if anything to push. + // Push if anything to push — unless the pushCooldown gate is holding our + // auto-sync commit open for further amending. The held commit will be + // pushed on a later tick once its age exceeds the cooldown, or sooner if + // a non-auto-sync commit lands on top of it. if ahead > 0 || behind > 0 { - if err := git.Push(repoRoot); err != nil { + if r.shouldHoldPush(repoRoot, autoSyncMsg, ahead) { + r.logger.Printf("reconciler: %s holding auto-sync commit for amend (cooldown %s)", repoRoot, r.pushCooldown) + } else if err := git.Push(repoRoot); err != nil { // One retry: fetch + rebase + push, mirror of the legacy syncer. if perr := runIn(repoRoot, "git", "pull", "--rebase"); perr != nil { r.recordTOMLConflict(repoRoot, conflict.KindTOMLMerge, perr) @@ -107,6 +133,38 @@ func (r *Reconciler) syncTOML() (bool, error) { return newHead != originalHead, nil } +// shouldHoldPush reports whether HEAD is our own auto-sync commit that is +// still young enough to absorb further amends. Zero pushCooldown disables +// the gate entirely (the historical behavior, kept for `ws sync`). +// +// The age check uses the author date — preserved by `git commit --amend +// --no-edit` — so continuous activity that keeps amending into the held +// commit cannot indefinitely defer the push. The committer date would +// refresh on every amend and silently turn the cooldown into "never push +// while busy", which is the failure mode this gate exists to prevent. +// +// The ahead==1 guard prevents the gate from withholding a user's manual +// commit that sits below the auto-sync: in that case `git push` would +// publish *both* commits, and the cooldown is only entitled to defer the +// auto-sync one. When ahead > 1 we always push. +func (r *Reconciler) shouldHoldPush(repoRoot, autoSyncMsg string, ahead int) bool { + if r.pushCooldown <= 0 { + return false + } + if ahead != 1 { + return false + } + headMsg, _ := git.LastCommitMessage(repoRoot) + if headMsg != autoSyncMsg { + return false + } + t, err := git.LastCommitAuthorTime(repoRoot) + if err != nil { + return false + } + return time.Since(t) < r.pushCooldown +} + func (r *Reconciler) recordTOMLConflict(workspace string, kind conflict.Kind, cause error) { if r.store == nil { return diff --git a/internal/daemon/toml_test.go b/internal/daemon/toml_test.go new file mode 100644 index 0000000..3267729 --- /dev/null +++ b/internal/daemon/toml_test.go @@ -0,0 +1,304 @@ +package daemon + +import ( + "io" + "log" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/kuchmenko/workspace/internal/testutil" +) + +// TestSyncTOMLAmendCooldownSquashesAutoSyncCommits is the regression test for +// the issue that motivated the cooldown gate: the daemon used to stack one +// "ws: auto-sync workspace.toml from " commit per tick, flooding the +// dotfiles history. With a positive pushCooldown, successive auto-sync +// commits should amend into a single local commit and only push once the +// cooldown elapses (here, simulated by flipping cooldown to 0). +func TestSyncTOMLAmendCooldownSquashesAutoSyncCommits(t *testing.T) { + wsRoot, bareDir := setupSyncTOMLRepo(t) + + r := NewReconciler(wsRoot, 5*time.Minute, log.New(io.Discard, "", 0)) + r.SetPushCooldown(time.Hour) + + remoteHead := testutil.RunGit(t, bareDir, "rev-parse", "refs/heads/main") + + // Edit 1 → commit, hold (do not push). + appendFile(t, filepath.Join(wsRoot, "workspace.toml"), "# edit 1\n") + if _, err := r.syncTOML(); err != nil { + t.Fatalf("syncTOML edit 1: %v", err) + } + if got := testutil.RunGit(t, bareDir, "rev-parse", "refs/heads/main"); got != remoteHead { + t.Fatalf("remote moved after first auto-sync; cooldown should have held the push") + } + if msg := testutil.RunGit(t, wsRoot, "log", "-1", "--format=%s"); !strings.HasPrefix(msg, "ws: auto-sync workspace.toml from ") { + t.Fatalf("HEAD message %q is not an auto-sync commit", msg) + } + if a := countAhead(t, wsRoot); a != 1 { + t.Fatalf("expected ahead=1 after first edit, got %d", a) + } + firstHead := testutil.RunGit(t, wsRoot, "rev-parse", "HEAD") + + // Edit 2 → amend onto the held commit (HEAD sha changes, ahead stays 1). + appendFile(t, filepath.Join(wsRoot, "workspace.toml"), "# edit 2\n") + if _, err := r.syncTOML(); err != nil { + t.Fatalf("syncTOML edit 2: %v", err) + } + if got := testutil.RunGit(t, bareDir, "rev-parse", "refs/heads/main"); got != remoteHead { + t.Fatalf("remote moved after amended auto-sync; cooldown should still hold") + } + if a := countAhead(t, wsRoot); a != 1 { + t.Fatalf("expected ahead=1 after amend, got %d", a) + } + secondHead := testutil.RunGit(t, wsRoot, "rev-parse", "HEAD") + if secondHead == firstHead { + t.Fatalf("HEAD should change on amend (new tree); stayed at %s", firstHead) + } + content, err := os.ReadFile(filepath.Join(wsRoot, "workspace.toml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "# edit 1") || !strings.Contains(string(content), "# edit 2") { + t.Fatalf("amended commit lost an edit; tree contents:\n%s", content) + } + + // Cooldown elapsed (simulated via SetPushCooldown(0)) → next tick must + // push the held commit even though the working tree is clean. + r.SetPushCooldown(0) + if _, err := r.syncTOML(); err != nil { + t.Fatalf("syncTOML post-cooldown push: %v", err) + } + if got := testutil.RunGit(t, bareDir, "rev-parse", "refs/heads/main"); got != secondHead { + t.Fatalf("expected remote at %s, got %s", secondHead, got) + } + if a := countAhead(t, wsRoot); a != 0 { + t.Fatalf("expected ahead=0 after push, got %d", a) + } +} + +// TestSyncTOMLZeroCooldownPushesEveryCommit pins the legacy behavior for +// `ws sync` (cooldown 0): every dirty edit produces its own commit and is +// pushed immediately, no amend. +func TestSyncTOMLZeroCooldownPushesEveryCommit(t *testing.T) { + wsRoot, bareDir := setupSyncTOMLRepo(t) + + r := NewReconciler(wsRoot, 5*time.Minute, log.New(io.Discard, "", 0)) + // Default pushCooldown is 0; assert that explicitly so the test does not + // silently depend on NewReconciler's choice. + r.SetPushCooldown(0) + + appendFile(t, filepath.Join(wsRoot, "workspace.toml"), "# edit A\n") + if _, err := r.syncTOML(); err != nil { + t.Fatalf("syncTOML A: %v", err) + } + headA := testutil.RunGit(t, wsRoot, "rev-parse", "HEAD") + if got := testutil.RunGit(t, bareDir, "rev-parse", "refs/heads/main"); got != headA { + t.Fatalf("expected immediate push to %s, remote at %s", headA, got) + } + + appendFile(t, filepath.Join(wsRoot, "workspace.toml"), "# edit B\n") + if _, err := r.syncTOML(); err != nil { + t.Fatalf("syncTOML B: %v", err) + } + headB := testutil.RunGit(t, wsRoot, "rev-parse", "HEAD") + if headB == headA { + t.Fatalf("expected a fresh commit (not amend) with cooldown=0; HEAD unchanged at %s", headA) + } + if got := testutil.RunGit(t, bareDir, "rev-parse", "refs/heads/main"); got != headB { + t.Fatalf("expected immediate push to %s, remote at %s", headB, got) + } +} + +// TestSyncTOMLAmendPreservesAuthorTimeAndReleasesGate is the regression test +// for the bug where the cooldown gate used the committer date. `git commit +// --amend --no-edit` refreshes %cI on every amend, so under continuous +// activity the gate would never elapse and the held auto-sync commit would +// never be pushed. shouldHoldPush now anchors on the author date (%aI), which +// is preserved across amends — so once enough wall time has passed since the +// first hold, the next dirty tick must push, not amend-and-hold again. +func TestSyncTOMLAmendPreservesAuthorTimeAndReleasesGate(t *testing.T) { + wsRoot, bareDir := setupSyncTOMLRepo(t) + + r := NewReconciler(wsRoot, 5*time.Minute, log.New(io.Discard, "", 0)) + // Cooldown chosen well above git's 1s author-date resolution so the + // "held" assertion is not racing the truncation of %aI to whole + // seconds. Sleep below uses cooldown + a buffer for the same reason. + const cooldown = 2 * time.Second + r.SetPushCooldown(cooldown) + + remoteHead := testutil.RunGit(t, bareDir, "rev-parse", "refs/heads/main") + + // First tick: dirty edit creates the held auto-sync commit (cooldown + // has not elapsed yet, so push must be held). + appendFile(t, filepath.Join(wsRoot, "workspace.toml"), "# edit 1\n") + if _, err := r.syncTOML(); err != nil { + t.Fatalf("syncTOML 1: %v", err) + } + if got := testutil.RunGit(t, bareDir, "rev-parse", "refs/heads/main"); got != remoteHead { + t.Fatalf("first edit pushed too early; cooldown should have held") + } + authorTime1 := testutil.RunGit(t, wsRoot, "log", "-1", "--format=%aI") + + // Wait past the cooldown, then make another dirty edit. The amend must + // preserve the author time so time.Since(author) > cooldown is true and + // shouldHoldPush releases the gate. + time.Sleep(cooldown + time.Second) + appendFile(t, filepath.Join(wsRoot, "workspace.toml"), "# edit 2\n") + if _, err := r.syncTOML(); err != nil { + t.Fatalf("syncTOML 2: %v", err) + } + + authorTime2 := testutil.RunGit(t, wsRoot, "log", "-1", "--format=%aI") + if authorTime2 != authorTime1 { + t.Fatalf("amend changed author time (%q → %q); cooldown anchor would drift", authorTime1, authorTime2) + } + headAfter := testutil.RunGit(t, wsRoot, "rev-parse", "HEAD") + if got := testutil.RunGit(t, bareDir, "rev-parse", "refs/heads/main"); got != headAfter { + t.Fatalf("expected remote at %s after cooldown elapsed, got %s", headAfter, got) + } +} + +// TestSyncTOMLAmendRevertedEditDropsHeldCommit covers the toggle-and-untoggle +// case: an auto-sync commit is held under the cooldown, then a follow-up +// edit puts workspace.toml back to its pre-commit contents. git refuses an +// amend whose tree equals its parent's, so the reconciler must drop the +// held commit entirely instead of erroring forever and leaving the file +// permanently staged. +func TestSyncTOMLAmendRevertedEditDropsHeldCommit(t *testing.T) { + wsRoot, bareDir := setupSyncTOMLRepo(t) + + r := NewReconciler(wsRoot, 5*time.Minute, log.New(io.Discard, "", 0)) + r.SetPushCooldown(time.Hour) + + parentHead := testutil.RunGit(t, wsRoot, "rev-parse", "HEAD") + remoteHead := testutil.RunGit(t, bareDir, "rev-parse", "refs/heads/main") + tomlPath := filepath.Join(wsRoot, "workspace.toml") + original, err := os.ReadFile(tomlPath) + if err != nil { + t.Fatal(err) + } + + // Held auto-sync commit lands under the cooldown. + if err := os.WriteFile(tomlPath, []byte(string(original)+"# toggled on\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := r.syncTOML(); err != nil { + t.Fatalf("syncTOML hold: %v", err) + } + if a := countAhead(t, wsRoot); a != 1 { + t.Fatalf("expected ahead=1 after first tick, got %d", a) + } + + // Undo the edit before the cooldown elapses. + if err := os.WriteFile(tomlPath, original, 0o644); err != nil { + t.Fatal(err) + } + if _, err := r.syncTOML(); err != nil { + t.Fatalf("syncTOML revert: %v", err) + } + + if got := testutil.RunGit(t, wsRoot, "rev-parse", "HEAD"); got != parentHead { + t.Fatalf("expected HEAD to roll back to %s, got %s", parentHead, got) + } + if a := countAhead(t, wsRoot); a != 0 { + t.Fatalf("expected ahead=0 after revert, got %d", a) + } + if got := testutil.RunGit(t, wsRoot, "status", "--porcelain", "workspace.toml"); got != "" { + t.Fatalf("workspace.toml should be clean after revert, got %q", got) + } + if got := testutil.RunGit(t, bareDir, "rev-parse", "refs/heads/main"); got != remoteHead { + t.Fatalf("remote should not move when the held commit is dropped; got %s, want %s", got, remoteHead) + } +} + +// TestSyncTOMLDoesNotHoldPushWhenManualCommitIsAhead protects the user's +// manual (non-auto-sync) commits from getting trapped behind the cooldown. +// When ahead > 1 — i.e. a manual commit sits below a held auto-sync — +// `git push` would publish both, so the gate must release. Otherwise the +// daemon would happily withhold the user's work for an hour. +func TestSyncTOMLDoesNotHoldPushWhenManualCommitIsAhead(t *testing.T) { + wsRoot, bareDir := setupSyncTOMLRepo(t) + + r := NewReconciler(wsRoot, 5*time.Minute, log.New(io.Discard, "", 0)) + r.SetPushCooldown(time.Hour) + + // User commits a manual workspace.toml edit but has not pushed yet. + tomlPath := filepath.Join(wsRoot, "workspace.toml") + appendFile(t, tomlPath, "# manual edit by user\n") + testutil.RunGit(t, wsRoot, "add", "workspace.toml") + testutil.RunGit(t, wsRoot, "commit", "-m", "manual: tweak workspace.toml") + if a := countAhead(t, wsRoot); a != 1 { + t.Fatalf("setup: expected ahead=1 after manual commit, got %d", a) + } + + // Daemon tick fires with a fresh dirty edit — would stack an auto-sync + // commit on top, leaving ahead=2. + appendFile(t, tomlPath, "# auto edit\n") + if _, err := r.syncTOML(); err != nil { + t.Fatalf("syncTOML: %v", err) + } + + headAfter := testutil.RunGit(t, wsRoot, "rev-parse", "HEAD") + if got := testutil.RunGit(t, bareDir, "rev-parse", "refs/heads/main"); got != headAfter { + t.Fatalf("manual commit was withheld behind cooldown; remote at %s, HEAD %s", got, headAfter) + } + if a := countAhead(t, wsRoot); a != 0 { + t.Fatalf("expected ahead=0 after push, got %d", a) + } +} + +// setupSyncTOMLRepo builds a workspace clone wired to a bare upstream with a +// seeded workspace.toml committed on main. Returns (wsRoot, bareDir). +func setupSyncTOMLRepo(t *testing.T) (string, string) { + t.Helper() + bareDir := testutil.InitFakeRemote(t, "ws-toml", "main") + + tmp := t.TempDir() + wsRoot := filepath.Join(tmp, "ws") + testutil.RunGit(t, tmp, "clone", bareDir, "ws") + + // Pin local identity / disable signing so the reconciler's plain + // exec.Command("git", ...) calls do not depend on the developer's + // global git config. + testutil.RunGit(t, wsRoot, "config", "user.name", "ws-test") + testutil.RunGit(t, wsRoot, "config", "user.email", "test@example.invalid") + testutil.RunGit(t, wsRoot, "config", "commit.gpgsign", "false") + testutil.RunGit(t, wsRoot, "config", "tag.gpgsign", "false") + + // Seed workspace.toml so syncTOML has a tracked file to watch. + tomlPath := filepath.Join(wsRoot, "workspace.toml") + if err := os.WriteFile(tomlPath, []byte("# seeded workspace.toml\n"), 0o644); err != nil { + t.Fatal(err) + } + testutil.RunGit(t, wsRoot, "add", "workspace.toml") + testutil.RunGit(t, wsRoot, "commit", "-m", "seed workspace.toml") + testutil.RunGit(t, wsRoot, "push", "-u", "origin", "main") + + return wsRoot, bareDir +} + +func appendFile(t *testing.T, path, s string) { + t.Helper() + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if _, err := f.WriteString(s); err != nil { + t.Fatal(err) + } +} + +func countAhead(t *testing.T, repo string) int { + t.Helper() + out := testutil.RunGit(t, repo, "rev-list", "--count", "@{u}..HEAD") + n, err := strconv.Atoi(out) + if err != nil { + t.Fatalf("parse ahead count %q: %v", out, err) + } + return n +} diff --git a/internal/git/git.go b/internal/git/git.go index 8ec70bd..d567472 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -77,6 +77,19 @@ func LastCommitTime(repoPath string) (time.Time, error) { return time.Parse(time.RFC3339, strings.TrimSpace(string(out))) } +// LastCommitAuthorTime returns the author date of HEAD. Unlike LastCommitTime +// (committer date), this is preserved across `git commit --amend --no-edit`, +// which makes it the right anchor for cooldowns that must bound the maximum +// time a coalesced commit can sit unpushed while activity keeps refreshing it. +func LastCommitAuthorTime(repoPath string) (time.Time, error) { + cmd := exec.Command("git", "-C", repoPath, "log", "-1", "--format=%aI") + out, err := cmd.Output() + if err != nil { + return time.Time{}, err + } + return time.Parse(time.RFC3339, strings.TrimSpace(string(out))) +} + func LastCommitMessage(repoPath string) (string, error) { cmd := exec.Command("git", "-C", repoPath, "log", "-1", "--format=%s") out, err := cmd.Output()