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 }