Repository: knative-sandbox/kn-plugin-func
Pull request #475 (2021-08-12 03:46:48)

feat: repository and templates client api
# Changes

- :gift: Templates management api
- :gift: Repositories management api expansion

Prerequisites for #453 and #451

/kind enhancement

knative-prow-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/knative-sandbox/kn-plugin-func/pull/475#" title="Author self-approved">lkingland</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=knative-sandbox%2Fkn-plugin-func).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/knative-sandbox/kn-plugin-func/blob/main/OWNERS)~~ [lkingland]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
knative-prow-robot: @lkingland: PR needs rebase.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository.
</details>
knative-prow-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/knative-sandbox/kn-plugin-func/pull/475#pullrequestreview-733454156" title="Approved">lance</a>*, *<a href="https://github.com/knative-sandbox/kn-plugin-func/pull/475#" title="Author self-approved">lkingland</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=knative-sandbox%2Fkn-plugin-func).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/knative-sandbox/kn-plugin-func/blob/main/OWNERS)~~ [lance,lkingland]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
google-cla[bot]: All (the pull request submitter and all commit authors) CLAs are signed, **but** one or more commits were authored or co-authored by someone other than the pull request submitter.

We need to confirm that all authors are ok with their commits being contributed to this project.  Please have them confirm that by leaving a comment that contains only `@googlebot I consent.` in this pull request.

*Note to project maintainer:* There may be cases where the author cannot leave a comment, or the comment is not properly detected as consent.  In those cases, you can manually confirm consent of the commit author(s), and set the `cla` label to `yes` (if enabled on your project).

