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