feat: lodge server, client and admin
This commit is contained in:
commit
95fc50e2c2
84 files changed
+7625
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
Reference in new issue
Block a user