main.mx raw

   1  package main
   2  
   3  import (
   4  	"bytes"
   5  	"fmt"
   6  	"net/http"
   7  	"os"
   8  	"path"
   9  	"strings"
  10  	"syscall"
  11  	"unsafe"
  12  )
  13  
  14  var (
  15  	repoRoot = "/home/git"
  16  	gitBin   = "/usr/bin/git"
  17  	addr     = "127.0.0.1:3000"
  18  	host       = "git.mleku.dev"
  19  	gitBackend = "/usr/lib/git-core/git-http-backend"
  20  )
  21  
  22  // ── exec ────────────────────────────────────────────────────────
  23  
  24  // run forks, execs bin with args, captures stdout via pipe.
  25  // Uses raw clone syscall to bypass moxie's missing runtime fork hooks.
  26  func run(bin string, args ...string) ([]byte, error) {
  27  	var p [2]int
  28  	if err := syscall.Pipe(p[:]); err != nil {
  29  		return nil, err
  30  	}
  31  
  32  	argv := make([]string, 0, len(args)+1)
  33  	argv = append(argv, bin)
  34  	argv = append(argv, args...)
  35  
  36  	binp, _ := syscall.BytePtrFromString(bin)
  37  	argvp, _ := syscall.SlicePtrFromStrings(argv)
  38  	envp, _ := syscall.SlicePtrFromStrings([]string{"PATH=/usr/bin:/bin"})
  39  
  40  	r1, _, e := syscall.RawSyscall(syscall.SYS_CLONE, uintptr(syscall.SIGCHLD), 0, 0)
  41  	if e != 0 {
  42  		syscall.Close(p[0])
  43  		syscall.Close(p[1])
  44  		return nil, e
  45  	}
  46  
  47  	if r1 == 0 {
  48  		// child
  49  		syscall.Dup2(p[1], 1)
  50  		syscall.Close(p[0])
  51  		syscall.Close(p[1])
  52  		devnull, _ := syscall.Open("/dev/null", syscall.O_RDWR, 0)
  53  		syscall.Dup2(devnull, 0)
  54  		syscall.Dup2(devnull, 2)
  55  		syscall.Close(devnull)
  56  		syscall.RawSyscall(syscall.SYS_EXECVE,
  57  			uintptr(unsafe.Pointer(binp)),
  58  			uintptr(unsafe.Pointer(&argvp[0])),
  59  			uintptr(unsafe.Pointer(&envp[0])))
  60  		syscall.Exit(127)
  61  	}
  62  
  63  	pid := int(r1)
  64  	syscall.Close(p[1])
  65  
  66  	var buf bytes.Buffer
  67  	tmp := make([]byte, 8192)
  68  	for {
  69  		n, _ := syscall.Read(p[0], tmp)
  70  		if n <= 0 {
  71  			break
  72  		}
  73  		buf.Write(tmp[:n])
  74  	}
  75  	syscall.Close(p[0])
  76  
  77  	var ws syscall.WaitStatus
  78  	syscall.Wait4(pid, &ws, 0, nil)
  79  
  80  	if ws.Exited() && ws.ExitStatus() != 0 {
  81  		return buf.Bytes(), fmt.Errorf("exit %d", ws.ExitStatus())
  82  	}
  83  	return buf.Bytes(), nil
  84  }
  85  
  86  func gitCmd(repoDir string, args ...string) ([]byte, error) {
  87  	full := make([]string, 0, len(args)+2)
  88  	full = append(full, "--git-dir="+repoDir)
  89  	full = append(full, args...)
  90  	return run(gitBin, full...)
  91  }
  92  
  93  // runIO forks, execs bin with args using given env, optionally piping stdin.
  94  func runIO(env []string, stdin []byte, bin string, args ...string) ([]byte, error) {
  95  	var pout [2]int
  96  	if err := syscall.Pipe(pout[:]); err != nil {
  97  		return nil, err
  98  	}
  99  
 100  	hasIn := stdin != nil
 101  	var pin [2]int
 102  	if hasIn {
 103  		if err := syscall.Pipe(pin[:]); err != nil {
 104  			syscall.Close(pout[0])
 105  			syscall.Close(pout[1])
 106  			return nil, err
 107  		}
 108  	}
 109  
 110  	argv := make([]string, 0, len(args)+1)
 111  	argv = append(argv, bin)
 112  	argv = append(argv, args...)
 113  
 114  	binp, _ := syscall.BytePtrFromString(bin)
 115  	argvp, _ := syscall.SlicePtrFromStrings(argv)
 116  	envp, _ := syscall.SlicePtrFromStrings(env)
 117  
 118  	r1, _, e := syscall.RawSyscall(syscall.SYS_CLONE, uintptr(syscall.SIGCHLD), 0, 0)
 119  	if e != 0 {
 120  		syscall.Close(pout[0])
 121  		syscall.Close(pout[1])
 122  		if hasIn {
 123  			syscall.Close(pin[0])
 124  			syscall.Close(pin[1])
 125  		}
 126  		return nil, e
 127  	}
 128  
 129  	if r1 == 0 {
 130  		syscall.Dup2(pout[1], 1)
 131  		syscall.Close(pout[0])
 132  		syscall.Close(pout[1])
 133  		if hasIn {
 134  			syscall.Dup2(pin[0], 0)
 135  			syscall.Close(pin[0])
 136  			syscall.Close(pin[1])
 137  		}
 138  		devnull, _ := syscall.Open("/dev/null", syscall.O_RDWR, 0)
 139  		if !hasIn {
 140  			syscall.Dup2(devnull, 0)
 141  		}
 142  		syscall.Dup2(devnull, 2)
 143  		syscall.Close(devnull)
 144  		syscall.RawSyscall(syscall.SYS_EXECVE,
 145  			uintptr(unsafe.Pointer(binp)),
 146  			uintptr(unsafe.Pointer(&argvp[0])),
 147  			uintptr(unsafe.Pointer(&envp[0])))
 148  		syscall.Exit(127)
 149  	}
 150  
 151  	pid := int(r1)
 152  	syscall.Close(pout[1])
 153  	if hasIn {
 154  		syscall.Close(pin[0])
 155  		for off := 0; off < len(stdin); {
 156  			n, err := syscall.Write(pin[1], stdin[off:])
 157  			if n <= 0 || err != nil {
 158  				break
 159  			}
 160  			off += n
 161  		}
 162  		syscall.Close(pin[1])
 163  	}
 164  
 165  	var buf bytes.Buffer
 166  	tmp := make([]byte, 32768)
 167  	for {
 168  		n, _ := syscall.Read(pout[0], tmp)
 169  		if n <= 0 {
 170  			break
 171  		}
 172  		buf.Write(tmp[:n])
 173  	}
 174  	syscall.Close(pout[0])
 175  
 176  	var ws syscall.WaitStatus
 177  	syscall.Wait4(pid, &ws, 0, nil)
 178  
 179  	if ws.Exited() && ws.ExitStatus() != 0 {
 180  		return buf.Bytes(), fmt.Errorf("exit %d", ws.ExitStatus())
 181  	}
 182  	return buf.Bytes(), nil
 183  }
 184  
 185  // ── html helpers ────────────────────────────────────────────────
 186  
 187  func esc(s string) string {
 188  	s = strings.ReplaceAll(s, "&", "&amp;")
 189  	s = strings.ReplaceAll(s, "<", "&lt;")
 190  	s = strings.ReplaceAll(s, ">", "&gt;")
 191  	s = strings.ReplaceAll(s, "\"", "&quot;")
 192  	return s
 193  }
 194  
 195  // ── markdown ────────────────────────────────────────────────────
 196  
 197  // renderMD converts markdown source to HTML.
 198  func renderMD(src, linkName string) string {
 199  	var b strings.Builder
 200  	lines := strings.Split(src, "\n")
 201  	n := len(lines)
 202  	i := 0
 203  	var closeTags []string // stack of tags to close
 204  
 205  	flush := func() {
 206  		for j := len(closeTags) - 1; j >= 0; j-- {
 207  			b.WriteString(closeTags[j])
 208  		}
 209  		closeTags = nil
 210  	}
 211  
 212  	inState := func(tag string) bool {
 213  		for _, t := range closeTags {
 214  			if t == tag {
 215  				return true
 216  			}
 217  		}
 218  		return false
 219  	}
 220  
 221  	lastWasHeading := false
 222  
 223  	for i < n {
 224  		line := lines[i]
 225  
 226  		// fenced code block
 227  		if strings.HasPrefix(line, "```") {
 228  			flush()
 229  			lastWasHeading = false
 230  			lang := strings.TrimSpace(line[3:])
 231  			b.WriteString(`<pre><code>`)
 232  			if lang != "" {
 233  				_ = lang // no syntax highlighting, just consume
 234  			}
 235  			i++
 236  			for i < n && !strings.HasPrefix(lines[i], "```") {
 237  				b.WriteString(esc(lines[i]))
 238  				b.WriteByte('\n')
 239  				i++
 240  			}
 241  			b.WriteString(`</code></pre>`)
 242  			i++ // skip closing ```
 243  			continue
 244  		}
 245  
 246  		// blank line — close open blocks
 247  		if strings.TrimSpace(line) == "" {
 248  			flush()
 249  			i++
 250  			continue
 251  		}
 252  
 253  		// heading
 254  		if line[0] == '#' {
 255  			flush()
 256  			lvl := 0
 257  			for lvl < len(line) && line[lvl] == '#' {
 258  				lvl++
 259  			}
 260  			if lvl <= 6 && lvl < len(line) && line[lvl] == ' ' {
 261  				text := strings.TrimSpace(line[lvl:])
 262  				text = strings.TrimRight(text, " #")
 263  				b.WriteString(fmt.Sprintf("<h%d>%s</h%d>\n", lvl, mdInline(text, linkName), lvl))
 264  				lastWasHeading = true
 265  				i++
 266  				continue
 267  			}
 268  		}
 269  
 270  		// horizontal rule — skip if immediately after a heading (already has border-bottom)
 271  		trimmed := strings.TrimSpace(line)
 272  		if len(trimmed) >= 3 && (allChar(trimmed, '-') || allChar(trimmed, '*') || allChar(trimmed, '_')) {
 273  			flush()
 274  			if !lastWasHeading {
 275  				b.WriteString("<hr>\n")
 276  			}
 277  			lastWasHeading = false
 278  			i++
 279  			continue
 280  		}
 281  
 282  		// table (line contains | and next line is separator)
 283  		if strings.Contains(line, "|") && i+1 < n && isTableSep(lines[i+1]) {
 284  			flush()
 285  			b.WriteString("<table>\n<thead><tr>")
 286  			for _, cell := range splitTableRow(line) {
 287  				b.WriteString("<th>" + mdInline(strings.TrimSpace(cell), linkName) + "</th>")
 288  			}
 289  			b.WriteString("</tr></thead>\n<tbody>\n")
 290  			i += 2 // skip header + separator
 291  			for i < n && strings.Contains(lines[i], "|") && strings.TrimSpace(lines[i]) != "" {
 292  				b.WriteString("<tr>")
 293  				for _, cell := range splitTableRow(lines[i]) {
 294  					b.WriteString("<td>" + mdInline(strings.TrimSpace(cell), linkName) + "</td>")
 295  				}
 296  				b.WriteString("</tr>\n")
 297  				i++
 298  			}
 299  			b.WriteString("</tbody></table>\n")
 300  			continue
 301  		}
 302  
 303  		// blockquote
 304  		if strings.HasPrefix(line, "> ") || line == ">" {
 305  			if !inState("</blockquote>\n") {
 306  				flush()
 307  				b.WriteString("<blockquote>")
 308  				closeTags = append(closeTags, "</blockquote>\n")
 309  			}
 310  			text := ""
 311  			if len(line) > 2 {
 312  				text = line[2:]
 313  			}
 314  			b.WriteString(mdInline(text, linkName) + "\n")
 315  			i++
 316  			continue
 317  		}
 318  
 319  		// unordered list
 320  		if (strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") || strings.HasPrefix(line, "+ ")) && len(line) > 2 {
 321  			if !inState("</ul>\n") {
 322  				flush()
 323  				b.WriteString("<ul>\n")
 324  				closeTags = append(closeTags, "</ul>\n")
 325  			}
 326  			b.WriteString("<li>" + mdInline(line[2:], linkName) + "</li>\n")
 327  			i++
 328  			continue
 329  		}
 330  
 331  		// ordered list
 332  		if isOL(line) {
 333  			if !inState("</ol>\n") {
 334  				flush()
 335  				b.WriteString("<ol>\n")
 336  				closeTags = append(closeTags, "</ol>\n")
 337  			}
 338  			dot := strings.IndexByte(line, '.')
 339  			b.WriteString("<li>" + mdInline(strings.TrimSpace(line[dot+1:]), linkName) + "</li>\n")
 340  			i++
 341  			continue
 342  		}
 343  
 344  		// paragraph
 345  		if !inState("</p>\n") {
 346  			flush()
 347  			b.WriteString("<p>")
 348  			closeTags = append(closeTags, "</p>\n")
 349  		} else {
 350  			b.WriteByte('\n')
 351  		}
 352  		b.WriteString(mdInline(line, linkName))
 353  		i++
 354  	}
 355  
 356  	flush()
 357  	return b.String()
 358  }
 359  
 360  // mdInline processes inline markdown: code, bold, italic, links, images.
 361  func mdInline(s, linkName string) string {
 362  	s = esc(s) // HTML-escape first; markdown punctuation (*,[,],`,!) is not affected
 363  	var b strings.Builder
 364  	i := 0
 365  	for i < len(s) {
 366  		// backtick code span
 367  		if s[i] == '`' {
 368  			end := strings.IndexByte(s[i+1:], '`')
 369  			if end >= 0 {
 370  				b.WriteString("<code>" + s[i+1:i+1+end] + "</code>")
 371  				i += end + 2
 372  				continue
 373  			}
 374  		}
 375  
 376  		// linked image [![text](img-url)](link-url) — render as text link to link-url
 377  		if s[i] == '[' && i+1 < len(s) && s[i+1] == '!' && i+2 < len(s) && s[i+2] == '[' {
 378  			if innerText, _, innerAdv := parseLink(s[i+2:]); innerAdv > 0 {
 379  				pos := i + 2 + innerAdv
 380  				if pos+1 < len(s) && s[pos] == ']' && s[pos+1] == '(' {
 381  					end := strings.IndexByte(s[pos+2:], ')')
 382  					if end >= 0 {
 383  						url := s[pos+2 : pos+2+end]
 384  						b.WriteString(`<a href="` + url + `">` + innerText + `</a>`)
 385  						i = pos + 2 + end + 1
 386  						continue
 387  					}
 388  				}
 389  			}
 390  		}
 391  
 392  		// image ![text](url) — inline if relative, link if external
 393  		if s[i] == '!' && i+1 < len(s) && s[i+1] == '[' {
 394  			if text, url, advance := parseLink(s[i+1:]); advance > 0 {
 395  				if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") || strings.HasPrefix(url, "//") {
 396  					b.WriteString(`<a href="` + url + `">` + text + `</a>`)
 397  				} else {
 398  					raw := "/" + linkName + "/raw/" + strings.TrimPrefix(url, "./")
 399  					b.WriteString(`<img src="` + raw + `" alt="` + text + `">`)
 400  				}
 401  				i += 1 + advance
 402  				continue
 403  			}
 404  		}
 405  
 406  		// link [text](url)
 407  		if s[i] == '[' {
 408  			if text, url, advance := parseLink(s[i:]); advance > 0 {
 409  				b.WriteString(`<a href="` + url + `">` + text + `</a>`)
 410  				i += advance
 411  				continue
 412  			}
 413  		}
 414  
 415  		// bold **text**
 416  		if i+1 < len(s) && s[i] == '*' && s[i+1] == '*' {
 417  			end := strings.Index(s[i+2:], "**")
 418  			if end >= 0 {
 419  				b.WriteString("<strong>" + s[i+2:i+2+end] + "</strong>")
 420  				i += end + 4
 421  				continue
 422  			}
 423  		}
 424  
 425  		// bold __text__
 426  		if i+1 < len(s) && s[i] == '_' && s[i+1] == '_' {
 427  			end := strings.Index(s[i+2:], "__")
 428  			if end >= 0 {
 429  				b.WriteString("<strong>" + s[i+2:i+2+end] + "</strong>")
 430  				i += end + 4
 431  				continue
 432  			}
 433  		}
 434  
 435  		// italic *text*
 436  		if s[i] == '*' && (i+1 < len(s) && s[i+1] != '*') {
 437  			end := strings.IndexByte(s[i+1:], '*')
 438  			if end > 0 {
 439  				b.WriteString("<em>" + s[i+1:i+1+end] + "</em>")
 440  				i += end + 2
 441  				continue
 442  			}
 443  		}
 444  
 445  		// italic _text_
 446  		if s[i] == '_' && (i+1 < len(s) && s[i+1] != '_') {
 447  			end := strings.IndexByte(s[i+1:], '_')
 448  			if end > 0 {
 449  				b.WriteString("<em>" + s[i+1:i+1+end] + "</em>")
 450  				i += end + 2
 451  				continue
 452  			}
 453  		}
 454  
 455  		b.WriteByte(s[i])
 456  		i++
 457  	}
 458  	return b.String()
 459  }
 460  
 461  // parseLink parses [text](url) starting at s[0]=='['.
 462  // Returns text, url, and total bytes consumed. Returns 0 advance on failure.
 463  func parseLink(s string) (string, string, int) {
 464  	if len(s) < 4 || s[0] != '[' {
 465  		return "", "", 0
 466  	}
 467  	closeBracket := strings.IndexByte(s[1:], ']')
 468  	if closeBracket < 0 {
 469  		return "", "", 0
 470  	}
 471  	closeBracket++ // adjust for offset
 472  	if closeBracket+1 >= len(s) || s[closeBracket+1] != '(' {
 473  		return "", "", 0
 474  	}
 475  	closeParen := strings.IndexByte(s[closeBracket+2:], ')')
 476  	if closeParen < 0 {
 477  		return "", "", 0
 478  	}
 479  	text := s[1:closeBracket]
 480  	url := s[closeBracket+2 : closeBracket+2+closeParen]
 481  	return text, url, closeBracket + 2 + closeParen + 1
 482  }
 483  
 484  func allChar(s string, c byte) bool {
 485  	for i := 0; i < len(s); i++ {
 486  		if s[i] != c && s[i] != ' ' {
 487  			return false
 488  		}
 489  	}
 490  	return true
 491  }
 492  
 493  func isOL(line string) bool {
 494  	i := 0
 495  	for i < len(line) && line[i] >= '0' && line[i] <= '9' {
 496  		i++
 497  	}
 498  	return i > 0 && i < len(line)-1 && line[i] == '.' && line[i+1] == ' '
 499  }
 500  
 501  func isTableSep(line string) bool {
 502  	t := strings.TrimSpace(line)
 503  	if !strings.Contains(t, "|") {
 504  		return false
 505  	}
 506  	for _, c := range t {
 507  		if c != '|' && c != '-' && c != ':' && c != ' ' {
 508  			return false
 509  		}
 510  	}
 511  	return true
 512  }
 513  
 514  func splitTableRow(line string) []string {
 515  	line = strings.TrimSpace(line)
 516  	line = strings.Trim(line, "|")
 517  	return strings.Split(line, "|")
 518  }
 519  
 520  const css = `
 521  *{box-sizing:border-box}
 522  body{background:#000;color:#bbb;font:14px/1.6 monospace;margin:0;padding:20px 40px;max-width:960px}
 523  a{color:#7ab;text-decoration:none}
 524  a:hover{text-decoration:underline}
 525  h1{color:#ddd;font-size:20px;border-bottom:1px solid #333;padding-bottom:6px}
 526  h2{color:#ccc;font-size:16px;margin-top:24px}
 527  pre{background:#0a0a0a;color:#ccc;padding:14px;border-radius:3px;overflow-x:auto;border:1px solid #222}
 528  .ls{list-style:none;padding:0;margin:0}
 529  .ls li{padding:3px 0;border-bottom:1px solid #1a1a1a}
 530  .ls .d a{color:#8bf}
 531  .ls .d a::before{content:"d  ";color:#555}
 532  .ls .f a::before{content:"   ";color:#555}
 533  nav{margin-bottom:16px;color:#666}
 534  nav a{margin-right:4px}
 535  .desc{color:#666;margin-left:12px;font-size:12px}
 536  .info{color:#666;font-size:12px;margin-top:4px}
 537  .md{line-height:1.7}
 538  .md h1{font-size:22px;margin-top:28px}
 539  .md h2{font-size:18px;margin-top:24px}
 540  .md h3{font-size:15px;color:#ccc;margin-top:20px}
 541  .md h4,.md h5,.md h6{font-size:14px;color:#aaa;margin-top:16px}
 542  .md p{margin:10px 0}
 543  .md code{background:#1a1a2a;padding:2px 5px;border-radius:3px;font-size:13px}
 544  .md pre{margin:12px 0}
 545  .md pre code{background:none;padding:0}
 546  .md blockquote{border-left:3px solid #444;margin:10px 0;padding:4px 16px;color:#999}
 547  .md ul,.md ol{padding-left:24px;margin:8px 0}
 548  .md li{margin:3px 0}
 549  .md hr{border:none;border-top:1px solid #333;margin:20px 0}
 550  .md img{max-width:100%}
 551  .md table{border-collapse:collapse;margin:12px 0}
 552  .md th,.md td{border:1px solid #333;padding:6px 12px}
 553  .md th{background:#1a1a2a}
 554  .readme-box{position:relative}
 555  .readme-box input{display:none}
 556  .readme-box .readme-body{max-height:50vh;overflow:hidden}
 557  .readme-box input:checked+.readme-body{max-height:none}
 558  .readme-box .readme-body::after{content:"";position:absolute;bottom:28px;left:0;right:0;height:80px;background:linear-gradient(transparent,#000);pointer-events:none}
 559  .readme-box input:checked+.readme-body::after{display:none}
 560  .readme-box label{display:block;text-align:center;padding:6px;color:#7ab;cursor:pointer;border-top:1px solid #222;margin-top:4px;font-size:13px}
 561  .readme-box label:hover{color:#9cd}
 562  .readme-box input:checked~label .show{display:none}
 563  .readme-box input:not(:checked)~label .hide{display:none}
 564  `
 565  
 566  func page(title, body string) []byte {
 567  	var b bytes.Buffer
 568  	b.WriteString(`<!doctype html><html><head><meta charset="utf-8">`)
 569  	b.WriteString(`<meta name="viewport" content="width=device-width">`)
 570  	b.WriteString(`<title>` + esc(title) + `</title>`)
 571  	b.WriteString(`<style>` + css + `</style>`)
 572  	b.WriteString(`</head><body>` + body + `</body></html>`)
 573  	return b.Bytes()
 574  }
 575  
 576  // ── repo discovery ──────────────────────────────────────────────
 577  
 578  func findRepos() []string {
 579  	entries, err := os.ReadDir(repoRoot)
 580  	if err != nil {
 581  		return nil
 582  	}
 583  	var repos []string
 584  	for _, e := range entries {
 585  		if !e.IsDir() {
 586  			continue
 587  		}
 588  		if _, err := os.Stat(path.Join(repoRoot, e.Name(), "HEAD")); err == nil {
 589  			repos = append(repos, e.Name())
 590  		}
 591  	}
 592  	return repos
 593  }
 594  
 595  func cleanName(s string) string {
 596  	return strings.TrimSuffix(s, ".git")
 597  }
 598  
 599  // resolveRepo maps a URL name to the actual repo directory name.
 600  // Accepts both "smesh" and "smesh.git".
 601  func resolveRepo(name string) (string, string) {
 602  	// try exact match first
 603  	rp := path.Join(repoRoot, name)
 604  	if _, err := os.Stat(path.Join(rp, "HEAD")); err == nil {
 605  		return name, rp
 606  	}
 607  	// try with .git suffix
 608  	gitName := name + ".git"
 609  	rp = path.Join(repoRoot, gitName)
 610  	if _, err := os.Stat(path.Join(rp, "HEAD")); err == nil {
 611  		return gitName, rp
 612  	}
 613  	return "", ""
 614  }
 615  
 616  func repoDesc(repoDir string) string {
 617  	data, err := os.ReadFile(path.Join(repoDir, "description"))
 618  	if err != nil {
 619  		return ""
 620  	}
 621  	s := strings.TrimSpace(string(data))
 622  	if strings.HasPrefix(s, "Unnamed repository") {
 623  		return ""
 624  	}
 625  	return s
 626  }
 627  
 628  // ── ref resolution ──────────────────────────────────────────────
 629  
 630  // defaultRef finds the actual default branch for a bare repo.
 631  // HEAD may point to a ref that doesn't exist (e.g. refs/heads/master
 632  // when the only branch is main).
 633  func defaultRef(repoDir string) string {
 634  	// read HEAD to get the symbolic ref
 635  	data, err := os.ReadFile(path.Join(repoDir, "HEAD"))
 636  	if err != nil {
 637  		return "HEAD"
 638  	}
 639  	s := strings.TrimSpace(string(data))
 640  	if strings.HasPrefix(s, "ref: ") {
 641  		ref := s[5:]
 642  		// check if this ref exists as a loose ref file
 643  		if _, err := os.Stat(path.Join(repoDir, ref)); err == nil {
 644  			return ref
 645  		}
 646  		// check packed-refs
 647  		packed, err := os.ReadFile(path.Join(repoDir, "packed-refs"))
 648  		if err == nil && strings.Contains(string(packed), ref) {
 649  			return ref
 650  		}
 651  	}
 652  
 653  	// HEAD ref is broken — find first available branch
 654  	refsDir := path.Join(repoDir, "refs", "heads")
 655  	entries, err := os.ReadDir(refsDir)
 656  	if err == nil {
 657  		for _, e := range entries {
 658  			if !e.IsDir() {
 659  				return "refs/heads/" + e.Name()
 660  			}
 661  		}
 662  	}
 663  
 664  	// try packed-refs as last resort
 665  	packed, err := os.ReadFile(path.Join(repoDir, "packed-refs"))
 666  	if err == nil {
 667  		for _, line := range strings.Split(string(packed), "\n") {
 668  			line = strings.TrimSpace(line)
 669  			if line == "" || line[0] == '#' || line[0] == '^' {
 670  				continue
 671  			}
 672  			fields := strings.Fields(line)
 673  			if len(fields) >= 2 && strings.HasPrefix(fields[1], "refs/heads/") {
 674  				return fields[1]
 675  			}
 676  		}
 677  	}
 678  
 679  	return "HEAD"
 680  }
 681  
 682  // ── tree parsing ────────────────────────────────────────────────
 683  
 684  type entry struct {
 685  	name string
 686  	typ  string // "tree" or "blob"
 687  }
 688  
 689  func lsTree(repoDir, ref, treePath string) ([]entry, error) {
 690  	args := []string{"ls-tree", ref}
 691  	if treePath != "" {
 692  		args = append(args, treePath+"/")
 693  	}
 694  	out, err := gitCmd(repoDir, args...)
 695  	if err != nil {
 696  		return nil, err
 697  	}
 698  
 699  	prefix := ""
 700  	if treePath != "" {
 701  		prefix = treePath + "/"
 702  	}
 703  
 704  	var entries []entry
 705  	for _, line := range strings.Split(string(out), "\n") {
 706  		line = strings.TrimSpace(line)
 707  		if line == "" {
 708  			continue
 709  		}
 710  		// format: <mode> <type> <hash>\t<name>
 711  		tab := strings.IndexByte(line, '\t')
 712  		if tab < 0 {
 713  			continue
 714  		}
 715  		name := line[tab+1:]
 716  		fields := strings.Fields(line[:tab])
 717  		if len(fields) < 2 {
 718  			continue
 719  		}
 720  		// strip directory prefix
 721  		if prefix != "" && strings.HasPrefix(name, prefix) {
 722  			name = name[len(prefix):]
 723  		}
 724  		entries = append(entries, entry{name: name, typ: fields[1]})
 725  	}
 726  	return entries, nil
 727  }
 728  
 729  func mimeType(name string) string {
 730  	ext := name
 731  	if i := strings.LastIndexByte(name, '.'); i >= 0 {
 732  		ext = name[i:]
 733  	}
 734  	switch strings.ToLower(ext) {
 735  	case ".html", ".htm":
 736  		return "text/html; charset=utf-8"
 737  	case ".css":
 738  		return "text/css; charset=utf-8"
 739  	case ".js":
 740  		return "text/javascript; charset=utf-8"
 741  	case ".json":
 742  		return "application/json"
 743  	case ".png":
 744  		return "image/png"
 745  	case ".jpg", ".jpeg":
 746  		return "image/jpeg"
 747  	case ".gif":
 748  		return "image/gif"
 749  	case ".svg":
 750  		return "image/svg+xml"
 751  	case ".ico":
 752  		return "image/x-icon"
 753  	case ".woff2":
 754  		return "font/woff2"
 755  	case ".wasm":
 756  		return "application/wasm"
 757  	case ".pdf":
 758  		return "application/pdf"
 759  	case ".xml":
 760  		return "application/xml"
 761  	case ".txt", ".md", ".go", ".rs", ".py", ".sh", ".yml", ".yaml", ".toml",
 762  		".c", ".h", ".cpp", ".java", ".rb", ".pl", ".lua", ".sql", ".diff",
 763  		".patch", ".conf", ".cfg", ".ini", ".log", ".csv", ".tsx", ".ts",
 764  		".jsx", ".vue", ".svelte", ".mx":
 765  		return "text/plain; charset=utf-8"
 766  	}
 767  	return "application/octet-stream"
 768  }
 769  
 770  func isGitProto(sub string) bool {
 771  	return sub == "info" || sub == "git-upload-pack" || sub == "git-receive-pack" ||
 772  		sub == "HEAD" || sub == "objects"
 773  }
 774  
 775  // ── handlers ────────────────────────────────────────────────────
 776  
 777  func handler(w http.ResponseWriter, r *http.Request) {
 778  	p := strings.Trim(r.URL.Path, "/")
 779  
 780  	if p == "" {
 781  		serveIndex(w)
 782  		return
 783  	}
 784  
 785  	parts := strings.SplitN(p, "/", 3)
 786  	urlName := parts[0]
 787  
 788  	// git smart HTTP protocol
 789  	if len(parts) > 1 && isGitProto(parts[1]) {
 790  		serveGit(w, r)
 791  		return
 792  	}
 793  
 794  	repo, rp := resolveRepo(urlName)
 795  
 796  	if repo == "" {
 797  		http.NotFound(w, r)
 798  		return
 799  	}
 800  
 801  	// go-get meta tag support
 802  	if r.URL.Query().Get("go-get") == "1" {
 803  		serveGoGet(w, urlName, repo)
 804  		return
 805  	}
 806  
 807  	// use the clean URL name for links
 808  	linkName := cleanName(repo)
 809  
 810  	if len(parts) == 1 {
 811  		serveRepo(w, linkName, repo, rp)
 812  		return
 813  	}
 814  
 815  	sub := ""
 816  	if len(parts) > 2 {
 817  		sub = parts[2]
 818  	}
 819  
 820  	switch parts[1] {
 821  	case "tree":
 822  		serveTree(w, linkName, repo, rp, sub)
 823  	case "blob":
 824  		serveBlob(w, linkName, repo, rp, sub)
 825  	case "raw":
 826  		serveRaw(w, linkName, rp, sub)
 827  	default:
 828  		http.NotFound(w, r)
 829  	}
 830  }
 831  
 832  func serveGoGet(w http.ResponseWriter, urlName, repo string) {
 833  	mod := cleanName(urlName)
 834  	w.Header().Set("Content-Type", "text/html")
 835  	fmt.Fprintf(w, `<html><head><meta name="go-import" content="%s/%s git https://%s/%s"></head><body>go get</body></html>`,
 836  		esc(host), esc(mod), esc(host), esc(repo))
 837  }
 838  
 839  func serveIndex(w http.ResponseWriter) {
 840  	repos := findRepos()
 841  	var b strings.Builder
 842  	b.WriteString(`<h1>` + esc(host) + `</h1><ul class="ls">`)
 843  	for _, r := range repos {
 844  		name := cleanName(r)
 845  		desc := repoDesc(path.Join(repoRoot, r))
 846  		b.WriteString(`<li><a href="/` + esc(name) + `">` + esc(name) + `</a>`)
 847  		if desc != "" {
 848  			b.WriteString(`<span class="desc">` + esc(desc) + `</span>`)
 849  		}
 850  		b.WriteString(`</li>`)
 851  	}
 852  	b.WriteString(`</ul>`)
 853  	w.Header().Set("Content-Type", "text/html; charset=utf-8")
 854  	w.Write(page("repos", b.String()))
 855  }
 856  
 857  func serveRepo(w http.ResponseWriter, linkName, repo, repoDir string) {
 858  	ref := defaultRef(repoDir)
 859  	var b strings.Builder
 860  
 861  	b.WriteString(`<nav><a href="/">repos</a></nav>`)
 862  	b.WriteString(`<h1>` + esc(linkName) + `</h1>`)
 863  
 864  	// description
 865  	if desc := repoDesc(repoDir); desc != "" {
 866  		b.WriteString(`<p class="info">` + esc(desc) + `</p>`)
 867  	}
 868  
 869  	// clone urls
 870  	b.WriteString(`<p class="info">git clone https://` + esc(host) + `/` + esc(repo) + `</p>`)
 871  	b.WriteString(`<p class="info">git clone ssh://git@` + esc(host) + `:2222/~/` + esc(repo) + `</p>`)
 872  
 873  	// readme
 874  	for _, readme := range []string{"README.md", "README", "README.txt", "readme.md"} {
 875  		data, err := gitCmd(repoDir, "show", ref+":"+readme)
 876  		if err == nil && len(data) > 0 {
 877  			b.WriteString(`<div class="readme-box">`)
 878  			b.WriteString(`<input type="checkbox" id="readme-toggle">`)
 879  			b.WriteString(`<div class="readme-body">`)
 880  			if strings.HasSuffix(readme, ".md") {
 881  				b.WriteString(`<div class="md">` + renderMD(string(data), linkName) + `</div>`)
 882  			} else {
 883  				b.WriteString(`<h2>` + esc(readme) + `</h2>`)
 884  				b.WriteString(`<pre>` + esc(string(data)) + `</pre>`)
 885  			}
 886  			b.WriteString(`</div>`)
 887  			b.WriteString(`<label for="readme-toggle"><span class="show">show more</span><span class="hide">show less</span></label>`)
 888  			b.WriteString(`</div>`)
 889  			break
 890  		}
 891  	}
 892  
 893  	// file listing
 894  	entries, err := lsTree(repoDir, ref, "")
 895  	if err != nil {
 896  		b.WriteString(`<p class="info">empty repository — push to get started</p>`)
 897  	} else if len(entries) > 0 {
 898  		b.WriteString(`<h2>files</h2>`)
 899  		writeEntriesList(&b, linkName, entries, "")
 900  	}
 901  
 902  	w.Header().Set("Content-Type", "text/html; charset=utf-8")
 903  	w.Write(page(linkName, b.String()))
 904  }
 905  
 906  func writeEntries(b *strings.Builder, linkName, repoDir, ref, treePath string) {
 907  	entries, err := lsTree(repoDir, ref, treePath)
 908  	if err != nil {
 909  		b.WriteString(`<p class="info">` + esc(err.Error()) + `</p>`)
 910  		return
 911  	}
 912  	writeEntriesList(b, linkName, entries, treePath)
 913  }
 914  
 915  func writeEntriesList(b *strings.Builder, linkName string, entries []entry, treePath string) {
 916  	b.WriteString(`<ul class="ls">`)
 917  	for _, e := range entries {
 918  		if e.typ != "tree" {
 919  			continue
 920  		}
 921  		fp := e.name
 922  		if treePath != "" {
 923  			fp = treePath + "/" + e.name
 924  		}
 925  		b.WriteString(`<li class="d"><a href="/` + esc(linkName) + `/tree/` + esc(fp) + `">` + esc(e.name) + `/</a></li>`)
 926  	}
 927  	for _, e := range entries {
 928  		if e.typ == "tree" {
 929  			continue
 930  		}
 931  		fp := e.name
 932  		if treePath != "" {
 933  			fp = treePath + "/" + e.name
 934  		}
 935  		b.WriteString(`<li class="f"><a href="/` + esc(linkName) + `/blob/` + esc(fp) + `">` + esc(e.name) + `</a></li>`)
 936  	}
 937  	b.WriteString(`</ul>`)
 938  }
 939  
 940  func breadcrumb(linkName, treePath string) string {
 941  	var b strings.Builder
 942  	b.WriteString(`<nav><a href="/">repos</a> / <a href="/` + esc(linkName) + `">` + esc(linkName) + `</a>`)
 943  	if treePath != "" {
 944  		parts := strings.Split(treePath, "/")
 945  		for i, p := range parts {
 946  			prefix := strings.Join(parts[:i+1], "/")
 947  			if i < len(parts)-1 {
 948  				b.WriteString(` / <a href="/` + esc(linkName) + `/tree/` + esc(prefix) + `">` + esc(p) + `</a>`)
 949  			} else {
 950  				b.WriteString(` / ` + esc(p))
 951  			}
 952  		}
 953  	}
 954  	b.WriteString(`</nav>`)
 955  	return b.String()
 956  }
 957  
 958  func serveTree(w http.ResponseWriter, linkName, repo, repoDir, treePath string) {
 959  	ref := defaultRef(repoDir)
 960  	var b strings.Builder
 961  	b.WriteString(breadcrumb(linkName, treePath))
 962  	b.WriteString(`<h1>` + esc(treePath) + `</h1>`)
 963  	writeEntries(&b, linkName, repoDir, ref, treePath)
 964  
 965  	w.Header().Set("Content-Type", "text/html; charset=utf-8")
 966  	w.Write(page(linkName+" / "+treePath, b.String()))
 967  }
 968  
 969  func serveBlob(w http.ResponseWriter, linkName, repo, repoDir, blobPath string) {
 970  	ref := defaultRef(repoDir)
 971  	data, err := gitCmd(repoDir, "show", ref+":"+blobPath)
 972  	if err != nil {
 973  		http.NotFound(w, nil)
 974  		return
 975  	}
 976  
 977  	var b strings.Builder
 978  	b.WriteString(breadcrumb(linkName, blobPath))
 979  
 980  	fname := blobPath
 981  	if idx := strings.LastIndexByte(blobPath, '/'); idx >= 0 {
 982  		fname = blobPath[idx+1:]
 983  	}
 984  	b.WriteString(`<h1>` + esc(fname) + ` <a href="/` + esc(linkName) + `/raw/` + esc(blobPath) + `" style="font-size:12px;font-weight:normal">raw</a></h1>`)
 985  
 986  	// line numbers
 987  	lines := strings.Split(string(data), "\n")
 988  	b.WriteString(`<pre>`)
 989  	for i, line := range lines {
 990  		ln := fmt.Sprintf("%4d  ", i+1)
 991  		b.WriteString(`<span style="color:#444">` + ln + `</span>` + esc(line) + "\n")
 992  	}
 993  	b.WriteString(`</pre>`)
 994  
 995  	w.Header().Set("Content-Type", "text/html; charset=utf-8")
 996  	w.Write(page(linkName+" / "+blobPath, b.String()))
 997  }
 998  
 999  func serveRaw(w http.ResponseWriter, linkName, repoDir, filePath string) {
1000  	if filePath == "" {
1001  		http.NotFound(w, nil)
1002  		return
1003  	}
1004  	ref := defaultRef(repoDir)
1005  	data, err := gitCmd(repoDir, "show", ref+":"+filePath)
1006  	if err != nil {
1007  		http.NotFound(w, nil)
1008  		return
1009  	}
1010  	w.Header().Set("Content-Type", mimeType(filePath))
1011  	w.Header().Set("Cache-Control", "max-age=300")
1012  	w.Write(data)
1013  }
1014  
1015  func serveGit(w http.ResponseWriter, r *http.Request) {
1016  	env := []string{
1017  		"PATH=/usr/bin:/bin",
1018  		"GIT_PROJECT_ROOT=" + repoRoot,
1019  		"GIT_HTTP_EXPORT_ALL=1",
1020  		"REQUEST_METHOD=" + r.Method,
1021  		"QUERY_STRING=" + r.URL.RawQuery,
1022  		"PATH_INFO=" + r.URL.Path,
1023  		"SERVER_PROTOCOL=HTTP/1.1",
1024  	}
1025  	if ct := r.Header.Get("Content-Type"); ct != "" {
1026  		env = append(env, "CONTENT_TYPE="+ct)
1027  	}
1028  	if cl := r.Header.Get("Content-Length"); cl != "" {
1029  		env = append(env, "CONTENT_LENGTH="+cl)
1030  	}
1031  	if proto := r.Header.Get("Git-Protocol"); proto != "" {
1032  		env = append(env, "GIT_PROTOCOL="+proto)
1033  	}
1034  	ra := r.RemoteAddr
1035  	if i := strings.LastIndexByte(ra, ':'); i >= 0 {
1036  		ra = ra[:i]
1037  	}
1038  	env = append(env, "REMOTE_ADDR="+ra)
1039  
1040  	var stdin []byte
1041  	if r.Method == "POST" && r.Body != nil {
1042  		var body bytes.Buffer
1043  		tmp := make([]byte, 8192)
1044  		for {
1045  			n, err := r.Body.Read(tmp)
1046  			if n > 0 {
1047  				body.Write(tmp[:n])
1048  			}
1049  			if err != nil {
1050  				break
1051  			}
1052  		}
1053  		if body.Len() > 0 {
1054  			stdin = body.Bytes()
1055  		}
1056  	}
1057  
1058  	out, err := runIO(env, stdin, gitBackend)
1059  	if err != nil && len(out) == 0 {
1060  		http.Error(w, "git backend error", 500)
1061  		return
1062  	}
1063  
1064  	// parse CGI response: headers \n\n body
1065  	sep := bytes.Index(out, []byte("\r\n\r\n"))
1066  	skip := 4
1067  	if sep < 0 {
1068  		sep = bytes.Index(out, []byte("\n\n"))
1069  		skip = 2
1070  	}
1071  	if sep < 0 {
1072  		http.Error(w, "bad cgi response", 500)
1073  		return
1074  	}
1075  
1076  	code := 200
1077  	for _, line := range strings.Split(string(out[:sep]), "\n") {
1078  		line = strings.TrimRight(line, "\r")
1079  		idx := strings.IndexByte(line, ':')
1080  		if idx < 0 {
1081  			continue
1082  		}
1083  		key := strings.TrimSpace(line[:idx])
1084  		val := strings.TrimSpace(line[idx+1:])
1085  		if strings.EqualFold(key, "Status") {
1086  			if len(val) >= 3 {
1087  				code = 0
1088  				for j := 0; j < 3; j++ {
1089  					if val[j] >= '0' && val[j] <= '9' {
1090  						code = code*10 + int(val[j]-'0')
1091  					}
1092  				}
1093  			}
1094  		} else {
1095  			w.Header().Set(key, val)
1096  		}
1097  	}
1098  	w.WriteHeader(code)
1099  	w.Write(out[sep+skip:])
1100  }
1101  
1102  // ── main ────────────────────────────────────────────────────────
1103  
1104  func main() {
1105  	for i := 1; i < len(os.Args); i++ {
1106  		switch os.Args[i] {
1107  		case "-repos":
1108  			i++
1109  			if i < len(os.Args) {
1110  				repoRoot = os.Args[i]
1111  			}
1112  		case "-listen":
1113  			i++
1114  			if i < len(os.Args) {
1115  				addr = os.Args[i]
1116  			}
1117  		case "-git":
1118  			i++
1119  			if i < len(os.Args) {
1120  				gitBin = os.Args[i]
1121  			}
1122  		case "-host":
1123  			i++
1124  			if i < len(os.Args) {
1125  				host = os.Args[i]
1126  			}
1127  		case "-git-backend":
1128  			i++
1129  			if i < len(os.Args) {
1130  				gitBackend = os.Args[i]
1131  			}
1132  		}
1133  	}
1134  
1135  	// auto-detect git-http-backend path
1136  	if _, err := os.Stat(gitBackend); err != nil {
1137  		for _, p := range []string{"/usr/lib/git-core/git-http-backend", "/usr/libexec/git-core/git-http-backend"} {
1138  			if _, err := os.Stat(p); err == nil {
1139  				gitBackend = p
1140  				break
1141  			}
1142  		}
1143  	}
1144  
1145  	fmt.Printf("gitweb %s repos=%s\n", addr, repoRoot)
1146  	http.HandleFunc("/", handler)
1147  	if err := http.ListenAndServe(addr, nil); err != nil {
1148  		fmt.Fprintf(os.Stderr, "%v\n", err)
1149  		os.Exit(1)
1150  	}
1151  }
1152