feat: lodge server, client and admin

This commit is contained in:
hh committed 2026-09-19 23:33:24 +02:00
commit 95fc50e2c2
84 files changed
+7625

No files matched your search

+224
View File
@@ -0,0 +1,224 @@
package client
import (
"bytes"
"cmp"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"time"
"lodge/internal/protocol"
)
const requestTimeout = 15 * time.Second
var (
ErrNotLoggedIn = errors.New("not logged in: run the install command from the lodge admin page")
errConflict = errors.New("server state changed")
)
type Client struct {
URL string `json:"url"`
Token string `json:"token"`
DataDir string `json:"-"`
}
func configPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(cmp.Or(os.Getenv("XDG_CONFIG_HOME"), filepath.Join(home, ".config")), "lodge", "config.json")
}
func dataDir() string {
home, _ := os.UserHomeDir()
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 {
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
}
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 {
return err
}
c.Token = claim.Token
return writeJSONFile(configPath(), c)
}
func writeJSONFile(path string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
return writeFile(path, data, 0o600)
}
func writeFile(path string, data []byte, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
temp := path + ".lodge-tmp"
if err := os.WriteFile(temp, data, mode); err != nil {
return err
}
return os.Rename(temp, path)
}
func (c *Client) request(ctx context.Context, method, path string, body []byte) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, c.URL+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
if res.StatusCode < http.StatusBadRequest {
return res, nil
}
defer res.Body.Close()
message, _ := io.ReadAll(io.LimitReader(res.Body, 1024))
if res.StatusCode == http.StatusConflict {
return nil, errConflict
}
return nil, fmt.Errorf("%s %s: %s: %s", method, path, res.Status, strings.TrimSpace(string(message)))
}
func (c *Client) call(ctx context.Context, method, path string, in, out any) error {
ctx, cancel := context.WithTimeout(ctx, requestTimeout)
defer cancel()
var body []byte
if in != nil {
body, _ = json.Marshal(in)
}
res, err := c.request(ctx, method, path, body)
if err != nil {
return err
}
defer res.Body.Close()
if out == nil {
return nil
}
return json.NewDecoder(res.Body).Decode(out)
}
func (c *Client) State(ctx context.Context, key, at string) (protocol.State, error) {
var state protocol.State
err := c.call(ctx, http.MethodGet, "/api/repos/"+key+"/state?at="+url.QueryEscape(at), nil, &state)
return state, err
}
func (c *Client) Revisions(ctx context.Context, key, path string) ([]protocol.Revision, error) {
var revisions []protocol.Revision
err := c.call(ctx, http.MethodGet, "/api/repos/"+key+"/revisions?path="+url.QueryEscape(path), nil, &revisions)
return revisions, err
}
func hashOf(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func (c *Client) blobPath(hash string) string {
return filepath.Join(c.DataDir, "blobs", hash[:2], hash)
}
func (c *Client) cacheBlob(data []byte) (string, error) {
hash := hashOf(data)
path := c.blobPath(hash)
if _, err := os.Stat(path); err == nil {
return hash, nil
}
return hash, writeFile(path, data, 0o600)
}
func (c *Client) Blob(ctx context.Context, hash string) ([]byte, error) {
if data, err := os.ReadFile(c.blobPath(hash)); err == nil {
return data, nil
}
ctx, cancel := context.WithTimeout(ctx, requestTimeout)
defer cancel()
res, err := c.request(ctx, http.MethodGet, "/api/blobs/"+hash, nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
if hashOf(data) != hash {
return nil, fmt.Errorf("blob %s arrived corrupted", hash)
}
_, err = c.cacheBlob(data)
return data, err
}
func (c *Client) uploadBlob(ctx context.Context, hash string) error {
data, err := os.ReadFile(c.blobPath(hash))
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, requestTimeout)
defer cancel()
res, err := c.request(ctx, http.MethodPut, "/api/blobs/"+hash, data)
if err != nil {
return err
}
return res.Body.Close()
}
func (c *Client) registryPath() string {
return filepath.Join(c.DataDir, "worktrees.json")
}
func (c *Client) Worktrees() []string {
var roots []string
if data, err := os.ReadFile(c.registryPath()); err == nil {
_ = json.Unmarshal(data, &roots)
}
return roots
}
func (c *Client) register(root string) error {
roots := c.Worktrees()
if slices.Contains(roots, root) {
return nil
}
roots = slices.DeleteFunc(roots, func(known string) bool {
_, err := os.Stat(known)
return err != nil
})
return writeJSONFile(c.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)
}
+222
View File
@@ -0,0 +1,222 @@
package client
import (
"bufio"
"context"
"encoding/json"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"lodge/internal/gitrepo"
"lodge/internal/protocol"
"github.com/fsnotify/fsnotify"
)
const (
debounce = 2 * time.Second
fullSyncEvery = 5 * time.Minute
minBackoff = 5 * time.Second
maxBackoff = time.Minute
headFile = "HEAD"
)
type daemon struct {
client *Client
watcher *fsnotify.Watcher
queue chan string
mu sync.Mutex
owners map[string]string
gitDirs map[string]bool
keys map[string]string
pending map[string]*time.Timer
}
func (c *Client) 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
}
d := &daemon{
client: c,
watcher: watcher,
queue: make(chan string, 256),
owners: map[string]string{},
gitDirs: map[string]bool{},
keys: map[string]string{},
pending: map[string]*time.Timer{},
}
go d.listen(ctx)
go d.work(ctx)
d.syncAll()
ticker := time.NewTicker(fullSyncEvery)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
d.syncAll()
case err := <-watcher.Errors:
log.Printf("watcher: %v", err)
case event := <-watcher.Events:
d.handle(event)
}
}
}
func (d *daemon) handle(event fsnotify.Event) {
if event.Name == d.client.registryPath() {
d.syncAll()
return
}
dir := filepath.Dir(event.Name)
d.mu.Lock()
root, watched := d.owners[dir]
irrelevant := d.gitDirs[dir] && filepath.Base(event.Name) != headFile
d.mu.Unlock()
if watched && !irrelevant && !strings.HasSuffix(event.Name, tempSuffix) {
d.schedule(root)
}
}
func (d *daemon) schedule(root string) {
d.mu.Lock()
defer d.mu.Unlock()
if timer, waiting := d.pending[root]; waiting {
timer.Reset(debounce)
return
}
d.pending[root] = time.AfterFunc(debounce, func() {
d.mu.Lock()
delete(d.pending, root)
d.mu.Unlock()
d.queue <- root
})
}
func (d *daemon) syncAll() {
for _, root := range d.client.Worktrees() {
d.schedule(root)
}
}
func (d *daemon) work(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case root := <-d.queue:
res, err := d.client.Sync(ctx, root, true)
if err != nil {
log.Printf("%s: %v", root, err)
} else if res.Pushed+res.Pulled > 0 {
log.Printf("%s: pushed %d, pulled %d", root, res.Pushed, res.Pulled)
}
d.watch(root)
}
}
}
func (d *daemon) watch(root string) {
repo, err := gitrepo.Open(root)
if err != nil {
return
}
dirs := []string{repo.GitDir}
for _, rule := range LoadRules(repo.Root) {
target := filepath.Join(repo.Root, filepath.FromSlash(rule.Path))
info, err := os.Stat(target)
for err != nil && target != repo.Root {
target = filepath.Dir(target)
info, err = os.Stat(target)
}
if err != nil {
continue
}
if !info.IsDir() {
dirs = append(dirs, filepath.Dir(target))
continue
}
_ = filepath.WalkDir(target, func(path string, entry fs.DirEntry, err error) error {
if err != nil || !entry.IsDir() {
return nil
}
if entry.Name() == ".git" {
return filepath.SkipDir
}
dirs = append(dirs, path)
return nil
})
}
d.mu.Lock()
defer d.mu.Unlock()
d.keys[repo.Root] = repo.Key
d.gitDirs[repo.GitDir] = true
for _, dir := range dirs {
if _, watching := d.owners[dir]; !watching && d.watcher.Add(dir) == nil {
d.owners[dir] = repo.Root
}
}
}
func (d *daemon) listen(ctx context.Context) {
backoff := minBackoff
for ctx.Err() == nil {
started := time.Now()
err := d.stream(ctx)
if time.Since(started) > maxBackoff {
backoff = minBackoff
}
log.Printf("event stream closed, retrying in %s: %v", backoff, err)
select {
case <-ctx.Done():
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)
if err != nil {
return err
}
defer res.Body.Close()
d.syncAll()
scanner := bufio.NewScanner(res.Body)
for scanner.Scan() {
data, isEvent := strings.CutPrefix(scanner.Text(), "data: ")
var event protocol.Event
if !isEvent || json.Unmarshal([]byte(data), &event) != nil {
continue
}
d.mu.Lock()
var roots []string
for root, key := range d.keys {
if key == event.Repo {
roots = append(roots, root)
}
}
d.mu.Unlock()
for _, root := range roots {
d.schedule(root)
}
}
return scanner.Err()
}
+195
View File
@@ -0,0 +1,195 @@
package client
import (
"context"
_ "embed"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
)
const (
serviceLabel = "com.kotikot.lodge"
systemdUnit = "lodge.service"
hookName = "post-checkout"
)
//go:embed skill.md
var skill []byte
var skillDirs = []string{".claude/skills/lodge", ".agents/skills/lodge"}
func launchdPlist(binary, logFile string) string {
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>%s</string>
<key>ProgramArguments</key><array><string>%s</string><string>daemon</string></array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ProcessType</key><string>Background</string>
<key>StandardErrorPath</key><string>%s</string>
</dict>
</plist>
`, serviceLabel, binary, logFile)
}
func systemdService(binary string) string {
return fmt.Sprintf(`[Unit]
Description=lodge sync daemon
[Service]
ExecStart=%s daemon
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
`, binary)
}
func command(name string, args ...string) error {
out, err := exec.Command(name, args...).CombinedOutput()
if err != nil {
return fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
return nil
}
func launchdTarget() string {
return "gui/" + strconv.Itoa(os.Getuid())
}
func plistPath(home string) string {
return filepath.Join(home, "Library", "LaunchAgents", serviceLabel+".plist")
}
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 (c *Client) Install() error {
binary, err := os.Executable()
if err != nil {
return err
}
home, err := os.UserHomeDir()
if err != nil {
return err
}
if err := c.installHook(binary); err != nil {
return err
}
for _, dir := range skillDirs {
if err := writeFile(filepath.Join(home, dir, "SKILL.md"), skill, 0o644); err != nil {
return err
}
}
return startService(home, binary, filepath.Join(c.DataDir, "daemon.log"))
}
func (c *Client) installHook(binary string) error {
template, _ := exec.Command("git", "config", "--global", "--get", "init.templateDir").Output()
dir := strings.TrimSpace(string(template))
if dir == "" {
dir = c.templateDir()
if err := command("git", "config", "--global", "init.templateDir", dir); err != nil {
return err
}
}
if home, err := os.UserHomeDir(); err == nil && strings.HasPrefix(dir, "~/") {
dir = filepath.Join(home, dir[2:])
}
hook := filepath.Join(dir, "hooks", hookName)
if existing, err := os.ReadFile(hook); err == nil && !strings.Contains(string(existing), "lodge hook") {
fmt.Fprintf(os.Stderr, "lodge: %s already exists, add this line to it yourself: %q hook\n", hook, binary)
return nil
}
script := fmt.Sprintf("#!/bin/sh\n[ -x %q ] && %q hook\nexit 0\n", binary, binary)
return writeFile(hook, []byte(script), 0o755)
}
func startService(home, binary, logFile string) error {
if runtime.GOOS == "darwin" {
plist := plistPath(home)
if err := writeFile(plist, []byte(launchdPlist(binary, logFile)), 0o644); err != nil {
return err
}
_ = command("launchctl", "bootout", launchdTarget()+"/"+serviceLabel)
return command("launchctl", "bootstrap", launchdTarget(), plist)
}
if err := writeFile(unitPath(home), []byte(systemdService(binary)), 0o644); err != nil {
return err
}
if err := command("systemctl", "--user", "daemon-reload"); err != nil {
return err
}
return command("systemctl", "--user", "enable", "--now", systemdUnit)
}
func restartService() error {
if runtime.GOOS == "darwin" {
return command("launchctl", "kickstart", "-k", launchdTarget()+"/"+serviceLabel)
}
return command("systemctl", "--user", "restart", systemdUnit)
}
func (c *Client) Uninstall() error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
if runtime.GOOS == "darwin" {
_ = command("launchctl", "bootout", launchdTarget()+"/"+serviceLabel)
_ = os.Remove(plistPath(home))
} else {
_ = command("systemctl", "--user", "disable", "--now", systemdUnit)
_ = os.Remove(unitPath(home))
}
template, _ := exec.Command("git", "config", "--global", "--get", "init.templateDir").Output()
if strings.TrimSpace(string(template)) == c.templateDir() {
_ = command("git", "config", "--global", "--unset", "init.templateDir")
}
for _, dir := range skillDirs {
_ = os.RemoveAll(filepath.Join(home, dir))
}
return os.RemoveAll(c.templateDir())
}
func (c *Client) Update(ctx context.Context) (bool, error) {
binary, err := os.Executable()
if err != nil {
return false, err
}
current, err := os.ReadFile(binary)
if err != nil {
return false, err
}
res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/dl/lodge-%s-%s", runtime.GOOS, runtime.GOARCH), nil)
if err != nil {
return false, err
}
defer res.Body.Close()
latest, err := io.ReadAll(res.Body)
if err != nil {
return false, err
}
if hashOf(latest) == hashOf(current) {
return false, nil
}
if err := writeFile(binary, latest, 0o755); err != nil {
return false, err
}
return true, restartService()
}
+89
View File
@@ -0,0 +1,89 @@
package client
import (
"bytes"
"os"
"path"
"path/filepath"
"strings"
"lodge/internal/gitrepo"
"lodge/internal/protocol"
)
const (
RulesFile = ".lodge"
sharedKeyword = "shared"
branchKeyword = "branch"
excludeBegin = "# lodge begin"
excludeEnd = "# lodge end"
DefaultRules = "shared docs/agents\nshared CLAUDE.local.md\nbranch .agent\n"
)
type Rule struct {
Path string `json:"path"`
Shared bool `json:"shared"`
}
type Rules []Rule
func parseRules(text string) Rules {
rules := Rules{{Path: RulesFile, Shared: true}}
for _, line := range strings.Split(text, "\n") {
fields := strings.Fields(line)
if len(fields) != 2 || fields[0] != sharedKeyword && fields[0] != branchKeyword {
continue
}
rulePath := path.Clean(strings.Trim(filepath.ToSlash(fields[1]), "/"))
if protocol.SafePath(rulePath) {
rules = append(rules, Rule{Path: rulePath, Shared: fields[0] == sharedKeyword})
}
}
return rules
}
func LoadRules(root string) Rules {
data, err := os.ReadFile(filepath.Join(root, RulesFile))
if err != nil {
return parseRules(DefaultRules)
}
return parseRules(string(data))
}
func (rules Rules) paths() []string {
paths := make([]string, len(rules))
for i, rule := range rules {
paths[i] = rule.Path
}
return paths
}
func (rules Rules) covers(file string, shared bool) bool {
best := Rule{}
for _, rule := range rules {
matches := file == rule.Path || strings.HasPrefix(file, rule.Path+"/")
if matches && len(rule.Path) > len(best.Path) {
best = rule
}
}
return best.Path != "" && best.Shared == shared
}
func writeExclude(repo *gitrepo.Repo, rules Rules) error {
file := filepath.Join(repo.CommonDir, "info", "exclude")
current, _ := os.ReadFile(file)
kept := current
if before, rest, found := bytes.Cut(current, []byte(excludeBegin)); found {
_, after, _ := bytes.Cut(rest, []byte(excludeEnd+"\n"))
kept = append(bytes.Clone(before), after...)
}
if len(kept) > 0 && !bytes.HasSuffix(kept, []byte("\n")) {
kept = append(kept, '\n')
}
block := excludeBegin + "\n/" + strings.Join(rules.paths(), "\n/") + "\n" + excludeEnd + "\n"
kept = append(kept, block...)
if bytes.Equal(kept, current) {
return nil
}
return writeFile(file, kept, 0o644)
}
+32
View File
@@ -0,0 +1,32 @@
---
name: lodge
description: Where to keep agent notes, plans and working docs so they stay out of git but follow the repository across machines and branches. Use when writing plans, notes, research or handoff docs into a repository, when such files seem to be missing from git status, or when you need what was written earlier at a given commit.
---
# lodge
This machine runs lodge. It syncs agent files that are deliberately kept out of git: they are hidden through `.git/info/exclude`, stored on a separate server, versioned there, and restored automatically after `git clone` on any machine.
## Where to write
Run `lodge status` inside the repository to see the rules in effect. The defaults are:
- `docs/agents/` holds what is true for the whole repository: architecture notes, conventions, research on the codebase. It stays in place when branches change.
- `.agent/` holds what belongs to the current branch: the plan for the feature, what was tried, why something was reverted. It is swapped on `git checkout` together with the branch.
- `CLAUDE.local.md` is shared across branches.
The rules live in `.lodge` at the repository root, one per line, as `shared <path>` or `branch <path>`. Paths are relative to the root, no globs. The file itself is synced too.
## Rules of conduct
- These files are absent from `git status` on purpose. Never `git add -f` them and never add them to `.gitignore`.
- 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.
- 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
- `lodge log [path]` lists revisions with the machine, the branch and the commit of the main repository at that moment.
- `lodge show <revision|commit-sha> <path>` prints a file as it was at that revision, or as it was while that commit was checked out.
- `--json` on `status`, `log` and `sync` gives machine-readable output.
+371
View File
@@ -0,0 +1,371 @@
package client
import (
"cmp"
"context"
"encoding/json"
"errors"
"io/fs"
"maps"
"net/http"
"os"
"path/filepath"
"slices"
"strings"
"syscall"
"lodge/internal/gitrepo"
"lodge/internal/protocol"
)
const (
maxPasses = 4
tempSuffix = ".lodge-tmp"
)
type worktreeState struct {
Branch string `json:"branch"`
Base map[string]protocol.Manifest `json:"base"`
Stash map[string]protocol.Manifest `json:"stash"`
}
type Result struct {
Pushed int `json:"pushed"`
Pulled int `json:"pulled"`
Skipped []string `json:"skipped"`
}
type diskFiles struct {
shared protocol.Manifest
branch protocol.Manifest
skipped []string
tracked map[string]bool
}
func (c *Client) statePath(repo *gitrepo.Repo) string {
return filepath.Join(c.DataDir, "repos", repo.Key, hashOf([]byte(repo.Root))[:16]+".json")
}
func (c *Client) loadState(repo *gitrepo.Repo) *worktreeState {
st := &worktreeState{}
if data, err := os.ReadFile(c.statePath(repo)); err == nil {
_ = json.Unmarshal(data, st)
}
if st.Base == nil {
st.Base = map[string]protocol.Manifest{}
}
if st.Stash == nil {
st.Stash = map[string]protocol.Manifest{}
}
return st
}
func (c *Client) lock(repo *gitrepo.Repo) (func(), error) {
path := c.statePath(repo) + ".lock"
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
file.Close()
return nil, err
}
return func() { file.Close() }, nil
}
func (c *Client) 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)
return err
}
func (c *Client) Sync(ctx context.Context, dir string, online bool) (Result, error) {
var res Result
repo, err := gitrepo.Open(dir)
if err != nil {
return res, err
}
if err := c.register(repo.Root); err != nil {
return res, err
}
unlock, err := c.lock(repo)
if err != nil {
return res, err
}
defer unlock()
st := c.loadState(repo)
for range maxPasses {
again, err := c.pass(ctx, repo, st, online, &res)
saveErr := writeJSONFile(c.statePath(repo), st)
if err != nil || saveErr != nil || !again {
return res, errors.Join(err, saveErr)
}
}
return res, errors.New("gave up: server state keeps changing")
}
func (c *Client) scan(repo *gitrepo.Repo, rules Rules) diskFiles {
found := diskFiles{shared: protocol.Manifest{}, branch: protocol.Manifest{}, tracked: repo.Tracked(rules.paths())}
for _, rule := range rules {
start := filepath.Join(repo.Root, filepath.FromSlash(rule.Path))
_ = filepath.WalkDir(start, func(full string, entry fs.DirEntry, err error) error {
if err != nil {
return nil
}
if entry.IsDir() && entry.Name() == ".git" {
return filepath.SkipDir
}
if !entry.Type().IsRegular() || strings.HasSuffix(full, tempSuffix) {
return nil
}
rel, _ := filepath.Rel(repo.Root, full)
rel = filepath.ToSlash(rel)
if found.tracked[rel] || !rules.covers(rel, rule.Shared) {
return nil
}
info, err := entry.Info()
if err != nil {
return nil
}
if info.Size() > protocol.MaxBlobSize {
found.skipped = append(found.skipped, rel)
return nil
}
data, err := os.ReadFile(full)
if err != nil {
return nil
}
hash, err := c.cacheBlob(data)
if err != nil {
return nil
}
manifest := found.branch
if rule.Shared {
manifest = found.shared
}
manifest[rel] = protocol.File{Hash: hash, Time: info.ModTime().UnixMilli()}
return nil
})
}
return found
}
func reconcile(space string, local, base, remote protocol.Manifest, relevant func(string) bool) (first, second []protocol.Change, pull, next protocol.Manifest) {
pull, next = protocol.Manifest{}, maps.Clone(base)
if next == nil {
next = protocol.Manifest{}
}
paths := map[string]bool{}
for _, manifest := range []protocol.Manifest{local, base, remote} {
for path := range manifest {
paths[path] = true
}
}
for path := range paths {
if !relevant(path) {
continue
}
l, b, r := local[path], base[path], remote[path]
mine := protocol.Change{Space: space, Path: path, Base: r.Hash, Hash: l.Hash}
settled := r
switch {
case l.Hash == r.Hash:
case l.Hash == b.Hash, l.Hash == "" && r.Hash != b.Hash:
pull[path] = r
case r.Hash == b.Hash, r.Hash == "", l.Time >= r.Time:
first = append(first, mine)
settled = l
default:
first = append(first, mine)
second = append(second, protocol.Change{Space: space, Path: path, Base: l.Hash, Hash: r.Hash})
pull[path] = r
}
if settled.Hash == "" {
delete(next, path)
} else {
next[path] = settled
}
}
return first, second, pull, next
}
func (c *Client) pass(ctx context.Context, repo *gitrepo.Repo, st *worktreeState, online bool, res *Result) (bool, error) {
rules := LoadRules(repo.Root)
if err := writeExclude(repo, rules); err != nil {
return false, err
}
var remote *protocol.State
var offlineErr error
if online {
state, err := c.State(ctx, repo.Key, "")
remote, offlineErr = &state, err
if err != nil {
remote = nil
}
}
found := c.scan(repo, rules)
branch := cmp.Or(repo.Branch(), st.Branch)
if st.Branch != "" && branch != st.Branch {
if err := c.switchBranch(ctx, repo, st, rules, branch, found, remote); err != nil {
return false, err
}
found = c.scan(repo, rules)
}
st.Branch = branch
res.Skipped = found.skipped
if remote == nil {
return false, offlineErr
}
if remote.Seq == 0 {
st.Base = map[string]protocol.Manifest{}
}
locals := map[string]protocol.Manifest{protocol.SharedSpace: found.shared}
if branch != "" {
locals[protocol.BranchSpace(branch)] = found.branch
}
active := maps.Clone(locals)
for space, stashed := range st.Stash {
if active[space] == nil {
locals[space] = stashed
}
}
var first, second []protocol.Change
pulls, bases := map[string]protocol.Manifest{}, map[string]protocol.Manifest{}
spaces := map[string]bool{}
for _, group := range []map[string]protocol.Manifest{locals, st.Base, remote.Spaces} {
for space := range group {
spaces[space] = true
}
}
for space := range spaces {
local, syncing := locals[space]
if !syncing {
st.Base[space] = remote.Spaces[space]
if st.Base[space] == nil {
delete(st.Base, space)
}
continue
}
relevant := protocol.SafePath
if active[space] != nil {
relevant = func(path string) bool {
return protocol.SafePath(path) && rules.covers(path, space == protocol.SharedSpace) && !found.tracked[path]
}
}
mine, theirs, pull, next := reconcile(space, local, st.Base[space], remote.Spaces[space], relevant)
first, second, pulls[space], bases[space] = append(first, mine...), append(second, theirs...), pull, next
}
for _, change := range first {
if change.Hash == "" {
continue
}
if err := c.uploadBlob(ctx, change.Hash); err != nil {
return false, err
}
}
for _, changes := range [][]protocol.Change{first, second} {
if len(changes) == 0 {
continue
}
push := protocol.Push{Name: repo.Name, MainSHA: repo.Head(), MainBranch: branch, Changes: changes}
err := c.call(ctx, http.MethodPost, "/api/repos/"+repo.Key+"/revisions", push, nil)
if errors.Is(err, errConflict) {
return true, nil
}
if err != nil {
return false, err
}
}
res.Pushed += len(first)
again := false
for space, pull := range pulls {
for path, file := range pull {
if active[space] != nil {
if err := c.materialize(ctx, repo.Root, path, file); err != nil {
return false, err
}
} else if file.Hash == "" {
delete(st.Stash[space], path)
} else if _, err := c.Blob(ctx, file.Hash); err != nil {
return false, err
} else {
st.Stash[space][path] = file
}
res.Pulled++
again = again || path == RulesFile
}
}
maps.Copy(st.Base, bases)
return again, nil
}
func (c *Client) switchBranch(ctx context.Context, repo *gitrepo.Repo, st *worktreeState, rules Rules, branch string, found diskFiles, remote *protocol.State) error {
space := protocol.BranchSpace(branch)
st.Stash[protocol.BranchSpace(st.Branch)] = found.branch
known, exists := st.Stash[space]
if !exists {
known, exists = st.Base[space]
}
if !exists && remote != nil {
known, exists = remote.Spaces[space]
}
if !exists {
return nil
}
incoming := protocol.Manifest{}
for path, file := range known {
if !protocol.SafePath(path) || !rules.covers(path, false) || found.tracked[path] {
continue
}
if _, err := c.Blob(ctx, file.Hash); err != nil {
return err
}
incoming[path] = file
}
for path := range found.branch {
if err := c.materialize(ctx, repo.Root, path, protocol.File{}); err != nil {
return err
}
}
for path, file := range incoming {
if err := c.materialize(ctx, repo.Root, path, file); err != nil {
return err
}
}
delete(st.Stash, space)
return nil
}
func (c *Client) materialize(ctx context.Context, root, path string, file protocol.File) error {
full := filepath.Join(root, filepath.FromSlash(path))
if file.Hash != "" {
data, err := c.Blob(ctx, file.Hash)
if err != nil {
return err
}
return writeFile(full, data, 0o644)
}
if err := os.Remove(full); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
for dir := filepath.Dir(full); dir != root; dir = filepath.Dir(dir) {
if os.Remove(dir) != nil {
break
}
}
return nil
}
+251
View File
@@ -0,0 +1,251 @@
package client
import (
"context"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"lodge/internal/protocol"
"lodge/internal/server"
)
const adminToken = "admin"
type machine struct {
t *testing.T
client *Client
root string
}
func git(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
return string(out)
}
func setup(t *testing.T) (*machine, *machine) {
t.Helper()
store, err := server.OpenStore(filepath.Join(t.TempDir(), "lodge.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
api := httptest.NewServer(server.New(store, server.Config{AdminToken: adminToken}))
t.Cleanup(api.Close)
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")
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()}}
}
return machines[0], machines[1]
}
func write(t *testing.T, root, path, content string) {
t.Helper()
if err := writeFile(filepath.Join(root, path), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func (m *machine) sync() Result {
m.t.Helper()
res, err := m.client.Sync(context.Background(), m.root, true)
if err != nil {
m.t.Fatal(err)
}
return res
}
func (m *machine) expect(path, content string) {
m.t.Helper()
data, err := os.ReadFile(filepath.Join(m.root, path))
if content == "" && err == nil {
m.t.Fatalf("%s should not exist", path)
}
if content != "" && string(data) != content {
m.t.Fatalf("%s = %q (%v), want %q", path, data, err, content)
}
}
func TestFilesTravelBetweenMachinesAndStayOutOfGit(t *testing.T) {
mac, dell := setup(t)
write(t, mac.root, "docs/agents/plan.md", "v1")
write(t, mac.root, "docs/agents/tracked.md", "edited locally")
mac.sync()
if status := git(t, mac.root, "status", "--porcelain", "--", "docs/agents/plan.md", ".lodge"); status != "" {
t.Fatalf("agent files are visible to git: %s", status)
}
if _, err := os.Stat(filepath.Join(mac.root, ".gitignore")); err == nil {
t.Fatal(".gitignore must never be created")
}
dell.sync()
dell.expect("docs/agents/plan.md", "v1")
dell.expect("docs/agents/tracked.md", "in git")
write(t, dell.root, "docs/agents/plan.md", "v2")
dell.sync()
mac.sync()
mac.expect("docs/agents/plan.md", "v2")
os.Remove(filepath.Join(dell.root, "docs/agents/plan.md"))
dell.sync()
mac.sync()
mac.expect("docs/agents/plan.md", "")
}
func TestBranchNotesFollowTheBranchAndSharedStay(t *testing.T) {
mac, dell := setup(t)
write(t, mac.root, "docs/agents/arch.md", "shared")
write(t, mac.root, ".agent/plan.md", "main plan")
mac.sync()
git(t, mac.root, "checkout", "-b", "feature")
mac.sync()
mac.expect(".agent/plan.md", "main plan")
write(t, mac.root, ".agent/plan.md", "feature plan")
write(t, mac.root, ".agent/tried.md", "tried")
mac.sync()
git(t, mac.root, "checkout", "main")
if _, err := mac.client.Sync(context.Background(), mac.root, false); err != nil {
t.Fatal(err)
}
mac.expect(".agent/plan.md", "main plan")
mac.expect(".agent/tried.md", "")
mac.expect("docs/agents/arch.md", "shared")
git(t, mac.root, "checkout", "feature")
mac.sync()
mac.expect(".agent/plan.md", "feature plan")
mac.expect(".agent/tried.md", "tried")
dell.sync()
dell.expect(".agent/plan.md", "main plan")
git(t, dell.root, "checkout", "-b", "feature")
dell.sync()
dell.expect(".agent/plan.md", "feature plan")
dell.expect(".agent/tried.md", "tried")
}
func TestLaterEditWinsAndLoserStaysInHistory(t *testing.T) {
mac, dell := setup(t)
write(t, mac.root, "docs/agents/plan.md", "base")
mac.sync()
dell.sync()
write(t, mac.root, "docs/agents/plan.md", "from mac")
old := time.Now().Add(-time.Hour)
if err := os.Chtimes(filepath.Join(mac.root, "docs/agents/plan.md"), old, old); err != nil {
t.Fatal(err)
}
write(t, dell.root, "docs/agents/plan.md", "from dell")
dell.sync()
mac.sync()
mac.expect("docs/agents/plan.md", "from dell")
revisions, err := mac.client.Revisions(context.Background(), repoKey(t, mac), "docs/agents/plan.md")
if err != nil {
t.Fatal(err)
}
if len(revisions) != 4 {
t.Fatalf("want base, dell, losing mac and restored dell revisions, got %d", len(revisions))
}
state, err := mac.client.State(context.Background(), repoKey(t, mac), "3")
if err != nil {
t.Fatal(err)
}
lost, err := mac.client.Blob(context.Background(), state.Spaces[protocol.SharedSpace]["docs/agents/plan.md"].Hash)
if err != nil || string(lost) != "from mac" {
t.Fatalf("losing version is not in history: %q %v", lost, err)
}
}
func TestRulesFileTravelsAndHostileServerPathsAreIgnored(t *testing.T) {
mac, dell := setup(t)
write(t, mac.root, RulesFile, "shared notes\n")
write(t, mac.root, "notes/a.md", "a")
mac.sync()
dell.sync()
dell.expect("notes/a.md", "a")
if status := git(t, dell.root, "status", "--porcelain"); status != "" {
t.Fatalf("synced rules did not reach exclude: %s", status)
}
hash, _ := mac.client.cacheBlob([]byte("#!/bin/sh"))
if err := mac.client.uploadBlob(context.Background(), hash); err != nil {
t.Fatal(err)
}
for _, path := range []string{".git/hooks/pre-commit", "notes/../../escape", "/etc/passwd"} {
push := protocol.Push{Changes: []protocol.Change{{Space: protocol.SharedSpace, Path: path, Hash: hash}}}
err := mac.client.call(context.Background(), http.MethodPost, "/api/repos/"+repoKey(t, mac)+"/revisions", push, nil)
if err == nil {
t.Fatalf("server accepted hostile path %s", path)
}
}
}
func TestDeletingRepositoryOnServerKeepsFilesOnMachines(t *testing.T) {
mac, _ := setup(t)
write(t, mac.root, "docs/agents/plan.md", "v1")
mac.sync()
if err := mac.client.call(context.Background(), http.MethodDelete, "/api/repos/"+repoKey(t, mac), nil, nil); err != nil {
t.Fatal(err)
}
mac.sync()
mac.expect("docs/agents/plan.md", "v1")
revisions, err := mac.client.Revisions(context.Background(), repoKey(t, mac), "")
if err != nil || len(revisions) != 1 {
t.Fatalf("want a fresh history of one revision, got %d (%v)", len(revisions), err)
}
}
func TestAdminSessionCookie(t *testing.T) {
mac, _ := setup(t)
res, err := http.Post(mac.client.URL+"/api/session", "application/json", strings.NewReader(`{"password":"`+adminToken+`"}`))
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != http.StatusNoContent || len(res.Cookies()) != 1 {
t.Fatalf("login failed: %d", res.StatusCode)
}
req, _ := http.NewRequest(http.MethodGet, mac.client.URL+"/api/machines", nil)
req.AddCookie(res.Cookies()[0])
got, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
got.Body.Close()
if got.StatusCode != http.StatusOK {
t.Fatalf("cookie was not accepted: %d", got.StatusCode)
}
}
func repoKey(t *testing.T, m *machine) string {
t.Helper()
return git(t, m.root, "rev-list", "--max-parents=0", "HEAD")[:40]
}