template-jj/cmd/jj-commitd/main_test.go
sid deb87b37a9 feat(commitd): legacy cleanup, conflict detection, session inventory, configurable reap/debounce
- On startup, daemon sweeps orphaned /tmp/jj-claude-*-files from pre-daemon
  hook versions and dead-PID tracking files from all repos
- Cross-session file conflicts detected on post-edit and returned in response
  (logged as warnings, non-blocking)
- session-start now returns inventory of all other active sessions with their
  tracked files, PIDs, and ages
- Stale session reap reduced from 2h to 30min default (matches idle shutdown),
  configurable via JJ_HOOK_STALE_MIN env var
- Debounce interval configurable via JJ_HOOK_DEBOUNCE_SEC env var (default 3s,
  max 30s) — previously documented but not implemented
- handleEvent response changed from bare bool to structured JSON with ok/error/
  sessions/conflicts fields (backward compatible — ok field still present)
- 30 Go tests (up from 13): inventory, conflicts, env vars, legacy cleanup
2026-03-29 03:50:06 -06:00

549 lines
16 KiB
Go

package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// ── repoID ──────────────────────────────────────────────────────
func TestRepoID(t *testing.T) {
id1 := repoID("/tmp/test-repo")
id2 := repoID("/tmp/test-repo")
if id1 != id2 {
t.Errorf("repoID not deterministic: %s != %s", id1, id2)
}
if len(id1) != 12 {
t.Errorf("repoID wrong length: got %d, want 12", len(id1))
}
}
func TestRepoIDDifferentPaths(t *testing.T) {
id1 := repoID("/tmp/repo-a")
id2 := repoID("/tmp/repo-b")
if id1 == id2 {
t.Errorf("different paths produced same repoID: %s", id1)
}
}
func TestRepoIDEmpty(t *testing.T) {
id := repoID("")
if id == "" {
t.Error("repoID returned empty string for empty input")
}
if len(id) != 12 {
t.Errorf("repoID wrong length for empty input: got %d, want 12", len(id))
}
}
// ── socketPath ──────────────────────────────────────────────────
func TestSocketPath(t *testing.T) {
path := socketPath("/tmp/test-repo")
if path == "" {
t.Error("socketPath returned empty string")
}
id := repoID("/tmp/test-repo")
if !strings.Contains(path, id) {
t.Errorf("socketPath %q does not contain repoID %q", path, id)
}
if !strings.HasPrefix(path, "/tmp/") {
t.Errorf("socketPath %q does not start with /tmp/", path)
}
if !strings.HasSuffix(path, ".sock") {
t.Errorf("socketPath %q does not end with .sock", path)
}
}
// ── Session lifecycle ───────────────────────────────────────────
func newTestDaemon() *Daemon {
return &Daemon{
sessions: make(map[string]*Session),
done: make(chan struct{}),
}
}
func TestSessionStart(t *testing.T) {
d := newTestDaemon()
resp := d.sessionStart(Event{
Event: "session-start",
SessionID: "test123",
RepoRoot: "/tmp/test",
PID: 12345,
})
if !resp.OK {
t.Fatal("sessionStart returned not OK")
}
if len(d.sessions) != 1 {
t.Fatalf("expected 1 session, got %d", len(d.sessions))
}
sess := d.sessions["test123"]
if sess.ID != "test123" {
t.Errorf("session ID = %q, want %q", sess.ID, "test123")
}
if sess.RepoRoot != "/tmp/test" {
t.Errorf("session RepoRoot = %q, want %q", sess.RepoRoot, "/tmp/test")
}
if sess.PID != 12345 {
t.Errorf("session PID = %d, want %d", sess.PID, 12345)
}
if sess.Dirty {
t.Error("new session should not be dirty")
}
if len(sess.Files) != 0 {
t.Errorf("new session should have 0 files, got %d", len(sess.Files))
}
}
func TestSessionStartResets(t *testing.T) {
d := newTestDaemon()
d.sessionStart(Event{SessionID: "s1", RepoRoot: "/tmp/a", PID: 100})
d.postEdit(Event{SessionID: "s1", File: "/tmp/a/foo.ex", RepoRoot: "/tmp/a"})
if d.sessions["s1"].Timer != nil {
d.sessions["s1"].Timer.Stop()
}
// Re-start same session — should reset
d.sessionStart(Event{SessionID: "s1", RepoRoot: "/tmp/b", PID: 200})
sess := d.sessions["s1"]
if sess.RepoRoot != "/tmp/b" {
t.Errorf("expected RepoRoot /tmp/b after reset, got %s", sess.RepoRoot)
}
if sess.PID != 200 {
t.Errorf("expected PID 200 after reset, got %d", sess.PID)
}
if len(sess.Files) != 0 {
t.Errorf("expected 0 files after reset, got %d", len(sess.Files))
}
}
// ── Session inventory on start ──────────────────────────────────
func TestSessionStartReturnsOtherSessions(t *testing.T) {
d := newTestDaemon()
d.sessionStart(Event{SessionID: "s1", RepoRoot: "/tmp/r", PID: 100})
d.postEdit(Event{SessionID: "s1", File: "/tmp/r/a.ex", RepoRoot: "/tmp/r"})
if d.sessions["s1"].Timer != nil {
d.sessions["s1"].Timer.Stop()
}
resp := d.sessionStart(Event{SessionID: "s2", RepoRoot: "/tmp/r", PID: 200})
if !resp.OK {
t.Fatal("sessionStart returned not OK")
}
if len(resp.Sessions) != 1 {
t.Fatalf("expected 1 other session, got %d", len(resp.Sessions))
}
if resp.Sessions[0].ID != "s1" {
t.Errorf("expected other session id=s1, got %s", resp.Sessions[0].ID)
}
if len(resp.Sessions[0].Files) != 1 {
t.Errorf("expected 1 file in s1, got %d", len(resp.Sessions[0].Files))
}
}
func TestSessionStartNoOtherSessions(t *testing.T) {
d := newTestDaemon()
resp := d.sessionStart(Event{SessionID: "s1", RepoRoot: "/tmp/r", PID: 100})
if len(resp.Sessions) != 0 {
t.Errorf("expected 0 other sessions for first start, got %d", len(resp.Sessions))
}
}
// ── postEdit ────────────────────────────────────────────────────
func TestPostEdit(t *testing.T) {
d := newTestDaemon()
d.sessionStart(Event{SessionID: "test123", RepoRoot: "/tmp/test"})
resp := d.postEdit(Event{
Event: "post-edit",
SessionID: "test123",
File: "/tmp/test/foo.ex",
RepoRoot: "/tmp/test",
})
if !resp.OK {
t.Fatal("postEdit returned not OK")
}
sess := d.sessions["test123"]
if len(sess.Files) != 1 {
t.Fatalf("expected 1 file, got %d", len(sess.Files))
}
if !sess.Files["foo.ex"] {
t.Error("expected foo.ex to be tracked (relative path)")
}
if !sess.Dirty {
t.Error("expected session to be dirty after post-edit")
}
if sess.Timer == nil {
t.Error("expected debounce timer to be set")
}
sess.Timer.Stop()
}
func TestPostEditAutoCreatesSession(t *testing.T) {
d := newTestDaemon()
resp := d.postEdit(Event{
Event: "post-edit",
SessionID: "auto123",
File: "/tmp/test/bar.ex",
RepoRoot: "/tmp/test",
})
if !resp.OK {
t.Fatal("postEdit returned not OK")
}
sess, exists := d.sessions["auto123"]
if !exists {
t.Fatal("expected session to be auto-created")
}
if !sess.Files["bar.ex"] {
t.Error("expected bar.ex to be tracked")
}
if sess.Timer != nil {
sess.Timer.Stop()
}
}
func TestPostEditDeduplicatesFiles(t *testing.T) {
d := newTestDaemon()
d.sessionStart(Event{SessionID: "dedup", RepoRoot: "/tmp/test"})
for i := 0; i < 5; i++ {
d.postEdit(Event{SessionID: "dedup", File: "/tmp/test/same.ex", RepoRoot: "/tmp/test"})
}
if len(d.sessions["dedup"].Files) != 1 {
t.Errorf("expected 1 file after dedup, got %d", len(d.sessions["dedup"].Files))
}
if d.sessions["dedup"].Timer != nil {
d.sessions["dedup"].Timer.Stop()
}
}
func TestPostEditMultipleFiles(t *testing.T) {
d := newTestDaemon()
d.sessionStart(Event{SessionID: "multi", RepoRoot: "/tmp/test"})
files := []string{"/tmp/test/a.ex", "/tmp/test/b.ex", "/tmp/test/c.ex"}
for _, f := range files {
d.postEdit(Event{SessionID: "multi", File: f, RepoRoot: "/tmp/test"})
}
sess := d.sessions["multi"]
if len(sess.Files) != 3 {
t.Errorf("expected 3 files, got %d", len(sess.Files))
}
for _, name := range []string{"a.ex", "b.ex", "c.ex"} {
if !sess.Files[name] {
t.Errorf("expected %s to be tracked", name)
}
}
if sess.Timer != nil {
sess.Timer.Stop()
}
}
func TestPostEditRelativePath(t *testing.T) {
d := newTestDaemon()
d.sessionStart(Event{SessionID: "rel", RepoRoot: "/tmp/test"})
// File outside repo root should be kept as-is
d.postEdit(Event{SessionID: "rel", File: "/other/path/file.ex", RepoRoot: "/tmp/test"})
sess := d.sessions["rel"]
if !sess.Files["/other/path/file.ex"] {
t.Error("file outside repo root should be stored as absolute path")
}
if sess.Timer != nil {
sess.Timer.Stop()
}
}
// ── Cross-session conflict detection ────────────────────────────
func TestPostEditDetectsConflict(t *testing.T) {
d := newTestDaemon()
d.sessionStart(Event{SessionID: "s1", RepoRoot: "/tmp/r", PID: 100})
d.postEdit(Event{SessionID: "s1", File: "/tmp/r/shared.ex", RepoRoot: "/tmp/r"})
if d.sessions["s1"].Timer != nil {
d.sessions["s1"].Timer.Stop()
}
d.sessionStart(Event{SessionID: "s2", RepoRoot: "/tmp/r", PID: 200})
resp := d.postEdit(Event{SessionID: "s2", File: "/tmp/r/shared.ex", RepoRoot: "/tmp/r"})
if !resp.OK {
t.Fatal("postEdit should succeed even with conflict")
}
if len(resp.Conflicts) != 1 {
t.Fatalf("expected 1 conflict, got %d", len(resp.Conflicts))
}
if resp.Conflicts[0].SessionID != "s1" {
t.Errorf("conflict session should be s1, got %s", resp.Conflicts[0].SessionID)
}
if resp.Conflicts[0].PID != 100 {
t.Errorf("conflict PID should be 100, got %d", resp.Conflicts[0].PID)
}
if resp.Conflicts[0].File != "shared.ex" {
t.Errorf("conflict file should be shared.ex, got %s", resp.Conflicts[0].File)
}
if d.sessions["s2"].Timer != nil {
d.sessions["s2"].Timer.Stop()
}
}
func TestPostEditNoConflictDifferentFiles(t *testing.T) {
d := newTestDaemon()
d.sessionStart(Event{SessionID: "s1", RepoRoot: "/tmp/r", PID: 100})
d.postEdit(Event{SessionID: "s1", File: "/tmp/r/a.ex", RepoRoot: "/tmp/r"})
if d.sessions["s1"].Timer != nil {
d.sessions["s1"].Timer.Stop()
}
d.sessionStart(Event{SessionID: "s2", RepoRoot: "/tmp/r", PID: 200})
resp := d.postEdit(Event{SessionID: "s2", File: "/tmp/r/b.ex", RepoRoot: "/tmp/r"})
if len(resp.Conflicts) != 0 {
t.Errorf("expected 0 conflicts for different files, got %d", len(resp.Conflicts))
}
if d.sessions["s2"].Timer != nil {
d.sessions["s2"].Timer.Stop()
}
}
// ── sessionEnd ──────────────────────────────────────────────────
func TestSessionEndUnknownSession(t *testing.T) {
d := newTestDaemon()
ok := d.sessionEnd(Event{SessionID: "nonexistent"})
if ok {
t.Error("sessionEnd should return false for unknown session")
}
}
func TestSessionEndRemovesSession(t *testing.T) {
d := newTestDaemon()
d.sessionStart(Event{SessionID: "s1", RepoRoot: "/tmp/test"})
// End the session (commitSession will be a no-op since nothing is dirty)
d.sessionEnd(Event{SessionID: "s1"})
if _, exists := d.sessions["s1"]; exists {
t.Error("expected session to be removed after session-end")
}
}
// ── handleEvent dispatch ────────────────────────────────────────
func TestHandleEventUnknown(t *testing.T) {
d := newTestDaemon()
resp := d.handleEvent(Event{Event: "bogus"})
if resp.OK {
t.Error("handleEvent should return not OK for unknown event type")
}
}
func TestHandleEventStatus(t *testing.T) {
d := newTestDaemon()
resp := d.handleEvent(Event{Event: "status"})
if !resp.OK {
t.Error("handleEvent should return OK for status event")
}
}
// ── Debounce timer ──────────────────────────────────────────────
func TestDebounceTimerResetsOnSubsequentEdits(t *testing.T) {
d := newTestDaemon()
d.sessionStart(Event{SessionID: "debounce", RepoRoot: "/tmp/test"})
d.postEdit(Event{SessionID: "debounce", File: "/tmp/test/a.ex", RepoRoot: "/tmp/test"})
timer1 := d.sessions["debounce"].Timer
d.postEdit(Event{SessionID: "debounce", File: "/tmp/test/b.ex", RepoRoot: "/tmp/test"})
timer2 := d.sessions["debounce"].Timer
// Timer should have been replaced
if timer1 == timer2 {
t.Error("expected debounce timer to be replaced on subsequent edit")
}
timer2.Stop()
}
func TestDebounceTimerSetsDirtyFlag(t *testing.T) {
d := newTestDaemon()
d.sessionStart(Event{SessionID: "dirty", RepoRoot: "/tmp/test"})
if d.sessions["dirty"].Dirty {
t.Error("session should not be dirty before any edit")
}
d.postEdit(Event{SessionID: "dirty", File: "/tmp/test/x.ex", RepoRoot: "/tmp/test"})
if !d.sessions["dirty"].Dirty {
t.Error("session should be dirty after post-edit")
}
d.sessions["dirty"].Timer.Stop()
}
func TestLastEditAtUpdated(t *testing.T) {
d := newTestDaemon()
d.sessionStart(Event{SessionID: "ts", RepoRoot: "/tmp/test"})
before := time.Now()
d.postEdit(Event{SessionID: "ts", File: "/tmp/test/f.ex", RepoRoot: "/tmp/test"})
after := time.Now()
sess := d.sessions["ts"]
if sess.LastEditAt.Before(before) || sess.LastEditAt.After(after) {
t.Errorf("LastEditAt %v not between %v and %v", sess.LastEditAt, before, after)
}
sess.Timer.Stop()
}
// ── Multiple sessions ───────────────────────────────────────────
func TestMultipleSessions(t *testing.T) {
d := newTestDaemon()
d.sessionStart(Event{SessionID: "s1", RepoRoot: "/tmp/repo1", PID: 100})
d.sessionStart(Event{SessionID: "s2", RepoRoot: "/tmp/repo2", PID: 200})
if len(d.sessions) != 2 {
t.Fatalf("expected 2 sessions, got %d", len(d.sessions))
}
d.postEdit(Event{SessionID: "s1", File: "/tmp/repo1/a.ex", RepoRoot: "/tmp/repo1"})
d.postEdit(Event{SessionID: "s2", File: "/tmp/repo2/b.ex", RepoRoot: "/tmp/repo2"})
if !d.sessions["s1"].Files["a.ex"] {
t.Error("s1 should track a.ex")
}
if !d.sessions["s2"].Files["b.ex"] {
t.Error("s2 should track b.ex")
}
// Files should not leak between sessions
if d.sessions["s1"].Files["b.ex"] {
t.Error("s1 should not have s2's file")
}
for _, s := range d.sessions {
if s.Timer != nil {
s.Timer.Stop()
}
}
}
// ── Debounce env var ────────────────────────────────────────────
func TestDebounceIntervalDefault(t *testing.T) {
os.Unsetenv("JJ_HOOK_DEBOUNCE_SEC")
d := debounceInterval()
if d != defaultDebounce {
t.Errorf("expected default debounce %v, got %v", defaultDebounce, d)
}
}
func TestDebounceIntervalEnvVar(t *testing.T) {
os.Setenv("JJ_HOOK_DEBOUNCE_SEC", "10")
defer os.Unsetenv("JJ_HOOK_DEBOUNCE_SEC")
d := debounceInterval()
if d != 10*time.Second {
t.Errorf("expected 10s debounce, got %v", d)
}
}
func TestDebounceIntervalCapped(t *testing.T) {
os.Setenv("JJ_HOOK_DEBOUNCE_SEC", "999")
defer os.Unsetenv("JJ_HOOK_DEBOUNCE_SEC")
d := debounceInterval()
if d != maxDebounce {
t.Errorf("expected max debounce %v, got %v", maxDebounce, d)
}
}
func TestDebounceIntervalInvalid(t *testing.T) {
os.Setenv("JJ_HOOK_DEBOUNCE_SEC", "not-a-number")
defer os.Unsetenv("JJ_HOOK_DEBOUNCE_SEC")
d := debounceInterval()
if d != defaultDebounce {
t.Errorf("expected default on invalid env, got %v", d)
}
}
// ── Stale session env var ───────────────────────────────────────
func TestStaleSessionAgeDefault(t *testing.T) {
os.Unsetenv("JJ_HOOK_STALE_MIN")
age := staleSessionAge()
if age != defaultStaleSessionAge {
t.Errorf("expected default stale age %v, got %v", defaultStaleSessionAge, age)
}
}
func TestStaleSessionAgeEnvVar(t *testing.T) {
os.Setenv("JJ_HOOK_STALE_MIN", "15")
defer os.Unsetenv("JJ_HOOK_STALE_MIN")
age := staleSessionAge()
if age != 15*time.Minute {
t.Errorf("expected 15min stale age, got %v", age)
}
}
func TestStaleSessionAgeInvalid(t *testing.T) {
os.Setenv("JJ_HOOK_STALE_MIN", "abc")
defer os.Unsetenv("JJ_HOOK_STALE_MIN")
age := staleSessionAge()
if age != defaultStaleSessionAge {
t.Errorf("expected default on invalid env, got %v", age)
}
}
// ── Legacy cleanup ──────────────────────────────────────────────
func TestCleanLegacyTrackingFiles(t *testing.T) {
d := newTestDaemon()
repoRoot := "/tmp/test-legacy-cleanup"
rid := repoID(repoRoot)
// Create some fake legacy tracking files
trackingPath := filepath.Join("/tmp", "jj-claude-"+rid+"-deadbeef-files")
pidPath := filepath.Join("/tmp", "jj-claude-"+rid+"-deadbeef-pid")
os.WriteFile(trackingPath, []byte("some/file.ex\n"), 0644)
os.WriteFile(pidPath, []byte("99999999\n"), 0644) // dead PID
// Should clean them up
d.cleanLegacyTrackingFiles(repoRoot)
if _, err := os.Stat(trackingPath); err == nil {
t.Error("expected tracking file to be removed")
}
if _, err := os.Stat(pidPath); err == nil {
t.Error("expected pid file to be removed")
}
}
func TestCleanLegacyTrackingFilesNoop(t *testing.T) {
d := newTestDaemon()
// Should not panic when no files match
d.cleanLegacyTrackingFiles("/tmp/nonexistent-repo-for-test")
}