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}