feat(client): lodge remote binds a repository to another server

This commit is contained in:
hh committed 2026-10-02 22:05:10 +00:00
1 parent 45c81cfcaf
commit 91d83f3032
10 files changed
+639 -147

No files matched your search

+109 -38
View File
@@ -9,6 +9,7 @@ import (
"os"
"os/signal"
"path/filepath"
"slices"
"syscall"
"time"
@@ -20,9 +21,11 @@ import (
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 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 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 forget [dir] stop syncing a repository
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 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"}
@@ -39,12 +42,18 @@ var hookEnvironment = []string{"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GI
type status struct {
Root string `json:"root"`
Key string `json:"key"`
Server string `json:"server"`
Branch string `json:"branch"`
Rules []client.Rule `json:"rules"`
Revision int64 `json:"revision"`
Spaces map[string]protocol.Manifest `json:"spaces"`
}
type remoteStatus struct {
URL string `json:"url"`
Default bool `json:"default"`
}
func main() {
if len(os.Args) < 2 {
fmt.Print(usage)
@@ -67,14 +76,15 @@ func hook(ctx context.Context) {
for _, name := range hookEnvironment {
os.Unsetenv(name)
}
if c, err := client.Load(); err == nil {
_ = c.Hook(ctx, ".")
if cfg, err := client.Load(); err == nil {
_ = cfg.Hook(ctx, ".")
}
}
func run(ctx context.Context, name string, args []string) error {
flags := flag.NewFlagSet(name, flag.ExitOnError)
asJSON := flags.Bool("json", false, "machine-readable output")
toDefault := flags.Bool("default", false, "go back to the default server")
_ = flags.Parse(args)
args = flags.Args()
target := func(fallback string) string {
@@ -84,45 +94,36 @@ func run(ctx context.Context, name string, args []string) error {
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 {
case "login":
if len(args) != 2 {
return errors.New("usage: lodge login <url> <code>")
}
return client.Login(ctx, args[0], args[1])
case "help", "-h", "--help":
fmt.Print(usage)
return nil
}
c, err := client.Load()
if err != nil {
return err
}
switch name {
return cfg.Login(ctx, args[0], args[1])
case "remote":
return remote(ctx, cfg, args, *toDefault, *asJSON)
case "install":
return c.Install()
return cfg.Install()
case "uninstall":
return c.Uninstall()
return cfg.Uninstall()
case "daemon":
return c.Daemon(ctx)
return cfg.Daemon(ctx)
case "update":
updated, err := c.Update(ctx)
updated, err := cfg.Update(ctx)
if err == nil && !updated {
fmt.Println("already up to date")
}
return err
case "add", "sync":
res, err := c.Sync(ctx, target("."), 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)
}
})
return syncRepo(ctx, cfg, target("."), *asJSON)
case "forget":
root, err := filepath.Abs(target("."))
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 {
root = repo.Root
}
return c.Forget(root)
return cfg.Forget(root)
case "status":
return showStatus(ctx, c, target("."), *asJSON)
return showStatus(ctx, cfg, target("."), *asJSON)
case "log":
return showLog(ctx, c, target(""), *asJSON)
return showLog(ctx, cfg, target(""), *asJSON)
case "show":
if len(args) != 2 {
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)
}
@@ -155,18 +156,80 @@ func output(asJSON bool, value any, human func()) error {
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)
if err != nil {
return err
}
c, err := cfg.Server(repo.Key)
if err != nil {
return err
}
state, err := c.State(ctx, repo.Key, "")
if err != nil {
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() {
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 {
kind := "branch"
switch {
@@ -186,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(".")
if err != nil {
return err
}
c, err := cfg.Server(repo.Key)
if err != nil {
return err
}
revisions, err := c.Revisions(ctx, repo.Key, path)
if err != nil {
return err
@@ -212,11 +279,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(".")
if err != nil {
return err
}
c, err := cfg.Server(repo.Key)
if err != nil {
return err
}
state, err := c.State(ctx, repo.Key, at)
if err != nil {
return err
+139 -31
View File
@@ -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)
}
+83 -36
View File
@@ -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()
+6 -6
View File
@@ -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)
}
}
+15 -11
View File
@@ -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
+219
View File
@@ -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
}
+1
View File
@@ -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
+32 -14
View File
@@ -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)
+31 -11
View File
@@ -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")