372 lines
9.3 KiB
Go
372 lines
9.3 KiB
Go
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
|
|
}
|