ℹ️ **Googlers: [Go here](https://goto.google.com/prinfo/https%3A%2F%2Fgithub.com%2Fknative-sandbox%2Fkn-plugin-func%2Fpull%2F475) for more info**.

<!-- need_author_consent -->
lkingland: Thank you very much @lance for the review!  This one is a bit of a read.
If it passes muster now, we'll just need a consent for the googlebot and an /lgtm label to merge
lance: > we'll just need a consent for the googlebot

￼I think this might happen when accepting suggestions in the GitHub UI. A rebase squashing the commit should fix it.
lance: @googlebot I consent.
lance: /lgtm

Patch:
diff --git a/Makefile b/Makefile
index 7f6527b6..f6565b29 100644
--- a/Makefile
+++ b/Makefile
@@ -53,7 +53,7 @@ $(BIN): $(CODE)
 	env CGO_ENABLED=0 go build -ldflags $(LDFLAGS) ./cmd/$(BIN)
 
 test: $(CODE) ## Run core unit tests
-	go test -race -cover -coverprofile=coverage.out -v ./...
+	go test -race -cover -coverprofile=coverage.out ./...
 
 check: bin/golangci-lint ## Check code quality (lint)
 	./bin/golangci-lint run --timeout 300s
diff --git a/client.go b/client.go
index be3ecc02..bca749e5 100644
--- a/client.go
+++ b/client.go
@@ -8,23 +8,35 @@ import (
 	"io/ioutil"
 	"os"
 	"path/filepath"
+	"sort"
+	"sync"
 
 	"gopkg.in/yaml.v2"
 )
 
 const (
+	// DefaultRegistry through which containers of Functions will be shuttled.
 	DefaultRegistry = "docker.io"
-	DefaultRuntime  = "node"
+
+	// DefaultRuntime is the runtime language for a new Function, including
+	// the template written and builder invoked on deploy.
+	DefaultRuntime = "node"
+
 	// DefautlTemplate is the default Function signature / environmental context
 	// of the resultant function.  All runtimes are expected to have at least
 	// one implementation of each supported funciton sinagure.  Currently that
 	// includes an HTTP Handler ("http") and Cloud Events handler ("events")
 	DefaultTemplate = "http"
+
+	// DefaultRepository is the name of the default (builtin) template repository,
+	// and is assumed when no template prefix is provided.
+	DefaultRepository = "default"
 )
 
 // Client for managing Function instances.
 type Client struct {
-	Repositories *Repositories // Repository management
+	Repositories *Repositories // Repositories management
+	Templates    *Templates    // Templates management
 
 	verbose          bool     // print verbose logs
 	builder          Builder  // Builds a runnable image from Function source
@@ -166,6 +178,7 @@ func New(options ...Option) *Client {
 	// Instantiate client with static defaults.
 	c := &Client{
 		Repositories:     &Repositories{},
+		Templates:        &Templates{},
 		builder:          &noopBuilder{output: os.Stdout},
 		pusher:           &noopPusher{output: os.Stdout},
 		deployer:         &noopDeployer{output: os.Stdout},
@@ -177,7 +190,13 @@ func New(options ...Option) *Client {
 		emitter:          &noopEmitter{},
 	}
 
-	// Apply passed options, which take ultimate precidence.
+	// TODO: Repositories default location ($XDG_CONFIG_HOME/func/repositories)
+	// will be relocated from CLI to here.
+	// c.Repositories.Path = ...
+
+	// Templates management requires the repositories management api
+	c.Templates.Repositories = c.Repositories
+
 	for _, o := range options {
 		o(c)
 	}
@@ -618,6 +637,45 @@ func (c *Client) Emit(ctx context.Context, endpoint string) error {
 	return c.emitter.Emit(ctx, endpoint)
 }
 
+// sorted set of strings.
+//
+// write-optimized and suitable only for fairly small values of N.
+// Should this increase dramatically in size, a different implementation,
+// such as a linked list, might be more appropriate.
+type sortedSet struct {
+	members map[string]bool
+	sync.Mutex
+}
+
+func newSortedSet() *sortedSet {
+	return &sortedSet{
+		members: make(map[string]bool),
+	}
+}
+
+func (s *sortedSet) Add(value string) {
+	s.Lock()
+	s.members[value] = true
+	s.Unlock()
+}
+
+func (s *sortedSet) Remove(value string) {
+	s.Lock()
+	delete(s.members, value)
+	s.Unlock()
+}
+
+func (s *sortedSet) Items() []string {
+	s.Lock()
+	defer s.Unlock()
+	n := []string{}
+	for k := range s.members {
+		n = append(n, k)
+	}
+	sort.Strings(n)
+	return n
+}
+
 // Manual implementations (noops) of required interfaces.
 // In practice, the user of this client package (for example the CLI) will
 // provide a concrete implementation for only the interfaces necessary to
@@ -630,36 +688,49 @@ func (c *Client) Emit(ctx context.Context, endpoint string) error {
 // with a minimum of external dependencies.
 // -----------------------------------------------------
 
+// Builder
 type noopBuilder struct{ output io.Writer }
 
 func (n *noopBuilder) Build(ctx context.Context, _ Function) error { return nil }
 
+// Pusher
 type noopPusher struct{ output io.Writer }
 
 func (n *noopPusher) Push(ctx context.Context, f Function) (string, error) { return "", nil }
 
+// Deployer
 type noopDeployer struct{ output io.Writer }
 
 func (n *noopDeployer) Deploy(ctx context.Context, _ Function) (DeploymentResult, error) {
 	return DeploymentResult{}, nil
 }
 
+// Runner
 type noopRunner struct{ output io.Writer }
 
 func (n *noopRunner) Run(_ context.Context, _ Function) error { return nil }
 
+// Remover
 type noopRemover struct{ output io.Writer }
 
 func (n *noopRemover) Remove(context.Context, string) error { return nil }
 
+// Lister
 type noopLister struct{ output io.Writer }
 
 func (n *noopLister) List(context.Context) ([]ListItem, error) { return []ListItem{}, nil }
 
+// Emitter
+type noopEmitter struct{}
+
+func (p *noopEmitter) Emit(ctx context.Context, endpoint string) error { return nil }
+
+// DNSProvider
 type noopDNSProvider struct{ output io.Writer }
 
 func (n *noopDNSProvider) Provide(_ Function) error { return nil }
 
+// ProgressListener
 type NoopProgressListener struct{}
 
 func (p *NoopProgressListener) SetTotal(i int)     {}
@@ -667,7 +738,3 @@ func (p *NoopProgressListener) Increment(m string) {}
 func (p *NoopProgressListener) Complete(m string)  {}
 func (p *NoopProgressListener) Stopping()          {}
 func (p *NoopProgressListener) Done()              {}
-
-type noopEmitter struct{}
-
-func (p *noopEmitter) Emit(ctx context.Context, endpoint string) error { return nil }
diff --git a/repositories.go b/repositories.go
index 1ae4fd08..f59404ca 100644
--- a/repositories.go
+++ b/repositories.go
@@ -10,10 +10,69 @@ import (
 	"github.com/go-git/go-git/v5"
 )
 
-// Repositories management.
+// Repositories manager
 type Repositories struct {
-	// Path to repositories
-	Path string
+	Path string // Path to repositories
+}
+
+// List all repositories installed at the defined root path plus builtin.
+func (r *Repositories) List() ([]string, error) {
+	repositories, err := r.All()
+	if err != nil {
+		return []string{}, err
+	}
+
+	names := []string{}
+	for _, repo := range repositories {
+		names = append(names, repo.Name)
+	}
+	return names, nil
+}
+
+// All repositories under management (at configured Path)
+func (r *Repositories) All() (repos []Repository, err error) {
+	repos = []Repository{}
+
+	// Single repo override
+	// TODO: Create single remote repository override for WithRepository option.
+
+	// Default (builtin) repo always first
+	builtin, err := NewRepositoryFromBuiltin()
+	if err != nil {
+		return
+	}
+	repos = append(repos, builtin)
+
+	// read repos from filesystem (sorted by name)
+	// TODO: when manifests are introduced, the final name may be different
+	// than the name on the filesystem, and as such we can not rely on the
+	// alphanumeric ordering of underlying list, and will instead have to sort
+	// by configured name.
+	ff, err := ioutil.ReadDir(r.Path)
+	if err != nil {
+		return
+	}
+	for _, f := range ff {
+		if !f.IsDir() || strings.HasPrefix(f.Name(), ".") {
+			continue
+		}
+		var repo Repository
+		repo, err = NewRepositoryFromPath(filepath.Join(r.Path, f.Name()))
+		if err != nil {
+			return
+		}
+		repos = append(repos, repo)
+	}
+	return repos, nil
+}
+
+// Get a repository by name, error if it does not exist.
+func (r *Repositories) Get(name string) (repo Repository, err error) {
+	if name == DefaultRepository {
+		return NewRepositoryFromBuiltin()
+	}
+	// TODO: when WithRepository defined, only it can be defined
+	return NewRepositoryFromPath(filepath.Join(r.Path, name))
 }
 
 // Add a repository of the given name from the URI.  Name, if not provided,
@@ -45,21 +104,6 @@ func (r *Repositories) Remove(name string) error {
 	return os.RemoveAll(path)
 }
 
-// List repositories installed at the defined root path.
-func (r *Repositories) List() (list []string, err error) {
-	list = []string{}
-	ff, err := ioutil.ReadDir(r.Path)
-	if err != nil {
-		return
-	}
-	for _, f := range ff {
-		if f.IsDir() && !strings.HasPrefix(f.Name(), ".") {
-			list = append(list, f.Name())
-		}
-	}
-	return
-}
-
 // repoNameFrom uri returns the last token with any .git suffix trimmed.
 // uri must be parseable as a net/URL
 func repoNameFrom(uri string) (name string, err error) {
diff --git a/repository.go b/repository.go
new file mode 100644
index 00000000..19e860a5
--- /dev/null
+++ b/repository.go
@@ -0,0 +1,123 @@
+package function
+
+import (
+	"errors"
+	"io/ioutil"
+	"path/filepath"
+	"strings"
+
+	"github.com/markbates/pkger"
+)
+
+// Path to builtin repositories.
+// note: this constant must be defined in the same file in which it is used due
+// to pkger performing static analysis on source files separately.
+const builtinRepositories = "/templates"
+
+// Repository
+type Repository struct {
+	Name      string
+	Templates []Template
+	Runtimes  []string
+}
+
+// NewRepository from path.
+// Represents the file structure of 'path' at time of construction as
+// a Repository with Templates, each of which has a Name and its Runtime.
+// a convenience member of Runtimes is the unique, sorted list of all
+// runtimes
+func NewRepositoryFromPath(path string) (Repository, error) {
+	// TODO: read and use manifest if it exists
+
+	r := Repository{
+		Name:      filepath.Base(path),
+		Templates: []Template{},
+		Runtimes:  []string{}}
+
+	// Each subdirectory is a Runtime
+	runtimes, err := ioutil.ReadDir(path)
+	if err != nil {
+		return r, err
+	}
+	for _, runtime := range runtimes {
+		if !runtime.IsDir() || strings.HasPrefix(runtime.Name(), ".") {
+			continue // ignore files and hidden
+		}
+		r.Runtimes = append(r.Runtimes, runtime.Name())
+
+		// Each subdirectory is a Template
+		templates, err := ioutil.ReadDir(filepath.Join(path, runtime.Name()))
+		if err != nil {
+			return r, err
+		}
+		for _, template := range templates {
+			if !template.IsDir() || strings.HasPrefix(template.Name(), ".") {
+				continue // ignore files and hidden
+			}
+			r.Templates = append(r.Templates, Template{
+				Runtime:    runtime.Name(),
+				Repository: r.Name,
+				Name:       template.Name()})
+		}
+	}
+	return r, nil
+}
+
+// NewRepository from builtin (encoded ./templates)
+func NewRepositoryFromBuiltin() (Repository, error) {
+	r := Repository{
+		Name:      DefaultRepository,
+		Templates: []Template{},
+		Runtimes:  []string{}}
+
+	// Read in runtimes
+	dir, err := pkger.Open(builtinRepositories)
+	if err != nil {
+		return r, err
+	}
+	runtimes, err := dir.Readdir(-1)
+	if err != nil {
+		return r, err
+	}
+	for _, runtime := range runtimes {
+		if !runtime.IsDir() || strings.HasPrefix(runtime.Name(), ".") {
+			continue // ignore from runtimes non-directory or hidden items
+		}
+		r.Runtimes = append(r.Runtimes, runtime.Name())
+
+		// Each subdirectory is a Template
+		templateDir, err := pkger.Open(filepath.Join(builtinRepositories, runtime.Name()))
+		if err != nil {
+			return r, err
+		}
+		templates, err := templateDir.Readdir(-1)
+		if err != nil {
+			return r, err
+		}
+		for _, template := range templates {
+			if !template.IsDir() || strings.HasPrefix(template.Name(), ".") {
+				continue // ignore from templates non-directory or hidden items
+			}
+			r.Templates = append(r.Templates, Template{
+				Runtime:    runtime.Name(),
+				Repository: r.Name,
+				Name:       template.Name(),
+			})
+
+		}
+	}
+	return r, nil
+}
+
+// GetTemplate from repo with given runtime
+func (r *Repository) GetTemplate(runtime, name string) (Template, error) {
+	// TODO: return a typed RuntimeNotFound in repo X
+	// rather than the generic Template Not Found
+	for _, t := range r.Templates {
+		if t.Runtime == runtime && t.Name == name {
+			return t, nil
+		}
+	}
+	// TODO: Typed TemplateNotFound in repo X
+	return Template{}, errors.New("template not found")
+}
diff --git a/templates.go b/templates.go
index b5ca2f30..e24c07e4 100644
--- a/templates.go
+++ b/templates.go
@@ -20,6 +20,128 @@ import (
 	"github.com/markbates/pkger"
 )
 
+// Path to builtin
+// note: this constant must be redefined in each file used due to pkger
+// performing static analysis on each source file separately.
+const builtinPath = "/templates"
+
+// Templates Manager
+type Templates struct {
+	Repositories *Repositories // Repository Manager
+}
+
+// Template metadata
+type Template struct {
+	Runtime    string
+	Repository string
+	Name       string
+}
+
+// Fullname is a caluclate field of [repo]/[name] used
+// to uniquely reference a template which may share a name
+// with one in another repository.
+func (t Template) Fullname() string {
+	return t.Repository + "/" + t.Name
+}
+
+// List the full name of templates available runtime.
+// Full name is the optional repository prefix plus the template's repository
+// local name.  Default templates grouped first sans prefix.
+func (t *Templates) List(runtime string) ([]string, error) {
+	// TODO: if repository override was enabled, we should just return those, flat.
+	builtin, err := t.ListDefault(runtime)
+	if err != nil {
+		return []string{}, err
+	}
+
+	extended, err := t.ListExtended(runtime)
+	if err != nil {
+		return []string{}, err
+	}
+
+	// Result is an alphanumerically sorted list first grouped by
+	// embedded at head.
+	return append(builtin, extended...), nil
+}
+
+// ListDefault (embedded) templates by runtime
+func (t *Templates) ListDefault(runtime string) ([]string, error) {
+	var (
+		names     = newSortedSet()
+		repo, err = t.Repositories.Get(DefaultRepository)
+	)
+	if err != nil {
+		return []string{}, err
+	}
+	for _, template := range repo.Templates {
+		if template.Runtime != runtime {
+			continue
+		}
+		names.Add(template.Name)
+	}
+	return names.Items(), nil
+}
+
+// ListExtended templates returns all template full names that
+// exist in all extended (config dir) repositories for a runtime.
+// Prefixed, sorted.
+func (t *Templates) ListExtended(runtime string) ([]string, error) {
+	var (
+		names      = newSortedSet()
+		repos, err = t.Repositories.All()
+	)
+	if err != nil {
+		return []string{}, err
+	}
+	for _, repo := range repos {
+		if repo.Name == DefaultRepository {
+			continue // already added at head of names
+		}
+		for _, template := range repo.Templates {
+			if template.Runtime != runtime {
+				continue
+			}
+			names.Add(template.Fullname())
+		}
+	}
+	return names.Items(), nil
+}
+
+// Template returns the named template in full form '[repo]/[name]' for the
+// specified runtime.
+// Templates from the default repository do not require the repo name prefix,
+// though it can be provided.
+func (t *Templates) Get(runtime, fullname string) (Template, error) {
+	var (
+		template Template
+		repoName string
+		tplName  string
+		repo     Repository
+		err      error
+	)
+
+	// Split into repo and template names.
+	// Defaults when unprefixed to DefaultRepository
+	cc := strings.Split(fullname, "/")
+	if len(cc) == 1 {
+		repoName = DefaultRepository
+		tplName = fullname
+	} else {
+		repoName = cc[0]
+		tplName = cc[1]
+	}
+
+	// Get specified repository
+	repo, err = t.Repositories.Get(repoName)
+	if err != nil {
+		return template, err
+	}
+
+	return repo.GetTemplate(runtime, tplName)
+}
+
+// Writing ------
+
 type filesystem interface {
 	Stat(name string) (os.FileInfo, error)
 	Open(path string) (file, error)
@@ -38,7 +160,7 @@ type file interface {
 // into pkged.go, which is then made available via a pkger filesystem.  Path is
 // relative to the go module root.
 func init() {
-	_ = pkger.Include("/templates")
+	_ = pkger.Include(builtinPath)
 }
 
 type templateWriter struct {
@@ -292,7 +414,7 @@ func (a pkgerFilesystem) ReadDir(path string) ([]os.FileInfo, error) {
 	if err != nil {
 		return nil, err
 	}
-	return f.Readdir(-1) // Really?  Pkger's ReadDir is Readdir.
+	return f.Readdir(-1)
 }
 
 // billyFilesystem is a template file accessor backed by a billy FS


Test patch:
diff --git a/client_test.go b/client_test.go
index 6917b1d7..9bb735f3 100644
--- a/client_test.go
+++ b/client_test.go
@@ -19,7 +19,7 @@ const (
 	// TestRegistry for calculating destination image during tests.
 	// Will be optional once we support in-cluster container registries
 	// by default.  See TestRegistryRequired for details.
-	TestRegistry = "quay.io/alice"
+	TestRegistry = "example.com/alice"
 
 	// TestRuntime consists of a specially designed templates directory
 	// used exclusively for tests.
@@ -31,65 +31,73 @@ const (
 // thus implicitly tests Create, Build and Deploy, which are exposed
 // by the client API for those who prefer manual transmissions.
 func TestNew(t *testing.T) {
-	root := "testdata/example.com/testCreate" // Root from which to run the test
+	root := "testdata/example.com/testNew"
 	defer using(t, root)()
 
-	// New Client
 	client := fn.New(fn.WithRegistry(TestRegistry))
 
-	// New Function using Client
 	if err := client.New(context.Background(), fn.Function{Root: root}); err != nil {
 		t.Fatal(err)
 	}
 }
 
-// TestTemplateWrites ensures a template is written.
-func TestTemplateWrites(t *testing.T) {
-	root := "testdata/example.com/testCreateWrites"
+// TestWritesTemplate ensures the config file and files from the template
+// are written on new.
+func TestWritesTemplate(t *testing.T) {
+	root := "testdata/example.com/testWritesTemplate"
 	defer using(t, root)()
 
 	client := fn.New(fn.WithRegistry(TestRegistry))
-	if err := client.Create(fn.Function{Root: root}); err != nil {
+
+	if err := client.New(context.Background(), fn.Function{Root: root}); err != nil {
 		t.Fatal(err)
 	}
 
-	// Assert file was written
+	// Assert the standard config file was written
 	if _, err := os.Stat(filepath.Join(root, fn.ConfigFile)); os.IsNotExist(err) {
 		t.Fatalf("Initialize did not result in '%v' being written to '%v'", fn.ConfigFile, root)
 	}
+
+	// Assert a file from the template was written
+	if _, err := os.Stat(filepath.Join(root, "README.md")); os.IsNotExist(err) {
+		t.Fatalf("Initialize did not result in '%v' being written to '%v'", fn.ConfigFile, root)
+	}
 }
 
 // TestExtantAborts ensures that a directory which contains an extant
-// Function does not reinitialize
+// Function does not reinitialize.
 func TestExtantAborts(t *testing.T) {
-	root := "testdata/example.com/testCreateInitializedAborts"
+	root := "testdata/example.com/testExtantAborts"
 	defer using(t, root)()
 
-	// New once
 	client := fn.New(fn.WithRegistry(TestRegistry))
+
+	// First .New should succeed...
 	if err := client.New(context.Background(), fn.Function{Root: root}); err != nil {
 		t.Fatal(err)
 	}
 
-	// New again should fail as already initialized
+	// Calling again should abort...
 	if err := client.New(context.Background(), fn.Function{Root: root}); err == nil {
 		t.Fatal("error expected initilizing a path already containing an initialized Function")
 	}
 }
 
-// TestNonemptyDirectoryAborts ensures that a directory which contains any
-// visible files aborts.
-func TestNonemptyDirectoryAborts(t *testing.T) {
-	root := "testdata/example.com/testCreateNonemptyDirectoryAborts"
+// TestNonemptyAborts ensures that a directory which contains any
+// (visible) files aborts.
+func TestNonemptyAborts(t *testing.T) {
+	root := "testdata/example.com/testNonemptyAborts"
 	defer using(t, root)()
 
-	// An unexpected, non-hidden file.
+	client := fn.New(fn.WithRegistry(TestRegistry))
+
+	// Write a visible file which should cause an aboert
 	visibleFile := filepath.Join(root, "file.txt")
 	if err := ioutil.WriteFile(visibleFile, []byte{}, 0644); err != nil {
 		t.Fatal(err)
 	}
 
-	client := fn.New(fn.WithRegistry(TestRegistry))
+	// Ceation should abort due to the visible file
 	if err := client.New(context.Background(), fn.Function{Root: root}); err == nil {
 		t.Fatal("error expected initilizing a Function in a nonempty directory")
 	}
@@ -102,16 +110,18 @@ func TestNonemptyDirectoryAborts(t *testing.T) {
 // conjunction with other tools (.envrc, etc)
 func TestHiddenFilesIgnored(t *testing.T) {
 	// Create a directory for the Function
-	root := "testdata/example.com/testCreateHiddenFilesIgnored"
+	root := "testdata/example.com/testHiddenFilesIgnored"
 	defer using(t, root)()
 
+	client := fn.New(fn.WithRegistry(TestRegistry))
+
 	// Create a hidden file that should be ignored.
 	hiddenFile := filepath.Join(root, ".envrc")
 	if err := ioutil.WriteFile(hiddenFile, []byte{}, 0644); err != nil {
 		t.Fatal(err)
 	}
 
-	client := fn.New(fn.WithRegistry(TestRegistry))
+	// Should succeed without error, ignoring the hidden file.
 	if err := client.New(context.Background(), fn.Function{Root: root}); err != nil {
 		t.Fatal(err)
 	}
@@ -121,11 +131,12 @@ func TestHiddenFilesIgnored(t *testing.T) {
 // Functions and persisted.
 func TestDefaultRuntime(t *testing.T) {
 	// Create a root for the new Function
-	root := "testdata/example.com/testCreateDefaultRuntime"
+	root := "testdata/example.com/testDefaultRuntime"
 	defer using(t, root)()
 
-	// Create a new function at root with all defaults.
 	client := fn.New(fn.WithRegistry(TestRegistry))
+
+	// Create a new function at root with all defaults.
 	if err := client.New(context.Background(), fn.Function{Root: root}); err != nil {
 		t.Fatal(err)
 	}
@@ -142,7 +153,7 @@ func TestDefaultRuntime(t *testing.T) {
 	}
 }
 
-// TestExtensibleRepositories ensures that templates are extensible
+// TestRepositoriesExtensible ensures that templates are extensible
 // using a custom path to template repositories on disk. The custom repositories
 // location is not defined herein but expected to be provided because, for
 // example, a CLI may want to use XDG_CONFIG_HOME.  Assuming a repository path
@@ -151,12 +162,10 @@ func TestDefaultRuntime(t *testing.T) {
 // $FUNC_REPOSITORIES/boson/go/json
 // See the CLI for full details, but a standard default location is
 // $HOME/.config/func/repositories/boson/go/json
-func TestExtensibleRepositories(t *testing.T) {
-	// Create a directory for the new Function
-	root := "testdata/example.com/testExtensibleRepositories"
+func TestRepositoriesExtensible(t *testing.T) {
+	root := "testdata/example.com/testRepositoriesExtensible"
 	defer using(t, root)()
 
-	// Create a new client with a path to the extensible templates
 	client := fn.New(
 		fn.WithRepositories("testdata/repositories"),
 		fn.WithRegistry(TestRegistry))
@@ -176,7 +185,6 @@ func TestExtensibleRepositories(t *testing.T) {
 
 // TestRuntimeNotFound generates an error (embedded default repository).
 func TestRuntimeNotFound(t *testing.T) {
-	// Create a directory for the Function
 	root := "testdata/example.com/testRuntimeNotFound"
 	defer using(t, root)()
 
@@ -184,8 +192,7 @@ func TestRuntimeNotFound(t *testing.T) {
 
 	// creating a Function with an unsupported runtime should bubble
 	// the error generated by the underlying template initializer.
-	f := fn.Function{Root: root, Runtime: "invalid"}
-	err := client.New(context.Background(), f)
+	err := client.New(context.Background(), fn.Function{Root: root, Runtime: "invalid"})
 	if !errors.Is(err, fn.ErrRuntimeNotFound) {
 		t.Fatalf("Expected ErrRuntimeNotFound, got %T", err)
 	}
@@ -258,7 +265,7 @@ func TestNamed(t *testing.T) {
 
 	// Path which would derive to testWithHame.example.com were it not for the
 	// explicitly provided name.
-	root := "testdata/example.com/testWithName"
+	root := "testdata/example.com/testNamed"
 	defer using(t, root)()
 
 	client := fn.New(fn.WithRegistry(TestRegistry))
@@ -289,7 +296,7 @@ func TestNamed(t *testing.T) {
 // this configuration parameter will become optional.
 func TestRegistryRequired(t *testing.T) {
 	// Create a root for the Function
-	root := "testdata/example.com/testRegistry"
+	root := "testdata/example.com/testRegistryRequired"
 	defer using(t, root)()
 
 	client := fn.New()
@@ -361,9 +368,9 @@ func TestDeriveImageDefaultRegistry(t *testing.T) {
 // Deploy (and confirms expected fields calculated).
 func TestNewDelegates(t *testing.T) {
 	var (
-		root          = "testdata/example.com/testCreateDelegates" // .. in which to initialize
-		expectedName  = "testCreateDelegates"                      // expected to be derived
-		expectedImage = "quay.io/alice/testCreateDelegates:latest"
+		root          = "testdata/example.com/testNewDelegates" // .. in which to initialize
+		expectedName  = "testNewDelegates"                      // expected to be derived
+		expectedImage = "example.com/alice/testNewDelegates:latest"
 		builder       = mock.NewBuilder()
 		pusher        = mock.NewPusher()
 		deployer      = mock.NewDeployer()
@@ -471,7 +478,7 @@ func TestUpdate(t *testing.T) {
 	var (
 		root          = "testdata/example.com/testUpdate"
 		expectedName  = "testUpdate"
-		expectedImage = "quay.io/alice/testUpdate:latest"
+		expectedImage = "example.com/alice/testUpdate:latest"
 		builder       = mock.NewBuilder()
 		pusher        = mock.NewPusher()
 		deployer      = mock.NewDeployer()
@@ -581,7 +588,7 @@ func TestRemoveByPath(t *testing.T) {
 // of the name provided, with precidence over a provided root path.
 func TestRemoveByName(t *testing.T) {
 	var (
-		root         = "testdata/example.com/testRemoveByPath"
+		root         = "testdata/example.com/testRemoveByName"
 		expectedName = "explicitName.example.com"
 		remover      = mock.NewRemover()
 	)
@@ -683,7 +690,7 @@ func TestListOutsideRoot(t *testing.T) {
 // fully created (ie. was only initialized, not actually built and deploys)
 // yields an expected, and informative, error.
 func TestDeployUnbuilt(t *testing.T) {
-	root := "testdata/example.com/testDeploy" // Root from which to run the test
+	root := "testdata/example.com/testDeployUnbuilt" // Root from which to run the test
 	defer using(t, root)()
 
 	// New Client
@@ -731,8 +738,7 @@ func TestEmit(t *testing.T) {
 
 // Helpers ----
 
-// using the given directory (creating it) returns a closure which removes the
-// directory, intended to be run in a defer statement.
+// USING:  Make specified dir.  Return deferrable cleanup fn.
 func using(t *testing.T, root string) func() {
 	t.Helper()
 	mkdir(t, root)
@@ -754,3 +760,51 @@ func rm(t *testing.T, dir string) {
 		t.Fatal(err)
 	}
 }
+
+// MKTEMP:  Create and CD to a temp dir.
+//          Returns a deferrable cleanup fn.
+func mktemp(t *testing.T) (string, func()) {
+	t.Helper()
+	tmp := tempdir(t)
+	owd := pwd(t)
+	cd(t, tmp)
+	return tmp, func() {
+		os.RemoveAll(tmp)
+		cd(t, owd)
+	}
+}
+
+func tempdir(t *testing.T) string {
+	d, err := ioutil.TempDir("", "dir")
+	if err != nil {
+		t.Fatal(err)
+	}
+	return d
+}
+
+func pwd(t *testing.T) string {
+	d, err := os.Getwd()
+	if err != nil {
+		t.Fatal(err)
+	}
+	return d
+}
+
+func cd(t *testing.T, dir string) {
+	if err := os.Chdir(dir); err != nil {
+		t.Fatal(err)
+	}
+}
+
+// TEST REPO URI:  Return URI to repo in ./testdata of matching name.
+// Suitable as URI for repository override. returns in form file://
+// Must be called prior to mktemp in tests which changes current
+// working directory as it depends on a relative path.
+// Repo uri:  file://$(pwd)/testdata/repository.git (unix-like)
+//            file: //$(pwd)\testdata\repository.git (windows)
+func testRepoURI(name string, t *testing.T) string {
+	t.Helper()
+	cwd, _ := os.Getwd()
+	repo := filepath.Join(cwd, "testdata", name+".git")
+	return fmt.Sprintf(`file://%s`, repo)
+}
diff --git a/repositories_test.go b/repositories_test.go
index 816bc26a..7846c99b 100644
--- a/repositories_test.go
+++ b/repositories_test.go
@@ -3,7 +3,6 @@
 package function_test
 
 import (
-	"io/ioutil"
 	"os"
 	"path/filepath"
 	"testing"
@@ -13,51 +12,110 @@ import (
 
 const RepositoriesTestRepo = "repository-a"
 
-// TestRepositoriesList ensures the base case of an empty list
-// when no repositories are installed.
+// TestRepositoriesList ensures the base case of listing
+// repositories without error in the default scenario of builtin only.
 func TestRepositoriesList(t *testing.T) {
 	root, rm := mktemp(t)
 	defer rm()
 
-	client := fn.New(fn.WithRepositories(root))
+	client := fn.New(fn.WithRepositories(root)) // Explicitly empty
 
 	rr, err := client.Repositories.List()
 	if err != nil {
 		t.Fatal(err)
 	}
-	if len(rr) != 0 {
-		t.Fatalf("Expected an empty repositories list, got %v", len(rr))
+	// Assert contains only the default repo
+	if len(rr) != 1 && rr[0] != fn.DefaultRepository {
+		t.Fatalf("Expected repository list '[%v]', got %v", fn.DefaultRepository, rr)
+	}
+}
+
+// TestRepositoriesGet ensures a repository can be accessed by name.
+func TestRepositoriesGet(t *testing.T) {
+	client := fn.New(fn.WithRepositories("testdata/repositories"))
+
+	// invalid should error
+	repo, err := client.Repositories.Get("invalid")
+	if err == nil {
+		t.Fatal("did not receive expected error getting inavlid repository")
+	}
+
+	// valid should not error
+	repo, err = client.Repositories.Get("customProvider")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// valid should have expected name
+	if repo.Name != "customProvider" {
+		t.Fatalf("expected 'customProvider' as repository name, got: %v", repo.Name)
+	}
+}
+
+// TestRepositoriesAll ensures builtin and extended repos are returned from
+// .All accessor.
+func TestRepositoriesAll(t *testing.T) {
+	uri := testRepoURI(RepositoriesTestRepo, t)
+	root, rm := mktemp(t)
+	defer rm()
+
+	client := fn.New(fn.WithRepositories(root))
+
+	// Assert initially only the default is included
+	rr, err := client.Repositories.All()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(rr) != 1 && rr[0].Name != fn.DefaultRepository {
+		t.Fatalf("Expected initial repo list to be only the default.  Got %v", rr)
+	}
+
+	// Add one
+	err = client.Repositories.Add("", uri)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Get full list
+	repositories, err := client.Repositories.All()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Assert it now includes both builtin and extended
+	if len(repositories) != 2 ||
+		repositories[0].Name != fn.DefaultRepository ||
+		repositories[1].Name != RepositoriesTestRepo {
+		t.Fatal("Repositories list does not pass shallow repository membership check")
 	}
 }
 
 // TestRepositoriesAdd ensures that adding a repository adds it to the FS
 // and List output.  Uses default name (repo name).
 func TestRepositoriesAdd(t *testing.T) {
-	uri := testRepoURI(t) // ./testdata/$RepositoriesTestRepo.git
-	root, rm := mktemp(t) // create and cd to a temp dir
+	uri := testRepoURI(RepositoriesTestRepo, t) // ./testdata/$RepositoriesTestRepo.git
+	root, rm := mktemp(t)                       // create and cd to a temp dir
 	defer rm()
 
-	// Instantiate the client using the current temp directory as the
-	// repositories' root location.
 	client := fn.New(fn.WithRepositories(root))
 
-	// Create a new repository without a name (use default of repo name)
+	// Add repo at uri
 	if err := client.Repositories.Add("", uri); err != nil {
 		t.Fatal(err)
 	}
 
-	// assert list len 1
+	// Assert list now includes the test repo
 	rr, err := client.Repositories.List()
 	if err != nil {
 		t.Fatal(err)
 	}
-	if len(rr) != 1 || rr[0] != RepositoriesTestRepo {
+	if len(rr) != 2 || rr[1] != RepositoriesTestRepo {
 		t.Fatalf("Expected '%v', got %v", RepositoriesTestRepo, rr)
 	}
 
 	// assert expected name
-	if rr[0] != RepositoriesTestRepo {
-		t.Fatalf("Expected name '%v', got %v", RepositoriesTestRepo, rr[0])
+	if rr[1] != RepositoriesTestRepo {
+		t.Fatalf("Expected name '%v', got %v", RepositoriesTestRepo, rr[1])
 	}
 
 	// assert repo was checked out
@@ -72,8 +130,8 @@ func TestRepositoriesAdd(t *testing.T) {
 // TestRepositoriesAddNamed ensures that adding a repository with a specified
 // name takes precidence over the default of repo name.
 func TestRepositoriesAddNamed(t *testing.T) {
-	uri := testRepoURI(t) // ./testdata/$RepositoriesTestRepo.git
-	root, rm := mktemp(t) // create and cd to a temp dir, returning path.
+	uri := testRepoURI(RepositoriesTestRepo, t) // ./testdata/$RepositoriesTestRepo.git
+	root, rm := mktemp(t)                       // create and cd to a temp dir, returning path.
 	defer rm()
 
 	// Instantiate the client using the current temp directory as the
@@ -89,7 +147,7 @@ func TestRepositoriesAddNamed(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	if len(rr) != 1 || rr[0] != name {
+	if len(rr) != 2 || rr[1] != name {
 		t.Fatalf("Expected '%v', got %v", name, rr)
 	}
 
@@ -102,7 +160,7 @@ func TestRepositoriesAddNamed(t *testing.T) {
 // TestRepositoriesAddExistingErrors ensures that adding a repository that
 // already exists yields an error.
 func TestRepositoriesAddExistingErrors(t *testing.T) {
-	uri := testRepoURI(t)
+	uri := testRepoURI(RepositoriesTestRepo, t)
 	root, rm := mktemp(t) // create and cd to a temp dir, returning path.
 	defer rm()
 
@@ -124,7 +182,7 @@ func TestRepositoriesAddExistingErrors(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	if len(rr) != 1 || rr[0] != name {
+	if len(rr) != 2 || rr[1] != name {
 		t.Fatalf("Expected '[%v]', got %v", name, rr)
 	}
 
@@ -136,7 +194,7 @@ func TestRepositoriesAddExistingErrors(t *testing.T) {
 
 // TestRepositoriesRename ensures renaming a repository.
 func TestRepositoriesRename(t *testing.T) {
-	uri := testRepoURI(t)
+	uri := testRepoURI(RepositoriesTestRepo, t)
 	root, rm := mktemp(t) // create and cd to a temp dir, returning path.
 	defer rm()
 
@@ -157,7 +215,7 @@ func TestRepositoriesRename(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	if len(rr) != 1 || rr[0] != "bar" {
+	if len(rr) != 2 || rr[1] != "bar" {
 		t.Fatalf("Expected '[bar]', got %v", rr)
 	}
 
@@ -170,8 +228,8 @@ func TestRepositoriesRename(t *testing.T) {
 // TestRepositoriesRemove ensures that removing a repository by name
 // removes it from the list and FS.
 func TestRepositoriesRemove(t *testing.T) {
-	uri := testRepoURI(t) // ./testdata/repository.git
-	root, rm := mktemp(t) // create and cd to a temp dir, returning path.
+	uri := testRepoURI(RepositoriesTestRepo, t) // ./testdata/repository.git
+	root, rm := mktemp(t)                       // create and cd to a temp dir, returning path.
 	defer rm()
 
 	// Instantiate the client using the current temp directory as the
@@ -192,8 +250,8 @@ func TestRepositoriesRemove(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	if len(rr) != 0 {
-		t.Fatalf("Expected empty repo list upon remove.  Got %v", rr)
+	if len(rr) != 1 {
+		t.Fatalf("Expected repo list of len 1.  Got %v", rr)
 	}
 
 	// assert repo not on filesystem
@@ -201,52 +259,3 @@ func TestRepositoriesRemove(t *testing.T) {
 		t.Fatalf("Repo %v still exists on filesystem.", name)
 	}
 }
-
-// Helpers ---
-
-// testRepoURI returns a file:// URI to a test repository in
-// testdata.  Must be called prior to mktemp in tests which changes current
-// working directory.
-// Repo uri:  file://$(pwd)/testdata/repository.git (unix-like)
-//            file: //$(pwd)\testdata\repository.git (windows)
-func testRepoURI(t *testing.T) string {
-	t.Helper()
-	cwd, _ := os.Getwd()
-	repo := filepath.Join(cwd, "testdata", RepositoriesTestRepo+".git")
-	return "file://" + filepath.ToSlash(repo)
-}
-
-// mktemp creates a temp dir, returning its path
-// and a function which will remove it.
-func mktemp(t *testing.T) (string, func()) {
-	t.Helper()
-	tmp := mktmp(t)
-	owd := pwd(t)
-	cd(t, tmp)
-	return tmp, func() {
-		os.RemoveAll(tmp)
-		cd(t, owd)
-	}
-}
-
-func mktmp(t *testing.T) string {
-	d, err := ioutil.TempDir("", "dir")
-	if err != nil {
-		t.Fatal(err)
-	}
-	return d
-}
-
-func pwd(t *testing.T) string {
-	d, err := os.Getwd()
-	if err != nil {
-		t.Fatal(err)
-	}
-	return d
-}
-
-func cd(t *testing.T, dir string) {
-	if err := os.Chdir(dir); err != nil {
-		t.Fatal(err)
-	}
-}
diff --git a/repository_test.go b/repository_test.go
new file mode 100644
index 00000000..f675f0c1
--- /dev/null
+++ b/repository_test.go
@@ -0,0 +1,59 @@
+package function_test
+
+import (
+	"reflect"
+	"testing"
+
+	fn "knative.dev/kn-plugin-func"
+)
+
+// TestRepositoryGetTemplateDefault ensures that repositories make templates
+// avaialble via the Get accessor which given name and runtime.
+func TestRepositoryGetTemplateDefault(t *testing.T) {
+	client := fn.New()
+
+	repo, err := client.Repositories.Get(fn.DefaultRepository)
+	if err != nil {
+		t.Fatal(err)
+	}
+	template, err := repo.GetTemplate("go", "http")
+	if err != nil {
+		t.Fatal(err)
+	}
+	expected := fn.Template{
+		Runtime:    "go",
+		Repository: fn.DefaultRepository,
+		Name:       "http",
+	}
+	if !reflect.DeepEqual(template, expected) {
+		t.Logf("expected: %v", expected)
+		t.Logf("received: %v", template)
+		t.Fatal("Default template not as expected")
+	}
+}
+
+// TestRepositoryGetTemplateCustom ensures that repositories make templates
+// avaialble via the Get accessor with given name and runtime.
+func TestRepositoryGetTemplateCustom(t *testing.T) {
+	client := fn.New(fn.WithRepositories("testdata/repositories"))
+
+	repo, err := client.Repositories.Get("repositoryTests")
+	if err != nil {
+		t.Fatal(err)
+	}
+	template, err := repo.GetTemplate("go", "custom")
+	if err != nil {
+		t.Fatal(err)
+	}
+	expected := fn.Template{
+		Runtime:    "go",
+		Repository: "repositoryTests",
+		Name:       "custom",
+	}
+	if !reflect.DeepEqual(template, expected) {
+		t.Logf("expected: %v", expected)
+		t.Logf("received: %v", template)
+		t.Fatal("Custom template not as expected")
+	}
+
+}
diff --git a/templates_test.go b/templates_test.go
index 02bbca5d..193d51d7 100644
--- a/templates_test.go
+++ b/templates_test.go
@@ -7,13 +7,89 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"reflect"
 	"runtime"
 	"testing"
 
 	fn "knative.dev/kn-plugin-func"
 )
 
-// TestTemplateEmbedded ensures that embedded templates are copied.
+// TestTemplatesList ensures that all templates are listed taking into account
+// both internal and extensible (prefixed) repositories.
+func TestTemplatesList(t *testing.T) {
+	// A client which specifies a location of exensible repositoreis on disk
+	// will list all builtin plus exensible
+	client := fn.New(fn.WithRepositories("testdata/repositories"))
+
+	// list templates for the "go" runtime
+	templates, err := client.Templates.List("go")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Note that this list will change as the customProvider
+	// and builtin templates are shared.  THis could be mitigated
+	// by creating a custom repository path for just this test, if
+	// that becomes a hassle.
+	expected := []string{
+		"events",
+		"http",
+		"customProvider/customTemplate",
+		"repositoryTests/custom",
+	}
+
+	if !reflect.DeepEqual(templates, expected) {
+		t.Logf("expected: %v", expected)
+		t.Logf("received: %v", templates)
+		t.Fatal("Expected templates list not received.")
+	}
+}
+
+// TestTemplatesGet ensures that a template's metadata object can
+// be retrieved by full name (full name prefix optional for embedded).
+func TestTemplatesGet(t *testing.T) {
+	client := fn.New(fn.WithRepositories("testdata/repositories"))
+
+	// Check embedded
+
+	embedded, err := client.Templates.Get("go", "http")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	expected := fn.Template{
+		Runtime:    "go",
+		Repository: "default",
+		Name:       "http",
+	}
+
+	if !reflect.DeepEqual(embedded, expected) {
+		t.Logf("expected: %v", expected)
+		t.Logf("received: %v", embedded)
+		t.Fatal("Template from embedded repo not as expected.")
+	}
+
+	// Check extended
+
+	extended, err := client.Templates.Get("go", "customProvider/customTemplate")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	expected = fn.Template{
+		Runtime:    "go",
+		Repository: "customProvider",
+		Name:       "customTemplate",
+	}
+
+	if !reflect.DeepEqual(extended, expected) {
+		t.Logf("expected: %v", expected)
+		t.Logf("received: %v", extended)
+		t.Fatal("Template from extended repo not as expected.")
+	}
+}
+
+// TestTemplateEmbedded ensures that embedded templates are copied on write.
 func TestTemplateEmbedded(t *testing.T) {
 	// create test directory
 	root := "testdata/testTemplateEmbedded"