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 dest := to.Path()
229
230 // A path element of the new path may be an empty directory, in which
231 // case we won't see it in .FilePatches() and need to identify it here.
232 pathElems := strings.Split(dest, string(filepath.Separator))
233 for i := 1; i < len(pathElems); i++ {
234 elemPath := filepath.Join(pathElems[:i]...)
235 _, err := r.prevTree.FindEntry(elemPath)
236 if errors.Is(err, object.ErrEntryNotFound) {
237 rebuildDirs[elemPath] = true
238 }
239 }
240
241 rebuildDirs[filepath.Dir(dest)] = true
242 }
243
244 fp := to.Path()
245 if isReadme(fp) {
246 rebuildDirs[filepath.Dir(fp)] = true
247 }
248
249 page, err := r.page(to.Hash(), to.Mode(), fp)
250 if err != nil {
251 return err
252 }
253 err = fn(fp, page)
254 if err != nil {
255 return err
256 }
257 }
258
259 // If the tree changed, assume that we need to rebuild the index.
260 // For example, because the commits are listed there. This a somewhat
261 // depp-specific assumption which is hackily backed into the gitweb library.
262 if r.prevTree.Hash != r.curTree.Hash {
263 rebuildDirs["."] = true
264 }
265
266 for dir, _ := range rebuildDirs {
267 var page *RepoPage
268 if dir == "." {
269 page = r.indexPage()
270 } else {
271 entry, err := r.curTree.FindEntry(dir)
272 if err != nil {
273 return err
274 }
275
276 page, err = r.page(entry.Hash, entry.Mode, dir)
277 if err != nil {
278 return err
279 }
280 }
281
282 err = fn(dir, page)
283 if err != nil {
284 return err
285 }
286 }
287
288 return nil
289}
290
291func (r *Repo) Walk(fn WalkFunc) error {
292 if r.prevTree == nil {
293 return r.walkTree(fn)
294 } else {
295 return r.walkDiff(fn)
296 }
297}
298
299func (r *Repo) page(hash plumbing.Hash, mode filemode.FileMode, fp string) (*RepoPage, error) {
300 page := &RepoPage{
301 Repo: r,
302 tree: nil,
303 CurrentFile: RepoFile{mode, filepath.ToSlash(fp)},
304 }
305
306 var err error
307 if page.CurrentFile.IsDir() {
308 page.tree, err = r.git.TreeObject(hash)
309 if err != nil {
310 return nil, err
311 }
312 }
313
314 return page, nil
315}
316
317func (r *Repo) Description() (string, error) {
318 fp := filepath.Join(r.Path, descFn)
319
320 desc, err := os.ReadFile(fp)
321 if errors.Is(err, os.ErrNotExist) {
322 return "", nil
323 } else if err != nil {
324 return "", err
325 }
326
327 descText := string(desc)
328 return strings.TrimSpace(descText), nil
329}