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,4 @@
|
||||
bin
|
||||
storage
|
||||
.env
|
||||
.env.*
|
||||
@@ -0,0 +1,2 @@
|
||||
ADMIN_TOKEN=
|
||||
PUBLIC_URL=
|
||||
@@ -0,0 +1,2 @@
|
||||
bin
|
||||
storage
|
||||
@@ -0,0 +1,60 @@
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
default: standard
|
||||
enable:
|
||||
- bodyclose
|
||||
- contextcheck
|
||||
- copyloopvar
|
||||
- errorlint
|
||||
- gocritic
|
||||
- gosec
|
||||
- misspell
|
||||
- perfsprint
|
||||
- revive
|
||||
- unconvert
|
||||
- unparam
|
||||
- usestdlibvars
|
||||
settings:
|
||||
gosec:
|
||||
excludes:
|
||||
- G104
|
||||
- G204
|
||||
- G301
|
||||
- G302
|
||||
- G304
|
||||
- G306
|
||||
- G122
|
||||
- G124
|
||||
- G703
|
||||
- G706
|
||||
revive:
|
||||
rules:
|
||||
- name: exported
|
||||
disabled: true
|
||||
- name: package-comments
|
||||
disabled: true
|
||||
errcheck:
|
||||
exclude-functions:
|
||||
- (*os.File).Close
|
||||
- (io.Closer).Close
|
||||
- (*database/sql.Rows).Close
|
||||
- (*database/sql.Tx).Rollback
|
||||
- (*lodge/internal/server.Store).Close
|
||||
- (*github.com/fsnotify/fsnotify.Watcher).Close
|
||||
- os.Unsetenv
|
||||
- os.Remove
|
||||
- fmt.Fprint
|
||||
- fmt.Fprintf
|
||||
- fmt.Fprintln
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofumpt
|
||||
- goimports
|
||||
settings:
|
||||
gofumpt:
|
||||
module-path: lodge
|
||||
goimports:
|
||||
local-prefixes:
|
||||
- lodge
|
||||
@@ -0,0 +1,25 @@
|
||||
FROM golang:1.27-alpine@sha256:4cb7ac979db5fcc41cae44b2227ba5ab8a51e8807f40d9ba4dee20a0ad960b5b AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
ENV CGO_ENABLED=0
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY cmd ./cmd
|
||||
COPY internal ./internal
|
||||
|
||||
RUN go build -trimpath -ldflags "-s -w" -o /out/lodge-server ./cmd/lodge-server
|
||||
RUN for target in darwin/arm64 darwin/amd64 linux/arm64 linux/amd64; do \
|
||||
GOOS=${target%/*} GOARCH=${target#*/} go build -trimpath -ldflags "-s -w" -o /out/dl/lodge-${target%/*}-${target#*/} ./cmd/lodge || exit 1; \
|
||||
done
|
||||
|
||||
FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /out/lodge-server /usr/local/bin/lodge-server
|
||||
COPY --from=build /out/dl ./dl
|
||||
|
||||
CMD ["lodge-server"]
|
||||
@@ -0,0 +1,35 @@
|
||||
COMPOSE := cd .. && docker compose
|
||||
IMAGE := lodge/backend
|
||||
LINT := go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2
|
||||
changed = ! git diff --quiet $(1) -- $(2) 2>/dev/null
|
||||
stamp = { git update-ref $(1) HEAD 2>/dev/null || true; }
|
||||
|
||||
.PHONY: fmt check test build pre-deploy post-deploy client dev
|
||||
|
||||
fmt:
|
||||
go mod tidy
|
||||
$(LINT) fmt
|
||||
|
||||
check:
|
||||
$(LINT) run
|
||||
$(MAKE) test
|
||||
|
||||
test:
|
||||
go test -race ./...
|
||||
|
||||
build:
|
||||
$(COMPOSE) build api
|
||||
|
||||
pre-deploy:
|
||||
@if $(call changed,refs/deploy/backend,.) || ! docker image inspect $(IMAGE) >/dev/null 2>&1; then \
|
||||
$(MAKE) build && $(call stamp,refs/deploy/backend); \
|
||||
fi
|
||||
|
||||
post-deploy:
|
||||
@:
|
||||
|
||||
client:
|
||||
go build -trimpath -ldflags "-s -w" -o bin/lodge ./cmd/lodge
|
||||
|
||||
dev:
|
||||
LODGE_DB=storage/lodge.db STATIC_DIR=../frontend/build CLIENTS_DIR=bin ADMIN_TOKEN=dev go run ./cmd/lodge-server
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"lodge/internal/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
database := cmp.Or(os.Getenv("LODGE_DB"), "/app/storage/lodge.db")
|
||||
if err := os.MkdirAll(filepath.Dir(database), 0o700); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
store, err := server.OpenStore(database)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
config := server.Config{
|
||||
AdminToken: os.Getenv("ADMIN_TOKEN"),
|
||||
PublicURL: os.Getenv("PUBLIC_URL"),
|
||||
StaticDir: cmp.Or(os.Getenv("STATIC_DIR"), "/app/static"),
|
||||
ClientsDir: cmp.Or(os.Getenv("CLIENTS_DIR"), "/app/dl"),
|
||||
}
|
||||
if config.AdminToken == "" {
|
||||
log.Print("ADMIN_TOKEN is empty: no machine can be added until it is set")
|
||||
}
|
||||
httpServer := &http.Server{
|
||||
Addr: cmp.Or(os.Getenv("LISTEN"), ":8080"),
|
||||
Handler: server.New(store, config),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
log.Printf("listening on %s", httpServer.Addr)
|
||||
log.Fatal(httpServer.ListenAndServe())
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"lodge/internal/client"
|
||||
"lodge/internal/gitrepo"
|
||||
"lodge/internal/protocol"
|
||||
)
|
||||
|
||||
const usage = `lodge keeps agent files out of git and in sync across machines
|
||||
|
||||
lodge login <url> <code> connect this machine using a code from the admin page
|
||||
lodge install start the daemon, install the git hook and the agent skill
|
||||
lodge uninstall undo install
|
||||
lodge update replace this binary with the one the server ships
|
||||
lodge add [dir] start syncing a repository that was cloned before install
|
||||
lodge forget [dir] stop syncing a repository
|
||||
lodge sync [dir] sync once, for machines without a daemon
|
||||
lodge status [dir] rules, files and oversized files of a repository
|
||||
lodge log [path] revisions of the repository or of one file
|
||||
lodge show <rev|sha> <path> a file as of a revision or a commit of the main repository
|
||||
lodge daemon run the sync daemon in the foreground
|
||||
|
||||
status, log and sync accept --json
|
||||
`
|
||||
|
||||
var hookEnvironment = []string{"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_PREFIX"}
|
||||
|
||||
type status struct {
|
||||
Root string `json:"root"`
|
||||
Key string `json:"key"`
|
||||
Branch string `json:"branch"`
|
||||
Rules []client.Rule `json:"rules"`
|
||||
Revision int64 `json:"revision"`
|
||||
Spaces map[string]protocol.Manifest `json:"spaces"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Print(usage)
|
||||
return
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
if os.Args[1] == "hook" {
|
||||
hook(ctx)
|
||||
return
|
||||
}
|
||||
err := run(ctx, os.Args[1], os.Args[2:])
|
||||
stop()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "lodge:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func hook(ctx context.Context) {
|
||||
for _, name := range hookEnvironment {
|
||||
os.Unsetenv(name)
|
||||
}
|
||||
if c, err := client.Load(); err == nil {
|
||||
_ = c.Hook(ctx, ".")
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, name string, args []string) error {
|
||||
flags := flag.NewFlagSet(name, flag.ExitOnError)
|
||||
asJSON := flags.Bool("json", false, "machine-readable output")
|
||||
_ = flags.Parse(args)
|
||||
args = flags.Args()
|
||||
target := func(fallback string) string {
|
||||
if len(args) > 0 {
|
||||
return args[0]
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "login":
|
||||
if len(args) != 2 {
|
||||
return errors.New("usage: lodge login <url> <code>")
|
||||
}
|
||||
return client.Login(ctx, args[0], args[1])
|
||||
case "help", "-h", "--help":
|
||||
fmt.Print(usage)
|
||||
return nil
|
||||
}
|
||||
|
||||
c, err := client.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch name {
|
||||
case "install":
|
||||
return c.Install()
|
||||
case "uninstall":
|
||||
return c.Uninstall()
|
||||
case "daemon":
|
||||
return c.Daemon(ctx)
|
||||
case "update":
|
||||
updated, err := c.Update(ctx)
|
||||
if err == nil && !updated {
|
||||
fmt.Println("already up to date")
|
||||
}
|
||||
return err
|
||||
case "add", "sync":
|
||||
res, err := c.Sync(ctx, target("."), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return output(*asJSON, res, func() {
|
||||
fmt.Printf("pushed %d, pulled %d\n", res.Pushed, res.Pulled)
|
||||
for _, path := range res.Skipped {
|
||||
fmt.Printf("skipped, over 5 MB: %s\n", path)
|
||||
}
|
||||
})
|
||||
case "forget":
|
||||
root, err := filepath.Abs(target("."))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if repo, err := gitrepo.Open(root); err == nil {
|
||||
root = repo.Root
|
||||
}
|
||||
return c.Forget(root)
|
||||
case "status":
|
||||
return showStatus(ctx, c, target("."), *asJSON)
|
||||
case "log":
|
||||
return showLog(ctx, c, target(""), *asJSON)
|
||||
case "show":
|
||||
if len(args) != 2 {
|
||||
return errors.New("usage: lodge show <revision|commit-sha> <path>")
|
||||
}
|
||||
return showFile(ctx, c, args[0], args[1])
|
||||
}
|
||||
return fmt.Errorf("unknown command %q, see lodge help", name)
|
||||
}
|
||||
|
||||
func output(asJSON bool, value any, human func()) error {
|
||||
if !asJSON {
|
||||
human()
|
||||
return nil
|
||||
}
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(value)
|
||||
}
|
||||
|
||||
func showStatus(ctx context.Context, c *client.Client, dir string, asJSON bool) error {
|
||||
repo, err := gitrepo.Open(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state, err := c.State(ctx, repo.Key, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
report := status{Root: repo.Root, Key: repo.Key, Branch: repo.Branch(), Rules: client.LoadRules(repo.Root), Revision: state.Seq, Spaces: state.Spaces}
|
||||
return output(asJSON, report, func() {
|
||||
fmt.Printf("%s\nkey %s, branch %s, revision %d\n\nrules:\n", report.Root, report.Key, report.Branch, report.Revision)
|
||||
for _, rule := range report.Rules {
|
||||
kind := "branch"
|
||||
if rule.Shared {
|
||||
kind = "shared"
|
||||
}
|
||||
fmt.Printf(" %s %s\n", kind, rule.Path)
|
||||
}
|
||||
for space, manifest := range report.Spaces {
|
||||
fmt.Printf("\n%s:\n", space)
|
||||
for path := range manifest {
|
||||
fmt.Printf(" %s\n", path)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func showLog(ctx context.Context, c *client.Client, path string, asJSON bool) error {
|
||||
repo, err := gitrepo.Open(".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
revisions, err := c.Revisions(ctx, repo.Key, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return output(asJSON, revisions, func() {
|
||||
for _, rev := range revisions {
|
||||
when := time.UnixMilli(rev.Time).Format("2006-01-02 15:04")
|
||||
fmt.Printf("%d %s %s %s@%.10s\n", rev.Seq, when, rev.Machine, rev.MainBranch, rev.MainSHA)
|
||||
for _, change := range rev.Changes {
|
||||
action := "M"
|
||||
if change.Base == "" {
|
||||
action = "A"
|
||||
} else if change.Hash == "" {
|
||||
action = "D"
|
||||
}
|
||||
fmt.Printf(" %s %s (%s)\n", action, change.Path, change.Space)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func showFile(ctx context.Context, c *client.Client, at, path string) error {
|
||||
repo, err := gitrepo.Open(".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state, err := c.State(ctx, repo.Key, at)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, space := range []string{protocol.SharedSpace, protocol.BranchSpace(repo.Branch())} {
|
||||
if file, found := state.Spaces[space][filepath.ToSlash(path)]; found {
|
||||
data, err := c.Blob(ctx, file.Hash)
|
||||
if err == nil {
|
||||
_, err = os.Stdout.Write(data)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s did not exist at %s", path, at)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
api:
|
||||
networks:
|
||||
default: {}
|
||||
caddy:
|
||||
aliases:
|
||||
- lodge-api
|
||||
|
||||
networks:
|
||||
caddy:
|
||||
external: true
|
||||
@@ -0,0 +1,14 @@
|
||||
services:
|
||||
api:
|
||||
build: backend
|
||||
image: lodge/backend
|
||||
profiles: [api, services]
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
- path: backend/.env
|
||||
required: false
|
||||
volumes:
|
||||
- ./frontend/build:/app/static:ro
|
||||
- ${STORAGE_ROOT:-./backend/storage}:/app/storage
|
||||
@@ -0,0 +1,20 @@
|
||||
module lodge
|
||||
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
modernc.org/sqlite v1.59.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
modernc.org/libc v1.75.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
||||
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
||||
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
|
||||
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
|
||||
modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
|
||||
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -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]
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package gitrepo
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var ErrNoIdentity = errors.New("repository has no commits and no origin to identify it by")
|
||||
|
||||
type Repo struct {
|
||||
Root string
|
||||
GitDir string
|
||||
CommonDir string
|
||||
Key string
|
||||
Name string
|
||||
}
|
||||
|
||||
func run(dir string, args ...string) (string, error) {
|
||||
out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).Output()
|
||||
return strings.TrimRight(string(out), "\n"), err
|
||||
}
|
||||
|
||||
func Open(dir string) (*Repo, error) {
|
||||
root, err := run(dir, "rev-parse", "--show-toplevel")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gitDir, err := run(root, "rev-parse", "--absolute-git-dir")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
commonDir, err := run(root, "rev-parse", "--path-format=absolute", "--git-common-dir")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
origin, _ := run(root, "config", "--get", "remote.origin.url")
|
||||
key, err := identity(root, origin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := path.Base(normalizeURL(origin))
|
||||
if origin == "" {
|
||||
name = path.Base(root)
|
||||
}
|
||||
return &Repo{Root: root, GitDir: gitDir, CommonDir: commonDir, Key: key, Name: name}, nil
|
||||
}
|
||||
|
||||
func identity(root, origin string) (string, error) {
|
||||
shallow, _ := run(root, "rev-parse", "--is-shallow-repository")
|
||||
if shallow != "true" {
|
||||
roots, err := run(root, "rev-list", "--max-parents=0", "HEAD")
|
||||
if err == nil && roots != "" {
|
||||
hashes := strings.Fields(roots)
|
||||
sort.Strings(hashes)
|
||||
return hashes[0], nil
|
||||
}
|
||||
}
|
||||
if origin == "" {
|
||||
return "", ErrNoIdentity
|
||||
}
|
||||
sum := sha256.Sum256([]byte(normalizeURL(origin)))
|
||||
return "tmp-" + hex.EncodeToString(sum[:20]), nil
|
||||
}
|
||||
|
||||
func normalizeURL(url string) string {
|
||||
url = strings.TrimSuffix(strings.TrimSuffix(strings.ToLower(url), "/"), ".git")
|
||||
if _, rest, ok := strings.Cut(url, "://"); ok {
|
||||
url = rest
|
||||
}
|
||||
if _, rest, ok := strings.Cut(url, "@"); ok {
|
||||
url = rest
|
||||
}
|
||||
return strings.ReplaceAll(url, ":", "/")
|
||||
}
|
||||
|
||||
func (r *Repo) Branch() string {
|
||||
branch, _ := run(r.Root, "symbolic-ref", "--short", "-q", "HEAD")
|
||||
return branch
|
||||
}
|
||||
|
||||
func (r *Repo) Head() string {
|
||||
sha, _ := run(r.Root, "rev-parse", "-q", "--verify", "HEAD")
|
||||
return sha
|
||||
}
|
||||
|
||||
func (r *Repo) Tracked(paths []string) map[string]bool {
|
||||
tracked := map[string]bool{}
|
||||
if len(paths) == 0 {
|
||||
return tracked
|
||||
}
|
||||
out, _ := run(r.Root, append([]string{"ls-files", "-z", "--"}, paths...)...)
|
||||
for _, file := range strings.Split(out, "\x00") {
|
||||
if file != "" {
|
||||
tracked[file] = true
|
||||
}
|
||||
}
|
||||
return tracked
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
SharedSpace = "shared"
|
||||
branchPrefix = "branch:"
|
||||
MaxBlobSize = 5 << 20
|
||||
)
|
||||
|
||||
type File struct {
|
||||
Hash string `json:"hash"`
|
||||
Time int64 `json:"time"`
|
||||
}
|
||||
|
||||
type Manifest map[string]File
|
||||
|
||||
type State struct {
|
||||
Seq int64 `json:"seq"`
|
||||
Spaces map[string]Manifest `json:"spaces"`
|
||||
}
|
||||
|
||||
type Change struct {
|
||||
Space string `json:"space"`
|
||||
Path string `json:"path"`
|
||||
Base string `json:"base"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
type Push struct {
|
||||
Name string `json:"name"`
|
||||
MainSHA string `json:"main_sha"`
|
||||
MainBranch string `json:"main_branch"`
|
||||
Changes []Change `json:"changes"`
|
||||
}
|
||||
|
||||
type Revision struct {
|
||||
Seq int64 `json:"seq"`
|
||||
Machine string `json:"machine"`
|
||||
Time int64 `json:"time"`
|
||||
MainSHA string `json:"main_sha"`
|
||||
MainBranch string `json:"main_branch"`
|
||||
Changes []Change `json:"changes"`
|
||||
}
|
||||
|
||||
type Repo struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Seq int64 `json:"seq"`
|
||||
Time int64 `json:"time"`
|
||||
}
|
||||
|
||||
type Machine struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
LastPush int64 `json:"last_push"`
|
||||
Revisions int64 `json:"revisions"`
|
||||
}
|
||||
|
||||
type Login struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
Repo string `json:"repo"`
|
||||
Seq int64 `json:"seq"`
|
||||
}
|
||||
|
||||
type ClaimRequest struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type ClaimResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type CodeResponse struct {
|
||||
Code string `json:"code"`
|
||||
Command string `json:"command"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
func BranchSpace(branch string) string {
|
||||
return branchPrefix + branch
|
||||
}
|
||||
|
||||
func ValidSpace(space string) bool {
|
||||
return space == SharedSpace || len(space) > len(branchPrefix) && strings.HasPrefix(space, branchPrefix)
|
||||
}
|
||||
|
||||
func SafePath(p string) bool {
|
||||
if p == "" || path.IsAbs(p) || path.Clean(p) != p || strings.ContainsAny(p, "\\\x00") {
|
||||
return false
|
||||
}
|
||||
first, _, _ := strings.Cut(p, "/")
|
||||
return first != ".." && first != "." && !strings.EqualFold(first, ".git")
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
os=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
arch=$(uname -m)
|
||||
case "$arch" in
|
||||
x86_64 | amd64) arch=amd64 ;;
|
||||
aarch64 | arm64) arch=arm64 ;;
|
||||
*) echo "lodge: unsupported architecture $arch" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
bin="$HOME/.local/bin"
|
||||
mkdir -p "$bin"
|
||||
curl -fsSL "{{URL}}/dl/lodge-$os-$arch" -o "$bin/lodge.new"
|
||||
chmod +x "$bin/lodge.new"
|
||||
mv "$bin/lodge.new" "$bin/lodge"
|
||||
|
||||
"$bin/lodge" login "{{URL}}" "{{CODE}}"
|
||||
"$bin/lodge" install
|
||||
|
||||
case ":$PATH:" in
|
||||
*":$bin:"*) ;;
|
||||
*) echo "lodge: add $bin to your PATH to use the lodge command" ;;
|
||||
esac
|
||||
@@ -0,0 +1,409 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"lodge/internal/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
heartbeat = 30 * time.Second
|
||||
sessionCookie = "lodge_session"
|
||||
loginDelay = time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed install.sh
|
||||
installScript string
|
||||
|
||||
repoKeyPattern = regexp.MustCompile(`^(tmp-)?[0-9a-f]{40,64}$`)
|
||||
hashPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
codePattern = regexp.MustCompile(`^[0-9a-f]+$`)
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AdminToken string
|
||||
PublicURL string
|
||||
StaticDir string
|
||||
ClientsDir string
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
config Config
|
||||
store *Store
|
||||
|
||||
mu sync.Mutex
|
||||
subscribers map[chan protocol.Event]struct{}
|
||||
}
|
||||
|
||||
type machineKey struct{}
|
||||
|
||||
func New(store *Store, config Config) http.Handler {
|
||||
s := &Server{config: config, store: store, subscribers: map[chan protocol.Event]struct{}{}}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /i/{code}", s.install)
|
||||
mux.Handle("GET /dl/", http.StripPrefix("/dl/", http.FileServer(http.Dir(config.ClientsDir))))
|
||||
mux.HandleFunc("POST /api/claim", s.claim)
|
||||
mux.HandleFunc("POST /api/session", s.login)
|
||||
mux.HandleFunc("GET /api/session", s.admin(noContent))
|
||||
mux.HandleFunc("DELETE /api/session", s.logout)
|
||||
mux.HandleFunc("POST /api/codes", s.admin(s.createCode))
|
||||
mux.HandleFunc("GET /api/machines", s.admin(s.machines))
|
||||
mux.HandleFunc("DELETE /api/machines/{id}", s.admin(s.deleteMachine))
|
||||
mux.HandleFunc("DELETE /api/repos/{key}", s.admin(s.deleteRepo))
|
||||
mux.HandleFunc("GET /api/repos", s.auth(s.repos))
|
||||
mux.HandleFunc("GET /api/repos/{key}/state", s.auth(s.state))
|
||||
mux.HandleFunc("GET /api/repos/{key}/revisions", s.auth(s.revisions))
|
||||
mux.HandleFunc("POST /api/repos/{key}/revisions", s.auth(s.push))
|
||||
mux.HandleFunc("GET /api/blobs/{hash}", s.auth(s.blob))
|
||||
mux.HandleFunc("PUT /api/blobs/{hash}", s.auth(s.putBlob))
|
||||
mux.HandleFunc("GET /api/events", s.auth(s.events))
|
||||
mux.HandleFunc("GET /api/", http.NotFound)
|
||||
mux.HandleFunc("GET /", s.static)
|
||||
return mux
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, err error) {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, ErrConflict):
|
||||
status = http.StatusConflict
|
||||
case errors.Is(err, ErrNotFound):
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
http.Error(w, err.Error(), status)
|
||||
}
|
||||
|
||||
func (s *Server) isAdminToken(token string) bool {
|
||||
return s.config.AdminToken != "" && subtle.ConstantTimeCompare([]byte(token), []byte(s.config.AdminToken)) == 1
|
||||
}
|
||||
|
||||
func (s *Server) isAdmin(r *http.Request) bool {
|
||||
if cookie, err := r.Cookie(sessionCookie); err == nil && s.store.SessionValid(r.Context(), cookie.Value) {
|
||||
return true
|
||||
}
|
||||
return s.isAdminToken(bearer(r))
|
||||
}
|
||||
|
||||
func noContent(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func bearer(r *http.Request) string {
|
||||
token, _ := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
return token
|
||||
}
|
||||
|
||||
func (s *Server) admin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.isAdmin(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var machineID int64
|
||||
if !s.isAdmin(r) {
|
||||
id, err := s.store.MachineID(r.Context(), bearer(r))
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
machineID = id
|
||||
}
|
||||
if key := r.PathValue("key"); key != "" && !repoKeyPattern.MatchString(key) {
|
||||
http.Error(w, "bad repository key", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), machineKey{}, machineID)))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) publicURL(r *http.Request) string {
|
||||
if s.config.PublicURL != "" {
|
||||
return strings.TrimSuffix(s.config.PublicURL, "/")
|
||||
}
|
||||
scheme := r.Header.Get("X-Forwarded-Proto")
|
||||
if scheme == "" {
|
||||
scheme = "http"
|
||||
}
|
||||
return scheme + "://" + r.Host
|
||||
}
|
||||
|
||||
func (s *Server) install(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.PathValue("code")
|
||||
if !codePattern.MatchString(code) || !s.store.CodeValid(r.Context(), code) {
|
||||
http.Error(w, "echo 'lodge: this install link has expired, create a new one' >&2; exit 1", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/x-shellscript")
|
||||
_, _ = strings.NewReplacer("{{URL}}", s.publicURL(r), "{{CODE}}", code).WriteString(w, installScript)
|
||||
}
|
||||
|
||||
func (s *Server) setSession(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
Expires: expires,
|
||||
HttpOnly: true,
|
||||
Secure: strings.HasPrefix(s.publicURL(r), "https://"),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
var req protocol.Login
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !s.isAdminToken(req.Password) {
|
||||
time.Sleep(loginDelay)
|
||||
http.Error(w, "wrong password", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
token, expires, err := s.store.CreateSession(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
s.setSession(w, r, token, expires)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie(sessionCookie); err == nil {
|
||||
_ = s.store.DeleteSession(r.Context(), cookie.Value)
|
||||
}
|
||||
s.setSession(w, r, "", time.Unix(0, 0))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) machines(w http.ResponseWriter, r *http.Request) {
|
||||
machines, err := s.store.Machines(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, machines)
|
||||
}
|
||||
|
||||
func (s *Server) deleteMachine(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err == nil {
|
||||
err = s.store.DeleteMachine(r.Context(), id)
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, ErrNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) deleteRepo(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.PathValue("key")
|
||||
if !repoKeyPattern.MatchString(key) {
|
||||
http.Error(w, "bad repository key", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteRepo(r.Context(), key); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
s.publish(protocol.Event{Repo: key})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) createCode(w http.ResponseWriter, r *http.Request) {
|
||||
code, expires, err := s.store.CreateCode(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, protocol.CodeResponse{
|
||||
Code: code,
|
||||
Command: fmt.Sprintf("curl -fsSL %s/i/%s | sh", s.publicURL(r), code),
|
||||
ExpiresAt: expires.UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) claim(w http.ResponseWriter, r *http.Request) {
|
||||
var req protocol.ClaimRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
token, err := s.store.Claim(r.Context(), req.Code, req.Name)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, protocol.ClaimResponse{Token: token})
|
||||
}
|
||||
|
||||
func (s *Server) repos(w http.ResponseWriter, r *http.Request) {
|
||||
repos, err := s.store.Repos(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, repos)
|
||||
}
|
||||
|
||||
func (s *Server) state(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := s.store.State(r.Context(), r.PathValue("key"), r.URL.Query().Get("at"))
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, state)
|
||||
}
|
||||
|
||||
func (s *Server) revisions(w http.ResponseWriter, r *http.Request) {
|
||||
revisions, err := s.store.Revisions(r.Context(), r.PathValue("key"), r.URL.Query().Get("path"))
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, revisions)
|
||||
}
|
||||
|
||||
func (s *Server) push(w http.ResponseWriter, r *http.Request) {
|
||||
var push protocol.Push
|
||||
if err := json.NewDecoder(r.Body).Decode(&push); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(push.Changes) == 0 {
|
||||
http.Error(w, "no changes", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
for _, change := range push.Changes {
|
||||
validHash := change.Hash == "" || hashPattern.MatchString(change.Hash)
|
||||
if !protocol.ValidSpace(change.Space) || !protocol.SafePath(change.Path) || !validHash || change.Hash == change.Base {
|
||||
http.Error(w, "bad change for "+change.Path, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
key := r.PathValue("key")
|
||||
machineID, _ := r.Context().Value(machineKey{}).(int64)
|
||||
seq, err := s.store.Push(r.Context(), key, machineID, push)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
s.publish(protocol.Event{Repo: key, Seq: seq})
|
||||
writeJSON(w, http.StatusCreated, protocol.Event{Repo: key, Seq: seq})
|
||||
}
|
||||
|
||||
func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := s.store.Blob(r.Context(), r.PathValue("hash"))
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func (s *Server) putBlob(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, protocol.MaxBlobSize))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
hash := hex.EncodeToString(sum[:])
|
||||
if hash != r.PathValue("hash") {
|
||||
http.Error(w, "content does not match hash", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.store.PutBlob(r.Context(), hash, data); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) publish(event protocol.Event) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for subscriber := range s.subscribers {
|
||||
select {
|
||||
case subscriber <- event:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
subscriber := make(chan protocol.Event, 16)
|
||||
s.mu.Lock()
|
||||
s.subscribers[subscriber] = struct{}{}
|
||||
s.mu.Unlock()
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.subscribers, subscriber)
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
fmt.Fprint(w, ": connected\n\n")
|
||||
flusher.Flush()
|
||||
ticker := time.NewTicker(heartbeat)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
fmt.Fprint(w, ": ping\n\n")
|
||||
case event := <-subscriber:
|
||||
data, _ := json.Marshal(event)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) static(w http.ResponseWriter, r *http.Request) {
|
||||
file := filepath.Join(s.config.StaticDir, filepath.FromSlash(filepath.Clean("/"+r.URL.Path)))
|
||||
if info, err := os.Stat(file); err != nil || info.IsDir() {
|
||||
file = filepath.Join(s.config.StaticDir, "index.html")
|
||||
}
|
||||
http.ServeFile(w, r, file)
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"lodge/internal/protocol"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const (
|
||||
codeLifetime = 10 * time.Minute
|
||||
sessionLifetime = 30 * 24 * time.Hour
|
||||
revisionLimit = 500
|
||||
schema = `
|
||||
create table if not exists machines (
|
||||
id integer primary key,
|
||||
name text not null,
|
||||
token_hash text not null unique,
|
||||
created_at integer not null
|
||||
);
|
||||
create table if not exists codes (
|
||||
code text primary key,
|
||||
expires_at integer not null
|
||||
);
|
||||
create table if not exists sessions (
|
||||
token_hash text primary key,
|
||||
expires_at integer not null
|
||||
);
|
||||
create table if not exists repos (
|
||||
key text primary key,
|
||||
name text not null,
|
||||
seq integer not null,
|
||||
time integer not null
|
||||
);
|
||||
create table if not exists blobs (
|
||||
hash text primary key,
|
||||
data blob not null
|
||||
);
|
||||
create table if not exists revisions (
|
||||
repo text not null,
|
||||
seq integer not null,
|
||||
machine_id integer,
|
||||
time integer not null,
|
||||
main_sha text not null,
|
||||
main_branch text not null,
|
||||
primary key (repo, seq)
|
||||
);
|
||||
create table if not exists changes (
|
||||
repo text not null,
|
||||
seq integer not null,
|
||||
space text not null,
|
||||
path text not null,
|
||||
base text not null,
|
||||
hash text not null,
|
||||
primary key (repo, seq, space, path)
|
||||
);
|
||||
create index if not exists changes_by_path on changes (repo, space, path, seq);
|
||||
`
|
||||
)
|
||||
|
||||
var (
|
||||
ErrConflict = errors.New("state changed since base")
|
||||
ErrNotFound = errors.New("not found")
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func OpenStore(path string) (*Store, error) {
|
||||
dsn := "file:" + path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=synchronous(NORMAL)"
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func randomHex(bytes int) string {
|
||||
buf := make([]byte, bytes)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s *Store) CreateCode(ctx context.Context) (string, time.Time, error) {
|
||||
code, expires := randomHex(12), time.Now().Add(codeLifetime)
|
||||
_, err := s.db.ExecContext(ctx, `insert into codes (code, expires_at) values (?, ?)`, code, expires.UnixMilli())
|
||||
return code, expires, err
|
||||
}
|
||||
|
||||
func (s *Store) CodeValid(ctx context.Context, code string) bool {
|
||||
var found int
|
||||
err := s.db.QueryRowContext(ctx, `select 1 from codes where code = ? and expires_at > ?`, code, time.Now().UnixMilli()).Scan(&found)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Store) Claim(ctx context.Context, code, name string) (string, error) {
|
||||
now := time.Now().UnixMilli()
|
||||
res, err := s.db.ExecContext(ctx, `delete from codes where code = ? and expires_at > ?`, code, now)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if deleted, _ := res.RowsAffected(); deleted == 0 {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
token := randomHex(32)
|
||||
_, err = s.db.ExecContext(ctx, `insert into machines (name, token_hash, created_at) values (?, ?, ?)`, name, hashToken(token), now)
|
||||
return token, err
|
||||
}
|
||||
|
||||
func (s *Store) MachineID(ctx context.Context, token string) (int64, error) {
|
||||
var id int64
|
||||
err := s.db.QueryRowContext(ctx, `select id from machines where token_hash = ?`, hashToken(token)).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *Store) CreateSession(ctx context.Context) (string, time.Time, error) {
|
||||
token, expires := randomHex(32), time.Now().Add(sessionLifetime)
|
||||
_, err := s.db.ExecContext(ctx, `insert into sessions (token_hash, expires_at) values (?, ?)`, hashToken(token), expires.UnixMilli())
|
||||
return token, expires, err
|
||||
}
|
||||
|
||||
func (s *Store) SessionValid(ctx context.Context, token string) bool {
|
||||
var found int
|
||||
err := s.db.QueryRowContext(ctx, `select 1 from sessions where token_hash = ? and expires_at > ?`, hashToken(token), time.Now().UnixMilli()).Scan(&found)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSession(ctx context.Context, token string) error {
|
||||
_, err := s.db.ExecContext(ctx, `delete from sessions where token_hash = ? or expires_at <= ?`, hashToken(token), time.Now().UnixMilli())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Machines(ctx context.Context) ([]protocol.Machine, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
select m.id, m.name, m.created_at, coalesce(max(r.time), 0), count(r.seq)
|
||||
from machines m left join revisions r on r.machine_id = m.id
|
||||
group by m.id order by m.created_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
machines := []protocol.Machine{}
|
||||
for rows.Next() {
|
||||
var machine protocol.Machine
|
||||
if err := rows.Scan(&machine.ID, &machine.Name, &machine.CreatedAt, &machine.LastPush, &machine.Revisions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
machines = append(machines, machine)
|
||||
}
|
||||
return machines, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) DeleteMachine(ctx context.Context, id int64) error {
|
||||
res, err := s.db.ExecContext(ctx, `delete from machines where id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if deleted, _ := res.RowsAffected(); deleted == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteRepo(ctx context.Context, repo string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `delete from changes where repo = ?`, repo); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `delete from revisions where repo = ?`, repo); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := tx.ExecContext(ctx, `delete from repos where key = ?`, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if deleted, _ := res.RowsAffected(); deleted == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `delete from blobs where hash not in (select hash from changes)`); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) PutBlob(ctx context.Context, hash string, data []byte) error {
|
||||
_, err := s.db.ExecContext(ctx, `insert or ignore into blobs (hash, data) values (?, ?)`, hash, data)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Blob(ctx context.Context, hash string) ([]byte, error) {
|
||||
var data []byte
|
||||
err := s.db.QueryRowContext(ctx, `select data from blobs where hash = ?`, hash).Scan(&data)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
func (s *Store) Repos(ctx context.Context) ([]protocol.Repo, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `select key, name, seq, time from repos order by time desc`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
repos := []protocol.Repo{}
|
||||
for rows.Next() {
|
||||
var repo protocol.Repo
|
||||
if err := rows.Scan(&repo.Key, &repo.Name, &repo.Seq, &repo.Time); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repos = append(repos, repo)
|
||||
}
|
||||
return repos, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) resolveSeq(ctx context.Context, repo, at string) (int64, error) {
|
||||
var seq sql.NullInt64
|
||||
var err error
|
||||
if n, convErr := strconv.ParseInt(at, 10, 64); at != "" && convErr == nil {
|
||||
return n, nil
|
||||
}
|
||||
if at == "" {
|
||||
err = s.db.QueryRowContext(ctx, `select max(seq) from revisions where repo = ?`, repo).Scan(&seq)
|
||||
} else {
|
||||
err = s.db.QueryRowContext(ctx, `select max(seq) from revisions where repo = ? and main_sha like ? || '%'`, repo, at).Scan(&seq)
|
||||
if err == nil && !seq.Valid {
|
||||
return 0, ErrNotFound
|
||||
}
|
||||
}
|
||||
return seq.Int64, err
|
||||
}
|
||||
|
||||
func (s *Store) State(ctx context.Context, repo, at string) (protocol.State, error) {
|
||||
state := protocol.State{Spaces: map[string]protocol.Manifest{}}
|
||||
seq, err := s.resolveSeq(ctx, repo, at)
|
||||
if err != nil {
|
||||
return state, err
|
||||
}
|
||||
state.Seq = seq
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
select c.space, c.path, c.hash, r.time
|
||||
from changes c join revisions r on r.repo = c.repo and r.seq = c.seq
|
||||
where c.repo = ?1 and c.hash != '' and c.seq = (
|
||||
select max(seq) from changes
|
||||
where repo = c.repo and space = c.space and path = c.path and seq <= ?2
|
||||
)`, repo, seq)
|
||||
if err != nil {
|
||||
return state, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var space, path string
|
||||
var file protocol.File
|
||||
if err := rows.Scan(&space, &path, &file.Hash, &file.Time); err != nil {
|
||||
return state, err
|
||||
}
|
||||
if state.Spaces[space] == nil {
|
||||
state.Spaces[space] = protocol.Manifest{}
|
||||
}
|
||||
state.Spaces[space][path] = file
|
||||
}
|
||||
return state, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) Revisions(ctx context.Context, repo, path string) ([]protocol.Revision, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
select r.seq, coalesce(m.name, iif(r.machine_id is null, 'web', 'revoked')), r.time, r.main_sha, r.main_branch, c.space, c.path, c.base, c.hash
|
||||
from revisions r
|
||||
join changes c on c.repo = r.repo and c.seq = r.seq
|
||||
left join machines m on m.id = r.machine_id
|
||||
where r.repo = ?1 and (?2 = '' or c.path = ?2)
|
||||
order by r.seq desc, c.path
|
||||
limit ?3`, repo, path, revisionLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
revisions := []protocol.Revision{}
|
||||
for rows.Next() {
|
||||
var rev protocol.Revision
|
||||
var change protocol.Change
|
||||
if err := rows.Scan(&rev.Seq, &rev.Machine, &rev.Time, &rev.MainSHA, &rev.MainBranch, &change.Space, &change.Path, &change.Base, &change.Hash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if last := len(revisions) - 1; last >= 0 && revisions[last].Seq == rev.Seq {
|
||||
revisions[last].Changes = append(revisions[last].Changes, change)
|
||||
continue
|
||||
}
|
||||
rev.Changes = []protocol.Change{change}
|
||||
revisions = append(revisions, rev)
|
||||
}
|
||||
return revisions, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) Push(ctx context.Context, repo string, machineID int64, push protocol.Push) (int64, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, change := range push.Changes {
|
||||
var current string
|
||||
err := tx.QueryRowContext(ctx, `select hash from changes where repo = ? and space = ? and path = ? order by seq desc limit 1`, repo, change.Space, change.Path).Scan(¤t)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, err
|
||||
}
|
||||
if current != change.Base {
|
||||
return 0, ErrConflict
|
||||
}
|
||||
if change.Hash == "" {
|
||||
continue
|
||||
}
|
||||
var found int
|
||||
if err := tx.QueryRowContext(ctx, `select 1 from blobs where hash = ?`, change.Hash).Scan(&found); err != nil {
|
||||
return 0, fmt.Errorf("blob %s was not uploaded: %w", change.Hash, ErrNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
var seq int64
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
insert into repos (key, name, seq, time) values (?, ?, 1, ?)
|
||||
on conflict (key) do update set seq = seq + 1, name = excluded.name, time = excluded.time
|
||||
returning seq`, repo, push.Name, now).Scan(&seq)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
machine := sql.NullInt64{Int64: machineID, Valid: machineID != 0}
|
||||
if _, err := tx.ExecContext(ctx, `insert into revisions (repo, seq, machine_id, time, main_sha, main_branch) values (?, ?, ?, ?, ?, ?)`, repo, seq, machine, now, push.MainSHA, push.MainBranch); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, change := range push.Changes {
|
||||
if _, err := tx.ExecContext(ctx, `insert into changes (repo, seq, space, path, base, hash) values (?, ?, ?, ?, ?, ?)`, repo, seq, change.Space, change.Path, change.Base, change.Hash); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return seq, tx.Commit()
|
||||
}
|
||||
Reference in new issue
Block a user