Merge lp:~jelmer/brz/merge-3.1 into lp:brz

Proposed by Jelmer Vernooij
Status: Merged
Approved by: Jelmer Vernooij
Approved revision: no longer in the source branch.
Merge reported by: The Breezy Bot
Merged at revision: not available
Proposed branch: lp:~jelmer/brz/merge-3.1
Merge into: lp:brz
Diff against target: 1683 lines (+1071/-85)
30 files modified
.github/workflows/pythonpackage.yml (+1/-1)
breezy/bzr/__init__.py (+14/-1)
breezy/bzr/tests/test_bzrdir.py (+11/-1)
breezy/commands.py (+4/-1)
breezy/dirty_tracker.py (+108/-0)
breezy/errors.py (+0/-9)
breezy/git/dir.py (+5/-0)
breezy/git/revspec.py (+26/-17)
breezy/memorybranch.py (+89/-0)
breezy/plugin.py (+5/-1)
breezy/plugins/github/hoster.py (+8/-0)
breezy/plugins/gitlab/hoster.py (+25/-8)
breezy/plugins/launchpad/hoster.py (+22/-10)
breezy/plugins/launchpad/lp_api.py (+13/-0)
breezy/plugins/propose/__init__.py (+1/-0)
breezy/plugins/propose/cmds.py (+28/-15)
breezy/plugins/svn/__init__.py (+6/-0)
breezy/plugins/svn/revspec.py (+44/-0)
breezy/propose.py (+32/-7)
breezy/revisionspec.py (+15/-1)
breezy/tests/__init__.py (+3/-0)
breezy/tests/test_dirty_tracker.py (+98/-0)
breezy/tests/test_memorybranch.py (+46/-0)
breezy/tests/test_plugins.py (+28/-10)
breezy/tests/test_workspace.py (+204/-0)
breezy/workspace.py (+217/-0)
byov.conf (+1/-1)
doc/en/conf.py (+2/-2)
doc/en/release-notes/brz-3.1.txt (+14/-0)
setup.py (+1/-0)
To merge this branch: bzr merge lp:~jelmer/brz/merge-3.1
Reviewer Review Type Date Requested Status
Jelmer Vernooij Approve
Review via email: mp+388173@code.launchpad.net

Commit message

Merge lp:brz/3.1.

Description of the change

Merge lp:brz/3.1.

