diff --git a/backend/internal/client/client.go b/backend/internal/client/client.go index d3c3509..0f4eee3 100644 --- a/backend/internal/client/client.go +++ b/backend/internal/client/client.go @@ -21,9 +21,14 @@ import ( "lodge/internal/protocol" ) -const requestTimeout = 15 * time.Second +const ( + requestTimeout = 15 * time.Second + sendPingAfter = 15 * time.Second + pingTimeout = 10 * time.Second +) var ( + httpClient = &http.Client{Transport: newTransport()} ErrNotLoggedIn = errors.New("not logged in: run the install command from the lodge admin page") errConflict = errors.New("server state changed") ) @@ -89,13 +94,19 @@ func writeFile(path string, data []byte, mode os.FileMode) error { return os.Rename(temp, path) } +func newTransport() *http.Transport { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.HTTP2 = &http.HTTP2Config{SendPingTimeout: sendPingAfter, PingTimeout: pingTimeout} + return transport +} + func (c *Client) request(ctx context.Context, method, path string, body []byte) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, method, c.URL+path, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+c.Token) - res, err := http.DefaultClient.Do(req) + res, err := httpClient.Do(req) if err != nil { return nil, err } diff --git a/backend/internal/client/daemon.go b/backend/internal/client/daemon.go index df5d3e3..913de9f 100644 --- a/backend/internal/client/daemon.go +++ b/backend/internal/client/daemon.go @@ -4,11 +4,13 @@ import ( "bufio" "context" "encoding/json" + "errors" "io/fs" "log" "net/http" "os" "path/filepath" + "slices" "strings" "sync" "time" @@ -27,6 +29,12 @@ const ( headFile = "HEAD" ) +type tree struct { + key string + gitDir string + rules Rules +} + type daemon struct { client *Client watcher *fsnotify.Watcher @@ -34,8 +42,7 @@ type daemon struct { mu sync.Mutex owners map[string]string - gitDirs map[string]bool - keys map[string]string + trees map[string]tree pending map[string]*time.Timer } @@ -56,8 +63,7 @@ func (c *Client) Daemon(ctx context.Context) error { watcher: watcher, queue: make(chan string, 256), owners: map[string]string{}, - gitDirs: map[string]bool{}, - keys: map[string]string{}, + trees: map[string]tree{}, pending: map[string]*time.Timer{}, } go d.listen(ctx) @@ -85,16 +91,32 @@ func (d *daemon) handle(event fsnotify.Event) { d.syncAll() return } - dir := filepath.Dir(event.Name) d.mu.Lock() - root, watched := d.owners[dir] - irrelevant := d.gitDirs[dir] && filepath.Base(event.Name) != headFile + if event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) { + delete(d.owners, event.Name) + } + root, watched := d.owners[filepath.Dir(event.Name)] + relevant := watched && d.trees[root].relevant(root, event.Name) d.mu.Unlock() - if watched && !irrelevant && !strings.HasSuffix(event.Name, tempSuffix) { + if relevant { d.schedule(root) } } +func (t tree) relevant(root, name string) bool { + if filepath.Dir(name) == t.gitDir { + return filepath.Base(name) == headFile + } + rel, err := filepath.Rel(root, name) + if err != nil || strings.HasSuffix(name, tempSuffix) { + return false + } + rel = filepath.ToSlash(rel) + return slices.ContainsFunc(t.rules, func(rule Rule) bool { + return within(rel, rule.Path) || within(rule.Path, rel) + }) +} + func (d *daemon) schedule(root string) { d.mu.Lock() defer d.mu.Unlock() @@ -112,10 +134,26 @@ func (d *daemon) schedule(root string) { func (d *daemon) syncAll() { for _, root := range d.client.Worktrees() { - d.schedule(root) + if _, err := os.Stat(root); errors.Is(err, fs.ErrNotExist) { + d.forget(root) + } else { + d.schedule(root) + } } } +func (d *daemon) forget(root string) { + if err := d.client.Forget(root); err != nil { + log.Printf("%s: %v", root, err) + return + } + log.Printf("%s: gone, forgotten", root) + d.mu.Lock() + defer d.mu.Unlock() + delete(d.trees, root) + d.release(root, nil) +} + func (d *daemon) work(ctx context.Context) { for { select { @@ -138,21 +176,11 @@ func (d *daemon) watch(root string) { if err != nil { return } - dirs := []string{repo.GitDir} - for _, rule := range LoadRules(repo.Root) { + rules := LoadRules(repo.Root) + dirs := map[string]bool{repo.GitDir: true} + for _, rule := range rules { target := filepath.Join(repo.Root, filepath.FromSlash(rule.Path)) - info, err := os.Stat(target) - for err != nil && target != repo.Root { - target = filepath.Dir(target) - info, err = os.Stat(target) - } - if err != nil { - continue - } - if !info.IsDir() { - dirs = append(dirs, filepath.Dir(target)) - continue - } + dirs[nearestDir(filepath.Dir(target), repo.Root)] = true _ = filepath.WalkDir(target, func(path string, entry fs.DirEntry, err error) error { if err != nil || !entry.IsDir() { return nil @@ -160,21 +188,42 @@ func (d *daemon) watch(root string) { if entry.Name() == ".git" { return filepath.SkipDir } - dirs = append(dirs, path) + dirs[path] = true return nil }) } d.mu.Lock() defer d.mu.Unlock() - d.keys[repo.Root] = repo.Key - d.gitDirs[repo.GitDir] = true - for _, dir := range dirs { - if _, watching := d.owners[dir]; !watching && d.watcher.Add(dir) == nil { + d.trees[repo.Root] = tree{key: repo.Key, gitDir: repo.GitDir, rules: rules} + d.release(repo.Root, dirs) + for dir := range dirs { + if owner, taken := d.owners[dir]; taken && owner != repo.Root { + continue + } + if d.watcher.Add(dir) == nil { d.owners[dir] = repo.Root } } } +func (d *daemon) release(root string, keep map[string]bool) { + for dir, owner := range d.owners { + if owner == root && !keep[dir] { + _ = d.watcher.Remove(dir) + delete(d.owners, dir) + } + } +} + +func nearestDir(path, root string) string { + for ; path != root; path = filepath.Dir(path) { + if info, err := os.Stat(path); err == nil && info.IsDir() { + return path + } + } + return root +} + func (d *daemon) listen(ctx context.Context) { backoff := minBackoff for ctx.Err() == nil { @@ -208,8 +257,8 @@ func (d *daemon) stream(ctx context.Context) error { } d.mu.Lock() var roots []string - for root, key := range d.keys { - if key == event.Repo { + for root, tree := range d.trees { + if tree.key == event.Repo { roots = append(roots, root) } } diff --git a/backend/internal/client/daemon_test.go b/backend/internal/client/daemon_test.go new file mode 100644 index 0000000..3f36814 --- /dev/null +++ b/backend/internal/client/daemon_test.go @@ -0,0 +1,68 @@ +package client + +import ( + "maps" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/fsnotify/fsnotify" +) + +func TestDaemonWatchesOnlyRulePaths(t *testing.T) { + m, _ := setup(t) + watcher, err := fsnotify.NewWatcher() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { watcher.Close() }) + d := &daemon{client: m.client, watcher: watcher, owners: map[string]string{}, trees: map[string]tree{}} + gitDir := filepath.Join(m.root, ".git") + expectWatched := func(want ...string) { + t.Helper() + slices.Sort(want) + if got := slices.Sorted(maps.Keys(d.owners)); !slices.Equal(got, want) { + t.Fatalf("watching %v, want %v", got, want) + } + } + + write(t, m.root, "target/debug/deps/big.o", "build output") + write(t, m.root, "docs/agents/sub/note.md", "note") + d.watch(m.root) + expectWatched(m.root, gitDir, filepath.Join(m.root, "docs"), filepath.Join(m.root, "docs/agents"), filepath.Join(m.root, "docs/agents/sub")) + + write(t, m.root, RulesFile, "shared notes/plans\n") + if err := os.RemoveAll(filepath.Join(m.root, "docs/agents/sub")); err != nil { + t.Fatal(err) + } + d.watch(m.root) + expectWatched(m.root, gitDir) + + for name, want := range map[string]bool{ + "notes": true, + "notes/plans/a.md": true, + RulesFile: true, + ".git/HEAD": true, + ".git/index": false, + "notes/plans/a.lodge-tmp": false, + "docs/agents/x.md": false, + "target/debug/deps/big.o": false, + } { + if got := d.trees[m.root].relevant(m.root, filepath.Join(m.root, name)); got != want { + t.Errorf("relevant(%s) = %v, want %v", name, got, want) + } + } +} + +func TestDaemonForgetsVanishedWorktrees(t *testing.T) { + c := &Client{DataDir: t.TempDir()} + if err := writeJSONFile(c.registryPath(), []string{filepath.Join(t.TempDir(), "gone")}); err != nil { + t.Fatal(err) + } + d := &daemon{client: c, owners: map[string]string{}, trees: map[string]tree{}} + d.syncAll() + if roots := c.Worktrees(); len(roots) != 0 { + t.Fatalf("registry still lists %v", roots) + } +} diff --git a/backend/internal/client/rules.go b/backend/internal/client/rules.go index 24685d1..13bb4c8 100644 --- a/backend/internal/client/rules.go +++ b/backend/internal/client/rules.go @@ -61,14 +61,17 @@ func (rules Rules) paths() []string { func (rules Rules) covers(file string, shared bool) bool { best := Rule{} for _, rule := range rules { - matches := file == rule.Path || strings.HasPrefix(file, rule.Path+"/") - if matches && len(rule.Path) > len(best.Path) { + if within(file, rule.Path) && len(rule.Path) > len(best.Path) { best = rule } } return best.Path != "" && best.Shared == shared } +func within(path, dir string) bool { + return path == dir || strings.HasPrefix(path, dir+"/") +} + func writeExclude(repo *gitrepo.Repo, rules Rules) error { file := filepath.Join(repo.CommonDir, "info", "exclude") current, _ := os.ReadFile(file)