1
# Copyright (C) 2011, 2012 Canonical Ltd
3
# This program is free software; you can redistribute it and/or modify
4
# it under the terms of the GNU General Public License as published by
5
# the Free Software Foundation; either version 2 of the License, or
6
# (at your option) any later version.
8
# This program is distributed in the hope that it will be useful,
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
# GNU General Public License for more details.
13
# You should have received a copy of the GNU General Public License
14
# along with this program; if not, write to the Free Software
15
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
17
"""bzr-upload command implementations."""
19
from __future__ import absolute_import
28
lazy_import.lazy_import(globals(), """
43
from ...sixish import (
47
auto_option = config.Option(
48
'upload_auto', default=False, from_unicode=config.bool_from_store,
50
Whether upload should occur when the tip of the branch changes.
52
auto_quiet_option = config.Option(
53
'upload_auto_quiet', default=False, from_unicode=config.bool_from_store,
55
Whether upload should occur quietly.
57
location_option = config.Option(
58
'upload_location', default=None,
60
The url to upload the working tree to.
62
revid_location_option = config.Option(
63
'upload_revid_location', default=u'.bzr-upload.revid',
65
The relative path to be used to store the uploaded revid.
67
The only bzr-related info uploaded with the working tree is the corresponding
68
revision id. The uploaded working tree is not linked to any other bzr data.
70
If the layout of your remote server is such that you can't write in the
71
root directory but only in the directories inside that root, you will need
72
to use the 'upload_revid_location' configuration variable to specify the
73
relative path to be used. That configuration variable can be specified in
74
locations.conf or branch.conf.
76
For example, given the following layout:
82
you may have write access in 'private' and 'public' but in 'Project'
83
itself. In that case, you can add the following in your locations.conf or
86
upload_revid_location = private/.bzr-upload.revid
90
# FIXME: Add more tests around invalid paths or relative paths that doesn't
91
# exist on remote (if only to get proper error messages) for
92
# 'upload_revid_location'
95
class BzrUploader(object):
97
def __init__(self, branch, to_transport, outf, tree, rev_id,
100
self.to_transport = to_transport
105
self._pending_deletions = []
106
self._pending_renames = []
107
self._uploaded_revid = None
110
def _up_stat(self, relpath):
111
return self.to_transport.stat(urlutils.escape(relpath))
113
def _up_rename(self, old_path, new_path):
114
return self.to_transport.rename(urlutils.escape(old_path),
115
urlutils.escape(new_path))
117
def _up_delete(self, relpath):
118
return self.to_transport.delete(urlutils.escape(relpath))
120
def _up_delete_tree(self, relpath):
121
return self.to_transport.delete_tree(urlutils.escape(relpath))
123
def _up_mkdir(self, relpath, mode):
124
return self.to_transport.mkdir(urlutils.escape(relpath), mode)
126
def _up_rmdir(self, relpath):
127
return self.to_transport.rmdir(urlutils.escape(relpath))
129
def _up_put_bytes(self, relpath, bytes, mode):
130
self.to_transport.put_bytes(urlutils.escape(relpath), bytes, mode)
132
def _up_get_bytes(self, relpath):
133
return self.to_transport.get_bytes(urlutils.escape(relpath))
135
def set_uploaded_revid(self, rev_id):
136
# XXX: Add tests for concurrent updates, etc.
137
revid_path = self.branch.get_config_stack().get('upload_revid_location')
138
self.to_transport.put_bytes(urlutils.escape(revid_path), rev_id)
139
self._uploaded_revid = rev_id
141
def get_uploaded_revid(self):
142
if self._uploaded_revid is None:
143
revid_path = self.branch.get_config_stack(
144
).get('upload_revid_location')
146
self._uploaded_revid = self._up_get_bytes(revid_path)
147
except errors.NoSuchFile:
148
# We have not uploaded to here.
149
self._uploaded_revid = revision.NULL_REVISION
150
return self._uploaded_revid
152
def _get_ignored(self):
153
if self._ignored is None:
155
ignore_file_path = '.bzrignore-upload'
156
ignore_file = self.tree.get_file(ignore_file_path)
157
except errors.NoSuchFile:
158
ignored_patterns = []
160
ignored_patterns = ignores.parse_ignore_file(ignore_file)
161
self._ignored = globbing.Globster(ignored_patterns)
164
def is_ignored(self, relpath):
165
glob = self._get_ignored()
166
ignored = glob.match(relpath)
169
# We still need to check that all parents are not ignored
170
dir = os.path.dirname(relpath)
171
while dir and not ignored:
172
ignored = glob.match(dir)
174
dir = os.path.dirname(dir)
177
def upload_file(self, old_relpath, new_relpath, id, mode=None):
179
if self.tree.is_executable(new_relpath, id):
184
self.outf.write('Uploading %s\n' % old_relpath)
185
self._up_put_bytes(old_relpath, self.tree.get_file_text(new_relpath, id), mode)
187
def upload_file_robustly(self, relpath, id, mode=None):
188
"""Upload a file, clearing the way on the remote side.
190
When doing a full upload, it may happen that a directory exists where
191
we want to put our file.
194
st = self._up_stat(relpath)
195
if stat.S_ISDIR(st.st_mode):
196
# A simple rmdir may not be enough
198
self.outf.write('Clearing %s/%s\n' % (
199
self.to_transport.external_url(), relpath))
200
self._up_delete_tree(relpath)
201
except errors.PathError:
203
self.upload_file(relpath, relpath, id, mode)
205
def make_remote_dir(self, relpath, mode=None):
208
self._up_mkdir(relpath, mode)
210
def make_remote_dir_robustly(self, relpath, mode=None):
211
"""Create a remote directory, clearing the way on the remote side.
213
When doing a full upload, it may happen that a file exists where we
214
want to create our directory.
217
st = self._up_stat(relpath)
218
if not stat.S_ISDIR(st.st_mode):
220
self.outf.write('Deleting %s/%s\n' % (
221
self.to_transport.external_url(), relpath))
222
self._up_delete(relpath)
224
# Ok the remote dir already exists, nothing to do
226
except errors.PathError:
228
self.make_remote_dir(relpath, mode)
230
def delete_remote_file(self, relpath):
232
self.outf.write('Deleting %s\n' % relpath)
233
self._up_delete(relpath)
235
def delete_remote_dir(self, relpath):
237
self.outf.write('Deleting %s\n' % relpath)
238
self._up_rmdir(relpath)
239
# XXX: Add a test where a subdir is ignored but we still want to
240
# delete the dir -- vila 100106
242
def delete_remote_dir_maybe(self, relpath):
243
"""Try to delete relpath, keeping failures to retry later."""
245
self._up_rmdir(relpath)
246
# any kind of PathError would be OK, though we normally expect
248
except errors.PathError:
249
self._pending_deletions.append(relpath)
251
def finish_deletions(self):
252
if self._pending_deletions:
253
# Process the previously failed deletions in reverse order to
254
# delete children before parents
255
for relpath in reversed(self._pending_deletions):
256
self._up_rmdir(relpath)
257
# The following shouldn't be needed since we use it once per
258
# upload, but better safe than sorry ;-)
259
self._pending_deletions = []
261
def rename_remote(self, old_relpath, new_relpath):
262
"""Rename a remote file or directory taking care of collisions.
264
To avoid collisions during bulk renames, each renamed target is
265
temporarily assigned a unique name. When all renames have been done,
266
each target get its proper name.
268
# We generate a sufficiently random name to *assume* that
269
# no collisions will occur and don't worry about it (nor
275
stamp = '.tmp.%.9f.%d.%d' % (time.time(),
277
random.randint(0, 0x7FFFFFFF))
279
self.outf.write('Renaming %s to %s\n' % (old_relpath, new_relpath))
280
self._up_rename(old_relpath, stamp)
281
self._pending_renames.append((stamp, new_relpath))
283
def finish_renames(self):
284
for (stamp, new_path) in self._pending_renames:
285
self._up_rename(stamp, new_path)
286
# The following shouldn't be needed since we use it once per upload,
287
# but better safe than sorry ;-)
288
self._pending_renames = []
290
def upload_full_tree(self):
291
self.to_transport.ensure_base() # XXX: Handle errors (add
292
# --create-prefix option ?)
293
with self.tree.lock_read():
294
for relpath, ie in self.tree.iter_entries_by_dir():
295
if relpath in ('', '.bzrignore', '.bzrignore-upload'):
297
# .bzrignore and .bzrignore-upload have no meaning outside
298
# a working tree so do not upload them
300
if self.is_ignored(relpath):
302
self.outf.write('Ignoring %s\n' % relpath)
304
if ie.kind == 'file':
305
self.upload_file_robustly(relpath, ie.file_id)
306
elif ie.kind == 'directory':
307
self.make_remote_dir_robustly(relpath)
308
elif ie.kind == 'symlink':
310
target = self.tree.path_content_summary(relpath)[3]
311
self.outf.write('Not uploading symlink %s -> %s\n'
314
raise NotImplementedError
315
self.set_uploaded_revid(self.rev_id)
317
def upload_tree(self):
318
# If we can't find the revid file on the remote location, upload the
320
rev_id = self.get_uploaded_revid()
322
if rev_id == revision.NULL_REVISION:
324
self.outf.write('No uploaded revision id found,'
325
' switching to full upload\n')
326
self.upload_full_tree()
330
# Check if the revision hasn't already been uploaded
331
if rev_id == self.rev_id:
333
self.outf.write('Remote location already up to date\n')
335
from_tree = self.branch.repository.revision_tree(rev_id)
336
self.to_transport.ensure_base() # XXX: Handle errors (add
337
# --create-prefix option ?)
338
changes = self.tree.changes_from(from_tree)
339
with self.tree.lock_read():
340
for (path, id, kind) in changes.removed:
341
if self.is_ignored(path):
343
self.outf.write('Ignoring %s\n' % path)
346
self.delete_remote_file(path)
347
elif kind is 'directory':
348
self.delete_remote_dir_maybe(path)
349
elif kind == 'symlink':
351
target = self.tree.path_content_summary(path)[3]
352
self.outf.write('Not deleting remote symlink %s -> %s\n'
355
raise NotImplementedError
357
for (old_path, new_path, id, kind,
358
content_change, exec_change) in changes.renamed:
359
if self.is_ignored(old_path) and self.is_ignored(new_path):
361
self.outf.write('Ignoring %s\n' % old_path)
362
self.outf.write('Ignoring %s\n' % new_path)
365
# We update the old_path content because renames and
366
# deletions are differed.
367
self.upload_file(old_path, new_path, id)
368
if kind == 'symlink':
370
self.outf.write('Not renaming remote symlink %s to %s\n'
371
% (old_path, new_path))
373
self.rename_remote(old_path, new_path)
374
self.finish_renames()
375
self.finish_deletions()
377
for (path, id, old_kind, new_kind) in changes.kind_changed:
378
if self.is_ignored(path):
380
self.outf.write('Ignoring %s\n' % path)
382
if old_kind == 'file':
383
self.delete_remote_file(path)
384
elif old_kind == 'directory':
385
self.delete_remote_dir(path)
387
raise NotImplementedError
389
if new_kind == 'file':
390
self.upload_file(path, path, id)
391
elif new_kind is 'directory':
392
self.make_remote_dir(path)
394
raise NotImplementedError
396
for (path, id, kind) in changes.added:
397
if self.is_ignored(path):
399
self.outf.write('Ignoring %s\n' % path)
402
self.upload_file(path, path, id)
403
elif kind == 'directory':
404
self.make_remote_dir(path)
405
elif kind == 'symlink':
407
target = self.tree.path_content_summary(path)[3]
408
self.outf.write('Not uploading symlink %s -> %s\n'
411
raise NotImplementedError
413
# XXX: Add a test for exec_change
415
content_change, exec_change) in changes.modified:
416
if self.is_ignored(path):
418
self.outf.write('Ignoring %s\n' % path)
421
self.upload_file(path, path, id)
423
raise NotImplementedError
425
self.set_uploaded_revid(self.rev_id)
428
class CannotUploadToWorkingTree(errors.BzrCommandError):
430
_fmt = 'Cannot upload to a bzr managed working tree: %(url)s".'
433
class DivergedUploadedTree(errors.BzrCommandError):
435
_fmt = ("Your branch (%(revid)s)"
436
" and the uploaded tree (%(uploaded_revid)s) have diverged: ")
439
class cmd_upload(commands.Command):
440
"""Upload a working tree, as a whole or incrementally.
442
If no destination is specified use the last one used.
443
If no revision is specified upload the changes since the last upload.
445
Changes include files added, renamed, modified or removed.
447
_see_also = ['plugins/upload']
448
takes_args = ['location?']
453
option.Option('full', 'Upload the full working tree.'),
454
option.Option('quiet', 'Do not output what is being done.',
456
option.Option('directory',
457
help='Branch to upload from, '
458
'rather than the one containing the working directory.',
462
option.Option('auto',
463
'Trigger an upload from this branch whenever the tip '
467
def run(self, location=None, full=False, revision=None, remember=None,
468
directory=None, quiet=False, auto=None, overwrite=False
470
if directory is None:
474
relpath) = controldir.ControlDir.open_containing_tree_or_branch(
485
changes = wt.changes_from(wt.basis_tree())
487
if revision is None and changes.has_changed():
488
raise errors.UncommittedChanges(wt)
490
conf = branch.get_config_stack()
492
stored_loc = conf.get('upload_location')
493
if stored_loc is None:
494
raise errors.BzrCommandError(
495
'No upload location known or specified.')
497
# FIXME: Not currently tested
498
display_url = urlutils.unescape_for_display(stored_loc,
500
self.outf.write("Using saved location: %s\n" % display_url)
501
location = stored_loc
503
to_transport = transport.get_transport(location)
505
# Check that we are not uploading to a existing working tree.
507
to_bzr_dir = controldir.ControlDir.open_from_transport(
509
has_wt = to_bzr_dir.has_workingtree()
510
except errors.NotBranchError:
512
except errors.NotLocalUrl:
513
# The exception raised is a bit weird... but that's life.
517
raise CannotUploadToWorkingTree(url=location)
519
rev_id = branch.last_revision()
521
if len(revision) != 1:
522
raise errors.BzrCommandError(
523
'bzr upload --revision takes exactly 1 argument')
524
rev_id = revision[0].in_history(branch).rev_id
526
tree = branch.repository.revision_tree(rev_id)
528
uploader = BzrUploader(branch, to_transport, self.outf, tree,
532
prev_uploaded_rev_id = uploader.get_uploaded_revid()
533
graph = branch.repository.get_graph()
534
if not graph.is_ancestor(prev_uploaded_rev_id, rev_id):
535
raise DivergedUploadedTree(
536
revid=rev_id, uploaded_revid=prev_uploaded_rev_id)
538
uploader.upload_full_tree()
540
uploader.upload_tree()
544
# We uploaded successfully, remember it
547
upload_location = conf.get('upload_location')
548
if upload_location is None or remember:
549
conf.set('upload_location',
550
urlutils.unescape(to_transport.base))
552
conf.set('upload_auto', auto)