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