mock_repo.go

  1package repository
  2
  3import (
  4	"bytes"
  5	"crypto/sha1"
  6	"fmt"
  7	"io"
  8	"strings"
  9	"sync"
 10	"time"
 11
 12	"github.com/99designs/keyring"
 13	"github.com/ProtonMail/go-crypto/openpgp"
 14	"github.com/go-git/go-billy/v5/memfs"
 15
 16	"github.com/git-bug/git-bug/util/lamport"
 17)
 18
 19var _ ClockedRepo = &mockRepo{}
 20var _ TestedRepo = &mockRepo{}
 21
 22// mockRepo defines an instance of Repo that can be used for testing.
 23type mockRepo struct {
 24	*mockRepoConfig
 25	*mockRepoKeyring
 26	*mockRepoCommon
 27	*mockRepoStorage
 28	*mockRepoIndex
 29	*mockRepoDataBrowse
 30	*mockRepoClock
 31	*mockRepoTest
 32}
 33
 34func (m *mockRepo) Close() error { return nil }
 35
 36func NewMockRepo() *mockRepo {
 37	return &mockRepo{
 38		mockRepoConfig:     NewMockRepoConfig(),
 39		mockRepoKeyring:    NewMockRepoKeyring(),
 40		mockRepoCommon:     NewMockRepoCommon(),
 41		mockRepoStorage:    NewMockRepoStorage(),
 42		mockRepoIndex:      newMockRepoIndex(),
 43		mockRepoDataBrowse: newMockRepoDataBrowse(),
 44		mockRepoClock:      NewMockRepoClock(),
 45		mockRepoTest:       NewMockRepoTest(),
 46	}
 47}
 48
 49var _ RepoConfig = &mockRepoConfig{}
 50
 51type mockRepoConfig struct {
 52	localConfig  *MemConfig
 53	globalConfig *MemConfig
 54}
 55
 56func NewMockRepoConfig() *mockRepoConfig {
 57	return &mockRepoConfig{
 58		localConfig:  NewMemConfig(),
 59		globalConfig: NewMemConfig(),
 60	}
 61}
 62
 63// LocalConfig give access to the repository scoped configuration
 64func (r *mockRepoConfig) LocalConfig() Config {
 65	return r.localConfig
 66}
 67
 68// GlobalConfig give access to the git global configuration
 69func (r *mockRepoConfig) GlobalConfig() Config {
 70	return r.globalConfig
 71}
 72
 73// AnyConfig give access to a merged local/global configuration
 74func (r *mockRepoConfig) AnyConfig() ConfigRead {
 75	return mergeConfig(r.localConfig, r.globalConfig)
 76}
 77
 78var _ RepoKeyring = &mockRepoKeyring{}
 79
 80type mockRepoKeyring struct {
 81	keyring *keyring.ArrayKeyring
 82}
 83
 84func NewMockRepoKeyring() *mockRepoKeyring {
 85	return &mockRepoKeyring{
 86		keyring: keyring.NewArrayKeyring(nil),
 87	}
 88}
 89
 90// Keyring give access to a user-wide storage for secrets
 91func (r *mockRepoKeyring) Keyring() Keyring {
 92	return r.keyring
 93}
 94
 95var _ RepoCommon = &mockRepoCommon{}
 96
 97type mockRepoCommon struct{}
 98
 99func NewMockRepoCommon() *mockRepoCommon {
100	return &mockRepoCommon{}
101}
102
103func (r *mockRepoCommon) GetUserName() (string, error) {
104	return "René Descartes", nil
105}
106
107// GetUserEmail returns the email address that the user has used to configure git.
108func (r *mockRepoCommon) GetUserEmail() (string, error) {
109	return "user@example.com", nil
110}
111
112// GetCoreEditor returns the name of the editor that the user has used to configure git.
113func (r *mockRepoCommon) GetCoreEditor() (string, error) {
114	return "vi", nil
115}
116
117// GetRemotes returns the configured remotes repositories.
118func (r *mockRepoCommon) GetRemotes() (map[string]string, error) {
119	return map[string]string{
120		"origin": "git://github.com/git-bug/git-bug",
121	}, nil
122}
123
124var _ RepoStorage = &mockRepoStorage{}
125
126type mockRepoStorage struct {
127	localFs LocalStorage
128}
129
130func NewMockRepoStorage() *mockRepoStorage {
131	return &mockRepoStorage{localFs: billyLocalStorage{Filesystem: memfs.New()}}
132}
133
134func (m *mockRepoStorage) LocalStorage() LocalStorage {
135	return m.localFs
136}
137
138var _ RepoIndex = &mockRepoIndex{}
139
140type mockRepoIndex struct {
141	indexesMutex sync.Mutex
142	indexes      map[string]Index
143}
144
145func newMockRepoIndex() *mockRepoIndex {
146	return &mockRepoIndex{
147		indexes: make(map[string]Index),
148	}
149}
150
151func (m *mockRepoIndex) GetIndex(name string) (Index, error) {
152	m.indexesMutex.Lock()
153	defer m.indexesMutex.Unlock()
154
155	if index, ok := m.indexes[name]; ok {
156		return index, nil
157	}
158
159	index := newIndex()
160	m.indexes[name] = index
161	return index, nil
162}
163
164var _ Index = &mockIndex{}
165
166type mockIndex map[string][]string
167
168func newIndex() *mockIndex {
169	m := make(map[string][]string)
170	return (*mockIndex)(&m)
171}
172
173func (m *mockIndex) IndexOne(id string, texts []string) error {
174	(*m)[id] = texts
175	return nil
176}
177
178func (m *mockIndex) IndexBatch() (indexer func(id string, texts []string) error, closer func() error) {
179	indexer = func(id string, texts []string) error {
180		(*m)[id] = texts
181		return nil
182	}
183	closer = func() error { return nil }
184	return indexer, closer
185}
186
187func (m *mockIndex) Search(terms []string) (ids []string, err error) {
188loop:
189	for id, texts := range *m {
190		for _, text := range texts {
191			for _, s := range strings.Fields(text) {
192				for _, term := range terms {
193					if s == term {
194						ids = append(ids, id)
195						continue loop
196					}
197				}
198			}
199		}
200	}
201	return ids, nil
202}
203
204func (m *mockIndex) DocCount() (uint64, error) {
205	return uint64(len(*m)), nil
206}
207
208func (m *mockIndex) Remove(id string) error {
209	delete(*m, id)
210	return nil
211}
212
213func (m *mockIndex) Clear() error {
214	for k, _ := range *m {
215		delete(*m, k)
216	}
217	return nil
218}
219
220func (m *mockIndex) Close() error {
221	return nil
222}
223
224var _ RepoData = &mockRepoDataBrowse{}
225
226type commit struct {
227	treeHash Hash
228	parents  []Hash
229	sig      string
230	date     time.Time
231	message  string
232}
233
234type mockRepoDataBrowse struct {
235	blobs         map[Hash][]byte
236	trees         map[Hash]string
237	commits       map[Hash]commit
238	refs          map[string]Hash
239	defaultBranch string
240}
241
242func newMockRepoDataBrowse() *mockRepoDataBrowse {
243	return &mockRepoDataBrowse{
244		blobs:         make(map[Hash][]byte),
245		trees:         make(map[Hash]string),
246		commits:       make(map[Hash]commit),
247		refs:          make(map[string]Hash),
248		defaultBranch: "main",
249	}
250}
251
252func (r *mockRepoDataBrowse) FetchRefs(remote string, prefixes ...string) (string, error) {
253	panic("implement me")
254}
255
256// PushRefs push git refs to a remote
257func (r *mockRepoDataBrowse) PushRefs(remote string, prefixes ...string) (string, error) {
258	panic("implement me")
259}
260
261func (r *mockRepoDataBrowse) StoreData(data []byte) (Hash, error) {
262	rawHash := sha1.Sum(data)
263	hash := Hash(fmt.Sprintf("%x", rawHash))
264	r.blobs[hash] = data
265	return hash, nil
266}
267
268func (r *mockRepoDataBrowse) ReadData(hash Hash) ([]byte, error) {
269	data, ok := r.blobs[hash]
270	if !ok {
271		return nil, ErrNotFound
272	}
273
274	return data, nil
275}
276
277func (r *mockRepoDataBrowse) StoreTree(entries []TreeEntry) (Hash, error) {
278	buffer := prepareTreeEntries(entries)
279	rawHash := sha1.Sum(buffer.Bytes())
280	hash := Hash(fmt.Sprintf("%x", rawHash))
281	r.trees[hash] = buffer.String()
282
283	return hash, nil
284}
285
286func (r *mockRepoDataBrowse) ReadTree(hash Hash) ([]TreeEntry, error) {
287	var data string
288
289	data, ok := r.trees[hash]
290
291	if !ok {
292		// Git will understand a commit hash to reach a tree
293		commit, ok := r.commits[hash]
294
295		if !ok {
296			return nil, ErrNotFound
297		}
298
299		data, ok = r.trees[commit.treeHash]
300
301		if !ok {
302			return nil, ErrNotFound
303		}
304	}
305
306	return readTreeEntries(data)
307}
308
309func (r *mockRepoDataBrowse) StoreCommit(treeHash Hash, parents ...Hash) (Hash, error) {
310	return r.StoreSignedCommit(treeHash, nil, parents...)
311}
312
313func (r *mockRepoDataBrowse) StoreSignedCommit(treeHash Hash, signKey *openpgp.Entity, parents ...Hash) (Hash, error) {
314	hasher := sha1.New()
315	hasher.Write([]byte(treeHash))
316	for _, parent := range parents {
317		hasher.Write([]byte(parent))
318	}
319	rawHash := hasher.Sum(nil)
320	hash := Hash(fmt.Sprintf("%x", rawHash))
321	c := commit{
322		treeHash: treeHash,
323		parents:  parents,
324		date:     time.Now(),
325	}
326	if signKey != nil {
327		// unlike go-git, we only sign the tree hash for simplicity instead of all the fields (parents ...)
328		var sig bytes.Buffer
329		if err := openpgp.DetachSign(&sig, signKey, strings.NewReader(string(treeHash)), nil); err != nil {
330			return "", err
331		}
332		c.sig = sig.String()
333	}
334	r.commits[hash] = c
335	return hash, nil
336}
337
338func (r *mockRepoDataBrowse) ReadCommit(hash Hash) (Commit, error) {
339	c, ok := r.commits[hash]
340	if !ok {
341		return Commit{}, ErrNotFound
342	}
343
344	result := Commit{
345		Hash:     hash,
346		Parents:  c.parents,
347		TreeHash: c.treeHash,
348	}
349
350	if c.sig != "" {
351		// Note: this is actually incorrect as the signed data should be the full commit (+comment, +date ...)
352		// but only the tree hash work for our purpose here.
353		result.SignedData = strings.NewReader(string(c.treeHash))
354		result.Signature = strings.NewReader(c.sig)
355	}
356
357	return result, nil
358}
359
360func (r *mockRepoDataBrowse) ResolveRef(ref string) (Hash, error) {
361	h, ok := r.refs[ref]
362	if !ok {
363		return "", ErrNotFound
364	}
365	return h, nil
366}
367
368func (r *mockRepoDataBrowse) UpdateRef(ref string, hash Hash) error {
369	r.refs[ref] = hash
370	return nil
371}
372
373func (r *mockRepoDataBrowse) RemoveRef(ref string) error {
374	delete(r.refs, ref)
375	return nil
376}
377
378func (r *mockRepoDataBrowse) ListRefs(refPrefix string) ([]string, error) {
379	var keys []string
380
381	for k := range r.refs {
382		if strings.HasPrefix(k, refPrefix) {
383			keys = append(keys, k)
384		}
385	}
386
387	return keys, nil
388}
389
390func (r *mockRepoDataBrowse) RefExist(ref string) (bool, error) {
391	_, exist := r.refs[ref]
392	return exist, nil
393}
394
395func (r *mockRepoDataBrowse) CopyRef(source string, dest string) error {
396	hash, exist := r.refs[source]
397
398	if !exist {
399		return ErrNotFound
400	}
401
402	r.refs[dest] = hash
403	return nil
404}
405
406func (r *mockRepoDataBrowse) ListCommits(ref string) ([]Hash, error) {
407	return nonNativeListCommits(r, ref)
408}
409
410// resolveRef resolves a ref matching the RepoBrowse contract:
411// refs/heads/<ref>, refs/tags/<ref>, full ref name, raw commit hash.
412func (r *mockRepoDataBrowse) resolveRef(ref string) (Hash, error) {
413	for _, candidate := range []string{"refs/heads/" + ref, "refs/tags/" + ref, ref} {
414		if h, ok := r.refs[candidate]; ok {
415			return h, nil
416		}
417	}
418	if _, ok := r.commits[Hash(ref)]; ok {
419		return Hash(ref), nil
420	}
421	return "", ErrNotFound
422}
423
424// treeEntriesAtHash parses the entries of the tree stored under hash.
425func (r *mockRepoDataBrowse) treeEntriesAtHash(hash Hash) ([]TreeEntry, error) {
426	data, ok := r.trees[hash]
427	if !ok {
428		return nil, ErrNotFound
429	}
430	return readTreeEntries(data)
431}
432
433// treeEntriesAt returns the directory entries at path inside the tree rooted at
434// treeHash. path="" returns root entries. Returns ErrNotFound if path doesn't
435// exist or resolves to a blob rather than a tree.
436func (r *mockRepoDataBrowse) treeEntriesAt(treeHash Hash, path string) ([]TreeEntry, error) {
437	path = strings.Trim(path, "/")
438	if path == "" {
439		return r.treeEntriesAtHash(treeHash)
440	}
441	seg, rest, _ := strings.Cut(path, "/")
442	entries, err := r.treeEntriesAtHash(treeHash)
443	if err != nil {
444		return nil, err
445	}
446	for _, e := range entries {
447		if e.Name != seg || e.ObjectType != Tree {
448			continue
449		}
450		if rest == "" {
451			return r.treeEntriesAtHash(e.Hash)
452		}
453		return r.treeEntriesAt(e.Hash, rest)
454	}
455	return nil, ErrNotFound
456}
457
458// blobHashAt walks the tree to find the blob hash for the file at path.
459func (r *mockRepoDataBrowse) blobHashAt(treeHash Hash, path string) (Hash, error) {
460	path = strings.Trim(path, "/")
461	seg, rest, hasRest := strings.Cut(path, "/")
462	entries, err := r.treeEntriesAtHash(treeHash)
463	if err != nil {
464		return "", err
465	}
466	for _, e := range entries {
467		if e.Name != seg {
468			continue
469		}
470		if !hasRest {
471			return e.Hash, nil
472		}
473		if e.ObjectType != Tree {
474			return "", ErrNotFound
475		}
476		return r.blobHashAt(e.Hash, rest)
477	}
478	return "", ErrNotFound
479}
480
481// diffTrees returns the changed files between two trees, recursing into
482// sub-trees. fromHash=="" means an empty (non-existent) tree.
483func (r *mockRepoDataBrowse) diffTrees(fromHash, toHash Hash, prefix string) []ChangedFile {
484	var fromEntries, toEntries []TreeEntry
485	if fromHash != "" {
486		fromEntries, _ = r.treeEntriesAtHash(fromHash)
487	}
488	if toHash != "" {
489		toEntries, _ = r.treeEntriesAtHash(toHash)
490	}
491
492	fromMap := make(map[string]TreeEntry, len(fromEntries))
493	for _, e := range fromEntries {
494		fromMap[e.Name] = e
495	}
496	toMap := make(map[string]TreeEntry, len(toEntries))
497	for _, e := range toEntries {
498		toMap[e.Name] = e
499	}
500
501	var result []ChangedFile
502	for _, e := range toEntries {
503		path := prefix + e.Name
504		f, existed := fromMap[e.Name]
505		if e.ObjectType == Tree {
506			var sub Hash
507			if existed {
508				sub = f.Hash
509			}
510			result = append(result, r.diffTrees(sub, e.Hash, path+"/")...)
511		} else if !existed {
512			result = append(result, ChangedFile{Path: path, Status: ChangeStatusAdded})
513		} else if f.Hash != e.Hash {
514			result = append(result, ChangedFile{Path: path, Status: ChangeStatusModified})
515		}
516	}
517	for _, f := range fromEntries {
518		if _, exists := toMap[f.Name]; exists {
519			continue
520		}
521		path := prefix + f.Name
522		if f.ObjectType == Tree {
523			result = append(result, r.diffTrees(f.Hash, "", path+"/")...)
524		} else {
525			result = append(result, ChangedFile{Path: path, Status: ChangeStatusDeleted})
526		}
527	}
528	return result
529}
530
531func mockCommitMeta(hash Hash, c commit) CommitMeta {
532	return CommitMeta{
533		Hash:    hash,
534		Parents: c.parents,
535		Date:    c.date,
536		Message: c.message,
537	}
538}
539
540func (r *mockRepoDataBrowse) Branches() ([]BranchInfo, error) {
541	var branches []BranchInfo
542	for ref, hash := range r.refs {
543		name, ok := strings.CutPrefix(ref, "refs/heads/")
544		if !ok {
545			continue
546		}
547		branches = append(branches, BranchInfo{
548			Name:      name,
549			Hash:      hash,
550			IsDefault: name == r.defaultBranch,
551		})
552	}
553	return branches, nil
554}
555
556func (r *mockRepoDataBrowse) Tags() ([]TagInfo, error) {
557	var tags []TagInfo
558	for ref, hash := range r.refs {
559		name, ok := strings.CutPrefix(ref, "refs/tags/")
560		if !ok {
561			continue
562		}
563		tags = append(tags, TagInfo{Name: name, Hash: hash})
564	}
565	return tags, nil
566}
567
568func (r *mockRepoDataBrowse) TreeAtPath(ref, path string) ([]TreeEntry, error) {
569	startHash, err := r.resolveRef(ref)
570	if err != nil {
571		return nil, ErrNotFound
572	}
573	c, ok := r.commits[startHash]
574	if !ok {
575		return nil, ErrNotFound
576	}
577	return r.treeEntriesAt(c.treeHash, path)
578}
579
580func (r *mockRepoDataBrowse) BlobAtPath(ref, path string) (io.ReadCloser, int64, Hash, error) {
581	startHash, err := r.resolveRef(ref)
582	if err != nil {
583		return nil, 0, "", ErrNotFound
584	}
585	c, ok := r.commits[startHash]
586	if !ok {
587		return nil, 0, "", ErrNotFound
588	}
589	blobHash, err := r.blobHashAt(c.treeHash, path)
590	if err != nil {
591		return nil, 0, "", ErrNotFound
592	}
593	data, ok := r.blobs[blobHash]
594	if !ok {
595		return nil, 0, "", ErrNotFound
596	}
597	return io.NopCloser(bytes.NewReader(data)), int64(len(data)), blobHash, nil
598}
599
600func (r *mockRepoDataBrowse) CommitLog(ref, path string, limit int, after Hash, since, until *time.Time) ([]CommitMeta, error) {
601	startHash, err := r.resolveRef(ref)
602	if err != nil {
603		return nil, ErrNotFound
604	}
605	path = strings.Trim(path, "/")
606	var result []CommitMeta
607	skipping := after != ""
608	current := startHash
609	seen := make(map[Hash]bool)
610	for {
611		if seen[current] {
612			break
613		}
614		seen[current] = true
615		c, ok := r.commits[current]
616		if !ok {
617			break
618		}
619		if skipping {
620			if current == after {
621				skipping = false
622			}
623			if len(c.parents) == 0 {
624				break
625			}
626			current = c.parents[0]
627			continue
628		}
629		meta := mockCommitMeta(current, c)
630		if since != nil && meta.Date.Before(*since) {
631			if len(c.parents) == 0 {
632				break
633			}
634			current = c.parents[0]
635			continue
636		}
637		if until != nil && meta.Date.After(*until) {
638			if len(c.parents) == 0 {
639				break
640			}
641			current = c.parents[0]
642			continue
643		}
644		if path != "" {
645			var fromTreeHash Hash
646			if len(c.parents) > 0 {
647				if parent, ok := r.commits[c.parents[0]]; ok {
648					fromTreeHash = parent.treeHash
649				}
650			}
651			touched := false
652			for _, f := range r.diffTrees(fromTreeHash, c.treeHash, "") {
653				if f.Path == path || strings.HasPrefix(f.Path, path+"/") {
654					touched = true
655					break
656				}
657			}
658			if !touched {
659				if len(c.parents) == 0 {
660					break
661				}
662				current = c.parents[0]
663				continue
664			}
665		}
666		result = append(result, meta)
667		if limit > 0 && len(result) >= limit {
668			break
669		}
670		if len(c.parents) == 0 {
671			break
672		}
673		current = c.parents[0]
674	}
675	return result, nil
676}
677
678func (r *mockRepoDataBrowse) LastCommitForEntries(ref, path string, names []string) (map[string]CommitMeta, error) {
679	startHash, err := r.resolveRef(ref)
680	if err != nil {
681		return nil, ErrNotFound
682	}
683	path = strings.Trim(path, "/")
684	remaining := make(map[string]bool, len(names))
685	for _, n := range names {
686		remaining[n] = true
687	}
688	result := make(map[string]CommitMeta)
689	current := startHash
690	seen := make(map[Hash]bool)
691	for len(remaining) > 0 {
692		if seen[current] {
693			break
694		}
695		seen[current] = true
696		c, ok := r.commits[current]
697		if !ok {
698			break
699		}
700		curEntries, err := r.treeEntriesAt(c.treeHash, path)
701		if err != nil {
702			if len(c.parents) == 0 {
703				break
704			}
705			current = c.parents[0]
706			continue
707		}
708		curMap := make(map[string]Hash, len(curEntries))
709		for _, e := range curEntries {
710			curMap[e.Name] = e.Hash
711		}
712		if len(c.parents) == 0 {
713			for name := range remaining {
714				if _, ok := curMap[name]; ok {
715					result[name] = mockCommitMeta(current, c)
716					delete(remaining, name)
717				}
718			}
719			break
720		}
721		pc, ok := r.commits[c.parents[0]]
722		if !ok {
723			break
724		}
725		parentEntries, _ := r.treeEntriesAt(pc.treeHash, path)
726		parentMap := make(map[string]Hash, len(parentEntries))
727		for _, e := range parentEntries {
728			parentMap[e.Name] = e.Hash
729		}
730		for name := range remaining {
731			cur, curExists := curMap[name]
732			par, parExists := parentMap[name]
733			if curExists && (!parExists || cur != par) {
734				result[name] = mockCommitMeta(current, c)
735				delete(remaining, name)
736			}
737		}
738		current = c.parents[0]
739	}
740	return result, nil
741}
742
743func (r *mockRepoDataBrowse) CommitDetail(hash Hash) (CommitDetail, error) {
744	c, ok := r.commits[hash]
745	if !ok {
746		return CommitDetail{}, ErrNotFound
747	}
748	var fromTreeHash Hash
749	if len(c.parents) > 0 {
750		if parent, ok := r.commits[c.parents[0]]; ok {
751			fromTreeHash = parent.treeHash
752		}
753	}
754	return CommitDetail{
755		CommitMeta: mockCommitMeta(hash, c),
756		Files:      r.diffTrees(fromTreeHash, c.treeHash, ""),
757	}, nil
758}
759
760func (r *mockRepoDataBrowse) CommitFileDiff(hash Hash, filePath string) (FileDiff, error) {
761	c, ok := r.commits[hash]
762	if !ok {
763		return FileDiff{}, ErrNotFound
764	}
765	var fromTreeHash Hash
766	if len(c.parents) > 0 {
767		if parent, ok := r.commits[c.parents[0]]; ok {
768			fromTreeHash = parent.treeHash
769		}
770	}
771	files := r.diffTrees(fromTreeHash, c.treeHash, "")
772	var matched *ChangedFile
773	for i := range files {
774		if files[i].Path == filePath {
775			matched = &files[i]
776			break
777		}
778	}
779	if matched == nil {
780		return FileDiff{}, ErrNotFound
781	}
782	fd := FileDiff{
783		Path:     filePath,
784		IsNew:    matched.Status == ChangeStatusAdded,
785		IsDelete: matched.Status == ChangeStatusDeleted,
786	}
787	var oldContent, newContent []byte
788	if fromTreeHash != "" {
789		if bh, err := r.blobHashAt(fromTreeHash, filePath); err == nil {
790			oldContent = r.blobs[bh]
791		}
792	}
793	if bh, err := r.blobHashAt(c.treeHash, filePath); err == nil {
794		newContent = r.blobs[bh]
795	}
796	fd.Hunks = mockDiffHunks(oldContent, newContent)
797	return fd, nil
798}
799
800// mockDiffHunks produces a single DiffHunk using a prefix/suffix scan.
801func mockDiffHunks(old, new []byte) []DiffHunk {
802	oldLines := splitBlobLines(old)
803	newLines := splitBlobLines(new)
804	i := 0
805	for i < len(oldLines) && i < len(newLines) && oldLines[i] == newLines[i] {
806		i++
807	}
808	j, k := len(oldLines), len(newLines)
809	for j > i && k > i && oldLines[j-1] == newLines[k-1] {
810		j--
811		k--
812	}
813	if j == i && k == i {
814		return nil // no changed region
815	}
816	oldLine, newLine := 1, 1
817	var lines []DiffLine
818	for _, l := range oldLines[:i] {
819		lines = append(lines, DiffLine{Type: DiffLineContext, Content: l, OldLine: oldLine, NewLine: newLine})
820		oldLine++
821		newLine++
822	}
823	for _, l := range oldLines[i:j] {
824		lines = append(lines, DiffLine{Type: DiffLineDeleted, Content: l, OldLine: oldLine})
825		oldLine++
826	}
827	for _, l := range newLines[i:k] {
828		lines = append(lines, DiffLine{Type: DiffLineAdded, Content: l, NewLine: newLine})
829		newLine++
830	}
831	for _, l := range oldLines[j:] {
832		lines = append(lines, DiffLine{Type: DiffLineContext, Content: l, OldLine: oldLine, NewLine: newLine})
833		oldLine++
834		newLine++
835	}
836	return []DiffHunk{{OldStart: 1, OldLines: len(oldLines), NewStart: 1, NewLines: len(newLines), Lines: lines}}
837}
838
839func splitBlobLines(data []byte) []string {
840	if len(data) == 0 {
841		return nil
842	}
843	return strings.Split(strings.TrimRight(string(data), "\n"), "\n")
844}
845
846var _ RepoClock = &mockRepoClock{}
847
848type mockRepoClock struct {
849	mu     sync.Mutex
850	clocks map[string]lamport.Clock
851}
852
853func NewMockRepoClock() *mockRepoClock {
854	return &mockRepoClock{
855		clocks: make(map[string]lamport.Clock),
856	}
857}
858
859func (r *mockRepoClock) AllClocks() (map[string]lamport.Clock, error) {
860	return r.clocks, nil
861}
862
863func (r *mockRepoClock) GetOrCreateClock(name string) (lamport.Clock, error) {
864	r.mu.Lock()
865	defer r.mu.Unlock()
866
867	if c, ok := r.clocks[name]; ok {
868		return c, nil
869	}
870
871	c := lamport.NewMemClock()
872	r.clocks[name] = c
873	return c, nil
874}
875
876func (r *mockRepoClock) Increment(name string) (lamport.Time, error) {
877	c, err := r.GetOrCreateClock(name)
878	if err != nil {
879		return lamport.Time(0), err
880	}
881	return c.Increment()
882}
883
884func (r *mockRepoClock) Witness(name string, time lamport.Time) error {
885	c, err := r.GetOrCreateClock(name)
886	if err != nil {
887		return err
888	}
889	return c.Witness(time)
890}
891
892var _ repoTest = &mockRepoTest{}
893
894type mockRepoTest struct{}
895
896func NewMockRepoTest() *mockRepoTest {
897	return &mockRepoTest{}
898}
899
900func (r *mockRepoTest) AddRemote(name string, url string) error {
901	panic("implement me")
902}
903
904func (r mockRepoTest) GetLocalRemote() string {
905	panic("implement me")
906}
907
908func (r mockRepoTest) EraseFromDisk() error {
909	// nothing to do
910	return nil
911}