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

+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
}