diff --git a/README.md b/README.md index 5c20853..763d872 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,17 @@ debounce = "30s" # Prefix emoji on generated commit messages. Default: 🐌 emoji = "🐌" +# Skip the auto-commit when a manual commit appears to be in progress. When +# composing a commit message by hand (e.g. in vim), git writes .git/COMMIT_EDITMSG +# and leaves it around — and holds no lock while your editor is open — so git-tend +# can't detect the editor directly. Instead it backs off committing for this +# window of time after COMMIT_EDITMSG was last touched. +# +# Trade-off: because the file lingers after a finished commit too, enabling this +# also leaves a quiet window after any manual commit before auto-commit resumes. +# Leave unset (default) to keep the previous always-commit behavior. +in_progress_window = "5m" + # Commit strategy. Currently only the default (heuristic) strategy is used. strategy = "" diff --git a/internal/config/config.go b/internal/config/config.go index f561529..1ddf060 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,12 +23,13 @@ type Config struct { } type CommitConfig struct { - Strategy string `toml:"strategy"` - Emoji string `toml:"emoji"` - ModelCmd string `toml:"model_cmd"` - ModelTimeout string `toml:"model_timeout"` - FallbackThresh int `toml:"model_fallback_threshold"` - NoVerify bool `toml:"no_verify"` + Strategy string `toml:"strategy"` + Emoji string `toml:"emoji"` + ModelCmd string `toml:"model_cmd"` + ModelTimeout string `toml:"model_timeout"` + FallbackThresh int `toml:"model_fallback_threshold"` + NoVerify bool `toml:"no_verify"` + InProgressWindow string `toml:"in_progress_window"` } type IncludeConfig struct { @@ -244,6 +245,12 @@ func Parse(path string) (*Config, error) { } } + if cfg.Commit.InProgressWindow != "" { + if _, err := time.ParseDuration(cfg.Commit.InProgressWindow); err != nil { + return nil, fmt.Errorf("invalid commit in_progress_window %q: %w", cfg.Commit.InProgressWindow, err) + } + } + if cfg.Commit.Emoji == "" { cfg.Commit.Emoji = "🐌" } diff --git a/internal/git/git.go b/internal/git/git.go index 339b32d..5f0fa35 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -4,7 +4,10 @@ import ( "bytes" "context" "errors" + "io/fs" + "os" "os/exec" + "path/filepath" "strings" "time" ) @@ -92,6 +95,77 @@ func DiffCached(repoPath string) (string, error) { return string(out), err } +func GitDir(repoPath string) (string, error) { + cmd := exec.Command("git", "-C", repoPath, "rev-parse", "--absolute-git-dir") + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// ActiveLocks reports whether a *.lock file exists anywhere under the repo's +// .git directory. Git creates these while a command is mid-write (e.g. +// index.lock during `git add`/`git commit` finalization), so their presence +// means the repo is being modified by git right now. +func ActiveLocks(repoPath string) (bool, error) { + gitDir, err := GitDir(repoPath) + if err != nil { + return false, err + } + found := false + err = filepath.WalkDir(gitDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if strings.HasSuffix(d.Name(), ".lock") { + found = true + return fs.SkipAll + } + return nil + }) + if err != nil { + return false, err + } + return found, nil +} + +// CommitEditRecent reports whether .git/COMMIT_EDITMSG — the file git holds +// while a commit message is being composed — has been touched within window. +// It is the only signal available during the editor-open phase (git holds no +// lock while waiting for an editor to close). +func CommitEditRecent(repoPath string, window time.Duration) (bool, error) { + gitDir, err := GitDir(repoPath) + if err != nil { + return false, err + } + fi, err := os.Stat(filepath.Join(gitDir, "COMMIT_EDITMSG")) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + return time.Since(fi.ModTime()) <= window, nil +} + +// ClearCommitEditMsg removes the leftover .git/COMMIT_EDITMSG after git-tend's +// own auto-commit, so the file reflects only human activity and does not cause +// git-tend to back off against its own commits. +func ClearCommitEditMsg(repoPath string) error { + gitDir, err := GitDir(repoPath) + if err != nil { + return err + } + if err := os.Remove(filepath.Join(gitDir, "COMMIT_EDITMSG")); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + func ListTrackedFiles(repoPath string) ([]string, error) { cmd := exec.Command("git", "-C", repoPath, "ls-files", "-z") out, err := cmd.Output() diff --git a/internal/git/git_test.go b/internal/git/git_test.go index a2e920a..3f4115f 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -3,9 +3,75 @@ package git import ( "context" "errors" + "os" + "os/exec" + "path/filepath" "testing" + "time" ) +func TestCommitEditRecent(t *testing.T) { + repo := t.TempDir() + run(t, repo, "init", "-q") + gitDir, err := GitDir(repo) + if err != nil { + t.Fatal(err) + } + msgPath := filepath.Join(gitDir, "COMMIT_EDITMSG") + + // No file yet: never recent. + if recent, err := CommitEditRecent(repo, time.Hour); err != nil || recent { + t.Fatalf("absent file should not be recent (recent=%v err=%v)", recent, err) + } + + // Written just now within a 1s window. + if err := os.WriteFile(msgPath, []byte("draft"), 0644); err != nil { + t.Fatal(err) + } + if recent, err := CommitEditRecent(repo, time.Hour); err != nil || !recent { + t.Fatalf("fresh file should be recent (recent=%v err=%v)", recent, err) + } + + // Stale beyond the window. + past := time.Now().Add(-10 * time.Minute) + if err := os.Chtimes(msgPath, past, past); err != nil { + t.Fatal(err) + } + if recent, err := CommitEditRecent(repo, time.Minute); err != nil || recent { + t.Fatalf("stale file should not be recent (recent=%v err=%v)", recent, err) + } +} + +func TestActiveLocks(t *testing.T) { + repo := t.TempDir() + run(t, repo, "init", "-q") + gitDir, err := GitDir(repo) + if err != nil { + t.Fatal(err) + } + + if locks, err := ActiveLocks(repo); err != nil || locks { + t.Fatalf("clean repo should have no locks (locks=%v err=%v)", locks, err) + } + + if err := os.WriteFile(filepath.Join(gitDir, "index.lock"), nil, 0644); err != nil { + t.Fatal(err) + } + if locks, err := ActiveLocks(repo); err != nil || !locks { + t.Fatalf("index.lock should be detected (locks=%v err=%v)", locks, err) + } +} + +func run(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + return string(out) +} + func TestIsNetworkError(t *testing.T) { tests := []struct { name string diff --git a/internal/sync/sync.go b/internal/sync/sync.go index 63a2b53..be23e8b 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -121,6 +121,25 @@ func syncReadOnly(ctx context.Context, repoPath string, timeout time.Duration) S func syncReadWrite(ctx context.Context, repoPath string, cfg *config.Config, timeout time.Duration, bypassDebounce bool) SyncResult { debounceDur := parseDebounce(cfg.Debounce) + // Back off if git appears busy, so we don't tend the repo out from under a + // concurrent manual operation. Locks appear while a git command is + // mid-write; a recently-written COMMIT_EDITMSG means the user is likely + // composing a commit message in an editor right now (git holds no lock + // during that phase). + if locks, err := git.ActiveLocks(repoPath); err != nil { + return SyncResult{State: "stuck", Error: fmt.Sprintf("checking for active git locks: %v", err)} + } else if locks { + return SyncResult{State: "skipped", Error: "git lock present (concurrent operation)"} + } + if cfg.Commit.InProgressWindow != "" { + window := parseDebounce(cfg.Commit.InProgressWindow) + if recent, err := git.CommitEditRecent(repoPath, window); err != nil { + return SyncResult{State: "stuck", Error: fmt.Sprintf("checking commit in progress: %v", err)} + } else if recent { + return SyncResult{State: "skipped", Error: "commit in progress"} + } + } + files, err := git.ListTrackedFiles(repoPath) if err != nil { return SyncResult{State: "stuck", Error: fmt.Sprintf("listing tracked files: %v", err)} @@ -190,6 +209,10 @@ func syncReadWrite(ctx context.Context, repoPath string, cfg *config.Config, tim writeStuck(repoPath, "hook_failed", "git commit", exitCodeFromErr(err), err.Error()) return SyncResult{State: "stuck", Error: fmt.Sprintf("commit: %v", err)} } + // The sentinel git writes for manual commits is only meaningful for + // human activity; clear it after our own commit so we don't back off + // against ourselves on the next tick. + _ = git.ClearCommitEditMsg(repoPath) } stdout, stderr, err, isNet := git.PullRebase(ctx, repoPath, timeout) diff --git a/internal/sync/sync_test.go b/internal/sync/sync_test.go index 0f19383..9494395 100644 --- a/internal/sync/sync_test.go +++ b/internal/sync/sync_test.go @@ -458,6 +458,61 @@ func TestSyncWithDebounce(t *testing.T) { } } +func TestSyncSkipCommitInProgress(t *testing.T) { + remote := gitInitBare(t) + defer os.RemoveAll(remote) + repo := gitClone(t, remote) + defer os.RemoveAll(repo) + + writeTestConfig(t, repo, "read-write", "main", "0s", nil, nil) + testGitOK(t, repo, "add", ".gittend") + testGitOK(t, repo, "commit", "-m", "initial config") + testGitOK(t, repo, "push", "origin", "main") + + // Dirty a file so there is something to commit. + if err := os.WriteFile(filepath.Join(repo, "work.txt"), []byte("changes"), 0644); err != nil { + t.Fatal(err) + } + + // Simulate an in-progress manual commit: git-tend's own setup commits + // always clear the sentinel, so write COMMIT_EDITMSG directly and leave it + // fresh. + gitDir := testGit(t, repo, "rev-parse", "--absolute-git-dir") + gitDir = strings.TrimSpace(gitDir) + if err := os.WriteFile(filepath.Join(gitDir, "COMMIT_EDITMSG"), []byte("draft message"), 0644); err != nil { + t.Fatal(err) + } + + stateDir, err := os.MkdirTemp("", "gittend-state-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(stateDir) + + cfg := &config.Config{ + Mode: "read-write", + SyncBranch: "main", + Interval: "30s", + Debounce: "0s", + Commit: config.CommitConfig{ + Emoji: "🐌", + InProgressWindow: "10m", + }, + } + + result := Sync(context.Background(), repo, cfg, stateDir) + if result.State != "skipped" { + t.Fatalf("expected skipped, got %s: %s", result.State, result.Error) + } + + // The change must not have been committed/pushed. + clone2 := gitClone(t, remote) + defer os.RemoveAll(clone2) + if _, err := os.Stat(filepath.Join(clone2, "work.txt")); err == nil { + t.Error("work.txt should NOT be committed while a commit is in progress") + } +} + func TestSyncLockContentionSkips(t *testing.T) { remote := gitInitBare(t) defer os.RemoveAll(remote)