1 // Package gitweb creates a static website from a directory of git repos. 2 package main 3 4 import ( 5 "errors" 6 "flag" 7 "fmt" 8 "io" 9 "io/fs" 10 "os" 11 "os/user" 12 "path" 13 "path/filepath" 14 "sort" 15 "time" 16 17 "vimagination.zapto.org/parser" 18 ) 19 20 var force bool 21 22 func main() { 23 u, err := user.Current() 24 if err != nil { 25 fmt.Fprintf(os.Stderr, "error getting current user: %s\n", err) 26 os.Exit(1) 27 } 28 29 flag.BoolVar(&force, "f", false, "force rebuild all files") 30 31 configFile := flag.String("c", filepath.Join(u.HomeDir, ".gitweb"), "config file location") 32 gitDir := flag.String("r", "", "git repo to build") 33 noIndex := flag.Bool("n", false, "no main index") 34 35 flag.Parse() 36 37 if err := readConfig(*configFile); err != nil { 38 fmt.Fprintf(os.Stderr, "error reading config: %s\n", err) 39 os.Exit(2) 40 } 41 42 if *gitDir != "" { 43 if err := buildRepo(*gitDir); err != nil { 44 fmt.Fprintf(os.Stderr, "error building repo: %s\n", err) 45 os.Exit(3) 46 } 47 } 48 49 if !*noIndex { 50 if err := buildIndex(); err != nil { 51 fmt.Fprintf(os.Stderr, "error building index: %s\n", err) 52 os.Exit(4) 53 } 54 } 55 } 56 57 func getFileLastCommit(r *Repo, path []string) (*Commit, error) { 58 cid, err := r.GetLatestCommitID() 59 if err != nil { 60 return nil, fmt.Errorf("error reading last commit id: %w", err) 61 } 62 63 last, err := r.GetCommit(cid) 64 if err != nil { 65 return nil, fmt.Errorf("error reading commit: %w", err) 66 } 67 68 objID := last.Tree 69 70 for _, p := range path { 71 t, err := r.GetTree(objID) 72 if err != nil { 73 return nil, fmt.Errorf("error reading tree: %w", err) 74 } 75 76 nID, ok := t[p] 77 if !ok { 78 return nil, errors.New("invalid file") 79 } 80 81 objID = nID 82 } 83 84 for cid != "" { 85 c, err := r.GetCommit(cid) 86 if err != nil { 87 return nil, fmt.Errorf("error reading commit: %w", err) 88 } 89 90 tID := c.Tree 91 92 for _, p := range path { 93 t, err := r.GetTree(tID) 94 if err != nil { 95 return nil, fmt.Errorf("error reading tree: %w", err) 96 } 97 98 nID, ok := t[p] 99 if !ok { 100 return last, nil 101 } 102 103 tID = nID 104 } 105 106 if tID != objID { 107 return last, nil 108 } 109 110 cid = c.Parent 111 last = c 112 } 113 114 return last, nil 115 } 116 117 type files []string 118 119 func (f files) Len() int { 120 return len(f) 121 } 122 123 func (f files) Less(i, j int) bool { 124 a := f[i] 125 b := f[j] 126 127 if a[len(a)-1] == '/' { 128 if b[len(b)-1] == '/' { 129 return a < b 130 } 131 132 return true 133 } else if b[len(b)-1] == '/' { 134 return false 135 } 136 137 return a < b 138 } 139 140 func (f files) Swap(i, j int) { 141 f[i], f[j] = f[j], f[i] 142 } 143 144 func sortedFiles(t Tree) files { 145 files := make(files, 0, len(t)) 146 147 for f := range t { 148 files = append(files, f) 149 } 150 151 sort.Sort(files) 152 153 return files 154 } 155 156 type Dir struct { 157 ID string 158 Path []string 159 Dirs map[string]*Dir 160 Files map[string]*File 161 } 162 163 type File struct { 164 Repo, Name, Path, Link, Ext string 165 Commit *Commit 166 Size int64 167 } 168 169 type Discard struct { 170 io.Writer 171 } 172 173 func (Discard) Close() error { 174 return nil 175 } 176 177 var discard = Discard{Writer: io.Discard} 178 179 func parseTree(repo string, r *Repo, tree Tree, p []string) (*Dir, error) { 180 basepath := filepath.Join(append(append(make([]string, len(p)+3), config.OutputDir, repo, "files"), p...)...) 181 182 if err := os.MkdirAll(basepath, 0o755); err != nil { 183 return nil, fmt.Errorf("error creating directories: %w", err) 184 } 185 186 files, err := os.ReadDir(basepath) 187 if err != nil { 188 return nil, fmt.Errorf("error reading file directory: %w", err) 189 } 190 191 fileMap := make(map[string]struct{}, len(files)) 192 193 for _, file := range files { 194 fileMap[file.Name()] = struct{}{} 195 } 196 197 dir := &Dir{ 198 Dirs: make(map[string]*Dir), 199 Files: make(map[string]*File), 200 Path: append(make([]string, 0, len(p)), p...), 201 } 202 203 for _, f := range sortedFiles(tree) { 204 if f[len(f)-1] == '/' { 205 nt, err := r.GetTree(tree[f]) 206 if err != nil { 207 return nil, fmt.Errorf("error reading tree: %w", err) 208 } 209 210 d, err := parseTree(repo, r, nt, append(p, f)) 211 if err != nil { 212 return nil, fmt.Errorf("error parsing dir: %w", err) 213 } 214 215 d.ID = tree[f] 216 dir.Dirs[f[:len(f)-1]] = d 217 218 delete(fileMap, f[:len(f)-1]) 219 } else { 220 fpath := append(p, f) 221 222 c, err := getFileLastCommit(r, fpath) 223 if err != nil { 224 return nil, fmt.Errorf("error reading files last commit: %w", err) 225 } 226 227 name := f 228 file := &File{ 229 Repo: repo, 230 Name: name, 231 Path: path.Join(fpath...), 232 Ext: filepath.Ext(name), 233 Commit: c, 234 } 235 236 if f[0] == '/' { 237 name = f[1:] 238 239 b, err := r.GetBlob(tree[f]) 240 if err != nil { 241 return nil, fmt.Errorf("error getting symlink data: %w", err) 242 } 243 244 d, err := io.ReadAll(b) 245 if err != nil { 246 b.Close() 247 return nil, fmt.Errorf("error reading symlink data: %w", err) 248 } 249 250 b.Close() 251 252 file.Link = string(d) 253 } else { 254 var o io.WriteCloser 255 256 output := true 257 outpath := filepath.Join(basepath, name) 258 259 b, err := r.GetBlob(tree[f]) 260 if err != nil { 261 return nil, fmt.Errorf("error getting file data: %w", err) 262 } 263 264 if _, ok := fileMap[name]; !force && ok { 265 fi, err := os.Stat(outpath) 266 if err != nil { 267 return nil, fmt.Errorf("error while stat'ing file: %w", err) 268 } 269 270 if fi.ModTime().Equal(c.Time) { 271 output = false 272 o = discard 273 } 274 } 275 276 var printer parser.TokenFunc 277 278 if output { 279 o, err = os.Create(outpath) 280 if err != nil { 281 return nil, fmt.Errorf("error creating data file: %w", err) 282 } 283 284 if p, ok := config.prettyMap[file.Ext]; ok { 285 printer = p 286 } 287 } 288 289 if file.Size, err = prettify(file, o, b, printer); err != nil { 290 o.Close() 291 292 return nil, fmt.Errorf("error writing file data: %w", err) 293 } 294 295 if err := o.Close(); err != nil { 296 return nil, fmt.Errorf("error closing file: %w", err) 297 } 298 299 if output { 300 if err := os.Chtimes(outpath, c.Time, c.Time); err != nil { 301 return nil, fmt.Errorf("error setting file time: %w", err) 302 } 303 } 304 305 delete(fileMap, name) 306 } 307 308 dir.Files[name] = file 309 } 310 } 311 312 for f := range fileMap { 313 if err := os.Remove(filepath.Join(basepath, f)); err != nil { 314 return nil, fmt.Errorf("error removing file: %w", err) 315 } 316 } 317 318 return dir, nil 319 } 320 321 type RepoInfo struct { 322 Name, Desc string 323 Root *Dir 324 } 325 326 func buildRepo(repo string) error { 327 r := OpenRepo(filepath.Join(config.ReposDir, repo, config.GitDir)) 328 329 cid, err := r.GetLatestCommitID() 330 if err != nil { 331 return fmt.Errorf("error reading last commit id: %w", err) 332 } 333 334 latest, err := r.GetCommit(cid) 335 if err != nil { 336 return fmt.Errorf("error reading commit: %w", err) 337 } 338 339 indexPath := filepath.Join(config.OutputDir, repo, "index.html") 340 341 if !force { 342 fi, err := os.Stat(indexPath) 343 if !os.IsNotExist(err) { 344 if err != nil { 345 return fmt.Errorf("error stat'ing repo index file: %w", err) 346 } else if fi.ModTime().Equal(latest.Time) { 347 return nil 348 } 349 } 350 } 351 352 tree, err := r.GetTree(latest.Tree) 353 if err != nil { 354 return fmt.Errorf("error reading tree: %w", err) 355 } 356 357 d, err := parseTree(repo, r, tree, []string{}) 358 if err != nil { 359 return err 360 } 361 362 index, err := os.Create(indexPath) 363 if err != nil { 364 return fmt.Errorf("error creating repo index: %w", err) 365 } 366 367 if err := config.repoTemplate.Execute(index, RepoInfo{ 368 Name: repo, 369 Desc: r.GetDescription(), 370 Root: d, 371 }); err != nil { 372 index.Close() 373 374 return fmt.Errorf("error processing repo template: %w", err) 375 } 376 377 if err = index.Close(); err != nil { 378 return fmt.Errorf("error closing index: %w", err) 379 } 380 381 if err := os.Chtimes(indexPath, latest.Time, latest.Time); err != nil { 382 return fmt.Errorf("error setting repo index file time: %w", err) 383 } 384 385 return nil 386 } 387 388 type RepoData struct { 389 Name, Desc, LastCommit string 390 LastCommitTime time.Time 391 Pin int 392 } 393 394 func buildIndex() error { 395 dir, err := os.ReadDir(config.ReposDir) 396 if err != nil { 397 return fmt.Errorf("error reading repos dir: %w", err) 398 } 399 400 repos := make([]RepoData, 0, len(dir)) 401 402 var latest time.Time 403 404 for _, r := range dir { 405 if r.Type()&fs.ModeDir != 0 { 406 var c *Commit 407 408 name := r.Name() 409 rp := OpenRepo(filepath.Join(config.ReposDir, name, config.GitDir)) 410 411 cid, err := rp.GetLatestCommitID() 412 if err == nil { 413 c, err = rp.GetCommit(cid) 414 } 415 416 if err == nil { 417 pinPos := -1 418 419 for n, m := range config.Pinned { 420 if m == name { 421 pinPos = n 422 423 break 424 } 425 } 426 427 if c.Time.After(latest) { 428 latest = c.Time 429 } 430 431 repos = append(repos, RepoData{ 432 Name: name, 433 Desc: rp.GetDescription(), 434 LastCommit: c.Msg, 435 LastCommitTime: c.Time, 436 Pin: pinPos, 437 }) 438 } 439 } 440 } 441 442 if latest.IsZero() { 443 return errors.New("no repos") 444 } 445 446 indexPath := filepath.Join(config.OutputDir, config.IndexFile) 447 448 if !force { 449 fi, err := os.Stat(indexPath) 450 if !os.IsNotExist(err) { 451 if err != nil { 452 return fmt.Errorf("error stat'ing main index file: %w", err) 453 } else if fi.ModTime().Equal(latest) { 454 return nil 455 } 456 } 457 } 458 459 sort.Slice(repos, func(i, j int) bool { 460 ir := repos[i] 461 jr := repos[j] 462 463 if ir.Pin == -1 && jr.Pin == -1 { 464 return ir.LastCommitTime.After(jr.LastCommitTime) 465 } else if ir.Pin == -1 { 466 return false 467 } else if jr.Pin == -1 { 468 return true 469 } 470 471 return ir.Pin < jr.Pin 472 }) 473 474 f, err := os.Create(indexPath) 475 if err != nil { 476 return fmt.Errorf("error creating index: %w", err) 477 } 478 479 defer f.Close() 480 481 if err := config.indexTemplate.Execute(f, repos); err != nil { 482 return fmt.Errorf("error processing template: %w", err) 483 } 484 485 if err := os.Chtimes(indexPath, latest, latest); err != nil { 486 return fmt.Errorf("error setting repo index file time: %w", err) 487 } 488 489 return nil 490 } 491