← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~twom/launchpad:git-branch-picker into launchpad:master

 

Tom Wardill has proposed merging ~twom/launchpad:git-branch-picker into launchpad:master.

Commit message:
Create a vocabulary for GitRef, and use it in a huge-vocabulary call to allow for JS based autocomplete on ref names in the edit snap page.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~twom/launchpad/+git/launchpad/+merge/392831
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~twom/launchpad:git-branch-picker into launchpad:master.
diff --git a/lib/lp/app/javascript/autocomplete.js b/lib/lp/app/javascript/autocomplete.js
new file mode 100644
index 0000000..25627ed
--- /dev/null
+++ b/lib/lp/app/javascript/autocomplete.js
@@ -0,0 +1,62 @@
+/* Copyright 2017 Canonical Ltd.  This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ */
+
+YUI.add('lp.app.autocomplete', function(Y) {
+
+var namespace = Y.namespace('lp.app.autocomplete');
+
+namespace.setupVocabAutocomplete = function(config, node) {
+    var qs = 'name=' + encodeURIComponent(config.vocabulary_name);
+
+    /* autocomplete will substitute these with appropriate encoding. */
+    /* XXX cjwatson 2017-07-24: Perhaps we should pass batch={maxResults}
+     * too, but we need to make sure that it doesn't exceed max_batch_size.
+     */
+    qs += '&search_text={query}';
+
+    var uri = '';
+    if (Y.Lang.isFunction(config.getContextPath)) {
+        var context_path = config.getContextPath();
+        if (context_path !== null) {
+            uri += config.getContextPath() + '/';
+        } else {
+            // No context, so we cannot autocomplete.
+            return;
+        }
+    } else if (Y.Lang.isValue(config.context)) {
+        uri += Y.lp.get_url_path(
+            config.context.get('web_link')) + '/';
+    }
+    uri += '@@+huge-vocabulary';
+
+
+    node.plug(Y.Plugin.AutoComplete, {
+        queryDelay: 500,  // milliseconds
+        requestTemplate: '?' + qs,
+        resultHighlighter: 'wordMatch',
+        resultListLocator: 'entries',
+        resultTextLocator: 'value',
+        source: uri
+    });
+};
+
+/**
+ * Add autocompletion to a text field.
+ * @param {Object} config Object literal of config name/value pairs.
+ *     config.vocabulary_name: the named vocabulary to select from.
+ *     config.input_element: the id of the text field to update with the
+ *                           selected value.
+ */
+namespace.addAutocomplete = function(config) {
+    var input_element = Y.one('[id="' + config.input_element + '"]');
+    // The node may already have been processed.
+    if (input_element.ac) {
+        return;
+    }
+    namespace.setupVocabAutocomplete(config, input_element);
+};
+
+}, '0.1', {'requires': [
+    'autocomplete', 'autocomplete-sources', 'datasource', 'lp'
+]});
diff --git a/lib/lp/app/javascript/picker/picker.js b/lib/lp/app/javascript/picker/picker.js
index e97b517..ef1e812 100644
--- a/lib/lp/app/javascript/picker/picker.js
+++ b/lib/lp/app/javascript/picker/picker.js
@@ -24,7 +24,7 @@ ns.Picker = Y.Base.create('picker', Y.lp.ui.PrettyOverlay, [], {
     /**
      * The search input node.
      *
-     * @property _search_button
+     * @property _search_input
      * @type Node
      * @private
      */
diff --git a/lib/lp/app/javascript/picker/picker_patcher.js b/lib/lp/app/javascript/picker/picker_patcher.js
index e220aa8..895b5b7 100644
--- a/lib/lp/app/javascript/picker/picker_patcher.js
+++ b/lib/lp/app/javascript/picker/picker_patcher.js
@@ -50,7 +50,7 @@ var _addPicker = function(config, show_widget_id) {
  * @param {Object} config Object literal of config name/value pairs. The
  *                        values listed below are common for all picker types.
  *     config.vocabulary_name: the named vocabulary to select from.
- *     config.vocabulary_filters: any vocaulary filters to use.
+ *     config.vocabulary_filters: any vocabulary filters to use.
  *     config.input_element: the id of the text field to update with the
  *                           selected value.
  *     config.picker_type: the type of picker to create (default or person).
@@ -619,23 +619,35 @@ namespace.setup_vocab_picker = function (picker, vocabulary, config) {
             // use the context to limit the results to the same project.
 
             var uri = '';
+            var context_ok = true;
             if (Y.Lang.isFunction(config.getContextPath)) {
-                uri += config.getContextPath() + '/';
+                var context_path = config.getContextPath();
+                if (context_path !== null) {
+                    uri += config.getContextPath() + '/';
+                } else {
+                    // No context, so proceed straight to validation.
+                    picker.set('error', e);
+                    picker.set('search_mode', false);
+                    picker.fire('validate', search_text);
+                    context_ok = false;
+                }
             } else if (Y.Lang.isValue(config.context)) {
                 uri += Y.lp.get_url_path(
                     config.context.get('web_link')) + '/';
             }
             uri += '@@+huge-vocabulary?' + qs;
 
-            var yio = (config.yio !== undefined) ? config.yio : Y;
-            yio.io(uri, {
-                headers: {'Accept': 'application/json'},
-                timeout: 20000,
-                on: {
-                    success: success_handler,
-                    failure: failure_handler
-                }
-            });
+            if (context_ok) {
+                var yio = (config.yio !== undefined) ? config.yio : Y;
+                yio.io(uri, {
+                    headers: {'Accept': 'application/json'},
+                    timeout: 20000,
+                    on: {
+                        success: success_handler,
+                        failure: failure_handler
+                    }
+                });
+            }
         // Or we can pass in a vocabulary directly.
         } else {
             display_vocabulary(vocabulary, Y.Object.size(vocabulary), 1);
diff --git a/lib/lp/app/widgets/templates/form-picker-macros.pt b/lib/lp/app/widgets/templates/form-picker-macros.pt
index 80f5be1..9710e52 100644
--- a/lib/lp/app/widgets/templates/form-picker-macros.pt
+++ b/lib/lp/app/widgets/templates/form-picker-macros.pt
@@ -33,9 +33,7 @@
     LPJS.use('node', 'lp.app.picker', function(Y) {
         var config = ${view/json_config};
         var show_widget_id = '${view/show_widget_id}';
-        Y.on('domready', function(e) {
-            Y.lp.app.picker.addPicker(config, show_widget_id);
-        });
+        Y.lp.app.picker.addPicker(config, show_widget_id);
     });
     "/>
   </metal:form-picker>
diff --git a/lib/lp/code/adapters/gitrepository.py b/lib/lp/code/adapters/gitrepository.py
index e55d5bb..653b03e 100644
--- a/lib/lp/code/adapters/gitrepository.py
+++ b/lib/lp/code/adapters/gitrepository.py
@@ -1,14 +1,16 @@
 # Copyright 2015-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
-"""Components related to Git repositories."""
+"""Components and adapters related to Git repositories."""
 
 __metaclass__ = type
 __all__ = [
     "GitRepositoryDelta",
+    "git_repository_for_snap",
     ]
 
 from lazr.lifecycle.objectdelta import ObjectDelta
+from zope.component.interfaces import ComponentLookupError
 from zope.interface import implementer
 
 from lp.code.interfaces.gitrepository import (
@@ -56,3 +58,10 @@ class GitRepositoryDelta:
             return GitRepositoryDelta(**changes)
         else:
             return None
+
+
+def git_repository_for_snap(snap):
+    """Adapt a snap package to a Git repository."""
+    if snap.git_repository is None:
+        raise ComponentLookupError
+    return snap.git_repository
diff --git a/lib/lp/code/browser/widgets/configure.zcml b/lib/lp/code/browser/widgets/configure.zcml
index 913d8c6..e056e42 100644
--- a/lib/lp/code/browser/widgets/configure.zcml
+++ b/lib/lp/code/browser/widgets/configure.zcml
@@ -16,4 +16,13 @@
       permission="zope.Public"
       />
 
+  <view
+      type="zope.publisher.interfaces.browser.IBrowserRequest"
+      for="zope.schema.interfaces.IChoice
+           lp.code.vocabularies.gitref.GitRefVocabulary"
+      provides="zope.formlib.interfaces.IInputWidget"
+      factory="lp.code.browser.widgets.gitref.GitRefPickerWidget"
+      permission="zope.Public"
+      />
+
 </configure>
diff --git a/lib/lp/code/browser/widgets/gitref.py b/lib/lp/code/browser/widgets/gitref.py
index cfe8e40..ad93a7a 100644
--- a/lib/lp/code/browser/widgets/gitref.py
+++ b/lib/lp/code/browser/widgets/gitref.py
@@ -23,12 +23,8 @@ from zope.formlib.widget import (
     InputWidget,
     )
 from zope.interface import implementer
-from zope.schema import (
-    Choice,
-    TextLine,
-    )
+from zope.schema import Choice
 from zope.schema.interfaces import IChoice
-
 from lp.app.errors import UnexpectedFormData
 from lp.app.validators import LaunchpadValidationError
 from lp.app.widgets.popup import VocabularyPickerWidget
@@ -104,21 +100,26 @@ class GitRepositoryPickerWidget(VocabularyPickerWidget):
 class GitRefWidget(BrowserWidget, InputWidget):
 
     template = ViewPageTemplateFile("templates/gitref.pt")
-    display_label = False
     _widgets_set_up = False
 
     # If True, allow entering external repository URLs.
     allow_external = False
 
+    # If True, only allow reference paths to be branches (refs/heads/*).
+    require_branch = False
+
     def setUpSubWidgets(self):
         if self._widgets_set_up:
             return
+        path_vocabulary = "GitBranch" if self.require_branch else "GitRef"
         fields = [
             GitRepositoryField(
                 __name__="repository", title=u"Git repository",
                 required=False, vocabulary="GitRepository",
                 allow_external=self.allow_external),
-            TextLine(__name__="path", title=u"Git branch", required=False),
+            Choice(
+                __name__="path", title=u"Git branch", required=False,
+                vocabulary=path_vocabulary),
             ]
         for field in fields:
             setUpWidget(
@@ -133,7 +134,12 @@ class GitRefWidget(BrowserWidget, InputWidget):
                 self.repository_widget.setRenderedValue(value.repository_url)
             else:
                 self.repository_widget.setRenderedValue(value.repository)
-            self.path_widget.setRenderedValue(value.path)
+            # if we're only talking about branches, we can deal in the
+            # name, rather than the full ref/heads/* path
+            if self.require_branch:
+                self.path_widget.setRenderedValue(value.name)
+            else:
+                self.path_widget.setRenderedValue(value.path)
         else:
             self.repository_widget.setRenderedValue(None)
             self.path_widget.setRenderedValue(None)
@@ -174,27 +180,31 @@ class GitRefWidget(BrowserWidget, InputWidget):
                     "There is no Git repository named '%s' registered in "
                     "Launchpad." % entered_name))
         if self.path_widget.hasInput():
-            path = self.path_widget.getInputValue()
-        else:
-            path = None
-        if not path:
-            if self.context.required:
-                raise WidgetInputError(
-                    self.name, self.label,
-                    LaunchpadValidationError(
-                        "Please enter a Git branch path."))
+            # We've potentially just tried to change the repository that is
+            # involved, or changing from a bzr branch to a git repo, so there
+            # is no existing repository set up. We need to set this so we
+            # can compare the ref against the 'new' repo.
+            from zope.security.proxy import removeSecurityProxy
+            unsecure_vocab = removeSecurityProxy(self.path_widget.vocabulary)
+            if IGitRepository.providedBy(repository):
+                unsecure_vocab.setRepository(repository)
             else:
-                return
-        if self.allow_external and not IGitRepository.providedBy(repository):
-            ref = getUtility(IGitRefRemoteSet).new(repository, path)
-        else:
-            ref = repository.getRefByPath(path)
-            if ref is None:
+                unsecure_vocab.setRepositoryURL(repository)
+            try:
+                ref = self.path_widget.getInputValue()
+            except ConversionError:
                 raise WidgetInputError(
                     self.name, self.label,
                     LaunchpadValidationError(
                         "The repository at %s does not contain a branch named "
-                        "'%s'." % (repository.display_name, path)))
+                        "'%s'." % (
+                            repository.display_name,
+                            self.path_widget._getFormInput())))
+        if not ref and self.context.required:
+            raise WidgetInputError(
+                    self.name, self.label,
+                    LaunchpadValidationError(
+                        "Please enter a Git branch path."))
         return ref
 
     def error(self):
@@ -210,3 +220,12 @@ class GitRefWidget(BrowserWidget, InputWidget):
         """See `IBrowserWidget`."""
         self.setUpSubWidgets()
         return self.template()
+
+
+class GitRefPickerWidget(VocabularyPickerWidget):
+
+    __call__ = ViewPageTemplateFile("templates/gitref-picker.pt")
+
+    @property
+    def repository_id(self):
+        return self._prefix + "repository"
diff --git a/lib/lp/code/browser/widgets/templates/gitref.pt b/lib/lp/code/browser/widgets/templates/gitref.pt
index f88a652..f972b89 100644
--- a/lib/lp/code/browser/widgets/templates/gitref.pt
+++ b/lib/lp/code/browser/widgets/templates/gitref.pt
@@ -1,18 +1,3 @@
-<table>
-  <tr>
-    <td>
-      <tal:widget define="widget nocall:view/repository_widget">
-        <metal:block
-            use-macro="context/@@launchpad_widget_macros/launchpad_widget_row" />
-      </tal:widget>
-    </td>
-  </tr>
-  <tr>
-    <td>
-      <tal:widget define="widget nocall:view/path_widget">
-        <metal:block
-            use-macro="context/@@launchpad_widget_macros/launchpad_widget_row" />
-      </tal:widget>
-    </td>
-  </tr>
-</table>
+<span title="Repository" tal:content="structure view/repository_widget" />
+&nbsp;
+<span title="Branch" tal:content="structure view/path_widget" />
diff --git a/lib/lp/code/browser/widgets/tests/test_gitrefwidget.py b/lib/lp/code/browser/widgets/tests/test_gitrefwidget.py
index b411651..2e2526c 100644
--- a/lib/lp/code/browser/widgets/tests/test_gitrefwidget.py
+++ b/lib/lp/code/browser/widgets/tests/test_gitrefwidget.py
@@ -194,7 +194,7 @@ class TestGitRefWidget(WithScenarios, TestCaseWithFactory):
         [ref] = self.factory.makeGitRefs()
         form = {
             "field.git_ref.repository": ref.repository.unique_name,
-            "field.git_ref.path": "non-existent",
+            "field.git_ref.path": u"non-existent",
             }
         self.assertGetInputValueError(
             form,
diff --git a/lib/lp/code/configure.zcml b/lib/lp/code/configure.zcml
index 898e645..ace3140 100644
--- a/lib/lp/code/configure.zcml
+++ b/lib/lp/code/configure.zcml
@@ -841,6 +841,10 @@
       for="lp.code.interfaces.gitrepository.IGitRepository"
       provides="lp.services.webapp.interfaces.ILaunchpadContainer"
       factory="lp.code.publisher.LaunchpadGitRepositoryContainer"/>
+  <adapter
+      for="lp.snappy.interfaces.snap.ISnap"
+      provides="lp.code.interfaces.gitrepository.IGitRepository"
+      factory="lp.code.adapters.gitrepository.git_repository_for_snap"/>
   <subscriber
       for="lp.code.interfaces.gitrepository.IGitRepository zope.lifecycleevent.interfaces.IObjectModifiedEvent"
       handler="lp.code.model.gitrepository.git_repository_modified"/>
diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py
index 192133a..ed17183 100644
--- a/lib/lp/code/interfaces/gitrepository.py
+++ b/lib/lp/code/interfaces/gitrepository.py
@@ -13,6 +13,7 @@ __all__ = [
     'IGitRepository',
     'IGitRepositoryDelta',
     'IGitRepositorySet',
+    'IHasGitRepositoryURL',
     'user_has_special_git_repository_access',
     ]
 
@@ -1227,6 +1228,13 @@ class GitIdentityMixin:
         return identities
 
 
+class IHasGitRepositoryURL(Interface):
+    """Marker interface for objects that have a Git repository URL."""
+
+    git_repository_url = Attribute(
+        "The Git repository URL (possibly external)")
+
+
 def user_has_special_git_repository_access(user, repository=None):
     """Admins have special access.
 
diff --git a/lib/lp/code/model/gitref.py b/lib/lp/code/model/gitref.py
index f7dc142..406c8de 100644
--- a/lib/lp/code/model/gitref.py
+++ b/lib/lp/code/model/gitref.py
@@ -933,6 +933,7 @@ class GitRefRemote(GitRefMixin):
 
     def __eq__(self, other):
         return (
+            other is not None and
             self.repository_url == other.repository_url and
             self.path == other.path)
 
diff --git a/lib/lp/code/vocabularies/configure.zcml b/lib/lp/code/vocabularies/configure.zcml
index 5782992..fe8f73b 100644
--- a/lib/lp/code/vocabularies/configure.zcml
+++ b/lib/lp/code/vocabularies/configure.zcml
@@ -88,4 +88,26 @@
     <allow interface="zope.schema.interfaces.IVocabularyTokenized"/>
   </class>
 
+  <securedutility
+    name="GitRef"
+    component=".gitref.GitRefVocabulary"
+    provides="zope.schema.interfaces.IVocabularyFactory">
+    <allow interface="zope.schema.interfaces.IVocabularyFactory"/>
+  </securedutility>
+
+  <class class=".gitref.GitRefVocabulary">
+    <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary"/>
+  </class>
+
+  <securedutility
+    name="GitBranch"
+    component=".gitref.GitBranchVocabulary"
+    provides="zope.schema.interfaces.IVocabularyFactory">
+    <allow interface="zope.schema.interfaces.IVocabularyFactory"/>
+  </securedutility>
+
+  <class class=".gitref.GitBranchVocabulary">
+    <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary"/>
+  </class>
+
 </configure>
diff --git a/lib/lp/code/vocabularies/gitref.py b/lib/lp/code/vocabularies/gitref.py
new file mode 100644
index 0000000..1e4a0aa
--- /dev/null
+++ b/lib/lp/code/vocabularies/gitref.py
@@ -0,0 +1,151 @@
+# Copyright 2017 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Vocabularies that contain Git references."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    "GitBranchVocabulary",
+    "GitRefVocabulary",
+    ]
+
+from lazr.restful.interfaces import IReference
+from storm.expr import (
+    Desc,
+    Like,
+    like_escape,
+    )
+from zope.component import getUtility
+from zope.interface import implementer
+from zope.schema.vocabulary import SimpleTerm
+from zope.security.proxy import isinstance as zope_isinstance
+
+from lp.code.interfaces.gitref import IGitRefRemoteSet
+from lp.code.interfaces.gitrepository import (
+    IGitRepository,
+    IHasGitRepositoryURL,
+    )
+from lp.code.model.gitref import (
+    GitRef,
+    GitRefRemote,
+    )
+from lp.services.database.interfaces import IStore
+from storm.databases.postgres import Case
+from lp.services.webapp.vocabulary import (
+    CountableIterator,
+    IHugeVocabulary,
+    StormVocabularyBase,
+    )
+
+
+@implementer(IHugeVocabulary)
+class GitRefVocabulary(StormVocabularyBase):
+    """A vocabulary for references in a given Git repository."""
+
+    _table = GitRef
+    # In the base case (i.e. not GitBranchVocabulary) this may also be a
+    # more general reference such as refs/tags/foo, but experience suggests
+    # that people find talking about references in the web UI to be
+    # baffling, so we tell a white lie here.
+    displayname = "Select a branch"
+    step_title = "Search"
+
+    def __init__(self, context):
+        super(GitRefVocabulary, self).__init__(context=context)
+        if IReference.providedBy(context):
+            context = context.context
+        try:
+            self.repository = IGitRepository(context)
+        except TypeError:
+            self.repository = None
+        try:
+            self.repository_url = (
+                IHasGitRepositoryURL(context).git_repository_url)
+        except TypeError:
+            self.repository_url = None
+
+    def setRepository(self, repository):
+        """Set the repository after the vocabulary was instantiated."""
+        self.repository = repository
+        self.repository_url = None
+
+    def setRepositoryURL(self, repository_url):
+        """Set the repository URL after the vocabulary was instantiated."""
+        self.repository = None
+        self.repository_url = repository_url
+
+    def _assertHasRepository(self):
+        if self.repository is None and self.repository_url is None:
+            raise AssertionError(
+                "GitRefVocabulary cannot be used without setting a "
+                "repository or a repository URL.")
+
+    @property
+    def _order_by(self):
+        rank = Case(
+            cases=[(self._table.path == self.repository.default_branch, 2)],
+            default=1)
+        return [Desc(rank), Desc(self._table.committer_date)]
+
+    def toTerm(self, ref):
+        """See `StormVocabularyBase`."""
+        return SimpleTerm(ref, ref.path, ref.name)
+
+    def getTermByToken(self, token):
+        """See `IVocabularyTokenized`."""
+        self._assertHasRepository()
+        if self.repository is not None:
+            ref = self.repository.getRefByPath(token)
+            if ref is None:
+                raise LookupError(token)
+        else:
+            ref = getUtility(IGitRefRemoteSet).new(self.repository_url, token)
+        return self.toTerm(ref)
+
+    def _makePattern(self, query=None):
+        parts = ["%"]
+        if query is not None:
+            parts.extend([query.lower().translate(like_escape), "%"])
+        return "".join(parts)
+
+    def searchForTerms(self, query=None, vocab_filter=None):
+        """See `IHugeVocabulary."""
+        self._assertHasRepository()
+        if self.repository is not None:
+            pattern = self._makePattern(query=query)
+            results = IStore(self._table).find(
+                self._table,
+                self._table.repository_id == self.repository.id,
+                Like(self._table.path, pattern, "!")).order_by(self._order_by)
+        else:
+            results = self.emptySelectResults()
+        return CountableIterator(results.count(), results, self.toTerm)
+
+    def __len__(self):
+        """See `IVocabulary`."""
+        return self.searchForTerms().count()
+
+    def __contains__(self, obj):
+        # We know nothing about GitRefRemote, so we just have to assume
+        # that they exist in the remote repository
+        if zope_isinstance(obj, GitRefRemote):
+            return True
+        if obj in self.repository.refs:
+            return True
+        return False
+
+
+class GitBranchVocabulary(GitRefVocabulary):
+    """A vocabulary for branches in a given Git repository."""
+
+    def _makePattern(self, query=None):
+        parts = []
+        # XXX allow HEAD?
+        if query is None or not query.startswith("refs/heads/"):
+            parts.append("refs/heads/".translate(like_escape))
+        parts.append("%")
+        if query is not None:
+            parts.extend([query.lower().translate(like_escape), "%"])
+        return "".join(parts)
diff --git a/lib/lp/code/vocabularies/tests/test_gitref_vocabularies.py b/lib/lp/code/vocabularies/tests/test_gitref_vocabularies.py
new file mode 100644
index 0000000..117c255
--- /dev/null
+++ b/lib/lp/code/vocabularies/tests/test_gitref_vocabularies.py
@@ -0,0 +1,188 @@
+# Copyright 2017 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test the Git reference vocabularies."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from datetime import (
+    datetime,
+    timedelta,
+    )
+
+import pytz
+from testtools.matchers import MatchesStructure
+from zope.schema.vocabulary import SimpleTerm
+from zope.security.proxy import removeSecurityProxy
+
+from lp.code.vocabularies.gitref import (
+    GitBranchVocabulary,
+    GitRefVocabulary,
+    )
+from lp.services.webapp.vocabulary import IHugeVocabulary
+from lp.testing import TestCaseWithFactory
+from lp.testing.layers import ZopelessDatabaseLayer
+
+
+class TestGitRefVocabularyMixin:
+
+    layer = ZopelessDatabaseLayer
+
+    def test_getTermByToken(self):
+        [ref] = self.factory.makeGitRefs()
+        vocab = self.vocabulary_class(ref.repository)
+        term = SimpleTerm(ref, ref.path, ref.name)
+        self.assertEqual(term.token, vocab.getTermByToken(ref.name).token)
+        self.assertEqual(term.token, vocab.getTermByToken(ref.path).token)
+        self.assertRaises(LookupError, vocab.getTermByToken, "nonexistent")
+
+
+class TestGitRefVocabulary(TestGitRefVocabularyMixin, TestCaseWithFactory):
+
+    vocabulary_class = GitRefVocabulary
+
+    def test_provides_IHugeVocabulary(self):
+        vocab = self.vocabulary_class(self.factory.makeGitRepository())
+        self.assertProvides(vocab, IHugeVocabulary)
+
+    def test_init_snap(self):
+        # A vocabulary may be instantiated with anything that can be adapted
+        # to an IGitRepository, such as a Snap configured to build from one.
+        [ref] = self.factory.makeGitRefs()
+        vocab = self.vocabulary_class(self.factory.makeSnap(git_ref=ref))
+        self.assertEqual(ref.repository, vocab.repository)
+
+    def test_init_no_repository(self):
+        # The repository is None if the context cannot be adapted to a
+        # repository.
+        vocab = self.vocabulary_class(
+            self.factory.makeSnap(branch=self.factory.makeAnyBranch()))
+        self.assertIsNone(vocab.repository)
+
+    def test_setRepository(self):
+        # Callers can set the repository after instantiation.
+        vocab = self.vocabulary_class(
+            self.factory.makeSnap(branch=self.factory.makeAnyBranch()))
+        repository = self.factory.makeGitRepository()
+        vocab.setRepository(repository)
+        self.assertEqual(repository, vocab.repository)
+
+    def test_toTerm(self):
+        [ref] = self.factory.makeGitRefs()
+        self.assertThat(
+            self.vocabulary_class(ref.repository).toTerm(ref),
+            MatchesStructure.byEquality(
+                value=ref, token=ref.path, title=ref.name))
+
+    def test_searchForTerms(self):
+        ref_master, ref_next, ref_next_squared, _ = (
+            self.factory.makeGitRefs(paths=[
+                "refs/heads/master", "refs/heads/next",
+                "refs/heads/next-squared", "refs/tags/next-1.0"]))
+        removeSecurityProxy(ref_master.repository)._default_branch = (
+            ref_master.path)
+        vocab = self.vocabulary_class(ref_master.repository)
+        self.assertContentEqual(
+            [term.value.path for term in vocab.searchForTerms("master")],
+            ["refs/heads/master"])
+        self.assertContentEqual(
+            [term.value.path for term in vocab.searchForTerms("next")],
+            ["refs/heads/next", "refs/heads/next-squared",
+             "refs/tags/next-1.0"])
+        self.assertContentEqual(
+            [term.value.path for term in vocab.searchForTerms("refs/heads/next")],
+            ["refs/heads/next", "refs/heads/next-squared"])
+        self.assertContentEqual(
+            [term.value.path for term in vocab.searchForTerms("")],
+            ["refs/heads/master", "refs/heads/next",
+             "refs/heads/next-squared", "refs/tags/next-1.0"])
+        self.assertContentEqual(
+            [term.token for term in vocab.searchForTerms("nonexistent")], [])
+
+    def test_searchForTerms_ordering(self):
+        # The default branch (if it matches) is shown first, followed by
+        # other matches in decreasing order of last commit date.
+        ref_master, ref_master_old, ref_master_older = (
+            self.factory.makeGitRefs(paths=[
+                "refs/heads/master", "refs/heads/master-old",
+                "refs/heads/master-older"]))
+        removeSecurityProxy(ref_master.repository)._default_branch = (
+            ref_master.path)
+        now = datetime.now(pytz.UTC)
+        removeSecurityProxy(ref_master_old).committer_date = (
+            now - timedelta(days=1))
+        removeSecurityProxy(ref_master_older).committer_date = (
+            now - timedelta(days=2))
+        vocab = self.vocabulary_class(ref_master.repository)
+        self.assertEqual(
+            [term.value.path for term in vocab.searchForTerms("master")],
+            ["refs/heads/master", "refs/heads/master-old",
+             "refs/heads/master-older"])
+
+    def test_len(self):
+        ref_master, _, _, _ = self.factory.makeGitRefs(paths=[
+            "refs/heads/master", "refs/heads/next",
+            "refs/heads/next-squared", "refs/tags/next-1.0"])
+        self.assertEqual(4, len(self.vocabulary_class(ref_master.repository)))
+
+
+class TestGitBranchVocabulary(TestGitRefVocabularyMixin, TestCaseWithFactory):
+
+    vocabulary_class = GitBranchVocabulary
+
+    def test_toTerm(self):
+        [ref] = self.factory.makeGitRefs()
+        self.assertThat(
+            self.vocabulary_class(ref.repository).toTerm(ref),
+            MatchesStructure.byEquality(
+                value=ref, token=ref.path, title=ref.name))
+
+    def test_searchForTerms(self):
+        ref_master, ref_next, ref_next_squared, _ = (
+            self.factory.makeGitRefs(paths=[
+                "refs/heads/master", "refs/heads/next",
+                "refs/heads/next-squared", "refs/tags/next-1.0"]))
+        removeSecurityProxy(ref_master.repository)._default_branch = (
+            ref_master.path)
+        vocab = self.vocabulary_class(ref_master.repository)
+        self.assertContentEqual(
+            [term.title for term in vocab.searchForTerms("master")],
+            ["master"])
+        self.assertContentEqual(
+            [term.title for term in vocab.searchForTerms("next")],
+            ["next", "next-squared"])
+        self.assertContentEqual(
+            [term.title for term in vocab.searchForTerms("refs/heads/next")],
+            ["next", "next-squared"])
+        self.assertContentEqual(
+            [term.title for term in vocab.searchForTerms("")],
+            ["master", "next", "next-squared"])
+        self.assertContentEqual(
+            [term.token for term in vocab.searchForTerms("nonexistent")], [])
+
+    def test_searchForTerms_ordering(self):
+        # The default branch (if it matches) is shown first, followed by
+        # other matches in decreasing order of last commit date.
+        ref_master, ref_master_old, ref_master_older = (
+            self.factory.makeGitRefs(paths=[
+                "refs/heads/master", "refs/heads/master-old",
+                "refs/heads/master-older"]))
+        removeSecurityProxy(ref_master.repository)._default_branch = (
+            ref_master.path)
+        now = datetime.now(pytz.UTC)
+        removeSecurityProxy(ref_master_old).committer_date = (
+            now - timedelta(days=1))
+        removeSecurityProxy(ref_master_older).committer_date = (
+            now - timedelta(days=2))
+        vocab = self.vocabulary_class(ref_master.repository)
+        self.assertEqual(
+            [term.title for term in vocab.searchForTerms("master")],
+            ["master", "master-old", "master-older"])
+
+    def test_len(self):
+        ref_master, _, _, _ = self.factory.makeGitRefs(paths=[
+            "refs/heads/master", "refs/heads/next",
+            "refs/heads/next-squared", "refs/tags/next-1.0"])
+        self.assertEqual(3, len(self.vocabulary_class(ref_master.repository)))
diff --git a/lib/lp/services/webapp/vocabulary.py b/lib/lp/services/webapp/vocabulary.py
index 8ccb7b2..29d3883 100644
--- a/lib/lp/services/webapp/vocabulary.py
+++ b/lib/lp/services/webapp/vocabulary.py
@@ -544,6 +544,7 @@ class StormVocabularyBase(FilteredVocabularyBase):
             found_obj = IStore(self._table).find(self._table, *clauses).one()
             return found_obj is not None and found_obj == obj
         else:
+            import ipdb; ipdb.set_trace()
             clauses = [self._table.id == int(obj)]
             if self._clauses:
                 # XXX kiko 2007-01-16: this code is untested.
diff --git a/lib/lp/snappy/browser/tests/test_snap.py b/lib/lp/snappy/browser/tests/test_snap.py
index 10e82da..9d0414a 100644
--- a/lib/lp/snappy/browser/tests/test_snap.py
+++ b/lib/lp/snappy/browser/tests/test_snap.py
@@ -1,4 +1,4 @@
-# Copyright 2015-2020 Canonical Ltd.  This software is licensed under the
+    # Copyright 2015-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Test snap package views."""
@@ -733,9 +733,9 @@ class TestSnapEditView(BaseTestSnapView):
         browser.getControl(name="field.store_distro_series").value = [
             "ubuntu/%s/%s" % (new_series.name, new_snappy_series.name)]
         browser.getControl("Git", index=0).click()
-        browser.getControl("Git repository").value = (
+        browser.getControl(name="field.git_ref.repository").value = (
             new_git_ref.repository.identity)
-        browser.getControl("Git branch").value = new_git_ref.path
+        browser.getControl(name="field.git_ref.path").value = new_git_ref.path
         browser.getControl("Build source tarball").selected = True
         browser.getControl(
             "Automatically build when branch changes").selected = True
@@ -952,8 +952,9 @@ class TestSnapEditView(BaseTestSnapView):
         private_ref_path = private_ref.path
         browser = self.getViewBrowser(snap, user=self.person)
         browser.getLink("Edit snap package").click()
-        browser.getControl("Git repository").value = private_ref_identity
-        browser.getControl("Git branch").value = private_ref_path
+        browser.getControl(name="field.git_ref.repository").value = (
+            private_ref_identity)
+        browser.getControl(name="field.git_ref.path").value = private_ref_path
         browser.getControl("Update snap package").click()
         self.assertEqual(
             "A public snap cannot have a private repository.",
@@ -973,8 +974,9 @@ class TestSnapEditView(BaseTestSnapView):
             git_ref=old_ref, store_series=snappy_series)
         browser = self.getViewBrowser(snap, user=self.person)
         browser.getLink("Edit snap package").click()
-        browser.getControl("Git repository").value = new_repository_url
-        browser.getControl("Git branch").value = new_path
+        browser.getControl(
+            name="field.git_ref.repository").value = new_repository_url
+        browser.getControl(name="field.git_ref.path").value = new_path
         browser.getControl("Update snap package").click()
         login_person(self.person)
         content = find_main_content(browser.contents)
diff --git a/lib/lp/snappy/model/snap.py b/lib/lp/snappy/model/snap.py
index 1ef8f45..7e3d14c 100644
--- a/lib/lp/snappy/model/snap.py
+++ b/lib/lp/snappy/model/snap.py
@@ -46,7 +46,10 @@ from zope.component import (
     getUtility,
     )
 from zope.event import notify
-from zope.interface import implementer
+from zope.interface import (
+    directlyProvides,
+    implementer,
+    )
 from zope.security.interfaces import Unauthorized
 from zope.security.proxy import removeSecurityProxy
 
@@ -82,7 +85,10 @@ from lp.code.interfaces.gitref import (
     IGitRef,
     IGitRefRemoteSet,
     )
-from lp.code.interfaces.gitrepository import IGitRepository
+from lp.code.interfaces.gitrepository import (
+    IGitRepository,
+    IHasGitRepositoryURL,
+    )
 from lp.code.model.branch import Branch
 from lp.code.model.branchcollection import GenericBranchCollection
 from lp.code.model.gitcollection import GenericGitCollection
@@ -440,6 +446,10 @@ class Snap(Storm, WebhookTargetMixin):
             self.git_repository = None
             self.git_repository_url = None
             self.git_path = None
+        if self.git_repository_url is not None:
+            directlyProvides(self, IHasGitRepositoryURL)
+        else:
+            directlyProvides(self)
 
     @property
     def source(self):

Follow ups