feat(client): lodge remote binds a repository to another server
This commit is contained in:
1 parent
45c81cfcaf
commit
91d83f3032
10 files changed
+622
-130
No files matched your search
@@ -43,6 +43,10 @@ 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`.
|
||||||
|
|
||||||
## 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.
|
||||||
|
|||||||
+109
-38
@@ -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,18 +156,80 @@ 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"
|
||||||
switch {
|
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(".")
|
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
|
||||||
@@ -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(".")
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
@@ -225,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
|
||||||
}
|
}
|
||||||
@@ -258,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)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -62,7 +62,7 @@ func TestDaemonSkipsIgnoredPaths(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{}}
|
||||||
write(t, m.root, RulesFile, "shared vault\nignore vault/.obsidian/workspace*.json\nignore .DS_Store\nignore vault/.trash\n")
|
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/.obsidian/app.json", "settings")
|
||||||
write(t, m.root, "vault/.trash/old.md", "old")
|
write(t, m.root, "vault/.trash/old.md", "old")
|
||||||
@@ -85,13 +85,13 @@ func TestDaemonSkipsIgnoredPaths(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDaemonForgetsVanishedWorktrees(t *testing.T) {
|
func TestDaemonForgetsVanishedWorktrees(t *testing.T) {
|
||||||
c := &Client{DataDir: t.TempDir()}
|
cfg := &Config{DataDir: t.TempDir()}
|
||||||
if err := writeJSONFile(c.registryPath(), []string{filepath.Join(t.TempDir(), "gone")}); err != nil {
|
if err := writeJSONFile(cfg.registryPath(), []string{filepath.Join(t.TempDir(), "gone")}); err != nil {
|
||||||
t.Fatal(err)
|
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()
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,14 @@ func (c *Client) Install() error {
|
|||||||
return err
|
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()
|
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 +146,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 +159,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
|
||||||
|
|||||||
@@ -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 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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in new issue
Block a user