fix(client): watch only rule paths, forget vanished worktrees, ping http2
This commit is contained in:
1 parent
3b2dcffcf0
commit
4207f7e6f8
4 files changed
+165
-34
No files matched your search
@@ -21,9 +21,14 @@ import (
|
|||||||
"lodge/internal/protocol"
|
"lodge/internal/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
const requestTimeout = 15 * time.Second
|
const (
|
||||||
|
requestTimeout = 15 * time.Second
|
||||||
|
sendPingAfter = 15 * time.Second
|
||||||
|
pingTimeout = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
httpClient = &http.Client{Transport: newTransport()}
|
||||||
ErrNotLoggedIn = errors.New("not logged in: run the install command from the lodge admin page")
|
ErrNotLoggedIn = errors.New("not logged in: run the install command from the lodge admin page")
|
||||||
errConflict = errors.New("server state changed")
|
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)
|
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) {
|
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))
|
req, err := http.NewRequestWithContext(ctx, method, c.URL+path, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||||
res, err := http.DefaultClient.Do(req)
|
res, err := httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -27,6 +29,12 @@ const (
|
|||||||
headFile = "HEAD"
|
headFile = "HEAD"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type tree struct {
|
||||||
|
key string
|
||||||
|
gitDir string
|
||||||
|
rules Rules
|
||||||
|
}
|
||||||
|
|
||||||
type daemon struct {
|
type daemon struct {
|
||||||
client *Client
|
client *Client
|
||||||
watcher *fsnotify.Watcher
|
watcher *fsnotify.Watcher
|
||||||
@@ -34,8 +42,7 @@ type daemon struct {
|
|||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
owners map[string]string
|
owners map[string]string
|
||||||
gitDirs map[string]bool
|
trees map[string]tree
|
||||||
keys map[string]string
|
|
||||||
pending map[string]*time.Timer
|
pending map[string]*time.Timer
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,8 +63,7 @@ func (c *Client) Daemon(ctx context.Context) error {
|
|||||||
watcher: watcher,
|
watcher: watcher,
|
||||||
queue: make(chan string, 256),
|
queue: make(chan string, 256),
|
||||||
owners: map[string]string{},
|
owners: map[string]string{},
|
||||||
gitDirs: map[string]bool{},
|
trees: map[string]tree{},
|
||||||
keys: map[string]string{},
|
|
||||||
pending: map[string]*time.Timer{},
|
pending: map[string]*time.Timer{},
|
||||||
}
|
}
|
||||||
go d.listen(ctx)
|
go d.listen(ctx)
|
||||||
@@ -85,16 +91,32 @@ func (d *daemon) handle(event fsnotify.Event) {
|
|||||||
d.syncAll()
|
d.syncAll()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dir := filepath.Dir(event.Name)
|
|
||||||
d.mu.Lock()
|
d.mu.Lock()
|
||||||
root, watched := d.owners[dir]
|
if event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) {
|
||||||
irrelevant := d.gitDirs[dir] && filepath.Base(event.Name) != headFile
|
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()
|
d.mu.Unlock()
|
||||||
if watched && !irrelevant && !strings.HasSuffix(event.Name, tempSuffix) {
|
if relevant {
|
||||||
d.schedule(root)
|
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) {
|
func (d *daemon) schedule(root string) {
|
||||||
d.mu.Lock()
|
d.mu.Lock()
|
||||||
defer d.mu.Unlock()
|
defer d.mu.Unlock()
|
||||||
@@ -112,10 +134,26 @@ func (d *daemon) schedule(root string) {
|
|||||||
|
|
||||||
func (d *daemon) syncAll() {
|
func (d *daemon) syncAll() {
|
||||||
for _, root := range d.client.Worktrees() {
|
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) {
|
func (d *daemon) work(ctx context.Context) {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -138,21 +176,11 @@ func (d *daemon) watch(root string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dirs := []string{repo.GitDir}
|
rules := LoadRules(repo.Root)
|
||||||
for _, rule := range LoadRules(repo.Root) {
|
dirs := map[string]bool{repo.GitDir: true}
|
||||||
|
for _, rule := range rules {
|
||||||
target := filepath.Join(repo.Root, filepath.FromSlash(rule.Path))
|
target := filepath.Join(repo.Root, filepath.FromSlash(rule.Path))
|
||||||
info, err := os.Stat(target)
|
dirs[nearestDir(filepath.Dir(target), repo.Root)] = true
|
||||||
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
|
|
||||||
}
|
|
||||||
_ = filepath.WalkDir(target, func(path string, entry fs.DirEntry, err error) error {
|
_ = filepath.WalkDir(target, func(path string, entry fs.DirEntry, err error) error {
|
||||||
if err != nil || !entry.IsDir() {
|
if err != nil || !entry.IsDir() {
|
||||||
return nil
|
return nil
|
||||||
@@ -160,21 +188,42 @@ func (d *daemon) watch(root string) {
|
|||||||
if entry.Name() == ".git" {
|
if entry.Name() == ".git" {
|
||||||
return filepath.SkipDir
|
return filepath.SkipDir
|
||||||
}
|
}
|
||||||
dirs = append(dirs, path)
|
dirs[path] = true
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
d.mu.Lock()
|
d.mu.Lock()
|
||||||
defer d.mu.Unlock()
|
defer d.mu.Unlock()
|
||||||
d.keys[repo.Root] = repo.Key
|
d.trees[repo.Root] = tree{key: repo.Key, gitDir: repo.GitDir, rules: rules}
|
||||||
d.gitDirs[repo.GitDir] = true
|
d.release(repo.Root, dirs)
|
||||||
for _, dir := range dirs {
|
for dir := range dirs {
|
||||||
if _, watching := d.owners[dir]; !watching && d.watcher.Add(dir) == nil {
|
if owner, taken := d.owners[dir]; taken && owner != repo.Root {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if d.watcher.Add(dir) == nil {
|
||||||
d.owners[dir] = repo.Root
|
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) {
|
func (d *daemon) listen(ctx context.Context) {
|
||||||
backoff := minBackoff
|
backoff := minBackoff
|
||||||
for ctx.Err() == nil {
|
for ctx.Err() == nil {
|
||||||
@@ -208,8 +257,8 @@ func (d *daemon) stream(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
d.mu.Lock()
|
d.mu.Lock()
|
||||||
var roots []string
|
var roots []string
|
||||||
for root, key := range d.keys {
|
for root, tree := range d.trees {
|
||||||
if key == event.Repo {
|
if tree.key == event.Repo {
|
||||||
roots = append(roots, root)
|
roots = append(roots, root)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,14 +61,17 @@ func (rules Rules) paths() []string {
|
|||||||
func (rules Rules) covers(file string, shared bool) bool {
|
func (rules Rules) covers(file string, shared bool) bool {
|
||||||
best := Rule{}
|
best := Rule{}
|
||||||
for _, rule := range rules {
|
for _, rule := range rules {
|
||||||
matches := file == rule.Path || strings.HasPrefix(file, rule.Path+"/")
|
if within(file, rule.Path) && len(rule.Path) > len(best.Path) {
|
||||||
if matches && len(rule.Path) > len(best.Path) {
|
|
||||||
best = rule
|
best = rule
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return best.Path != "" && best.Shared == shared
|
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 {
|
func writeExclude(repo *gitrepo.Repo, rules Rules) error {
|
||||||
file := filepath.Join(repo.CommonDir, "info", "exclude")
|
file := filepath.Join(repo.CommonDir, "info", "exclude")
|
||||||
current, _ := os.ReadFile(file)
|
current, _ := os.ReadFile(file)
|
||||||
|
|||||||
Reference in new issue
Block a user