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