repos / git-pr

a self-hosted git collaboration server
git clone https://github.com/picosh/git-pr.git

commit
cd00d78
parent
10f0f90
author
jolheiser
date
2025-04-17 17:07:03 -0400 EDT
single binary

Reduce web and ssh to a single binary called git-pr that runs both the SSH and web servers at the same time

Signed-off-by: jolheiser <git@jolheiser.com>
13 files changed,  +105, -170
M ssh.go
M web.go
M Caddyfile
+1, -1
1@@ -48,6 +48,6 @@
2 }
3 
4 :443 {
5-	reverse_proxy git-web:3000
6+	reverse_proxy git-pr:3000
7 	encode zstd gzip
8 }
M Dockerfile
+6, -29
 1@@ -10,7 +10,7 @@ COPY go.* ./
 2 
 3 RUN go mod download
 4 
 5-FROM builder-deps as builder-web
 6+FROM builder-deps as builder
 7 
 8 COPY . .
 9 
10@@ -22,37 +22,14 @@ ENV LDFLAGS="-s -w"
11 
12 ENV GOOS=${TARGETOS} GOARCH=${TARGETARCH}
13 
14-RUN go build -ldflags "$LDFLAGS" -o /go/bin/git-web ./cmd/git-web
15+RUN go build -ldflags "$LDFLAGS" -o /go/bin/git-pr ./cmd/git-pr
16 
17-FROM builder-deps as builder-ssh
18-
19-COPY . .
20-
21-ARG TARGETOS
22-ARG TARGETARCH
23-
24-ENV CGO_ENABLED=0
25-ENV LDFLAGS="-s -w"
26-
27-ENV GOOS=${TARGETOS} GOARCH=${TARGETARCH}
28-
29-RUN go build -ldflags "$LDFLAGS" -o /go/bin/git-ssh ./cmd/git-ssh
30-
31-FROM scratch as release-web
32-
33-WORKDIR /app
34-
35-COPY --from=builder-web /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
36-COPY --from=builder-web /go/bin/git-web ./git-web
37-
38-CMD ["/app/git-web"]
39-
40-FROM scratch as release-ssh
41+FROM scratch as release
42 
43 WORKDIR /app
44 ENV TERM="xterm-256color"
45 
46-COPY --from=builder-ssh /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
47-COPY --from=builder-ssh /go/bin/git-ssh ./git-ssh
48+COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
49+COPY --from=builder /go/bin/git-pr ./git-pr
50 
51-CMD ["/app/git-ssh"]
52+CMD ["/app/git-pr"]
M Makefile
+3, -8
 1@@ -21,8 +21,7 @@ snapshot:
 2 .PHONY: snapshot
 3 
 4 build:
 5-	go build -o ./build/git-ssh ./cmd/git-ssh
 6-	go build -o ./build/git-web ./cmd/git-web
 7+	go build -o ./build/git-pr ./cmd/git-pr
 8 .PHONY: build
 9 
