depp

No frills static page generator for Git repositories

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

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