Merge lp:~cjwatson/launchpad/git-basic-model into lp:launchpad

Proposed by Colin Watson
Status: Superseded
Proposed branch: lp:~cjwatson/launchpad/git-basic-model
Merge into: lp:launchpad
Diff against target: 1217 lines (+961/-20)
17 files modified
configs/development/launchpad-lazr.conf (+4/-0)
lib/lp/code/interfaces/gitrepository.py (+384/-0)
lib/lp/code/interfaces/hasgitrepositories.py (+50/-0)
lib/lp/code/model/gitrepository.py (+366/-0)
lib/lp/code/model/hasgitrepositories.py (+29/-0)
lib/lp/code/model/tests/test_hasgitrepositories.py (+34/-0)
lib/lp/registry/configure.zcml (+8/-0)
lib/lp/registry/interfaces/distributionsourcepackage.py (+3/-1)
lib/lp/registry/interfaces/person.py (+9/-3)
lib/lp/registry/interfaces/product.py (+10/-3)
lib/lp/registry/model/distributionsourcepackage.py (+3/-1)
lib/lp/registry/model/person.py (+2/-1)
lib/lp/registry/model/product.py (+3/-1)
lib/lp/registry/personmerge.py (+4/-0)
lib/lp/registry/tests/test_product.py (+6/-5)
lib/lp/security.py (+25/-5)
lib/lp/services/config/schema-lazr.conf (+21/-0)
To merge this branch: bzr merge lp:~cjwatson/launchpad/git-basic-model
Reviewer Review Type Date Requested Status
Launchpad code reviewers Pending
Review via email: mp+248959@code.launchpad.net

This proposal has been superseded by a proposal from 2015-02-06.

Commit message

Very basic preliminary model for GitRepository. Make Product, DistributionSourcePackage, and Person implement IHasGitRepositories.

Description of the change

Very basic preliminary model for GitRepository. Make Product, DistributionSourcePackage, and Person implement IHasGitRepositories.

This doesn't really do anything useful by itself. In particular, in the cause of getting this chunk of changes down to a reasonable size, I left out the GitNamespace stuff that makes it possible to actually create GitRepository objects (the general architecture is fairly similar to Branch*), which also means that most of the tests are deferred to a later branch when we have enough infrastructure to support creating test objects.

This branch implements one of the candidate proposals for Git URL layouts in Launchpad. I'm not assuming that this will be the final layout; it's relatively easy to change later.

