detached 1cce7e1

feat(git): path normalisation and per-path TREESAME comparison

commit
1cce7e1edae9f9dec2abf406109951f1c0b3b348
author
brice · · 2026-10-10 20:58 UTC
parent
ad03ed7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +337 -0
A treediff.go +226 -0
@@ -0,0 +1,226 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "bytes"
7+ "errors"
8+ "fmt"
9+ "strings"
10+)
11+
12+// This file holds the tree comparison used by history walks with a path: a
13+// cheap TREESAME test for one path, done by comparing the entry ids
14+// component by component. Only the trees on the way to the path are read,
15+// and only while the ids differ, so a commit that did not touch the path
16+// usually costs the two root trees at most, and nothing when the root trees
17+// are identical. Lookups are memoised per (tree, depth), which makes a walk
18+// along a chain read each tree once instead of twice.
19+
20+// ErrInvalidPath reports a path that cannot designate anything in a
21+// repository: an absolute path or one that leaves the root with "..".
22+var ErrInvalidPath = errors.New("git: invalid path")
23+
24+// emptyTreeID is the id of the empty tree, which Git knows without storing it.
25+var emptyTreeID = ObjectID{
26+ 0x4b, 0x82, 0x5d, 0xc6, 0x42, 0xcb, 0x6e, 0xb9, 0xa0, 0x60,
27+ 0xe5, 0x4b, 0xf8, 0xd6, 0x92, 0x88, 0xfb, 0xee, 0x49, 0x04,
28+}
29+
30+// pathSpec is a literal path, normalised as Git does for a pathspec.
31+type pathSpec struct {
32+ comps []string // no empty, "." or ".." component; none means the root
33+ // bloom reports that the path is exactly what Git inserted in the
34+ // filters: spelled without a "." or ".." component, an empty one, or
35+ // more than one trailing slash. Otherwise the trees are compared.
36+ bloom bool
37+}
38+
39+// parsePathSpec normalises p: empty and "." components are dropped, ".."
40+// removes the previous one, and one trailing slash is ignored. Absolute
41+// paths and paths that climb above the root are ErrInvalidPath.
42+func parsePathSpec(p string) (pathSpec, error) {
43+ if strings.IndexByte(p, 0) >= 0 {
44+ return pathSpec{}, fmt.Errorf("%w: NUL byte", ErrInvalidPath)
45+ }
46+ if strings.HasPrefix(p, "/") {
47+ return pathSpec{}, fmt.Errorf("%w: %q is absolute", ErrInvalidPath, p)
48+ }
49+ s := pathSpec{bloom: true}
50+ t := strings.TrimSuffix(p, "/")
51+ if t == "" {
52+ s.bloom = false // the root: every commit with a changed tree
53+ return s, nil
54+ }
55+ for _, c := range strings.Split(t, "/") {
56+ switch c {
57+ case "", ".":
58+ s.bloom = false
59+ case "..":
60+ s.bloom = false
61+ if len(s.comps) == 0 {
62+ return pathSpec{}, fmt.Errorf("%w: %q is outside the repository", ErrInvalidPath, p)
63+ }
64+ s.comps = s.comps[:len(s.comps)-1]
65+ default:
66+ s.comps = append(s.comps, c)
67+ }
68+ }
69+ if len(s.comps) == 0 {
70+ s.bloom = false
71+ }
72+ return s, nil
73+}
74+
75+// String returns the normalised path ("" for the root).
76+func (s pathSpec) String() string { return strings.Join(s.comps, "/") }
77+
78+// pathEntry is what a tree holds at a path.
79+type pathEntry struct {
80+ id ObjectID
81+ mode Mode
82+ found bool
83+}
84+
85+type lookupKey struct {
86+ tree ObjectID
87+ level uint16
88+}
89+
90+const maxLookupCache = 1 << 14
91+
92+// pathDiffer answers TREESAME questions for one path. It is not safe for
93+// concurrent use.
94+type pathDiffer struct {
95+ r *Repo
96+ comps []string
97+ names [][]byte
98+ cache map[lookupKey]pathEntry
99+ buf []byte
100+}
101+
102+func newPathDiffer(r *Repo, comps []string) *pathDiffer {
103+ d := &pathDiffer{r: r, comps: comps, cache: make(map[lookupKey]pathEntry, 256)}
104+ d.names = make([][]byte, len(comps))
105+ for i, c := range comps {
106+ d.names[i] = []byte(c)
107+ }
108+ return d
109+}
110+
111+// lookup finds the entry named comps[level] in tree.
112+func (d *pathDiffer) lookup(tree ObjectID, level int) (pathEntry, error) {
113+ if tree == emptyTreeID {
114+ return pathEntry{}, nil
115+ }
116+ k := lookupKey{tree, uint16(level)}
117+ if e, ok := d.cache[k]; ok {
118+ return e, nil
119+ }
120+ typ, data, err := d.r.ReadObject(tree, d.buf)
121+ if err != nil {
122+ return pathEntry{}, err
123+ }
124+ d.buf = data
125+ if typ != TypeTree {
126+ return pathEntry{}, wrongType(tree, typ, TypeTree)
127+ }
128+ e, err := findEntry(data, d.names[level])
129+ if err != nil {
130+ return pathEntry{}, wrapObj(tree, err)
131+ }
132+ if len(d.cache) >= maxLookupCache {
133+ clear(d.cache)
134+ }
135+ d.cache[k] = e
136+ return e, nil
137+}
138+
139+// findEntry scans a tree for name. It validates the entries it walks over
140+// like ParseTree and does not allocate.
141+func findEntry(data, name []byte) (pathEntry, error) {
142+ for len(data) > 0 {
143+ sp := bytes.IndexByte(data, ' ')
144+ if sp <= 0 || sp > 7 {
145+ return pathEntry{}, corrupt("tree: bad mode")
146+ }
147+ var mode uint32
148+ for _, c := range data[:sp] {
149+ if c < '0' || c > '7' {
150+ return pathEntry{}, corrupt("tree: bad mode")
151+ }
152+ mode = mode<<3 | uint32(c-'0')
153+ }
154+ data = data[sp+1:]
155+ nul := bytes.IndexByte(data, 0)
156+ if nul <= 0 || len(data)-nul-1 < 20 {
157+ return pathEntry{}, corrupt("tree: truncated entry")
158+ }
159+ if bytes.Equal(data[:nul], name) {
160+ e := pathEntry{mode: Mode(mode), found: true}
161+ copy(e.id[:], data[nul+1:nul+21])
162+ return e, nil
163+ }
164+ data = data[nul+21:]
165+ }
166+ return pathEntry{}, nil
167+}
168+
169+// resolve returns the entry at the path below the root tree. The root path
170+// (no component) resolves to the tree itself.
171+func (d *pathDiffer) resolve(root ObjectID) (pathEntry, error) {
172+ cur := pathEntry{id: root, mode: ModeTree, found: true}
173+ for i := range d.comps {
174+ if !cur.mode.IsTree() {
175+ return pathEntry{}, nil
176+ }
177+ e, err := d.lookup(cur.id, i)
178+ if err != nil || !e.found {
179+ return pathEntry{}, err
180+ }
181+ cur = e
182+ }
183+ return cur, nil
184+}
185+
186+// child returns what the directory entry e holds at comps[level]: absent when
187+// e is absent or not a directory.
188+func (d *pathDiffer) child(e pathEntry, level int) (pathEntry, error) {
189+ if !e.found || !e.mode.IsTree() {
190+ return pathEntry{}, nil
191+ }
192+ return d.lookup(e.id, level)
193+}
194+
195+// same reports whether the path is identical in the root trees a and b: both
196+// absent, or the same id and mode. The comparison stops at the first level
197+// where the entries are equal.
198+func (d *pathDiffer) same(a, b ObjectID) (bool, error) {
199+ ea := pathEntry{id: a, mode: ModeTree, found: true}
200+ eb := pathEntry{id: b, mode: ModeTree, found: true}
201+ for i := 0; ; i++ {
202+ if ea == eb {
203+ return true, nil
204+ }
205+ if i == len(d.comps) {
206+ return false, nil
207+ }
208+ var err error
209+ if ea, err = d.child(ea, i); err != nil {
210+ return false, err
211+ }
212+ if eb, err = d.child(eb, i); err != nil {
213+ return false, err
214+ }
215+ }
216+}
217+
218+// exists reports whether the path exists in the root tree, that is whether a
219+// root commit changes it.
220+func (d *pathDiffer) exists(root ObjectID) (bool, error) {
221+ if len(d.comps) == 0 {
222+ return root != emptyTreeID, nil
223+ }
224+ e, err := d.resolve(root)
225+ return e.found, err
226+}
@@ -0,0 +1,226 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "bytes"
7+ "errors"
8+ "fmt"
9+ "strings"
10+)
11+
12+// This file holds the tree comparison used by history walks with a path: a
13+// cheap TREESAME test for one path, done by comparing the entry ids
14+// component by component. Only the trees on the way to the path are read,
15+// and only while the ids differ, so a commit that did not touch the path
16+// usually costs the two root trees at most, and nothing when the root trees
17+// are identical. Lookups are memoised per (tree, depth), which makes a walk
18+// along a chain read each tree once instead of twice.
19+
20+// ErrInvalidPath reports a path that cannot designate anything in a
21+// repository: an absolute path or one that leaves the root with "..".
22+var ErrInvalidPath = errors.New("git: invalid path")
23+
24+// emptyTreeID is the id of the empty tree, which Git knows without storing it.
25+var emptyTreeID = ObjectID{
26+ 0x4b, 0x82, 0x5d, 0xc6, 0x42, 0xcb, 0x6e, 0xb9, 0xa0, 0x60,
27+ 0xe5, 0x4b, 0xf8, 0xd6, 0x92, 0x88, 0xfb, 0xee, 0x49, 0x04,
28+}
29+
30+// pathSpec is a literal path, normalised as Git does for a pathspec.
31+type pathSpec struct {
32+ comps []string // no empty, "." or ".." component; none means the root
33+ // bloom reports that the path is exactly what Git inserted in the
34+ // filters: spelled without a "." or ".." component, an empty one, or
35+ // more than one trailing slash. Otherwise the trees are compared.
36+ bloom bool
37+}
38+
39+// parsePathSpec normalises p: empty and "." components are dropped, ".."
40+// removes the previous one, and one trailing slash is ignored. Absolute
41+// paths and paths that climb above the root are ErrInvalidPath.
42+func parsePathSpec(p string) (pathSpec, error) {
43+ if strings.IndexByte(p, 0) >= 0 {
44+ return pathSpec{}, fmt.Errorf("%w: NUL byte", ErrInvalidPath)
45+ }
46+ if strings.HasPrefix(p, "/") {
47+ return pathSpec{}, fmt.Errorf("%w: %q is absolute", ErrInvalidPath, p)
48+ }
49+ s := pathSpec{bloom: true}
50+ t := strings.TrimSuffix(p, "/")
51+ if t == "" {
52+ s.bloom = false // the root: every commit with a changed tree
53+ return s, nil
54+ }
55+ for _, c := range strings.Split(t, "/") {
56+ switch c {
57+ case "", ".":
58+ s.bloom = false
59+ case "..":
60+ s.bloom = false
61+ if len(s.comps) == 0 {
62+ return pathSpec{}, fmt.Errorf("%w: %q is outside the repository", ErrInvalidPath, p)
63+ }
64+ s.comps = s.comps[:len(s.comps)-1]
65+ default:
66+ s.comps = append(s.comps, c)
67+ }
68+ }
69+ if len(s.comps) == 0 {
70+ s.bloom = false
71+ }
72+ return s, nil
73+}
74+
75+// String returns the normalised path ("" for the root).
76+func (s pathSpec) String() string { return strings.Join(s.comps, "/") }
77+
78+// pathEntry is what a tree holds at a path.
79+type pathEntry struct {
80+ id ObjectID
81+ mode Mode
82+ found bool
83+}
84+
85+type lookupKey struct {
86+ tree ObjectID
87+ level uint16
88+}
89+
90+const maxLookupCache = 1 << 14
91+
92+// pathDiffer answers TREESAME questions for one path. It is not safe for
93+// concurrent use.
94+type pathDiffer struct {
95+ r *Repo
96+ comps []string
97+ names [][]byte
98+ cache map[lookupKey]pathEntry
99+ buf []byte
100+}
101+
102+func newPathDiffer(r *Repo, comps []string) *pathDiffer {
103+ d := &pathDiffer{r: r, comps: comps, cache: make(map[lookupKey]pathEntry, 256)}
104+ d.names = make([][]byte, len(comps))
105+ for i, c := range comps {
106+ d.names[i] = []byte(c)
107+ }
108+ return d
109+}
110+
111+// lookup finds the entry named comps[level] in tree.
112+func (d *pathDiffer) lookup(tree ObjectID, level int) (pathEntry, error) {
113+ if tree == emptyTreeID {
114+ return pathEntry{}, nil
115+ }
116+ k := lookupKey{tree, uint16(level)}
117+ if e, ok := d.cache[k]; ok {
118+ return e, nil
119+ }
120+ typ, data, err := d.r.ReadObject(tree, d.buf)
121+ if err != nil {
122+ return pathEntry{}, err
123+ }
124+ d.buf = data
125+ if typ != TypeTree {
126+ return pathEntry{}, wrongType(tree, typ, TypeTree)
127+ }
128+ e, err := findEntry(data, d.names[level])
129+ if err != nil {
130+ return pathEntry{}, wrapObj(tree, err)
131+ }
132+ if len(d.cache) >= maxLookupCache {
133+ clear(d.cache)
134+ }
135+ d.cache[k] = e
136+ return e, nil
137+}
138+
139+// findEntry scans a tree for name. It validates the entries it walks over
140+// like ParseTree and does not allocate.
141+func findEntry(data, name []byte) (pathEntry, error) {
142+ for len(data) > 0 {
143+ sp := bytes.IndexByte(data, ' ')
144+ if sp <= 0 || sp > 7 {
145+ return pathEntry{}, corrupt("tree: bad mode")
146+ }
147+ var mode uint32
148+ for _, c := range data[:sp] {
149+ if c < '0' || c > '7' {
150+ return pathEntry{}, corrupt("tree: bad mode")
151+ }
152+ mode = mode<<3 | uint32(c-'0')
153+ }
154+ data = data[sp+1:]
155+ nul := bytes.IndexByte(data, 0)
156+ if nul <= 0 || len(data)-nul-1 < 20 {
157+ return pathEntry{}, corrupt("tree: truncated entry")
158+ }
159+ if bytes.Equal(data[:nul], name) {
160+ e := pathEntry{mode: Mode(mode), found: true}
161+ copy(e.id[:], data[nul+1:nul+21])
162+ return e, nil
163+ }
164+ data = data[nul+21:]
165+ }
166+ return pathEntry{}, nil
167+}
168+
169+// resolve returns the entry at the path below the root tree. The root path
170+// (no component) resolves to the tree itself.
171+func (d *pathDiffer) resolve(root ObjectID) (pathEntry, error) {
172+ cur := pathEntry{id: root, mode: ModeTree, found: true}
173+ for i := range d.comps {
174+ if !cur.mode.IsTree() {
175+ return pathEntry{}, nil
176+ }
177+ e, err := d.lookup(cur.id, i)
178+ if err != nil || !e.found {
179+ return pathEntry{}, err
180+ }
181+ cur = e
182+ }
183+ return cur, nil
184+}
185+
186+// child returns what the directory entry e holds at comps[level]: absent when
187+// e is absent or not a directory.
188+func (d *pathDiffer) child(e pathEntry, level int) (pathEntry, error) {
189+ if !e.found || !e.mode.IsTree() {
190+ return pathEntry{}, nil
191+ }
192+ return d.lookup(e.id, level)
193+}
194+
195+// same reports whether the path is identical in the root trees a and b: both
196+// absent, or the same id and mode. The comparison stops at the first level
197+// where the entries are equal.
198+func (d *pathDiffer) same(a, b ObjectID) (bool, error) {
199+ ea := pathEntry{id: a, mode: ModeTree, found: true}
200+ eb := pathEntry{id: b, mode: ModeTree, found: true}
201+ for i := 0; ; i++ {
202+ if ea == eb {
203+ return true, nil
204+ }
205+ if i == len(d.comps) {
206+ return false, nil
207+ }
208+ var err error
209+ if ea, err = d.child(ea, i); err != nil {
210+ return false, err
211+ }
212+ if eb, err = d.child(eb, i); err != nil {
213+ return false, err
214+ }
215+ }
216+}
217+
218+// exists reports whether the path exists in the root tree, that is whether a
219+// root commit changes it.
220+func (d *pathDiffer) exists(root ObjectID) (bool, error) {
221+ if len(d.comps) == 0 {
222+ return root != emptyTreeID, nil
223+ }
224+ e, err := d.resolve(root)
225+ return e.found, err
226+}
A treediff_test.go +111 -0
@@ -0,0 +1,111 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "errors"
7+ "testing"
8+)
9+
10+func TestParsePathSpec(t *testing.T) {
11+ cases := []struct {
12+ in string
13+ want string
14+ bloom bool
15+ bad bool
16+ }{
17+ {"a/b/c", "a/b/c", true, false},
18+ {"a/b/", "a/b", true, false},
19+ {"a", "a", true, false},
20+ {"a//b", "a/b", false, false},
21+ {"a/b//", "a/b", false, false},
22+ {"./a", "a", false, false},
23+ {"a/./b", "a/b", false, false},
24+ {"a/b/../c", "a/c", false, false},
25+ {".", "", false, false},
26+ {"./", "", false, false},
27+ {"", "", false, false},
28+ {"a/..", "", false, false},
29+ {"../a", "", false, true},
30+ {"/a", "", false, true},
31+ {"a\x00b", "", false, true},
32+ }
33+ for _, c := range cases {
34+ s, err := parsePathSpec(c.in)
35+ if c.bad {
36+ if !errors.Is(err, ErrInvalidPath) {
37+ t.Errorf("%q: err = %v, want ErrInvalidPath", c.in, err)
38+ }
39+ continue
40+ }
41+ if err != nil {
42+ t.Errorf("%q: %v", c.in, err)
43+ continue
44+ }
45+ if s.String() != c.want || s.bloom != c.bloom {
46+ t.Errorf("%q: got %q bloom=%v, want %q bloom=%v", c.in, s.String(), s.bloom, c.want, c.bloom)
47+ }
48+ }
49+}
50+
51+func TestEmptyTreeID(t *testing.T) {
52+ want := "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
53+ if got := emptyTreeID.String(); got != want {
54+ t.Fatalf("emptyTreeID = %s", got)
55+ }
56+}
57+
58+// TestPathDiffer checks same and exists against git rev-parse on trees that
59+// turn files into directories and back.
60+func TestPathDiffer(t *testing.T) {
61+ f := newFx(t)
62+ var trees []ObjectID
63+ step := func(setup func()) {
64+ setup()
65+ f.commit("c")
66+ trees = append(trees, mustID(t, f.git("rev-parse", "HEAD^{tree}")))
67+ }
68+ step(func() { f.write("a", []byte("file"), 0o644); f.write("d/x", []byte("1"), 0o644) })
69+ step(func() { f.write("a", []byte("file2"), 0o644) })
70+ step(func() { f.git("rm", "-q", "a"); f.write("a/b", []byte("1"), 0o644) })
71+ step(func() { f.write("a/b", []byte("2"), 0o644); f.write("d/y", []byte("1"), 0o644) })
72+ step(func() { f.write("a/b", []byte("2"), 0o755) })
73+ r := f.open()
74+ for _, p := range []string{"a", "a/b", "a/b/c", "d", "d/x", "d/y", "zz", ""} {
75+ spec, err := parsePathSpec(p)
76+ if err != nil {
77+ t.Fatal(err)
78+ }
79+ d := newPathDiffer(r, spec.comps)
80+ for i := range trees {
81+ for j := range trees {
82+ got, err := d.same(trees[i], trees[j])
83+ if err != nil {
84+ t.Fatal(err)
85+ }
86+ want := f.git("diff-tree", "-r", "--name-only", trees[i].String(), trees[j].String(), "--", orDot(p)) == ""
87+ if got != want {
88+ t.Errorf("same(%d,%d,%q) = %v, git says %v", i, j, p, got, want)
89+ }
90+ }
91+ ex, err := d.exists(trees[i])
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ want := f.git("ls-tree", "-r", "--name-only", trees[i].String(), "--", orDot(p)) != ""
96+ if p == "" {
97+ want = true
98+ }
99+ if ex != want {
100+ t.Errorf("exists(%d,%q) = %v, git says %v", i, p, ex, want)
101+ }
102+ }
103+ }
104+}
105+
106+func orDot(p string) string {
107+ if p == "" {
108+ return "."
109+ }
110+ return p
111+}
@@ -0,0 +1,111 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "errors"
7+ "testing"
8+)
9+
10+func TestParsePathSpec(t *testing.T) {
11+ cases := []struct {
12+ in string
13+ want string
14+ bloom bool
15+ bad bool
16+ }{
17+ {"a/b/c", "a/b/c", true, false},
18+ {"a/b/", "a/b", true, false},
19+ {"a", "a", true, false},
20+ {"a//b", "a/b", false, false},
21+ {"a/b//", "a/b", false, false},
22+ {"./a", "a", false, false},
23+ {"a/./b", "a/b", false, false},
24+ {"a/b/../c", "a/c", false, false},
25+ {".", "", false, false},
26+ {"./", "", false, false},
27+ {"", "", false, false},
28+ {"a/..", "", false, false},
29+ {"../a", "", false, true},
30+ {"/a", "", false, true},
31+ {"a\x00b", "", false, true},
32+ }
33+ for _, c := range cases {
34+ s, err := parsePathSpec(c.in)
35+ if c.bad {
36+ if !errors.Is(err, ErrInvalidPath) {
37+ t.Errorf("%q: err = %v, want ErrInvalidPath", c.in, err)
38+ }
39+ continue
40+ }
41+ if err != nil {
42+ t.Errorf("%q: %v", c.in, err)
43+ continue
44+ }
45+ if s.String() != c.want || s.bloom != c.bloom {
46+ t.Errorf("%q: got %q bloom=%v, want %q bloom=%v", c.in, s.String(), s.bloom, c.want, c.bloom)
47+ }
48+ }
49+}
50+
51+func TestEmptyTreeID(t *testing.T) {
52+ want := "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
53+ if got := emptyTreeID.String(); got != want {
54+ t.Fatalf("emptyTreeID = %s", got)
55+ }
56+}
57+
58+// TestPathDiffer checks same and exists against git rev-parse on trees that
59+// turn files into directories and back.
60+func TestPathDiffer(t *testing.T) {
61+ f := newFx(t)
62+ var trees []ObjectID
63+ step := func(setup func()) {
64+ setup()
65+ f.commit("c")
66+ trees = append(trees, mustID(t, f.git("rev-parse", "HEAD^{tree}")))
67+ }
68+ step(func() { f.write("a", []byte("file"), 0o644); f.write("d/x", []byte("1"), 0o644) })
69+ step(func() { f.write("a", []byte("file2"), 0o644) })
70+ step(func() { f.git("rm", "-q", "a"); f.write("a/b", []byte("1"), 0o644) })
71+ step(func() { f.write("a/b", []byte("2"), 0o644); f.write("d/y", []byte("1"), 0o644) })
72+ step(func() { f.write("a/b", []byte("2"), 0o755) })
73+ r := f.open()
74+ for _, p := range []string{"a", "a/b", "a/b/c", "d", "d/x", "d/y", "zz", ""} {
75+ spec, err := parsePathSpec(p)
76+ if err != nil {
77+ t.Fatal(err)
78+ }
79+ d := newPathDiffer(r, spec.comps)
80+ for i := range trees {
81+ for j := range trees {
82+ got, err := d.same(trees[i], trees[j])
83+ if err != nil {
84+ t.Fatal(err)
85+ }
86+ want := f.git("diff-tree", "-r", "--name-only", trees[i].String(), trees[j].String(), "--", orDot(p)) == ""
87+ if got != want {
88+ t.Errorf("same(%d,%d,%q) = %v, git says %v", i, j, p, got, want)
89+ }
90+ }
91+ ex, err := d.exists(trees[i])
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ want := f.git("ls-tree", "-r", "--name-only", trees[i].String(), "--", orDot(p)) != ""
96+ if p == "" {
97+ want = true
98+ }
99+ if ex != want {
100+ t.Errorf("exists(%d,%q) = %v, git says %v", i, p, ex, want)
101+ }
102+ }
103+ }
104+}
105+
106+func orDot(p string) string {
107+ if p == "" {
108+ return "."
109+ }
110+ return p
111+}

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