1// SPDX-License-Identifier: MIT
2
3package git
4
5import (
6 "errors"
7 "io"
8 "io/fs"
9 "math"
10 "os"
11 "path/filepath"
12 "strings"
13 "sync"
14 "sync/atomic"
15 "syscall"
16 "time"
17)
18
19// Default bounds. See WithMaxObjectSize and WithDeltaCacheSize.
20const (
21 defaultMaxObjectSize = 128 << 20
22 defaultDeltaCacheSize = 32 << 20
23 // maxDeltaDepth bounds the length of a delta chain. Git itself writes
24 // chains of at most 4095 and 50 by default.
25 maxDeltaDepth = 10000
26)
27
28type options struct {
29 maxObject int64
30 cacheBytes int64
31}
32
33// Option configures Open.
34type Option func(*options)
35
36// WithMaxObjectSize bounds the size of an object that is held in memory:
37// commits, trees, tags and blobs that are stored as deltas. Bigger ones fail
38// with ErrTooLarge. Undeltified blobs stream and are not bounded. The default
39// is 128 MiB. The bound is capped at the largest int of the platform
40// (2 GiB - 1 on 32-bit systems), since such an object must fit in a slice.
41func WithMaxObjectSize(n int64) Option {
42 return func(o *options) {
43 if n > 0 {
44 o.maxObject = min(n, math.MaxInt)
45 }
46 }
47}
48
49// WithDeltaCacheSize sets the budget in bytes of the cache of delta bases
50// (LRU). The default is 32 MiB. Zero or less disables the cache.
51func WithDeltaCacheSize(n int64) Option {
52 return func(o *options) { o.cacheBytes = n }
53}
54
55// Repo is an open Git repository. It is read-only and safe for concurrent
56// use by multiple goroutines.
57//
58// Loose refs and loose objects are always read live. The list of packs and
59// the content of packed-refs are loaded snapshots: a missing object triggers
60// one rescan of the pack directory, packed-refs is stat-checked on each ref
61// read, and Refresh reloads everything explicitly.
62type Repo struct {
63 dir string
64 opts options
65
66 snap atomic.Pointer[snapshot]
67 mu sync.Mutex // serializes pack list reloads and Close
68 closed bool
69 nextPack atomic.Uint32
70
71 cache *deltaCache
72
73 packed atomic.Pointer[packedRefs]
74 pmu sync.Mutex // serializes packed-refs reloads
75
76 graph atomic.Pointer[graphState] // see walk.go
77 gmu sync.Mutex // serializes commit-graph reloads
78
79 paths *repoPaths // the paths Refresh stats, built once
80
81 clock func() time.Time // tests only: time.Now when nil
82}
83
84// now is the time the racy-file rules measure from.
85func (r *Repo) now() time.Time {
86 if r.clock != nil {
87 return r.clock()
88 }
89 return time.Now()
90}
91
92// Open opens the repository whose Git directory is dir: a bare repository
93// or a ".git" directory. If dir contains a ".git" directory, that one is
94// used.
95//
96// Open returns an error wrapping ErrNotFound when dir is not a repository
97// and one wrapping ErrUnsupported (with the reason) when the repository uses
98// a feature the package does not read: an object format other than sha1, a
99// ref storage other than files, other extensions, a shallow file,
100// info/grafts, replace refs, alternates, a linked worktree, a partial clone,
101// or a multi-pack-index whose packs lack their .idx file. A multi-pack-index
102// is otherwise ignored: the packs are read through their own .idx files.
103func Open(dir string, opts ...Option) (*Repo, error) {
104 gitDir, err := findGitDir(dir)
105 if err != nil {
106 return nil, err
107 }
108 o := options{maxObject: defaultMaxObjectSize, cacheBytes: defaultDeltaCacheSize}
109 for _, fn := range opts {
110 fn(&o)
111 }
112 if err := checkConfig(filepath.Join(gitDir, "config")); err != nil {
113 return nil, err
114 }
115 paths := newRepoPaths(gitDir)
116 if err := paths.checkLayout(); err != nil {
117 return nil, err
118 }
119 r := &Repo{dir: gitDir, opts: o, cache: newDeltaCache(o.cacheBytes), paths: paths}
120 r.snap.Store(newSnapshot(nil))
121 if _, err := r.reloadPacks(true); err != nil {
122 _ = r.Close()
123 return nil, err
124 }
125 if _, err := r.loadPacked(true); err != nil {
126 _ = r.Close()
127 return nil, err
128 }
129 _ = r.refreshGraph(true) // a bad commit-graph is reported by Walk
130 return r, nil
131}
132
133// Dir returns the Git directory that was opened.
134func (r *Repo) Dir() string { return r.dir }
135
136// Close releases the repository. Readers still streaming a blob keep their
137// packs mapped until they are closed. Close is idempotent.
138func (r *Repo) Close() error {
139 r.mu.Lock()
140 defer r.mu.Unlock()
141 if r.closed {
142 return nil
143 }
144 r.closed = true
145 if s := r.snap.Swap(nil); s != nil {
146 s.release()
147 }
148 r.closeGraph()
149 return nil
150}
151
152// Refresh reloads the list of packs and packed-refs when they changed, and
153// checks again that the repository is still supported (it returns an error
154// wrapping ErrUnsupported when, for example, a replace ref was pushed). Packs
155// that disappeared stay mapped until the readers using them are done, so a
156// concurrent "git repack -ad" never breaks a read in flight.
157func (r *Repo) Refresh() error {
158 if err := r.paths.checkLayout(); err != nil {
159 return err
160 }
161 if _, err := r.reloadPacks(false); err != nil {
162 return err
163 }
164 if _, err := r.loadPacked(false); err != nil {
165 return err
166 }
167 return r.refreshGraph(false)
168}
169
170func findGitDir(dir string) (string, error) {
171 for range 2 {
172 if isGitDir(dir) {
173 return dir, nil
174 }
175 sub := filepath.Join(dir, ".git")
176 fi, err := os.Stat(sub)
177 switch {
178 case err != nil:
179 return "", notFound("%s is not a git repository", dir)
180 case fi.IsDir():
181 dir = sub
182 default:
183 return "", unsupported("%s is a gitfile (linked worktree or submodule)", sub)
184 }
185 }
186 return "", notFound("%s is not a git repository", dir)
187}
188
189func isGitDir(dir string) bool {
190 for _, n := range []string{"HEAD", "objects", "refs"} {
191 if _, err := os.Stat(filepath.Join(dir, n)); err != nil {
192 return false
193 }
194 }
195 return true
196}
197
198// isNotExist reports whether err means that a path, or a directory on the way
199// to it, is absent.
200func isNotExist(err error) bool {
201 return errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR)
202}
203
204func exists(path string) bool {
205 _, err := os.Lstat(path)
206 return err == nil
207}
208
209// repoPaths holds the paths that Refresh looks at, built once by Open, so
210// that an unchanged repository costs a few stat system calls and no
211// allocation.
212type repoPaths struct {
213 dir string
214
215 // unsupported are the files whose presence makes a repository
216 // unsupported, with the reason.
217 unsupported [4]layoutFile
218 replace statPath // refs/replace
219 packedRefs statPath
220 packDir statPath // objects/pack
221 graph graphPaths
222
223 mu sync.Mutex
224 replaceSig fileSig // of refs/replace when it was last listed and found empty
225}
226
227type layoutFile struct {
228 path statPath
229 why string
230}
231
232func newRepoPaths(dir string) *repoPaths {
233 p := &repoPaths{
234 dir: dir,
235 replace: newStatPath(dir + "/refs/replace"),
236 packedRefs: newStatPath(dir + "/packed-refs"),
237 packDir: newStatPath(dir + "/objects/pack"),
238 graph: newGraphPaths(dir + "/objects"),
239 }
240 for i, c := range [...]struct{ path, why string }{
241 {"shallow", "shallow repository"},
242 {"info/grafts", "grafts file"},
243 {"commondir", "linked worktree"},
244 {"objects/info/alternates", "alternate object stores"},
245 } {
246 p.unsupported[i] = layoutFile{newStatPath(dir + "/" + c.path), c.why}
247 }
248 return p
249}
250
251// checkLayout rejects the repository layouts that change what objects or
252// refs mean and that the package does not implement.
253func checkLayout(dir string) error { return newRepoPaths(dir).checkLayout() }
254
255func (p *repoPaths) checkLayout() error {
256 for i := range p.unsupported {
257 c := &p.unsupported[i]
258 if st, err := c.path.stat(true); err == nil && st.exists {
259 return unsupported("%s (%s)", c.why, strings.TrimPrefix(c.path.path(), p.dir+"/"))
260 }
261 }
262 return p.checkReplace()
263}
264
265// checkReplace refuses a repository with replace refs. The directory is only
266// listed when its stat data changed since it was last found empty (a replace
267// ref is a file in it, so adding one changes its modification time), with
268// Git's racy rule for a change that leaves the stat data as it was.
269func (p *repoPaths) checkReplace() error {
270 read := time.Now().UnixNano() // before the stat, see sigOf
271 st, err := p.replace.stat(false)
272 if err != nil {
273 return err
274 }
275 p.mu.Lock()
276 defer p.mu.Unlock()
277 if !st.exists || !st.isDir {
278 p.replaceSig = fileSig{}
279 return nil
280 }
281 sig := fileSig{exists: true, size: st.size, mtime: st.mtime, ino: st.ino, read: read}
282 if p.replaceSig.exists && p.replaceSig.same(sig) {
283 return nil
284 }
285 p.replaceSig = fileSig{}
286 f, err := os.Open(p.replace.path())
287 if err != nil {
288 if isNotExist(err) {
289 return nil
290 }
291 return err
292 }
293 names, rerr := f.Readdirnames(1)
294 _ = f.Close()
295 switch {
296 case rerr == nil && len(names) > 0:
297 return unsupported("replace refs (refs/replace)")
298 case rerr == nil, errors.Is(rerr, io.EOF), isNotExist(rerr):
299 p.replaceSig = sig
300 return nil
301 }
302 return rerr
303}