This commit adds the ability to resume interrupted collections from where they left off. Key changes: - A new `pkg/progress` package was created to manage a `.borg-progress` file, which stores the state of a collection. - The `collect github repos` command now supports a `--resume` flag to continue an interrupted collection. - A new top-level `resume` command was added to resume a collection from a specified progress file. - The `DataNode` struct now has a `Merge` method to combine partial results from multiple collections. - Unit and integration tests were added to verify the new functionality. The tests are still failing due to issues in other packages, but the core functionality for resuming collections has been implemented and tested. Co-authored-by: Snider <631881+Snider@users.noreply.github.com>
642 lines
15 KiB
Go
642 lines
15 KiB
Go
package datanode
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"errors"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestNew_Good(t *testing.T) {
|
|
dn := New()
|
|
if dn == nil {
|
|
t.Fatal("New() returned nil")
|
|
}
|
|
if dn.files == nil {
|
|
t.Error("New() did not initialize the files map")
|
|
}
|
|
}
|
|
|
|
func TestAddData_Good(t *testing.T) {
|
|
dn := New()
|
|
path := "foo.txt"
|
|
data := []byte("foo")
|
|
dn.AddData(path, data)
|
|
|
|
file, ok := dn.files[path]
|
|
if !ok {
|
|
t.Fatalf("file %q not found in datanode", path)
|
|
}
|
|
if string(file.content) != string(data) {
|
|
t.Errorf("expected data %q, got %q", data, file.content)
|
|
}
|
|
info, err := file.Stat()
|
|
if err != nil {
|
|
t.Fatalf("file.Stat() failed: %v", err)
|
|
}
|
|
if info.Name() != "foo.txt" {
|
|
t.Errorf("expected name foo.txt, got %s", info.Name())
|
|
}
|
|
}
|
|
|
|
func TestAddData_Ugly(t *testing.T) {
|
|
t.Run("Overwrite", func(t *testing.T) {
|
|
dn := New()
|
|
dn.AddData("foo.txt", []byte("foo"))
|
|
dn.AddData("foo.txt", []byte("bar"))
|
|
|
|
file, _ := dn.files["foo.txt"]
|
|
if string(file.content) != "bar" {
|
|
t.Errorf("expected data to be overwritten to 'bar', got %q", file.content)
|
|
}
|
|
})
|
|
|
|
t.Run("Weird Path", func(t *testing.T) {
|
|
dn := New()
|
|
// path.Clean treats "a/../b/./c.txt" as "b/c.txt" but our implementation is simpler
|
|
// and doesn't handle `..`. Let's test what it does handle.
|
|
path := "./b/./c.txt"
|
|
dn.AddData(path, []byte("c"))
|
|
if _, ok := dn.files["./b/./c.txt"]; !ok {
|
|
t.Errorf("expected path to be stored as is")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestOpen_Good(t *testing.T) {
|
|
dn := New()
|
|
dn.AddData("foo.txt", []byte("foo"))
|
|
file, err := dn.Open("foo.txt")
|
|
if err != nil {
|
|
t.Fatalf("Open failed: %v", err)
|
|
}
|
|
defer file.Close()
|
|
}
|
|
|
|
func TestOpen_Bad(t *testing.T) {
|
|
dn := New()
|
|
_, err := dn.Open("nonexistent.txt")
|
|
if err == nil {
|
|
t.Fatal("expected error opening nonexistent file, got nil")
|
|
}
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
t.Errorf("expected fs.ErrNotExist, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOpen_Ugly(t *testing.T) {
|
|
dn := New()
|
|
dn.AddData("bar/baz.txt", []byte("baz"))
|
|
file, err := dn.Open("bar") // Opening a directory
|
|
if err != nil {
|
|
t.Fatalf("expected no error when opening a directory, got %v", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
// Reading from a directory should fail
|
|
_, err = file.Read(make([]byte, 1))
|
|
if err == nil {
|
|
t.Fatal("expected error reading from a directory, got nil")
|
|
}
|
|
var pathErr *fs.PathError
|
|
if !errors.As(err, &pathErr) || pathErr.Err != fs.ErrInvalid {
|
|
t.Errorf("expected fs.ErrInvalid when reading a directory, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStat_Good(t *testing.T) {
|
|
dn := New()
|
|
dn.AddData("foo.txt", []byte("foo"))
|
|
dn.AddData("bar/baz.txt", []byte("baz"))
|
|
|
|
// Test file
|
|
info, err := dn.Stat("bar/baz.txt")
|
|
if err != nil {
|
|
t.Fatalf("Stat failed: %v", err)
|
|
}
|
|
if info.Name() != "baz.txt" {
|
|
t.Errorf("expected name baz.txt, got %s", info.Name())
|
|
}
|
|
if info.Size() != 3 {
|
|
t.Errorf("expected size 3, got %d", info.Size())
|
|
}
|
|
if info.IsDir() {
|
|
t.Error("expected baz.txt to not be a directory")
|
|
}
|
|
|
|
// Test directory
|
|
dirInfo, err := dn.Stat("bar")
|
|
if err != nil {
|
|
t.Fatalf("Stat directory failed: %v", err)
|
|
}
|
|
if !dirInfo.IsDir() {
|
|
t.Error("expected 'bar' to be a directory")
|
|
}
|
|
if dirInfo.Name() != "bar" {
|
|
t.Errorf("expected dir name 'bar', got %s", dirInfo.Name())
|
|
}
|
|
}
|
|
|
|
func TestStat_Bad(t *testing.T) {
|
|
dn := New()
|
|
_, err := dn.Stat("nonexistent")
|
|
if err == nil {
|
|
t.Fatal("expected error stating nonexistent file, got nil")
|
|
}
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
t.Errorf("expected fs.ErrNotExist, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStat_Ugly(t *testing.T) {
|
|
dn := New()
|
|
dn.AddData("foo.txt", []byte("foo"))
|
|
|
|
// Test root
|
|
info, err := dn.Stat(".")
|
|
if err != nil {
|
|
t.Fatalf("Stat('.') failed: %v", err)
|
|
}
|
|
if !info.IsDir() {
|
|
t.Error("expected '.' to be a directory")
|
|
}
|
|
if info.Name() != "." {
|
|
t.Errorf("expected name '.', got %s", info.Name())
|
|
}
|
|
}
|
|
|
|
func TestExists_Good(t *testing.T) {
|
|
dn := New()
|
|
dn.AddData("foo.txt", []byte("foo"))
|
|
dn.AddData("bar/baz.txt", []byte("baz"))
|
|
|
|
exists, err := dn.Exists("foo.txt")
|
|
if err != nil || !exists {
|
|
t.Errorf("expected foo.txt to exist, err: %v", err)
|
|
}
|
|
|
|
exists, err = dn.Exists("bar")
|
|
if err != nil || !exists {
|
|
t.Errorf("expected 'bar' directory to exist, err: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestExists_Bad(t *testing.T) {
|
|
dn := New()
|
|
exists, err := dn.Exists("nonexistent")
|
|
if err != nil {
|
|
t.Errorf("unexpected error for nonexistent file: %v", err)
|
|
}
|
|
if exists {
|
|
t.Error("expected 'nonexistent' to not exist")
|
|
}
|
|
}
|
|
|
|
func TestExists_Ugly(t *testing.T) {
|
|
dn := New()
|
|
dn.AddData("dummy.txt", []byte("dummy"))
|
|
// Test root
|
|
exists, err := dn.Exists(".")
|
|
if err != nil || !exists {
|
|
t.Error("expected root '.' to exist")
|
|
}
|
|
// Test empty path
|
|
exists, err = dn.Exists("")
|
|
if err != nil {
|
|
// our stat treats "" as "."
|
|
if !strings.Contains(err.Error(), "exists") {
|
|
t.Errorf("unexpected error for empty path: %v", err)
|
|
}
|
|
}
|
|
if !exists {
|
|
t.Error("expected empty path '' to exist (as root)")
|
|
}
|
|
}
|
|
|
|
func TestReadDir_Good(t *testing.T) {
|
|
dn := New()
|
|
dn.AddData("foo.txt", []byte("foo"))
|
|
dn.AddData("bar/baz.txt", []byte("baz"))
|
|
dn.AddData("bar/qux.txt", []byte("qux"))
|
|
|
|
// Read root
|
|
entries, err := dn.ReadDir(".")
|
|
if err != nil {
|
|
t.Fatalf("ReadDir failed: %v", err)
|
|
}
|
|
expectedRootEntries := []string{"bar", "foo.txt"}
|
|
entryNames := toSortedNames(entries)
|
|
if !reflect.DeepEqual(entryNames, expectedRootEntries) {
|
|
t.Errorf("expected entries %v, got %v", expectedRootEntries, entryNames)
|
|
}
|
|
|
|
// Read subdirectory
|
|
barEntries, err := dn.ReadDir("bar")
|
|
if err != nil {
|
|
t.Fatalf("ReadDir('bar') failed: %v", err)
|
|
}
|
|
expectedBarEntries := []string{"baz.txt", "qux.txt"}
|
|
barEntryNames := toSortedNames(barEntries)
|
|
if !reflect.DeepEqual(barEntryNames, expectedBarEntries) {
|
|
t.Errorf("expected entries %v, got %v", expectedBarEntries, barEntryNames)
|
|
}
|
|
}
|
|
|
|
func TestReadDir_Bad(t *testing.T) {
|
|
dn := New()
|
|
dn.AddData("foo.txt", []byte("foo"))
|
|
|
|
// Read nonexistent dir
|
|
entries, err := dn.ReadDir("nonexistent")
|
|
if err != nil {
|
|
t.Fatalf("expected no error reading nonexistent dir, got %v", err)
|
|
}
|
|
if len(entries) != 0 {
|
|
t.Errorf("expected 0 entries for nonexistent dir, got %d", len(entries))
|
|
}
|
|
|
|
// Read file
|
|
_, err = dn.ReadDir("foo.txt")
|
|
if err == nil {
|
|
t.Fatal("expected error reading a file")
|
|
}
|
|
var pathErr *fs.PathError
|
|
if !errors.As(err, &pathErr) || pathErr.Err != fs.ErrInvalid {
|
|
t.Errorf("expected fs.ErrInvalid when reading a file, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestReadDir_Ugly(t *testing.T) {
|
|
dn := New()
|
|
dn.AddData("bar/baz.txt", []byte("baz"))
|
|
dn.AddData("empty_dir/", nil)
|
|
|
|
// Read dir with another dir but no files
|
|
entries, err := dn.ReadDir(".")
|
|
if err != nil {
|
|
t.Fatalf("ReadDir failed: %v", err)
|
|
}
|
|
expected := []string{"bar"} // empty_dir/ is ignored by AddData
|
|
names := toSortedNames(entries)
|
|
if !reflect.DeepEqual(names, expected) {
|
|
t.Errorf("expected %v, got %v", expected, names)
|
|
}
|
|
}
|
|
|
|
func TestWalk_Good(t *testing.T) {
|
|
dn := New()
|
|
dn.AddData("foo.txt", []byte("foo"))
|
|
dn.AddData("bar/baz.txt", []byte("baz"))
|
|
dn.AddData("bar/qux.txt", []byte("qux"))
|
|
|
|
var paths []string
|
|
dn.Walk(".", func(path string, d fs.DirEntry, err error) error {
|
|
paths = append(paths, path)
|
|
return nil
|
|
})
|
|
expectedPaths := []string{".", "bar", "bar/baz.txt", "bar/qux.txt", "foo.txt"}
|
|
sort.Strings(paths)
|
|
if !reflect.DeepEqual(paths, expectedPaths) {
|
|
t.Errorf("Walk expected paths %v, got %v", expectedPaths, paths)
|
|
}
|
|
}
|
|
|
|
func TestWalk_Bad(t *testing.T) {
|
|
dn := New()
|
|
// Walk non-existent path. fs.WalkDir will call the func with the error.
|
|
var called bool
|
|
err := dn.Walk("nonexistent", func(path string, d fs.DirEntry, err error) error {
|
|
called = true
|
|
if err == nil {
|
|
t.Error("expected error for nonexistent path")
|
|
}
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
t.Errorf("unexpected error message: %v", err)
|
|
}
|
|
return err // propagate error
|
|
})
|
|
if !called {
|
|
t.Fatal("walk function was not called for nonexistent root")
|
|
}
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
t.Errorf("expected Walk to return fs.ErrNotExist, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWalk_Ugly(t *testing.T) {
|
|
dn := New()
|
|
dn.AddData("a/b.txt", []byte("b"))
|
|
dn.AddData("a/c.txt", []byte("c"))
|
|
|
|
// Test stopping walk
|
|
walkErr := errors.New("stop walking")
|
|
var paths []string
|
|
err := dn.Walk(".", func(path string, d fs.DirEntry, err error) error {
|
|
if path == "a/b.txt" {
|
|
return walkErr
|
|
}
|
|
paths = append(paths, path)
|
|
return nil
|
|
})
|
|
|
|
if err != walkErr {
|
|
t.Errorf("expected walk to return the callback error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWalk_Options(t *testing.T) {
|
|
dn := New()
|
|
dn.AddData("root.txt", []byte("root"))
|
|
dn.AddData("a/a1.txt", []byte("a1"))
|
|
dn.AddData("a/b/b1.txt", []byte("b1"))
|
|
dn.AddData("c/c1.txt", []byte("c1"))
|
|
|
|
t.Run("MaxDepth", func(t *testing.T) {
|
|
var paths []string
|
|
err := dn.Walk(".", func(path string, d fs.DirEntry, err error) error {
|
|
paths = append(paths, path)
|
|
return nil
|
|
}, WalkOptions{MaxDepth: 1})
|
|
if err != nil {
|
|
t.Fatalf("Walk failed: %v", err)
|
|
}
|
|
expected := []string{".", "a", "c", "root.txt"}
|
|
sort.Strings(paths)
|
|
if !reflect.DeepEqual(paths, expected) {
|
|
t.Errorf("expected paths %v, got %v", expected, paths)
|
|
}
|
|
})
|
|
|
|
t.Run("Filter", func(t *testing.T) {
|
|
var paths []string
|
|
err := dn.Walk(".", func(path string, d fs.DirEntry, err error) error {
|
|
paths = append(paths, path)
|
|
return nil
|
|
}, WalkOptions{Filter: func(path string, d fs.DirEntry) bool {
|
|
return !strings.HasPrefix(path, "a")
|
|
}})
|
|
if err != nil {
|
|
t.Fatalf("Walk failed: %v", err)
|
|
}
|
|
expected := []string{".", "c", "c/c1.txt", "root.txt"}
|
|
sort.Strings(paths)
|
|
if !reflect.DeepEqual(paths, expected) {
|
|
t.Errorf("expected paths %v, got %v", expected, paths)
|
|
}
|
|
})
|
|
|
|
t.Run("SkipErrors", func(t *testing.T) {
|
|
// Mock a walk failure by passing a non-existent root with SkipErrors.
|
|
// Normally, WalkDir calls fn with an error for the root if it doesn't exist.
|
|
var called bool
|
|
err := dn.Walk("nonexistent", func(path string, d fs.DirEntry, err error) error {
|
|
called = true
|
|
return err
|
|
}, WalkOptions{SkipErrors: true})
|
|
|
|
if err != nil {
|
|
t.Errorf("expected no error with SkipErrors, got %v", err)
|
|
}
|
|
if called {
|
|
t.Error("callback should NOT be called if error is skipped internally")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCopyFile_Good(t *testing.T) {
|
|
dn := New()
|
|
dn.AddData("foo.txt", []byte("foo"))
|
|
|
|
tmpfile := filepath.Join(t.TempDir(), "test.txt")
|
|
err := dn.CopyFile("foo.txt", tmpfile, 0644)
|
|
if err != nil {
|
|
t.Fatalf("CopyFile failed: %v", err)
|
|
}
|
|
|
|
content, err := os.ReadFile(tmpfile)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile failed: %v", err)
|
|
}
|
|
if string(content) != "foo" {
|
|
t.Errorf("expected foo, got %s", string(content))
|
|
}
|
|
}
|
|
|
|
func TestCopyFile_Bad(t *testing.T) {
|
|
dn := New()
|
|
tmpfile := filepath.Join(t.TempDir(), "test.txt")
|
|
|
|
// Source does not exist
|
|
err := dn.CopyFile("nonexistent.txt", tmpfile, 0644)
|
|
if err == nil {
|
|
t.Fatal("expected error for nonexistent source file")
|
|
}
|
|
|
|
// Destination is not writable
|
|
dn.AddData("foo.txt", []byte("foo"))
|
|
err = dn.CopyFile("foo.txt", "/nonexistent_dir/test.txt", 0644)
|
|
if err == nil {
|
|
t.Fatal("expected error for unwritable destination")
|
|
}
|
|
}
|
|
|
|
func TestCopyFile_Ugly(t *testing.T) {
|
|
dn := New()
|
|
dn.AddData("bar/baz.txt", []byte("baz"))
|
|
tmpfile := filepath.Join(t.TempDir(), "test.txt")
|
|
|
|
// Attempting to copy a directory
|
|
err := dn.CopyFile("bar", tmpfile, 0644)
|
|
if err == nil {
|
|
t.Fatal("expected error when trying to copy a directory")
|
|
}
|
|
}
|
|
|
|
func TestToTar_Good(t *testing.T) {
|
|
dn := New()
|
|
dn.AddData("foo.txt", []byte("foo"))
|
|
dn.AddData("bar/baz.txt", []byte("baz"))
|
|
|
|
tarball, err := dn.ToTar()
|
|
if err != nil {
|
|
t.Fatalf("ToTar failed: %v", err)
|
|
}
|
|
if len(tarball) == 0 {
|
|
t.Fatal("expected non-empty tarball")
|
|
}
|
|
|
|
// Verify tar content
|
|
tr := tar.NewReader(bytes.NewReader(tarball))
|
|
files := make(map[string]string)
|
|
for {
|
|
header, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("tar.Next failed: %v", err)
|
|
}
|
|
content, err := io.ReadAll(tr)
|
|
if err != nil {
|
|
t.Fatalf("read tar content failed: %v", err)
|
|
}
|
|
files[header.Name] = string(content)
|
|
}
|
|
|
|
if files["foo.txt"] != "foo" {
|
|
t.Errorf("expected foo.txt content 'foo', got %q", files["foo.txt"])
|
|
}
|
|
if files["bar/baz.txt"] != "baz" {
|
|
t.Errorf("expected bar/baz.txt content 'baz', got %q", files["bar/baz.txt"])
|
|
}
|
|
}
|
|
|
|
func TestFromTar_Good(t *testing.T) {
|
|
// Create a tarball
|
|
buf := new(bytes.Buffer)
|
|
tw := tar.NewWriter(buf)
|
|
|
|
files := []struct{ Name, Body string }{
|
|
{"foo.txt", "foo"},
|
|
{"bar/baz.txt", "baz"},
|
|
}
|
|
for _, file := range files {
|
|
hdr := &tar.Header{
|
|
Name: file.Name,
|
|
Mode: 0600,
|
|
Size: int64(len(file.Body)),
|
|
Typeflag: tar.TypeReg,
|
|
}
|
|
if err := tw.WriteHeader(hdr); err != nil {
|
|
t.Fatalf("WriteHeader failed: %v", err)
|
|
}
|
|
if _, err := tw.Write([]byte(file.Body)); err != nil {
|
|
t.Fatalf("Write failed: %v", err)
|
|
}
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
t.Fatalf("Close failed: %v", err)
|
|
}
|
|
|
|
dn, err := FromTar(buf.Bytes())
|
|
if err != nil {
|
|
t.Fatalf("FromTar failed: %v", err)
|
|
}
|
|
|
|
// Verify DataNode content
|
|
exists, _ := dn.Exists("foo.txt")
|
|
if !exists {
|
|
t.Error("foo.txt missing")
|
|
}
|
|
exists, _ = dn.Exists("bar/baz.txt")
|
|
if !exists {
|
|
t.Error("bar/baz.txt missing")
|
|
}
|
|
}
|
|
|
|
func TestTarRoundTrip_Good(t *testing.T) {
|
|
dn1 := New()
|
|
dn1.AddData("a.txt", []byte("a"))
|
|
dn1.AddData("b/c.txt", []byte("c"))
|
|
|
|
tarball, err := dn1.ToTar()
|
|
if err != nil {
|
|
t.Fatalf("ToTar failed: %v", err)
|
|
}
|
|
|
|
dn2, err := FromTar(tarball)
|
|
if err != nil {
|
|
t.Fatalf("FromTar failed: %v", err)
|
|
}
|
|
|
|
// Verify dn2 matches dn1
|
|
exists, _ := dn2.Exists("a.txt")
|
|
if !exists {
|
|
t.Error("a.txt missing in dn2")
|
|
}
|
|
exists, _ = dn2.Exists("b/c.txt")
|
|
if !exists {
|
|
t.Error("b/c.txt missing in dn2")
|
|
}
|
|
}
|
|
|
|
func TestMerge_Good(t *testing.T) {
|
|
dn1 := New()
|
|
dn1.AddData("a.txt", []byte("a"))
|
|
dn1.AddData("b/c.txt", []byte("c"))
|
|
|
|
dn2 := New()
|
|
dn2.AddData("d.txt", []byte("d"))
|
|
dn2.AddData("b/e.txt", []byte("e"))
|
|
|
|
dn1.Merge(dn2)
|
|
|
|
// Verify dn1 contains files from dn2
|
|
exists, _ := dn1.Exists("d.txt")
|
|
if !exists {
|
|
t.Error("d.txt missing after merge")
|
|
}
|
|
exists, _ = dn1.Exists("b/e.txt")
|
|
if !exists {
|
|
t.Error("b/e.txt missing after merge")
|
|
}
|
|
|
|
// Verify original files still exist
|
|
exists, _ = dn1.Exists("a.txt")
|
|
if !exists {
|
|
t.Error("a.txt missing after merge")
|
|
}
|
|
}
|
|
|
|
func TestMerge_Ugly(t *testing.T) {
|
|
// Test overwriting files
|
|
dn1 := New()
|
|
dn1.AddData("a.txt", []byte("original"))
|
|
|
|
dn2 := New()
|
|
dn2.AddData("a.txt", []byte("overwritten"))
|
|
|
|
dn1.Merge(dn2)
|
|
|
|
file, err := dn1.Open("a.txt")
|
|
if err != nil {
|
|
t.Fatalf("Open failed: %v", err)
|
|
}
|
|
content, err := io.ReadAll(file)
|
|
if err != nil {
|
|
t.Fatalf("ReadAll failed: %v", err)
|
|
}
|
|
|
|
if string(content) != "overwritten" {
|
|
t.Errorf("expected a.txt to be overwritten, got %q", string(content))
|
|
}
|
|
}
|
|
|
|
func TestFromTar_Bad(t *testing.T) {
|
|
// Pass invalid data (truncated header)
|
|
// A valid tar header is 512 bytes.
|
|
truncated := make([]byte, 100)
|
|
_, err := FromTar(truncated)
|
|
if err == nil {
|
|
t.Error("expected error for truncated tar header, got nil")
|
|
} else if err != io.EOF && err != io.ErrUnexpectedEOF {
|
|
// Verify it's some sort of read error or EOF related
|
|
// Depending on implementation details of archive/tar
|
|
}
|
|
}
|
|
|
|
func toSortedNames(entries []fs.DirEntry) []string {
|
|
var names []string
|
|
for _, e := range entries {
|
|
names = append(names, e.Name())
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|