depp

No frills static page generator for Git repositories

git clone https://git.8pit.net/depp.git

  1package gitweb
  2
  3import (
  4	"errors"
  5	"io"
  6	"net/url"
  7	"os"
  8	"path/filepath"
  9	"strings"
 10
 11	"github.com/go-git/go-git/v5"
 12	"github.com/go-git/go-git/v5/plumbing"
 13	"github.com/go-git/go-git/v5/plumbing/cache"
 14	"github.com/go-git/go-git/v5/plumbing/filemode"
 15	"github.com/go-git/go-git/v5/plumbing/object"
 16	"github.com/go-git/go-git/v5/storage/filesystem"
 17
 18	"github.com/go-git/go-billy/v5/osfs"
 19)
 20
 21type Repo struct {
 22	curTree  *object.Tree
 23	prevTree *object.Tree // may be nil
 24
 25	git        *git.Repository
 26	maxCommits uint
 27
 28	Conf  Config
 29	Path  string
 30	Title string
 31	URL   string
 32}
 33
 34type WalkFunc func(string, *RepoPage) error
 35
 36const (
 37	// File name of the git description file.
 38	descFn = "description"
 39)
 40
 41func NewRepo(fp string, cloneURL *url.URL, commits uint) (*Repo, error) {
 42	absFp, err := filepath.Abs(fp)
 43	if err != nil {
 44		return nil, err
 45	}
 46
 47	r := &Repo{Path: absFp, Title: repoTitle(absFp), maxCommits: commits}
 48	if cloneURL != nil {
 49		r.URL = cloneURL.String()
 50	}
 51
 52	fs := osfs.New(absFp)
 53	if _, err := fs.Stat(git.GitDirName); err == nil {
 54		// If this is not a bare repository, we change into
 55		// the .git directory so that we can treat it as such.
 56		fs, err = fs.Chroot(git.GitDirName)
 57		if err != nil {
 58			return nil, err
 59		}
 60	}
 61
 62	s := filesystem.NewStorage(fs, cache.NewObjectLRUDefault())
 63	r.git, err = git.Open(s, fs)
 64	if err != nil {
 65		return nil, err
 66	}
 67
 68	// TODO: Make head a public member of the Repository struct.
 69	head, err := r.Tip()
 70	if err != nil {
 71		return nil, err
 72	}
 73	r.curTree, err = head.Tree()
 74	if err != nil {
 75		return nil, err
 76	}
 77
 78	r.Conf, err = loadConfig(r.git)
 79	if err != nil {
 80		return nil, err
 81	}
 82
 83	return r, nil
 84}
 85
 86func (r *Repo) ReadState(fp string) error {
 87	stateFile, err := os.Open(fp)
 88	if err != nil {
 89		return err
 90	}
 91
 92	h, err := readHashFile(stateFile)
 93	if err != nil {
 94		return err
 95	}
 96
 97	r.prevTree, err = r.git.TreeObject(h)
 98	if err != nil {
 99		return err
100	}
101
102	return nil
103}
104
105func (r *Repo) WriteState(fp string) error {
106	stateFile, err := os.Create(fp)
107	if err != nil {
108		return err
109	}
110
111	_, err = stateFile.WriteString(r.curTree.Hash.String())
112	if err != nil {
113		return err
114	}
115
116	return stateFile.Close()
117}
118
119func (r *Repo) Tip() (*object.Commit, error) {
120	head, err := r.git.Head()
121	if err != nil {
122		return nil, err
123	}
124
125	hash := head.Hash()
126	commit, err := r.git.CommitObject(hash)
127	if err != nil {
128		return nil, err
129	}
130
131	return commit, nil
132}
133
134func (r *Repo) indexPage() *RepoPage {
135	return &RepoPage{
136		Repo:        r,
137		tree:        r.curTree,
138		CurrentFile: RepoFile{mode: filemode.Dir, Path: ""},
139	}
140}
141
142func (r *Repo) walkTree(fn WalkFunc) error {
143	err := fn(".", r.indexPage())
144	if err != nil {
145		return err
146	}
147
148	walker := object.NewTreeWalker(r.curTree, true, nil)
149	defer walker.Close()
150	for {
151		fp, entry, err := walker.Next()
152		if err == io.EOF {
153			break
154		} else if err != nil {
155			return err
156		}
157
158		page, err := r.page(entry.Hash, entry.Mode, fp)
159		if err != nil {
160			return err
161		}
162		err = fn(fp, page)
163		if err != nil {
164			return err
165		}
166	}
167
168	return nil
169}
170
171// Returns a list of all parent directory of the given fp, which are not present
172// in the given tree. For example, because they have been removed in the tree.
173func (r *Repo) changedParents(tree *object.Tree, fp string) ([]string, error) {
174	var parents []string
175	for {
176		fp = filepath.Dir(fp)
177		if fp == "." {
178			break
179		}
180
181		_, err := tree.Tree(fp)
182		if err == object.ErrDirectoryNotFound {
183			parents = append(parents, fp)
184		} else if err != nil {
185			return []string{}, err
186		} else {
187			break // fp is unchanged
188		}
189	}
190
191	return parents, nil
192}
193
194func (r *Repo) walkDiff(fn WalkFunc) error {
195	changes, err := object.DiffTree(r.prevTree, r.curTree)
196	if err != nil {
197		return err
198	}
199	patch, err := changes.Patch()
200	if err != nil {
201		return err
202	}
203
204	rebuildDirs := make(map[string]bool)
205	for _, filePatch := range patch.FilePatches() {
206		from, to := filePatch.Files()
207		if to == nil { // file was removed
208			err = fn(from.Path(), nil)
209			if err != nil {
210				return err
211			}
212
213			// Assuming the file pointed to by fp was deleted in the
214			// newTree, check which parent directories are now also
215			// deleted implicitly (because they are empty now).
216			deadParents, err := r.changedParents(r.curTree, from.Path())
217			if err != nil {
218				return err
219			}
220
221			for _, p := range deadParents {
222				err = fn(p, nil)
223				if err != nil {
224					return err
225				}
226			}
227
228			lastDead := from.Path()
229			if len(deadParents) > 0 {
230				lastDead = deadParents[len(deadParents)-1]
231			}
232			rebuildDirs[filepath.Dir(lastDead)] = true
233
234			continue
235		} else if from == nil { // created a new file
236			dest := to.Path()
237
238			newParents, err := r.changedParents(r.prevTree, dest)
239			if err != nil {
240				return err
241			}
242			lastNew := dest
243			for _, np := range newParents {
244				lastNew = np
245				rebuildDirs[np] = true
246			}
247			// rebuild directory index page containing the last new entry.
248			rebuildDirs[filepath.Dir(lastNew)] = true
249		}
250
251		fp := to.Path()
252		if isReadme(fp) {
253			rebuildDirs[filepath.Dir(fp)] = true
254		}
255
256		page, err := r.page(to.Hash(), to.Mode(), fp)
257		if err != nil {
258			return err
259		}
260		err = fn(fp, page)
261		if err != nil {
262			return err
263		}
264	}
265
266	// If the tree changed, assume that we need to rebuild the index.
267	// For example, because the commits are listed there. This a somewhat
268	// depp-specific assumption which is hackily backed into the gitweb library.
269	if r.prevTree.Hash != r.curTree.Hash {
270		rebuildDirs["."] = true
271	}
272
273	for dir, _ := range rebuildDirs {
274		var page *RepoPage
275		if dir == "." {
276			page = r.indexPage()
277		} else {
278			entry, err := r.curTree.FindEntry(dir)
279			if err != nil {
280				return err
281			}
282
283			page, err = r.page(entry.Hash, entry.Mode, dir)
284			if err != nil {
285				return err
286			}
287		}
288
289		err = fn(dir, page)
290		if err != nil {
291			return err
292		}
293	}
294
295	return nil
296}
297
298func (r *Repo) Walk(fn WalkFunc) error {
299	if r.prevTree == nil {
300		return r.walkTree(fn)
301	} else {
302		return r.walkDiff(fn)
303	}
304}
305
306func (r *Repo) page(hash plumbing.Hash, mode filemode.FileMode, fp string) (*RepoPage, error) {
307	page := &RepoPage{
308		Repo:        r,
309		tree:        nil,
310		CurrentFile: RepoFile{mode, filepath.ToSlash(fp)},
311	}
312
313	var err error
314	if page.CurrentFile.IsDir() {
315		page.tree, err = r.git.TreeObject(hash)
316		if err != nil {
317			return nil, err
318		}
319	}
320
321	return page, nil
322}
323
324func (r *Repo) Description() (string, error) {
325	fp := filepath.Join(r.Path, descFn)
326
327	desc, err := os.ReadFile(fp)
328	if errors.Is(err, os.ErrNotExist) {
329		return "", nil
330	} else if err != nil {
331		return "", err
332	}
333
334	descText := string(desc)
335	return strings.TrimSpace(descText), nil
336}