10 bp-setup:
11@@ -30,12 +29,8 @@ bp-setup:
12 	$(DOCKER_CMD) buildx use pico
13 .PHONY: bp-setup
14 
15-bp-web: bp-setup
16-	$(DOCKER_BUILDX_BUILD) -t "ghcr.io/picosh/pico/git-web:$(DOCKER_TAG)" --target release-web .
17-.PHONY: bp-web
18-
19-bp: bp-web
20-	$(DOCKER_BUILDX_BUILD) -t "ghcr.io/picosh/pico/git-ssh:$(DOCKER_TAG)" --target release-ssh .
21+bp: bp-setup
22+	$(DOCKER_BUILDX_BUILD) -t "ghcr.io/picosh/pico/git-pr:$(DOCKER_TAG)" --target release-pr .
23 .PHONY: bp
24 
25 deploy: bp-web
M README.md
+3, -13
 1@@ -159,16 +159,10 @@ vim ./data/git-pr.toml
 2 
 3 ## docker
 4 
 5-Run the ssh app image:
 6+Run the app image:
 7 
 8 ```bash
 9-docker run -d -v ./data:/app/data ghcr.io/picosh/pico/git-ssh:latest
10-```
11-
12-Run the web app image:
13-
14-```bash
15-docker run -d -v ./data:/app/data ghcr.io/picosh/pico/git-web:latest
16+docker run -d -v ./data:/app/data ghcr.io/picosh/pico/git-pr:latest
17 ```
18 
19 ## golang
20@@ -180,11 +174,7 @@ make build
21 ```
22 
23 ```bash
24-./build/ssh --config ./data/git-pr.toml
25-```
26-
27-```bash
28-./build/web --config ./data/git-pr.toml
29+./build/git-pr --config ./data/git-pr.toml
30 ```
31 
32 ## done!
A cmd/git-pr/main.go
+57, -0
 1@@ -0,0 +1,57 @@
 2+package main
 3+
 4+import (
 5+	"context"
 6+	"flag"
 7+	"fmt"
 8+	"log/slog"
 9+	"net/http"
10+	"os"
11+	"os/signal"
12+	"syscall"
13+	"time"
14+
15+	git "github.com/picosh/git-pr"
16+)
17+
18+func main() {
19+	fpath := flag.String("config", "git-pr.toml", "configuration toml file")
20+	flag.Parse()
21+	opts := &slog.HandlerOptions{
22+		AddSource: true,
23+	}
24+	logger := slog.New(
25+		slog.NewTextHandler(os.Stdout, opts),
26+	)
27+	git.LoadConfigFile(*fpath, logger)
28+	cfg := git.NewGitCfg(logger)
29+
30+	// SSH Server
31+	ssh := git.GitSshServer(cfg)
32+	cfg.Logger.Info("starting SSH server", "host", cfg.Host, "port", cfg.SshPort)
33+	go func() {
34+		if err := ssh.ListenAndServe(); err != nil {
35+			cfg.Logger.Error("serve error", "err", err)
36+		}
37+	}()
38+
39+	// Web Server
40+	addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.WebPort)
41+	web := git.GitWebServer(cfg)
42+	cfg.Logger.Info("starting web server", "addr", addr)
43+	go func() {
44+		if err := http.ListenAndServe(addr, web); err != nil {
45+			cfg.Logger.Error("listen", "err", err)
46+		}
47+	}()
48+
49+	done := make(chan os.Signal, 1)
50+	signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
51+	<-done
52+	cfg.Logger.Info("stopping SSH server")
53+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
54+	defer func() { cancel() }()
55+	if err := ssh.Shutdown(ctx); err != nil {
56+		cfg.Logger.Error("shutdown", "err", err)
57+	}
58+}
D cmd/git-ssh/main.go
+0, -22
 1@@ -1,22 +0,0 @@
 2-package main
 3-
 4-import (
 5-	"flag"
 6-	"log/slog"
 7-	"os"
 8-
 9-	git "github.com/picosh/git-pr"
10-)
11-
12-func main() {
13-	fpath := flag.String("config", "git-pr.toml", "configuration toml file")
14-	flag.Parse()
15-	opts := &slog.HandlerOptions{
16-		AddSource: true,
17-	}
18-	logger := slog.New(
19-		slog.NewTextHandler(os.Stdout, opts),
20-	)
21-	git.LoadConfigFile(*fpath, logger)
22-	git.GitSshServer(git.NewGitCfg(logger), nil)
23-}
D cmd/git-web/main.go
+0, -22
 1@@ -1,22 +0,0 @@
 2-package main
 3-
 4-import (
 5-	"flag"
 6-	"log/slog"
 7-	"os"
 8-
 9-	git "github.com/picosh/git-pr"
10-)
11-
12-func main() {
13-	fpath := flag.String("config", "git-pr.toml", "configuration toml file")
14-	flag.Parse()
15-	opts := &slog.HandlerOptions{
16-		AddSource: true,
17-	}
18-	logger := slog.New(
19-		slog.NewTextHandler(os.Stdout, opts),
20-	)
21-	git.LoadConfigFile(*fpath, logger)
22-	git.StartWebServer(git.NewGitCfg(logger))
23-}
M contrib/dev/main.go
+6, -2
 1@@ -4,6 +4,7 @@ import (
 2 	"flag"
 3 	"fmt"
 4 	"log/slog"
 5+	"net/http"
 6 	"os"
 7 	"os/signal"
 8 	"syscall"
 9@@ -37,9 +38,12 @@ func main() {
10 	git.LoadConfigFile(cfgPath, logger)
11 	cfg := git.NewGitCfg(logger)
12 
13-	go git.GitSshServer(cfg, nil)
14+	s := git.GitSshServer(cfg)
15+	go s.ListenAndServe()
16 	time.Sleep(time.Millisecond * 100)
17-	go git.StartWebServer(cfg)
18+	w := git.GitWebServer(cfg)
19+	addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.WebPort)
20+	go http.ListenAndServe(addr, w)
21 
22 	// Hack to wait for startup
23 	time.Sleep(time.Millisecond * 100)
M docker-compose.prod.yml
+2, -9
 1@@ -18,19 +18,12 @@ services:
 2       - "${GITPR_HTTP_V4:-80}:80"
 3       - "${GITPR_HTTPS_V6:-[::1]:443}:443"
 4       - "${GITPR_HTTP_V6:-[::1]:80}:80"
 5-  web:
 6-    command: "/app/git-web --config ${GITPR_CONFIG_PATH}"
 7+  git-pr:
 8+    command: "/app/git-pr --config ${GITPR_CONFIG_PATH}"
 9     networks:
10       git:
11         aliases:
12           - web
13-    env_file:
14-      - .env.prod
15-  ssh:
16-    command: "/app/git-ssh --config ${GITPR_CONFIG_PATH}"
17-    networks:
18-      git:
19-        aliases:
20           - ssh
21     env_file:
22       - .env.prod
M docker-compose.yml
+2, -7
 1@@ -1,11 +1,6 @@
 2 services:
 3-  web:
 4-    image: ghcr.io/picosh/pico/git-web:latest
 5-    restart: always
 6-    volumes:
 7-      - ./data/git-pr/data:/app/data
 8-  ssh:
 9-    image: ghcr.io/picosh/pico/git-ssh:latest
10+  git-pr:
11+    image: ghcr.io/picosh/pico/git-pr:latest
12     restart: always
13     volumes:
14       - ./data/git-pr/data:/app/data
M e2e_test.go
+7, -6
 1@@ -1,6 +1,7 @@
 2 package git
 3 
 4 import (
 5+	"context"
 6 	"log/slog"
 7 	"os"
 8 	"testing"
 9@@ -23,8 +24,8 @@ func testSingleTenantE2E(t *testing.T) {
10 		os.RemoveAll(dataDir)
11 	}()
12 	suite := setupTest(dataDir, cfgSingleTenantTmpl)
13-	done := make(chan error)
14-	go GitSshServer(suite.cfg, done)
15+	s := GitSshServer(suite.cfg)
16+	go s.ListenAndServe()
17 	// Hack to wait for startup
18 	time.Sleep(time.Millisecond * 100)
19 
20@@ -43,7 +44,7 @@ func testSingleTenantE2E(t *testing.T) {
21 	bail(err)
22 	snaps.MatchSnapshot(t, actual)
23 
24-	done <- nil
25+	s.Shutdown(context.Background())
26 }
27 
28 func testMultiTenantE2E(t *testing.T) {
29@@ -53,8 +54,8 @@ func testMultiTenantE2E(t *testing.T) {
30 		os.RemoveAll(dataDir)
31 	}()
32 	suite := setupTest(dataDir, cfgMultiTenantTmpl)
33-	done := make(chan error)
34-	go GitSshServer(suite.cfg, done)
35+	s := GitSshServer(suite.cfg)
36+	go s.ListenAndServe()
37 
38 	time.Sleep(time.Millisecond * 100)
39 
40@@ -120,7 +121,7 @@ func testMultiTenantE2E(t *testing.T) {
41 	bail(err)
42 	snaps.MatchSnapshot(t, actual)
43 
44-	done <- nil
45+	s.Shutdown(context.Background())
46 }
47 
48 type TestSuite struct {
M ssh.go
+3, -30
 1@@ -1,13 +1,8 @@
 2 package git
 3 
 4 import (
 5-	"context"
 6 	"fmt"
 7-	"os"
 8-	"os/signal"
 9 	"path/filepath"
10-	"syscall"
11-	"time"
12 
13 	"github.com/charmbracelet/ssh"
14 	"github.com/charmbracelet/wish"
15@@ -31,7 +26,7 @@ func authHandler(pr *PrCmd) func(ctx ssh.Context, key ssh.PublicKey) bool {
16 	}
17 }
18 
19-func GitSshServer(cfg *GitCfg, killCh chan error) {
20+func GitSshServer(cfg *GitCfg) *ssh.Server {
21 	dbpath := filepath.Join(cfg.DataDir, "pr.db?_fk=on")
22 	dbh, err := SqliteOpen("file:"+dbpath, cfg.Logger)
23 	if err != nil {
24@@ -59,31 +54,9 @@ func GitSshServer(cfg *GitCfg, killCh chan error) {
25 			GitPatchRequestMiddleware(be, prCmd),
26 		),
27 	)
28-
29 	if err != nil {
30 		cfg.Logger.Error("could not create server", "err", err)
31-		return
32-	}
33-
34-	done := make(chan os.Signal, 1)
35-	signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
36-	cfg.Logger.Info("starting SSH server", "host", cfg.Host, "port", cfg.SshPort)
37-	go func() {
38-		if err = s.ListenAndServe(); err != nil {
39-			cfg.Logger.Error("serve error", "err", err)
40-			// os.Exit(1)
41-		}
42-	}()
43-
44-	select {
45-	case <-done:
46-	case <-killCh:
47-	}
48-	cfg.Logger.Info("stopping SSH server")
49-	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
50-	defer func() { cancel() }()
51-	if err := s.Shutdown(ctx); err != nil {
52-		cfg.Logger.Error("shutdown", "err", err)
53-		// os.Exit(1)
54+		return nil
55 	}
56+	return s
57 }
M web.go
+15, -21
 1@@ -1086,9 +1086,7 @@ func getEmbedFS(ffs embed.FS, dirName string) (fs.FS, error) {
 2 	return fsys, nil
 3 }
 4 
 5-func StartWebServer(cfg *GitCfg) {
 6-	addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.WebPort)
 7-
 8+func GitWebServer(cfg *GitCfg) http.Handler {
 9 	dbpath := filepath.Join(cfg.DataDir, "pr.db?_fk=on")
10 	dbh, err := SqliteOpen("file:"+dbpath, cfg.Logger)
11 	if err != nil {
12@@ -1122,28 +1120,24 @@ func StartWebServer(cfg *GitCfg) {
13 
14 	// ensure legacy router is disabled
15 	// GODEBUG=httpmuxgo121=0
16-	http.HandleFunc("GET /prs/{id}", ctxMdw(ctx, createPrDetail("pr")))
17-	http.HandleFunc("GET /prs/{id}/rss", ctxMdw(ctx, rssHandler))
18-	http.HandleFunc("GET /ps/{id}", ctxMdw(ctx, createPrDetail("ps")))
19-	http.HandleFunc("GET /rd/{id}", ctxMdw(ctx, createPrDetail("rd")))
20-	http.HandleFunc("GET /r/{user}/{repo}/rss", ctxMdw(ctx, rssHandler))
21-	http.HandleFunc("GET /r/{user}/{repo}", ctxMdw(ctx, repoDetailHandler))
22-	http.HandleFunc("GET /r/{user}", ctxMdw(ctx, userDetailHandler))
23-	http.HandleFunc("GET /rss/{user}", ctxMdw(ctx, rssHandler))
24-	http.HandleFunc("GET /rss", ctxMdw(ctx, rssHandler))
25-	http.HandleFunc("GET /", ctxMdw(ctx, indexHandler))
26-	http.HandleFunc("GET /syntax.css", ctxMdw(ctx, chromaStyleHandler))
27+	mux := http.NewServeMux()
28+	mux.HandleFunc("GET /prs/{id}", ctxMdw(ctx, createPrDetail("pr")))
29+	mux.HandleFunc("GET /prs/{id}/rss", ctxMdw(ctx, rssHandler))
30+	mux.HandleFunc("GET /ps/{id}", ctxMdw(ctx, createPrDetail("ps")))
31+	mux.HandleFunc("GET /rd/{id}", ctxMdw(ctx, createPrDetail("rd")))
32+	mux.HandleFunc("GET /r/{user}/{repo}/rss", ctxMdw(ctx, rssHandler))
33+	mux.HandleFunc("GET /r/{user}/{repo}", ctxMdw(ctx, repoDetailHandler))
34+	mux.HandleFunc("GET /r/{user}", ctxMdw(ctx, userDetailHandler))
35+	mux.HandleFunc("GET /rss/{user}", ctxMdw(ctx, rssHandler))
36+	mux.HandleFunc("GET /rss", ctxMdw(ctx, rssHandler))
37+	mux.HandleFunc("GET /", ctxMdw(ctx, indexHandler))
38+	mux.HandleFunc("GET /syntax.css", ctxMdw(ctx, chromaStyleHandler))
39 	embedFS, err := getEmbedFS(embedStaticFS, "static")
40 	if err != nil {
41 		panic(err)
42 	}
43 	userFS := getUserDefinedFS(cfg.DataDir, "static")
44 
45-	http.HandleFunc("GET /static/{file}", ctxMdw(ctx, serveFile(userFS, embedFS)))
46-
47-	cfg.Logger.Info("starting web server", "addr", addr)
48-	err = http.ListenAndServe(addr, nil)
49-	if err != nil {
50-		cfg.Logger.Error("listen", "err", err)
51-	}
52+	mux.HandleFunc("GET /static/{file}", ctxMdw(ctx, serveFile(userFS, embedFS)))
53+	return mux
54 }