ikurotime / gitgud

public
Add base URL configuration and update clone instructions in templates
DDavid committed on 2026-08-09 16:17 commit e6331de
README.md +4 −4
# gitgud
A small, self-hosted GitHub clone written in Go. It serves real git repositories over
HTTP (clone / push / pull) and provides a web UI for browsing code, opening issues, and
reviewing and merging pull requests.
## Features
- User registration, login, and session auth
- Public and private repositories (private repos are invisible to others — 404, not 403)
- Real git over HTTP via `git http-backend` (clone, push, pull) with HTTP Basic auth
- Code browsing: file tree, syntax-highlighted files, rendered README, branch switcher,
commit log, and commit diffs (powered by go-git)
- Issues: per-repo numbering, comments, open/close, markdown bodies
- Pull requests: compare two branches, view the diff and commits, comment, and merge
- Flash messages, friendly 404/403/500 pages, and CSRF-protected forms
## Run locally
Requirements: **Go 1.25+** and the **`git`** binary on your `PATH`.
```bash
go run ./cmd/server # serves http://localhost:8080
# data (SQLite db + bare repos) lands in ./data
```
Configuration is read from the environment (a local `.env` file is loaded automatically):
| Variable | Example | Purpose |
| -------------------- | ----------------- | --------------------------------------- |
-| `GITGUD_ADDR` | `:8080` | Listen address |
-| `GITGUD_DATA_DIR` | `./data` | Where the database and repos are stored |
-| `GITGUD_SESSION_KEY` | `a-random-secret` | Session secret (use a random value) |
+| `GITGUD_ADDR` | `:8080` | Listen address |
+| `GITGUD_DATA_DIR` | `./data` | Where the database and repos are stored |
+| `GITGUD_SESSION_KEY` | `a-random-secret` | Session secret (use a random value) |
+| `GITGUD_BASE_URL` | `https://gitgud.ikuro.dev` | External URL used in the clone instructions shown in the UI (defaults to `http://localhost:8080`) |
Then register a user, create a repository, and follow the on-screen clone instructions:
```bash
git clone http://localhost:8080/<you>/<repo>.git
cd <repo>
echo "# <repo>" > README.md
git add README.md && git commit -m "first commit"
git push -u origin main # prompts for your gitgud username/password
```
## How it works
gitgud has two git surfaces — a smart-HTTP server (`git http-backend` via CGI) for
clone/push/pull, and a go-git reader for the web browsing UI — sitting on top of a clean,
layered architecture (domain / application / interface / infrastructure). See
[docs/00-overview.md](docs/00-overview.md) for the full design, and the numbered files in
[docs/](docs/) for how each milestone was built.
## Known limitations (v1)
- Git over HTTP only (no SSH)
- Pull requests are between two branches of the **same** repo (no forks)
- Single-node; SQLite storage; bare repos on the local filesystem
-- The clone URL shown in the UI is hardcoded to `http://localhost:8080`
cmd/server/main.go +3 −0
package main
import (
"log"
"net/http"
"time"
"gitgud/internal/app"
"gitgud/internal/infra/config"
"gitgud/internal/infra/git"
"gitgud/internal/infra/persistence/sqlite"
"gitgud/internal/infra/security"
"gitgud/internal/infra/session"
"gitgud/internal/interface/web"
+ "gitgud/internal/interface/web/templates"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
}
db, err := sqlite.Open(cfg.DBPath())
if err != nil {
log.Fatal(err)
}
defer db.Close()
hasher := security.NewBcryptHasher()
userRepo := sqlite.NewUserRepo(db)
userService := app.NewUserService(userRepo, hasher)
gitSvc := git.NewCLIGit(cfg.ReposDir())
gitReader := git.NewGoGitReader(cfg.ReposDir())
repoRepo := sqlite.NewRepoRepo(db)
repoService := app.NewRepoService(repoRepo, gitSvc)
browseService := app.NewBrowseService(repoRepo, gitReader)
issueRepo := sqlite.NewIssueRepo(db)
issueService := app.NewIssueService(issueRepo)
prRepo := sqlite.NewPRRepo(db)
pullService := app.NewPullService(prRepo, gitReader, gitSvc)
gitAccess := app.NewGitAccessService(repoRepo)
gitBackend, err := git.NewBackend(cfg.ReposDir())
if err != nil {
log.Fatal(err)
}
+
+ templates.SetBaseURL(cfg.BaseURL)
sm := session.NewSessionManager(db)
handlers := web.NewHandlers(userService, repoService, browseService, issueService, pullService, gitAccess, gitBackend, sm)
handler := web.NewRouter(cfg, handlers)
srv := &http.Server{
Addr: cfg.Addr,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 60 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 120 * time.Second,
}
log.Printf("listening on %s", cfg.Addr)
log.Fatal(srv.ListenAndServe())
}
internal/infra/config/config.go +13 −0
package config
import (
"os"
"path/filepath"
+ "strings"
"github.com/joho/godotenv"
)
type Config struct {
Addr string
DataDir string
SessionKey string // random string
+ BaseURL string // external URL used in clone instructions, e.g. https://gitgud.ikuro.dev
}
func (c Config) DBPath() string {
return filepath.Join(c.DataDir, "app.db")
}
func (c Config) ReposDir() string {
return filepath.Join(c.DataDir, "repos")
}
func Load() (Config, error) {
// Load .env if present; in containers config comes from real env vars.
_ = godotenv.Load()
dataDir, err := filepath.Abs(os.Getenv("GITGUD_DATA_DIR"))
if err != nil {
return Config{}, err
}
+ baseURL := os.Getenv("GITGUD_BASE_URL")
+ if baseURL == "" {
+ addr := os.Getenv("GITGUD_ADDR")
+ if strings.HasPrefix(addr, ":") {
+ addr = "localhost" + addr
+ }
+ baseURL = "http://" + addr
+ }
+ baseURL = strings.TrimRight(baseURL, "/")
+
return Config{
Addr: os.Getenv("GITGUD_ADDR"),
DataDir: dataDir,
SessionKey: os.Getenv("GITGUD_SESSION_KEY"),
+ BaseURL: baseURL,
}, nil
}
internal/interface/web/templates/helpers.go +13 −1
package templates
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"gitgud/internal/domain"
"gitgud/internal/interface/web/presenter"
)
type ctxKey int
const (
flashKey ctxKey = iota
csrfKey
)
func WithFlash(ctx context.Context, msg string) context.Context {
return context.WithValue(ctx, flashKey, msg)
}
func flashOf(ctx context.Context) string {
s, _ := ctx.Value(flashKey).(string)
return s
}
func WithCSRF(ctx context.Context, token string) context.Context {
return context.WithValue(ctx, csrfKey, token)
}
func csrfToken(ctx context.Context) string {
s, _ := ctx.Value(csrfKey).(string)
return s
}
func markdown(s string) string {
return presenter.RenderMarkdown([]byte(s))
}
func fmtTime(t time.Time) string {
return t.Format("2006-01-02 15:04")
}
func stateAction(state domain.IssueState) string {
if state == domain.IssueOpen {
return "/close"
}
return "/reopen"
}
func stateTabClass(current, want string) string {
if current == want {
return "font-semibold text-ink"
}
return "text-muted hover:text-ink"
}
+// baseURL is the external URL of this gitgud instance, used to build clone
+// URLs shown in the UI. It defaults to the local dev address and is overridden
+// at startup via SetBaseURL from config (GITGUD_BASE_URL).
+var baseURL = "http://localhost:8080"
+
+// SetBaseURL configures the external base URL used in clone instructions.
+func SetBaseURL(u string) {
+ if u != "" {
+ baseURL = strings.TrimRight(u, "/")
+ }
+}
+
func cloneInstructions(repo *domain.Repository) string {
- url := "http://localhost:8080/" + repo.OwnerName + "/" + repo.Name + ".git"
+ url := baseURL + "/" + repo.OwnerName + "/" + repo.Name + ".git"
return fmt.Sprintf(`git clone %s
cd %s
echo "# %s" > README.md
git add README.md
git commit -m "first commit"
git push -u origin %s`, url, repo.Name, repo.Name, repo.DefaultBranch)
}
type Crumb struct {
Name string
Href string
}
func treeCrumbs(repo *domain.Repository, ref, p string) []Crumb {
base := "/" + repo.OwnerName + "/" + repo.Name + "/tree/" + ref
crumbs := []Crumb{{Name: repo.Name, Href: base}}
if p = strings.Trim(p, "/"); p != "" {
acc := base
for _, seg := range strings.Split(p, "/") {
acc += "/" + seg
crumbs = append(crumbs, Crumb{Name: seg, Href: acc})
}
}
return crumbs
}
func entryHref(repo *domain.Repository, ref string, e domain.TreeEntry) string {
kind := "blob"
if e.IsDir {
kind = "tree"
}
return "/" + repo.OwnerName + "/" + repo.Name + "/" + kind + "/" + ref + "/" + e.Path
}
func repoPath(repo *domain.Repository, sub string) string {
return "/" + repo.OwnerName + "/" + repo.Name + sub
}
func humanSize(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for x := n / unit; x >= unit; x /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp])
}
func itoa(n int) string {
return strconv.Itoa(n)
}
func countVisibility(repos []*domain.Repository, private bool) int {
n := 0
for _, r := range repos {
if r.IsPrivate == private {
n++
}
}
return n
}
func initial(s string) string {
if s == "" {
return "?"
}
return strings.ToUpper(s[:1])
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
func diffLineClass(line string) string {
switch {
case strings.HasPrefix(line, "@@"):
return "diff-hunk"
case strings.HasPrefix(line, "+"):
return "diff-add"
case strings.HasPrefix(line, "-"):
return "diff-del"
default:
return "text-muted"
}
}
// statBlocks renders a 5-square GitHub-style diffstat: green for additions,
// red for deletions, proportional to the change, padded with neutral squares.
func statBlocks(added, deleted int) []string {
blocks := make([]string, 5)
total := added + deleted
if total == 0 {
for i := range blocks {
blocks[i] = "none"
}
return blocks
}
greens := added * 5 / total
if added > 0 && greens == 0 {
greens = 1
}
reds := 5 - greens
if deleted > 0 && reds == 0 && greens > 0 {
greens--
reds = 1
}
for i := range blocks {
switch {
case i < greens:
blocks[i] = "add"
case i < greens+reds:
blocks[i] = "del"
default:
blocks[i] = "none"
}
}
return blocks
}
// langLabel maps a file path to a human language label for the blob header.
func langLabel(path string) string {
ext := strings.ToLower(path)
if i := strings.LastIndexByte(ext, '.'); i >= 0 {
ext = ext[i+1:]
}
switch ext {
case "go":
return "Go"
case "js", "mjs", "cjs":
return "JavaScript"
case "ts", "tsx":
return "TypeScript"
case "py":
return "Python"
case "rs":
return "Rust"
case "rb":
return "Ruby"
case "java":
return "Java"
case "c", "h":
return "C"
case "cpp", "cc", "hpp":
return "C++"
case "sh", "bash", "zsh":
return "Shell"
case "html", "htm":
return "HTML"
case "css":
return "CSS"
case "json":
return "JSON"
case "yml", "yaml":
return "YAML"
case "md", "markdown":
return "Markdown"
case "sql":
return "SQL"
case "templ":
return "Templ"
case "":
return "Text"
default:
return strings.ToUpper(ext)
}
}
func diffLines(patch string) []string {
return strings.Split(strings.TrimRight(patch, "\n"), "\n")
}
internal/interface/web/templates/home.templ +1 −1
package templates
import "gitgud/internal/domain"
templ Home(user *domain.User) {
@Layout("gitgud — minimal git hosting", user) {
<div class="mx-auto max-w-2xl py-16 text-center">
<div class="mx-auto mb-8 flex size-14 items-center justify-center rounded-2xl border border-accent/30 bg-accent/[.08] text-accent shadow-[0_0_40px_-6px_rgba(63,207,127,.6)]">
@gitLogo("size-7")
</div>
<h1 class="text-4xl font-bold tracking-tight text-ink sm:text-5xl">
git hosting,<br/><span class="text-accent">stripped down.</span>
</h1>
<p class="mx-auto mt-5 max-w-md text-muted">
Clone, push, browse, and review — a small, self-hosted home for your repositories.
</p>
<div class="mt-8 flex items-center justify-center gap-3">
<a class="btn btn-primary" href="/register">Get started</a>
<a class="btn" href="/login">Sign in</a>
</div>
<div class="mx-auto mt-12 max-w-md rounded-lg border border-line bg-panel2 px-4 py-3 text-left font-mono text-xs text-muted">
- <span class="text-faint">$</span> git clone http://localhost:8080/<span class="text-accent">you</span>/<span class="text-accent">repo</span>.git
+ <span class="text-faint">$</span> git clone { baseURL }/<span class="text-accent">you</span>/<span class="text-accent">repo</span>.git
</div>
</div>
}
}
internal/interface/web/templates/home_templ.go +14 −1
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.865
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "gitgud/internal/domain"
func Home(user *domain.User) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"mx-auto max-w-2xl py-16 text-center\"><div class=\"mx-auto mb-8 flex size-14 items-center justify-center rounded-2xl border border-accent/30 bg-accent/[.08] text-accent shadow-[0_0_40px_-6px_rgba(63,207,127,.6)]\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = gitLogo("size-7").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div><h1 class=\"text-4xl font-bold tracking-tight text-ink sm:text-5xl\">git hosting,<br><span class=\"text-accent\">stripped down.</span></h1><p class=\"mx-auto mt-5 max-w-md text-muted\">Clone, push, browse, and review — a small, self-hosted home for your repositories.</p><div class=\"mt-8 flex items-center justify-center gap-3\"><a class=\"btn btn-primary\" href=\"/register\">Get started</a> <a class=\"btn\" href=\"/login\">Sign in</a></div><div class=\"mx-auto mt-12 max-w-md rounded-lg border border-line bg-panel2 px-4 py-3 text-left font-mono text-xs text-muted\"><span class=\"text-faint\">$</span> git clone http://localhost:8080/<span class=\"text-accent\">you</span>/<span class=\"text-accent\">repo</span>.git</div></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div><h1 class=\"text-4xl font-bold tracking-tight text-ink sm:text-5xl\">git hosting,<br><span class=\"text-accent\">stripped down.</span></h1><p class=\"mx-auto mt-5 max-w-md text-muted\">Clone, push, browse, and review — a small, self-hosted home for your repositories.</p><div class=\"mt-8 flex items-center justify-center gap-3\"><a class=\"btn btn-primary\" href=\"/register\">Get started</a> <a class=\"btn\" href=\"/login\">Sign in</a></div><div class=\"mx-auto mt-12 max-w-md rounded-lg border border-line bg-panel2 px-4 py-3 text-left font-mono text-xs text-muted\"><span class=\"text-faint\">$</span> git clone ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(baseURL)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/interface/web/templates/home.templ`, Line: 22, Col: 57}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "/<span class=\"text-accent\">you</span>/<span class=\"text-accent\">repo</span>.git</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = Layout("gitgud — minimal git hosting", user).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate