← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/snap-canonicalise-git-path into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/snap-canonicalise-git-path into lp:launchpad.

Commit message:
Introduce Snap.git_ref as a combined field for modification purposes.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1476405 in Launchpad itself: "Add support for building snaps"
  https://bugs.launchpad.net/launchpad/+bug/1476405

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/snap-canonicalise-git-path/+merge/270543

Introduce Snap.git_ref as a combined field for modification purposes.

This has the effect of validating and canonicalising the git_path database column; of course it's still possible for a branch to be removed after a Snap was created using it, but at least it means that typos in web forms and such are caught.  Adding a new GitRefWidget makes the Snap browser code simpler, and opens the way for improved branch picker code later.  I had to fix the build behaviour to pass ref.name rather than ref.path, as "git clone -b" doesn't accept fully-qualified ref paths (thanks, git).
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snap-canonicalise-git-path into lp:launchpad.
=== modified file 'lib/lp/app/templates/launchpad-widget-macros.pt'
--- lib/lp/app/templates/launchpad-widget-macros.pt	2013-04-22 06:20:18 +0000
+++ lib/lp/app/templates/launchpad-widget-macros.pt	2015-09-09 14:30:28 +0000
@@ -29,7 +29,7 @@
                  tal:define="error widget/error">
 
               <label tal:attributes="for widget/name"
-                     tal:content="widget/label">Label</label>
+                     tal:content="string:${widget/label}:">Label</label>
 
               <span tal:condition="widget/required"
                     class="fieldRequired"

=== added file 'lib/lp/code/browser/widgets/gitref.py'
--- lib/lp/code/browser/widgets/gitref.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/browser/widgets/gitref.py	2015-09-09 14:30:28 +0000
@@ -0,0 +1,129 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+
+__all__ = [
+    'GitRefWidget',
+    ]
+
+from z3c.ptcompat import ViewPageTemplateFile
+from zope.formlib.interfaces import (
+    ConversionError,
+    IInputWidget,
+    MissingInputError,
+    WidgetInputError,
+    )
+from zope.formlib.utility import setUpWidget
+from zope.formlib.widget import (
+    BrowserWidget,
+    InputErrors,
+    InputWidget,
+    )
+from zope.interface import implementer
+from zope.schema import (
+    Choice,
+    TextLine,
+    )
+
+from lp.app.errors import UnexpectedFormData
+from lp.app.validators import LaunchpadValidationError
+from lp.services.webapp.interfaces import (
+    IAlwaysSubmittedWidget,
+    IMultiLineWidgetLayout,
+    )
+
+
+@implementer(IMultiLineWidgetLayout, IAlwaysSubmittedWidget, IInputWidget)
+class GitRefWidget(BrowserWidget, InputWidget):
+
+    template = ViewPageTemplateFile("templates/gitref.pt")
+    display_label = False
+    _widgets_set_up = False
+
+    def setUpSubWidgets(self):
+        if self._widgets_set_up:
+            return
+        fields = [
+            Choice(
+                __name__="repository", title=u"Git repository",
+                required=False, vocabulary="GitRepository"),
+            TextLine(
+                __name__="path", title=u"Git branch path", required=False),
+            ]
+        for field in fields:
+            setUpWidget(
+                self, field.__name__, field, IInputWidget, prefix=self.name)
+        self._widgets_set_up = True
+
+    def setRenderedValue(self, value):
+        """See `IWidget`."""
+        self.setUpSubWidgets()
+        if value is not None:
+            self.repository_widget.setRenderedValue(value.repository)
+            self.path_widget.setRenderedValue(value.path)
+        else:
+            self.repository_widget.setRenderedValue(None)
+            self.path_widget.setRenderedValue(None)
+
+    def hasInput(self):
+        """See `IInputWidget`."""
+        return (
+            ("%s.repository" % self.name) in self.request.form or
+            ("%s.path" % self.name) in self.request.form)
+
+    def hasValidInput(self):
+        """See `IInputWidget`."""
+        try:
+            self.getInputValue()
+            return True
+        except (InputErrors, UnexpectedFormData):
+            return False
+
+    def getInputValue(self):
+        """See `IInputWidget`."""
+        self.setUpSubWidgets()
+        try:
+            repository = self.repository_widget.getInputValue()
+        except MissingInputError:
+            raise WidgetInputError(
+                self.name, self.label,
+                LaunchpadValidationError("Please choose a Git repository."))
+        except ConversionError:
+            entered_name = self.request.form_ng.getOne(
+                "%s.repository" % self.name)
+            raise WidgetInputError(
+                self.name, self.label,
+                LaunchpadValidationError(
+                    "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:
+            raise WidgetInputError(
+                self.name, self.label,
+                LaunchpadValidationError("Please enter a Git branch path."))
+        ref = repository.getRefByPath(path)
+        if ref is None:
+            raise WidgetInputError(
+                self.name, self.label,
+                LaunchpadValidationError(
+                    "The repository at %s does not contain a branch named "
+                    "'%s'." % (repository.display_name, path)))
+        return ref
+
+    def error(self):
+        """See `IBrowserWidget`."""
+        try:
+            if self.hasInput():
+                self.getInputValue()
+        except InputErrors as error:
+            self._error = error
+        return super(GitRefWidget, self).error()
+
+    def __call__(self):
+        """See `IBrowserWidget`."""
+        self.setUpSubWidgets()
+        return self.template()

=== added file 'lib/lp/code/browser/widgets/templates/gitref.pt'
--- lib/lp/code/browser/widgets/templates/gitref.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/code/browser/widgets/templates/gitref.pt	2015-09-09 14:30:28 +0000
@@ -0,0 +1,18 @@
+<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>

=== added file 'lib/lp/code/browser/widgets/tests/test_gitrefwidget.py'
--- lib/lp/code/browser/widgets/tests/test_gitrefwidget.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/browser/widgets/tests/test_gitrefwidget.py	2015-09-09 14:30:28 +0000
@@ -0,0 +1,199 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+
+from BeautifulSoup import BeautifulSoup
+from lazr.restful.fields import Reference
+from zope.formlib.interfaces import (
+    IBrowserWidget,
+    IInputWidget,
+    WidgetInputError,
+    )
+from zope.interface import (
+    implementer,
+    Interface,
+    )
+
+from lp.app.validators import LaunchpadValidationError
+from lp.code.browser.widgets.gitref import GitRefWidget
+from lp.code.vocabularies.gitrepository import GitRepositoryVocabulary
+from lp.services.webapp.escaping import html_escape
+from lp.services.webapp.servers import LaunchpadTestRequest
+from lp.testing import (
+    TestCaseWithFactory,
+    verifyObject,
+    )
+from lp.testing.layers import DatabaseFunctionalLayer
+
+
+class IThing(Interface):
+    pass
+
+
+@implementer(IThing)
+class Thing:
+    pass
+
+
+class TestGitRefWidget(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestGitRefWidget, self).setUp()
+        field = Reference(
+            __name__="git_ref", schema=Interface, title=u"Git reference")
+        self.context = Thing()
+        field = field.bind(self.context)
+        request = LaunchpadTestRequest()
+        self.widget = GitRefWidget(field, request)
+
+    def test_implements(self):
+        self.assertTrue(verifyObject(IBrowserWidget, self.widget))
+        self.assertTrue(verifyObject(IInputWidget, self.widget))
+
+    def test_template(self):
+        self.assertTrue(
+            self.widget.template.filename.endswith("gitref.pt"),
+            "Template was not set up.")
+
+    def test_setUpSubWidgets_first_call(self):
+        # The subwidgets are set up and a flag is set.
+        self.widget.setUpSubWidgets()
+        self.assertTrue(self.widget._widgets_set_up)
+        self.assertIsInstance(
+            self.widget.repository_widget.context.vocabulary,
+            GitRepositoryVocabulary)
+        self.assertIsNotNone(self.widget.path_widget)
+
+    def test_setUpSubWidgets_second_call(self):
+        # The setUpSubWidgets method exits early if a flag is set to
+        # indicate that the widgets were set up.
+        self.widget._widgets_set_up = True
+        self.widget.setUpSubWidgets()
+        self.assertIsNone(getattr(self.widget, "repository_widget", None))
+        self.assertIsNone(getattr(self.widget, "path_widget", None))
+
+    def test_setRenderedValue(self):
+        # The widget's render state is set from the provided reference's
+        # repository and path.
+        self.widget.setUpSubWidgets()
+        [ref] = self.factory.makeGitRefs()
+        self.widget.setRenderedValue(ref)
+        self.assertEqual(
+            ref.repository, self.widget.repository_widget._getCurrentValue())
+        self.assertEqual(ref.path, self.widget.path_widget._getCurrentValue())
+
+    def test_hasInput_false(self):
+        # hasInput is false when the widget's name is not in the form data.
+        self.widget.request = LaunchpadTestRequest(form={})
+        self.assertFalse(self.widget.hasInput())
+
+    def test_hasInput_true(self):
+        # hasInput is true when the subwidgets are in the form data.
+        form = {
+            "field.git_ref.repository": "",
+            "field.git_ref.path": "",
+            }
+        self.widget.request = LaunchpadTestRequest(form=form)
+        self.assertEqual("field.git_ref", self.widget.name)
+        self.assertTrue(self.widget.hasInput())
+
+    def test_hasValidInput_false(self):
+        # The field input is invalid if any of the submitted parts are
+        # invalid.
+        form = {
+            "field.git_ref.repository": "non-existent",
+            "field.git_ref.path": "non-existent",
+            }
+        self.widget.request = LaunchpadTestRequest(form=form)
+        self.assertFalse(self.widget.hasValidInput())
+
+    def test_hasValidInput_true(self):
+        # The field input is valid when all submitted parts are valid.
+        [ref] = self.factory.makeGitRefs()
+        form = {
+            "field.git_ref.repository": ref.repository.unique_name,
+            "field.git_ref.path": ref.path,
+            }
+        self.widget.request = LaunchpadTestRequest(form=form)
+        self.assertTrue(self.widget.hasValidInput())
+
+    def assertGetInputValueError(self, form, message):
+        self.widget.request = LaunchpadTestRequest(form=form)
+        e = self.assertRaises(WidgetInputError, self.widget.getInputValue)
+        self.assertEqual(LaunchpadValidationError(message), e.errors)
+        self.assertEqual(html_escape(message), self.widget.error())
+
+    def test_getInputValue_repository_missing(self):
+        # An error is raised when the repository field is missing.
+        form = {
+            "field.git_ref.path": "master",
+            }
+        self.assertGetInputValueError(form, "Please choose a Git repository.")
+
+    def test_getInputValue_repository_invalid(self):
+        # An error is raised when the repository does not exist.
+        form = {
+            "field.git_ref.repository": "non-existent",
+            "field.git_ref.path": "master",
+            }
+        self.assertGetInputValueError(
+            form,
+            "There is no Git repository named 'non-existent' registered in "
+            "Launchpad.")
+
+    def test_getInputValue_path_empty(self):
+        # An error is raised when the path field is empty.
+        repository = self.factory.makeGitRepository()
+        form = {
+            "field.git_ref.repository": repository.unique_name,
+            "field.git_ref.path": "",
+            }
+        self.assertGetInputValueError(form, "Please enter a Git branch path.")
+
+    def test_getInputValue_path_invalid(self):
+        # An error is raised when the branch path does not identify a
+        # reference in the repository.
+        [ref] = self.factory.makeGitRefs()
+        form = {
+            "field.git_ref.repository": ref.repository.unique_name,
+            "field.git_ref.path": "non-existent",
+            }
+        self.assertGetInputValueError(
+            form,
+            "The repository at %s does not contain a branch named "
+            "'non-existent'." % ref.repository.display_name)
+
+    def test_getInputValue_valid(self):
+        # When both the repository and the path are valid, the field value
+        # is the reference they identify.
+        [ref] = self.factory.makeGitRefs()
+        form = {
+            "field.git_ref.repository": ref.repository.unique_name,
+            "field.git_ref.path": ref.path,
+            }
+        self.widget.request = LaunchpadTestRequest(form=form)
+        self.assertEqual(ref, self.widget.getInputValue())
+
+    def test_getInputValue_canonicalises_path(self):
+        # A shortened version of the branch path may be used.
+        [ref] = self.factory.makeGitRefs()
+        form = {
+            "field.git_ref.repository": ref.repository.unique_name,
+            "field.git_ref.path": ref.name,
+            }
+        self.widget.request = LaunchpadTestRequest(form=form)
+        self.assertEqual(ref, self.widget.getInputValue())
+
+    def test_call(self):
+        # The __call__ method sets up the widgets.
+        markup = self.widget()
+        self.assertIsNotNone(self.widget.repository_widget)
+        self.assertIsNotNone(self.widget.path_widget)
+        soup = BeautifulSoup(markup)
+        fields = soup.findAll("input", id=True)
+        ids = [field["id"] for field in fields]
+        self.assertContentEqual(
+            ["field.git_ref.repository", "field.git_ref.path"], ids)

=== modified file 'lib/lp/snappy/browser/snap.py'
--- lib/lp/snappy/browser/snap.py	2015-09-07 16:05:51 +0000
+++ lib/lp/snappy/browser/snap.py	2015-09-09 14:30:28 +0000
@@ -29,6 +29,7 @@
 from lp.app.browser.lazrjs import InlinePersonEditPickerWidget
 from lp.app.browser.tales import format_link
 from lp.app.widgets.itemswidgets import LaunchpadRadioWidget
+from lp.code.browser.widgets.gitref import GitRefWidget
 from lp.registry.enums import VCSType
 from lp.services.webapp import (
     canonical_url,
@@ -116,9 +117,8 @@
     def source(self):
         if self.context.branch is not None:
             return self.context.branch
-        elif self.context.git_repository is not None:
-            return self.context.git_repository.getRefByPath(
-                self.context.git_path)
+        elif self.context.git_ref is not None:
+            return self.context.git_ref
         else:
             return None
 
@@ -162,8 +162,7 @@
     # Each of these is only required if vcs has an appropriate value.  Later
     # validation takes care of adjusting the required attribute.
     branch = copy_field(ISnap['branch'], required=True)
-    git_repository = copy_field(ISnap['git_repository'], required=True)
-    git_path = copy_field(ISnap['git_path'], required=True)
+    git_ref = copy_field(ISnap['git_ref'], required=True)
 
 
 class BaseSnapAddEditView(LaunchpadEditFormView):
@@ -193,12 +192,10 @@
             vcs = data.get('vcs')
             if vcs == VCSType.BZR:
                 self.widgets['branch'].context.required = True
-                self.widgets['git_repository'].context.required = False
-                self.widgets['git_path'].context.required = False
+                self.widgets['git_ref'].context.required = False
             elif vcs == VCSType.GIT:
                 self.widgets['branch'].context.required = False
-                self.widgets['git_repository'].context.required = True
-                self.widgets['git_path'].context.required = True
+                self.widgets['git_ref'].context.required = True
             else:
                 raise AssertionError("Unknown branch type %s" % vcs)
         super(BaseSnapAddEditView, self).validate_widgets(data, names=names)
@@ -210,8 +207,7 @@
     def request_action(self, action, data):
         vcs = data.pop('vcs', None)
         if vcs == VCSType.BZR:
-            data['git_repository'] = None
-            data['git_path'] = None
+            data['git_ref'] = None
         elif vcs == VCSType.GIT:
             data['branch'] = None
         self.updateContextFromData(data)
@@ -245,14 +241,14 @@
     page_title = 'Edit'
 
     field_names = [
-        'owner', 'name', 'distro_series', 'vcs', 'branch', 'git_repository',
-        'git_path']
+        'owner', 'name', 'distro_series', 'vcs', 'branch', 'git_ref']
     custom_widget('distro_series', LaunchpadRadioWidget)
     custom_widget('vcs', LaunchpadRadioWidget)
+    custom_widget('git_ref', GitRefWidget)
 
     @property
     def initial_values(self):
-        if self.context.git_repository is not None:
+        if self.context.git_ref is not None:
             vcs = VCSType.GIT
         else:
             vcs = VCSType.BZR

=== modified file 'lib/lp/snappy/interfaces/snap.py'
--- lib/lp/snappy/interfaces/snap.py	2015-08-05 00:27:16 +0000
+++ lib/lp/snappy/interfaces/snap.py	2015-09-09 14:30:28 +0000
@@ -65,6 +65,7 @@
 from lp.app.validators.name import name_validator
 from lp.buildmaster.interfaces.processor import IProcessor
 from lp.code.interfaces.branch import IBranch
+from lp.code.interfaces.gitref import IGitRef
 from lp.code.interfaces.gitrepository import IGitRepository
 from lp.registry.interfaces.distroseries import IDistroSeries
 from lp.registry.interfaces.person import IPerson
@@ -149,12 +150,12 @@
 
 @error_status(httplib.BAD_REQUEST)
 class NoSourceForSnap(Exception):
-    """Snap packages must have a source (Bazaar branch or Git repository)."""
+    """Snap packages must have a source (Bazaar or Git branch)."""
 
     def __init__(self):
         super(NoSourceForSnap, self).__init__(
             "New snap packages must have either a Bazaar branch or a Git "
-            "repository.")
+            "branch.")
 
 
 @error_status(httplib.BAD_REQUEST)
@@ -264,17 +265,23 @@
     git_repository = exported(ReferenceChoice(
         title=_("Git repository"),
         schema=IGitRepository, vocabulary="GitRepository",
-        required=False, readonly=False,
+        required=False, readonly=True,
         description=_(
             "A Git repository with a branch containing a snapcraft.yaml "
             "recipe at the top level.")))
 
     git_path = exported(TextLine(
-        title=_("Git branch path"), required=False, readonly=False,
+        title=_("Git branch path"), required=False, readonly=True,
         description=_(
             "The path of the Git branch containing a snapcraft.yaml recipe at "
             "the top level.")))
 
+    git_ref = exported(Reference(
+        IGitRef, title=_("Git branch"), required=False, readonly=False,
+        description=_(
+            "The Git branch containing a snapcraft.yaml recipe at the top "
+            "level.")))
+
 
 class ISnapAdminAttributes(Interface):
     """`ISnap` attributes that can be edited by admins.
@@ -325,11 +332,11 @@
     @export_factory_operation(
         ISnap, [
             "owner", "distro_series", "name", "description", "branch",
-            "git_repository", "git_path"])
+            "git_ref"])
     @operation_for_version("devel")
     def new(registrant, owner, distro_series, name, description=None,
-            branch=None, git_repository=None, git_path=None,
-            require_virtualized=True, processors=None, date_created=None):
+            branch=None, git_ref=None, require_virtualized=True,
+            processors=None, date_created=None):
         """Create an `ISnap`."""
 
     def exists(owner, name):

=== modified file 'lib/lp/snappy/javascript/snap.edit.js'
--- lib/lp/snappy/javascript/snap.edit.js	2015-09-04 16:20:26 +0000
+++ lib/lp/snappy/javascript/snap.edit.js	2015-09-09 14:30:28 +0000
@@ -23,8 +23,8 @@
             }
         });
         module.set_enabled('field.branch', selected_vcs === 'BZR');
-        module.set_enabled('field.git_repository', selected_vcs === 'GIT');
-        module.set_enabled('field.git_path', selected_vcs === 'GIT');
+        module.set_enabled('field.git_ref.repository', selected_vcs === 'GIT');
+        module.set_enabled('field.git_ref.path', selected_vcs === 'GIT');
     };
 
     module.setup = function() {

=== modified file 'lib/lp/snappy/javascript/tests/test_snap.edit.html'
--- lib/lp/snappy/javascript/tests/test_snap.edit.html	2015-09-04 16:20:26 +0000
+++ lib/lp/snappy/javascript/tests/test_snap.edit.html	2015-09-09 14:30:28 +0000
@@ -99,12 +99,12 @@
                           <tr>
                             <td colspan="2">
                               <div>
-                                <label for="field.git_repository">Git repository:</label>
+                                <label for="field.git_ref.repository">Git repository:</label>
                                 <div>
                                   <input type="text"
                                          value=""
-                                         id="field.git_repository"
-                                         name="field.git_repository"
+                                         id="field.git_ref.repository"
+                                         name="field.git_ref.repository"
                                          size="20"
                                          class="" />
                                 </div>
@@ -114,11 +114,11 @@
                           <tr>
                             <td colspan="2">
                               <div>
-                                <label for="field.git_path">Git path:</label>
+                                <label for="field.git_ref.path">Git path:</label>
                                 <div>
                                   <input class="textType"
-                                         id="field.git_path"
-                                         name="field.git_path"
+                                         id="field.git_ref.path"
+                                         name="field.git_ref.path"
                                          size="20"
                                          type="text"
                                          value="" />

=== modified file 'lib/lp/snappy/javascript/tests/test_snap.edit.js'
--- lib/lp/snappy/javascript/tests/test_snap.edit.js	2015-09-04 16:20:26 +0000
+++ lib/lp/snappy/javascript/tests/test_snap.edit.js	2015-09-09 14:30:28 +0000
@@ -20,8 +20,8 @@
 
             // Get the input widgets.
             this.input_branch = Y.DOM.byId('field.branch');
-            this.input_git_repository = Y.DOM.byId('field.git_repository');
-            this.input_git_path = Y.DOM.byId('field.git_path');
+            this.input_git_repository = Y.DOM.byId('field.git_ref.repository');
+            this.input_git_path = Y.DOM.byId('field.git_ref.path');
         },
 
         tearDown: function() {
@@ -53,11 +53,12 @@
             // The branch input field is enabled.
             Y.Assert.isFalse(this.input_branch.disabled,
                              'branch field disabled');
-            // The git_repository and git_path input fields are disabled.
+            // The git_ref.repository and git_ref.path input fields are
+            // disabled.
             Y.Assert.isTrue(this.input_git_repository.disabled,
-                            'git_repository field not disabled');
+                            'git_ref.repository field not disabled');
             Y.Assert.isTrue(this.input_git_path.disabled,
-                            'git_path field not disabled');
+                            'git_ref.path field not disabled');
         },
 
         test_select_vcs_git: function() {
@@ -66,11 +67,12 @@
             // The branch input field is disabled.
             Y.Assert.isTrue(this.input_branch.disabled,
                             'branch field not disabled');
-            // The git_repository and git_path input fields are enabled.
+            // The git_ref.repository and git_ref.path input fields are
+            // enabled.
             Y.Assert.isFalse(this.input_git_repository.disabled,
-                             'git_repository field disabled');
+                             'git_ref.repository field disabled');
             Y.Assert.isFalse(this.input_git_path.disabled,
-                             'git_path field disabled');
+                             'git_ref.path field disabled');
         }
     }));
 }, '0.1', {

=== modified file 'lib/lp/snappy/model/snap.py'
--- lib/lp/snappy/model/snap.py	2015-08-07 10:09:26 +0000
+++ lib/lp/snappy/model/snap.py	2015-09-09 14:30:28 +0000
@@ -110,12 +110,12 @@
     require_virtualized = Bool(name='require_virtualized')
 
     def __init__(self, registrant, owner, distro_series, name,
-                 description=None, branch=None, git_repository=None,
-                 git_path=None, require_virtualized=True,
-                 date_created=DEFAULT):
+                 description=None, branch=None, git_ref=None,
+                 require_virtualized=True, date_created=DEFAULT):
         """Construct a `Snap`."""
         if not getFeatureFlag(SNAP_FEATURE_FLAG):
             raise SnapFeatureDisabled
+
         super(Snap, self).__init__()
         self.registrant = registrant
         self.owner = owner
@@ -123,12 +123,30 @@
         self.name = name
         self.description = description
         self.branch = branch
-        self.git_repository = git_repository
-        self.git_path = git_path
+        if git_ref is not None:
+            self.git_repository = git_ref.repository
+            self.git_path = git_ref.path
+        else:
+            self.git_repository = None
+            self.git_path = None
         self.require_virtualized = require_virtualized
         self.date_created = date_created
         self.date_last_modified = date_created
 
+    @property
+    def git_ref(self):
+        """See `ISnap`."""
+        if self.git_repository is not None:
+            return self.git_repository.getRefByPath(self.git_path)
+        else:
+            return None
+
+    @git_ref.setter
+    def git_ref(self, value):
+        """See `ISnap`."""
+        self.git_repository = value.repository
+        self.git_path = value.path
+
     def _getProcessors(self):
         return list(Store.of(self).find(
             Processor,
@@ -280,8 +298,8 @@
     """See `ISnapSet`."""
 
     def new(self, registrant, owner, distro_series, name, description=None,
-            branch=None, git_repository=None, git_path=None,
-            require_virtualized=True, processors=None, date_created=DEFAULT):
+            branch=None, git_ref=None, require_virtualized=True,
+            processors=None, date_created=DEFAULT):
         """See `ISnapSet`."""
         if not registrant.inTeam(owner):
             if owner.is_team:
@@ -293,13 +311,13 @@
                     "%s cannot create snap packages owned by %s." %
                     (registrant.displayname, owner.displayname))
 
-        if branch is None and git_repository is None:
+        if branch is None and git_ref is None:
             raise NoSourceForSnap
 
         store = IMasterStore(Snap)
         snap = Snap(
             registrant, owner, distro_series, name, description=description,
-            branch=branch, git_repository=git_repository, git_path=git_path,
+            branch=branch, git_ref=git_ref,
             require_virtualized=require_virtualized, date_created=date_created)
         store.add(snap)
 

=== modified file 'lib/lp/snappy/model/snapbuildbehaviour.py'
--- lib/lp/snappy/model/snapbuildbehaviour.py	2015-08-05 12:25:30 +0000
+++ lib/lp/snappy/model/snapbuildbehaviour.py	2015-09-09 14:30:28 +0000
@@ -86,9 +86,9 @@
         args["archive_private"] = build.archive.private
         if build.snap.branch is not None:
             args["branch"] = build.snap.branch.bzr_identity
-        elif build.snap.git_repository is not None:
+        elif build.snap.git_ref is not None:
             args["git_repository"] = build.snap.git_repository.git_https_url
-            args["git_path"] = build.snap.git_path
+            args["git_path"] = build.snap.git_ref.name
         else:
             raise CannotBuild(
                 "Source branch/repository for ~%s/%s has been deleted." %

=== modified file 'lib/lp/snappy/templates/snap-edit.pt'
--- lib/lp/snappy/templates/snap-edit.pt	2015-09-07 16:05:51 +0000
+++ lib/lp/snappy/templates/snap-edit.pt	2015-09-09 14:30:28 +0000
@@ -49,10 +49,7 @@
                   <td>
                     <label tal:replace="structure view/vcs_git_radio" />
                     <table class="subordinate">
-                      <tal:widget define="widget nocall:view/widgets/git_repository">
-                        <metal:block use-macro="context/@@launchpad_form/widget_row" />
-                      </tal:widget>
-                      <tal:widget define="widget nocall:view/widgets/git_path">
+                      <tal:widget define="widget nocall:view/widgets/git_ref">
                         <metal:block use-macro="context/@@launchpad_form/widget_row" />
                       </tal:widget>
                     </table>

=== modified file 'lib/lp/snappy/tests/test_snap.py'
--- lib/lp/snappy/tests/test_snap.py	2015-08-05 10:52:13 +0000
+++ lib/lp/snappy/tests/test_snap.py	2015-09-09 14:30:28 +0000
@@ -382,8 +382,7 @@
         if branch is not None:
             components["branch"] = branch
         else:
-            components["git_repository"] = git_ref.repository
-            components["git_path"] = git_ref.path
+            components["git_ref"] = git_ref
         return components
 
     def test_creation_bzr(self):
@@ -392,7 +391,6 @@
         branch = self.factory.makeAnyBranch()
         components = self.makeSnapComponents(branch=branch)
         snap = getUtility(ISnapSet).new(**components)
-        transaction.commit()
         self.assertEqual(components["registrant"], snap.registrant)
         self.assertEqual(components["owner"], snap.owner)
         self.assertEqual(components["distro_series"], snap.distro_series)
@@ -400,6 +398,7 @@
         self.assertEqual(branch, snap.branch)
         self.assertIsNone(snap.git_repository)
         self.assertIsNone(snap.git_path)
+        self.assertIsNone(snap.git_ref)
         self.assertTrue(snap.require_virtualized)
 
     def test_creation_git(self):
@@ -408,7 +407,6 @@
         [ref] = self.factory.makeGitRefs()
         components = self.makeSnapComponents(git_ref=ref)
         snap = getUtility(ISnapSet).new(**components)
-        transaction.commit()
         self.assertEqual(components["registrant"], snap.registrant)
         self.assertEqual(components["owner"], snap.owner)
         self.assertEqual(components["distro_series"], snap.distro_series)
@@ -416,6 +414,7 @@
         self.assertIsNone(snap.branch)
         self.assertEqual(ref.repository, snap.git_repository)
         self.assertEqual(ref.path, snap.git_path)
+        self.assertEqual(ref, snap.git_ref)
         self.assertTrue(snap.require_virtualized)
 
     def test_creation_no_source(self):
@@ -499,10 +498,12 @@
         repositories = [self.factory.makeGitRepository() for i in range(2)]
         snaps = []
         paths = []
+        refs = []
         for repository in repositories:
             for i in range(2):
                 [ref] = self.factory.makeGitRefs(repository=repository)
                 paths.append(ref.path)
+                refs.append(ref)
                 snaps.append(self.factory.makeSnap(
                     git_ref=ref, date_created=ONE_DAY_AGO))
         getUtility(ISnapSet).detachFromGitRepository(repositories[0])
@@ -512,6 +513,8 @@
         self.assertEqual(
             [None, None, paths[2], paths[3]],
             [snap.git_path for snap in snaps])
+        self.assertEqual(
+            [None, None, refs[2], refs[3]], [snap.git_ref for snap in snaps])
         for snap in snaps[:2]:
             self.assertSqlAttributeEqualsDate(
                 snap, "date_last_modified", UTC_NOW)
@@ -601,8 +604,7 @@
         if branch is not None:
             kwargs["branch"] = api_url(branch)
         if git_ref is not None:
-            kwargs["git_repository"] = api_url(git_ref.repository)
-            kwargs["git_path"] = git_ref.path
+            kwargs["git_ref"] = api_url(git_ref)
         if processors is not None:
             kwargs["processors"] = [
                 api_url(processor) for processor in processors]
@@ -635,6 +637,7 @@
             self.assertEqual(self.getURL(branch), snap["branch_link"])
             self.assertIsNone(snap["git_repository_link"])
             self.assertIsNone(snap["git_path"])
+            self.assertIsNone(snap["git_ref_link"])
             self.assertTrue(snap["require_virtualized"])
 
     def test_new_git(self):
@@ -654,6 +657,7 @@
             self.assertEqual(
                 self.getURL(ref.repository), snap["git_repository_link"])
             self.assertEqual(ref.path, snap["git_path"])
+            self.assertEqual(self.getURL(ref), snap["git_ref_link"])
             self.assertTrue(snap["require_virtualized"])
 
     def test_duplicate(self):

=== modified file 'lib/lp/snappy/tests/test_snapbuildbehaviour.py'
--- lib/lp/snappy/tests/test_snapbuildbehaviour.py	2015-08-05 12:25:30 +0000
+++ lib/lp/snappy/tests/test_snapbuildbehaviour.py	2015-09-09 14:30:28 +0000
@@ -180,7 +180,7 @@
             "archives": expected_archives,
             "arch_tag": "i386",
             "git_repository": ref.repository.git_https_url,
-            "git_path": ref.path,
+            "git_path": ref.name,
             "name": u"test-snap",
             }, job._extraBuildArgs())
 
@@ -208,6 +208,21 @@
             "deleted.",
             job.composeBuildRequest, None)
 
+    def test_composeBuildRequest_git_ref_deleted(self):
+        # If the source Git reference has been deleted, composeBuildRequest
+        # raises CannotBuild.
+        repository = self.factory.makeGitRepository()
+        [ref] = self.factory.makeGitRefs(repository=repository)
+        owner = self.factory.makePerson(name="snap-owner")
+        job = self.makeJob(registrant=owner, owner=owner, git_ref=ref)
+        repository.removeRefs([ref.path])
+        self.assertIsNone(job.build.snap.git_ref)
+        self.assertRaisesWithContent(
+            CannotBuild,
+            "Source branch/repository for ~snap-owner/test-snap has been "
+            "deleted.",
+            job.composeBuildRequest, None)
+
 
 class MakeSnapBuildMixin:
     """Provide the common makeBuild method returning a queued build."""

=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py	2015-08-10 07:36:52 +0000
+++ lib/lp/testing/factory.py	2015-09-09 14:30:28 +0000
@@ -4548,18 +4548,12 @@
             distroseries = self.makeDistroSeries()
         if name is None:
             name = self.getUniqueString(u"snap-name")
-        kwargs = {}
         if branch is None and git_ref is None:
             branch = self.makeAnyBranch()
-        if branch is not None:
-            kwargs["branch"] = branch
-        elif git_ref is not None:
-            kwargs["git_repository"] = git_ref.repository
-            kwargs["git_path"] = git_ref.path
         snap = getUtility(ISnapSet).new(
             registrant, owner, distroseries, name,
             require_virtualized=require_virtualized, processors=processors,
-            date_created=date_created, **kwargs)
+            date_created=date_created, branch=branch, git_ref=git_ref)
         IStore(snap).flush()
         return snap
 


Follow ups