Compare commits

...
4 Commits
16 changed files with 1133 additions and 164 deletions

No files matched your search

+11 -1
View File
@@ -25,9 +25,11 @@ A repository is identified by the hash of its root commit, so nothing is registe
shared docs/agents shared docs/agents
shared CLAUDE.local.md shared CLAUDE.local.md
branch .agent branch .agent
ignore vault/.obsidian/workspace*.json
ignore .DS_Store
``` ```
Synced paths are hidden through `.git/info/exclude`; `.gitignore` is never touched, and files tracked by git are never synced. When two machines edit the same file, the later edit wins and the other version stays in history. `ignore` keeps matching files on the machine that has them: they are never pushed, pulled or watched. Its pattern is a glob over the whole path, or over any single name when it has no slash. Synced paths are hidden through `.git/info/exclude`; `.gitignore` is never touched, and files tracked by git are never synced. When two machines edit the same file, the later edit wins and the other version stays in history.
## Adding a machine ## Adding a machine
@@ -41,6 +43,14 @@ Running the returned `curl ... | sh` on a Mac or a Linux machine downloads the c
`make -C backend dev` runs the server without Docker, `make -C backend client` builds the client into `backend/bin`. `make -C backend dev` runs the server without Docker, `make -C backend client` builds the client into `backend/bin`.
## Several servers
A repository can live on another lodge server than the rest of the machine, for example a team's server during an event, while everything else stays on the personal one. Inside the repository, `lodge remote <url> <code>` claims a code from that server and binds the repository to it; further repositories on the same server need only `lodge remote <url>`. Outside a repository the same command only stores the server's token. The binding belongs to the repository key, so worktrees and later clones follow it, and the daemon listens to every server it holds a token for. `lodge remote` prints the server of the current repository, `lodge remote --default` sends it back to the default one. `lodge login` and `lodge update` only touch the default server, and a machine with no default server at all syncs just the repositories bound with `lodge remote`.
On a machine that is logged in elsewhere, the code's `remote_command`, `curl -fsSL https://lodge.example.com/i/<code>/remote | sh`, installs or updates the client, connects the machine to that server without touching the default one, restarts a running daemon so it runs the new binary, and installs the daemon only if it is not there yet. Repositories are then bound with `lodge remote <url>`. Keep the servers a machine talks to on the same version: an older client ignores bindings and syncs every repository with the default server, and `lodge update` brings the default server's client.
Where no service manager runs, in a container for example, `lodge install` says so and leaves it to `lodge daemon &`.
## The admin ## The admin
The server serves the admin from `frontend/build`. History is drawn as a borehole log: every revision is a bed, newest on top, hatched by the machine that wrote it and labelled with the branch and commit of the main repository. Dragging along the column, or the arrow keys, shows the notes as they were at that revision, and every depth, file and view is a link. A file can be compared with its previous version and put back on the surface; a repository can be deleted with its history, which leaves the files on the machines alone. Machines are listed with their hatch and can be revoked. The server serves the admin from `frontend/build`. History is drawn as a borehole log: every revision is a bed, newest on top, hatched by the machine that wrote it and labelled with the branch and commit of the main repository. Dragging along the column, or the arrow keys, shows the notes as they were at that revision, and every depth, file and view is a link. A file can be compared with its previous version and put back on the surface; a repository can be deleted with its history, which leaves the files on the machines alone. Machines are listed with their hatch and can be revoked.
+118 -40
View File
@@ -9,6 +9,7 @@ import (
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"slices"
"syscall" "syscall"
"time" "time"
@@ -20,9 +21,11 @@ import (
const usage = `lodge keeps agent files out of git and in sync across machines const usage = `lodge keeps agent files out of git and in sync across machines
lodge login <url> <code> connect this machine using a code from the admin page lodge login <url> <code> connect this machine using a code from the admin page
lodge remote <url> [code] sync the current repository with another server
lodge remote [--default] show that server, or go back to the default one
lodge install start the daemon, install the git hook and the agent skill lodge install start the daemon, install the git hook and the agent skill
lodge uninstall undo install lodge uninstall undo install
lodge update replace this binary with the one the server ships lodge update replace this binary with the one the default server ships
lodge add [dir] start syncing a repository that was cloned before install lodge add [dir] start syncing a repository that was cloned before install
lodge forget [dir] stop syncing a repository lodge forget [dir] stop syncing a repository
lodge sync [dir] sync once, for machines without a daemon lodge sync [dir] sync once, for machines without a daemon
@@ -31,7 +34,7 @@ const usage = `lodge keeps agent files out of git and in sync across machines
lodge show <rev|sha> <path> a file as of a revision or a commit of the main repository lodge show <rev|sha> <path> a file as of a revision or a commit of the main repository
lodge daemon run the sync daemon in the foreground lodge daemon run the sync daemon in the foreground
status, log and sync accept --json status, log, sync and remote accept --json
` `
var hookEnvironment = []string{"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_PREFIX"} var hookEnvironment = []string{"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_PREFIX"}
@@ -39,12 +42,18 @@ var hookEnvironment = []string{"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GI
type status struct { type status struct {
Root string `json:"root"` Root string `json:"root"`
Key string `json:"key"` Key string `json:"key"`
Server string `json:"server"`
Branch string `json:"branch"` Branch string `json:"branch"`
Rules []client.Rule `json:"rules"` Rules []client.Rule `json:"rules"`
Revision int64 `json:"revision"` Revision int64 `json:"revision"`
Spaces map[string]protocol.Manifest `json:"spaces"` Spaces map[string]protocol.Manifest `json:"spaces"`
} }
type remoteStatus struct {
URL string `json:"url"`
Default bool `json:"default"`
}
func main() { func main() {
if len(os.Args) < 2 { if len(os.Args) < 2 {
fmt.Print(usage) fmt.Print(usage)
@@ -67,14 +76,15 @@ func hook(ctx context.Context) {
for _, name := range hookEnvironment { for _, name := range hookEnvironment {
os.Unsetenv(name) os.Unsetenv(name)
} }
if c, err := client.Load(); err == nil { if cfg, err := client.Load(); err == nil {
_ = c.Hook(ctx, ".") _ = cfg.Hook(ctx, ".")
} }
} }
func run(ctx context.Context, name string, args []string) error { func run(ctx context.Context, name string, args []string) error {
flags := flag.NewFlagSet(name, flag.ExitOnError) flags := flag.NewFlagSet(name, flag.ExitOnError)
asJSON := flags.Bool("json", false, "machine-readable output") asJSON := flags.Bool("json", false, "machine-readable output")
toDefault := flags.Bool("default", false, "go back to the default server")
_ = flags.Parse(args) _ = flags.Parse(args)
args = flags.Args() args = flags.Args()
target := func(fallback string) string { target := func(fallback string) string {
@@ -84,45 +94,36 @@ func run(ctx context.Context, name string, args []string) error {
return fallback return fallback
} }
if slices.Contains([]string{"help", "-h", "--help"}, name) {
fmt.Print(usage)
return nil
}
cfg, err := client.Load()
if err != nil {
return err
}
switch name { switch name {
case "login": case "login":
if len(args) != 2 { if len(args) != 2 {
return errors.New("usage: lodge login <url> <code>") return errors.New("usage: lodge login <url> <code>")
} }
return client.Login(ctx, args[0], args[1]) return cfg.Login(ctx, args[0], args[1])
case "help", "-h", "--help": case "remote":
fmt.Print(usage) return remote(ctx, cfg, args, *toDefault, *asJSON)
return nil
}
c, err := client.Load()
if err != nil {
return err
}
switch name {
case "install": case "install":
return c.Install() return cfg.Install()
case "uninstall": case "uninstall":
return c.Uninstall() return cfg.Uninstall()
case "daemon": case "daemon":
return c.Daemon(ctx) return cfg.Daemon(ctx)
case "update": case "update":
updated, err := c.Update(ctx) updated, err := cfg.Update(ctx)
if err == nil && !updated { if err == nil && !updated {
fmt.Println("already up to date") fmt.Println("already up to date")
} }
return err return err
case "add", "sync": case "add", "sync":
res, err := c.Sync(ctx, target("."), true) return syncRepo(ctx, cfg, target("."), *asJSON)
if err != nil {
return err
}
return output(*asJSON, res, func() {
fmt.Printf("pushed %d, pulled %d\n", res.Pushed, res.Pulled)
for _, path := range res.Skipped {
fmt.Printf("skipped, over 5 MB: %s\n", path)
}
})
case "forget": case "forget":
root, err := filepath.Abs(target(".")) root, err := filepath.Abs(target("."))
if err != nil { if err != nil {
@@ -131,16 +132,16 @@ func run(ctx context.Context, name string, args []string) error {
if repo, err := gitrepo.Open(root); err == nil { if repo, err := gitrepo.Open(root); err == nil {
root = repo.Root root = repo.Root
} }
return c.Forget(root) return cfg.Forget(root)
case "status": case "status":
return showStatus(ctx, c, target("."), *asJSON) return showStatus(ctx, cfg, target("."), *asJSON)
case "log": case "log":
return showLog(ctx, c, target(""), *asJSON) return showLog(ctx, cfg, target(""), *asJSON)
case "show": case "show":
if len(args) != 2 { if len(args) != 2 {
return errors.New("usage: lodge show <revision|commit-sha> <path>") return errors.New("usage: lodge show <revision|commit-sha> <path>")
} }
return showFile(ctx, c, args[0], args[1]) return showFile(ctx, cfg, args[0], args[1])
} }
return fmt.Errorf("unknown command %q, see lodge help", name) return fmt.Errorf("unknown command %q, see lodge help", name)
} }
@@ -155,21 +156,86 @@ func output(asJSON bool, value any, human func()) error {
return encoder.Encode(value) return encoder.Encode(value)
} }
func showStatus(ctx context.Context, c *client.Client, dir string, asJSON bool) error { func syncRepo(ctx context.Context, cfg *client.Config, dir string, asJSON bool) error {
res, err := cfg.Sync(ctx, dir, true)
if err != nil {
return err
}
return output(asJSON, res, func() {
fmt.Printf("pushed %d, pulled %d\n", res.Pushed, res.Pulled)
for _, path := range res.Skipped {
fmt.Printf("skipped, over 5 MB: %s\n", path)
}
})
}
func remote(ctx context.Context, cfg *client.Config, args []string, toDefault, asJSON bool) error {
if len(args) > 2 || toDefault && len(args) > 0 {
return errors.New("usage: lodge remote [<url> [code]] | lodge remote --default")
}
if len(args) == 2 {
if err := cfg.Connect(ctx, args[0], args[1]); err != nil {
return err
}
}
repo, err := gitrepo.Open(".")
if err != nil && len(args) == 2 {
fmt.Printf("connected to %s, run lodge remote %s inside a repository to sync it there\n", args[0], args[0])
return nil
}
if err != nil {
return err
}
if len(args) == 0 && !toDefault {
c, err := cfg.Server(repo.Key)
if err != nil {
return err
}
report := remoteStatus{URL: c.URL, Default: !cfg.Bound(repo.Key)}
return output(asJSON, report, func() {
if report.Default {
fmt.Printf("%s (default)\n", report.URL)
} else {
fmt.Println(report.URL)
}
})
}
server := ""
if len(args) > 0 {
server = args[0]
}
if err := cfg.Bind(repo.Key, server); err != nil {
return err
}
err = syncRepo(ctx, cfg, repo.Root, asJSON)
if toDefault && errors.Is(err, client.ErrNotLoggedIn) {
return nil
}
return err
}
func showStatus(ctx context.Context, cfg *client.Config, dir string, asJSON bool) error {
repo, err := gitrepo.Open(dir) repo, err := gitrepo.Open(dir)
if err != nil { if err != nil {
return err return err
} }
c, err := cfg.Server(repo.Key)
if err != nil {
return err
}
state, err := c.State(ctx, repo.Key, "") state, err := c.State(ctx, repo.Key, "")
if err != nil { if err != nil {
return err return err
} }
report := status{Root: repo.Root, Key: repo.Key, Branch: repo.Branch(), Rules: client.LoadRules(repo.Root), Revision: state.Seq, Spaces: state.Spaces} report := status{Root: repo.Root, Key: repo.Key, Server: c.URL, Branch: repo.Branch(), Rules: client.LoadRules(repo.Root), Revision: state.Seq, Spaces: state.Spaces}
return output(asJSON, report, func() { return output(asJSON, report, func() {
fmt.Printf("%s\nkey %s, branch %s, revision %d\n\nrules:\n", report.Root, report.Key, report.Branch, report.Revision) fmt.Printf("%s\nkey %s, branch %s, revision %d\nserver %s\n\nrules:\n", report.Root, report.Key, report.Branch, report.Revision, report.Server)
for _, rule := range report.Rules { for _, rule := range report.Rules {
kind := "branch" kind := "branch"
if rule.Shared { switch {
case rule.Ignore:
kind = "ignore"
case rule.Shared:
kind = "shared" kind = "shared"
} }
fmt.Printf(" %s %s\n", kind, rule.Path) fmt.Printf(" %s %s\n", kind, rule.Path)
@@ -183,11 +249,15 @@ func showStatus(ctx context.Context, c *client.Client, dir string, asJSON bool)
}) })
} }
func showLog(ctx context.Context, c *client.Client, path string, asJSON bool) error { func showLog(ctx context.Context, cfg *client.Config, path string, asJSON bool) error {
repo, err := gitrepo.Open(".") repo, err := gitrepo.Open(".")
if err != nil { if err != nil {
return err return err
} }
c, err := cfg.Server(repo.Key)
if err != nil {
return err
}
revisions, err := c.Revisions(ctx, repo.Key, path) revisions, err := c.Revisions(ctx, repo.Key, path)
if err != nil { if err != nil {
return err return err
@@ -195,7 +265,11 @@ func showLog(ctx context.Context, c *client.Client, path string, asJSON bool) er
return output(asJSON, revisions, func() { return output(asJSON, revisions, func() {
for _, rev := range revisions { for _, rev := range revisions {
when := time.UnixMilli(rev.Time).Format("2006-01-02 15:04") when := time.UnixMilli(rev.Time).Format("2006-01-02 15:04")
fmt.Printf("%d %s %s %s@%.10s\n", rev.Seq, when, rev.Machine, rev.MainBranch, rev.MainSHA) who := rev.Machine
if rev.Author != "" {
who = rev.Author + " via " + rev.Machine
}
fmt.Printf("%d %s %s %s@%.10s\n", rev.Seq, when, who, rev.MainBranch, rev.MainSHA)
for _, change := range rev.Changes { for _, change := range rev.Changes {
action := "M" action := "M"
if change.Base == "" { if change.Base == "" {
@@ -209,11 +283,15 @@ func showLog(ctx context.Context, c *client.Client, path string, asJSON bool) er
}) })
} }
func showFile(ctx context.Context, c *client.Client, at, path string) error { func showFile(ctx context.Context, cfg *client.Config, at, path string) error {
repo, err := gitrepo.Open(".") repo, err := gitrepo.Open(".")
if err != nil { if err != nil {
return err return err
} }
c, err := cfg.Server(repo.Key)
if err != nil {
return err
}
state, err := c.State(ctx, repo.Key, at) state, err := c.State(ctx, repo.Key, at)
if err != nil { if err != nil {
return err return err
+139 -31
View File
@@ -10,6 +10,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"maps"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -29,14 +30,23 @@ const (
var ( var (
httpClient = &http.Client{Transport: newTransport()} 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, or lodge remote <url> <code> inside the repository")
errConflict = errors.New("server state changed") errConflict = errors.New("server state changed")
) )
type Client struct { type Config struct {
URL string `json:"url"` URL string `json:"url,omitempty"`
Token string `json:"token"` Token string `json:"token,omitempty"`
Servers map[string]string `json:"servers,omitempty"`
Repos map[string]string `json:"repos,omitempty"`
DataDir string `json:"-"` DataDir string `json:"-"`
path string
}
type Client struct {
URL string
Token string
DataDir string
} }
func configPath() string { func configPath() string {
@@ -49,30 +59,128 @@ func dataDir() string {
return filepath.Join(cmp.Or(os.Getenv("XDG_DATA_HOME"), filepath.Join(home, ".local", "share")), "lodge") return filepath.Join(cmp.Or(os.Getenv("XDG_DATA_HOME"), filepath.Join(home, ".local", "share")), "lodge")
} }
func Load() (*Client, error) { func serverURL(raw string) string {
c := &Client{DataDir: dataDir()} return strings.TrimSuffix(raw, "/")
if data, err := os.ReadFile(configPath()); err == nil { }
if err := json.Unmarshal(data, c); err != nil {
func Load() (*Config, error) {
return loadConfig(configPath(), dataDir())
}
func loadConfig(file, dir string) (*Config, error) {
cfg := &Config{DataDir: dir, path: file}
if data, err := os.ReadFile(file); err == nil {
if err := json.Unmarshal(data, cfg); err != nil {
return nil, err return nil, err
} }
} }
c.URL = strings.TrimSuffix(cmp.Or(os.Getenv("LODGE_URL"), c.URL), "/") cfg.URL = serverURL(cmp.Or(os.Getenv("LODGE_URL"), cfg.URL))
c.Token = cmp.Or(os.Getenv("LODGE_TOKEN"), c.Token) cfg.Token = cmp.Or(os.Getenv("LODGE_TOKEN"), cfg.Token)
if c.URL == "" || c.Token == "" { return cfg, nil
return nil, ErrNotLoggedIn
}
return c, nil
} }
func Login(ctx context.Context, serverURL, code string) error { func (cfg *Config) reload() (*Config, error) {
name, _ := os.Hostname() return loadConfig(cfg.path, cfg.DataDir)
c := &Client{URL: strings.TrimSuffix(serverURL, "/")} }
var claim protocol.ClaimResponse
if err := c.call(ctx, http.MethodPost, "/api/claim", protocol.ClaimRequest{Code: code, Name: name}, &claim); err != nil { func (cfg *Config) update(change func(*Config)) error {
unlock, err := flock(cfg.path + ".lock")
if err != nil {
return err return err
} }
c.Token = claim.Token defer unlock()
return writeJSONFile(configPath(), c) saved := &Config{Servers: map[string]string{}, Repos: map[string]string{}}
if data, err := os.ReadFile(cfg.path); err == nil {
if err := json.Unmarshal(data, saved); err != nil {
return err
}
}
change(saved)
if err := writeJSONFile(cfg.path, saved); err != nil {
return err
}
fresh, err := cfg.reload()
if err == nil {
*cfg = *fresh
}
return err
}
func (cfg *Config) serverOf(key string) string {
return cmp.Or(cfg.Repos[key], cfg.URL)
}
func (cfg *Config) Bound(key string) bool {
return cfg.Repos[key] != ""
}
func (cfg *Config) Server(key string) (*Client, error) {
return cfg.client(cfg.serverOf(key))
}
func (cfg *Config) client(server string) (*Client, error) {
token := cfg.Servers[server]
if server == cfg.URL {
token = cmp.Or(cfg.Token, token)
}
switch {
case server == "":
return nil, ErrNotLoggedIn
case token == "":
return nil, fmt.Errorf("no token for %s: run lodge remote %s <code> with a code from that server", server, server)
}
return &Client{URL: server, Token: token, DataDir: cfg.DataDir}, nil
}
func (cfg *Config) clients() map[Client]bool {
clients := map[Client]bool{}
for _, server := range append(slices.Collect(maps.Keys(cfg.Servers)), cfg.URL) {
if c, err := cfg.client(server); err == nil {
clients[*c] = true
}
}
return clients
}
func claim(ctx context.Context, server, code string) (string, error) {
name, _ := os.Hostname()
var res protocol.ClaimResponse
err := (&Client{URL: server}).call(ctx, http.MethodPost, "/api/claim", protocol.ClaimRequest{Code: code, Name: name}, &res)
return res.Token, err
}
func (cfg *Config) Login(ctx context.Context, server, code string) error {
server = serverURL(server)
token, err := claim(ctx, server, code)
if err != nil {
return err
}
return cfg.update(func(saved *Config) { saved.URL, saved.Token = server, token })
}
func (cfg *Config) Connect(ctx context.Context, server, code string) error {
server = serverURL(server)
token, err := claim(ctx, server, code)
if err != nil {
return err
}
return cfg.update(func(saved *Config) { saved.Servers[server] = token })
}
func (cfg *Config) Bind(key, server string) error {
server = serverURL(server)
if server != "" {
if _, err := cfg.client(server); err != nil {
return err
}
}
return cfg.update(func(saved *Config) {
if server == "" {
delete(saved.Repos, key)
} else {
saved.Repos[key] = server
}
})
} }
func writeJSONFile(path string, value any) error { func writeJSONFile(path string, value any) error {
@@ -205,20 +313,20 @@ func (c *Client) uploadBlob(ctx context.Context, hash string) error {
return res.Body.Close() return res.Body.Close()
} }
func (c *Client) registryPath() string { func (cfg *Config) registryPath() string {
return filepath.Join(c.DataDir, "worktrees.json") return filepath.Join(cfg.DataDir, "worktrees.json")
} }
func (c *Client) Worktrees() []string { func (cfg *Config) Worktrees() []string {
var roots []string var roots []string
if data, err := os.ReadFile(c.registryPath()); err == nil { if data, err := os.ReadFile(cfg.registryPath()); err == nil {
_ = json.Unmarshal(data, &roots) _ = json.Unmarshal(data, &roots)
} }
return roots return roots
} }
func (c *Client) register(root string) error { func (cfg *Config) register(root string) error {
roots := c.Worktrees() roots := cfg.Worktrees()
if slices.Contains(roots, root) { if slices.Contains(roots, root) {
return nil return nil
} }
@@ -226,10 +334,10 @@ func (c *Client) register(root string) error {
_, err := os.Stat(known) _, err := os.Stat(known)
return err != nil return err != nil
}) })
return writeJSONFile(c.registryPath(), append(roots, root)) return writeJSONFile(cfg.registryPath(), append(roots, root))
} }
func (c *Client) Forget(root string) error { func (cfg *Config) Forget(root string) error {
roots := slices.DeleteFunc(c.Worktrees(), func(known string) bool { return known == root }) roots := slices.DeleteFunc(cfg.Worktrees(), func(known string) bool { return known == root })
return writeJSONFile(c.registryPath(), roots) return writeJSONFile(cfg.registryPath(), roots)
} }
+73 -25
View File
@@ -36,38 +36,46 @@ type tree struct {
} }
type daemon struct { type daemon struct {
client *Client config *Config
watcher *fsnotify.Watcher watcher *fsnotify.Watcher
queue chan string queue chan string
mu sync.Mutex mu sync.Mutex
current *Config
listeners map[Client]context.CancelFunc
owners map[string]string owners map[string]string
trees map[string]tree trees map[string]tree
pending map[string]*time.Timer pending map[string]*time.Timer
} }
func (c *Client) Daemon(ctx context.Context) error { func (cfg *Config) Daemon(ctx context.Context) error {
watcher, err := fsnotify.NewWatcher() watcher, err := fsnotify.NewWatcher()
if err != nil { if err != nil {
return err return err
} }
defer watcher.Close() defer watcher.Close()
if err := os.MkdirAll(c.DataDir, 0o755); err != nil { for _, dir := range []string{cfg.DataDir, filepath.Dir(cfg.path)} {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err return err
} }
if err := watcher.Add(c.DataDir); err != nil { if err := watcher.Add(dir); err != nil {
return err return err
} }
}
d := &daemon{ d := &daemon{
client: c, config: cfg,
watcher: watcher, watcher: watcher,
queue: make(chan string, 256), queue: make(chan string, 256),
current: cfg,
listeners: map[Client]context.CancelFunc{},
owners: map[string]string{}, owners: map[string]string{},
trees: map[string]tree{}, trees: map[string]tree{},
pending: map[string]*time.Timer{}, pending: map[string]*time.Timer{},
} }
go d.listen(ctx) d.connect(ctx)
go d.work(ctx) var workers sync.WaitGroup
defer workers.Wait()
workers.Go(func() { d.work(ctx) })
d.syncAll() d.syncAll()
ticker := time.NewTicker(fullSyncEvery) ticker := time.NewTicker(fullSyncEvery)
@@ -81,13 +89,43 @@ func (c *Client) Daemon(ctx context.Context) error {
case err := <-watcher.Errors: case err := <-watcher.Errors:
log.Printf("watcher: %v", err) log.Printf("watcher: %v", err)
case event := <-watcher.Events: case event := <-watcher.Events:
d.handle(event) d.handle(ctx, event)
} }
} }
} }
func (d *daemon) handle(event fsnotify.Event) { func (d *daemon) connect(ctx context.Context) {
if event.Name == d.client.registryPath() { current, err := d.config.reload()
if err != nil {
log.Printf("config: %v", err)
return
}
clients := current.clients()
d.mu.Lock()
defer d.mu.Unlock()
d.current = current
for c, stop := range d.listeners {
if !clients[c] {
stop()
delete(d.listeners, c)
}
}
for c := range clients {
if d.listeners[c] == nil {
listenCtx, stop := context.WithCancel(ctx)
d.listeners[c] = stop
go d.listen(listenCtx, c)
}
}
}
func (d *daemon) handle(ctx context.Context, event fsnotify.Event) {
switch event.Name {
case d.config.registryPath():
d.syncAll()
return
case d.config.path:
d.connect(ctx)
d.syncAll() d.syncAll()
return return
} }
@@ -112,8 +150,8 @@ func (t tree) relevant(root, name string) bool {
return false return false
} }
rel = filepath.ToSlash(rel) rel = filepath.ToSlash(rel)
return slices.ContainsFunc(t.rules, func(rule Rule) bool { return !t.rules.ignored(rel) && slices.ContainsFunc(t.rules, func(rule Rule) bool {
return within(rel, rule.Path) || within(rule.Path, rel) return !rule.Ignore && (within(rel, rule.Path) || within(rule.Path, rel))
}) })
} }
@@ -133,7 +171,7 @@ func (d *daemon) schedule(root string) {
} }
func (d *daemon) syncAll() { func (d *daemon) syncAll() {
for _, root := range d.client.Worktrees() { for _, root := range d.config.Worktrees() {
if _, err := os.Stat(root); errors.Is(err, fs.ErrNotExist) { if _, err := os.Stat(root); errors.Is(err, fs.ErrNotExist) {
d.forget(root) d.forget(root)
} else { } else {
@@ -143,7 +181,7 @@ func (d *daemon) syncAll() {
} }
func (d *daemon) forget(root string) { func (d *daemon) forget(root string) {
if err := d.client.Forget(root); err != nil { if err := d.config.Forget(root); err != nil {
log.Printf("%s: %v", root, err) log.Printf("%s: %v", root, err)
return return
} }
@@ -160,10 +198,13 @@ func (d *daemon) work(ctx context.Context) {
case <-ctx.Done(): case <-ctx.Done():
return return
case root := <-d.queue: case root := <-d.queue:
res, err := d.client.Sync(ctx, root, true) res, err := d.config.Sync(ctx, root, true)
if err != nil { switch {
case errors.Is(err, ErrNotLoggedIn):
continue
case err != nil:
log.Printf("%s: %v", root, err) log.Printf("%s: %v", root, err)
} else if res.Pushed+res.Pulled > 0 { case res.Pushed+res.Pulled > 0:
log.Printf("%s: pushed %d, pulled %d", root, res.Pushed, res.Pulled) log.Printf("%s: pushed %d, pulled %d", root, res.Pushed, res.Pulled)
} }
d.watch(root) d.watch(root)
@@ -178,14 +219,15 @@ func (d *daemon) watch(root string) {
} }
rules := LoadRules(repo.Root) rules := LoadRules(repo.Root)
dirs := map[string]bool{repo.GitDir: true} dirs := map[string]bool{repo.GitDir: true}
for _, rule := range rules { for _, rule := range rules.synced() {
target := filepath.Join(repo.Root, filepath.FromSlash(rule.Path)) target := filepath.Join(repo.Root, filepath.FromSlash(rule.Path))
dirs[nearestDir(filepath.Dir(target), repo.Root)] = true dirs[nearestDir(filepath.Dir(target), repo.Root)] = true
_ = 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
} }
if entry.Name() == ".git" { rel, _ := filepath.Rel(repo.Root, path)
if entry.Name() == ".git" || rules.ignored(filepath.ToSlash(rel)) {
return filepath.SkipDir return filepath.SkipDir
} }
dirs[path] = true dirs[path] = true
@@ -224,25 +266,29 @@ func nearestDir(path, root string) string {
return root return root
} }
func (d *daemon) listen(ctx context.Context) { func (d *daemon) listen(ctx context.Context, c Client) {
backoff := minBackoff backoff := minBackoff
for ctx.Err() == nil { for {
started := time.Now() started := time.Now()
err := d.stream(ctx) err := d.stream(ctx, c)
if ctx.Err() != nil {
return
}
if time.Since(started) > maxBackoff { if time.Since(started) > maxBackoff {
backoff = minBackoff backoff = minBackoff
} }
log.Printf("event stream closed, retrying in %s: %v", backoff, err) log.Printf("%s: event stream closed, retrying in %s: %v", c.URL, backoff, err)
select { select {
case <-ctx.Done(): case <-ctx.Done():
return
case <-time.After(backoff): case <-time.After(backoff):
} }
backoff = min(backoff*2, maxBackoff) backoff = min(backoff*2, maxBackoff)
} }
} }
func (d *daemon) stream(ctx context.Context) error { func (d *daemon) stream(ctx context.Context, c Client) error {
res, err := d.client.request(ctx, http.MethodGet, "/api/events", nil) res, err := c.request(ctx, http.MethodGet, "/api/events", nil)
if err != nil { if err != nil {
return err return err
} }
@@ -257,11 +303,13 @@ func (d *daemon) stream(ctx context.Context) error {
} }
d.mu.Lock() d.mu.Lock()
var roots []string var roots []string
if d.current.serverOf(event.Repo) == c.URL {
for root, tree := range d.trees { for root, tree := range d.trees {
if tree.key == event.Repo { if tree.key == event.Repo {
roots = append(roots, root) roots = append(roots, root)
} }
} }
}
d.mu.Unlock() d.mu.Unlock()
for _, root := range roots { for _, root := range roots {
d.schedule(root) d.schedule(root)
+35 -6
View File
@@ -17,7 +17,7 @@ func TestDaemonWatchesOnlyRulePaths(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
t.Cleanup(func() { watcher.Close() }) t.Cleanup(func() { watcher.Close() })
d := &daemon{client: m.client, watcher: watcher, owners: map[string]string{}, trees: map[string]tree{}} d := &daemon{config: m.config, watcher: watcher, owners: map[string]string{}, trees: map[string]tree{}}
gitDir := filepath.Join(m.root, ".git") gitDir := filepath.Join(m.root, ".git")
expectWatched := func(want ...string) { expectWatched := func(want ...string) {
t.Helper() t.Helper()
@@ -55,14 +55,43 @@ func TestDaemonWatchesOnlyRulePaths(t *testing.T) {
} }
} }
func TestDaemonForgetsVanishedWorktrees(t *testing.T) { func TestDaemonSkipsIgnoredPaths(t *testing.T) {
c := &Client{DataDir: t.TempDir()} m, _ := setup(t)
if err := writeJSONFile(c.registryPath(), []string{filepath.Join(t.TempDir(), "gone")}); err != nil { watcher, err := fsnotify.NewWatcher()
if err != nil {
t.Fatal(err) t.Fatal(err)
} }
d := &daemon{client: c, owners: map[string]string{}, trees: map[string]tree{}} t.Cleanup(func() { watcher.Close() })
d := &daemon{config: m.config, watcher: watcher, owners: map[string]string{}, trees: map[string]tree{}}
write(t, m.root, RulesFile, "shared vault\nignore vault/.obsidian/workspace*.json\nignore .DS_Store\nignore vault/.trash\n")
write(t, m.root, "vault/.obsidian/app.json", "settings")
write(t, m.root, "vault/.trash/old.md", "old")
d.watch(m.root)
if _, watched := d.owners[filepath.Join(m.root, "vault/.trash")]; watched {
t.Fatal("ignored directory is watched")
}
for name, want := range map[string]bool{
"vault/note.md": true,
"vault/.obsidian/app.json": true,
"vault/.obsidian/workspace.json": false,
"vault/.obsidian/workspace-mobile.json": false,
"vault/.DS_Store": false,
"vault/.trash/old.md": 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) {
cfg := &Config{DataDir: t.TempDir()}
if err := writeJSONFile(cfg.registryPath(), []string{filepath.Join(t.TempDir(), "gone")}); err != nil {
t.Fatal(err)
}
d := &daemon{config: cfg, owners: map[string]string{}, trees: map[string]tree{}}
d.syncAll() d.syncAll()
if roots := c.Worktrees(); len(roots) != 0 { if roots := cfg.Worktrees(); len(roots) != 0 {
t.Fatalf("registry still lists %v", roots) t.Fatalf("registry still lists %v", roots)
} }
} }
+18 -11
View File
@@ -75,11 +75,11 @@ func unitPath(home string) string {
return filepath.Join(home, ".config", "systemd", "user", systemdUnit) return filepath.Join(home, ".config", "systemd", "user", systemdUnit)
} }
func (c *Client) templateDir() string { func (cfg *Config) templateDir() string {
return filepath.Join(c.DataDir, "git-template") return filepath.Join(cfg.DataDir, "git-template")
} }
func (c *Client) Install() error { func (cfg *Config) Install() error {
binary, err := os.Executable() binary, err := os.Executable()
if err != nil { if err != nil {
return err return err
@@ -88,7 +88,7 @@ func (c *Client) Install() error {
if err != nil { if err != nil {
return err return err
} }
if err := c.installHook(binary); err != nil { if err := cfg.installHook(binary); err != nil {
return err return err
} }
for _, dir := range skillDirs { for _, dir := range skillDirs {
@@ -96,14 +96,17 @@ func (c *Client) Install() error {
return err return err
} }
} }
return startService(home, binary, filepath.Join(c.DataDir, "daemon.log")) if err := startService(home, binary, filepath.Join(cfg.DataDir, "daemon.log")); err != nil {
fmt.Fprintf(os.Stderr, "lodge: the daemon could not be started as a service: %v\nrun `lodge daemon &` to keep this machine in sync\n", err)
}
return nil
} }
func (c *Client) installHook(binary string) error { func (cfg *Config) installHook(binary string) error {
template, _ := exec.Command("git", "config", "--global", "--get", "init.templateDir").Output() template, _ := exec.Command("git", "config", "--global", "--get", "init.templateDir").Output()
dir := strings.TrimSpace(string(template)) dir := strings.TrimSpace(string(template))
if dir == "" { if dir == "" {
dir = c.templateDir() dir = cfg.templateDir()
if err := command("git", "config", "--global", "init.templateDir", dir); err != nil { if err := command("git", "config", "--global", "init.templateDir", dir); err != nil {
return err return err
} }
@@ -146,7 +149,7 @@ func restartService() error {
return command("systemctl", "--user", "restart", systemdUnit) return command("systemctl", "--user", "restart", systemdUnit)
} }
func (c *Client) Uninstall() error { func (cfg *Config) Uninstall() error {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return err return err
@@ -159,16 +162,20 @@ func (c *Client) Uninstall() error {
_ = os.Remove(unitPath(home)) _ = os.Remove(unitPath(home))
} }
template, _ := exec.Command("git", "config", "--global", "--get", "init.templateDir").Output() template, _ := exec.Command("git", "config", "--global", "--get", "init.templateDir").Output()
if strings.TrimSpace(string(template)) == c.templateDir() { if strings.TrimSpace(string(template)) == cfg.templateDir() {
_ = command("git", "config", "--global", "--unset", "init.templateDir") _ = command("git", "config", "--global", "--unset", "init.templateDir")
} }
for _, dir := range skillDirs { for _, dir := range skillDirs {
_ = os.RemoveAll(filepath.Join(home, dir)) _ = os.RemoveAll(filepath.Join(home, dir))
} }
return os.RemoveAll(c.templateDir()) return os.RemoveAll(cfg.templateDir())
} }
func (c *Client) Update(ctx context.Context) (bool, error) { func (cfg *Config) Update(ctx context.Context) (bool, error) {
c, err := cfg.client(cfg.URL)
if err != nil {
return false, err
}
binary, err := os.Executable() binary, err := os.Executable()
if err != nil { if err != nil {
return false, err return false, err
+227
View File
@@ -0,0 +1,227 @@
package client
import (
"context"
"errors"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"lodge/internal/gitrepo"
"lodge/internal/protocol"
)
func issueCode(t *testing.T, url string) string {
t.Helper()
var code protocol.CodeResponse
admin := &Client{URL: url, Token: adminToken}
if err := admin.call(context.Background(), http.MethodPost, "/api/codes", nil, &code); err != nil {
t.Fatal(err)
}
return code.Code
}
func (m *machine) remote(url, code string) {
m.t.Helper()
ctx := context.Background()
if code != "" {
if err := m.config.Connect(ctx, url, code); err != nil {
m.t.Fatal(err)
}
}
if err := m.config.Bind(repoKey(m.t, m), url); err != nil {
m.t.Fatal(err)
}
m.sync()
}
func revisionsOn(t *testing.T, url, key string) []protocol.Revision {
t.Helper()
revisions, err := (&Client{URL: url, Token: adminToken}).Revisions(context.Background(), key, "")
if err != nil {
t.Fatal(err)
}
return revisions
}
func TestBoundRepositoryNeverReachesTheDefaultServer(t *testing.T) {
personal, event, origin := newServer(t), newServer(t), newOrigin(t)
mac := newMachine(t, origin, personal, adminToken)
key := repoKey(t, mac)
ctx := context.Background()
if err := mac.config.Hook(ctx, mac.root); err != nil {
t.Fatal(err)
}
stale, err := mac.config.reload()
if err != nil {
t.Fatal(err)
}
mac.remote(event+"/", issueCode(t, event))
write(t, mac.root, "docs/agents/plan.md", "event plan")
if _, err := stale.Sync(ctx, mac.root, true); err != nil {
t.Fatal(err)
}
worktree := filepath.Join(t.TempDir(), "feature")
git(t, mac.root, "worktree", "add", "-b", "feature", worktree)
worktree, _ = filepath.EvalSymlinks(worktree)
if err := mac.config.Hook(ctx, worktree); err != nil {
t.Fatal(err)
}
if data, _ := os.ReadFile(filepath.Join(worktree, "docs/agents/plan.md")); string(data) != "event plan" {
t.Fatalf("new worktree of a bound repository got %q", data)
}
other := newMachine(t, newOrigin(t), personal, adminToken)
other.config = mac.config
write(t, other.root, "docs/agents/private.md", "personal")
other.sync()
if revisions := revisionsOn(t, personal, key); len(revisions) != 0 {
t.Fatalf("the default server received %d revisions of a bound repository", len(revisions))
}
if revisions := revisionsOn(t, event, key); len(revisions) != 1 {
t.Fatalf("the event server has %d revisions, want 1", len(revisions))
}
if revisions := revisionsOn(t, event, repoKey(t, other)); len(revisions) != 0 {
t.Fatal("an unbound repository reached the event server")
}
if revisions := revisionsOn(t, personal, repoKey(t, other)); len(revisions) != 1 {
t.Fatal("an unbound repository did not reach the default server")
}
if c, err := mac.config.Server(key); err != nil || c.URL != event {
t.Fatalf("server of the bound repository = %v (%v), want %s", c, err, event)
}
}
func TestMachineWithoutDefaultServer(t *testing.T) {
event, origin := newServer(t), newOrigin(t)
mac := newMachine(t, origin, event, adminToken)
write(t, mac.root, "docs/agents/plan.md", "plan")
mac.sync()
fresh := newMachine(t, origin, "", "")
if _, err := fresh.config.Sync(context.Background(), fresh.root, true); !errors.Is(err, ErrNotLoggedIn) {
t.Fatalf("sync without any server: %v", err)
}
if roots := fresh.config.Worktrees(); len(roots) != 0 {
t.Fatalf("a repository without a server was registered: %v", roots)
}
if err := fresh.config.Connect(context.Background(), event, issueCode(t, event)); err != nil {
t.Fatal(err)
}
fresh.remote(event, "")
fresh.expect("docs/agents/plan.md", "plan")
if fresh.config.URL != "" {
t.Fatalf("lodge remote set the default server to %q", fresh.config.URL)
}
}
func TestMovingBetweenServersKeepsLocalFiles(t *testing.T) {
personal, event, origin := newServer(t), newServer(t), newOrigin(t)
mac := newMachine(t, origin, personal, adminToken)
dell := newMachine(t, origin, "", "")
key := repoKey(t, mac)
write(t, mac.root, "docs/agents/mine.md", "mine")
mac.sync()
dell.remote(event, issueCode(t, event))
write(t, dell.root, "docs/agents/theirs.md", "theirs")
dell.sync()
mac.remote(event, issueCode(t, event))
mac.expect("docs/agents/mine.md", "mine")
mac.expect("docs/agents/theirs.md", "theirs")
dell.sync()
dell.expect("docs/agents/mine.md", "mine")
if err := mac.config.Bind(key, ""); err != nil {
t.Fatal(err)
}
mac.sync()
mac.expect("docs/agents/mine.md", "mine")
mac.expect("docs/agents/theirs.md", "theirs")
state, err := mac.client.State(context.Background(), key, "")
if err != nil || len(state.Spaces[protocol.SharedSpace]) != 2 {
t.Fatalf("back on the default server: %v (%v)", state.Spaces, err)
}
}
func TestStateFromBeforeServersIsAdopted(t *testing.T) {
mac, dell := setup(t)
write(t, mac.root, "docs/agents/plan.md", "plan")
mac.sync()
dell.sync()
repo := openRepo(t, mac)
if err := os.Rename(mac.client.statePath(repo), worktreePath(mac.config.DataDir, repo)+".json"); err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join(dell.root, "docs/agents/plan.md")); err != nil {
t.Fatal(err)
}
dell.sync()
mac.sync()
mac.expect("docs/agents/plan.md", "")
}
func TestDaemonFollowsServersAddedLater(t *testing.T) {
event, origin := newServer(t), newOrigin(t)
mac := newMachine(t, origin, event, adminToken)
fresh := newMachine(t, origin, "", "")
daemonConfig, err := fresh.config.reload()
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error)
go func() { done <- daemonConfig.Daemon(ctx) }()
t.Cleanup(func() {
cancel()
if err := <-done; err != nil {
t.Error(err)
}
})
fresh.remote(event, issueCode(t, event))
write(t, mac.root, "docs/agents/first.md", "first")
mac.sync()
fresh.waitFor("docs/agents/first.md", "first")
write(t, mac.root, "docs/agents/second.md", "second")
mac.sync()
fresh.waitFor("docs/agents/second.md", "second")
var machines []protocol.Machine
if err := (&Client{URL: event, Token: adminToken}).call(context.Background(), http.MethodGet, "/api/machines", nil, &machines); err != nil {
t.Fatal(err)
}
if len(machines) != 1 || !machines[0].Online {
t.Fatalf("the daemon is not online on the server it was bound to: %+v", machines)
}
}
func (m *machine) waitFor(path, content string) {
m.t.Helper()
deadline := time.Now().Add(15 * time.Second)
for {
if data, _ := os.ReadFile(filepath.Join(m.root, path)); string(data) == content {
return
}
if time.Now().After(deadline) {
m.t.Fatalf("the daemon did not bring %s", path)
}
time.Sleep(50 * time.Millisecond)
}
}
func openRepo(t *testing.T, m *machine) *gitrepo.Repo {
t.Helper()
repo, err := gitrepo.Open(m.root)
if err != nil {
t.Fatal(err)
}
return repo
}
+31 -6
View File
@@ -5,6 +5,7 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"lodge/internal/gitrepo" "lodge/internal/gitrepo"
@@ -15,6 +16,7 @@ const (
RulesFile = ".lodge" RulesFile = ".lodge"
sharedKeyword = "shared" sharedKeyword = "shared"
branchKeyword = "branch" branchKeyword = "branch"
ignoreKeyword = "ignore"
excludeBegin = "# lodge begin" excludeBegin = "# lodge begin"
excludeEnd = "# lodge end" excludeEnd = "# lodge end"
DefaultRules = "shared docs/agents\nshared CLAUDE.local.md\nbranch .agent\n" DefaultRules = "shared docs/agents\nshared CLAUDE.local.md\nbranch .agent\n"
@@ -23,6 +25,7 @@ const (
type Rule struct { type Rule struct {
Path string `json:"path"` Path string `json:"path"`
Shared bool `json:"shared"` Shared bool `json:"shared"`
Ignore bool `json:"ignore,omitempty"`
} }
type Rules []Rule type Rules []Rule
@@ -31,12 +34,12 @@ func parseRules(text string) Rules {
rules := Rules{{Path: RulesFile, Shared: true}} rules := Rules{{Path: RulesFile, Shared: true}}
for _, line := range strings.Split(text, "\n") { for _, line := range strings.Split(text, "\n") {
fields := strings.Fields(line) fields := strings.Fields(line)
if len(fields) != 2 || fields[0] != sharedKeyword && fields[0] != branchKeyword { if len(fields) != 2 || !slices.Contains([]string{sharedKeyword, branchKeyword, ignoreKeyword}, fields[0]) {
continue continue
} }
rulePath := path.Clean(strings.Trim(filepath.ToSlash(fields[1]), "/")) rulePath := path.Clean(strings.Trim(filepath.ToSlash(fields[1]), "/"))
if protocol.SafePath(rulePath) { if protocol.SafePath(rulePath) {
rules = append(rules, Rule{Path: rulePath, Shared: fields[0] == sharedKeyword}) rules = append(rules, Rule{Path: rulePath, Shared: fields[0] == sharedKeyword, Ignore: fields[0] == ignoreKeyword})
} }
} }
return rules return rules
@@ -50,9 +53,14 @@ func LoadRules(root string) Rules {
return parseRules(string(data)) return parseRules(string(data))
} }
func (rules Rules) synced() Rules {
return slices.DeleteFunc(slices.Clone(rules), func(rule Rule) bool { return rule.Ignore })
}
func (rules Rules) paths() []string { func (rules Rules) paths() []string {
paths := make([]string, len(rules)) synced := rules.synced()
for i, rule := range rules { paths := make([]string, len(synced))
for i, rule := range synced {
paths[i] = rule.Path paths[i] = rule.Path
} }
return paths return paths
@@ -60,12 +68,29 @@ 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.synced() {
if within(file, rule.Path) && len(rule.Path) > len(best.Path) { if within(file, rule.Path) && len(rule.Path) > len(best.Path) {
best = rule best = rule
} }
} }
return best.Path != "" && best.Shared == shared return best.Path != "" && best.Shared == shared && !rules.ignored(file)
}
func (rules Rules) ignored(file string) bool {
return file != RulesFile && slices.ContainsFunc(rules, func(rule Rule) bool { return rule.Ignore && rule.matches(file) })
}
func (rule Rule) matches(file string) bool {
for name := file; name != "." && name != "/"; name = path.Dir(name) {
target := name
if !strings.Contains(rule.Path, "/") {
target = path.Base(name)
}
if matched, _ := path.Match(rule.Path, target); matched {
return true
}
}
return false
} }
func within(path, dir string) bool { func within(path, dir string) bool {
+2 -1
View File
@@ -15,7 +15,7 @@ Run `lodge status` inside the repository to see the rules in effect. The default
- `.agent/` holds what belongs to the current branch: the plan for the feature, what was tried, why something was reverted. It is swapped on `git checkout` together with the branch. - `.agent/` holds what belongs to the current branch: the plan for the feature, what was tried, why something was reverted. It is swapped on `git checkout` together with the branch.
- `CLAUDE.local.md` is shared across branches. - `CLAUDE.local.md` is shared across branches.
The rules live in `.lodge` at the repository root, one per line, as `shared <path>` or `branch <path>`. Paths are relative to the root, no globs. The file itself is synced too. The rules live in `.lodge` at the repository root, one per line, as `shared <path>`, `branch <path>` or `ignore <pattern>`. Paths are relative to the root. `ignore` keeps matching files local to each machine, for editor state and similar noise: the pattern is a glob over the whole path, or over any single name when it has no `/`, as in `ignore vault/.obsidian/workspace*.json` or `ignore .DS_Store`. The file itself is synced too.
## Rules of conduct ## Rules of conduct
@@ -23,6 +23,7 @@ The rules live in `.lodge` at the repository root, one per line, as `shared <pat
- Files tracked by git are never touched by lodge, even under a synced path. - Files tracked by git are never touched by lodge, even under a synced path.
- Files over 5 MB are not synced; `lodge status` lists them. - Files over 5 MB are not synced; `lodge status` lists them.
- A deleted file is deleted on every machine. Its history stays on the server. - A deleted file is deleted on every machine. Its history stays on the server.
- A repository may sync with another server than the rest of the machine; `lodge status` and `lodge remote` show which. Never run `lodge login`, `lodge remote` or `lodge update` unless asked: they change where files go.
- Nothing needs to be run after writing a file: the daemon picks it up within seconds. Without a daemon (CI, cloud sandbox) run `lodge sync` at the start and at the end of the work. - Nothing needs to be run after writing a file: the daemon picks it up within seconds. Without a daemon (CI, cloud sandbox) run `lodge sync` at the start and at the end of the work.
## Reading history ## Reading history
+39 -19
View File
@@ -42,8 +42,12 @@ type diskFiles struct {
tracked map[string]bool tracked map[string]bool
} }
func worktreePath(dataDir string, repo *gitrepo.Repo) string {
return filepath.Join(dataDir, "repos", repo.Key, hashOf([]byte(repo.Root))[:16])
}
func (c *Client) statePath(repo *gitrepo.Repo) string { func (c *Client) statePath(repo *gitrepo.Repo) string {
return filepath.Join(c.DataDir, "repos", repo.Key, hashOf([]byte(repo.Root))[:16]+".json") return worktreePath(c.DataDir, repo) + "-" + hashOf([]byte(c.URL))[:16] + ".json"
} }
func (c *Client) loadState(repo *gitrepo.Repo) *worktreeState { func (c *Client) loadState(repo *gitrepo.Repo) *worktreeState {
@@ -60,8 +64,7 @@ func (c *Client) loadState(repo *gitrepo.Repo) *worktreeState {
return st return st
} }
func (c *Client) lock(repo *gitrepo.Repo) (func(), error) { func flock(path string) (func(), error) {
path := c.statePath(repo) + ".lock"
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err return nil, err
} }
@@ -76,31 +79,46 @@ func (c *Client) lock(repo *gitrepo.Repo) (func(), error) {
return func() { file.Close() }, nil return func() { file.Close() }, nil
} }
func (c *Client) Hook(ctx context.Context, dir string) error { func (cfg *Config) Hook(ctx context.Context, dir string) error {
repo, err := gitrepo.Open(dir) repo, err := gitrepo.Open(dir)
if err != nil { if err != nil {
return err return err
} }
firstSight := !slices.Contains(c.Worktrees(), repo.Root) firstSight := !slices.Contains(cfg.Worktrees(), repo.Root)
_, err = c.Sync(ctx, repo.Root, firstSight) _, err = cfg.Sync(ctx, repo.Root, firstSight)
return err return err
} }
func (c *Client) Sync(ctx context.Context, dir string, online bool) (Result, error) { func (cfg *Config) Sync(ctx context.Context, dir string, online bool) (Result, error) {
var res Result
repo, err := gitrepo.Open(dir) repo, err := gitrepo.Open(dir)
if err != nil { if err != nil {
return res, err return Result{}, err
} }
if err := c.register(repo.Root); err != nil { worktree := worktreePath(cfg.DataDir, repo)
return res, err unlock, err := flock(worktree + ".lock")
}
unlock, err := c.lock(repo)
if err != nil { if err != nil {
return res, err return Result{}, err
} }
defer unlock() defer unlock()
current, err := cfg.reload()
if err != nil {
return Result{}, err
}
c, err := current.Server(repo.Key)
if err != nil {
return Result{}, err
}
if err := cfg.register(repo.Root); err != nil {
return Result{}, err
}
if c.URL == current.URL {
_ = os.Rename(worktree+".json", c.statePath(repo))
}
return c.sync(ctx, repo, online)
}
func (c *Client) sync(ctx context.Context, repo *gitrepo.Repo, online bool) (Result, error) {
var res Result
st := c.loadState(repo) st := c.loadState(repo)
for range maxPasses { for range maxPasses {
again, err := c.pass(ctx, repo, st, online, &res) again, err := c.pass(ctx, repo, st, online, &res)
@@ -114,20 +132,20 @@ func (c *Client) Sync(ctx context.Context, dir string, online bool) (Result, err
func (c *Client) scan(repo *gitrepo.Repo, rules Rules) diskFiles { func (c *Client) scan(repo *gitrepo.Repo, rules Rules) diskFiles {
found := diskFiles{shared: protocol.Manifest{}, branch: protocol.Manifest{}, tracked: repo.Tracked(rules.paths())} found := diskFiles{shared: protocol.Manifest{}, branch: protocol.Manifest{}, tracked: repo.Tracked(rules.paths())}
for _, rule := range rules { for _, rule := range rules.synced() {
start := filepath.Join(repo.Root, filepath.FromSlash(rule.Path)) start := filepath.Join(repo.Root, filepath.FromSlash(rule.Path))
_ = filepath.WalkDir(start, func(full string, entry fs.DirEntry, err error) error { _ = filepath.WalkDir(start, func(full string, entry fs.DirEntry, err error) error {
if err != nil { if err != nil {
return nil return nil
} }
if entry.IsDir() && entry.Name() == ".git" { rel, _ := filepath.Rel(repo.Root, full)
rel = filepath.ToSlash(rel)
if entry.IsDir() && (entry.Name() == ".git" || rules.ignored(rel)) {
return filepath.SkipDir return filepath.SkipDir
} }
if !entry.Type().IsRegular() || strings.HasSuffix(full, tempSuffix) { if !entry.Type().IsRegular() || strings.HasSuffix(full, tempSuffix) {
return nil return nil
} }
rel, _ := filepath.Rel(repo.Root, full)
rel = filepath.ToSlash(rel)
if found.tracked[rel] || !rules.covers(rel, rule.Shared) { if found.tracked[rel] || !rules.covers(rel, rule.Shared) {
return nil return nil
} }
@@ -257,7 +275,9 @@ func (c *Client) pass(ctx context.Context, repo *gitrepo.Repo, st *worktreeState
} }
continue continue
} }
relevant := protocol.SafePath relevant := func(path string) bool {
return protocol.SafePath(path) && !rules.ignored(path)
}
if active[space] != nil { if active[space] != nil {
relevant = func(path string) bool { relevant = func(path string) bool {
return protocol.SafePath(path) && rules.covers(path, space == protocol.SharedSpace) && !found.tracked[path] return protocol.SafePath(path) && rules.covers(path, space == protocol.SharedSpace) && !found.tracked[path]
+94 -8
View File
@@ -19,6 +19,7 @@ const adminToken = "admin"
type machine struct { type machine struct {
t *testing.T t *testing.T
config *Config
client *Client client *Client
root string root string
} }
@@ -34,7 +35,7 @@ func git(t *testing.T, dir string, args ...string) string {
return string(out) return string(out)
} }
func setup(t *testing.T) (*machine, *machine) { func newServer(t *testing.T) string {
t.Helper() t.Helper()
store, err := server.OpenStore(filepath.Join(t.TempDir(), "lodge.db")) store, err := server.OpenStore(filepath.Join(t.TempDir(), "lodge.db"))
if err != nil { if err != nil {
@@ -43,22 +44,41 @@ func setup(t *testing.T) (*machine, *machine) {
t.Cleanup(func() { store.Close() }) t.Cleanup(func() { store.Close() })
api := httptest.NewServer(server.New(store, server.Config{AdminToken: adminToken})) api := httptest.NewServer(server.New(store, server.Config{AdminToken: adminToken}))
t.Cleanup(api.Close) t.Cleanup(api.Close)
return api.URL
}
func newOrigin(t *testing.T) string {
t.Helper()
origin := t.TempDir() origin := t.TempDir()
git(t, origin, "init", "-b", "main") git(t, origin, "init", "-b", "main")
write(t, origin, "README.md", "readme") write(t, origin, "README.md", "readme")
write(t, origin, "docs/agents/tracked.md", "in git") write(t, origin, "docs/agents/tracked.md", "in git")
git(t, origin, "add", ".") git(t, origin, "add", ".")
git(t, origin, "commit", "-m", "root") git(t, origin, "commit", "-m", "root "+origin)
return origin
}
machines := [2]*machine{} func newMachine(t *testing.T, origin, url, token string) *machine {
for i := range machines { t.Helper()
cfg := &Config{URL: url, Token: token, DataDir: t.TempDir(), path: filepath.Join(t.TempDir(), "config.json")}
if err := writeJSONFile(cfg.path, cfg); err != nil {
t.Fatal(err)
}
return &machine{t: t, config: cfg, client: &Client{URL: url, Token: token, DataDir: cfg.DataDir}, root: clone(t, origin)}
}
func clone(t *testing.T, origin string) string {
t.Helper()
root := filepath.Join(t.TempDir(), "clone") root := filepath.Join(t.TempDir(), "clone")
git(t, origin, "clone", origin, root) git(t, origin, "clone", origin, root)
resolved, _ := filepath.EvalSymlinks(root) resolved, _ := filepath.EvalSymlinks(root)
machines[i] = &machine{t: t, root: resolved, client: &Client{URL: api.URL, Token: adminToken, DataDir: t.TempDir()}} return resolved
} }
return machines[0], machines[1]
func setup(t *testing.T) (*machine, *machine) {
t.Helper()
url, origin := newServer(t), newOrigin(t)
return newMachine(t, origin, url, adminToken), newMachine(t, origin, url, adminToken)
} }
func write(t *testing.T, root, path, content string) { func write(t *testing.T, root, path, content string) {
@@ -70,7 +90,7 @@ func write(t *testing.T, root, path, content string) {
func (m *machine) sync() Result { func (m *machine) sync() Result {
m.t.Helper() m.t.Helper()
res, err := m.client.Sync(context.Background(), m.root, true) res, err := m.config.Sync(context.Background(), m.root, true)
if err != nil { if err != nil {
m.t.Fatal(err) m.t.Fatal(err)
} }
@@ -130,7 +150,7 @@ func TestBranchNotesFollowTheBranchAndSharedStay(t *testing.T) {
mac.sync() mac.sync()
git(t, mac.root, "checkout", "main") git(t, mac.root, "checkout", "main")
if _, err := mac.client.Sync(context.Background(), mac.root, false); err != nil { if _, err := mac.config.Sync(context.Background(), mac.root, false); err != nil {
t.Fatal(err) t.Fatal(err)
} }
mac.expect(".agent/plan.md", "main plan") mac.expect(".agent/plan.md", "main plan")
@@ -207,6 +227,72 @@ func TestRulesFileTravelsAndHostileServerPathsAreIgnored(t *testing.T) {
} }
} }
func TestIgnoredFilesNeverTravel(t *testing.T) {
mac, dell := setup(t)
rules := "shared vault\nignore vault/.obsidian/workspace*.json\nignore .DS_Store\nignore vault/cache\n"
write(t, mac.root, RulesFile, rules)
write(t, mac.root, "vault/note.md", "note")
write(t, mac.root, "vault/.obsidian/app.json", "settings")
write(t, mac.root, "vault/.obsidian/workspace.json", "mac layout")
write(t, mac.root, "vault/.obsidian/workspace-mobile.json", "mac mobile")
write(t, mac.root, "vault/deep/.DS_Store", "finder")
write(t, mac.root, "vault/cache/index.bin", "cache")
if res := mac.sync(); res.Pushed != 3 {
t.Fatalf("pushed %d files, want the rules, the note and app.json", res.Pushed)
}
write(t, dell.root, "vault/.obsidian/workspace.json", "dell layout")
dell.sync()
dell.expect("vault/note.md", "note")
dell.expect("vault/.obsidian/app.json", "settings")
dell.expect("vault/.obsidian/workspace.json", "dell layout")
dell.expect("vault/.obsidian/workspace-mobile.json", "")
dell.expect("vault/deep/.DS_Store", "")
dell.expect("vault/cache/index.bin", "")
hash, _ := mac.client.cacheBlob([]byte("pushed by an old client"))
if err := mac.client.uploadBlob(context.Background(), hash); err != nil {
t.Fatal(err)
}
push := protocol.Push{Changes: []protocol.Change{{Space: protocol.SharedSpace, Path: "vault/.obsidian/workspace.json", Hash: hash}}}
if err := mac.client.call(context.Background(), http.MethodPost, "/api/repos/"+repoKey(t, mac)+"/revisions", push, nil); err != nil {
t.Fatal(err)
}
mac.sync()
dell.sync()
mac.expect("vault/.obsidian/workspace.json", "mac layout")
dell.expect("vault/.obsidian/workspace.json", "dell layout")
exclude, _ := os.ReadFile(filepath.Join(dell.root, ".git/info/exclude"))
if strings.Contains(string(exclude), "workspace") || strings.Contains(string(exclude), "DS_Store") {
t.Fatalf("ignore rules leaked into exclude:\n%s", exclude)
}
}
func TestIgnoreRuleMatching(t *testing.T) {
rules := parseRules("shared vault\nbranch .agent\nignore vault/.obsidian/workspace*.json\nignore .DS_Store\nignore *.tmp\nignore .lodge\n")
for file, want := range map[string]bool{
"vault/note.md": true,
"vault/.obsidian/workspace.json": false,
"vault/.obsidian/workspace-mobile.json": false,
"vault/.obsidian/workspaces.json": false,
"vault/.obsidian/app.json": true,
"vault/sub/.obsidian/workspace.json": true,
"vault/.DS_Store": false,
"vault/a/b/.DS_Store": false,
"vault/.DS_Store/inside": false,
"vault/draft.tmp": false,
RulesFile: true,
} {
if got := rules.covers(file, true); got != want {
t.Errorf("covers(%s) = %v, want %v", file, got, want)
}
}
if rules.covers(".agent/.DS_Store", false) || !rules.covers(".agent/plan.md", false) {
t.Error("ignore rules must apply to branch paths too")
}
}
func TestDeletingRepositoryOnServerKeepsFilesOnMachines(t *testing.T) { func TestDeletingRepositoryOnServerKeepsFilesOnMachines(t *testing.T) {
mac, _ := setup(t) mac, _ := setup(t)
write(t, mac.root, "docs/agents/plan.md", "v1") write(t, mac.root, "docs/agents/plan.md", "v1")
+6
View File
@@ -32,6 +32,7 @@ type Change struct {
type Push struct { type Push struct {
Name string `json:"name"` Name string `json:"name"`
Author string `json:"author,omitempty"`
MainSHA string `json:"main_sha"` MainSHA string `json:"main_sha"`
MainBranch string `json:"main_branch"` MainBranch string `json:"main_branch"`
Changes []Change `json:"changes"` Changes []Change `json:"changes"`
@@ -40,6 +41,7 @@ type Push struct {
type Revision struct { type Revision struct {
Seq int64 `json:"seq"` Seq int64 `json:"seq"`
Machine string `json:"machine"` Machine string `json:"machine"`
Author string `json:"author"`
Time int64 `json:"time"` Time int64 `json:"time"`
MainSHA string `json:"main_sha"` MainSHA string `json:"main_sha"`
MainBranch string `json:"main_branch"` MainBranch string `json:"main_branch"`
@@ -58,6 +60,8 @@ type Machine struct {
Name string `json:"name"` Name string `json:"name"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
LastPush int64 `json:"last_push"` LastPush int64 `json:"last_push"`
LastSeen int64 `json:"last_seen"`
Online bool `json:"online"`
Revisions int64 `json:"revisions"` Revisions int64 `json:"revisions"`
} }
@@ -68,6 +72,7 @@ type Login struct {
type Event struct { type Event struct {
Repo string `json:"repo"` Repo string `json:"repo"`
Seq int64 `json:"seq"` Seq int64 `json:"seq"`
Author string `json:"author,omitempty"`
} }
type ClaimRequest struct { type ClaimRequest struct {
@@ -82,6 +87,7 @@ type ClaimResponse struct {
type CodeResponse struct { type CodeResponse struct {
Code string `json:"code"` Code string `json:"code"`
Command string `json:"command"` Command string `json:"command"`
RemoteCommand string `json:"remote_command"`
ExpiresAt int64 `json:"expires_at"` ExpiresAt int64 `json:"expires_at"`
} }
+20 -2
View File
@@ -1,6 +1,10 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
url="{{URL}}"
code="{{CODE}}"
mode="{{MODE}}"
os=$(uname -s | tr '[:upper:]' '[:lower:]') os=$(uname -s | tr '[:upper:]' '[:lower:]')
arch=$(uname -m) arch=$(uname -m)
case "$arch" in case "$arch" in
@@ -11,12 +15,26 @@ esac
bin="$HOME/.local/bin" bin="$HOME/.local/bin"
mkdir -p "$bin" mkdir -p "$bin"
curl -fsSL "{{URL}}/dl/lodge-$os-$arch" -o "$bin/lodge.new" curl -fsSL "$url/dl/lodge-$os-$arch" -o "$bin/lodge.new"
chmod +x "$bin/lodge.new" chmod +x "$bin/lodge.new"
mv "$bin/lodge.new" "$bin/lodge" mv "$bin/lodge.new" "$bin/lodge"
"$bin/lodge" login "{{URL}}" "{{CODE}}" if [ "$mode" = login ]; then
"$bin/lodge" login "$url" "$code"
"$bin/lodge" install "$bin/lodge" install
else
plist="$HOME/Library/LaunchAgents/com.kotikot.lodge.plist"
unit="$HOME/.config/systemd/user/lodge.service"
restarted=yes
if [ -f "$plist" ]; then
launchctl kickstart -k "gui/$(id -u)/com.kotikot.lodge" || restarted=no
elif [ -f "$unit" ]; then
systemctl --user restart lodge.service || restarted=no
fi
[ "$restarted" = yes ] || echo "lodge: restart the running daemon so it runs the new binary" >&2
(cd / && "$bin/lodge" remote "$url" "$code")
[ -f "$plist" ] || [ -f "$unit" ] || "$bin/lodge" install
fi
case ":$PATH:" in case ":$PATH:" in
*":$bin:"*) ;; *":$bin:"*) ;;
+44 -7
View File
@@ -50,14 +50,17 @@ type Server struct {
mu sync.Mutex mu sync.Mutex
subscribers map[chan protocol.Event]struct{} subscribers map[chan protocol.Event]struct{}
streams map[int64]int
seen map[int64]int64
} }
type machineKey struct{} type machineKey struct{}
func New(store *Store, config Config) http.Handler { func New(store *Store, config Config) http.Handler {
s := &Server{config: config, store: store, subscribers: map[chan protocol.Event]struct{}{}} s := &Server{config: config, store: store, subscribers: map[chan protocol.Event]struct{}{}, streams: map[int64]int{}, seen: map[int64]int64{}}
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /i/{code}", s.install) mux.HandleFunc("GET /i/{code}", s.install("login"))
mux.HandleFunc("GET /i/{code}/remote", s.install("remote"))
mux.Handle("GET /dl/", http.StripPrefix("/dl/", http.FileServer(http.Dir(config.ClientsDir)))) mux.Handle("GET /dl/", http.StripPrefix("/dl/", http.FileServer(http.Dir(config.ClientsDir))))
mux.HandleFunc("POST /api/claim", s.claim) mux.HandleFunc("POST /api/claim", s.claim)
mux.HandleFunc("POST /api/session", s.login) mux.HandleFunc("POST /api/session", s.login)
@@ -136,6 +139,7 @@ func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
return return
} }
machineID = id machineID = id
s.track(id, 0)
} }
if key := r.PathValue("key"); key != "" && !repoKeyPattern.MatchString(key) { if key := r.PathValue("key"); key != "" && !repoKeyPattern.MatchString(key) {
http.Error(w, "bad repository key", http.StatusBadRequest) http.Error(w, "bad repository key", http.StatusBadRequest)
@@ -156,14 +160,16 @@ func (s *Server) publicURL(r *http.Request) string {
return scheme + "://" + r.Host return scheme + "://" + r.Host
} }
func (s *Server) install(w http.ResponseWriter, r *http.Request) { func (s *Server) install(mode string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code") code := r.PathValue("code")
if !codePattern.MatchString(code) || !s.store.CodeValid(r.Context(), code) { if !codePattern.MatchString(code) || !s.store.CodeValid(r.Context(), code) {
http.Error(w, "echo 'lodge: this install link has expired, create a new one' >&2; exit 1", http.StatusNotFound) http.Error(w, "echo 'lodge: this install link has expired, create a new one' >&2; exit 1", http.StatusNotFound)
return return
} }
w.Header().Set("Content-Type", "text/x-shellscript") w.Header().Set("Content-Type", "text/x-shellscript")
_, _ = strings.NewReplacer("{{URL}}", s.publicURL(r), "{{CODE}}", code).WriteString(w, installScript) _, _ = strings.NewReplacer("{{URL}}", s.publicURL(r), "{{CODE}}", code, "{{MODE}}", mode).WriteString(w, installScript)
}
} }
func (s *Server) setSession(w http.ResponseWriter, r *http.Request, token string, expires time.Time) { func (s *Server) setSession(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
@@ -206,12 +212,25 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
func (s *Server) track(machineID int64, streams int) {
s.mu.Lock()
defer s.mu.Unlock()
s.seen[machineID] = time.Now().UnixMilli()
s.streams[machineID] += streams
}
func (s *Server) machines(w http.ResponseWriter, r *http.Request) { func (s *Server) machines(w http.ResponseWriter, r *http.Request) {
machines, err := s.store.Machines(r.Context()) machines, err := s.store.Machines(r.Context())
if err != nil { if err != nil {
writeError(w, err) writeError(w, err)
return return
} }
s.mu.Lock()
for i, machine := range machines {
machines[i].Online = s.streams[machine.ID] > 0
machines[i].LastSeen = max(s.seen[machine.ID], machine.LastPush)
}
s.mu.Unlock()
writeJSON(w, http.StatusOK, machines) writeJSON(w, http.StatusOK, machines)
} }
@@ -250,6 +269,7 @@ func (s *Server) createCode(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, protocol.CodeResponse{ writeJSON(w, http.StatusCreated, protocol.CodeResponse{
Code: code, Code: code,
Command: fmt.Sprintf("curl -fsSL %s/i/%s | sh", s.publicURL(r), code), Command: fmt.Sprintf("curl -fsSL %s/i/%s | sh", s.publicURL(r), code),
RemoteCommand: fmt.Sprintf("curl -fsSL %s/i/%s/remote | sh", s.publicURL(r), code),
ExpiresAt: expires.UnixMilli(), ExpiresAt: expires.UnixMilli(),
}) })
} }
@@ -287,7 +307,19 @@ func (s *Server) state(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) revisions(w http.ResponseWriter, r *http.Request) { func (s *Server) revisions(w http.ResponseWriter, r *http.Request) {
revisions, err := s.store.Revisions(r.Context(), r.PathValue("key"), r.URL.Query().Get("path")) key, query := r.PathValue("key"), r.URL.Query()
var revisions []protocol.Revision
var err error
if query.Has("after") {
after, parseErr := strconv.ParseInt(query.Get("after"), 10, 64)
if parseErr != nil {
http.Error(w, "bad after", http.StatusBadRequest)
return
}
revisions, err = s.store.RevisionsAfter(r.Context(), key, query.Get("path"), after)
} else {
revisions, err = s.store.Revisions(r.Context(), key, query.Get("path"))
}
if err != nil { if err != nil {
writeError(w, err) writeError(w, err)
return return
@@ -319,8 +351,9 @@ func (s *Server) push(w http.ResponseWriter, r *http.Request) {
writeError(w, err) writeError(w, err)
return return
} }
s.publish(protocol.Event{Repo: key, Seq: seq}) event := protocol.Event{Repo: key, Seq: seq, Author: push.Author}
writeJSON(w, http.StatusCreated, protocol.Event{Repo: key, Seq: seq}) s.publish(event)
writeJSON(w, http.StatusCreated, event)
} }
func (s *Server) blob(w http.ResponseWriter, r *http.Request) { func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
@@ -370,14 +403,17 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
http.Error(w, "streaming unsupported", http.StatusInternalServerError) http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return return
} }
machineID, _ := r.Context().Value(machineKey{}).(int64)
subscriber := make(chan protocol.Event, 16) subscriber := make(chan protocol.Event, 16)
s.mu.Lock() s.mu.Lock()
s.subscribers[subscriber] = struct{}{} s.subscribers[subscriber] = struct{}{}
s.mu.Unlock() s.mu.Unlock()
s.track(machineID, 1)
defer func() { defer func() {
s.mu.Lock() s.mu.Lock()
delete(s.subscribers, subscriber) delete(s.subscribers, subscriber)
s.mu.Unlock() s.mu.Unlock()
s.track(machineID, -1)
}() }()
w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Content-Type", "text/event-stream")
@@ -391,6 +427,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
case <-r.Context().Done(): case <-r.Context().Done():
return return
case <-ticker.C: case <-ticker.C:
s.track(machineID, 0)
fmt.Fprint(w, ": ping\n\n") fmt.Fprint(w, ": ping\n\n")
case event := <-subscriber: case event := <-subscriber:
data, _ := json.Marshal(event) data, _ := json.Marshal(event)
+241
View File
@@ -0,0 +1,241 @@
package server
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"lodge/internal/protocol"
)
const (
adminToken = "admin"
repoKey = "0123456789abcdef0123456789abcdef01234567"
)
func newServer(t *testing.T) string {
t.Helper()
store, err := OpenStore(filepath.Join(t.TempDir(), "lodge.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
api := httptest.NewServer(New(store, Config{AdminToken: adminToken}))
t.Cleanup(api.Close)
return api.URL
}
func call(t *testing.T, method, url, token string, in, out any) {
t.Helper()
var body bytes.Buffer
if in != nil {
_ = json.NewEncoder(&body).Encode(in)
}
req, _ := http.NewRequest(method, url, &body)
req.Header.Set("Authorization", "Bearer "+token)
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode >= http.StatusBadRequest {
t.Fatalf("%s %s: %s", method, url, res.Status)
}
if out != nil {
if err := json.NewDecoder(res.Body).Decode(out); err != nil {
t.Fatal(err)
}
}
}
func push(t *testing.T, url, author, path, base, content string) string {
t.Helper()
sum := sha256.Sum256([]byte(content))
hash := hex.EncodeToString(sum[:])
req, _ := http.NewRequest(http.MethodPut, url+"/api/blobs/"+hash, strings.NewReader(content))
req.Header.Set("Authorization", "Bearer "+adminToken)
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
res.Body.Close()
change := protocol.Change{Space: protocol.SharedSpace, Path: path, Base: base, Hash: hash}
call(t, http.MethodPost, url+"/api/repos/"+repoKey+"/revisions", adminToken, protocol.Push{Name: "vault", Author: author, Changes: []protocol.Change{change}}, nil)
return hash
}
func revisions(t *testing.T, url, query string) []protocol.Revision {
t.Helper()
var revisions []protocol.Revision
call(t, http.MethodGet, url+"/api/repos/"+repoKey+"/revisions"+query, adminToken, nil, &revisions)
return revisions
}
func seqs(revisions []protocol.Revision) []int64 {
seqs := make([]int64, len(revisions))
for i, rev := range revisions {
seqs[i] = rev.Seq
}
return seqs
}
func TestRevisionsCarryAuthorAndPageAfterSeq(t *testing.T) {
url := newServer(t)
events, err := http.NewRequest(http.MethodGet, url+"/api/events", nil)
if err != nil {
t.Fatal(err)
}
events.Header.Set("Authorization", "Bearer "+adminToken)
stream, err := http.DefaultClient.Do(events)
if err != nil {
t.Fatal(err)
}
defer stream.Body.Close()
first := push(t, url, "", "a.md", "", "one")
push(t, url, "alice", "a.md", first, "two")
push(t, url, "bob", "b.md", "", "three")
if got := seqs(revisions(t, url, "")); len(got) != 3 || got[0] != 3 {
t.Fatalf("default order = %v, want newest first", got)
}
after := revisions(t, url, "?after=1")
if got := seqs(after); len(got) != 2 || got[0] != 2 || got[1] != 3 {
t.Fatalf("after=1 = %v, want [2 3]", got)
}
if after[0].Author != "alice" || after[0].Machine != "web" || revisions(t, url, "?after=0")[0].Author != "" {
t.Fatalf("authors = %+v", after)
}
if got := seqs(revisions(t, url, "?after=0&path=b.md")); len(got) != 1 || got[0] != 3 {
t.Fatalf("after with path = %v", got)
}
if got := revisions(t, url, "?after=3"); len(got) != 0 {
t.Fatalf("after the last revision = %v", got)
}
var authors []string
scanner := bufio.NewScanner(stream.Body)
for len(authors) < 3 && scanner.Scan() {
if data, ok := strings.CutPrefix(scanner.Text(), "data: "); ok {
var event protocol.Event
_ = json.Unmarshal([]byte(data), &event)
authors = append(authors, event.Author)
}
}
if strings.Join(authors, ",") != ",alice,bob" {
t.Fatalf("event authors = %q", authors)
}
}
func TestOldDatabaseGainsAuthorColumn(t *testing.T) {
path := filepath.Join(t.TempDir(), "lodge.db")
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(`
create table revisions (repo text not null, seq integer not null, machine_id integer, time integer not null, main_sha text not null, main_branch text not null, primary key (repo, seq));
create table changes (repo text not null, seq integer not null, space text not null, path text not null, base text not null, hash text not null, primary key (repo, seq, space, path));
insert into revisions values ('` + repoKey + `', 1, null, 1, '', 'main');
insert into changes values ('` + repoKey + `', 1, 'shared', 'a.md', '', 'x');`)
if err := errors.Join(err, db.Close()); err != nil {
t.Fatal(err)
}
for range 2 {
store, err := OpenStore(path)
if err != nil {
t.Fatal(err)
}
revisions, err := store.Revisions(context.Background(), repoKey, "")
store.Close()
if err != nil || len(revisions) != 1 || revisions[0].Author != "" {
t.Fatalf("old revisions = %+v (%v)", revisions, err)
}
}
}
func TestMachinesShowWhoIsOnline(t *testing.T) {
url := newServer(t)
var code protocol.CodeResponse
call(t, http.MethodPost, url+"/api/codes", adminToken, nil, &code)
var claim protocol.ClaimResponse
call(t, http.MethodPost, url+"/api/claim", "", protocol.ClaimRequest{Code: code.Code, Name: "dell"}, &claim)
online := func() protocol.Machine {
t.Helper()
var machines []protocol.Machine
call(t, http.MethodGet, url+"/api/machines", adminToken, nil, &machines)
if len(machines) != 1 {
t.Fatalf("machines = %+v", machines)
}
return machines[0]
}
if machine := online(); machine.Online || machine.LastSeen != 0 {
t.Fatalf("a machine that never connected: %+v", machine)
}
ctx, cancel := context.WithCancel(context.Background())
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url+"/api/events", nil)
req.Header.Set("Authorization", "Bearer "+claim.Token)
stream, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
if machine := online(); !machine.Online || machine.LastSeen == 0 {
t.Fatalf("a machine listening for events: %+v", machine)
}
cancel()
stream.Body.Close()
deadline := time.Now().Add(5 * time.Second)
for online().Online {
if time.Now().After(deadline) {
t.Fatal("the machine stays online after its stream closed")
}
time.Sleep(20 * time.Millisecond)
}
}
func TestInstallScriptModes(t *testing.T) {
url := newServer(t)
var code protocol.CodeResponse
call(t, http.MethodPost, url+"/api/codes", adminToken, nil, &code)
if code.RemoteCommand != "curl -fsSL "+url+"/i/"+code.Code+"/remote | sh" {
t.Fatalf("remote command = %q", code.RemoteCommand)
}
for path, mode := range map[string]string{"/i/" + code.Code: "login", "/i/" + code.Code + "/remote": "remote"} {
res, err := http.Get(url + path)
if err != nil {
t.Fatal(err)
}
script, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode != http.StatusOK || !strings.Contains(string(script), `mode="`+mode+`"`) || !strings.Contains(string(script), `code="`+code.Code+`"`) {
t.Fatalf("%s: %d\n%s", path, res.StatusCode, script)
}
check := exec.Command("sh", "-n")
check.Stdin = bytes.NewReader(script)
if out, err := check.CombinedOutput(); err != nil {
t.Fatalf("%s is not valid sh: %v %s", path, err, out)
}
}
res, err := http.Get(url + "/i/abcdef/remote")
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != http.StatusNotFound {
t.Fatalf("unknown code: %d", res.StatusCode)
}
}
+35 -7
View File
@@ -20,6 +20,11 @@ const (
codeLifetime = 10 * time.Minute codeLifetime = 10 * time.Minute
sessionLifetime = 30 * 24 * time.Hour sessionLifetime = 30 * 24 * time.Hour
revisionLimit = 500 revisionLimit = 500
revisionRows = `
select r.seq, coalesce(m.name, iif(r.machine_id is null, 'web', 'revoked')), r.author, r.time, r.main_sha, r.main_branch, c.space, c.path, c.base, c.hash
from revisions r
join changes c on c.repo = r.repo and c.seq = r.seq
left join machines m on m.id = r.machine_id`
schema = ` schema = `
create table if not exists machines ( create table if not exists machines (
id integer primary key, id integer primary key,
@@ -49,6 +54,7 @@ create table if not exists revisions (
repo text not null, repo text not null,
seq integer not null, seq integer not null,
machine_id integer, machine_id integer,
author text not null default '',
time integer not null, time integer not null,
main_sha text not null, main_sha text not null,
main_branch text not null, main_branch text not null,
@@ -86,9 +92,21 @@ func OpenStore(path string) (*Store, error) {
if _, err := db.Exec(schema); err != nil { if _, err := db.Exec(schema); err != nil {
return nil, err return nil, err
} }
if err := addAuthor(db); err != nil {
return nil, err
}
return &Store{db: db}, nil return &Store{db: db}, nil
} }
func addAuthor(db *sql.DB) error {
var found int
if err := db.QueryRow(`select count(*) from pragma_table_info('revisions') where name = 'author'`).Scan(&found); err != nil || found > 0 {
return err
}
_, err := db.Exec(`alter table revisions add column author text not null default ''`)
return err
}
func (s *Store) Close() error { func (s *Store) Close() error {
return s.db.Close() return s.db.Close()
} }
@@ -292,14 +310,24 @@ func (s *Store) State(ctx context.Context, repo, at string) (protocol.State, err
} }
func (s *Store) Revisions(ctx context.Context, repo, path string) ([]protocol.Revision, error) { func (s *Store) Revisions(ctx context.Context, repo, path string) ([]protocol.Revision, error) {
rows, err := s.db.QueryContext(ctx, ` return s.revisions(ctx, revisionRows+`
select r.seq, coalesce(m.name, iif(r.machine_id is null, 'web', 'revoked')), r.time, r.main_sha, r.main_branch, c.space, c.path, c.base, c.hash
from revisions r
join changes c on c.repo = r.repo and c.seq = r.seq
left join machines m on m.id = r.machine_id
where r.repo = ?1 and (?2 = '' or c.path = ?2) where r.repo = ?1 and (?2 = '' or c.path = ?2)
order by r.seq desc, c.path order by r.seq desc, c.path
limit ?3`, repo, path, revisionLimit) limit ?3`, repo, path, revisionLimit)
}
func (s *Store) RevisionsAfter(ctx context.Context, repo, path string, after int64) ([]protocol.Revision, error) {
return s.revisions(ctx, revisionRows+`
where r.repo = ?1 and (?2 = '' or c.path = ?2) and r.seq in (
select distinct seq from changes
where repo = ?1 and (?2 = '' or path = ?2) and seq > ?4
order by seq limit ?3
)
order by r.seq, c.path`, repo, path, revisionLimit, after)
}
func (s *Store) revisions(ctx context.Context, query string, args ...any) ([]protocol.Revision, error) {
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -308,7 +336,7 @@ func (s *Store) Revisions(ctx context.Context, repo, path string) ([]protocol.Re
for rows.Next() { for rows.Next() {
var rev protocol.Revision var rev protocol.Revision
var change protocol.Change var change protocol.Change
if err := rows.Scan(&rev.Seq, &rev.Machine, &rev.Time, &rev.MainSHA, &rev.MainBranch, &change.Space, &change.Path, &change.Base, &change.Hash); err != nil { if err := rows.Scan(&rev.Seq, &rev.Machine, &rev.Author, &rev.Time, &rev.MainSHA, &rev.MainBranch, &change.Space, &change.Path, &change.Base, &change.Hash); err != nil {
return nil, err return nil, err
} }
if last := len(revisions) - 1; last >= 0 && revisions[last].Seq == rev.Seq { if last := len(revisions) - 1; last >= 0 && revisions[last].Seq == rev.Seq {
@@ -356,7 +384,7 @@ func (s *Store) Push(ctx context.Context, repo string, machineID int64, push pro
return 0, err return 0, err
} }
machine := sql.NullInt64{Int64: machineID, Valid: machineID != 0} machine := sql.NullInt64{Int64: machineID, Valid: machineID != 0}
if _, err := tx.ExecContext(ctx, `insert into revisions (repo, seq, machine_id, time, main_sha, main_branch) values (?, ?, ?, ?, ?, ?)`, repo, seq, machine, now, push.MainSHA, push.MainBranch); err != nil { if _, err := tx.ExecContext(ctx, `insert into revisions (repo, seq, machine_id, author, time, main_sha, main_branch) values (?, ?, ?, ?, ?, ?, ?)`, repo, seq, machine, push.Author, now, push.MainSHA, push.MainBranch); err != nil {
return 0, err return 0, err
} }
for _, change := range push.Changes { for _, change := range push.Changes {