detached 1a6c6c9

sync.go RawHistoryFolder

1package main
2
3import (
4	"bytes"
5	"crypto/sha256"
6	"encoding/hex"
7	"encoding/json"
8	"errors"
9	"fmt"
10	"io/fs"
11	"os"
12	"path/filepath"
13	"sort"
14	"strings"
15
16	"github.com/bnema/go-wayland-bindings/spec"
17)
18
19// candidate is an upstream XML file that may provide a package.
20type candidate struct {
21	source    string
22	path      string // slash-separated path inside the repository
23	pkg       string
24	stability Stability
25	major     int
26	data      []byte
27}
28
29// tree is the desired content of spec/: the manifest plus the XML files.
30type tree struct {
31	manifest *spec.Manifest
32	xml      map[string][]byte // file name under spec/xml
33}
34
35// rank orders candidates of one package: higher is better.
36func (c candidate) beats(o candidate) bool {
37	if c.stability != o.stability {
38		return c.stability > o.stability
39	}
40	return c.major > o.major
41}
42
43func (c candidate) tied(o candidate) bool {
44	return c.stability == o.stability && c.major == o.major
45}
46
47// collect resolves and checks out every source and returns the candidates
48// found, together with the resolved source records.
49func collect(srcs []upstream, pins map[string]string, f Fetcher) ([]spec.Source, []candidate, error) {
50	var (
51		sources []spec.Source
52		cands   []candidate
53	)
54	for _, src := range srcs {
55		ref, commit, err := f.Resolve(src, pins[src.ID])
56		if err != nil {
57			return nil, nil, fmt.Errorf("resolve %s: %w", src.ID, err)
58		}
59		found, err := checkoutAndScan(src, ref, commit, f)
60		if err != nil {
61			return nil, nil, err
62		}
63		if len(found) == 0 {
64			return nil, nil, fmt.Errorf("%s@%s: no protocol XML found", src.ID, ref)
65		}
66		sources = append(sources, spec.Source{ID: src.ID, URL: src.URL, Ref: ref, Commit: commit})
67		cands = append(cands, found...)
68	}
69	return sources, cands, nil
70}
71
72func checkoutAndScan(src upstream, ref, commit string, f Fetcher) ([]candidate, error) {
73	dir, err := os.MkdirTemp("", "wlbsync-"+src.ID+"-")
74	if err != nil {
75		return nil, err
76	}
77	defer os.RemoveAll(dir)
78	if err := f.Checkout(src, ref, commit, dir); err != nil {
79		return nil, fmt.Errorf("checkout %s@%s: %w", src.ID, ref, err)
80	}
81	var out []candidate
82	err = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
83		if err != nil {
84			return err
85		}
86		if d.IsDir() {
87			if d.Name() == ".git" {
88				return filepath.SkipDir
89			}
90			return nil
91		}
92		if !d.Type().IsRegular() {
93			return nil // symlinks and other special files are never vendored
94		}
95		rel, err := filepath.Rel(dir, p)
96		if err != nil {
97			return err
98		}
99		rel = filepath.ToSlash(rel)
100		st, ok := src.Match(rel)
101		if !ok {
102			return nil
103		}
104		data, err := os.ReadFile(p)
105		if err != nil {
106			return err
107		}
108		out = append(out, candidate{
109			source:    src.ID,
110			path:      rel,
111			pkg:       spec.PackageName(rel),
112			stability: st,
113			major:     fileMajor(rel),
114			data:      data,
115		})
116		return nil
117	})
118	if err != nil {
119		return nil, fmt.Errorf("scan %s: %w", src.ID, err)
120	}
121	return out, nil
122}
123
124// build applies the selection rule and derives the manifest.
125func build(sources []spec.Source, cands []candidate) (*tree, error) {
126	byPkg := map[string][]candidate{}
127	for _, c := range cands {
128		if !spec.ValidPackageName(c.pkg) {
129			return nil, fmt.Errorf("%s:%s maps to invalid package name %q", c.source, c.path, c.pkg)
130		}
131		byPkg[c.pkg] = append(byPkg[c.pkg], c)
132	}
133
134	t := &tree{manifest: &spec.Manifest{Sources: sources}, xml: map[string][]byte{}}
135	parsed := map[string]*spec.Protocol{}
136	pkgs := make([]string, 0, len(byPkg))
137	for p := range byPkg {
138		pkgs = append(pkgs, p)
139	}
140	sort.Strings(pkgs)
141
142	for _, pkg := range pkgs {
143		cs := byPkg[pkg]
144		sort.Slice(cs, func(i, j int) bool {
145			if cs[i].beats(cs[j]) != cs[j].beats(cs[i]) {
146				return cs[i].beats(cs[j])
147			}
148			return cs[i].source+":"+cs[i].path < cs[j].source+":"+cs[j].path
149		})
150		best := cs[0]
151		if len(cs) > 1 && best.tied(cs[1]) {
152			return nil, fmt.Errorf("package %q: %s:%s and %s:%s tie (same stability and major version)",
153				pkg, best.source, best.path, cs[1].source, cs[1].path)
154		}
155		p, err := spec.Parse(best.data)
156		if err != nil {
157			return nil, fmt.Errorf("%s:%s: %w", best.source, best.path, err)
158		}
159		if strings.TrimSpace(p.Copyright) == "" {
160			return nil, fmt.Errorf("%s:%s: missing <copyright>", best.source, best.path)
161		}
162		parsed[pkg] = p
163
164		sum := sha256.Sum256(best.data)
165		entry := spec.ProtocolEntry{
166			Package: pkg,
167			XML:     pkg + ".xml",
168			Source:  best.source,
169			Path:    best.path,
170			SHA256:  hex.EncodeToString(sum[:]),
171			License: licenseFor(pkg),
172			Deps:    []string{},
173		}
174		for _, it := range p.Interfaces {
175			entry.Interfaces = append(entry.Interfaces, spec.InterfaceEntry{Name: it.Name, Version: it.Version})
176		}
177		sort.Slice(entry.Interfaces, func(i, j int) bool { return entry.Interfaces[i].Name < entry.Interfaces[j].Name })
178		t.manifest.Protocols = append(t.manifest.Protocols, entry)
179		t.xml[entry.XML] = best.data
180	}
181
182	owners := map[string]string{}
183	for _, e := range t.manifest.Protocols {
184		for _, it := range e.Interfaces {
185			if prev, dup := owners[it.Name]; dup {
186				return nil, fmt.Errorf("interface %q is defined by both %q and %q", it.Name, prev, e.Package)
187			}
188			owners[it.Name] = e.Package
189		}
190	}
191	for i := range t.manifest.Protocols {
192		e := &t.manifest.Protocols[i]
193		deps := map[string]bool{}
194		for _, ref := range parsed[e.Package].ReferencedInterfaces() {
195			owner, ok := owners[ref]
196			if !ok {
197				return nil, fmt.Errorf("package %q references unknown interface %q", e.Package, ref)
198			}
199			if owner != e.Package {
200				deps[owner] = true
201			}
202		}
203		for d := range deps {
204			e.Deps = append(e.Deps, d)
205		}
206		sort.Strings(e.Deps)
207	}
208	t.manifest.Sort()
209	return t, nil
210}
211
212func licenseFor(pkg string) string {
213	if pkg == "serverdecoration" {
214		return "LGPL-2.1-or-later"
215	}
216	return "MIT"
217}
218
219// state is what is currently on disk under spec/.
220type state struct {
221	manifest *spec.Manifest
222	raw      []byte
223	xml      map[string][]byte
224}
225
226func readState(root string) (*state, error) {
227	s := &state{manifest: &spec.Manifest{}, xml: map[string][]byte{}}
228	raw, err := os.ReadFile(filepath.Join(root, "spec", "manifest.json"))
229	switch {
230	case errors.Is(err, fs.ErrNotExist):
231	case err != nil:
232		return nil, err
233	default:
234		s.raw = raw
235		if len(bytes.TrimSpace(raw)) > 0 {
236			if err := json.Unmarshal(raw, s.manifest); err != nil {
237				return nil, fmt.Errorf("decode current manifest: %w", err)
238			}
239		}
240	}
241	entries, err := os.ReadDir(filepath.Join(root, "spec", "xml"))
242	if err != nil && !errors.Is(err, fs.ErrNotExist) {
243		return nil, err
244	}
245	for _, e := range entries {
246		if e.IsDir() {
247			continue
248		}
249		b, err := os.ReadFile(filepath.Join(root, "spec", "xml", e.Name()))
250		if err != nil {
251			return nil, err
252		}
253		s.xml[e.Name()] = b
254	}
255	return s, nil
256}
257
258// differs reports whether t carries a real content change relative to disk:
259// different XML bytes or different protocol entries. A move of a source ref
260// or commit that leaves every selected XML byte-identical (and the set of
261// packages unchanged) is not a change, so nothing is rewritten and no release
262// is cut for it.
263func (s *state) differs(t *tree) (bool, error) {
264	if len(s.xml) != len(t.xml) {
265		return true, nil
266	}
267	// A different set of upstream repositories is a change.
268	if len(s.manifest.Sources) != len(t.manifest.Sources) {
269		return true, nil
270	}
271	for i, o := range s.manifest.Sources {
272		if n := t.manifest.Sources[i]; o.ID != n.ID || o.URL != n.URL {
273			return true, nil
274		}
275	}
276	// Compare manifests with the current source records substituted in.
277	probe := &spec.Manifest{Sources: s.manifest.Sources, Protocols: t.manifest.Protocols}
278	b, err := probe.Marshal()
279	if err != nil {
280		return false, err
281	}
282	if !bytes.Equal(s.raw, b) {
283		return true, nil
284	}
285	for name, b := range t.xml {
286		if old, ok := s.xml[name]; !ok || !bytes.Equal(old, b) {
287			return true, nil
288		}
289	}
290	return false, nil
291}
292
293// write stores t under root/spec and removes stale XML files.
294func write(root string, s *state, t *tree, manifest []byte) error {
295	dir := filepath.Join(root, "spec", "xml")
296	if err := os.MkdirAll(dir, 0o755); err != nil {
297		return err
298	}
299	for name, b := range t.xml {
300		if old, ok := s.xml[name]; ok && bytes.Equal(old, b) {
301			continue
302		}
303		if err := os.WriteFile(filepath.Join(dir, name), b, 0o644); err != nil {
304			return err
305		}
306	}
307	for name := range s.xml {
308		if _, keep := t.xml[name]; !keep {
309			if err := os.Remove(filepath.Join(dir, name)); err != nil {
310				return err
311			}
312		}
313	}
314	if !bytes.Equal(s.raw, manifest) {
315		return os.WriteFile(filepath.Join(root, "spec", "manifest.json"), manifest, 0o644)
316	}
317	return nil
318}

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 1

↑↓ moveenter openesc close