From 78f30f901e6c11ae1cb064acc6d58eec1901ad0d Mon Sep 17 00:00:00 2001 From: jolheiser Date: Thu, 22 Feb 2024 13:14:05 -0600 Subject: [PATCH] feat: native git Signed-off-by: jolheiser --- cmd/ugitd/main.go | 76 +++++++++++- internal/git/protocol.go | 196 ++----------------------------- internal/git/protocol_git.go | 62 ++++++++++ internal/git/protocol_gogit.go | 191 ++++++++++++++++++++++++++++++ internal/html/generate.go | 3 +- internal/html/markup/markdown.go | 3 +- internal/http/index.go | 4 + internal/http/repo.go | 3 +- 8 files changed, 350 insertions(+), 188 deletions(-) create mode 100644 internal/git/protocol_git.go create mode 100644 internal/git/protocol_gogit.go diff --git a/cmd/ugitd/main.go b/cmd/ugitd/main.go index 3286e60..e7fab79 100644 --- a/cmd/ugitd/main.go +++ b/cmd/ugitd/main.go @@ -6,6 +6,12 @@ import ( "fmt" "os" "os/signal" + "path/filepath" + "strconv" + "strings" + + "github.com/go-git/go-git/v5/plumbing/protocol/packp" + "go.jolheiser.com/ugit/internal/git" "go.jolheiser.com/ugit/internal/http" "go.jolheiser.com/ugit/internal/ssh" @@ -16,6 +22,11 @@ import ( ) func main() { + if len(os.Args) > 1 && os.Args[1] == "pre-receive-hook" { + preReceive() + return + } + args, err := parseArgs(os.Args[1:]) if err != nil { if errors.Is(err, flag.ErrHelp) { @@ -23,6 +34,10 @@ func main() { } panic(err) } + args.RepoDir, err = filepath.Abs(args.RepoDir) + if err != nil { + panic(err) + } if args.Debug { trace.SetTarget(trace.Packet) @@ -32,7 +47,7 @@ func main() { ssh.DefaultLogger = ssh.NoopLogger } - if err := os.MkdirAll(args.RepoDir, os.ModePerm); err != nil { + if err := requiredFS(args.RepoDir); err != nil { panic(err) } @@ -83,3 +98,62 @@ func main() { signal.Notify(ch, os.Kill, os.Interrupt) <-ch } + +func requiredFS(repoDir string) error { + if err := os.MkdirAll(repoDir, os.ModePerm); err != nil { + return err + } + + if !git.RequiresHook { + return nil + } + bin, err := os.Executable() + if err != nil { + return err + } + + fp := filepath.Join(repoDir, "hooks") + if err := os.MkdirAll(fp, os.ModePerm); err != nil { + return err + } + fp = filepath.Join(fp, "pre-receive") + + fi, err := os.Create(fp) + if err != nil { + return err + } + fi.WriteString("#!/usr/bin/env bash\n") + fi.WriteString(fmt.Sprintf("%s pre-receive-hook\n", bin)) + fi.Close() + + return os.Chmod(fp, 0o755) +} + +func preReceive() { + repoDir, ok := os.LookupEnv("UGIT_REPODIR") + if !ok { + panic("UGIT_REPODIR is not set") + } + + opts := make([]*packp.Option, 0) + if pushCount, err := strconv.Atoi(os.Getenv("GIT_PUSH_OPTION_COUNT")); err == nil { + for idx := 0; idx < pushCount; idx++ { + opt := os.Getenv(fmt.Sprintf("GIT_PUSH_OPTION_%d", idx)) + kv := strings.SplitN(opt, "=", 2) + if len(kv) == 2 { + opts = append(opts, &packp.Option{ + Key: kv[0], + Value: kv[1], + }) + } + } + } + + repo, err := git.NewRepo(filepath.Dir(repoDir), filepath.Base(repoDir)) + if err != nil { + panic(err) + } + if err := git.HandlePushOptions(repo, opts); err != nil { + panic(err) + } +} diff --git a/internal/git/protocol.go b/internal/git/protocol.go index 2da2417..26a5ae7 100644 --- a/internal/git/protocol.go +++ b/internal/git/protocol.go @@ -1,23 +1,15 @@ package git import ( - "bufio" "context" - "fmt" "io" "strconv" "strings" - "github.com/go-git/go-billy/v5/osfs" "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing/format/pktline" "github.com/go-git/go-git/v5/plumbing/protocol/packp" - "github.com/go-git/go-git/v5/plumbing/protocol/packp/capability" "github.com/go-git/go-git/v5/plumbing/serverinfo" - "github.com/go-git/go-git/v5/plumbing/transport" - "github.com/go-git/go-git/v5/plumbing/transport/server" "github.com/go-git/go-git/v5/storage/filesystem" - "github.com/go-git/go-git/v5/utils/ioutil" ) // ReadWriteContexter is the interface required to operate on git protocols @@ -26,179 +18,25 @@ type ReadWriteContexter interface { Context() context.Context } -// Protocol handles the endpoint and server of the git protocols -type Protocol struct { - endpoint *transport.Endpoint - server transport.Transport +type Protocoler interface { + HTTPInfoRefs(ReadWriteContexter) error + HTTPUploadPack(ReadWriteContexter) error + SSHUploadPack(ReadWriteContexter) error + SSHReceivePack(ReadWriteContexter, *Repo) error } -// NewProtocol constructs a Protocol for a given repo -func NewProtocol(repoPath string) (Protocol, error) { - endpoint, err := transport.NewEndpoint("/") - if err != nil { - return Protocol{}, err - } - fs := osfs.New(repoPath) - loader := server.NewFilesystemLoader(fs) - gitServer := server.NewServer(loader) - return Protocol{ - endpoint: endpoint, - server: gitServer, - }, nil -} - -// HTTPInfoRefs handles the inforef part of the HTTP protocol -func (p Protocol) HTTPInfoRefs(rwc ReadWriteContexter) error { - session, err := p.server.NewUploadPackSession(p.endpoint, nil) +// UpdateServerInfo handles updating server info for the git repo +func UpdateServerInfo(repo string) error { + r, err := git.PlainOpen(repo) if err != nil { return err } - defer ioutil.CheckClose(rwc, &err) - return p.infoRefs(rwc, session, "# service=git-upload-pack") + fs := r.Storer.(*filesystem.Storage).Filesystem() + return serverinfo.UpdateServerInfo(r.Storer, fs) } -func (p Protocol) infoRefs(rwc ReadWriteContexter, session transport.UploadPackSession, prefix string) error { - ar, err := session.AdvertisedReferencesContext(rwc.Context()) - if err != nil { - return err - } - - if prefix != "" { - ar.Prefix = [][]byte{ - []byte(prefix), - pktline.Flush, - } - } - - if err := ar.Encode(rwc); err != nil { - return err - } - - return nil -} - -// HTTPUploadPack handles the upload-pack process for HTTP -func (p Protocol) HTTPUploadPack(rwc ReadWriteContexter) error { - return p.uploadPack(rwc, false) -} - -// SSHUploadPack handles the upload-pack process for SSH -func (p Protocol) SSHUploadPack(rwc ReadWriteContexter) error { - return p.uploadPack(rwc, true) -} - -func (p Protocol) uploadPack(rwc ReadWriteContexter, ssh bool) error { - session, err := p.server.NewUploadPackSession(p.endpoint, nil) - if err != nil { - return err - } - defer ioutil.CheckClose(rwc, &err) - - if ssh { - if err := p.infoRefs(rwc, session, ""); err != nil { - return err - } - } - - req := packp.NewUploadPackRequest() - if err := req.Decode(rwc); err != nil { - return err - } - - var resp *packp.UploadPackResponse - resp, err = session.UploadPack(rwc.Context(), req) - if err != nil { - return err - } - - if err := resp.Encode(rwc); err != nil { - return fmt.Errorf("could not encode upload pack: %w", err) - } - - return nil -} - -// SSHReceivePack handles the receive-pack process for SSH -func (p Protocol) SSHReceivePack(rwc ReadWriteContexter, repo *Repo) error { - buf := bufio.NewReader(rwc) - - session, err := p.server.NewReceivePackSession(p.endpoint, nil) - if err != nil { - return err - } - - ar, err := session.AdvertisedReferencesContext(rwc.Context()) - if err != nil { - return fmt.Errorf("internal error in advertised references: %w", err) - } - _ = ar.Capabilities.Set(capability.PushOptions) - _ = ar.Capabilities.Set("no-thin") - - if err := ar.Encode(rwc); err != nil { - return fmt.Errorf("error in advertised references encoding: %w", err) - } - - req := packp.NewReferenceUpdateRequest() - _ = req.Capabilities.Set(capability.ReportStatus) - if err := req.Decode(buf); err != nil { - // FIXME this is a hack, but go-git doesn't accept a 0000 if there are no refs to update - if !strings.EqualFold(err.Error(), "capabilities delimiter not found") { - return fmt.Errorf("error decoding: %w", err) - } - } - - // FIXME also a hack, if the next bytes are PACK then we have a packfile, otherwise assume it's push options - peek, err := buf.Peek(4) - if err != nil { - return err - } - if string(peek) != "PACK" { - s := pktline.NewScanner(buf) - for s.Scan() { - val := string(s.Bytes()) - if val == "" { - break - } - if s.Err() != nil { - return s.Err() - } - parts := strings.SplitN(val, "=", 2) - req.Options = append(req.Options, &packp.Option{ - Key: parts[0], - Value: parts[1], - }) - } - } - - if err := handlePushOptions(repo, req.Options); err != nil { - return fmt.Errorf("could not handle push options: %w", err) - } - - // FIXME if there are only delete commands, there is no packfile and ReceivePack will block forever - noPack := true - for _, c := range req.Commands { - if c.Action() != packp.Delete { - noPack = false - break - } - } - if noPack { - req.Packfile = nil - } - - rs, err := session.ReceivePack(rwc.Context(), req) - if err != nil { - return fmt.Errorf("error in receive pack: %w", err) - } - - if err := rs.Encode(rwc); err != nil { - return fmt.Errorf("could not encode receive pack: %w", err) - } - - return nil -} - -func handlePushOptions(repo *Repo, opts []*packp.Option) error { +// HandlePushOptions handles all relevant push options for a [Repo] and saves the new [RepoMeta] +func HandlePushOptions(repo *Repo, opts []*packp.Option) error { var changed bool for _, opt := range opts { switch strings.ToLower(opt.Key) { @@ -219,13 +57,3 @@ func handlePushOptions(repo *Repo, opts []*packp.Option) error { } return nil } - -// UpdateServerInfo handles updating server info for the git repo -func UpdateServerInfo(repo string) error { - r, err := git.PlainOpen(repo) - if err != nil { - return err - } - fs := r.Storer.(*filesystem.Storage).Filesystem() - return serverinfo.UpdateServerInfo(r.Storer, fs) -} diff --git a/internal/git/protocol_git.go b/internal/git/protocol_git.go new file mode 100644 index 0000000..7ee650c --- /dev/null +++ b/internal/git/protocol_git.go @@ -0,0 +1,62 @@ +//go:build !gogit + +package git + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/go-git/go-git/v5/plumbing/format/pktline" +) + +var RequiresHook = true + +type CmdProtocol string + +func NewProtocol(repoPath string) (Protocoler, error) { + return CmdProtocol(repoPath), nil +} + +func (c CmdProtocol) HTTPInfoRefs(ctx ReadWriteContexter) error { + pkt := pktline.NewEncoder(ctx) + if err := pkt.EncodeString("# service=git-upload-pack"); err != nil { + return err + } + if err := pkt.Flush(); err != nil { + return err + } + return gitService(ctx, "receive-pack", string(c), "--stateless-rpc", "--advertise-refs") +} + +func (c CmdProtocol) HTTPUploadPack(ctx ReadWriteContexter) error { + return gitService(ctx, "upload-pack", string(c), "--stateless-rpc") +} + +func (c CmdProtocol) SSHUploadPack(ctx ReadWriteContexter) error { + return gitService(ctx, "upload-pack", string(c)) +} + +func (c CmdProtocol) SSHReceivePack(ctx ReadWriteContexter, _ *Repo) error { + return gitService(ctx, "receive-pack", string(c)) +} + +func gitService(ctx ReadWriteContexter, command, repoDir string, args ...string) error { + cmd := exec.CommandContext(ctx.Context(), "git") + cmd.Args = append(cmd.Args, []string{ + "-c", "uploadpack.allowFilter=true", + "-c", "receive.advertisePushOptions=true", + "-c", fmt.Sprintf("core.hooksPath=%s", filepath.Join(filepath.Dir(repoDir), "hooks")), + command, + }...) + if len(args) > 0 { + cmd.Args = append(cmd.Args, args...) + } + cmd.Args = append(cmd.Args, repoDir) + cmd.Env = append(os.Environ(), fmt.Sprintf("UGIT_REPODIR=%s", repoDir)) + cmd.Stdin = ctx + cmd.Stdout = ctx + + return cmd.Run() +} diff --git a/internal/git/protocol_gogit.go b/internal/git/protocol_gogit.go new file mode 100644 index 0000000..c437b29 --- /dev/null +++ b/internal/git/protocol_gogit.go @@ -0,0 +1,191 @@ +//go:build gogit + +package git + +import ( + "bufio" + "fmt" + "strings" + + "github.com/go-git/go-billy/v5/osfs" + "github.com/go-git/go-git/v5/plumbing/format/pktline" + "github.com/go-git/go-git/v5/plumbing/protocol/packp" + "github.com/go-git/go-git/v5/plumbing/protocol/packp/capability" + "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/go-git/go-git/v5/plumbing/transport/server" + "github.com/go-git/go-git/v5/utils/ioutil" +) + +var RequiresHook = false + +// Protocol handles the endpoint and server of the git protocols +type Protocol struct { + endpoint *transport.Endpoint + server transport.Transport +} + +// NewProtocol constructs a Protocol for a given repo +func NewProtocol(repoPath string) (Protocoler, error) { + endpoint, err := transport.NewEndpoint("/") + if err != nil { + return Protocol{}, err + } + fs := osfs.New(repoPath) + loader := server.NewFilesystemLoader(fs) + gitServer := server.NewServer(loader) + return Protocol{ + endpoint: endpoint, + server: gitServer, + }, nil +} + +// HTTPInfoRefs handles the inforef part of the HTTP protocol +func (p Protocol) HTTPInfoRefs(rwc ReadWriteContexter) error { + session, err := p.server.NewUploadPackSession(p.endpoint, nil) + if err != nil { + return err + } + defer ioutil.CheckClose(rwc, &err) + return p.infoRefs(rwc, session, "# service=git-upload-pack") +} + +func (p Protocol) infoRefs(rwc ReadWriteContexter, session transport.UploadPackSession, prefix string) error { + ar, err := session.AdvertisedReferencesContext(rwc.Context()) + if err != nil { + return err + } + + if prefix != "" { + ar.Prefix = [][]byte{ + []byte(prefix), + pktline.Flush, + } + } + + if err := ar.Encode(rwc); err != nil { + return err + } + + return nil +} + +// HTTPUploadPack handles the upload-pack process for HTTP +func (p Protocol) HTTPUploadPack(rwc ReadWriteContexter) error { + return p.uploadPack(rwc, false) +} + +// SSHUploadPack handles the upload-pack process for SSH +func (p Protocol) SSHUploadPack(rwc ReadWriteContexter) error { + return p.uploadPack(rwc, true) +} + +func (p Protocol) uploadPack(rwc ReadWriteContexter, ssh bool) error { + session, err := p.server.NewUploadPackSession(p.endpoint, nil) + if err != nil { + return err + } + defer ioutil.CheckClose(rwc, &err) + + if ssh { + if err := p.infoRefs(rwc, session, ""); err != nil { + return err + } + } + + req := packp.NewUploadPackRequest() + if err := req.Decode(rwc); err != nil { + return err + } + + var resp *packp.UploadPackResponse + resp, err = session.UploadPack(rwc.Context(), req) + if err != nil { + return err + } + + if err := resp.Encode(rwc); err != nil { + return fmt.Errorf("could not encode upload pack: %w", err) + } + + return nil +} + +// SSHReceivePack handles the receive-pack process for SSH +func (p Protocol) SSHReceivePack(rwc ReadWriteContexter, repo *Repo) error { + buf := bufio.NewReader(rwc) + + session, err := p.server.NewReceivePackSession(p.endpoint, nil) + if err != nil { + return err + } + + ar, err := session.AdvertisedReferencesContext(rwc.Context()) + if err != nil { + return fmt.Errorf("internal error in advertised references: %w", err) + } + _ = ar.Capabilities.Set(capability.PushOptions) + _ = ar.Capabilities.Set("no-thin") + + if err := ar.Encode(rwc); err != nil { + return fmt.Errorf("error in advertised references encoding: %w", err) + } + + req := packp.NewReferenceUpdateRequest() + _ = req.Capabilities.Set(capability.ReportStatus) + if err := req.Decode(buf); err != nil { + // FIXME this is a hack, but go-git doesn't accept a 0000 if there are no refs to update + if !strings.EqualFold(err.Error(), "capabilities delimiter not found") { + return fmt.Errorf("error decoding: %w", err) + } + } + + // FIXME also a hack, if the next bytes are PACK then we have a packfile, otherwise assume it's push options + peek, err := buf.Peek(4) + if err != nil { + return err + } + if string(peek) != "PACK" { + s := pktline.NewScanner(buf) + for s.Scan() { + val := string(s.Bytes()) + if val == "" { + break + } + if s.Err() != nil { + return s.Err() + } + parts := strings.SplitN(val, "=", 2) + req.Options = append(req.Options, &packp.Option{ + Key: parts[0], + Value: parts[1], + }) + } + } + + if err := HandlePushOptions(repo, req.Options); err != nil { + return fmt.Errorf("could not handle push options: %w", err) + } + + // FIXME if there are only delete commands, there is no packfile and ReceivePack will block forever + noPack := true + for _, c := range req.Commands { + if c.Action() != packp.Delete { + noPack = false + break + } + } + if noPack { + req.Packfile = nil + } + + rs, err := session.ReceivePack(rwc.Context(), req) + if err != nil { + return fmt.Errorf("error in receive pack: %w", err) + } + + if err := rs.Encode(rwc); err != nil { + return fmt.Errorf("could not encode receive pack: %w", err) + } + + return nil +} diff --git a/internal/html/generate.go b/internal/html/generate.go index 678f3a3..e4cd47c 100644 --- a/internal/html/generate.go +++ b/internal/html/generate.go @@ -6,11 +6,12 @@ import ( "bytes" _ "embed" "fmt" - "go.jolheiser.com/ugit/internal/html/markup" "go/format" "os" "os/exec" + "go.jolheiser.com/ugit/internal/html/markup" + "github.com/alecthomas/chroma/v2/styles" ) diff --git a/internal/html/markup/markdown.go b/internal/html/markup/markdown.go index 787fe1b..20c3b85 100644 --- a/internal/html/markup/markdown.go +++ b/internal/html/markup/markdown.go @@ -3,12 +3,13 @@ package markup import ( "bytes" "fmt" - "golang.org/x/net/html" "io" "net/url" "path/filepath" "strings" + "golang.org/x/net/html" + "go.jolheiser.com/ugit/internal/git" chromahtml "github.com/alecthomas/chroma/v2/formatters/html" diff --git a/internal/http/index.go b/internal/http/index.go index 9e43dfb..56a1798 100644 --- a/internal/http/index.go +++ b/internal/http/index.go @@ -4,6 +4,7 @@ import ( "net/http" "os" "sort" + "strings" "time" "go.jolheiser.com/ugit/internal/git" @@ -19,6 +20,9 @@ func (rh repoHandler) index(w http.ResponseWriter, r *http.Request) error { repos := make([]*git.Repo, 0, len(repoPaths)) for _, repoName := range repoPaths { + if !strings.HasSuffix(repoName.Name(), ".git") { + continue + } repo, err := git.NewRepo(rh.s.RepoDir, repoName.Name()) if err != nil { return httperr.Error(err) diff --git a/internal/http/repo.go b/internal/http/repo.go index 2c4aa93..82b5af6 100644 --- a/internal/http/repo.go +++ b/internal/http/repo.go @@ -3,11 +3,12 @@ package http import ( "bytes" "errors" - "go.jolheiser.com/ugit/internal/html/markup" "mime" "net/http" "path/filepath" + "go.jolheiser.com/ugit/internal/html/markup" + "go.jolheiser.com/ugit/internal/git" "go.jolheiser.com/ugit/internal/html" "go.jolheiser.com/ugit/internal/http/httperr"