Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""

Expand Down
19 changes: 13 additions & 6 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 = "🐌"
}
Expand Down
74 changes: 74 additions & 0 deletions internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import (
"bytes"
"context"
"errors"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
Expand Down Expand Up @@ -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()
Expand Down
66 changes: 66 additions & 0 deletions internal/git/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions internal/sync/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down Expand Up @@ -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)
Expand Down
55 changes: 55 additions & 0 deletions internal/sync/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading