- 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
549 lines
16 KiB
Go
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")
|
|
}
|