main de06cbd

repo.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 2

↑↓ moveenter openesc close