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// Assuming the file pointed to by fp was deleted in the newTree, check
166// which parent directories are now also deleted implicitly (because they
167// are empty now) and return them as a list.
168func (r *Repo) checkParents(fp string) ([]string, error) {
169	var deadParents []string
170	for {
171		fp = filepath.Dir(fp)
172		if fp == "." {
173			break
174		}
175
176		_, err := r.curTree.Tree(fp)
177		if err == object.ErrDirectoryNotFound {
178			deadParents = append(deadParents, fp)
179		} else if err != nil {
180			return []string{}, err
181		} else {
182			break // fp is still alive
183		}
184	}
185
186	return deadParents, nil
187}
188
189func (r *Repo) walkDiff(fn WalkFunc) error {
190	changes, err := object.DiffTree(r.prevTree, r.curTree)
191	if err != nil {
192		return err
193	}
194	patch, err := changes.Patch()
195	if err != nil {
196		return err
197	}
198
199	rebuildDirs := make(map[string]bool)
200	for _, filePatch := range patch.FilePatches() {
201		from, to := filePatch.Files()
202		if to == nil { // file was removed
203			err = fn(from.Path(), nil)
204			if err != nil {
205				return err
206			}
207
208			deadParents, err := r.checkParents(from.Path())
209			if err != nil {
210				return err
211			}
212
213			for _, p := range deadParents {
214				err = fn(p, nil)
215				if err != nil {
216					return err
217				}
218			}
219
220			lastDead := from.Path()
221			if len(deadParents) > 0 {
222				lastDead = deadParents[len(deadParents)-1]
223			}
224			rebuildDirs[filepath.Dir(lastDead)] = true
225
226			continue
227		} else if from == nil { // created a new file
228			rebuildDirs[filepath.Dir(to.Path())] = true
229		}
230
231		fp := to.Path()
232		if isReadme(fp) {
233			rebuildDirs[filepath.Dir(fp)] = true
234		}
235
236		page, err := r.page(to.Hash(), to.Mode(), fp)
237		if err != nil {
238			return err
239		}
240		err = fn(fp, page)
241		if err != nil {
242			return err
243		}
244	}
245
246	// If the tree changed, assume that we need to rebuild the index.
247	// For example, because the commits are listed there. This a somewhat
248	// depp-specific assumption which is hackily backed into the gitweb library.
249	if r.prevTree.Hash != r.curTree.Hash {
250		rebuildDirs["."] = true
251	}
252
253	for dir, _ := range rebuildDirs {
254		var page *RepoPage
255		if dir == "." {
256			page = r.indexPage()
257		} else {
258			entry, err := r.curTree.FindEntry(dir)
259			if err != nil {
260				return err
261			}
262
263			page, err = r.page(entry.Hash, entry.Mode, dir)
264			if err != nil {
265				return err
266			}
267		}
268
269		err = fn(dir, page)
270		if err != nil {
271			return err
272		}
273	}
274
275	return nil
276}
277
278func (r *Repo) Walk(fn WalkFunc) error {
279	if r.prevTree == nil {
280		return r.walkTree(fn)
281	} else {
282		return r.walkDiff(fn)
283	}
284}
285
286func (r *Repo) page(hash plumbing.Hash, mode filemode.FileMode, fp string) (*RepoPage, error) {
287	page := &RepoPage{
288		Repo:        r,
289		tree:        nil,
290		CurrentFile: RepoFile{mode, filepath.ToSlash(fp)},
291	}
292
293	var err error
294	if page.CurrentFile.IsDir() {
295		page.tree, err = r.git.TreeObject(hash)
296		if err != nil {
297			return nil, err
298		}
299	}
300
301	return page, nil
302}
303
304func (r *Repo) Description() (string, error) {
305	fp := filepath.Join(r.Path, descFn)
306
307	desc, err := os.ReadFile(fp)
308	if errors.Is(err, os.ErrNotExist) {
309		return "", nil
310	} else if err != nil {
311		return "", err
312	}
313
314	descText := string(desc)
315	return strings.TrimSpace(descText), nil
316}