feat(client): lodge remote binds a repository to another server
This commit is contained in:
1 parent
45c81cfcaf
commit
91d83f3032
10 files changed
+639
-147
No files matched your search
@@ -10,6 +10,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -29,14 +30,23 @@ const (
|
||||
|
||||
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, or lodge remote <url> <code> inside the repository")
|
||||
errConflict = errors.New("server state changed")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
URL string `json:"url,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
Servers map[string]string `json:"servers,omitempty"`
|
||||
Repos map[string]string `json:"repos,omitempty"`
|
||||
DataDir string `json:"-"`
|
||||
path string
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
URL string `json:"url"`
|
||||
Token string `json:"token"`
|
||||
DataDir string `json:"-"`
|
||||
URL string
|
||||
Token string
|
||||
DataDir 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")
|
||||
}
|
||||
|
||||
func Load() (*Client, error) {
|
||||
c := &Client{DataDir: dataDir()}
|
||||
if data, err := os.ReadFile(configPath()); err == nil {
|
||||
if err := json.Unmarshal(data, c); err != nil {
|
||||
func serverURL(raw string) string {
|
||||
return strings.TrimSuffix(raw, "/")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
c.URL = strings.TrimSuffix(cmp.Or(os.Getenv("LODGE_URL"), c.URL), "/")
|
||||
c.Token = cmp.Or(os.Getenv("LODGE_TOKEN"), c.Token)
|
||||
if c.URL == "" || c.Token == "" {
|
||||
return nil, ErrNotLoggedIn
|
||||
}
|
||||
return c, nil
|
||||
cfg.URL = serverURL(cmp.Or(os.Getenv("LODGE_URL"), cfg.URL))
|
||||
cfg.Token = cmp.Or(os.Getenv("LODGE_TOKEN"), cfg.Token)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func Login(ctx context.Context, serverURL, code string) error {
|
||||
name, _ := os.Hostname()
|
||||
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) reload() (*Config, error) {
|
||||
return loadConfig(cfg.path, cfg.DataDir)
|
||||
}
|
||||
|
||||
func (cfg *Config) update(change func(*Config)) error {
|
||||
unlock, err := flock(cfg.path + ".lock")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Token = claim.Token
|
||||
return writeJSONFile(configPath(), c)
|
||||
defer unlock()
|
||||
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 {
|
||||
@@ -205,20 +313,20 @@ func (c *Client) uploadBlob(ctx context.Context, hash string) error {
|
||||
return res.Body.Close()
|
||||
}
|
||||
|
||||
func (c *Client) registryPath() string {
|
||||
return filepath.Join(c.DataDir, "worktrees.json")
|
||||
func (cfg *Config) registryPath() string {
|
||||
return filepath.Join(cfg.DataDir, "worktrees.json")
|
||||
}
|
||||
|
||||
func (c *Client) Worktrees() []string {
|
||||
func (cfg *Config) Worktrees() []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)
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
func (c *Client) register(root string) error {
|
||||
roots := c.Worktrees()
|
||||
func (cfg *Config) register(root string) error {
|
||||
roots := cfg.Worktrees()
|
||||
if slices.Contains(roots, root) {
|
||||
return nil
|
||||
}
|
||||
@@ -226,10 +334,10 @@ func (c *Client) register(root string) error {
|
||||
_, err := os.Stat(known)
|
||||
return err != nil
|
||||
})
|
||||
return writeJSONFile(c.registryPath(), append(roots, root))
|
||||
return writeJSONFile(cfg.registryPath(), append(roots, root))
|
||||
}
|
||||
|
||||
func (c *Client) Forget(root string) error {
|
||||
roots := slices.DeleteFunc(c.Worktrees(), func(known string) bool { return known == root })
|
||||
return writeJSONFile(c.registryPath(), roots)
|
||||
func (cfg *Config) Forget(root string) error {
|
||||
roots := slices.DeleteFunc(cfg.Worktrees(), func(known string) bool { return known == root })
|
||||
return writeJSONFile(cfg.registryPath(), roots)
|
||||
}
|
||||
@@ -36,38 +36,46 @@ type tree struct {
|
||||
}
|
||||
|
||||
type daemon struct {
|
||||
client *Client
|
||||
config *Config
|
||||
watcher *fsnotify.Watcher
|
||||
queue chan string
|
||||
|
||||
mu sync.Mutex
|
||||
owners map[string]string
|
||||
trees map[string]tree
|
||||
pending map[string]*time.Timer
|
||||
mu sync.Mutex
|
||||
current *Config
|
||||
listeners map[Client]context.CancelFunc
|
||||
owners map[string]string
|
||||
trees map[string]tree
|
||||
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()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer watcher.Close()
|
||||
if err := os.MkdirAll(c.DataDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := watcher.Add(c.DataDir); err != nil {
|
||||
return err
|
||||
for _, dir := range []string{cfg.DataDir, filepath.Dir(cfg.path)} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := watcher.Add(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
d := &daemon{
|
||||
client: c,
|
||||
watcher: watcher,
|
||||
queue: make(chan string, 256),
|
||||
owners: map[string]string{},
|
||||
trees: map[string]tree{},
|
||||
pending: map[string]*time.Timer{},
|
||||
config: cfg,
|
||||
watcher: watcher,
|
||||
queue: make(chan string, 256),
|
||||
current: cfg,
|
||||
listeners: map[Client]context.CancelFunc{},
|
||||
owners: map[string]string{},
|
||||
trees: map[string]tree{},
|
||||
pending: map[string]*time.Timer{},
|
||||
}
|
||||
go d.listen(ctx)
|
||||
go d.work(ctx)
|
||||
d.connect(ctx)
|
||||
var workers sync.WaitGroup
|
||||
defer workers.Wait()
|
||||
workers.Go(func() { d.work(ctx) })
|
||||
d.syncAll()
|
||||
|
||||
ticker := time.NewTicker(fullSyncEvery)
|
||||
@@ -81,13 +89,43 @@ func (c *Client) Daemon(ctx context.Context) error {
|
||||
case err := <-watcher.Errors:
|
||||
log.Printf("watcher: %v", err)
|
||||
case event := <-watcher.Events:
|
||||
d.handle(event)
|
||||
d.handle(ctx, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *daemon) handle(event fsnotify.Event) {
|
||||
if event.Name == d.client.registryPath() {
|
||||
func (d *daemon) connect(ctx context.Context) {
|
||||
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()
|
||||
return
|
||||
}
|
||||
@@ -133,7 +171,7 @@ func (d *daemon) schedule(root string) {
|
||||
}
|
||||
|
||||
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) {
|
||||
d.forget(root)
|
||||
} else {
|
||||
@@ -143,7 +181,7 @@ func (d *daemon) syncAll() {
|
||||
}
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -160,10 +198,13 @@ func (d *daemon) work(ctx context.Context) {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case root := <-d.queue:
|
||||
res, err := d.client.Sync(ctx, root, true)
|
||||
if err != nil {
|
||||
res, err := d.config.Sync(ctx, root, true)
|
||||
switch {
|
||||
case errors.Is(err, ErrNotLoggedIn):
|
||||
continue
|
||||
case err != nil:
|
||||
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)
|
||||
}
|
||||
d.watch(root)
|
||||
@@ -225,25 +266,29 @@ func nearestDir(path, root string) string {
|
||||
return root
|
||||
}
|
||||
|
||||
func (d *daemon) listen(ctx context.Context) {
|
||||
func (d *daemon) listen(ctx context.Context, c Client) {
|
||||
backoff := minBackoff
|
||||
for ctx.Err() == nil {
|
||||
for {
|
||||
started := time.Now()
|
||||
err := d.stream(ctx)
|
||||
err := d.stream(ctx, c)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if time.Since(started) > maxBackoff {
|
||||
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 {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
backoff = min(backoff*2, maxBackoff)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *daemon) stream(ctx context.Context) error {
|
||||
res, err := d.client.request(ctx, http.MethodGet, "/api/events", nil)
|
||||
func (d *daemon) stream(ctx context.Context, c Client) error {
|
||||
res, err := c.request(ctx, http.MethodGet, "/api/events", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -258,9 +303,11 @@ func (d *daemon) stream(ctx context.Context) error {
|
||||
}
|
||||
d.mu.Lock()
|
||||
var roots []string
|
||||
for root, tree := range d.trees {
|
||||
if tree.key == event.Repo {
|
||||
roots = append(roots, root)
|
||||
if d.current.serverOf(event.Repo) == c.URL {
|
||||
for root, tree := range d.trees {
|
||||
if tree.key == event.Repo {
|
||||
roots = append(roots, root)
|
||||
}
|
||||
}
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestDaemonWatchesOnlyRulePaths(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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")
|
||||
expectWatched := func(want ...string) {
|
||||
t.Helper()
|
||||
@@ -62,7 +62,7 @@ func TestDaemonSkipsIgnoredPaths(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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{}}
|
||||
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")
|
||||
@@ -85,13 +85,13 @@ func TestDaemonSkipsIgnoredPaths(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDaemonForgetsVanishedWorktrees(t *testing.T) {
|
||||
c := &Client{DataDir: t.TempDir()}
|
||||
if err := writeJSONFile(c.registryPath(), []string{filepath.Join(t.TempDir(), "gone")}); err != nil {
|
||||
cfg := &Config{DataDir: t.TempDir()}
|
||||
if err := writeJSONFile(cfg.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 := &daemon{config: cfg, owners: map[string]string{}, trees: map[string]tree{}}
|
||||
d.syncAll()
|
||||
if roots := c.Worktrees(); len(roots) != 0 {
|
||||
if roots := cfg.Worktrees(); len(roots) != 0 {
|
||||
t.Fatalf("registry still lists %v", roots)
|
||||
}
|
||||
}
|
||||
@@ -75,11 +75,11 @@ func unitPath(home string) string {
|
||||
return filepath.Join(home, ".config", "systemd", "user", systemdUnit)
|
||||
}
|
||||
|
||||
func (c *Client) templateDir() string {
|
||||
return filepath.Join(c.DataDir, "git-template")
|
||||
func (cfg *Config) templateDir() string {
|
||||
return filepath.Join(cfg.DataDir, "git-template")
|
||||
}
|
||||
|
||||
func (c *Client) Install() error {
|
||||
func (cfg *Config) Install() error {
|
||||
binary, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -88,7 +88,7 @@ func (c *Client) Install() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.installHook(binary); err != nil {
|
||||
if err := cfg.installHook(binary); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, dir := range skillDirs {
|
||||
@@ -96,14 +96,14 @@ func (c *Client) Install() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return startService(home, binary, filepath.Join(c.DataDir, "daemon.log"))
|
||||
return startService(home, binary, filepath.Join(cfg.DataDir, "daemon.log"))
|
||||
}
|
||||
|
||||
func (c *Client) installHook(binary string) error {
|
||||
func (cfg *Config) installHook(binary string) error {
|
||||
template, _ := exec.Command("git", "config", "--global", "--get", "init.templateDir").Output()
|
||||
dir := strings.TrimSpace(string(template))
|
||||
if dir == "" {
|
||||
dir = c.templateDir()
|
||||
dir = cfg.templateDir()
|
||||
if err := command("git", "config", "--global", "init.templateDir", dir); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -146,7 +146,7 @@ func restartService() error {
|
||||
return command("systemctl", "--user", "restart", systemdUnit)
|
||||
}
|
||||
|
||||
func (c *Client) Uninstall() error {
|
||||
func (cfg *Config) Uninstall() error {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -159,16 +159,20 @@ func (c *Client) Uninstall() error {
|
||||
_ = os.Remove(unitPath(home))
|
||||
}
|
||||
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")
|
||||
}
|
||||
for _, dir := range skillDirs {
|
||||
_ = 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()
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
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")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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 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 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.
|
||||
|
||||
## Reading history
|
||||
|
||||
@@ -42,8 +42,12 @@ type diskFiles struct {
|
||||
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 {
|
||||
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 {
|
||||
@@ -60,8 +64,7 @@ func (c *Client) loadState(repo *gitrepo.Repo) *worktreeState {
|
||||
return st
|
||||
}
|
||||
|
||||
func (c *Client) lock(repo *gitrepo.Repo) (func(), error) {
|
||||
path := c.statePath(repo) + ".lock"
|
||||
func flock(path string) (func(), error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -76,31 +79,46 @@ func (c *Client) lock(repo *gitrepo.Repo) (func(), error) {
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
firstSight := !slices.Contains(c.Worktrees(), repo.Root)
|
||||
_, err = c.Sync(ctx, repo.Root, firstSight)
|
||||
firstSight := !slices.Contains(cfg.Worktrees(), repo.Root)
|
||||
_, err = cfg.Sync(ctx, repo.Root, firstSight)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) Sync(ctx context.Context, dir string, online bool) (Result, error) {
|
||||
var res Result
|
||||
func (cfg *Config) Sync(ctx context.Context, dir string, online bool) (Result, error) {
|
||||
repo, err := gitrepo.Open(dir)
|
||||
if err != nil {
|
||||
return res, err
|
||||
return Result{}, err
|
||||
}
|
||||
if err := c.register(repo.Root); err != nil {
|
||||
return res, err
|
||||
}
|
||||
unlock, err := c.lock(repo)
|
||||
worktree := worktreePath(cfg.DataDir, repo)
|
||||
unlock, err := flock(worktree + ".lock")
|
||||
if err != nil {
|
||||
return res, err
|
||||
return Result{}, err
|
||||
}
|
||||
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)
|
||||
for range maxPasses {
|
||||
again, err := c.pass(ctx, repo, st, online, &res)
|
||||
|
||||
@@ -19,6 +19,7 @@ const adminToken = "admin"
|
||||
|
||||
type machine struct {
|
||||
t *testing.T
|
||||
config *Config
|
||||
client *Client
|
||||
root string
|
||||
}
|
||||
@@ -34,7 +35,7 @@ func git(t *testing.T, dir string, args ...string) string {
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func setup(t *testing.T) (*machine, *machine) {
|
||||
func newServer(t *testing.T) string {
|
||||
t.Helper()
|
||||
store, err := server.OpenStore(filepath.Join(t.TempDir(), "lodge.db"))
|
||||
if err != nil {
|
||||
@@ -43,22 +44,41 @@ func setup(t *testing.T) (*machine, *machine) {
|
||||
t.Cleanup(func() { store.Close() })
|
||||
api := httptest.NewServer(server.New(store, server.Config{AdminToken: adminToken}))
|
||||
t.Cleanup(api.Close)
|
||||
return api.URL
|
||||
}
|
||||
|
||||
func newOrigin(t *testing.T) string {
|
||||
t.Helper()
|
||||
origin := t.TempDir()
|
||||
git(t, origin, "init", "-b", "main")
|
||||
write(t, origin, "README.md", "readme")
|
||||
write(t, origin, "docs/agents/tracked.md", "in git")
|
||||
git(t, origin, "add", ".")
|
||||
git(t, origin, "commit", "-m", "root")
|
||||
git(t, origin, "commit", "-m", "root "+origin)
|
||||
return origin
|
||||
}
|
||||
|
||||
machines := [2]*machine{}
|
||||
for i := range machines {
|
||||
root := filepath.Join(t.TempDir(), "clone")
|
||||
git(t, origin, "clone", origin, root)
|
||||
resolved, _ := filepath.EvalSymlinks(root)
|
||||
machines[i] = &machine{t: t, root: resolved, client: &Client{URL: api.URL, Token: adminToken, DataDir: t.TempDir()}}
|
||||
func newMachine(t *testing.T, origin, url, token string) *machine {
|
||||
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 machines[0], machines[1]
|
||||
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")
|
||||
git(t, origin, "clone", origin, root)
|
||||
resolved, _ := filepath.EvalSymlinks(root)
|
||||
return resolved
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -70,7 +90,7 @@ func write(t *testing.T, root, path, content string) {
|
||||
|
||||
func (m *machine) sync() Result {
|
||||
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 {
|
||||
m.t.Fatal(err)
|
||||
}
|
||||
@@ -130,7 +150,7 @@ func TestBranchNotesFollowTheBranchAndSharedStay(t *testing.T) {
|
||||
mac.sync()
|
||||
|
||||
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)
|
||||
}
|
||||
mac.expect(".agent/plan.md", "main plan")
|
||||
|
||||
Reference in new issue
Block a user