To post a comment you must log in.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'configs/development/launchpad-lazr.conf'
2--- configs/development/launchpad-lazr.conf 2014-02-27 08:39:44 +0000
3+++ configs/development/launchpad-lazr.conf 2015-02-06 19:34:05 +0000
4@@ -48,6 +48,10 @@
5 access_log: /var/tmp/bazaar.launchpad.dev/codehosting-access.log
6 blacklisted_hostnames:
7 use_forking_daemon: True
8+internal_git_endpoint: http://git.launchpad.dev:19417/
9+git_browse_root: https://git.launchpad.dev/
10+git_anon_root: git://git.launchpad.dev/
11+git_ssh_root: git+ssh://git.launchpad.dev/
12
13 [codeimport]
14 bazaar_branch_store: file:///tmp/bazaar-branches
15
16=== added file 'lib/lp/code/interfaces/gitrepository.py'
17--- lib/lp/code/interfaces/gitrepository.py 1970-01-01 00:00:00 +0000
18+++ lib/lp/code/interfaces/gitrepository.py 2015-02-06 19:34:05 +0000
19@@ -0,0 +1,384 @@
20+# Copyright 2015 Canonical Ltd. This software is licensed under the
21+# GNU Affero General Public License version 3 (see the file LICENSE).
22+
23+"""Git repository interfaces."""
24+
25+__metaclass__ = type
26+
27+__all__ = [
28+ 'GitPathMixin',
29+ 'git_repository_name_validator',
30+ 'IGitRepository',
31+ 'IGitRepositorySet',
32+ 'user_has_special_git_repository_access',
33+ ]
34+
35+import re
36+
37+from lazr.restful.fields import (
38+ Reference,
39+ ReferenceChoice,
40+ )
41+from zope.interface import (
42+ Attribute,
43+ Interface,
44+ )
45+from zope.schema import (
46+ Bool,
47+ Choice,
48+ Datetime,
49+ Int,
50+ Text,
51+ TextLine,
52+ )
53+
54+from lp import _
55+from lp.app.enums import InformationType
56+from lp.app.validators import LaunchpadValidationError
57+from lp.code.interfaces.hasgitrepositories import IHasGitRepositories
58+from lp.registry.interfaces.distributionsourcepackage import (
59+ IDistributionSourcePackage,
60+ )
61+from lp.registry.interfaces.product import IProduct
62+from lp.registry.interfaces.role import IPersonRoles
63+from lp.services.fields import (
64+ PersonChoice,
65+ PublicPersonChoice,
66+ )
67+
68+
69+GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE = _(
70+ "Git repository names must start with a number or letter. The characters "
71+ "+, -, _, . and @ are also allowed after the first character. Repository "
72+ "names must not end with \".git\".")
73+
74+
75+# This is a copy of the pattern in database/schema/patch-2209-61-0.sql.
76+# Don't change it without changing that.
77+valid_git_repository_name_pattern = re.compile(
78+ r"^(?i)[a-z0-9][a-z0-9+\.\-@_]*\Z")
79+
80+
81+def valid_git_repository_name(name):
82+ """Return True iff the name is valid as a Git repository name.
83+
84+ The rules for what is a valid Git repository name are described in
85+ GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE.
86+ """
87+ if (not name.endswith(".git") and
88+ valid_git_repository_name_pattern.match(name)):
89+ return True
90+ return False
91+
92+
93+def git_repository_name_validator(name):
94+ """Return True if the name is valid, or raise a LaunchpadValidationError.
95+ """
96+ if not valid_git_repository_name(name):
97+ raise LaunchpadValidationError(
98+ _("Invalid Git repository name '${name}'. ${message}",
99+ mapping={
100+ "name": name,
101+ "message": GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE,
102+ }))
103+ return True
104+
105+
106+class IGitRepositoryView(Interface):
107+ """IGitRepository attributes that require launchpad.View permission."""
108+
109+ id = Int(title=_("ID"), readonly=True, required=True)
110+
111+ date_created = Datetime(
112+ title=_("Date created"), required=True, readonly=True)
113+
114+ date_last_modified = Datetime(
115+ title=_("Date last modified"), required=True, readonly=False)
116+
117+ registrant = PublicPersonChoice(
118+ title=_("Registrant"), required=True, readonly=True,
119+ vocabulary="ValidPersonOrTeam",
120+ description=_("The person who registered this Git repository."))
121+
122+ owner = PersonChoice(
123+ title=_("Owner"), required=True, readonly=False,
124+ vocabulary="AllUserTeamsParticipationPlusSelf",
125+ description=_(
126+ "The owner of this Git repository. This controls who can modify "
127+ "the repository."))
128+
129+ project = ReferenceChoice(
130+ title=_("Project"), required=False, readonly=True,
131+ vocabulary="Product", schema=IProduct,
132+ description=_(
133+ "The project that this Git repository belongs to. None if it "
134+ "belongs to a distribution source package instead."))
135+
136+ # The distribution and sourcepackagename attributes are exported
137+ # together as distro_source_package.
138+ distribution = Choice(
139+ title=_("Distribution"), required=False,
140+ vocabulary="Distribution",
141+ description=_(
142+ "The distribution that this Git repository belongs to. None if it "
143+ "belongs to a project instead."))
144+
145+ sourcepackagename = Choice(
146+ title=_("Source Package Name"), required=False,
147+ vocabulary="SourcePackageName",
148+ description=_(
149+ "The source package that this Git repository belongs to. None if "
150+ "it belongs to a project instead. Source package repositories "
151+ "always belong to a distribution."))
152+
153+ distro_source_package = Reference(
154+ title=_(
155+ "The IDistributionSourcePackage that this Git repository belongs "
156+ "to. None if it belongs to a project instead."),
157+ schema=IDistributionSourcePackage, required=False, readonly=True)
158+
159+ target = Reference(
160+ title=_("Target"), required=True, readonly=True,
161+ schema=IHasGitRepositories,
162+ description=_("The target of the repository."))
163+
164+ information_type = Choice(
165+ title=_("Information Type"), vocabulary=InformationType,
166+ required=True, readonly=True, default=InformationType.PUBLIC,
167+ description=_(
168+ "The type of information contained in this repository."))
169+
170+ unique_name = Text(
171+ title=_("Unique name"), readonly=True,
172+ description=_(
173+ "Unique name of the repository, including the owner and project "
174+ "names."))
175+
176+ displayname = Text(
177+ title=_("Display name"), readonly=True,
178+ description=_("Display name of the repository."))
179+
180+ shortened_path = Attribute(
181+ "The shortest reasonable version of the path to this repository.")
182+
183+ git_identity = Text(
184+ title=_("Git identity"), readonly=True,
185+ description=_(
186+ "If this is the default repository for some target, then this is "
187+ "'lp:' plus a shortcut version of the path via that target. "
188+ "Otherwise it is simply 'lp:' plus the unique name."))
189+
190+ def codebrowse_url():
191+ """Construct a browsing URL for this branch."""
192+
193+ def addToLaunchBag(launchbag):
194+ """Add information about this branch to `launchbag'.
195+
196+ Use this when traversing to this branch in the web UI.
197+
198+ In particular, add information about the branch's target to the
199+ launchbag. If the branch has a product, add that; if it has a source
200+ package, add lots of information about that.
201+
202+ :param launchbag: `ILaunchBag`.
203+ """
204+
205+ def visibleByUser(user):
206+ """Can the specified user see this repository?"""
207+
208+ def getAllowedInformationTypes(user):
209+ """Get a list of acceptable `InformationType`s for this repository.
210+
211+ If the user is a Launchpad admin, any type is acceptable.
212+ """
213+
214+ def getInternalPath():
215+ """Get the internal path to this repository.
216+
217+ This is used on the storage backend.
218+ """
219+
220+ def getRepositoryLinks():
221+ """Return a sorted list of `ICanHasLinkedGitRepository` objects.
222+
223+ There is one result for each related object that the repository is
224+ linked to. For example, in the case where a branch is linked to a
225+ project and is also its owner's preferred branch for that project,
226+ the link objects for both the project and the person-project are
227+ returned.
228+
229+ The sorting uses the defined order of the linked objects where the
230+ more important links are sorted first.
231+ """
232+
233+ def getRepositoryIdentities():
234+ """A list of aliases for a repository.
235+
236+ Returns a list of tuples of path and context object. There is at
237+ least one alias for any repository, and that is the repository
238+ itself. For linked repositories, the context object is the
239+ appropriate linked object.
240+
241+ Where a repository is linked to a product or a distribution source
242+ package, the repository is available through a number of different
243+ URLs. These URLs are the aliases for the repository.
244+
245+ For example, a repository which is linked to the 'fooix' project and
246+ which is also its owner's preferred repository for that project is
247+ accessible using:
248+ fooix - the linked object is the project fooix
249+ ~fooix-owner/fooix - the linked object is the person-project
250+ ~fooix-owner and fooix
251+ ~fooix-owner/fooix/g/fooix - the unique name of the repository
252+ where the linked object is the repository itself.
253+ """
254+
255+
256+class IGitRepositoryModerateAttributes(Interface):
257+ """IGitRepository attributes that can be edited by more than one community.
258+ """
259+
260+ # XXX cjwatson 2015-01-29: Add some advice about default repository
261+ # naming.
262+ name = TextLine(
263+ title=_("Name"), required=True,
264+ constraint=git_repository_name_validator,
265+ description=_(
266+ "The repository name. Keep very short, unique, and descriptive, "
267+ "because it will be used in URLs."))
268+
269+
270+class IGitRepositoryModerate(Interface):
271+ """IGitRepository methods that can be called by more than one community."""
272+
273+ def transitionToInformationType(information_type, user,
274+ verify_policy=True):
275+ """Set the information type for this repository.
276+
277+ :param information_type: The `InformationType` to transition to.
278+ :param user: The `IPerson` who is making the change.
279+ :param verify_policy: Check if the new information type complies
280+ with the `IGitNamespacePolicy`.
281+ """
282+
283+
284+class IGitRepositoryEdit(Interface):
285+ """IGitRepository methods that require launchpad.Edit permission."""
286+
287+ def setOwner(new_owner, user):
288+ """Set the owner of the repository to be `new_owner`."""
289+
290+ def setTarget(user, target):
291+ """Set the target of the repository."""
292+
293+ def destroySelf():
294+ """Delete the specified repository."""
295+
296+
297+class IGitRepository(IGitRepositoryView, IGitRepositoryModerateAttributes,
298+ IGitRepositoryModerate, IGitRepositoryEdit):
299+ """A Git repository."""
300+
301+ private = Bool(
302+ title=_("Repository is confidential"), required=False, readonly=True,
303+ description=_("This repository is visible only to its subscribers."))
304+
305+
306+class IGitRepositorySet(Interface):
307+ """Interface representing the set of Git repositories."""
308+
309+ def new(registrant, owner, target, name, information_type=None,
310+ date_created=None):
311+ """Create a Git repository and return it.
312+
313+ :param registrant: The `IPerson` who registered the new repository.
314+ :param owner: The `IPerson` who owns the new repository.
315+ :param target: The `IProduct`, `IDistributionSourcePackage`, or
316+ `IPerson` that the new repository is associated with.
317+ :param name: The repository name.
318+ :param information_type: Set the repository's information type to
319+ one different from the target's default. The type must conform
320+ to the target's code sharing policy. (optional)
321+ """
322+
323+ def getByPath(user, path):
324+ """Find a repository by its path.
325+
326+ Any of these forms may be used, with or without a leading slash:
327+ Unique names:
328+ ~OWNER/PROJECT/g/NAME
329+ ~OWNER/DISTRO/+source/SOURCE/g/NAME
330+ ~OWNER/g/NAME
331+ Owner-target default aliases:
332+ ~OWNER/PROJECT
333+ ~OWNER/DISTRO/+source/SOURCE
334+ Official aliases:
335+ PROJECT
336+ DISTRO/+source/SOURCE
337+
338+ Return None if no match was found.
339+ """
340+
341+ def getPersonalRepository(person, repository_name):
342+ """Find a personal repository."""
343+
344+ def getProjectRepository(person, project, repository_name=None):
345+ """Find a project repository."""
346+
347+ def getPackageRepository(person, distribution, sourcepackagename,
348+ repository_name=None):
349+ """Find a package repository."""
350+
351+ def getRepositories(limit=50, eager_load=True):
352+ """Return a collection of repositories.
353+
354+ :param eager_load: If True (the default because this is used in the
355+ web service and it needs the related objects to create links)
356+ eager load related objects (projects, etc.).
357+ """
358+
359+
360+class GitPathMixin:
361+ """This mixin class determines Git repository paths.
362+
363+ Used by both the model GitRepository class and the browser repository
364+ listing item. This allows the browser code to cache the associated
365+ links which reduces query counts.
366+ """
367+
368+ @property
369+ def shortened_path(self):
370+ """See `IGitRepository`."""
371+ path, context = self.getRepositoryIdentities()[0]
372+ return path
373+
374+ @property
375+ def git_identity(self):
376+ """See `IGitRepository`."""
377+ return "lp:" + self.shortened_path
378+
379+ def getRepositoryLinks(self):
380+ """See `IGitRepository`."""
381+ # XXX cjwatson 2015-02-06: This will return shortcut links once
382+ # they're implemented.
383+ return []
384+
385+ def getRepositoryIdentities(self):
386+ """See `IGitRepository`."""
387+ identities = [
388+ (link.path, link.context) for link in self.getRepositoryLinks()]
389+ identities.append((self.unique_name, self))
390+ return identities
391+
392+
393+def user_has_special_git_repository_access(user):
394+ """Admins have special access.
395+
396+ :param user: An `IPerson` or None.
397+ """
398+ if user is None:
399+ return False
400+ roles = IPersonRoles(user)
401+ if roles.in_admin:
402+ return True
403+ return False
404
405=== added file 'lib/lp/code/interfaces/hasgitrepositories.py'
406--- lib/lp/code/interfaces/hasgitrepositories.py 1970-01-01 00:00:00 +0000
407+++ lib/lp/code/interfaces/hasgitrepositories.py 2015-02-06 19:34:05 +0000
408@@ -0,0 +1,50 @@
409+# Copyright 2015 Canonical Ltd. This software is licensed under the
410+# GNU Affero General Public License version 3 (see the file LICENSE).
411+
412+"""Interfaces relating to targets of Git repositories."""
413+
414+__metaclass__ = type
415+
416+__all__ = [
417+ 'IHasGitRepositories',
418+ 'IHasGitRepositoriesEdit',
419+ 'IHasGitRepositoriesView',
420+ ]
421+
422+from zope.interface import Interface
423+
424+
425+class IHasGitRepositoriesView(Interface):
426+ """Viewing an object that has related Git repositories."""
427+
428+ def getGitRepositories(visible_by_user=None, eager_load=False):
429+ """Returns all Git repositories related to this object.
430+
431+ :param visible_by_user: Normally the user who is asking.
432+ :param eager_load: If True, load related objects for the whole
433+ collection.
434+ :returns: A list of `IGitRepository` objects.
435+ """
436+
437+
438+class IHasGitRepositoriesEdit(Interface):
439+ """Editing an object that has related Git repositories."""
440+
441+ def createGitRepository(registrant, owner, name, information_type=None):
442+ """Create a Git repository for this target and return it.
443+
444+ :param registrant: The `IPerson` who registered the new repository.
445+ :param owner: The `IPerson` who owns the new repository.
446+ :param name: The repository name.
447+ :param information_type: Set the repository's information type to
448+ one different from the target's default. The type must conform
449+ to the target's code sharing policy. (optional)
450+ """
451+
452+
453+class IHasGitRepositories(IHasGitRepositoriesView, IHasGitRepositoriesEdit):
454+ """An object that has related Git repositories.
455+
456+ A project contains Git repositories, a source package on a distribution
457+ contains branches, and a person contains "personal" branches.
458+ """
459
460=== added file 'lib/lp/code/model/gitrepository.py'
461--- lib/lp/code/model/gitrepository.py 1970-01-01 00:00:00 +0000
462+++ lib/lp/code/model/gitrepository.py 2015-02-06 19:34:05 +0000
463@@ -0,0 +1,366 @@
464+# Copyright 2015 Canonical Ltd. This software is licensed under the
465+# GNU Affero General Public License version 3 (see the file LICENSE).
466+
467+__metaclass__ = type
468+__all__ = [
469+ 'get_git_repository_privacy_filter',
470+ 'GitRepository',
471+ 'GitRepositorySet',
472+ ]
473+
474+from bzrlib import urlutils
475+import pytz
476+from storm.expr import (
477+ Coalesce,
478+ Join,
479+ Or,
480+ Select,
481+ SQL,
482+ )
483+from storm.locals import (
484+ DateTime,
485+ Int,
486+ Reference,
487+ Unicode,
488+ )
489+from zope.component import getUtility
490+from zope.interface import implements
491+
492+from lp.app.enums import (
493+ InformationType,
494+ PRIVATE_INFORMATION_TYPES,
495+ PUBLIC_INFORMATION_TYPES,
496+ )
497+from lp.app.interfaces.informationtype import IInformationType
498+from lp.app.interfaces.launchpad import IPrivacy
499+from lp.app.interfaces.services import IService
500+from lp.code.interfaces.gitrepository import (
501+ GitPathMixin,
502+ IGitRepository,
503+ IGitRepositorySet,
504+ user_has_special_git_repository_access,
505+ )
506+from lp.registry.errors import CannotChangeInformationType
507+from lp.registry.interfaces.accesspolicy import (
508+ IAccessArtifactSource,
509+ IAccessPolicySource,
510+ )
511+from lp.registry.interfaces.role import IHasOwner
512+from lp.registry.interfaces.sharingjob import (
513+ IRemoveArtifactSubscriptionsJobSource,
514+ )
515+from lp.registry.model.accesspolicy import (
516+ AccessPolicyGrant,
517+ reconcile_access_for_artifact,
518+ )
519+from lp.registry.model.teammembership import TeamParticipation
520+from lp.services.config import config
521+from lp.services.database.constants import (
522+ DEFAULT,
523+ UTC_NOW,
524+ )
525+from lp.services.database.enumcol import EnumCol
526+from lp.services.database.stormbase import StormBase
527+from lp.services.database.stormexpr import (
528+ Array,
529+ ArrayAgg,
530+ ArrayIntersects,
531+ )
532+from lp.services.propertycache import cachedproperty
533+
534+
535+def git_repository_modified(repository, event):
536+ """Update the date_last_modified property when a GitRepository is modified.
537+
538+ This method is registered as a subscriber to `IObjectModifiedEvent`
539+ events on Git repositories.
540+ """
541+ repository.date_last_modified = UTC_NOW
542+
543+
544+class GitRepository(StormBase, GitPathMixin):
545+ """See `IGitRepository`."""
546+
547+ __storm_table__ = 'GitRepository'
548+
549+ implements(IGitRepository, IHasOwner, IPrivacy, IInformationType)
550+
551+ id = Int(primary=True)
552+
553+ date_created = DateTime(
554+ name='date_created', tzinfo=pytz.UTC, allow_none=False)
555+ date_last_modified = DateTime(
556+ name='date_last_modified', tzinfo=pytz.UTC, allow_none=False)
557+
558+ registrant_id = Int(name='registrant', allow_none=False)
559+ registrant = Reference(registrant_id, 'Person.id')
560+
561+ owner_id = Int(name='owner', allow_none=False)
562+ owner = Reference(owner_id, 'Person.id')
563+
564+ project_id = Int(name='project', allow_none=True)
565+ project = Reference(project_id, 'Product.id')
566+
567+ distribution_id = Int(name='distribution', allow_none=True)
568+ distribution = Reference(distribution_id, 'Distribution.id')
569+
570+ sourcepackagename_id = Int(name='sourcepackagename', allow_none=True)
571+ sourcepackagename = Reference(sourcepackagename_id, 'SourcePackageName.id')
572+
573+ name = Unicode(name='name', allow_none=False)
574+
575+ information_type = EnumCol(enum=InformationType, notNull=True)
576+ access_policy = Int(name='access_policy')
577+
578+ def __init__(self, registrant, owner, name, information_type, date_created,
579+ project=None, distribution=None, sourcepackagename=None):
580+ super(GitRepository, self).__init__()
581+ self.registrant = registrant
582+ self.owner = owner
583+ self.name = name
584+ self.information_type = information_type
585+ self.date_created = date_created
586+ self.date_last_modified = date_created
587+ self.project = project
588+ self.distribution = distribution
589+ self.sourcepackagename = sourcepackagename
590+
591+ @property
592+ def unique_name(self):
593+ names = {"owner": self.owner.name, "repository": self.name}
594+ if self.project is not None:
595+ fmt = "~%(owner)s/%(project)s"
596+ names["project"] = self.project.name
597+ elif self.distribution is not None:
598+ fmt = "~%(owner)s/%(distribution)s/+source/%(source)s"
599+ names["distribution"] = self.distribution.name
600+ names["source"] = self.sourcepackagename.name
601+ else:
602+ fmt = "~%(owner)s"
603+ fmt += "/g/%(repository)s"
604+ return fmt % names
605+
606+ def __repr__(self):
607+ return "<GitRepository %r (%d)>" % (self.unique_name, self.id)
608+
609+ @property
610+ def target(self):
611+ """See `IGitRepository`."""
612+ if self.project is None:
613+ if self.distribution is None:
614+ return self.owner
615+ else:
616+ return self.distro_source_package
617+ else:
618+ return self.project
619+
620+ def setTarget(self, user, target):
621+ """See `IGitRepository`."""
622+ # XXX cjwatson 2015-02-06: Fill this in once IGitNamespace is in
623+ # place.
624+ raise NotImplementedError
625+
626+ @property
627+ def displayname(self):
628+ return self.git_identity
629+
630+ def getInternalPath(self):
631+ """See `IGitRepository`."""
632+ # This may need to change later to improve support for sharding.
633+ return str(self.id)
634+
635+ def codebrowse_url(self):
636+ """See `IGitRepository`."""
637+ return urlutils.join(
638+ config.codehosting.git_browse_root, self.unique_name)
639+
640+ @cachedproperty
641+ def distro_source_package(self):
642+ """See `IGitRepository`."""
643+ if self.distribution is not None:
644+ return self.distribution.getSourcePackage(self.sourcepackagename)
645+ else:
646+ return None
647+
648+ @property
649+ def private(self):
650+ return self.information_type in PRIVATE_INFORMATION_TYPES
651+
652+ def _reconcileAccess(self):
653+ """Reconcile the repository's sharing information.
654+
655+ Takes the information_type and target and makes the related
656+ AccessArtifact and AccessPolicyArtifacts match.
657+ """
658+ wanted_links = None
659+ pillars = []
660+ # For private personal repositories, we calculate the wanted grants.
661+ if (not self.project and not self.distribution and
662+ not self.information_type in PUBLIC_INFORMATION_TYPES):
663+ aasource = getUtility(IAccessArtifactSource)
664+ [abstract_artifact] = aasource.ensure([self])
665+ wanted_links = set(
666+ (abstract_artifact, policy) for policy in
667+ getUtility(IAccessPolicySource).findByTeam([self.owner]))
668+ else:
669+ # We haven't yet quite worked out how distribution privacy
670+ # works, so only work for projects for now.
671+ if self.project is not None:
672+ pillars = [self.project]
673+ reconcile_access_for_artifact(
674+ self, self.information_type, pillars, wanted_links)
675+
676+ def addToLaunchBag(self, launchbag):
677+ """See `IGitRepository`."""
678+ launchbag.add(self.project)
679+ launchbag.add(self.distribution)
680+
681+ @cachedproperty
682+ def _known_viewers(self):
683+ """A set of known persons able to view this repository.
684+
685+ This method must return an empty set or repository searches will
686+ trigger late evaluation. Any 'should be set on load' properties
687+ must be done by the repository search.
688+
689+ If you are tempted to change this method, don't. Instead see
690+ visibleByUser which defines the just-in-time policy for repository
691+ visibility, and IGitCollection which honours visibility rules.
692+ """
693+ return set()
694+
695+ def visibleByUser(self, user):
696+ """See `IGitRepository`."""
697+ if self.information_type in PUBLIC_INFORMATION_TYPES:
698+ return True
699+ elif user is None:
700+ return False
701+ elif user.id in self._known_viewers:
702+ return True
703+ else:
704+ # XXX cjwatson 2015-02-06: Fill this in once IGitCollection is
705+ # in place.
706+ return False
707+
708+ def getAllowedInformationTypes(self, user):
709+ """See `IGitRepository`."""
710+ if user_has_special_git_repository_access(user):
711+ # Admins can set any type.
712+ types = set(PUBLIC_INFORMATION_TYPES + PRIVATE_INFORMATION_TYPES)
713+ else:
714+ # Otherwise the permitted types are defined by the namespace.
715+ # XXX cjwatson 2015-01-19: Define permitted types properly. For
716+ # now, non-admins only get public repository access.
717+ types = set(PUBLIC_INFORMATION_TYPES)
718+ return types
719+
720+ def transitionToInformationType(self, information_type, user,
721+ verify_policy=True):
722+ """See `IGitRepository`."""
723+ if self.information_type == information_type:
724+ return
725+ if (verify_policy and
726+ information_type not in self.getAllowedInformationTypes(user)):
727+ raise CannotChangeInformationType("Forbidden by project policy.")
728+ self.information_type = information_type
729+ self._reconcileAccess()
730+ # XXX cjwatson 2015-02-05: Once we have repository subscribers, we
731+ # need to grant them access if necessary. For now, treat the owner
732+ # as always subscribed, which is just about enough to make the
733+ # GitCollection tests pass.
734+ if information_type in PRIVATE_INFORMATION_TYPES:
735+ # Grant the subscriber access if they can't see the repository.
736+ service = getUtility(IService, "sharing")
737+ blind_subscribers = service.getPeopleWithoutAccess(
738+ self, [self.owner])
739+ if len(blind_subscribers):
740+ service.ensureAccessGrants(
741+ blind_subscribers, user, gitrepositories=[self],
742+ ignore_permissions=True)
743+ # As a result of the transition, some subscribers may no longer have
744+ # access to the repository. We need to run a job to remove any such
745+ # subscriptions.
746+ getUtility(IRemoveArtifactSubscriptionsJobSource).create(user, [self])
747+
748+ def setOwner(self, new_owner, user):
749+ """See `IGitRepository`."""
750+ # XXX cjwatson 2015-02-06: Fill this in once IGitNamespace is in
751+ # place.
752+ raise NotImplementedError
753+
754+ def destroySelf(self):
755+ raise NotImplementedError
756+
757+
758+class GitRepositorySet:
759+ """See `IGitRepositorySet`."""
760+
761+ implements(IGitRepositorySet)
762+
763+ def new(self, registrant, owner, target, name, information_type=None,
764+ date_created=DEFAULT):
765+ """See `IGitRepositorySet`."""
766+ # XXX cjwatson 2015-02-06: Fill this in once IGitNamespace is in
767+ # place.
768+ raise NotImplementedError
769+
770+ def getPersonalRepository(self, person, repository_name):
771+ """See `IGitRepositorySet`."""
772+ # XXX cjwatson 2015-02-06: Fill this in once IGitNamespace is in
773+ # place.
774+ raise NotImplementedError
775+
776+ def getProjectRepository(self, person, project, repository_name=None):
777+ """See `IGitRepositorySet`."""
778+ # XXX cjwatson 2015-02-06: Fill this in once IGitNamespace is in
779+ # place.
780+ raise NotImplementedError
781+
782+ def getPackageRepository(self, person, distribution, sourcepackagename,
783+ repository_name=None):
784+ """See `IGitRepositorySet`."""
785+ # XXX cjwatson 2015-02-06: Fill this in once IGitNamespace is in
786+ # place.
787+ raise NotImplementedError
788+
789+ def getByPath(self, user, path):
790+ """See `IGitRepositorySet`."""
791+ # XXX cjwatson 2015-02-06: Fill this in once IGitLookup is in place.
792+ raise NotImplementedError
793+
794+ def getRepositories(self, limit=50, eager_load=True):
795+ """See `IGitRepositorySet`."""
796+ # XXX cjwatson 2015-02-06: Fill this in once IGitCollection is in
797+ # place.
798+ raise NotImplementedError
799+
800+
801+def get_git_repository_privacy_filter(user):
802+ public_filter = GitRepository.information_type.is_in(
803+ PUBLIC_INFORMATION_TYPES)
804+
805+ if user is None:
806+ return [public_filter]
807+
808+ artifact_grant_query = Coalesce(
809+ ArrayIntersects(
810+ SQL("GitRepository.access_grants"),
811+ Select(
812+ ArrayAgg(TeamParticipation.teamID),
813+ tables=TeamParticipation,
814+ where=(TeamParticipation.person == user)
815+ )), False)
816+
817+ policy_grant_query = Coalesce(
818+ ArrayIntersects(
819+ Array(GitRepository.access_policy),
820+ Select(
821+ ArrayAgg(AccessPolicyGrant.policy_id),
822+ tables=(AccessPolicyGrant,
823+ Join(TeamParticipation,
824+ TeamParticipation.teamID ==
825+ AccessPolicyGrant.grantee_id)),
826+ where=(TeamParticipation.person == user)
827+ )), False)
828+
829+ return [Or(public_filter, artifact_grant_query, policy_grant_query)]
830
831=== added file 'lib/lp/code/model/hasgitrepositories.py'
832--- lib/lp/code/model/hasgitrepositories.py 1970-01-01 00:00:00 +0000
833+++ lib/lp/code/model/hasgitrepositories.py 2015-02-06 19:34:05 +0000
834@@ -0,0 +1,29 @@
835+# Copyright 2015 Canonical Ltd. This software is licensed under the
836+# GNU Affero General Public License version 3 (see the file LICENSE).
837+
838+__metaclass__ = type
839+__all__ = [
840+ 'HasGitRepositoriesMixin',
841+ 'HasGitShortcutsMixin',
842+ ]
843+
844+from zope.component import getUtility
845+
846+from lp.code.interfaces.gitrepository import IGitRepositorySet
847+
848+
849+class HasGitRepositoriesMixin:
850+ """A mixin implementation for `IHasGitRepositories`."""
851+
852+ def createGitRepository(self, registrant, owner, name,
853+ information_type=None):
854+ """See `IHasGitRepositories`."""
855+ return getUtility(IGitRepositorySet).new(
856+ registrant, owner, self, name,
857+ information_type=information_type)
858+
859+ def getGitRepositories(self, visible_by_user=None, eager_load=False):
860+ """See `IHasGitRepositories`."""
861+ # XXX cjwatson 2015-02-06: Fill this in once IGitCollection is in
862+ # place.
863+ raise NotImplementedError
864
865=== added file 'lib/lp/code/model/tests/test_hasgitrepositories.py'
866--- lib/lp/code/model/tests/test_hasgitrepositories.py 1970-01-01 00:00:00 +0000
867+++ lib/lp/code/model/tests/test_hasgitrepositories.py 2015-02-06 19:34:05 +0000
868@@ -0,0 +1,34 @@
869+# Copyright 2015 Canonical Ltd. This software is licensed under the
870+# GNU Affero General Public License version 3 (see the file LICENSE).
871+
872+"""Tests for classes that implement IHasGitRepositories."""
873+
874+__metaclass__ = type
875+
876+from lp.code.interfaces.hasgitrepositories import IHasGitRepositories
877+from lp.testing import (
878+ TestCaseWithFactory,
879+ verifyObject,
880+ )
881+from lp.testing.layers import DatabaseFunctionalLayer
882+
883+
884+class TestIHasGitRepositories(TestCaseWithFactory):
885+ """Test that the correct objects implement the interface."""
886+
887+ layer = DatabaseFunctionalLayer
888+
889+ def test_project_implements_hasgitrepositories(self):
890+ # Projects should implement IHasGitRepositories.
891+ project = self.factory.makeProduct()
892+ verifyObject(IHasGitRepositories, project)
893+
894+ def test_dsp_implements_hasgitrepositories(self):
895+ # DistributionSourcePackages should implement IHasGitRepositories.
896+ dsp = self.factory.makeDistributionSourcePackage()
897+ verifyObject(IHasGitRepositories, dsp)
898+
899+ def test_person_implements_hasgitrepositories(self):
900+ # People should implement IHasGitRepositories.
901+ person = self.factory.makePerson()
902+ verifyObject(IHasGitRepositories, person)
903
904=== modified file 'lib/lp/registry/configure.zcml'
905--- lib/lp/registry/configure.zcml 2015-01-29 16:28:30 +0000
906+++ lib/lp/registry/configure.zcml 2015-02-06 19:34:05 +0000
907@@ -556,6 +556,14 @@
908 bug_reporting_guidelines
909 enable_bugfiling_duplicate_search
910 "/>
911+
912+ <!-- IHasGitRepositories -->
913+
914+ <allow
915+ interface="lp.code.interfaces.hasgitrepositories.IHasGitRepositoriesView" />
916+ <require
917+ permission="launchpad.Edit"
918+ interface="lp.code.interfaces.hasgitrepositories.IHasGitRepositoriesEdit" />
919 </class>
920 <adapter
921 provides="lp.registry.interfaces.distribution.IDistribution"
922
923=== modified file 'lib/lp/registry/interfaces/distributionsourcepackage.py'
924--- lib/lp/registry/interfaces/distributionsourcepackage.py 2014-11-28 22:28:40 +0000
925+++ lib/lp/registry/interfaces/distributionsourcepackage.py 2015-02-06 19:34:05 +0000
926@@ -34,6 +34,7 @@
927 IHasBranches,
928 IHasMergeProposals,
929 )
930+from lp.code.interfaces.hasgitrepositories import IHasGitRepositories
931 from lp.registry.interfaces.distribution import IDistribution
932 from lp.registry.interfaces.role import IHasDrivers
933 from lp.soyuz.enums import ArchivePurpose
934@@ -42,7 +43,8 @@
935 class IDistributionSourcePackage(IHeadingContext, IBugTarget, IHasBranches,
936 IHasMergeProposals, IHasOfficialBugTags,
937 IStructuralSubscriptionTarget,
938- IQuestionTarget, IHasDrivers):
939+ IQuestionTarget, IHasDrivers,
940+ IHasGitRepositories):
941 """Represents a source package in a distribution.
942
943 Create IDistributionSourcePackages by invoking
944
945=== modified file 'lib/lp/registry/interfaces/person.py'
946--- lib/lp/registry/interfaces/person.py 2015-01-30 18:24:07 +0000
947+++ lib/lp/registry/interfaces/person.py 2015-02-06 19:34:05 +0000
948@@ -111,6 +111,11 @@
949 IHasMergeProposals,
950 IHasRequestedReviews,
951 )
952+from lp.code.interfaces.hasgitrepositories import (
953+ IHasGitRepositories,
954+ IHasGitRepositoriesEdit,
955+ IHasGitRepositoriesView,
956+ )
957 from lp.code.interfaces.hasrecipes import IHasRecipes
958 from lp.registry.enums import (
959 EXCLUSIVE_TEAM_POLICY,
960@@ -688,7 +693,8 @@
961 IHasMergeProposals, IHasMugshot,
962 IHasLocation, IHasRequestedReviews, IObjectWithLocation,
963 IHasBugs, IHasRecipes, IHasTranslationImports,
964- IPersonSettings, IQuestionsPerson):
965+ IPersonSettings, IQuestionsPerson,
966+ IHasGitRepositoriesView):
967 """IPerson attributes that require launchpad.View permission."""
968 account = Object(schema=IAccount)
969 accountID = Int(title=_('Account ID'), required=True, readonly=True)
970@@ -1581,7 +1587,7 @@
971 """
972
973
974-class IPersonEditRestricted(Interface):
975+class IPersonEditRestricted(IHasGitRepositoriesEdit):
976 """IPerson attributes that require launchpad.Edit permission."""
977
978 @call_with(requester=REQUEST_USER)
979@@ -1870,7 +1876,7 @@
980 class IPerson(IPersonPublic, IPersonLimitedView, IPersonViewRestricted,
981 IPersonEditRestricted, IPersonModerateRestricted,
982 IPersonSpecialRestricted, IHasStanding, ISetLocation,
983- IHeadingContext):
984+ IHeadingContext, IHasGitRepositories):
985 """A Person."""
986 export_as_webservice_entry(plural_name='people')
987
988
989=== modified file 'lib/lp/registry/interfaces/product.py'
990--- lib/lp/registry/interfaces/product.py 2015-01-30 18:24:07 +0000
991+++ lib/lp/registry/interfaces/product.py 2015-02-06 19:34:05 +0000
992@@ -102,6 +102,11 @@
993 IHasCodeImports,
994 IHasMergeProposals,
995 )
996+from lp.code.interfaces.hasgitrepositories import (
997+ IHasGitRepositories,
998+ IHasGitRepositoriesEdit,
999+ IHasGitRepositoriesView,
1000+ )
1001 from lp.code.interfaces.hasrecipes import IHasRecipes
1002 from lp.registry.enums import (
1003 BranchSharingPolicy,
1004@@ -475,7 +480,8 @@
1005 IHasMugshot, IHasSprints, IHasTranslationImports,
1006 ITranslationPolicy, IKarmaContext, IMakesAnnouncements,
1007 IOfficialBugTagTargetPublic, IHasOOPSReferences,
1008- IHasRecipes, IHasCodeImports, IServiceUsage):
1009+ IHasRecipes, IHasCodeImports, IServiceUsage,
1010+ IHasGitRepositoriesView):
1011 """Public IProduct properties."""
1012
1013 registrant = exported(
1014@@ -837,7 +843,8 @@
1015 """
1016
1017
1018-class IProductEditRestricted(IOfficialBugTagTargetRestricted):
1019+class IProductEditRestricted(IOfficialBugTagTargetRestricted,
1020+ IHasGitRepositoriesEdit):
1021 """`IProduct` properties which require launchpad.Edit permission."""
1022
1023 @mutator_for(IProductView['bug_sharing_policy'])
1024@@ -889,7 +896,7 @@
1025 IProductModerateRestricted, IProductDriverRestricted, IProductView,
1026 IProductLimitedView, IProductPublic, IQuestionTarget,
1027 ISpecificationTarget, IStructuralSubscriptionTarget, IInformationType,
1028- IPillar):
1029+ IPillar, IHasGitRepositories):
1030 """A Product.
1031
1032 The Launchpad Registry describes the open source world as ProjectGroups
1033
1034=== modified file 'lib/lp/registry/model/distributionsourcepackage.py'
1035--- lib/lp/registry/model/distributionsourcepackage.py 2014-11-27 20:52:37 +0000
1036+++ lib/lp/registry/model/distributionsourcepackage.py 2015-02-06 19:34:05 +0000
1037@@ -43,6 +43,7 @@
1038 HasBranchesMixin,
1039 HasMergeProposalsMixin,
1040 )
1041+from lp.code.model.hasgitrepositories import HasGitRepositoriesMixin
1042 from lp.registry.interfaces.distributionsourcepackage import (
1043 IDistributionSourcePackage,
1044 )
1045@@ -119,7 +120,8 @@
1046 HasBranchesMixin,
1047 HasCustomLanguageCodesMixin,
1048 HasMergeProposalsMixin,
1049- HasDriversMixin):
1050+ HasDriversMixin,
1051+ HasGitRepositoriesMixin):
1052 """This is a "Magic Distribution Source Package". It is not an
1053 SQLObject, but instead it represents a source package with a particular
1054 name in a particular distribution. You can then ask it all sorts of
1055
1056=== modified file 'lib/lp/registry/model/person.py'
1057--- lib/lp/registry/model/person.py 2015-01-28 16:10:51 +0000
1058+++ lib/lp/registry/model/person.py 2015-02-06 19:34:05 +0000
1059@@ -146,6 +146,7 @@
1060 HasMergeProposalsMixin,
1061 HasRequestedReviewsMixin,
1062 )
1063+from lp.code.model.hasgitrepositories import HasGitRepositoriesMixin
1064 from lp.registry.enums import (
1065 EXCLUSIVE_TEAM_POLICY,
1066 INCLUSIVE_TEAM_POLICY,
1067@@ -476,7 +477,7 @@
1068 class Person(
1069 SQLBase, HasBugsBase, HasSpecificationsMixin, HasTranslationImportsMixin,
1070 HasBranchesMixin, HasMergeProposalsMixin, HasRequestedReviewsMixin,
1071- QuestionsPersonMixin):
1072+ QuestionsPersonMixin, HasGitRepositoriesMixin):
1073 """A Person."""
1074
1075 implements(IPerson, IHasIcon, IHasLogo, IHasMugshot)
1076
1077=== modified file 'lib/lp/registry/model/product.py'
1078--- lib/lp/registry/model/product.py 2015-01-29 16:28:30 +0000
1079+++ lib/lp/registry/model/product.py 2015-02-06 19:34:05 +0000
1080@@ -124,6 +124,7 @@
1081 HasCodeImportsMixin,
1082 HasMergeProposalsMixin,
1083 )
1084+from lp.code.model.hasgitrepositories import HasGitRepositoriesMixin
1085 from lp.code.model.sourcepackagerecipe import SourcePackageRecipe
1086 from lp.code.model.sourcepackagerecipedata import SourcePackageRecipeData
1087 from lp.registry.enums import (
1088@@ -361,7 +362,8 @@
1089 OfficialBugTagTargetMixin, HasBranchesMixin,
1090 HasCustomLanguageCodesMixin, HasMergeProposalsMixin,
1091 HasCodeImportsMixin, InformationTypeMixin,
1092- TranslationPolicyMixin):
1093+ TranslationPolicyMixin,
1094+ HasGitRepositoriesMixin):
1095 """A Product."""
1096
1097 implements(
1098
1099=== modified file 'lib/lp/registry/personmerge.py'
1100--- lib/lp/registry/personmerge.py 2014-06-18 18:29:13 +0000
1101+++ lib/lp/registry/personmerge.py 2015-02-06 19:34:05 +0000
1102@@ -691,6 +691,10 @@
1103 ('bugsummaryjournal', 'viewed_by'),
1104 ('latestpersonsourcepackagereleasecache', 'creator'),
1105 ('latestpersonsourcepackagereleasecache', 'maintainer'),
1106+ # These need handling before we deploy the git code, but can be
1107+ # ignored for the purpose of deploying the database tables.
1108+ ('gitrepository', 'owner'),
1109+ ('gitshortcut', 'owner'),
1110 ]
1111
1112 references = list(postgresql.listReferences(cur, 'person', 'id'))
1113
1114=== modified file 'lib/lp/registry/tests/test_product.py'
1115--- lib/lp/registry/tests/test_product.py 2015-01-29 16:28:30 +0000
1116+++ lib/lp/registry/tests/test_product.py 2015-02-06 19:34:05 +0000
1117@@ -858,10 +858,10 @@
1118 'getCustomLanguageCode', 'getDefaultBugInformationType',
1119 'getDefaultSpecificationInformationType',
1120 'getEffectiveTranslationPermission', 'getExternalBugTracker',
1121- 'getFAQ', 'getFirstEntryToImport', 'getLinkedBugWatches',
1122- 'getMergeProposals', 'getMilestone', 'getMilestonesAndReleases',
1123- 'getQuestion', 'getQuestionLanguages', 'getPackage', 'getRelease',
1124- 'getSeries', 'getSubscription',
1125+ 'getFAQ', 'getFirstEntryToImport', 'getGitRepositories',
1126+ 'getLinkedBugWatches', 'getMergeProposals', 'getMilestone',
1127+ 'getMilestonesAndReleases', 'getQuestion', 'getQuestionLanguages',
1128+ 'getPackage', 'getRelease', 'getSeries', 'getSubscription',
1129 'getSubscriptions', 'getSupportedLanguages', 'getTimeline',
1130 'getTopContributors', 'getTopContributorsGroupedByCategory',
1131 'getTranslationGroups', 'getTranslationImportQueueEntries',
1132@@ -902,7 +902,8 @@
1133 'launchpad.Edit': set((
1134 'addOfficialBugTag', 'removeOfficialBugTag',
1135 'setBranchSharingPolicy', 'setBugSharingPolicy',
1136- 'setSpecificationSharingPolicy', 'checkInformationType')),
1137+ 'setSpecificationSharingPolicy', 'checkInformationType',
1138+ 'createGitRepository')),
1139 'launchpad.Moderate': set((
1140 'is_permitted', 'license_approved', 'project_reviewed',
1141 'reviewer_whiteboard', 'setAliases')),
1142
1143=== modified file 'lib/lp/security.py'
1144--- lib/lp/security.py 2015-01-06 04:52:44 +0000
1145+++ lib/lp/security.py 2015-02-06 19:34:05 +0000
1146@@ -1151,14 +1151,34 @@
1147
1148
1149 class EditDistributionSourcePackage(AuthorizationBase):
1150- """DistributionSourcePackage is not editable.
1151-
1152- But EditStructuralSubscription needs launchpad.Edit defined on all
1153- targets.
1154- """
1155 permission = 'launchpad.Edit'
1156 usedfor = IDistributionSourcePackage
1157
1158+ def checkAuthenticated(self, user):
1159+ """Anyone who can upload a package can edit it.
1160+
1161+ Checking upload permission requires a distroseries; a reasonable
1162+ approximation is to check whether the user can upload the package to
1163+ the current series.
1164+ """
1165+ if user.in_admin:
1166+ return True
1167+
1168+ distribution = self.obj.distribution
1169+ if user.inTeam(distribution.owner):
1170+ return True
1171+
1172+ # We use verifyUpload() instead of checkUpload() because we don't
1173+ # have a pocket. It returns the reason the user can't upload or
1174+ # None if they are allowed.
1175+ if distribution.currentseries is None:
1176+ return False
1177+ reason = distribution.main_archive.verifyUpload(
1178+ user.person, sourcepackagename=self.obj.sourcepackagename,
1179+ component=None, distroseries=distribution.currentseries,
1180+ strict_component=False)
1181+ return reason is None
1182+
1183
1184 class BugTargetOwnerOrBugSupervisorOrAdmins(AuthorizationBase):
1185 """Product's owner and bug supervisor can set official bug tags."""
1186
1187=== modified file 'lib/lp/services/config/schema-lazr.conf'
1188--- lib/lp/services/config/schema-lazr.conf 2014-08-05 08:58:14 +0000
1189+++ lib/lp/services/config/schema-lazr.conf 2015-02-06 19:34:05 +0000
1190@@ -335,6 +335,27 @@
1191 # of shutting down and so should not receive any more connections.
1192 web_status_port = tcp:8022
1193
1194+# The URL of the internal Git hosting API endpoint.
1195+internal_git_endpoint: none
1196+
1197+# The URL prefix for links to the Git code browser. Links are formed by
1198+# appending the repository's path to the root URL.
1199+#
1200+# datatype: urlbase
1201+git_browse_root: https://git.launchpad.net/
1202+
1203+# The URL prefix for anonymous Git protocol fetches. Links are formed by
1204+# appending the repository's path to the root URL.
1205+#
1206+# datatype: urlbase
1207+git_anon_root: git://git.launchpad.net/
1208+
1209+# The URL prefix for Git-over-SSH. Links are formed by appending the
1210+# repository's path to the root URL.
1211+#
1212+# datatype: urlbase
1213+git_ssh_root: git+ssh://git.launchpad.net/
1214+
1215
1216 [codeimport]
1217 # Where the Bazaar imports are stored.