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,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
|
||||
}
|
||||
Reference in new issue
Block a user