feat(git): path normalisation and per-path TREESAME comparison
- commit
- 1cce7e1edae9f9dec2abf406109951f1c0b3b348
- author
- brice · · 2026-10-10 20:58 UTC
- parent
- ad03ed7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | + | } | |||