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