main.mx raw

   1  package main
   2  
   3  import (
   4  	"bytes"
   5  	"fmt"
   6  	"net"
   7  	"net/http"
   8  	"os"
   9  	"path"
  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.smesh.lol"
  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 := []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 := []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 := []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 := []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 := []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 = bytes.ReplaceAll(s, "&", "&amp;")
 189  	s = bytes.ReplaceAll(s, "<", "&lt;")
 190  	s = bytes.ReplaceAll(s, ">", "&gt;")
 191  	s = bytes.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 bytes.Buffer
 200  	lines := bytes.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 — count opening backticks for GFM nesting
 227  		if bytes.HasPrefix(line, "```") {
 228  			flush()
 229  			lastWasHeading = false
 230  			fenceLen := 0
 231  			for fenceLen < len(line) && line[fenceLen] == '`' {
 232  				fenceLen++
 233  			}
 234  			lang := bytes.TrimSpace(line[fenceLen:])
 235  			b.WriteString(`<pre><code>`)
 236  			if lang != "" {
 237  				_ = lang // no syntax highlighting, just consume
 238  			}
 239  			i++
 240  			for i < n {
 241  				cl := bytes.TrimSpace(lines[i])
 242  				// closing fence: >= same backtick count, nothing else
 243  				ticks := 0
 244  				for ticks < len(cl) && cl[ticks] == '`' {
 245  					ticks++
 246  				}
 247  				if ticks >= fenceLen && allChar(cl, '`') {
 248  					break
 249  				}
 250  				b.WriteString(esc(lines[i]))
 251  				b.WriteByte('\n')
 252  				i++
 253  			}
 254  			b.WriteString(`</code></pre>`)
 255  			i++ // skip closing ```
 256  			continue
 257  		}
 258  
 259  		// blank line — close open blocks
 260  		if bytes.TrimSpace(line) == "" {
 261  			flush()
 262  			i++
 263  			continue
 264  		}
 265  
 266  		// heading
 267  		if line[0] == '#' {
 268  			flush()
 269  			lvl := 0
 270  			for lvl < len(line) && line[lvl] == '#' {
 271  				lvl++
 272  			}
 273  			if lvl <= 6 && lvl < len(line) && line[lvl] == ' ' {
 274  				text := bytes.TrimSpace(line[lvl:])
 275  				text = bytes.TrimRight(text, " #")
 276  				b.WriteString(fmt.Sprintf("<h%d>%s</h%d>\n", lvl, mdInline(text, linkName), lvl))
 277  				lastWasHeading = true
 278  				i++
 279  				continue
 280  			}
 281  		}
 282  
 283  		// horizontal rule — skip if immediately after a heading (already has border-bottom)
 284  		trimmed := bytes.TrimSpace(line)
 285  		if len(trimmed) >= 3 && (allChar(trimmed, '-') || allChar(trimmed, '*') || allChar(trimmed, '_')) {
 286  			flush()
 287  			if !lastWasHeading {
 288  				b.WriteString("<hr>\n")
 289  			}
 290  			lastWasHeading = false
 291  			i++
 292  			continue
 293  		}
 294  
 295  		// table (line contains unescaped | and next line is separator)
 296  		if hasUnescapedPipe(line) && i+1 < n && isTableSep(lines[i+1]) {
 297  			flush()
 298  			b.WriteString("<table>\n<thead><tr>")
 299  			for _, cell := range splitTableRow(line) {
 300  				b.WriteString("<th>" + mdInline(bytes.TrimSpace(cell), linkName) + "</th>")
 301  			}
 302  			b.WriteString("</tr></thead>\n<tbody>\n")
 303  			i += 2 // skip header + separator
 304  			for i < n && hasUnescapedPipe(lines[i]) && bytes.TrimSpace(lines[i]) != "" {
 305  				b.WriteString("<tr>")
 306  				for _, cell := range splitTableRow(lines[i]) {
 307  					b.WriteString("<td>" + mdInline(bytes.TrimSpace(cell), linkName) + "</td>")
 308  				}
 309  				b.WriteString("</tr>\n")
 310  				i++
 311  			}
 312  			b.WriteString("</tbody></table>\n")
 313  			continue
 314  		}
 315  
 316  		// blockquote
 317  		if bytes.HasPrefix(line, "> ") || line == ">" {
 318  			if !inState("</blockquote>\n") {
 319  				flush()
 320  				b.WriteString("<blockquote>")
 321  				closeTags = append(closeTags, "</blockquote>\n")
 322  			}
 323  			text := ""
 324  			if len(line) > 2 {
 325  				text = line[2:]
 326  			}
 327  			b.WriteString(mdInline(text, linkName) + "\n")
 328  			i++
 329  			continue
 330  		}
 331  
 332  		// unordered list
 333  		if (bytes.HasPrefix(line, "- ") || bytes.HasPrefix(line, "* ") || bytes.HasPrefix(line, "+ ")) && len(line) > 2 {
 334  			if !inState("</ul>\n") {
 335  				flush()
 336  				b.WriteString("<ul>\n")
 337  				closeTags = append(closeTags, "</ul>\n")
 338  			}
 339  			b.WriteString("<li>" + mdInline(line[2:], linkName) + "</li>\n")
 340  			i++
 341  			continue
 342  		}
 343  
 344  		// ordered list
 345  		if isOL(line) {
 346  			if !inState("</ol>\n") {
 347  				flush()
 348  				b.WriteString("<ol>\n")
 349  				closeTags = append(closeTags, "</ol>\n")
 350  			}
 351  			dot := bytes.IndexByte(line, '.')
 352  			b.WriteString("<li>" + mdInline(bytes.TrimSpace(line[dot+1:]), linkName) + "</li>\n")
 353  			i++
 354  			continue
 355  		}
 356  
 357  		// paragraph
 358  		if !inState("</p>\n") {
 359  			flush()
 360  			b.WriteString("<p>")
 361  			closeTags = append(closeTags, "</p>\n")
 362  		} else {
 363  			b.WriteByte('\n')
 364  		}
 365  		b.WriteString(mdInline(line, linkName))
 366  		i++
 367  	}
 368  
 369  	flush()
 370  	return b.String()
 371  }
 372  
 373  // mdInline processes inline markdown: code, bold, italic, links, images.
 374  func mdInline(s, linkName string) string {
 375  	s = esc(s) // HTML-escape first; markdown punctuation (*,[,],`,!) is not affected
 376  	var b bytes.Buffer
 377  	i := 0
 378  	for i < len(s) {
 379  		// backtick code span
 380  		if s[i] == '`' {
 381  			end := bytes.IndexByte(s[i+1:], '`')
 382  			if end >= 0 {
 383  				b.WriteString("<code>" + s[i+1:i+1+end] + "</code>")
 384  				i += end + 2
 385  				continue
 386  			}
 387  		}
 388  
 389  		// linked image [![text](img-url)](link-url) — render as text link to link-url
 390  		if s[i] == '[' && i+1 < len(s) && s[i+1] == '!' && i+2 < len(s) && s[i+2] == '[' {
 391  			if innerText, _, innerAdv := parseLink(s[i+2:]); innerAdv > 0 {
 392  				pos := i + 2 + innerAdv
 393  				if pos+1 < len(s) && s[pos] == ']' && s[pos+1] == '(' {
 394  					end := bytes.IndexByte(s[pos+2:], ')')
 395  					if end >= 0 {
 396  						url := s[pos+2 : pos+2+end]
 397  						b.WriteString(`<a href="` + url + `">` + innerText + `</a>`)
 398  						i = pos + 2 + end + 1
 399  						continue
 400  					}
 401  				}
 402  			}
 403  		}
 404  
 405  		// image ![text](url) — inline if relative, link if external
 406  		if s[i] == '!' && i+1 < len(s) && s[i+1] == '[' {
 407  			if text, url, advance := parseLink(s[i+1:]); advance > 0 {
 408  				if bytes.HasPrefix(url, "http://") || bytes.HasPrefix(url, "https://") || bytes.HasPrefix(url, "//") {
 409  					b.WriteString(`<a href="` + url + `">` + text + `</a>`)
 410  				} else {
 411  					raw := "/" + linkName + "/raw/" + bytes.TrimPrefix(url, "./")
 412  					b.WriteString(`<img src="` + raw + `" alt="` + text + `">`)
 413  				}
 414  				i += 1 + advance
 415  				continue
 416  			}
 417  		}
 418  
 419  		// link [text](url) — rewrite relative paths to gitweb blob URLs
 420  		if s[i] == '[' {
 421  			if text, url, advance := parseLink(s[i:]); advance > 0 {
 422  				href := url
 423  				if linkName != "" && !bytes.HasPrefix(url, "http://") && !bytes.HasPrefix(url, "https://") && !bytes.HasPrefix(url, "//") && !bytes.HasPrefix(url, "#") && !bytes.HasPrefix(url, "/") {
 424  					href = "/" + linkName + "/blob/" + bytes.TrimPrefix(url, "./")
 425  				}
 426  				b.WriteString(`<a href="` + href + `">` + text + `</a>`)
 427  				i += advance
 428  				continue
 429  			}
 430  		}
 431  
 432  		// bold **text**
 433  		if i+1 < len(s) && s[i] == '*' && s[i+1] == '*' {
 434  			end := bytes.Index(s[i+2:], "**")
 435  			if end >= 0 {
 436  				b.WriteString("<strong>" + s[i+2:i+2+end] + "</strong>")
 437  				i += end + 4
 438  				continue
 439  			}
 440  		}
 441  
 442  		// bold __text__
 443  		if i+1 < len(s) && s[i] == '_' && s[i+1] == '_' {
 444  			end := bytes.Index(s[i+2:], "__")
 445  			if end >= 0 {
 446  				b.WriteString("<strong>" + s[i+2:i+2+end] + "</strong>")
 447  				i += end + 4
 448  				continue
 449  			}
 450  		}
 451  
 452  		// italic *text*
 453  		if s[i] == '*' && (i+1 < len(s) && s[i+1] != '*') {
 454  			end := bytes.IndexByte(s[i+1:], '*')
 455  			if end > 0 {
 456  				b.WriteString("<em>" + s[i+1:i+1+end] + "</em>")
 457  				i += end + 2
 458  				continue
 459  			}
 460  		}
 461  
 462  		// italic _text_
 463  		if s[i] == '_' && (i+1 < len(s) && s[i+1] != '_') {
 464  			end := bytes.IndexByte(s[i+1:], '_')
 465  			if end > 0 {
 466  				b.WriteString("<em>" + s[i+1:i+1+end] + "</em>")
 467  				i += end + 2
 468  				continue
 469  			}
 470  		}
 471  
 472  		// backslash escape — \| \* \_ \` \[ \] \\ render as literal
 473  		if s[i] == '\\' && i+1 < len(s) {
 474  			next := s[i+1]
 475  			if next == '|' || next == '*' || next == '_' || next == '`' || next == '[' || next == ']' || next == '\\' {
 476  				b.WriteByte(next)
 477  				i += 2
 478  				continue
 479  			}
 480  		}
 481  
 482  		b.WriteByte(s[i])
 483  		i++
 484  	}
 485  	return b.String()
 486  }
 487  
 488  // parseLink parses [text](url) starting at s[0]=='['.
 489  // Returns text, url, and total bytes consumed. Returns 0 advance on failure.
 490  func parseLink(s string) (string, string, int) {
 491  	if len(s) < 4 || s[0] != '[' {
 492  		return "", "", 0
 493  	}
 494  	closeBracket := bytes.IndexByte(s[1:], ']')
 495  	if closeBracket < 0 {
 496  		return "", "", 0
 497  	}
 498  	closeBracket++ // adjust for offset
 499  	if closeBracket+1 >= len(s) || s[closeBracket+1] != '(' {
 500  		return "", "", 0
 501  	}
 502  	closeParen := bytes.IndexByte(s[closeBracket+2:], ')')
 503  	if closeParen < 0 {
 504  		return "", "", 0
 505  	}
 506  	text := s[1:closeBracket]
 507  	url := s[closeBracket+2 : closeBracket+2+closeParen]
 508  	return text, url, closeBracket + 2 + closeParen + 1
 509  }
 510  
 511  func allChar(s string, c byte) bool {
 512  	for i := 0; i < len(s); i++ {
 513  		if s[i] != c && s[i] != ' ' {
 514  			return false
 515  		}
 516  	}
 517  	return true
 518  }
 519  
 520  func isOL(line string) bool {
 521  	i := 0
 522  	for i < len(line) && line[i] >= '0' && line[i] <= '9' {
 523  		i++
 524  	}
 525  	return i > 0 && i < len(line)-1 && line[i] == '.' && line[i+1] == ' '
 526  }
 527  
 528  // hasUnescapedPipe returns true if the line contains | that isn't
 529  // preceded by \ and isn't inside a backtick code span.
 530  func hasUnescapedPipe(line string) bool {
 531  	inCode := false
 532  	for i := 0; i < len(line); i++ {
 533  		if line[i] == '`' {
 534  			inCode = !inCode
 535  		} else if line[i] == '\\' && i+1 < len(line) && line[i+1] == '|' {
 536  			i++ // skip escaped pipe
 537  		} else if line[i] == '|' && !inCode {
 538  			return true
 539  		}
 540  	}
 541  	return false
 542  }
 543  
 544  func isTableSep(line string) bool {
 545  	t := bytes.TrimSpace(line)
 546  	if !bytes.Contains(t, "|") {
 547  		return false
 548  	}
 549  	for _, c := range t {
 550  		if c != '|' && c != '-' && c != ':' && c != ' ' {
 551  			return false
 552  		}
 553  	}
 554  	return true
 555  }
 556  
 557  func splitTableRow(line string) []string {
 558  	line = bytes.TrimSpace(line)
 559  	line = bytes.Trim(line, "|")
 560  	// Split on | but not \| (escaped pipe) or | inside backtick spans.
 561  	// Uses index slicing into the original line — avoids bytes.Buffer
 562  	// aliasing where string=[]byte causes Reset() to overwrite prior cells.
 563  	var cells []string
 564  	start := 0
 565  	inCode := false
 566  	for i := 0; i < len(line); i++ {
 567  		if line[i] == '`' {
 568  			inCode = !inCode
 569  		} else if line[i] == '\\' && i+1 < len(line) && line[i+1] == '|' {
 570  			i++ // skip escaped pipe — mdInline handles \| → |
 571  		} else if line[i] == '|' && !inCode {
 572  			cells = append(cells, line[start:i])
 573  			start = i + 1
 574  		}
 575  	}
 576  	cells = append(cells, line[start:])
 577  	return cells
 578  }
 579  
 580  const css = `
 581  :root{--bg:#fff;--bg2:#eaeaea;--fg:#111;--accent:#7b3fe4;--muted:#666;--border:#bbb}
 582  .dark{--bg:#000;--bg2:#111;--fg:#e0e0e0;--accent:#f59e0b;--muted:#666;--border:#333}
 583  *{box-sizing:border-box}
 584  body{background:var(--bg);color:var(--fg);font:14px/1.6 'Fira Code',monospace;margin:0;padding:20px 40px;max-width:960px}
 585  a{color:var(--accent);text-decoration:none}
 586  a:hover{text-decoration:underline}
 587  h1{color:var(--fg);font-size:20px;border-bottom:1px solid var(--border);padding-bottom:6px}
 588  h2{color:var(--fg);font-size:16px;margin-top:24px}
 589  pre{background:var(--bg2);color:var(--fg);padding:14px;border-radius:3px;overflow-x:auto;border:1px solid var(--border)}
 590  .ls{list-style:none;padding:0;margin:0}
 591  .ls li{padding:3px 0;border-bottom:1px solid var(--border)}
 592  .ls .d a{color:var(--accent)}
 593  .ls .d a::before{content:"d  ";color:var(--muted)}
 594  .ls .f a::before{content:"   ";color:var(--muted)}
 595  nav{margin-bottom:16px;color:var(--muted)}
 596  nav a{margin-right:4px}
 597  .desc{color:var(--muted);margin-left:12px;font-size:12px}
 598  .info{color:var(--muted);font-size:12px;margin-top:4px}
 599  .md{line-height:1.7}
 600  .md h1{font-size:22px;margin-top:28px}
 601  .md h2{font-size:18px;margin-top:24px}
 602  .md h3{font-size:15px;margin-top:20px}
 603  .md h4,.md h5,.md h6{font-size:14px;color:var(--muted);margin-top:16px}
 604  .md p{margin:10px 0}
 605  .md code{background:var(--bg2);padding:2px 5px;border-radius:3px;font-size:13px}
 606  .md pre{margin:12px 0}
 607  .md pre code{background:none;padding:0}
 608  .md blockquote{border-left:3px solid var(--border);margin:10px 0;padding:4px 16px;color:var(--muted)}
 609  .md ul,.md ol{padding-left:24px;margin:8px 0}
 610  .md li{margin:3px 0}
 611  .md hr{border:none;border-top:1px solid var(--border);margin:20px 0}
 612  .md img{max-width:100%}
 613  .md table{border-collapse:collapse;margin:12px 0}
 614  .md th,.md td{border:1px solid var(--border);padding:6px 12px}
 615  .md th{background:var(--bg2)}
 616  .readme-box{position:relative}
 617  .readme-box input{display:none}
 618  .readme-box .readme-body{max-height:50vh;overflow:hidden}
 619  .readme-box input:checked+.readme-body{max-height:none}
 620  .readme-box .readme-body::after{content:"";position:absolute;bottom:28px;left:0;right:0;height:80px;background:linear-gradient(transparent,var(--bg));pointer-events:none}
 621  .readme-box input:checked+.readme-body::after{display:none}
 622  .readme-box label{display:block;text-align:center;padding:6px;color:var(--accent);cursor:pointer;border-top:1px solid var(--border);margin-top:4px;font-size:13px}
 623  .readme-box label:hover{opacity:0.8}
 624  .readme-box input:checked~label .show{display:none}
 625  .readme-box input:not(:checked)~label .hide{display:none}
 626  .hdr{display:flex;align-items:center;gap:12px;margin-bottom:16px}
 627  .hdr svg{width:56px;height:56px;flex-shrink:0}
 628  .hdr h1{border:none;padding:0;margin:0}
 629  .theme-btn{position:fixed;top:12px;right:12px;background:none;border:none;color:var(--fg);cursor:pointer;padding:8px;opacity:0.6}
 630  .theme-btn:hover{opacity:1}
 631  .theme-btn svg{width:22px;height:22px}
 632  .when-dark{display:none}
 633  .dark .when-light{display:none}
 634  .dark .when-dark{display:inline}
 635  .ln{color:var(--muted)}
 636  ::-webkit-scrollbar{width:14px}
 637  ::-webkit-scrollbar-track{background:var(--bg)}
 638  ::-webkit-scrollbar-thumb{background:var(--border);border-radius:7px;border:3px solid var(--bg)}
 639  ::-webkit-scrollbar-thumb:hover{background:var(--muted)}
 640  *{scrollbar-width:auto;scrollbar-color:var(--border) var(--bg)}
 641  `
 642  
 643  const logo = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 680 680"><defs><clipPath id="clip"><circle cx="340" cy="340" r="290"/></clipPath></defs><g clip-path="url(#clip)"><line x1="340" y1="340" x2="340" y2="282" stroke="currentColor" stroke-width="64" stroke-linecap="round"/><line x1="340" y1="282" x2="282" y2="239.5" stroke="currentColor" stroke-width="48" stroke-linecap="round"/><line x1="282" y1="239.5" x2="217" y2="217" stroke="currentColor" stroke-width="36" stroke-linecap="round"/><line x1="217" y1="217" x2="155.9" y2="198.8" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="155.9" y1="198.8" x2="98.9" y2="178.9" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="155.9" y1="198.8" x2="122" y2="148.8" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="217" y1="217" x2="198.8" y2="155.9" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="198.8" y1="155.9" x2="148.8" y2="122" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="198.8" y1="155.9" x2="178.9" y2="98.9" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="282" y1="239.5" x2="295" y2="171.9" stroke="currentColor" stroke-width="36" stroke-linecap="round"/><line x1="295" y1="171.9" x2="251.2" y2="125.7" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="251.2" y1="125.7" x2="211.7" y2="79.9" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="251.2" y1="125.7" x2="246.8" y2="65.4" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="295" y1="171.9" x2="309.7" y2="110" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="309.7" y1="110" x2="283.4" y2="55.6" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="309.7" y1="110" x2="321" y2="50.6" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="340" y1="282" x2="398" y2="239.5" stroke="currentColor" stroke-width="48" stroke-linecap="round"/><line x1="398" y1="239.5" x2="385" y2="171.9" stroke="currentColor" stroke-width="36" stroke-linecap="round"/><line x1="385" y1="171.9" x2="370.3" y2="110" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="370.3" y1="110" x2="359" y2="50.6" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="370.3" y1="110" x2="396.6" y2="55.6" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="385" y1="171.9" x2="428.8" y2="125.7" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="428.8" y1="125.7" x2="433.2" y2="65.4" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="428.8" y1="125.7" x2="468.3" y2="79.9" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="398" y1="239.5" x2="463" y2="217" stroke="currentColor" stroke-width="36" stroke-linecap="round"/><line x1="463" y1="217" x2="481.2" y2="155.9" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="481.2" y1="155.9" x2="501.1" y2="98.9" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="481.2" y1="155.9" x2="531.2" y2="122" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="463" y1="217" x2="524.1" y2="198.8" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="524.1" y1="198.8" x2="558" y2="148.8" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="524.1" y1="198.8" x2="581.1" y2="178.9" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="340" y1="340" x2="390.2" y2="369" stroke="currentColor" stroke-width="64" stroke-linecap="round"/><line x1="390.2" y1="369" x2="456" y2="340" stroke="currentColor" stroke-width="48" stroke-linecap="round"/><line x1="456" y1="340" x2="508.1" y2="295" stroke="currentColor" stroke-width="36" stroke-linecap="round"/><line x1="508.1" y1="295" x2="554.3" y2="251.2" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="554.3" y1="251.2" x2="600.1" y2="211.7" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="554.3" y1="251.2" x2="614.6" y2="246.8" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="508.1" y1="295" x2="570" y2="309.7" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="570" y1="309.7" x2="624.4" y2="283.4" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="570" y1="309.7" x2="629.4" y2="321" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="456" y1="340" x2="508.1" y2="385" stroke="currentColor" stroke-width="36" stroke-linecap="round"/><line x1="508.1" y1="385" x2="570" y2="370.3" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="570" y1="370.3" x2="629.4" y2="359" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="570" y1="370.3" x2="624.4" y2="396.6" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="508.1" y1="385" x2="554.3" y2="428.8" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="554.3" y1="428.8" x2="614.6" y2="433.2" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="554.3" y1="428.8" x2="600.1" y2="468.3" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="390.2" y1="369" x2="398" y2="440.5" stroke="currentColor" stroke-width="48" stroke-linecap="round"/><line x1="398" y1="440.5" x2="463" y2="463" stroke="currentColor" stroke-width="36" stroke-linecap="round"/><line x1="463" y1="463" x2="524.1" y2="481.2" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="524.1" y1="481.2" x2="581.1" y2="501.1" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="524.1" y1="481.2" x2="558" y2="531.2" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="463" y1="463" x2="481.2" y2="524.1" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="481.2" y1="524.1" x2="531.2" y2="558" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="481.2" y1="524.1" x2="501.1" y2="581.1" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="398" y1="440.5" x2="385" y2="508.1" stroke="currentColor" stroke-width="36" stroke-linecap="round"/><line x1="385" y1="508.1" x2="428.8" y2="554.3" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="428.8" y1="554.3" x2="468.3" y2="600.1" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="428.8" y1="554.3" x2="433.2" y2="614.6" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="385" y1="508.1" x2="370.3" y2="570" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="370.3" y1="570" x2="396.6" y2="624.4" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="370.3" y1="570" x2="359" y2="629.4" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="340" y1="340" x2="289.8" y2="369" stroke="currentColor" stroke-width="64" stroke-linecap="round"/><line x1="289.8" y1="369" x2="282" y2="440.5" stroke="currentColor" stroke-width="48" stroke-linecap="round"/><line x1="282" y1="440.5" x2="295" y2="508.1" stroke="currentColor" stroke-width="36" stroke-linecap="round"/><line x1="295" y1="508.1" x2="309.7" y2="570" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="309.7" y1="570" x2="321" y2="629.4" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="309.7" y1="570" x2="283.4" y2="624.4" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="295" y1="508.1" x2="251.2" y2="554.3" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="251.2" y1="554.3" x2="246.8" y2="614.6" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="251.2" y1="554.3" x2="211.7" y2="600.1" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="282" y1="440.5" x2="217" y2="463" stroke="currentColor" stroke-width="36" stroke-linecap="round"/><line x1="217" y1="463" x2="198.8" y2="524.1" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="198.8" y1="524.1" x2="178.9" y2="581.1" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="198.8" y1="524.1" x2="148.8" y2="558" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="217" y1="463" x2="155.9" y2="481.2" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="155.9" y1="481.2" x2="122" y2="531.2" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="155.9" y1="481.2" x2="98.9" y2="501.1" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="289.8" y1="369" x2="224" y2="340" stroke="currentColor" stroke-width="48" stroke-linecap="round"/><line x1="224" y1="340" x2="171.9" y2="385" stroke="currentColor" stroke-width="36" stroke-linecap="round"/><line x1="171.9" y1="385" x2="125.7" y2="428.8" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="125.7" y1="428.8" x2="79.9" y2="468.3" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="125.7" y1="428.8" x2="65.4" y2="433.2" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="171.9" y1="385" x2="110" y2="370.3" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="110" y1="370.3" x2="55.6" y2="396.6" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="110" y1="370.3" x2="50.6" y2="359" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="224" y1="340" x2="171.9" y2="295" stroke="currentColor" stroke-width="36" stroke-linecap="round"/><line x1="171.9" y1="295" x2="110" y2="309.7" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="110" y1="309.7" x2="50.6" y2="321" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="110" y1="309.7" x2="55.6" y2="283.4" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="171.9" y1="295" x2="125.7" y2="251.2" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="125.7" y1="251.2" x2="65.4" y2="246.8" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="125.7" y1="251.2" x2="79.9" y2="211.7" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/></g><circle cx="340" cy="340" r="290" fill="none" stroke="currentColor" stroke-width="12"/></svg>`
 644  
 645  const themeJS = `<script>(function(){var t=localStorage.getItem("smesh-theme");if(!t)t=matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light";if(t==="dark")document.documentElement.classList.add("dark")})()</script>`
 646  
 647  const sunIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>`
 648  const moonIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg>`
 649  
 650  const toggleJS = `<script>document.getElementById("theme-toggle").onclick=function(){var d=document.documentElement.classList.toggle("dark");localStorage.setItem("smesh-theme",d?"dark":"light")}</script>`
 651  
 652  func page(title, body string) []byte {
 653  	var b bytes.Buffer
 654  	b.WriteString(`<!doctype html><html><head><meta charset="utf-8">`)
 655  	b.WriteString(`<meta name="viewport" content="width=device-width">`)
 656  	b.WriteString(`<title>` + esc(title) + `</title>`)
 657  	b.WriteString(`<style>` + css + `</style>`)
 658  	b.WriteString(themeJS)
 659  	b.WriteString(`</head><body>`)
 660  	b.WriteString(`<button class="theme-btn" id="theme-toggle"><span class="when-light">` + moonIcon + `</span><span class="when-dark">` + sunIcon + `</span></button>`)
 661  	b.WriteString(body)
 662  	b.WriteString(toggleJS)
 663  	b.WriteString(`</body></html>`)
 664  	return b.Bytes()
 665  }
 666  
 667  // ── repo discovery ──────────────────────────────────────────────
 668  
 669  func findRepos() []string {
 670  	entries, err := os.ReadDir(repoRoot)
 671  	if err != nil {
 672  		return nil
 673  	}
 674  	var repos []string
 675  	for _, e := range entries {
 676  		if !e.IsDir() {
 677  			continue
 678  		}
 679  		if _, err := os.Stat(path.Join(repoRoot, e.Name(), "HEAD")); err == nil {
 680  			repos = append(repos, e.Name())
 681  		}
 682  	}
 683  	return repos
 684  }
 685  
 686  func cleanName(s string) string {
 687  	return bytes.TrimSuffix(s, ".git")
 688  }
 689  
 690  // resolveRepo maps a URL name to the actual repo directory name.
 691  // Accepts both "smesh" and "smesh.git".
 692  func resolveRepo(name string) (string, string) {
 693  	// try exact match first
 694  	rp := path.Join(repoRoot, name)
 695  	if _, err := os.Stat(path.Join(rp, "HEAD")); err == nil {
 696  		return name, rp
 697  	}
 698  	// try with .git suffix
 699  	gitName := name + ".git"
 700  	rp = path.Join(repoRoot, gitName)
 701  	if _, err := os.Stat(path.Join(rp, "HEAD")); err == nil {
 702  		return gitName, rp
 703  	}
 704  	return "", ""
 705  }
 706  
 707  func repoDesc(repoDir string) string {
 708  	data, err := os.ReadFile(path.Join(repoDir, "description"))
 709  	if err != nil {
 710  		return ""
 711  	}
 712  	s := bytes.TrimSpace(string(data))
 713  	if bytes.HasPrefix(s, "Unnamed repository") {
 714  		return ""
 715  	}
 716  	return s
 717  }
 718  
 719  // ── ref resolution ──────────────────────────────────────────────
 720  
 721  // defaultRef finds the actual default branch for a bare repo.
 722  // HEAD may point to a ref that doesn't exist (e.g. refs/heads/master
 723  // when the only branch is main).
 724  func defaultRef(repoDir string) string {
 725  	// read HEAD to get the symbolic ref
 726  	data, err := os.ReadFile(path.Join(repoDir, "HEAD"))
 727  	if err != nil {
 728  		return "HEAD"
 729  	}
 730  	s := bytes.TrimSpace(string(data))
 731  	if bytes.HasPrefix(s, "ref: ") {
 732  		ref := s[5:]
 733  		// check if this ref exists as a loose ref file
 734  		if _, err := os.Stat(path.Join(repoDir, ref)); err == nil {
 735  			return ref
 736  		}
 737  		// check packed-refs
 738  		packed, err := os.ReadFile(path.Join(repoDir, "packed-refs"))
 739  		if err == nil && bytes.Contains(string(packed), ref) {
 740  			return ref
 741  		}
 742  	}
 743  
 744  	// HEAD ref is broken — find first available branch
 745  	refsDir := path.Join(repoDir, "refs", "heads")
 746  	entries, err := os.ReadDir(refsDir)
 747  	if err == nil {
 748  		for _, e := range entries {
 749  			if !e.IsDir() {
 750  				return "refs/heads/" + e.Name()
 751  			}
 752  		}
 753  	}
 754  
 755  	// try packed-refs as last resort
 756  	packed, err := os.ReadFile(path.Join(repoDir, "packed-refs"))
 757  	if err == nil {
 758  		for _, line := range bytes.Split(string(packed), "\n") {
 759  			line = bytes.TrimSpace(line)
 760  			if line == "" || line[0] == '#' || line[0] == '^' {
 761  				continue
 762  			}
 763  			fields := bytes.Fields(line)
 764  			if len(fields) >= 2 && bytes.HasPrefix(fields[1], "refs/heads/") {
 765  				return fields[1]
 766  			}
 767  		}
 768  	}
 769  
 770  	return "HEAD"
 771  }
 772  
 773  // ── tree parsing ────────────────────────────────────────────────
 774  
 775  type entry struct {
 776  	name string
 777  	typ  string // "tree" or "blob"
 778  }
 779  
 780  func lsTree(repoDir, ref, treePath string) ([]entry, error) {
 781  	args := []string{"ls-tree", ref}
 782  	if treePath != "" {
 783  		args = append(args, treePath+"/")
 784  	}
 785  	out, err := gitCmd(repoDir, args...)
 786  	if err != nil {
 787  		return nil, err
 788  	}
 789  
 790  	prefix := ""
 791  	if treePath != "" {
 792  		prefix = treePath + "/"
 793  	}
 794  
 795  	var entries []entry
 796  	for _, line := range bytes.Split(string(out), "\n") {
 797  		line = bytes.TrimSpace(line)
 798  		if line == "" {
 799  			continue
 800  		}
 801  		// format: <mode> <type> <hash>\t<name>
 802  		tab := bytes.IndexByte(line, '\t')
 803  		if tab < 0 {
 804  			continue
 805  		}
 806  		name := line[tab+1:]
 807  		fields := bytes.Fields(line[:tab])
 808  		if len(fields) < 2 {
 809  			continue
 810  		}
 811  		// strip directory prefix
 812  		if prefix != "" && bytes.HasPrefix(name, prefix) {
 813  			name = name[len(prefix):]
 814  		}
 815  		entries = append(entries, entry{name: name, typ: fields[1]})
 816  	}
 817  	return entries, nil
 818  }
 819  
 820  func mimeType(name string) string {
 821  	ext := name
 822  	if i := bytes.LastIndexByte(name, '.'); i >= 0 {
 823  		ext = name[i:]
 824  	}
 825  	switch bytes.ToLower(ext) {
 826  	case ".html", ".htm":
 827  		return "text/html; charset=utf-8"
 828  	case ".css":
 829  		return "text/css; charset=utf-8"
 830  	case ".js":
 831  		return "text/javascript; charset=utf-8"
 832  	case ".json":
 833  		return "application/json"
 834  	case ".png":
 835  		return "image/png"
 836  	case ".jpg", ".jpeg":
 837  		return "image/jpeg"
 838  	case ".gif":
 839  		return "image/gif"
 840  	case ".svg":
 841  		return "image/svg+xml"
 842  	case ".ico":
 843  		return "image/x-icon"
 844  	case ".woff2":
 845  		return "font/woff2"
 846  	case ".wasm":
 847  		return "application/wasm"
 848  	case ".pdf":
 849  		return "application/pdf"
 850  	case ".xml":
 851  		return "application/xml"
 852  	case ".txt", ".md", ".go", ".rs", ".py", ".sh", ".yml", ".yaml", ".toml",
 853  		".c", ".h", ".cpp", ".java", ".rb", ".pl", ".lua", ".sql", ".diff",
 854  		".patch", ".conf", ".cfg", ".ini", ".log", ".csv", ".tsx", ".ts",
 855  		".jsx", ".vue", ".svelte", ".mx":
 856  		return "text/plain; charset=utf-8"
 857  	}
 858  	return "application/octet-stream"
 859  }
 860  
 861  func isGitProto(sub string) bool {
 862  	return sub == "info" || sub == "git-upload-pack" || sub == "git-receive-pack" ||
 863  		sub == "HEAD" || sub == "objects"
 864  }
 865  
 866  // ── handlers ────────────────────────────────────────────────────
 867  
 868  func handler(w http.ResponseWriter, r *http.Request) {
 869  	p := bytes.Trim(r.URL.Path, "/")
 870  
 871  	if p == "" {
 872  		serveIndex(w)
 873  		return
 874  	}
 875  
 876  	parts := bytes.SplitN(p, "/", 3)
 877  	urlName := parts[0]
 878  
 879  	// git smart HTTP protocol
 880  	if len(parts) > 1 && isGitProto(parts[1]) {
 881  		serveGit(w, r)
 882  		return
 883  	}
 884  
 885  	repo, rp := resolveRepo(urlName)
 886  
 887  	if repo == "" {
 888  		http.NotFound(w, r)
 889  		return
 890  	}
 891  
 892  	// go-get meta tag support
 893  	if r.URL.Query().Get("go-get") == "1" {
 894  		serveGoGet(w, urlName, repo)
 895  		return
 896  	}
 897  
 898  	// use the clean URL name for links
 899  	linkName := cleanName(repo)
 900  
 901  	if len(parts) == 1 {
 902  		serveRepo(w, linkName, repo, rp)
 903  		return
 904  	}
 905  
 906  	sub := ""
 907  	if len(parts) > 2 {
 908  		sub = parts[2]
 909  	}
 910  
 911  	switch parts[1] {
 912  	case "tree":
 913  		serveTree(w, linkName, repo, rp, sub)
 914  	case "blob":
 915  		serveBlob(w, linkName, repo, rp, sub)
 916  	case "raw":
 917  		serveRaw(w, linkName, rp, sub)
 918  	default:
 919  		http.NotFound(w, r)
 920  	}
 921  }
 922  
 923  func serveGoGet(w http.ResponseWriter, urlName, repo string) {
 924  	mod := cleanName(urlName)
 925  	w.Header().Set("Content-Type", "text/html")
 926  	fmt.Fprintf(w, `<html><head><meta name="go-import" content="%s/%s git https://%s/%s"></head><body>go get</body></html>`,
 927  		esc(host), esc(mod), esc(host), esc(repo))
 928  }
 929  
 930  func serveIndex(w http.ResponseWriter) {
 931  	repos := findRepos()
 932  	var b bytes.Buffer
 933  	b.WriteString(`<div class="hdr"><a href="/" style="color:var(--accent)">` + logo + `</a><h1>` + esc(host) + `</h1></div><ul class="ls">`)
 934  	for _, r := range repos {
 935  		name := cleanName(r)
 936  		desc := repoDesc(path.Join(repoRoot, r))
 937  		b.WriteString(`<li><a href="/` + esc(name) + `">` + esc(name) + `</a>`)
 938  		if desc != "" {
 939  			b.WriteString(`<span class="desc">` + esc(desc) + `</span>`)
 940  		}
 941  		b.WriteString(`</li>`)
 942  	}
 943  	b.WriteString(`</ul>`)
 944  	w.Header().Set("Content-Type", "text/html; charset=utf-8")
 945  	w.Write(page("repos", b.String()))
 946  }
 947  
 948  func serveRepo(w http.ResponseWriter, linkName, repo, repoDir string) {
 949  	ref := defaultRef(repoDir)
 950  	var b bytes.Buffer
 951  
 952  	b.WriteString(`<nav><a href="/">repos</a></nav>`)
 953  	b.WriteString(`<h1>` + esc(linkName) + `</h1>`)
 954  
 955  	// description
 956  	if desc := repoDesc(repoDir); desc != "" {
 957  		b.WriteString(`<p class="info">` + esc(desc) + `</p>`)
 958  	}
 959  
 960  	// clone urls
 961  	b.WriteString(`<p class="info">git clone https://` + esc(host) + `/` + esc(repo) + `</p>`)
 962  	b.WriteString(`<p class="info">git clone ssh://git@` + esc(host) + `:2222/~/` + esc(repo) + `</p>`)
 963  
 964  	// readme
 965  	for _, readme := range []string{"README.md", "README", "README.txt", "readme.md"} {
 966  		data, err := gitCmd(repoDir, "show", ref+":"+readme)
 967  		if err == nil && len(data) > 0 {
 968  			b.WriteString(`<div class="readme-box">`)
 969  			b.WriteString(`<input type="checkbox" id="readme-toggle">`)
 970  			b.WriteString(`<div class="readme-body">`)
 971  			if bytes.HasSuffix(readme, ".md") {
 972  				b.WriteString(`<div class="md">` + renderMD(string(data), linkName) + `</div>`)
 973  			} else {
 974  				b.WriteString(`<h2>` + esc(readme) + `</h2>`)
 975  				b.WriteString(`<pre>` + esc(string(data)) + `</pre>`)
 976  			}
 977  			b.WriteString(`</div>`)
 978  			b.WriteString(`<label for="readme-toggle"><span class="show">show more</span><span class="hide">show less</span></label>`)
 979  			b.WriteString(`</div>`)
 980  			break
 981  		}
 982  	}
 983  
 984  	// file listing
 985  	entries, err := lsTree(repoDir, ref, "")
 986  	if err != nil {
 987  		b.WriteString(`<p class="info">empty repository — push to get started</p>`)
 988  	} else if len(entries) > 0 {
 989  		b.WriteString(`<h2>files</h2>`)
 990  		writeEntriesList(&b, linkName, entries, "")
 991  	}
 992  
 993  	w.Header().Set("Content-Type", "text/html; charset=utf-8")
 994  	w.Write(page(linkName, b.String()))
 995  }
 996  
 997  func writeEntries(b *bytes.Buffer, linkName, repoDir, ref, treePath string) {
 998  	entries, err := lsTree(repoDir, ref, treePath)
 999  	if err != nil {
1000  		b.WriteString(`<p class="info">` + esc(err.Error()) + `</p>`)
1001  		return
1002  	}
1003  	writeEntriesList(b, linkName, entries, treePath)
1004  }
1005  
1006  func writeEntriesList(b *bytes.Buffer, linkName string, entries []entry, treePath string) {
1007  	b.WriteString(`<ul class="ls">`)
1008  	for _, e := range entries {
1009  		if e.typ != "tree" {
1010  			continue
1011  		}
1012  		fp := e.name
1013  		if treePath != "" {
1014  			fp = treePath + "/" + e.name
1015  		}
1016  		b.WriteString(`<li class="d"><a href="/` + esc(linkName) + `/tree/` + esc(fp) + `">` + esc(e.name) + `/</a></li>`)
1017  	}
1018  	for _, e := range entries {
1019  		if e.typ == "tree" {
1020  			continue
1021  		}
1022  		fp := e.name
1023  		if treePath != "" {
1024  			fp = treePath + "/" + e.name
1025  		}
1026  		b.WriteString(`<li class="f"><a href="/` + esc(linkName) + `/blob/` + esc(fp) + `">` + esc(e.name) + `</a></li>`)
1027  	}
1028  	b.WriteString(`</ul>`)
1029  }
1030  
1031  func breadcrumb(linkName, treePath string) string {
1032  	var b bytes.Buffer
1033  	b.WriteString(`<nav><a href="/">repos</a> / <a href="/` + esc(linkName) + `">` + esc(linkName) + `</a>`)
1034  	if treePath != "" {
1035  		parts := bytes.Split(treePath, "/")
1036  		for i, p := range parts {
1037  			prefix := bytes.Join(parts[:i+1], "/")
1038  			if i < len(parts)-1 {
1039  				b.WriteString(` / <a href="/` + esc(linkName) + `/tree/` + esc(prefix) + `">` + esc(p) + `</a>`)
1040  			} else {
1041  				b.WriteString(` / ` + esc(p))
1042  			}
1043  		}
1044  	}
1045  	b.WriteString(`</nav>`)
1046  	return b.String()
1047  }
1048  
1049  func serveTree(w http.ResponseWriter, linkName, repo, repoDir, treePath string) {
1050  	ref := defaultRef(repoDir)
1051  	var b bytes.Buffer
1052  	b.WriteString(breadcrumb(linkName, treePath))
1053  	b.WriteString(`<h1>` + esc(treePath) + `</h1>`)
1054  	writeEntries(&b, linkName, repoDir, ref, treePath)
1055  
1056  	w.Header().Set("Content-Type", "text/html; charset=utf-8")
1057  	w.Write(page(linkName+" / "+treePath, b.String()))
1058  }
1059  
1060  func serveBlob(w http.ResponseWriter, linkName, repo, repoDir, blobPath string) {
1061  	ref := defaultRef(repoDir)
1062  	data, err := gitCmd(repoDir, "show", ref+":"+blobPath)
1063  	if err != nil {
1064  		http.NotFound(w, nil)
1065  		return
1066  	}
1067  
1068  	var b bytes.Buffer
1069  	b.WriteString(breadcrumb(linkName, blobPath))
1070  
1071  	fname := blobPath
1072  	if idx := bytes.LastIndexByte(blobPath, '/'); idx >= 0 {
1073  		fname = blobPath[idx+1:]
1074  	}
1075  	b.WriteString(`<h1>` + esc(fname) + ` <a href="/` + esc(linkName) + `/raw/` + esc(blobPath) + `" style="font-size:12px;font-weight:normal">raw</a></h1>`)
1076  
1077  	if bytes.HasSuffix(blobPath, ".md") {
1078  		b.WriteString(`<div class="md">` + renderMD(string(data), linkName) + `</div>`)
1079  	} else {
1080  		lines := bytes.Split(string(data), "\n")
1081  		b.WriteString(`<pre>`)
1082  		for i, line := range lines {
1083  			ln := fmt.Sprintf("%4d  ", i+1)
1084  			b.WriteString(`<span class="ln">` + ln + `</span>` + esc(line) + "\n")
1085  		}
1086  		b.WriteString(`</pre>`)
1087  	}
1088  
1089  	w.Header().Set("Content-Type", "text/html; charset=utf-8")
1090  	w.Write(page(linkName+" / "+blobPath, b.String()))
1091  }
1092  
1093  func serveRaw(w http.ResponseWriter, linkName, repoDir, filePath string) {
1094  	if filePath == "" {
1095  		http.NotFound(w, nil)
1096  		return
1097  	}
1098  	ref := defaultRef(repoDir)
1099  	data, err := gitCmd(repoDir, "show", ref+":"+filePath)
1100  	if err != nil {
1101  		http.NotFound(w, nil)
1102  		return
1103  	}
1104  	w.Header().Set("Content-Type", mimeType(filePath))
1105  	w.Header().Set("Cache-Control", "max-age=300")
1106  	w.Write(data)
1107  }
1108  
1109  func serveGit(w http.ResponseWriter, r *http.Request) {
1110  	env := []string{
1111  		"PATH=/usr/bin:/bin",
1112  		"GIT_PROJECT_ROOT=" + repoRoot,
1113  		"GIT_HTTP_EXPORT_ALL=1",
1114  		"REQUEST_METHOD=" + r.Method,
1115  		"QUERY_STRING=" + r.URL.RawQuery,
1116  		"PATH_INFO=" + r.URL.Path,
1117  		"SERVER_PROTOCOL=HTTP/1.1",
1118  	}
1119  	if ct := r.Header.Get("Content-Type"); ct != "" {
1120  		env = append(env, "CONTENT_TYPE="+ct)
1121  	}
1122  	if cl := r.Header.Get("Content-Length"); cl != "" {
1123  		env = append(env, "CONTENT_LENGTH="+cl)
1124  	}
1125  	if proto := r.Header.Get("Git-Protocol"); proto != "" {
1126  		env = append(env, "GIT_PROTOCOL="+proto)
1127  	}
1128  	ra := r.RemoteAddr
1129  	if i := bytes.LastIndexByte(ra, ':'); i >= 0 {
1130  		ra = ra[:i]
1131  	}
1132  	env = append(env, "REMOTE_ADDR="+ra)
1133  
1134  	var stdin []byte
1135  	if r.Method == "POST" && r.Body != nil {
1136  		var body bytes.Buffer
1137  		tmp := []byte{:8192}
1138  		for {
1139  			n, err := r.Body.Read(tmp)
1140  			if n > 0 {
1141  				body.Write(tmp[:n])
1142  			}
1143  			if err != nil {
1144  				break
1145  			}
1146  		}
1147  		if body.Len() > 0 {
1148  			stdin = body.Bytes()
1149  		}
1150  	}
1151  
1152  	out, err := runIO(env, stdin, gitBackend)
1153  	if err != nil && len(out) == 0 {
1154  		http.Error(w, "git backend error", 500)
1155  		return
1156  	}
1157  
1158  	// parse CGI response: headers \n\n body
1159  	sep := bytes.Index(out, []byte("\r\n\r\n"))
1160  	skip := 4
1161  	if sep < 0 {
1162  		sep = bytes.Index(out, []byte("\n\n"))
1163  		skip = 2
1164  	}
1165  	if sep < 0 {
1166  		http.Error(w, "bad cgi response", 500)
1167  		return
1168  	}
1169  
1170  	code := 200
1171  	for _, line := range bytes.Split(string(out[:sep]), "\n") {
1172  		line = bytes.TrimRight(line, "\r")
1173  		idx := bytes.IndexByte(line, ':')
1174  		if idx < 0 {
1175  			continue
1176  		}
1177  		key := bytes.TrimSpace(line[:idx])
1178  		val := bytes.TrimSpace(line[idx+1:])
1179  		if bytes.EqualFold(key, "Status") {
1180  			if len(val) >= 3 {
1181  				code = 0
1182  				for j := 0; j < 3; j++ {
1183  					if val[j] >= '0' && val[j] <= '9' {
1184  						code = code*10 + int(val[j]-'0')
1185  					}
1186  				}
1187  			}
1188  		} else {
1189  			w.Header().Set(key, val)
1190  		}
1191  	}
1192  	w.WriteHeader(code)
1193  	w.Write(out[sep+skip:])
1194  }
1195  
1196  // ── main ────────────────────────────────────────────────────────
1197  
1198  func main() {
1199  	for i := 1; i < len(os.Args); i++ {
1200  		switch os.Args[i] {
1201  		case "-repos":
1202  			i++
1203  			if i < len(os.Args) {
1204  				repoRoot = os.Args[i]
1205  			}
1206  		case "-listen":
1207  			i++
1208  			if i < len(os.Args) {
1209  				addr = os.Args[i]
1210  			}
1211  		case "-git":
1212  			i++
1213  			if i < len(os.Args) {
1214  				gitBin = os.Args[i]
1215  			}
1216  		case "-host":
1217  			i++
1218  			if i < len(os.Args) {
1219  				host = os.Args[i]
1220  			}
1221  		case "-git-backend":
1222  			i++
1223  			if i < len(os.Args) {
1224  				gitBackend = os.Args[i]
1225  			}
1226  		}
1227  	}
1228  
1229  	// auto-detect git-http-backend path
1230  	if _, err := os.Stat(gitBackend); err != nil {
1231  		for _, p := range []string{"/usr/lib/git-core/git-http-backend", "/usr/libexec/git-core/git-http-backend"} {
1232  			if _, err := os.Stat(p); err == nil {
1233  				gitBackend = p
1234  				break
1235  			}
1236  		}
1237  	}
1238  
1239  	fmt.Printf("gitweb %s repos=%s\n", addr, repoRoot)
1240  	// Parse addr ourselves to bypass moxie SplitHostPort codegen bug.
1241  	tcpAddr := &net.TCPAddr{Port: 3000}
1242  	if i := bytes.LastIndexByte(addr, ':'); i >= 0 {
1243  		h := addr[:i]
1244  		p := addr[i+1:]
1245  		if h != "" {
1246  			tcpAddr.IP = net.ParseIP(h)
1247  		}
1248  		port := 0
1249  		for _, c := range p {
1250  			port = port*10 + int(c-'0')
1251  		}
1252  		tcpAddr.Port = port
1253  	}
1254  	ln, err := net.ListenTCP("tcp", tcpAddr)
1255  	if err != nil {
1256  		fmt.Fprintf(os.Stderr, "%v\n", err)
1257  		os.Exit(1)
1258  	}
1259  	http.HandleFunc("/", handler)
1260  	if err := http.Serve(ln, nil); err != nil {
1261  		fmt.Fprintf(os.Stderr, "%v\n", err)
1262  		os.Exit(1)
1263  	}
1264  }
1265