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}