To post a comment you must log in.
Revision history for this message
Jelmer Vernooij (jelmer) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file '.github/workflows/pythonpackage.yml'
2--- .github/workflows/pythonpackage.yml 2020-06-12 13:01:14 +0000
3+++ .github/workflows/pythonpackage.yml 2020-07-28 02:11:42 +0000
4@@ -32,7 +32,7 @@
5 run: |
6 python -m pip install --upgrade pip
7 pip install -U pip setuptools
8- pip install -U pip coverage codecov flake8 testtools paramiko fastimport configobj cython testscenarios six docutils $TEST_REQUIRE sphinx sphinx_epytext launchpadlib patiencediff git+https://github.com/dulwich/dulwich
9+ pip install -U pip coverage codecov flake8 testtools paramiko fastimport configobj cython testscenarios six docutils $TEST_REQUIRE sphinx sphinx_epytext launchpadlib patiencediff pyinotify git+https://github.com/dulwich/dulwich
10 - name: Build docs
11 run: |
12 make docs PYTHON=python
13
14=== modified file 'breezy/bzr/__init__.py'
15--- breezy/bzr/__init__.py 2020-07-18 23:14:00 +0000
16+++ breezy/bzr/__init__.py 2020-07-28 02:11:42 +0000
17@@ -23,6 +23,15 @@
18 )
19
20
21+class LineEndingError(errors.BzrError):
22+
23+ _fmt = ("Line ending corrupted for file: %(file)s; "
24+ "Maybe your files got corrupted in transport?")
25+
26+ def __init__(self, file):
27+ self.file = file
28+
29+
30 class BzrProber(controldir.Prober):
31 """Prober for formats that use a .bzr/ control directory."""
32
33@@ -50,11 +59,15 @@
34 first_line = format_string[:format_string.index(b"\n") + 1]
35 except ValueError:
36 first_line = format_string
37+ if (first_line.startswith(b'<!DOCTYPE') or
38+ first_line.startswith(b'<html')):
39+ raise errors.NotBranchError(
40+ path=transport.base, detail="format file looks like HTML")
41 try:
42 cls = klass.formats.get(first_line)
43 except KeyError:
44 if first_line.endswith(b"\r\n"):
45- raise errors.LineEndingError(file=".bzr/branch-format")
46+ raise LineEndingError(file=".bzr/branch-format")
47 else:
48 raise errors.UnknownFormatError(
49 format=first_line, kind='bzrdir')
50
51=== modified file 'breezy/bzr/tests/test_bzrdir.py'
52--- breezy/bzr/tests/test_bzrdir.py 2020-06-17 21:04:15 +0000
53+++ breezy/bzr/tests/test_bzrdir.py 2020-07-28 02:11:42 +0000
54@@ -318,10 +318,20 @@
55 t = self.get_transport()
56 t.mkdir('.bzr')
57 t.put_bytes('.bzr/branch-format', b'Corrupt line endings\r\n')
58- self.assertRaises(errors.LineEndingError,
59+ self.assertRaises(bzr.LineEndingError,
60 bzrdir.BzrDirFormat.find_format,
61 _mod_transport.get_transport_from_path('.'))
62
63+ def test_find_format_html(self):
64+ t = self.get_transport()
65+ t.mkdir('.bzr')
66+ t.put_bytes(
67+ '.bzr/branch-format',
68+ b'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">')
69+ e = self.assertRaises(
70+ NotBranchError, bzrdir.BzrDirFormat.find_format,
71+ _mod_transport.get_transport_from_path('.'))
72+
73 def test_register_unregister_format(self):
74 format = SampleBzrDirFormat()
75 url = self.get_url()
76
77=== modified file 'breezy/commands.py'
78--- breezy/commands.py 2020-06-23 01:02:30 +0000
79+++ breezy/commands.py 2020-07-28 02:11:42 +0000
80@@ -1155,7 +1155,10 @@
81 debug.set_debug_flags_from_config()
82
83 if not opt_no_plugins:
84- load_plugins()
85+ from breezy import config
86+ c = config.GlobalConfig()
87+ warn_load_problems = not c.suppress_warning('plugin_load_failure')
88+ load_plugins(warn_load_problems=warn_load_problems)
89 else:
90 disable_plugins()
91
92
93=== added file 'breezy/dirty_tracker.py'
94--- breezy/dirty_tracker.py 1970-01-01 00:00:00 +0000
95+++ breezy/dirty_tracker.py 2020-07-28 02:11:42 +0000
96@@ -0,0 +1,108 @@
97+#!/usr/bin/python3
98+# Copyright (C) 2019 Jelmer Vernooij
99+#
100+# This program is free software; you can redistribute it and/or modify
101+# it under the terms of the GNU General Public License as published by
102+# the Free Software Foundation; either version 2 of the License, or
103+# (at your option) any later version.
104+#
105+# This program is distributed in the hope that it will be useful,
106+# but WITHOUT ANY WARRANTY; without even the implied warranty of
107+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
108+# GNU General Public License for more details.
109+#
110+# You should have received a copy of the GNU General Public License
111+# along with this program; if not, write to the Free Software
112+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
113+
114+"""Track whether a directory structure was touched since last revision.
115+"""
116+
117+from __future__ import absolute_import
118+
119+# TODO(jelmer): Add support for ignore files
120+
121+import os
122+try:
123+ from pyinotify import (
124+ WatchManager,
125+ IN_CREATE,
126+ IN_CLOSE_WRITE,
127+ IN_Q_OVERFLOW,
128+ IN_DELETE,
129+ IN_MOVED_TO,
130+ IN_MOVED_FROM,
131+ IN_ATTRIB,
132+ ProcessEvent,
133+ Notifier,
134+ Event,
135+ )
136+except ImportError as e:
137+ from .errors import DependencyNotPresent
138+ raise DependencyNotPresent(library='pyinotify', error=e)
139+
140+
141+MASK = (
142+ IN_CLOSE_WRITE | IN_DELETE | IN_Q_OVERFLOW | IN_MOVED_TO | IN_MOVED_FROM |
143+ IN_ATTRIB)
144+
145+
146+class _Process(ProcessEvent):
147+
148+ def my_init(self):
149+ self.paths = set()
150+ self.created = set()
151+
152+ def process_default(self, event):
153+ path = os.path.join(event.path, event.name)
154+ if event.mask & IN_CREATE:
155+ self.created.add(path)
156+ self.paths.add(path)
157+ if event.mask & IN_DELETE and path in self.created:
158+ self.paths.remove(path)
159+ self.created.remove(path)
160+
161+
162+class DirtyTracker(object):
163+ """Track the changes to (part of) a working tree."""
164+
165+ def __init__(self, tree, subpath='.'):
166+ self._tree = tree
167+ self._wm = WatchManager()
168+ self._process = _Process()
169+ self._notifier = Notifier(self._wm, self._process)
170+ self._notifier.coalesce_events(True)
171+
172+ def check_excluded(p):
173+ return tree.is_control_filename(tree.relpath(p))
174+ self._wdd = self._wm.add_watch(
175+ tree.abspath(subpath), MASK, rec=True, auto_add=True,
176+ exclude_filter=check_excluded)
177+
178+ def _process_pending(self):
179+ if self._notifier.check_events(timeout=0):
180+ self._notifier.read_events()
181+ self._notifier.process_events()
182+
183+ def __del__(self):
184+ self._notifier.stop()
185+
186+ def mark_clean(self):
187+ """Mark the subtree as not having any changes."""
188+ self._process_pending()
189+ self._process.paths.clear()
190+ self._process.created.clear()
191+
192+ def is_dirty(self):
193+ """Check whether there are any changes."""
194+ self._process_pending()
195+ return bool(self._process.paths)
196+
197+ def paths(self):
198+ """Return the paths that have changed."""
199+ self._process_pending()
200+ return self._process.paths
201+
202+ def relpaths(self):
203+ """Return the paths relative to the tree root that changed."""
204+ return set(self._tree.relpath(p) for p in self.paths())
205
206=== modified file 'breezy/errors.py'
207--- breezy/errors.py 2020-07-18 23:14:00 +0000
208+++ breezy/errors.py 2020-07-28 02:11:42 +0000
209@@ -563,15 +563,6 @@
210 self.format = format
211
212
213-class LineEndingError(BzrError):
214-
215- _fmt = ("Line ending corrupted for file: %(file)s; "
216- "Maybe your files got corrupted in transport?")
217-
218- def __init__(self, file):
219- self.file = file
220-
221-
222 class IncompatibleFormat(BzrError):
223
224 _fmt = "Format %(format)s is not compatible with .bzr version %(controldir)s."
225
226=== modified file 'breezy/git/dir.py'
227--- breezy/git/dir.py 2020-07-18 23:14:00 +0000
228+++ breezy/git/dir.py 2020-07-28 02:11:42 +0000
229@@ -217,6 +217,11 @@
230 target, basis.get_reference_revision(path),
231 force_new_repo=force_new_repo, recurse=recurse,
232 stacked=stacked)
233+ if getattr(result_repo, '_git', None):
234+ # Don't leak resources:
235+ # TODO(jelmer): This shouldn't be git-specific, and possibly
236+ # just use read locks.
237+ result_repo._git.object_store.close()
238 return result
239
240 def clone_on_transport(self, transport, revision_id=None,
241
242=== modified file 'breezy/git/revspec.py'
243--- breezy/git/revspec.py 2020-03-25 20:24:54 +0000
244+++ breezy/git/revspec.py 2020-07-28 02:11:42 +0000
245@@ -72,9 +72,9 @@
246 default_mapping.revision_id_foreign_to_bzr)(sha1)
247 try:
248 if branch.repository.has_revision(bzr_revid):
249- return RevisionInfo.from_revision_id(branch, bzr_revid)
250+ return bzr_revid
251 except GitSmartRemoteNotSupported:
252- return RevisionInfo(branch, None, bzr_revid)
253+ return bzr_revid
254 raise InvalidRevisionSpec(self.user_spec, branch)
255
256 def __nonzero__(self):
257@@ -92,35 +92,44 @@
258 )
259 parse_revid = getattr(branch.repository, "lookup_bzr_revision_id",
260 mapping_registry.parse_revision_id)
261+ def matches_revid(revid):
262+ if revid == NULL_REVISION:
263+ return False
264+ try:
265+ foreign_revid, mapping = parse_revid(revid)
266+ except InvalidRevisionId:
267+ return False
268+ if not isinstance(mapping.vcs, ForeignGit):
269+ return False
270+ return foreign_revid.startswith(sha1)
271 with branch.repository.lock_read():
272 graph = branch.repository.get_graph()
273- for revid, _ in graph.iter_ancestry([branch.last_revision()]):
274- if revid == NULL_REVISION:
275- continue
276- try:
277- foreign_revid, mapping = parse_revid(revid)
278- except InvalidRevisionId:
279- continue
280- if not isinstance(mapping.vcs, ForeignGit):
281- continue
282- if foreign_revid.startswith(sha1):
283- return RevisionInfo.from_revision_id(branch, revid)
284+ last_revid = branch.last_revision()
285+ if matches_revid(last_revid):
286+ return last_revid
287+ for revid, _ in graph.iter_ancestry([last_revid]):
288+ if matches_revid(revid):
289+ return revid
290 raise InvalidRevisionSpec(self.user_spec, branch)
291
292- def _match_on(self, branch, revs):
293+ def _as_revision_id(self, context_branch):
294 loc = self.spec.find(':')
295 git_sha1 = self.spec[loc + 1:].encode("utf-8")
296 if (len(git_sha1) > 40 or len(git_sha1) < 4 or
297 not valid_git_sha1(git_sha1)):
298- raise InvalidRevisionSpec(self.user_spec, branch)
299+ raise InvalidRevisionSpec(self.user_spec, context_branch)
300 from . import (
301 lazy_check_versions,
302 )
303 lazy_check_versions()
304 if len(git_sha1) == 40:
305- return self._lookup_git_sha1(branch, git_sha1)
306+ return self._lookup_git_sha1(context_branch, git_sha1)
307 else:
308- return self._find_short_git_sha1(branch, git_sha1)
309+ return self._find_short_git_sha1(context_branch, git_sha1)
310+
311+ def _match_on(self, branch, revs):
312+ revid = self._as_revision_id(branch)
313+ return RevisionInfo.from_revision_id(branch, revid)
314
315 def needs_branch(self):
316 return True
317
318=== added file 'breezy/memorybranch.py'
319--- breezy/memorybranch.py 1970-01-01 00:00:00 +0000
320+++ breezy/memorybranch.py 2020-07-28 02:11:42 +0000
321@@ -0,0 +1,89 @@
322+# Copyright (C) 2020 Breezy Developers
323+#
324+# This program is free software; you can redistribute it and/or modify
325+# it under the terms of the GNU General Public License as published by
326+# the Free Software Foundation; either version 2 of the License, or
327+# (at your option) any later version.
328+#
329+# This program is distributed in the hope that it will be useful,
330+# but WITHOUT ANY WARRANTY; without even the implied warranty of
331+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
332+# GNU General Public License for more details.
333+#
334+# You should have received a copy of the GNU General Public License
335+# along with this program; if not, write to the Free Software
336+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
337+
338+"""MemoryBranch object.
339+"""
340+
341+from __future__ import absolute_import
342+
343+from . import config as _mod_config, errors, osutils
344+from .branch import Branch
345+from .lock import LogicalLockResult, _RelockDebugMixin
346+from .revision import NULL_REVISION
347+from .tag import DisabledTags, MemoryTags
348+
349+
350+class MemoryBranch(Branch, _RelockDebugMixin):
351+
352+ def __init__(self, repository, last_revision_info, tags=None):
353+ self.repository = repository
354+ self._last_revision_info = last_revision_info
355+ self._revision_history_cache = None
356+ if tags is not None:
357+ self.tags = MemoryTags(tags)
358+ else:
359+ self.tags = DisabledTags(self)
360+ self._partial_revision_history_cache = []
361+ self._last_revision_info_cache = None
362+ self._revision_id_to_revno_cache = None
363+ self._partial_revision_id_to_revno_cache = {}
364+ self.base = 'memory://' + osutils.rand_chars(10)
365+
366+ def get_config(self):
367+ return _mod_config.Config()
368+
369+ def lock_read(self):
370+ self.repository.lock_read()
371+ return LogicalLockResult(self.unlock)
372+
373+ def lock_write(self, token=None):
374+ self.repository.lock_write()
375+ return BranchWriteLockResult(self.unlock, None)
376+
377+ def unlock(self):
378+ self.repository.unlock()
379+
380+ def last_revision_info(self):
381+ return self._last_revision_info
382+
383+ def _gen_revision_history(self):
384+ """Generate the revision history from last revision
385+ """
386+ last_revno, last_revision = self.last_revision_info()
387+ with self.lock_read():
388+ self._extend_partial_history()
389+ return list(reversed(self._partial_revision_history_cache))
390+
391+ def get_rev_id(self, revno, history=None):
392+ """Find the revision id of the specified revno."""
393+ with self.lock_read():
394+ if revno == 0:
395+ return NULL_REVISION
396+ last_revno, last_revid = self.last_revision_info()
397+ if revno == last_revno:
398+ return last_revid
399+ if last_revno is None:
400+ self._extend_partial_history()
401+ return self._partial_revision_history_cache[
402+ len(self._partial_revision_history_cache) - revno]
403+ else:
404+ if revno <= 0 or revno > last_revno:
405+ raise errors.NoSuchRevision(self, revno)
406+ distance_from_last = last_revno - revno
407+ if len(self._partial_revision_history_cache) <= \
408+ distance_from_last:
409+ self._extend_partial_history(distance_from_last)
410+ return self._partial_revision_history_cache[distance_from_last]
411
412=== modified file 'breezy/plugin.py'
413--- breezy/plugin.py 2020-05-24 00:42:36 +0000
414+++ breezy/plugin.py 2020-07-28 02:11:42 +0000
415@@ -76,7 +76,7 @@
416 state.plugins = {}
417
418
419-def load_plugins(path=None, state=None):
420+def load_plugins(path=None, state=None, warn_load_problems=True):
421 """Load breezy plugins.
422
423 The environment variable BRZ_PLUGIN_PATH is considered a delimited
424@@ -103,6 +103,10 @@
425 if (None, 'entrypoints') in _env_plugin_path():
426 _load_plugins_from_entrypoints(state)
427 state.plugins = plugins()
428+ if warn_load_problems:
429+ for plugin, errors in state.plugin_warnings.items():
430+ for error in errors:
431+ trace.warning('%s', error)
432
433
434 def _load_plugins_from_entrypoints(state):
435
436=== modified file 'breezy/plugins/github/hoster.py'
437--- breezy/plugins/github/hoster.py 2020-07-18 23:14:00 +0000
438+++ breezy/plugins/github/hoster.py 2020-07-28 02:11:42 +0000
439@@ -124,6 +124,8 @@
440 def __repr__(self):
441 return "<%s at %r>" % (type(self).__name__, self.url)
442
443+ name = 'GitHub'
444+
445 @property
446 def url(self):
447 return self._pr['html_url']
448@@ -558,6 +560,12 @@
449 return json.loads(response.text)
450 raise UnexpectedHttpStatus(path, response.status)
451
452+ def get_current_user(self):
453+ return self.current_user['login']
454+
455+ def get_user_url(self, username):
456+ return urlutils.join(self.base_url, username)
457+
458
459 class GitHubMergeProposalBuilder(MergeProposalBuilder):
460
461
462=== modified file 'breezy/plugins/gitlab/hoster.py'
463--- breezy/plugins/gitlab/hoster.py 2020-07-18 23:14:00 +0000
464+++ breezy/plugins/gitlab/hoster.py 2020-07-28 02:11:42 +0000
465@@ -97,6 +97,14 @@
466 self.error = error
467
468
469+class GitLabConflict(errors.BzrError):
470+
471+ _fmt = "Conflict during operation: %(reason)s"
472+
473+ def __init__(self, reason):
474+ errors.BzrError(self, reason=reason)
475+
476+
477 class ForkingDisabled(errors.BzrError):
478
479 _fmt = ("Forking on project %(project)s is disabled.")
480@@ -356,11 +364,17 @@
481 return json.loads(response.data)
482 raise errors.UnexpectedHttpStatus(path, response.status)
483
484- def _fork_project(self, project_name, timeout=50, interval=5):
485+ def _fork_project(self, project_name, timeout=50, interval=5, owner=None):
486 path = 'projects/%s/fork' % urlutils.quote(str(project_name), '')
487- response = self._api_request('POST', path)
488+ fields = {}
489+ if owner is not None:
490+ fields['namespace'] = owner
491+ response = self._api_request('POST', path, fields=fields)
492 if response.status == 404:
493 raise ForkingDisabled(project_name)
494+ if response.status == 409:
495+ resp = json.loads(response.data)
496+ raise GitLabConflict(resp.get('message'))
497 if response.status not in (200, 201):
498 raise errors.UnexpectedHttpStatus(path, response.status)
499 # The response should be valid JSON, but let's ignore it
500@@ -376,9 +390,12 @@
501 project = self._get_project(project['path_with_namespace'])
502 return project
503
504- def _get_logged_in_username(self):
505+ def get_current_user(self):
506 return self._current_user['username']
507
508+ def get_user_url(self, username):
509+ return urlutils.join(self.base_url, username)
510+
511 def _list_paged(self, path, parameters=None, per_page=None):
512 if parameters is None:
513 parameters = {}
514@@ -478,13 +495,13 @@
515 allow_lossy=True, tag_selector=None):
516 (host, base_project, base_branch_name) = parse_gitlab_branch_url(base_branch)
517 if owner is None:
518- owner = self._get_logged_in_username()
519+ owner = self.get_current_user()
520 if project is None:
521 project = self._get_project(base_project)['path']
522 try:
523 target_project = self._get_project('%s/%s' % (owner, project))
524 except NoSuchProject:
525- target_project = self._fork_project(base_project)
526+ target_project = self._fork_project(base_project, owner=owner)
527 remote_repo_url = git_url_to_bzr_url(target_project['ssh_url_to_repo'])
528 remote_dir = controldir.ControlDir.open(remote_repo_url)
529 try:
530@@ -504,7 +521,7 @@
531 def get_derived_branch(self, base_branch, name, project=None, owner=None):
532 (host, base_project, base_branch_name) = parse_gitlab_branch_url(base_branch)
533 if owner is None:
534- owner = self._get_logged_in_username()
535+ owner = self.get_current_user()
536 if project is None:
537 project = self._get_project(base_project)['path']
538 try:
539@@ -580,11 +597,11 @@
540 def iter_my_proposals(self, status='open'):
541 state = mp_status_to_status(status)
542 for mp in self._list_merge_requests(
543- owner=self._get_logged_in_username(), state=state):
544+ owner=self.get_current_user(), state=state):
545 yield GitLabMergeProposal(self, mp)
546
547 def iter_my_forks(self):
548- for project in self._list_projects(owner=self._get_logged_in_username()):
549+ for project in self._list_projects(owner=self.get_current_user()):
550 base_project = project.get('forked_from_project')
551 if not base_project:
552 continue
553
554=== modified file 'breezy/plugins/launchpad/hoster.py'
555--- breezy/plugins/launchpad/hoster.py 2020-07-18 23:14:00 +0000
556+++ breezy/plugins/launchpad/hoster.py 2020-07-28 02:11:42 +0000
557@@ -222,16 +222,17 @@
558
559 merge_proposal_description_format = 'plain'
560
561- def __init__(self, staging=False):
562- self._staging = staging
563- if staging:
564- lp_base_url = uris.STAGING_SERVICE_ROOT
565- else:
566- lp_base_url = uris.LPNET_SERVICE_ROOT
567- self._api_base_url = lp_base_url
568+ def __init__(self, service_root):
569+ self._api_base_url = service_root
570 self._launchpad = None
571
572 @property
573+ def name(self):
574+ if self._api_base_url == uris.LPNET_SERVICE_ROOT:
575+ return 'Launchpad'
576+ return 'Launchpad at %s' % self.base_url
577+
578+ @property
579 def launchpad(self):
580 if self._launchpad is None:
581 self._launchpad = lp_api.connect_launchpad(self._api_base_url, version='devel')
582@@ -242,7 +243,13 @@
583 return lp_api.uris.web_root_for_service_root(self._api_base_url)
584
585 def __repr__(self):
586- return "Launchpad(staging=%s)" % self._staging
587+ return "Launchpad(service_root=%s)" % self._api_base_url
588+
589+ def get_current_user(self):
590+ return self.launchpad.me.name
591+
592+ def get_user_url(self, username):
593+ return self.launchpad.people[username].web_link
594
595 def hosts(self, branch):
596 # TODO(jelmer): staging vs non-staging?
597@@ -251,7 +258,7 @@
598 @classmethod
599 def probe_from_url(cls, url, possible_transports=None):
600 if plausible_launchpad_url(url):
601- return Launchpad()
602+ return Launchpad(uris.LPNET_SERVICE_ROOT)
603 raise UnsupportedHoster(url)
604
605 def _get_lp_git_ref_from_branch(self, branch):
606@@ -462,7 +469,12 @@
607
608 @classmethod
609 def iter_instances(cls):
610- yield cls()
611+ credential_store = lp_api.get_credential_store()
612+ for service_root in set(uris.service_roots.values()):
613+ auth_engine = lp_api.get_auth_engine(service_root)
614+ creds = credential_store.load(auth_engine.unique_consumer_id)
615+ if creds is not None:
616+ yield cls(service_root)
617
618 def iter_my_proposals(self, status='open'):
619 statuses = status_to_lp_mp_statuses(status)
620
621=== modified file 'breezy/plugins/launchpad/lp_api.py'
622--- breezy/plugins/launchpad/lp_api.py 2020-02-18 01:57:45 +0000
623+++ breezy/plugins/launchpad/lp_api.py 2020-07-28 02:11:42 +0000
624@@ -52,6 +52,7 @@
625 except ImportError as e:
626 raise LaunchpadlibMissing(e)
627
628+from launchpadlib.credentials import RequestTokenAuthorizationEngine
629 from launchpadlib.launchpad import (
630 Launchpad,
631 )
632@@ -98,6 +99,14 @@
633 errors.BzrError.__init__(self, branch=branch, url=branch.base)
634
635
636+def get_auth_engine(base_url):
637+ return Launchpad.authorization_engine_factory(base_url, 'breezy')
638+
639+
640+def get_credential_store():
641+ return Launchpad.credential_store_factory(None)
642+
643+
644 def connect_launchpad(base_url, timeout=None, proxy_info=None,
645 version=Launchpad.DEFAULT_VERSION):
646 """Log in to the Launchpad API.
647@@ -111,8 +120,12 @@
648 cache_directory = get_cache_directory()
649 except EnvironmentError:
650 cache_directory = None
651+ credential_store = get_credential_store()
652+ authorization_engine = get_auth_engine(base_url)
653 return Launchpad.login_with(
654 'breezy', base_url, cache_directory, timeout=timeout,
655+ credential_store=credential_store,
656+ authorization_engine=authorization_engine,
657 proxy_info=proxy_info, version=version)
658
659
660
661=== modified file 'breezy/plugins/propose/__init__.py'
662--- breezy/plugins/propose/__init__.py 2020-06-01 21:57:00 +0000
663+++ breezy/plugins/propose/__init__.py 2020-07-28 02:11:42 +0000
664@@ -26,6 +26,7 @@
665 plugin_cmds.register_lazy(
666 "cmd_my_merge_proposals", ["my-proposals"],
667 __name__ + ".cmds")
668+plugin_cmds.register_lazy("cmd_hosters", [], __name__ + ".cmds")
669
670
671 def test_suite():
672
673=== modified file 'breezy/plugins/propose/cmds.py'
674--- breezy/plugins/propose/cmds.py 2020-06-23 01:02:30 +0000
675+++ breezy/plugins/propose/cmds.py 2020-07-28 02:11:42 +0000
676@@ -267,21 +267,20 @@
677 closed='Closed merge proposals')]
678
679 def run(self, status='open', verbose=False):
680- for name, hoster_cls in _mod_propose.hosters.items():
681- for instance in hoster_cls.iter_instances():
682- for mp in instance.iter_my_proposals(status=status):
683- self.outf.write('%s\n' % mp.url)
684- if verbose:
685- self.outf.write(
686- '(Merging %s into %s)\n' %
687- (mp.get_source_branch_url(),
688- mp.get_target_branch_url()))
689- description = mp.get_description()
690- if description:
691- self.outf.writelines(
692- ['\t%s\n' % l
693- for l in description.splitlines()])
694- self.outf.write('\n')
695+ for instance in _mod_propose.iter_hoster_instances():
696+ for mp in instance.iter_my_proposals(status=status):
697+ self.outf.write('%s\n' % mp.url)
698+ if verbose:
699+ self.outf.write(
700+ '(Merging %s into %s)\n' %
701+ (mp.get_source_branch_url(),
702+ mp.get_target_branch_url()))
703+ description = mp.get_description()
704+ if description:
705+ self.outf.writelines(
706+ ['\t%s\n' % l
707+ for l in description.splitlines()])
708+ self.outf.write('\n')
709
710
711 class cmd_land_merge_proposal(Command):
712@@ -294,3 +293,17 @@
713 def run(self, url, message=None):
714 proposal = _mod_propose.get_proposal_by_url(url)
715 proposal.merge(commit_message=message)
716+
717+
718+class cmd_hosters(Command):
719+ __doc__ = """List all known hosting sites and user details."""
720+
721+ hidden = True
722+
723+ def run(self):
724+ for instance in _mod_propose.iter_hoster_instances():
725+ current_user = instance.get_current_user()
726+ self.outf.write(
727+ gettext('%s (%s) - user: %s (%s)\n') % (
728+ instance.name, instance.base_url,
729+ current_user, instance.get_user_url(current_user)))
730
731=== modified file 'breezy/plugins/svn/__init__.py'
732--- breezy/plugins/svn/__init__.py 2020-02-18 01:57:45 +0000
733+++ breezy/plugins/svn/__init__.py 2020-07-28 02:11:42 +0000
734@@ -26,6 +26,9 @@
735 errors,
736 transport as _mod_transport,
737 )
738+from ...revisionspec import (
739+ revspec_registry,
740+ )
741
742
743 class SubversionUnsupportedError(errors.UnsupportedFormatError):
744@@ -183,6 +186,9 @@
745 controldir.ControlDirFormat.register_prober(SvnRepositoryProber)
746
747
748+revspec_registry.register_lazy("svn:", __name__ + ".revspec", "RevisionSpec_svn")
749+
750+
751 _mod_transport.register_transport_proto(
752 'svn+ssh://',
753 help="Access using the Subversion smart server tunneled over SSH.")
754
755=== added file 'breezy/plugins/svn/revspec.py'
756--- breezy/plugins/svn/revspec.py 1970-01-01 00:00:00 +0000
757+++ breezy/plugins/svn/revspec.py 2020-07-28 02:11:42 +0000
758@@ -0,0 +1,44 @@
759+# Copyright (C) 2020 Jelmer Vernooij <jelmer@samba.org>
760+#
761+# This program is free software; you can redistribute it and/or modify
762+# it under the terms of the GNU General Public License as published by
763+# the Free Software Foundation; version 3 of the License or
764+# (at your option) a later version.
765+#
766+# This program is distributed in the hope that it will be useful,
767+# but WITHOUT ANY WARRANTY; without even the implied warranty of
768+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
769+# GNU General Public License for more details.
770+#
771+# You should have received a copy of the GNU General Public License
772+# along with this program; if not, write to the Free Software
773+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
774+
775+from __future__ import absolute_import
776+
777+from ...revisionspec import (
778+ InvalidRevisionSpec,
779+ RevisionSpec,
780+ )
781+
782+
783+class RevisionSpec_svn(RevisionSpec):
784+ """Selects a revision using a Subversion revision number."""
785+
786+ help_txt = """Selects a revision using a Subversion revision number (revno).
787+
788+ Subversion revision numbers are per-repository whereas Bazaar revision
789+ numbers are per-branch. This revision specifier allows specifying
790+ a Subversion revision number.
791+ """
792+
793+ prefix = 'svn:'
794+
795+ def _match_on(self, branch, revs):
796+ raise InvalidRevisionSpec(self.user_spec, branch)
797+
798+ def needs_branch(self):
799+ return True
800+
801+ def get_branch(self):
802+ return None
803
804=== modified file 'breezy/propose.py'
805--- breezy/propose.py 2020-06-23 01:02:30 +0000
806+++ breezy/propose.py 2020-07-28 02:11:42 +0000
807@@ -238,6 +238,11 @@
808 # proposals?
809 supports_merge_proposal_labels = None
810
811+ @property
812+ def name(self):
813+ """Name of this instance."""
814+ return "%s at %s" % (type(self).__name__, self.base_url)
815+
816 # Does this hoster support suggesting a commit message in the
817 # merge proposal?
818 supports_merge_proposal_commit_message = None
819@@ -264,7 +269,7 @@
820 :raise HosterLoginRequired: Action requires a hoster login, but none is
821 known.
822 """
823- raise NotImplementedError(self.publish)
824+ raise NotImplementedError(self.publish_derived)
825
826 def get_derived_branch(self, base_branch, name, project=None, owner=None):
827 """Get a derived branch ('a fork').
828@@ -352,6 +357,17 @@
829 """
830 raise NotImplementedError(cls.iter_instances)
831
832+ def get_current_user(self):
833+ """Retrieve the name of the currently logged in user.
834+
835+ :return: Username or None if not logged in
836+ """
837+ raise NotImplementedError(self.get_current_user)
838+
839+ def get_user_url(self, user):
840+ """Rerieve the web URL for a user."""
841+ raise NotImplementedError(self.get_user_url)
842+
843
844 def determine_title(description):
845 """Determine the title for a merge proposal based on full description."""
846@@ -382,6 +398,16 @@
847 raise UnsupportedHoster(branch)
848
849
850+def iter_hoster_instances():
851+ """Iterate over all known hoster instances.
852+
853+ :return: Iterator over Hoster instances
854+ """
855+ for name, hoster_cls in hosters.items():
856+ for instance in hoster_cls.iter_instances():
857+ yield instance
858+
859+
860 def get_proposal_by_url(url):
861 """Get the proposal object associated with a URL.
862
863@@ -389,12 +415,11 @@
864 :raise UnsupportedHoster: if there is no hoster that supports the URL
865 :return: A `MergeProposal` object
866 """
867- for name, hoster_cls in hosters.items():
868- for instance in hoster_cls.iter_instances():
869- try:
870- return instance.get_proposal_by_url(url)
871- except UnsupportedHoster:
872- pass
873+ for instance in iter_hoster_instances():
874+ try:
875+ return instance.get_proposal_by_url(url)
876+ except UnsupportedHoster:
877+ pass
878 raise UnsupportedHoster(url)
879
880
881
882=== modified file 'breezy/revisionspec.py'
883--- breezy/revisionspec.py 2020-03-25 21:52:04 +0000
884+++ breezy/revisionspec.py 2020-07-28 02:11:42 +0000
885@@ -51,6 +51,20 @@
886 self.extra = ''
887
888
889+class InvalidRevisionSpec(errors.BzrError):
890+
891+ _fmt = ("Requested revision: '%(spec)s' does not exist in branch:"
892+ " %(branch_url)s%(extra)s")
893+
894+ def __init__(self, spec, branch, extra=None):
895+ errors.BzrError.__init__(self, branch=branch, spec=spec)
896+ self.branch_url = getattr(branch, 'user_url', str(branch))
897+ if extra:
898+ self.extra = '\n' + str(extra)
899+ else:
900+ self.extra = ''
901+
902+
903 class RevisionInfo(object):
904 """The results of applying a revision specification to a branch."""
905
906@@ -599,7 +613,7 @@
907 self.user_spec, context_branch, 'cannot find the matching revision')
908 parents = parent_map[base_revision_id]
909 if len(parents) < 1:
910- raise errors.InvalidRevisionSpec(
911+ raise InvalidRevisionSpec(
912 self.user_spec, context_branch, 'No parents for revision.')
913 return parents[0]
914
915
916=== modified file 'breezy/tests/__init__.py'
917--- breezy/tests/__init__.py 2020-07-18 23:14:00 +0000
918+++ breezy/tests/__init__.py 2020-07-28 02:11:42 +0000
919@@ -4018,6 +4018,7 @@
920 'breezy.tests.test_debug',
921 'breezy.tests.test_diff',
922 'breezy.tests.test_directory_service',
923+ 'breezy.tests.test_dirty_tracker',
924 'breezy.tests.test_email_message',
925 'breezy.tests.test_eol_filters',
926 'breezy.tests.test_errors',
927@@ -4062,6 +4063,7 @@
928 'breezy.tests.test_lsprof',
929 'breezy.tests.test_mail_client',
930 'breezy.tests.test_matchers',
931+ 'breezy.tests.test_memorybranch',
932 'breezy.tests.test_memorytree',
933 'breezy.tests.test_merge',
934 'breezy.tests.test_merge3',
935@@ -4142,6 +4144,7 @@
936 'breezy.tests.test_views',
937 'breezy.tests.test_whitebox',
938 'breezy.tests.test_win32utils',
939+ 'breezy.tests.test_workspace',
940 'breezy.tests.test_workingtree',
941 'breezy.tests.test_wsgi',
942 ]
943
944=== added file 'breezy/tests/test_dirty_tracker.py'
945--- breezy/tests/test_dirty_tracker.py 1970-01-01 00:00:00 +0000
946+++ breezy/tests/test_dirty_tracker.py 2020-07-28 02:11:42 +0000
947@@ -0,0 +1,98 @@
948+#!/usr/bin/python
949+# Copyright (C) 2019 Jelmer Vernooij
950+#
951+# This program is free software; you can redistribute it and/or modify
952+# it under the terms of the GNU General Public License as published by
953+# the Free Software Foundation; either version 2 of the License, or
954+# (at your option) any later version.
955+#
956+# This program is distributed in the hope that it will be useful,
957+# but WITHOUT ANY WARRANTY; without even the implied warranty of
958+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
959+# GNU General Public License for more details.
960+#
961+# You should have received a copy of the GNU General Public License
962+# along with this program; if not, write to the Free Software
963+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
964+
965+"""Tests for lintian_brush.dirty_tracker."""
966+
967+import os
968+
969+from breezy.tests import (
970+ TestCaseWithTransport,
971+ )
972+
973+
974+class DirtyTrackerTests(TestCaseWithTransport):
975+
976+ def setUp(self):
977+ super(DirtyTrackerTests, self).setUp()
978+ self.tree = self.make_branch_and_tree('tree')
979+ try:
980+ from lintian_brush.dirty_tracker import DirtyTracker
981+ except ImportError:
982+ self.skipTest('pyinotify not available')
983+ self.tracker = DirtyTracker(self.tree)
984+
985+ def test_nothing_changes(self):
986+ self.assertFalse(self.tracker.is_dirty())
987+
988+ def test_regular_file_added(self):
989+ self.build_tree_contents([('tree/foo', 'bar')])
990+ self.assertTrue(self.tracker.is_dirty())
991+ self.assertEqual(self.tracker.relpaths(), set(['foo']))
992+
993+ def test_many_added(self):
994+ self.build_tree_contents(
995+ [('tree/f%d' % d, 'content') for d in range(100)])
996+ self.assertTrue(self.tracker.is_dirty())
997+ self.assertEqual(
998+ self.tracker.relpaths(), set(['f%d' % d for d in range(100)]))
999+
1000+ def test_regular_file_in_subdir_added(self):
1001+ self.build_tree_contents([('tree/foo/', ), ('tree/foo/blah', 'bar')])
1002+ self.assertTrue(self.tracker.is_dirty())
1003+ self.assertEqual(self.tracker.relpaths(), set(['foo', 'foo/blah']))
1004+
1005+ def test_directory_added(self):
1006+ self.build_tree_contents([('tree/foo/', )])
1007+ self.assertTrue(self.tracker.is_dirty())
1008+ self.assertEqual(self.tracker.relpaths(), set(['foo']))
1009+
1010+ def test_file_removed(self):
1011+ self.build_tree_contents([('tree/foo', 'foo')])
1012+ self.assertTrue(self.tracker.is_dirty())
1013+ self.tracker.mark_clean()
1014+ self.build_tree_contents([('tree/foo', 'bar')])
1015+ self.assertTrue(self.tracker.is_dirty())
1016+ self.assertEqual(self.tracker.relpaths(), set(['foo']))
1017+
1018+ def test_control_file(self):
1019+ self.tree.commit('Some change')
1020+ self.assertFalse(self.tracker.is_dirty())
1021+ self.assertEqual(self.tracker.relpaths(), set([]))
1022+
1023+ def test_renamed(self):
1024+ self.build_tree_contents([('tree/foo', 'bar')])
1025+ self.tracker.mark_clean()
1026+ self.assertFalse(self.tracker.is_dirty())
1027+ os.rename('tree/foo', 'tree/bar')
1028+ self.assertTrue(self.tracker.is_dirty())
1029+ self.assertEqual(self.tracker.relpaths(), set(['foo', 'bar']))
1030+
1031+ def test_deleted(self):
1032+ self.build_tree_contents([('tree/foo', 'bar')])
1033+ self.tracker.mark_clean()
1034+ self.assertFalse(self.tracker.is_dirty())
1035+ os.unlink('tree/foo')
1036+ self.assertTrue(self.tracker.is_dirty(), self.tracker._process.paths)
1037+ self.assertEqual(self.tracker.relpaths(), set(['foo']))
1038+
1039+ def test_added_then_deleted(self):
1040+ self.tracker.mark_clean()
1041+ self.assertFalse(self.tracker.is_dirty())
1042+ self.build_tree_contents([('tree/foo', 'bar')])
1043+ os.unlink('tree/foo')
1044+ self.assertFalse(self.tracker.is_dirty())
1045+ self.assertEqual(self.tracker.relpaths(), set([]))
1046
1047=== added file 'breezy/tests/test_memorybranch.py'
1048--- breezy/tests/test_memorybranch.py 1970-01-01 00:00:00 +0000
1049+++ breezy/tests/test_memorybranch.py 2020-07-28 02:11:42 +0000
1050@@ -0,0 +1,46 @@
1051+# Copyright (C) 2020 Jelmer Vernooij
1052+#
1053+# This program is free software; you can redistribute it and/or modify
1054+# it under the terms of the GNU General Public License as published by
1055+# the Free Software Foundation; either version 2 of the License, or
1056+# (at your option) any later version.
1057+#
1058+# This program is distributed in the hope that it will be useful,
1059+# but WITHOUT ANY WARRANTY; without even the implied warranty of
1060+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1061+# GNU General Public License for more details.
1062+#
1063+# You should have received a copy of the GNU General Public License
1064+# along with this program; if not, write to the Free Software
1065+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
1066+
1067+from . import TestCaseWithTransport
1068+
1069+from ..memorybranch import MemoryBranch
1070+
1071+
1072+class MemoryBranchTests(TestCaseWithTransport):
1073+
1074+ def setUp(self):
1075+ super(MemoryBranchTests, self).setUp()
1076+ self.tree = self.make_branch_and_tree('.')
1077+ self.revid1 = self.tree.commit('rev1')
1078+ self.revid2 = self.tree.commit('rev2')
1079+ self.branch = MemoryBranch(
1080+ self.tree.branch.repository, (2, self.revid2))
1081+
1082+ def test_last_revision_info(self):
1083+ self.assertEqual((2, self.revid2), self.branch.last_revision_info())
1084+
1085+ def test_last_revision(self):
1086+ self.assertEqual(self.revid2, self.branch.last_revision())
1087+
1088+ def test_revno(self):
1089+ self.assertEqual(2, self.branch.revno())
1090+
1091+ def test_get_rev_id(self):
1092+ self.assertEqual(self.revid1, self.branch.get_rev_id(1))
1093+
1094+ def test_revision_id_to_revno(self):
1095+ self.assertEqual(2, self.branch.revision_id_to_revno(self.revid2))
1096+ self.assertEqual(1, self.branch.revision_id_to_revno(self.revid1))
1097
1098=== modified file 'breezy/tests/test_plugins.py'
1099--- breezy/tests/test_plugins.py 2020-02-07 02:14:30 +0000
1100+++ breezy/tests/test_plugins.py 2020-07-28 02:11:42 +0000
1101@@ -76,9 +76,11 @@
1102 self.log("using %r", paths)
1103 return paths
1104
1105- def load_with_paths(self, paths):
1106+ def load_with_paths(self, paths, warn_load_problems=True):
1107 self.log("loading plugins!")
1108- plugin.load_plugins(self.update_module_paths(paths), state=self)
1109+ plugin.load_plugins(
1110+ self.update_module_paths(paths), state=self,
1111+ warn_load_problems=warn_load_problems)
1112
1113 def create_plugin(self, name, source=None, dir='.', file_name=None):
1114 if source is None:
1115@@ -260,7 +262,7 @@
1116 finally:
1117 del self.activeattributes[tempattribute]
1118
1119- def load_and_capture(self, name):
1120+ def load_and_capture(self, name, warn_load_problems=True):
1121 """Load plugins from '.' capturing the output.
1122
1123 :param name: The name of the plugin.
1124@@ -273,7 +275,8 @@
1125 log = logging.getLogger('brz')
1126 log.addHandler(handler)
1127 try:
1128- self.load_with_paths(['.'])
1129+ self.load_with_paths(
1130+ ['.'], warn_load_problems=warn_load_problems)
1131 finally:
1132 # Stop capturing output
1133 handler.flush()
1134@@ -286,18 +289,16 @@
1135 def test_plugin_with_bad_api_version_reports(self):
1136 """Try loading a plugin that requests an unsupported api.
1137
1138- Observe that it records the problem but doesn't complain on stderr.
1139-
1140- See https://bugs.launchpad.net/bzr/+bug/704195
1141+ Observe that it records the problem but doesn't complain on stderr
1142+ when warn_load_problems=False
1143 """
1144 name = 'wants100.py'
1145 with open(name, 'w') as f:
1146 f.write("import breezy\n"
1147 "from breezy.errors import IncompatibleVersion\n"
1148 "raise IncompatibleVersion(breezy, [(1, 0, 0)], (0, 0, 5))\n")
1149- log = self.load_and_capture(name)
1150- self.assertNotContainsRe(log,
1151- r"It supports breezy version")
1152+ log = self.load_and_capture(name, warn_load_problems=False)
1153+ self.assertNotContainsRe(log, r"It supports breezy version")
1154 self.assertEqual({'wants100'}, self.plugin_warnings.keys())
1155 self.assertContainsRe(
1156 self.plugin_warnings['wants100'][0],
1157@@ -313,6 +314,23 @@
1158 "because the file path isn't a valid module name; try renaming "
1159 "it to 'bad_plugin_name_'\\.")
1160
1161+ def test_plugin_with_error_suppress(self):
1162+ # The file name here invalid for a python module.
1163+ name = 'some_error.py'
1164+ with open(name, 'w') as f:
1165+ f.write('raise Exception("bad")\n')
1166+ log = self.load_and_capture(name, warn_load_problems=False)
1167+ self.assertEqual('', log)
1168+
1169+ def test_plugin_with_error(self):
1170+ # The file name here invalid for a python module.
1171+ name = 'some_error.py'
1172+ with open(name, 'w') as f:
1173+ f.write('raise Exception("bad")\n')
1174+ log = self.load_and_capture(name, warn_load_problems=True)
1175+ self.assertEqual(
1176+ 'Unable to load plugin \'some_error\' from \'.\': bad\n', log)
1177+
1178
1179 class TestPlugins(BaseTestPlugins):
1180
1181
1182=== added file 'breezy/tests/test_workspace.py'
1183--- breezy/tests/test_workspace.py 1970-01-01 00:00:00 +0000
1184+++ breezy/tests/test_workspace.py 2020-07-28 02:11:42 +0000
1185@@ -0,0 +1,204 @@
1186+# Copyright (C) 2020 Jelmer Vernooij <jelmer@jelmer.uk>
1187+#
1188+# This program is free software; you can redistribute it and/or modify
1189+# it under the terms of the GNU General Public License as published by
1190+# the Free Software Foundation; either version 2 of the License, or
1191+# (at your option) any later version.
1192+#
1193+# This program is distributed in the hope that it will be useful,
1194+# but WITHOUT ANY WARRANTY; without even the implied warranty of
1195+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1196+# GNU General Public License for more details.
1197+#
1198+# You should have received a copy of the GNU General Public License
1199+# along with this program; if not, write to the Free Software
1200+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
1201+
1202+import os
1203+
1204+from . import (
1205+ TestCaseWithTransport,
1206+ multiply_scenarios,
1207+ )
1208+from .scenarios import load_tests_apply_scenarios
1209+
1210+from ..workspace import (
1211+ WorkspaceDirty,
1212+ Workspace,
1213+ check_clean_tree,
1214+ )
1215+
1216+
1217+load_tests = load_tests_apply_scenarios
1218+
1219+
1220+class CheckCleanTreeTests(TestCaseWithTransport):
1221+
1222+ def make_test_tree(self, format=None):
1223+ tree = self.make_branch_and_tree('.', format=format)
1224+ self.build_tree_contents([
1225+ ('debian/', ),
1226+ ('debian/control', """\
1227+Source: blah
1228+Vcs-Git: https://example.com/blah
1229+Testsuite: autopkgtest
1230+
1231+Binary: blah
1232+Arch: all
1233+
1234+"""),
1235+ ('debian/changelog', 'Some contents')])
1236+ tree.add(['debian', 'debian/changelog', 'debian/control'])
1237+ tree.commit('Initial thingy.')
1238+ return tree
1239+
1240+ def test_pending_changes(self):
1241+ tree = self.make_test_tree()
1242+ self.build_tree_contents([('debian/changelog', 'blah')])
1243+ with tree.lock_write():
1244+ self.assertRaises(
1245+ WorkspaceDirty, check_clean_tree, tree)
1246+
1247+ def test_pending_changes_bzr_empty_dir(self):
1248+ # See https://bugs.debian.org/914038
1249+ tree = self.make_test_tree(format='bzr')
1250+ self.build_tree_contents([('debian/upstream/', )])
1251+ with tree.lock_write():
1252+ self.assertRaises(
1253+ WorkspaceDirty, check_clean_tree, tree)
1254+
1255+ def test_pending_changes_git_empty_dir(self):
1256+ # See https://bugs.debian.org/914038
1257+ tree = self.make_test_tree(format='git')
1258+ self.build_tree_contents([('debian/upstream/', )])
1259+ with tree.lock_write():
1260+ check_clean_tree(tree)
1261+
1262+ def test_pending_changes_git_dir_with_ignored(self):
1263+ # See https://bugs.debian.org/914038
1264+ tree = self.make_test_tree(format='git')
1265+ self.build_tree_contents([
1266+ ('debian/upstream/', ),
1267+ ('debian/upstream/blah', ''),
1268+ ('.gitignore', 'blah\n'),
1269+ ])
1270+ tree.add('.gitignore')
1271+ tree.commit('add gitignore')
1272+ with tree.lock_write():
1273+ check_clean_tree(tree)
1274+
1275+ def test_extra(self):
1276+ tree = self.make_test_tree()
1277+ self.build_tree_contents([('debian/foo', 'blah')])
1278+ with tree.lock_write():
1279+ self.assertRaises(
1280+ WorkspaceDirty, check_clean_tree,
1281+ tree)
1282+
1283+
1284+def vary_by_inotify():
1285+ return [
1286+ ('with_inotify', dict(_use_inotify=True)),
1287+ ('without_inotify', dict(_use_inotify=False)),
1288+ ]
1289+
1290+
1291+def vary_by_format():
1292+ return [
1293+ ('bzr', dict(_format='bzr')),
1294+ ('git', dict(_format='git')),
1295+ ]
1296+
1297+
1298+class WorkspaceTests(TestCaseWithTransport):
1299+
1300+ scenarios = multiply_scenarios(
1301+ vary_by_inotify(),
1302+ vary_by_format(),
1303+ )
1304+
1305+ def test_root_add(self):
1306+ tree = self.make_branch_and_tree('.', format=self._format)
1307+ with Workspace(tree, use_inotify=self._use_inotify) as ws:
1308+ self.build_tree_contents([('afile', 'somecontents')])
1309+ changes = [c for c in ws.iter_changes() if c.path[1] != '']
1310+ self.assertEqual(1, len(changes), changes)
1311+ self.assertEqual((None, 'afile'), changes[0].path)
1312+ ws.commit(message='Commit message')
1313+ self.assertEqual(list(ws.iter_changes()), [])
1314+ self.build_tree_contents([('afile', 'newcontents')])
1315+ [change] = list(ws.iter_changes())
1316+ self.assertEqual(('afile', 'afile'), change.path)
1317+
1318+ def test_root_remove(self):
1319+ tree = self.make_branch_and_tree('.', format=self._format)
1320+ self.build_tree_contents([('afile', 'somecontents')])
1321+ tree.add(['afile'])
1322+ tree.commit('Afile')
1323+ with Workspace(tree, use_inotify=self._use_inotify) as ws:
1324+ os.remove('afile')
1325+ changes = list(ws.iter_changes())
1326+ self.assertEqual(1, len(changes), changes)
1327+ self.assertEqual(('afile', None), changes[0].path)
1328+ ws.commit(message='Commit message')
1329+ self.assertEqual(list(ws.iter_changes()), [])
1330+
1331+ def test_subpath_add(self):
1332+ tree = self.make_branch_and_tree('.', format=self._format)
1333+ self.build_tree(['subpath/'])
1334+ tree.add('subpath')
1335+ tree.commit('add subpath')
1336+ with Workspace(
1337+ tree, subpath='subpath', use_inotify=self._use_inotify) as ws:
1338+ self.build_tree_contents([('outside', 'somecontents')])
1339+ self.build_tree_contents([('subpath/afile', 'somecontents')])
1340+ changes = [c for c in ws.iter_changes() if c.path[1] != 'subpath']
1341+ self.assertEqual(1, len(changes), changes)
1342+ self.assertEqual((None, 'subpath/afile'), changes[0].path)
1343+ ws.commit(message='Commit message')
1344+ self.assertEqual(list(ws.iter_changes()), [])
1345+
1346+ def test_dirty(self):
1347+ tree = self.make_branch_and_tree('.', format=self._format)
1348+ self.build_tree(['subpath'])
1349+ self.assertRaises(
1350+ WorkspaceDirty, Workspace(tree, use_inotify=self._use_inotify).__enter__)
1351+
1352+ def test_reset(self):
1353+ tree = self.make_branch_and_tree('.', format=self._format)
1354+ with Workspace(tree, use_inotify=self._use_inotify) as ws:
1355+ self.build_tree(['blah'])
1356+ ws.reset()
1357+ self.assertPathDoesNotExist('blah')
1358+
1359+ def test_tree_path(self):
1360+ tree = self.make_branch_and_tree('.', format=self._format)
1361+ tree.mkdir('subdir')
1362+ tree.commit('Add subdir')
1363+ with Workspace(tree, use_inotify=self._use_inotify) as ws:
1364+ self.assertEqual('foo', ws.tree_path('foo'))
1365+ self.assertEqual('', ws.tree_path())
1366+ with Workspace(tree, subpath='subdir', use_inotify=self._use_inotify) as ws:
1367+ self.assertEqual('subdir/foo', ws.tree_path('foo'))
1368+ self.assertEqual('subdir/', ws.tree_path())
1369+
1370+ def test_abspath(self):
1371+ tree = self.make_branch_and_tree('.', format=self._format)
1372+ tree.mkdir('subdir')
1373+ tree.commit('Add subdir')
1374+ with Workspace(tree, use_inotify=self._use_inotify) as ws:
1375+ self.assertEqual(tree.abspath('foo'), ws.abspath('foo'))
1376+ self.assertEqual(tree.abspath(''), ws.abspath())
1377+ with Workspace(tree, subpath='subdir', use_inotify=self._use_inotify) as ws:
1378+ self.assertEqual(tree.abspath('subdir/foo'), ws.abspath('foo'))
1379+ self.assertEqual(tree.abspath('subdir') + '/', ws.abspath(''))
1380+ self.assertEqual(tree.abspath('subdir') + '/', ws.abspath())
1381+
1382+ def test_open_containing(self):
1383+ tree = self.make_branch_and_tree('.', format=self._format)
1384+ tree.mkdir('subdir')
1385+ tree.commit('Add subdir')
1386+ ws = Workspace.from_path('subdir')
1387+ self.assertEqual(ws.tree.abspath('.'), tree.abspath('.'))
1388+ self.assertEqual(ws.subpath, 'subdir')
1389+ self.assertEqual(None, ws.use_inotify)
1390
1391=== added file 'breezy/workspace.py'
1392--- breezy/workspace.py 1970-01-01 00:00:00 +0000
1393+++ breezy/workspace.py 2020-07-28 02:11:42 +0000
1394@@ -0,0 +1,217 @@
1395+# Copyright (C) 2018-2020 Jelmer Vernooij
1396+#
1397+# This program is free software; you can redistribute it and/or modify
1398+# it under the terms of the GNU General Public License as published by
1399+# the Free Software Foundation; either version 2 of the License, or
1400+# (at your option) any later version.
1401+#
1402+# This program is distributed in the hope that it will be useful,
1403+# but WITHOUT ANY WARRANTY; without even the implied warranty of
1404+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1405+# GNU General Public License for more details.
1406+#
1407+# You should have received a copy of the GNU General Public License
1408+# along with this program; if not, write to the Free Software
1409+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
1410+
1411+"""Convenience functions for efficiently making changes to a working tree.
1412+
1413+If possible, uses inotify to track changes in the tree - providing
1414+high performance in large trees with a small number of changes.
1415+"""
1416+
1417+from __future__ import absolute_import
1418+
1419+import errno
1420+import os
1421+import shutil
1422+
1423+
1424+from .clean_tree import iter_deletables
1425+from .errors import BzrError, DependencyNotPresent
1426+from .trace import warning
1427+from .transform import revert
1428+from .workingtree import WorkingTree
1429+
1430+
1431+class WorkspaceDirty(BzrError):
1432+ _fmt = "The directory %(path)s has pending changes."
1433+
1434+ def __init__(self, tree):
1435+ BzrError(self, path=tree.abspath('.'))
1436+
1437+
1438+# TODO(jelmer): Move to .clean_tree?
1439+def reset_tree(local_tree, subpath=''):
1440+ """Reset a tree back to its basis tree.
1441+
1442+ This will leave ignored and detritus files alone.
1443+
1444+ Args:
1445+ local_tree: tree to work on
1446+ subpath: Subpath to operate on
1447+ """
1448+ revert(local_tree, local_tree.branch.basis_tree(),
1449+ [subpath] if subpath not in ('.', '') else None)
1450+ deletables = list(iter_deletables(
1451+ local_tree, unknown=True, ignored=False, detritus=False))
1452+ delete_items(deletables)
1453+
1454+
1455+# TODO(jelmer): Move to .clean_tree?
1456+def check_clean_tree(local_tree):
1457+ """Check that a tree is clean and has no pending changes or unknown files.
1458+
1459+ Args:
1460+ local_tree: The tree to check
1461+ Raises:
1462+ PendingChanges: When there are pending changes
1463+ """
1464+ # Just check there are no changes to begin with
1465+ if local_tree.has_changes():
1466+ raise WorkspaceDirty(local_tree)
1467+ if list(local_tree.unknowns()):
1468+ raise WorkspaceDirty(local_tree)
1469+
1470+
1471+def delete_items(deletables, dry_run: bool = False):
1472+ """Delete files in the deletables iterable"""
1473+ def onerror(function, path, excinfo):
1474+ """Show warning for errors seen by rmtree.
1475+ """
1476+ # Handle only permission error while removing files.
1477+ # Other errors are re-raised.
1478+ if function is not os.remove or excinfo[1].errno != errno.EACCES:
1479+ raise
1480+ warnings.warn('unable to remove %s' % path)
1481+ for path, subp in deletables:
1482+ if os.path.isdir(path):
1483+ shutil.rmtree(path, onerror=onerror)
1484+ else:
1485+ try:
1486+ os.unlink(path)
1487+ except OSError as e:
1488+ # We handle only permission error here
1489+ if e.errno != errno.EACCES:
1490+ raise e
1491+ warning('unable to remove "%s": %s.', path, e.strerror)
1492+
1493+
1494+def get_dirty_tracker(local_tree, subpath='', use_inotify=None):
1495+ """Create a dirty tracker object."""
1496+ if use_inotify is True:
1497+ from .dirty_tracker import DirtyTracker
1498+ return DirtyTracker(local_tree, subpath)
1499+ elif use_inotify is False:
1500+ return None
1501+ else:
1502+ try:
1503+ from .dirty_tracker import DirtyTracker
1504+ except DependencyNotPresent:
1505+ return None
1506+ else:
1507+ return DirtyTracker(local_tree, subpath)
1508+
1509+
1510+class Workspace(object):
1511+ """Create a workspace.
1512+
1513+ :param tree: Tree to work in
1514+ :param subpath: path under which to consider and commit changes
1515+ :param use_inotify: whether to use inotify (default: yes, if available)
1516+ """
1517+
1518+ def __init__(self, tree, subpath='', use_inotify=None):
1519+ self.tree = tree
1520+ self.subpath = subpath
1521+ self.use_inotify = use_inotify
1522+ self._dirty_tracker = None
1523+
1524+ @classmethod
1525+ def from_path(cls, path, use_inotify=None):
1526+ tree, subpath = WorkingTree.open_containing(path)
1527+ return cls(tree, subpath, use_inotify=use_inotify)
1528+
1529+ def __enter__(self):
1530+ check_clean_tree(self.tree)
1531+ self._dirty_tracker = get_dirty_tracker(
1532+ self.tree, subpath=self.subpath, use_inotify=self.use_inotify)
1533+ return self
1534+
1535+ def __exit__(self, exc_type, exc_val, exc_tb):
1536+ if self._dirty_tracker:
1537+ del self._dirty_tracker
1538+ self._dirty_tracker = None
1539+ return False
1540+
1541+ def tree_path(self, path=''):
1542+ """Return a path relative to the tree subpath used by this workspace.
1543+ """
1544+ return os.path.join(self.subpath, path)
1545+
1546+ def abspath(self, path=''):
1547+ """Return an absolute path for the tree."""
1548+ return self.tree.abspath(self.tree_path(path))
1549+
1550+ def reset(self):
1551+ """Reset - revert local changes, revive deleted files, remove added.
1552+ """
1553+ if self._dirty_tracker and not self._dirty_tracker.is_dirty():
1554+ return
1555+ reset_tree(self.tree, self.subpath)
1556+ if self._dirty_tracker is not None:
1557+ self._dirty_tracker.mark_clean()
1558+
1559+ def _stage(self):
1560+ if self._dirty_tracker:
1561+ relpaths = self._dirty_tracker.relpaths()
1562+ # Sort paths so that directories get added before the files they
1563+ # contain (on VCSes where it matters)
1564+ self.tree.add(
1565+ [p for p in sorted(relpaths)
1566+ if self.tree.has_filename(p) and not
1567+ self.tree.is_ignored(p)])
1568+ return [
1569+ p for p in relpaths
1570+ if self.tree.is_versioned(p)]
1571+ else:
1572+ self.tree.smart_add([self.tree.abspath(self.subpath)])
1573+ return [self.subpath] if self.subpath else None
1574+
1575+ def iter_changes(self):
1576+ with self.tree.lock_write():
1577+ specific_files = self._stage()
1578+ basis_tree = self.tree.basis_tree()
1579+ # TODO(jelmer): After Python 3.3, use 'yield from'
1580+ for change in self.tree.iter_changes(
1581+ basis_tree, specific_files=specific_files,
1582+ want_unversioned=False, require_versioned=True):
1583+ if change.kind[1] is None and change.versioned[1]:
1584+ if change.path[0] is None:
1585+ continue
1586+ # "missing" path
1587+ change = change.discard_new()
1588+ yield change
1589+
1590+ def commit(self, **kwargs):
1591+ """Create a commit.
1592+
1593+ See WorkingTree.commit() for documentation.
1594+ """
1595+ if 'specific_files' in kwargs:
1596+ raise NotImplementedError(self.commit)
1597+
1598+ with self.tree.lock_write():
1599+ specific_files = self._stage()
1600+
1601+ if self.tree.supports_setting_file_ids():
1602+ from .rename_map import RenameMap
1603+ basis_tree = self.tree.basis_tree()
1604+ RenameMap.guess_renames(
1605+ basis_tree, self.tree, dry_run=False)
1606+
1607+ kwargs['specific_files'] = specific_files
1608+ revid = self.tree.commit(**kwargs)
1609+ if self._dirty_tracker:
1610+ self._dirty_tracker.mark_clean()
1611+ return revid
1612
1613=== modified file 'byov.conf'
1614--- byov.conf 2020-06-01 19:35:12 +0000
1615+++ byov.conf 2020-07-28 02:11:42 +0000
1616@@ -23,7 +23,7 @@
1617
1618 # FIXME: Arguably this should be vm.build_deps=brz but it requires either an
1619 # available package or at least a debian/ dir ? -- vila 2018-02-23
1620-brz.build_deps = gcc, debhelper, python3, python3-all-dev, python3-configobj, python3-docutils, python3-paramiko, python3-subunit, python3-testtools, subunit, python3-pip, python3-setuptools, python3-flake8, python3-sphinx, python3-launchpadlib
1621+brz.build_deps = gcc, debhelper, python3, python3-all-dev, python3-configobj, python3-docutils, python3-paramiko, python3-subunit, python3-testtools, subunit, python3-pip, python3-setuptools, python3-flake8, python3-sphinx, python3-launchpadlib, python3-pyinotify
1622 subunit.build_deps = python3-testscenarios, python3-testtools, cython, cython3, quilt
1623 vm.packages = {brz.build_deps}, {subunit.build_deps}, bzr, git, python-junitxml
1624 [brz-xenial]
1625
1626=== modified file 'doc/en/conf.py'
1627--- doc/en/conf.py 2018-11-21 21:34:30 +0000
1628+++ doc/en/conf.py 2020-07-28 02:11:42 +0000
1629@@ -75,8 +75,8 @@
1630 ('tutorials/centralized_workflow',
1631 'brz-%s-tutorial-centralized' % (brz_locale,),
1632 brz_title(u'Centralized Workflow Tutorial'), brz_team, 'howto'),
1633- ('whats-new/whats-new-in-3.0', 'brz-%s-whats-new' % (brz_locale,),
1634- brz_title(u"What's New in Breezy 3.0?"), brz_team, 'howto'),
1635+ ('whats-new/whats-new-in-3.1', 'brz-%s-whats-new' % (brz_locale,),
1636+ brz_title(u"What's New in Breezy 3.1?"), brz_team, 'howto'),
1637 ]
1638
1639 latex_documents = [
1640
1641=== modified file 'doc/en/release-notes/brz-3.1.txt'
1642--- doc/en/release-notes/brz-3.1.txt 2020-07-18 19:28:51 +0000
1643+++ doc/en/release-notes/brz-3.1.txt 2020-07-28 02:11:42 +0000
1644@@ -46,6 +46,14 @@
1645 * ``BzrDir.sprout`` now correctly handles the ``revision_id``
1646 argument when ``source_branch`` is None. (Jelmer Vernooij)
1647
1648+ * Warn when loading a plugin that is broken, but support
1649+ ``suppress_warnings=load_plugin_failure`` to suppress it.
1650+ (Jelmer Vernooij, #1882528)
1651+
1652+ * Add a basic ``svn:`` revision spec. Currently this doesn't work,
1653+ but it prevents the DWIM revision specifier from treating "svn:"
1654+ as a URL. (Jelmer Vernooij)
1655+
1656 Bug Fixes
1657 *********
1658
1659@@ -85,6 +93,12 @@
1660 is currently implemented for GitHub, GitLab and Launchpad.
1661 (Jelmer Vernooij)
1662
1663+ * Add a ``MemoryBranch`` implementation. (Jelmer Vernooij)
1664+
1665+ * A new ``Workspace`` interface is now available for efficiently
1666+ making changes to large working trees from automation.
1667+ (Jelmer Vernooij)
1668+
1669 Testing
1670 *******
1671
1672
1673=== modified file 'setup.py'
1674--- setup.py 2020-06-23 01:02:30 +0000
1675+++ setup.py 2020-07-28 02:11:42 +0000
1676@@ -74,6 +74,7 @@
1677 'fastimport': [],
1678 'git': [],
1679 'launchpad': ['launchpadlib>=1.6.3'],
1680+ 'workspace': ['pyinotify'],
1681 },
1682 'tests_require': [
1683 'testtools',

Subscribers

People subscribed via source and target branches