Merge lp:~jelmer/brz/win-symlink-warning into lp:brz

Proposed by Jelmer Vernooij
Status: Superseded
Proposed branch: lp:~jelmer/brz/win-symlink-warning
Merge into: lp:brz
Diff against target: 1464 lines (+487/-201)
27 files modified
breezy/bzr/dirstate.py (+15/-11)
breezy/bzr/inventorytree.py (+1/-1)
breezy/bzr/workingtree.py (+1/-0)
breezy/bzr/workingtree_4.py (+2/-1)
breezy/commit.py (+5/-0)
breezy/delta.py (+7/-3)
breezy/diff.py (+5/-0)
breezy/errors.py (+0/-15)
breezy/git/memorytree.py (+7/-0)
breezy/git/tree.py (+8/-1)
breezy/git/workingtree.py (+6/-8)
breezy/memorytree.py (+21/-4)
breezy/mutabletree.py (+25/-7)
breezy/osutils.py (+55/-2)
breezy/tests/per_tree/test_symlinks.py (+40/-0)
breezy/tests/test_commit.py (+31/-0)
breezy/tests/test_errors.py (+0/-14)
breezy/tests/test_memorytree.py (+65/-70)
breezy/tests/test_osutils.py (+21/-0)
breezy/tests/test_transform.py (+55/-11)
breezy/tests/test_workingtree.py (+33/-0)
breezy/transform.py (+15/-10)
breezy/transport/local.py (+1/-1)
breezy/transport/memory.py (+52/-34)
breezy/tree.py (+7/-1)
breezy/workingtree.py (+4/-7)
doc/en/release-notes/brz-3.0.txt (+5/-0)
To merge this branch: bzr merge lp:~jelmer/brz/win-symlink-warning
Reviewer Review Type Date Requested Status
Breezy developers Pending
Review via email: mp+363899@code.launchpad.net

This proposal has been superseded by a proposal from 2019-03-04.

Description of the change

Allow symbolic links to exist when checking out trees on Windows.

Warnings are printed for the symbolic links, but the rest of the tree is still
checked out. Operations that work across the tree and require on-disk access
(e.g. "commit", "diff", "status") will warn about the symbolic links but not
otherwise prentend that the files are missing.

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
=== modified file 'breezy/bzr/dirstate.py'
--- breezy/bzr/dirstate.py 2018-11-18 19:48:57 +0000
+++ breezy/bzr/dirstate.py 2019-03-04 01:57:09 +0000
@@ -366,7 +366,8 @@
366 HEADER_FORMAT_2 = b'#bazaar dirstate flat format 2\n'366 HEADER_FORMAT_2 = b'#bazaar dirstate flat format 2\n'
367 HEADER_FORMAT_3 = b'#bazaar dirstate flat format 3\n'367 HEADER_FORMAT_3 = b'#bazaar dirstate flat format 3\n'
368368
369 def __init__(self, path, sha1_provider, worth_saving_limit=0):369 def __init__(self, path, sha1_provider, worth_saving_limit=0,
370 use_filesystem_for_exec=True):
370 """Create a DirState object.371 """Create a DirState object.
371372
372 :param path: The path at which the dirstate file on disk should live.373 :param path: The path at which the dirstate file on disk should live.
@@ -375,6 +376,8 @@
375 entries is known, only bother saving the dirstate if more than376 entries is known, only bother saving the dirstate if more than
376 this count of entries have changed.377 this count of entries have changed.
377 -1 means never save hash changes, 0 means always save hash changes.378 -1 means never save hash changes, 0 means always save hash changes.
379 :param use_filesystem_for_exec: Whether to trust the filesystem
380 for executable bit information
378 """381 """
379 # _header_state and _dirblock_state represent the current state382 # _header_state and _dirblock_state represent the current state
380 # of the dirstate metadata and the per-row data respectiely.383 # of the dirstate metadata and the per-row data respectiely.
@@ -423,6 +426,7 @@
423 self._worth_saving_limit = worth_saving_limit426 self._worth_saving_limit = worth_saving_limit
424 self._config_stack = config.LocationStack(urlutils.local_path_to_url(427 self._config_stack = config.LocationStack(urlutils.local_path_to_url(
425 path))428 path))
429 self._use_filesystem_for_exec = use_filesystem_for_exec
426430
427 def __repr__(self):431 def __repr__(self):
428 return "%s(%r)" % \432 return "%s(%r)" % \
@@ -1949,14 +1953,10 @@
19491953
1950 def _is_executable(self, mode, old_executable):1954 def _is_executable(self, mode, old_executable):
1951 """Is this file executable?"""1955 """Is this file executable?"""
1952 return bool(S_IEXEC & mode)1956 if self._use_filesystem_for_exec:
19531957 return bool(S_IEXEC & mode)
1954 def _is_executable_win32(self, mode, old_executable):1958 else:
1955 """On win32 the executable bit is stored in the dirstate."""1959 return old_executable
1956 return old_executable
1957
1958 if sys.platform == 'win32':
1959 _is_executable = _is_executable_win32
19601960
1961 def _read_link(self, abspath, old_link):1961 def _read_link(self, abspath, old_link):
1962 """Read the target of a symlink"""1962 """Read the target of a symlink"""
@@ -2403,7 +2403,8 @@
2403 return len(self._parents) - len(self._ghosts)2403 return len(self._parents) - len(self._ghosts)
24042404
2405 @classmethod2405 @classmethod
2406 def on_file(cls, path, sha1_provider=None, worth_saving_limit=0):2406 def on_file(cls, path, sha1_provider=None, worth_saving_limit=0,
2407 use_filesystem_for_exec=True):
2407 """Construct a DirState on the file at path "path".2408 """Construct a DirState on the file at path "path".
24082409
2409 :param path: The path at which the dirstate file on disk should live.2410 :param path: The path at which the dirstate file on disk should live.
@@ -2412,12 +2413,15 @@
2412 :param worth_saving_limit: when the exact number of hash changed2413 :param worth_saving_limit: when the exact number of hash changed
2413 entries is known, only bother saving the dirstate if more than2414 entries is known, only bother saving the dirstate if more than
2414 this count of entries have changed. -1 means never save.2415 this count of entries have changed. -1 means never save.
2416 :param use_filesystem_for_exec: Whether to trust the filesystem
2417 for executable bit information
2415 :return: An unlocked DirState object, associated with the given path.2418 :return: An unlocked DirState object, associated with the given path.
2416 """2419 """
2417 if sha1_provider is None:2420 if sha1_provider is None:
2418 sha1_provider = DefaultSHA1Provider()2421 sha1_provider = DefaultSHA1Provider()
2419 result = cls(path, sha1_provider,2422 result = cls(path, sha1_provider,
2420 worth_saving_limit=worth_saving_limit)2423 worth_saving_limit=worth_saving_limit,
2424 use_filesystem_for_exec=use_filesystem_for_exec)
2421 return result2425 return result
24222426
2423 def _read_dirblocks_if_needed(self):2427 def _read_dirblocks_if_needed(self):
24242428
=== modified file 'breezy/bzr/inventorytree.py'
--- breezy/bzr/inventorytree.py 2019-02-14 03:30:18 +0000
+++ breezy/bzr/inventorytree.py 2019-03-04 01:57:09 +0000
@@ -573,7 +573,7 @@
573 # expand any symlinks in the directory part, while leaving the573 # expand any symlinks in the directory part, while leaving the
574 # filename alone574 # filename alone
575 # only expanding if symlinks are supported avoids windows path bugs575 # only expanding if symlinks are supported avoids windows path bugs
576 if osutils.has_symlinks():576 if self.tree.supports_symlinks():
577 file_list = list(map(osutils.normalizepath, file_list))577 file_list = list(map(osutils.normalizepath, file_list))
578578
579 user_dirs = {}579 user_dirs = {}
580580
=== modified file 'breezy/bzr/workingtree.py'
--- breezy/bzr/workingtree.py 2018-12-11 00:51:46 +0000
+++ breezy/bzr/workingtree.py 2019-03-04 01:57:09 +0000
@@ -96,6 +96,7 @@
96# impossible as there is no clear relationship between the working tree format96# impossible as there is no clear relationship between the working tree format
97# and the conflict list file format.97# and the conflict list file format.
98CONFLICT_HEADER_1 = b"BZR conflict list format 1"98CONFLICT_HEADER_1 = b"BZR conflict list format 1"
99ERROR_PATH_NOT_FOUND = 3 # WindowsError errno code, equivalent to ENOENT
99100
100101
101class InventoryWorkingTree(WorkingTree, MutableInventoryTree):102class InventoryWorkingTree(WorkingTree, MutableInventoryTree):
102103
=== modified file 'breezy/bzr/workingtree_4.py'
--- breezy/bzr/workingtree_4.py 2019-02-04 19:39:30 +0000
+++ breezy/bzr/workingtree_4.py 2019-03-04 01:57:09 +0000
@@ -256,7 +256,8 @@
256 local_path = self.controldir.get_workingtree_transport(256 local_path = self.controldir.get_workingtree_transport(
257 None).local_abspath('dirstate')257 None).local_abspath('dirstate')
258 self._dirstate = dirstate.DirState.on_file(258 self._dirstate = dirstate.DirState.on_file(
259 local_path, self._sha1_provider(), self._worth_saving_limit())259 local_path, self._sha1_provider(), self._worth_saving_limit(),
260 self._supports_executable())
260 return self._dirstate261 return self._dirstate
261262
262 def _sha1_provider(self):263 def _sha1_provider(self):
263264
=== modified file 'breezy/commit.py'
--- breezy/commit.py 2018-11-25 20:44:56 +0000
+++ breezy/commit.py 2019-03-04 01:57:09 +0000
@@ -64,6 +64,7 @@
64 StrictCommitFailed64 StrictCommitFailed
65 )65 )
66from .osutils import (get_user_encoding,66from .osutils import (get_user_encoding,
67 has_symlinks,
67 is_inside_any,68 is_inside_any,
68 minimum_path_selection,69 minimum_path_selection,
69 )70 )
@@ -709,6 +710,10 @@
709 # 'missing' path710 # 'missing' path
710 if report_changes:711 if report_changes:
711 reporter.missing(new_path)712 reporter.missing(new_path)
713 if change[6][0] == 'symlink' and not self.work_tree.supports_symlinks():
714 trace.warning('Ignoring "%s" as symlinks are not '
715 'supported on this filesystem.' % (change[1][0],))
716 continue
712 deleted_paths.append(change[1][1])717 deleted_paths.append(change[1][1])
713 # Reset the new path (None) and new versioned flag (False)718 # Reset the new path (None) and new versioned flag (False)
714 change = (change[0], (change[1][0], None), change[2],719 change = (change[0], (change[1][0], None), change[2],
715720
=== modified file 'breezy/delta.py'
--- breezy/delta.py 2018-11-11 04:08:32 +0000
+++ breezy/delta.py 2019-03-04 01:57:09 +0000
@@ -18,11 +18,11 @@
1818
19from breezy import (19from breezy import (
20 osutils,20 osutils,
21 trace,
21 )22 )
22from .sixish import (23from .sixish import (
23 StringIO,24 StringIO,
24 )25 )
25from .trace import is_quiet
2626
2727
28class TreeDelta(object):28class TreeDelta(object):
@@ -141,7 +141,11 @@
141 if fully_present[1] is True:141 if fully_present[1] is True:
142 delta.added.append((path[1], file_id, kind[1]))142 delta.added.append((path[1], file_id, kind[1]))
143 else:143 else:
144 delta.removed.append((path[0], file_id, kind[0]))144 if kind[0] == 'symlink' and not new_tree.supports_symlinks():
145 trace.warning('Ignoring "%s" as symlinks '
146 'are not supported on this filesystem.' % (path[0],))
147 else:
148 delta.removed.append((path[0], file_id, kind[0]))
145 elif fully_present[0] is False:149 elif fully_present[0] is False:
146 delta.missing.append((path[1], file_id, kind[1]))150 delta.missing.append((path[1], file_id, kind[1]))
147 elif name[0] != name[1] or parent_id[0] != parent_id[1]:151 elif name[0] != name[1] or parent_id[0] != parent_id[1]:
@@ -253,7 +257,7 @@
253 :param kind: A pair of file kinds, as generated by Tree.iter_changes.257 :param kind: A pair of file kinds, as generated by Tree.iter_changes.
254 None indicates no file present.258 None indicates no file present.
255 """259 """
256 if is_quiet():260 if trace.is_quiet():
257 return261 return
258 if paths[1] == '' and versioned == 'added' and self.suppress_root_add:262 if paths[1] == '' and versioned == 'added' and self.suppress_root_add:
259 return263 return
260264
=== modified file 'breezy/diff.py'
--- breezy/diff.py 2018-11-25 21:48:55 +0000
+++ breezy/diff.py 2019-03-04 01:57:09 +0000
@@ -982,6 +982,11 @@
982 # is, missing) in both trees are skipped as well.982 # is, missing) in both trees are skipped as well.
983 if parent == (None, None) or kind == (None, None):983 if parent == (None, None) or kind == (None, None):
984 continue984 continue
985 if kind[0] == 'symlink' and not self.new_tree.supports_symlinks():
986 warning(
987 'Ignoring "%s" as symlinks are not '
988 'supported on this filesystem.' % (paths[0],))
989 continue
985 oldpath, newpath = paths990 oldpath, newpath = paths
986 oldpath_encoded = get_encoded_path(paths[0])991 oldpath_encoded = get_encoded_path(paths[0])
987 newpath_encoded = get_encoded_path(paths[1])992 newpath_encoded = get_encoded_path(paths[1])
988993
=== modified file 'breezy/errors.py'
--- breezy/errors.py 2018-11-11 04:08:32 +0000
+++ breezy/errors.py 2019-03-04 01:57:09 +0000
@@ -2303,21 +2303,6 @@
2303 ' (See brz shelve --list).%(more)s')2303 ' (See brz shelve --list).%(more)s')
23042304
23052305
2306class UnableCreateSymlink(BzrError):
2307
2308 _fmt = 'Unable to create symlink %(path_str)son this platform'
2309
2310 def __init__(self, path=None):
2311 path_str = ''
2312 if path:
2313 try:
2314 path_str = repr(str(path))
2315 except UnicodeEncodeError:
2316 path_str = repr(path)
2317 path_str += ' '
2318 self.path_str = path_str
2319
2320
2321class UnableEncodePath(BzrError):2306class UnableEncodePath(BzrError):
23222307
2323 _fmt = ('Unable to encode %(kind)s path %(path)r in '2308 _fmt = ('Unable to encode %(kind)s path %(path)r in '
23242309
=== modified file 'breezy/git/memorytree.py'
--- breezy/git/memorytree.py 2018-11-18 00:25:19 +0000
+++ breezy/git/memorytree.py 2019-03-04 01:57:09 +0000
@@ -58,6 +58,9 @@
58 self._lock_mode = None58 self._lock_mode = None
59 self._populate_from_branch()59 self._populate_from_branch()
6060
61 def _supports_executable(self):
62 return True
63
61 @property64 @property
62 def controldir(self):65 def controldir(self):
63 return self.branch.controldir66 return self.branch.controldir
@@ -258,3 +261,7 @@
258 def kind(self, p):261 def kind(self, p):
259 stat_value = self._file_transport.stat(p)262 stat_value = self._file_transport.stat(p)
260 return osutils.file_kind_from_stat_mode(stat_value.st_mode)263 return osutils.file_kind_from_stat_mode(stat_value.st_mode)
264
265 def get_symlink_target(self, path):
266 with self.lock_read():
267 return self._file_transport.readlink(path)
261268
=== modified file 'breezy/git/tree.py'
--- breezy/git/tree.py 2018-12-18 20:55:37 +0000
+++ breezy/git/tree.py 2019-03-04 01:57:09 +0000
@@ -1447,6 +1447,7 @@
1447 # Report dirified directories to commit_tree first, so that they can be1447 # Report dirified directories to commit_tree first, so that they can be
1448 # replaced with non-empty directories if they have contents.1448 # replaced with non-empty directories if they have contents.
1449 dirified = []1449 dirified = []
1450 trust_executable = target._supports_executable()
1450 for path, index_entry in target._recurse_index_entries():1451 for path, index_entry in target._recurse_index_entries():
1451 try:1452 try:
1452 live_entry = target._live_entry(path)1453 live_entry = target._live_entry(path)
@@ -1465,7 +1466,13 @@
1465 else:1466 else:
1466 raise1467 raise
1467 else:1468 else:
1468 blobs[path] = (live_entry.sha, cleanup_mode(live_entry.mode))1469 mode = live_entry.mode
1470 if not trust_executable:
1471 if mode_is_executable(index_entry.mode):
1472 mode |= 0o111
1473 else:
1474 mode &= ~0o111
1475 blobs[path] = (live_entry.sha, cleanup_mode(mode))
1469 if want_unversioned:1476 if want_unversioned:
1470 for e in target.extras():1477 for e in target.extras():
1471 st = target._lstat(e)1478 st = target._lstat(e)
14721479
=== modified file 'breezy/git/workingtree.py'
--- breezy/git/workingtree.py 2019-02-05 04:00:02 +0000
+++ breezy/git/workingtree.py 2019-03-04 01:57:09 +0000
@@ -414,7 +414,7 @@
414 # expand any symlinks in the directory part, while leaving the414 # expand any symlinks in the directory part, while leaving the
415 # filename alone415 # filename alone
416 # only expanding if symlinks are supported avoids windows path bugs416 # only expanding if symlinks are supported avoids windows path bugs
417 if osutils.has_symlinks():417 if self.supports_symlinks():
418 file_list = list(map(osutils.normalizepath, file_list))418 file_list = list(map(osutils.normalizepath, file_list))
419419
420 conflicts_related = set()420 conflicts_related = set()
@@ -743,8 +743,7 @@
743743
744 def is_executable(self, path):744 def is_executable(self, path):
745 with self.lock_read():745 with self.lock_read():
746 if getattr(self, "_supports_executable",746 if self._supports_executable():
747 osutils.supports_executable)():
748 mode = self._lstat(path).st_mode747 mode = self._lstat(path).st_mode
749 else:748 else:
750 (index, subpath) = self._lookup_index(path.encode('utf-8'))749 (index, subpath) = self._lookup_index(path.encode('utf-8'))
@@ -755,10 +754,8 @@
755 return bool(stat.S_ISREG(mode) and stat.S_IEXEC & mode)754 return bool(stat.S_ISREG(mode) and stat.S_IEXEC & mode)
756755
757 def _is_executable_from_path_and_stat(self, path, stat_result):756 def _is_executable_from_path_and_stat(self, path, stat_result):
758 if getattr(self, "_supports_executable",757 if self._supports_executable():
759 osutils.supports_executable)():758 return self._is_executable_from_path_and_stat_from_stat(path, stat_result)
760 return self._is_executable_from_path_and_stat_from_stat(
761 path, stat_result)
762 else:759 else:
763 return self._is_executable_from_path_and_stat_from_basis(760 return self._is_executable_from_path_and_stat_from_basis(
764 path, stat_result)761 path, stat_result)
@@ -1166,7 +1163,8 @@
1166 self.store,1163 self.store,
1167 None1164 None
1168 if self.branch.head is None1165 if self.branch.head is None
1169 else self.store[self.branch.head].tree)1166 else self.store[self.branch.head].tree,
1167 honor_filemode=self._supports_executable())
11701168
1171 def reset_state(self, revision_ids=None):1169 def reset_state(self, revision_ids=None):
1172 """Reset the state of the working tree.1170 """Reset the state of the working tree.
11731171
=== modified file 'breezy/memorytree.py'
--- breezy/memorytree.py 2018-11-18 00:25:19 +0000
+++ breezy/memorytree.py 2019-03-04 01:57:09 +0000
@@ -22,6 +22,7 @@
22from __future__ import absolute_import22from __future__ import absolute_import
2323
24import os24import os
25import stat
2526
26from . import (27from . import (
27 errors,28 errors,
@@ -50,6 +51,9 @@
50 self._locks = 051 self._locks = 0
51 self._lock_mode = None52 self._lock_mode = None
5253
54 def supports_symlinks(self):
55 return True
56
53 def get_config_stack(self):57 def get_config_stack(self):
54 return self.branch.get_config_stack()58 return self.branch.get_config_stack()
5559
@@ -62,7 +66,15 @@
62 with self.lock_tree_write():66 with self.lock_tree_write():
63 for f, file_id, kind in zip(files, ids, kinds):67 for f, file_id, kind in zip(files, ids, kinds):
64 if kind is None:68 if kind is None:
65 kind = 'file'69 st_mode = self._file_transport.stat(f).st_mode
70 if stat.S_ISREG(st_mode):
71 kind = 'file'
72 elif stat.S_ISLNK(st_mode):
73 kind = 'symlink'
74 elif stat.S_ISDIR(st_mode):
75 kind = 'directory'
76 else:
77 raise AssertionError('Unknown file kind')
66 if file_id is None:78 if file_id is None:
67 self._inventory.add_path(f, kind=kind)79 self._inventory.add_path(f, kind=kind)
68 else:80 else:
@@ -127,7 +139,7 @@
127 # memory tree does not support nested trees yet.139 # memory tree does not support nested trees yet.
128 return kind, None, None, None140 return kind, None, None, None
129 elif kind == 'symlink':141 elif kind == 'symlink':
130 raise NotImplementedError('symlink support')142 return kind, None, None, self._inventory[id].symlink_target
131 else:143 else:
132 raise NotImplementedError('unknown kind')144 raise NotImplementedError('unknown kind')
133145
@@ -148,8 +160,7 @@
148 return self._inventory.get_entry_by_path(path).executable160 return self._inventory.get_entry_by_path(path).executable
149161
150 def kind(self, path):162 def kind(self, path):
151 file_id = self.path2id(path)163 return self._inventory.get_entry_by_path(path).kind
152 return self._inventory[file_id].kind
153164
154 def mkdir(self, path, file_id=None):165 def mkdir(self, path, file_id=None):
155 """See MutableTree.mkdir()."""166 """See MutableTree.mkdir()."""
@@ -227,6 +238,8 @@
227 continue238 continue
228 if entry.kind == 'directory':239 if entry.kind == 'directory':
229 self._file_transport.mkdir(path)240 self._file_transport.mkdir(path)
241 elif entry.kind == 'symlink':
242 self._file_transport.symlink(entry.symlink_target, path)
230 elif entry.kind == 'file':243 elif entry.kind == 'file':
231 self._file_transport.put_file(244 self._file_transport.put_file(
232 path, self._basis_tree.get_file(path))245 path, self._basis_tree.get_file(path))
@@ -302,6 +315,10 @@
302 else:315 else:
303 raise316 raise
304317
318 def get_symlink_target(self, path):
319 with self.lock_read():
320 return self._file_transport.readlink(path)
321
305 def set_parent_trees(self, parents_list, allow_leftmost_as_ghost=False):322 def set_parent_trees(self, parents_list, allow_leftmost_as_ghost=False):
306 """See MutableTree.set_parent_trees()."""323 """See MutableTree.set_parent_trees()."""
307 if len(parents_list) == 0:324 if len(parents_list) == 0:
308325
=== modified file 'breezy/mutabletree.py'
--- breezy/mutabletree.py 2018-11-18 00:25:19 +0000
+++ breezy/mutabletree.py 2019-03-04 01:57:09 +0000
@@ -197,14 +197,32 @@
197 if _from_tree is None:197 if _from_tree is None:
198 _from_tree = self.basis_tree()198 _from_tree = self.basis_tree()
199 changes = self.iter_changes(_from_tree)199 changes = self.iter_changes(_from_tree)
200 try:200 if self.supports_symlinks():
201 change = next(changes)201 # Fast path for has_changes.
202 # Exclude root (talk about black magic... --vila 20090629)202 try:
203 if change[4] == (None, None):
204 change = next(changes)203 change = next(changes)
205 return True204 # Exclude root (talk about black magic... --vila 20090629)
206 except StopIteration:205 if change[4] == (None, None):
207 # No changes206 change = next(changes)
207 return True
208 except StopIteration:
209 # No changes
210 return False
211 else:
212 # Slow path for has_changes.
213 # Handle platforms that do not support symlinks in the
214 # conditional below. This is slower than the try/except
215 # approach below that but we don't have a choice as we
216 # need to be sure that all symlinks are removed from the
217 # entire changeset. This is because in plantforms that
218 # do not support symlinks, they show up as None in the
219 # working copy as compared to the repository.
220 # Also, exclude root as mention in the above fast path.
221 changes = filter(
222 lambda c: c[6][0] != 'symlink' and c[4] != (None, None),
223 changes)
224 if len(changes) > 0:
225 return True
208 return False226 return False
209227
210 def check_changed_or_out_of_date(self, strict, opt_name,228 def check_changed_or_out_of_date(self, strict, opt_name,
211229
=== modified file 'breezy/osutils.py'
--- breezy/osutils.py 2019-03-02 23:49:52 +0000
+++ breezy/osutils.py 2019-03-04 01:57:09 +0000
@@ -1662,8 +1662,42 @@
1662 _terminal_size = _ioctl_terminal_size1662 _terminal_size = _ioctl_terminal_size
16631663
16641664
1665def supports_executable():1665def supports_executable(path):
1666 return sys.platform != "win32"1666 """Return if filesystem at path supports executable bit.
1667
1668 :param path: Path for which to check the file system
1669 :return: boolean indicating whether executable bit can be stored/relied upon
1670 """
1671 if sys.platform == 'win32':
1672 return False
1673 try:
1674 fs_type = get_fs_type(path)
1675 except errors.DependencyNotPresent:
1676 # TODO(jelmer): Warn here?
1677 pass
1678 else:
1679 if fs_type in ('vfat', 'ntfs'):
1680 # filesystems known to not support executable bit
1681 return False
1682 return True
1683
1684
1685def supports_symlinks(path):
1686 """Return if the filesystem at path supports the creation of symbolic links.
1687
1688 """
1689 if not has_symlinks():
1690 return False
1691 try:
1692 fs_type = get_fs_type(path)
1693 except errors.DependencyNotPresent:
1694 # TODO(jelmer): Warn here?
1695 pass
1696 else:
1697 if fs_type in ('vfat', 'ntfs'):
1698 # filesystems known to not support executable bit
1699 return False
1700 return True
16671701
16681702
1669def supports_posix_readonly():1703def supports_posix_readonly():
@@ -2602,6 +2636,25 @@
2602 return False2636 return False
26032637
26042638
2639def get_fs_type(path):
2640 """Return the filesystem type for the partition a path is in.
2641
2642 :param path: Path to search filesystem type for
2643 :return: A FS type, as string. E.g. "ext2"
2644 """
2645 # TODO(jelmer): It would be nice to avoid an extra dependency here, but the only
2646 # alternative is reading platform-specific files under /proc :(
2647 try:
2648 import psutil
2649 except ImportError as e:
2650 raise errors.DependencyNotPresent('psutil', e)
2651 for part in sorted(psutil.disk_partitions(), key=lambda x: len(x.mountpoint), reverse=True):
2652 if is_inside(part.mountpoint, path):
2653 return part.fstype
2654 # Unable to parse the file? Since otherwise at least the entry for / should match..
2655 return None
2656
2657
2605if PY3:2658if PY3:
2606 perf_counter = time.perf_counter2659 perf_counter = time.perf_counter
2607else:2660else:
26082661
=== modified file 'breezy/tests/per_tree/test_symlinks.py'
--- breezy/tests/per_tree/test_symlinks.py 2018-11-11 04:08:32 +0000
+++ breezy/tests/per_tree/test_symlinks.py 2019-03-04 01:57:09 +0000
@@ -18,8 +18,10 @@
1818
1919
20from breezy import (20from breezy import (
21 osutils,
21 tests,22 tests,
22 )23 )
24from breezy.git.branch import GitBranch
23from breezy.tests import (25from breezy.tests import (
24 per_tree,26 per_tree,
25 )27 )
@@ -35,6 +37,13 @@
35 return next(tree.iter_entries_by_dir(specific_files=[path]))[1]37 return next(tree.iter_entries_by_dir(specific_files=[path]))[1]
3638
3739
40class TestSymlinkSupportFunction(per_tree.TestCaseWithTree):
41
42 def test_supports_symlinks(self):
43 self.tree = self.make_branch_and_tree('.')
44 self.assertIn(self.tree.supports_symlinks(), [True, False])
45
46
38class TestTreeWithSymlinks(per_tree.TestCaseWithTree):47class TestTreeWithSymlinks(per_tree.TestCaseWithTree):
3948
40 _test_needs_features = [features.SymlinkFeature]49 _test_needs_features = [features.SymlinkFeature]
@@ -65,3 +74,34 @@
65 entry = get_entry(self.tree, 'symlink')74 entry = get_entry(self.tree, 'symlink')
66 self.assertEqual(entry.kind, 'symlink')75 self.assertEqual(entry.kind, 'symlink')
67 self.assertEqual(None, entry.text_size)76 self.assertEqual(None, entry.text_size)
77
78
79class TestTreeWithoutSymlinks(per_tree.TestCaseWithTree):
80
81 def setUp(self):
82 super(TestTreeWithoutSymlinks, self).setUp()
83 self.branch = self.make_branch('a')
84 mem_tree = self.branch.create_memorytree()
85 with mem_tree.lock_write():
86 mem_tree._file_transport.symlink('source', 'symlink')
87 mem_tree.add(['', 'symlink'])
88 rev1 = mem_tree.commit('rev1')
89 self.assertPathDoesNotExist('a/symlink')
90
91 def test_clone_skips_symlinks(self):
92 if isinstance(self.branch, (GitBranch,)):
93 # TODO(jelmer): Fix this test for git repositories
94 raise TestSkipped(
95 'git trees do not honor osutils.supports_symlinks yet')
96 self.overrideAttr(osutils, 'supports_symlinks', lambda p: False)
97 # This should not attempt to create any symlinks
98 result_dir = self.branch.controldir.sprout('b')
99 result_tree = result_dir.open_workingtree()
100 self.assertFalse(result_tree.supports_symlinks())
101 self.assertPathDoesNotExist('b/symlink')
102 basis_tree = self.branch.basis_tree()
103 self.assertTrue(basis_tree.has_filename('symlink'))
104 with result_tree.lock_read():
105 self.assertEqual(
106 [('symlink', 'symlink')],
107 [c[1] for c in result_tree.iter_changes(basis_tree)])
68108
=== modified file 'breezy/tests/test_commit.py'
--- breezy/tests/test_commit.py 2018-11-29 23:42:41 +0000
+++ breezy/tests/test_commit.py 2019-03-04 01:57:09 +0000
@@ -16,12 +16,14 @@
1616
1717
18import os18import os
19from StringIO import StringIO
1920
20import breezy21import breezy
21from .. import (22from .. import (
22 config,23 config,
23 controldir,24 controldir,
24 errors,25 errors,
26 trace,
25 )27 )
26from ..branch import Branch28from ..branch import Branch
27from ..bzr.bzrdir import BzrDirMetaFormat129from ..bzr.bzrdir import BzrDirMetaFormat1
@@ -676,6 +678,35 @@
676 finally:678 finally:
677 basis.unlock()679 basis.unlock()
678680
681 def test_unsupported_symlink_commit(self):
682 self.requireFeature(SymlinkFeature)
683 tree = self.make_branch_and_tree('.')
684 self.build_tree(['hello'])
685 tree.add('hello')
686 tree.commit('added hello', rev_id='hello_id')
687 os.symlink('hello', 'foo')
688 tree.add('foo')
689 tree.commit('added foo', rev_id='foo_id')
690 log = StringIO()
691 trace.push_log_file(log)
692 os_symlink = getattr(os, 'symlink', None)
693 os.symlink = None
694 try:
695 # At this point as bzr thinks symlinks are not supported
696 # we should get a warning about symlink foo and bzr should
697 # not think its removed.
698 os.unlink('foo')
699 self.build_tree(['world'])
700 tree.add('world')
701 tree.commit('added world', rev_id='world_id')
702 finally:
703 if os_symlink:
704 os.symlink = os_symlink
705 self.assertContainsRe(
706 log.getvalue(),
707 'Ignoring "foo" as symlinks are not '
708 'supported on this filesystem.')
709
679 def test_commit_kind_changes(self):710 def test_commit_kind_changes(self):
680 self.requireFeature(SymlinkFeature)711 self.requireFeature(SymlinkFeature)
681 tree = self.make_branch_and_tree('.')712 tree = self.make_branch_and_tree('.')
682713
=== modified file 'breezy/tests/test_errors.py'
--- breezy/tests/test_errors.py 2018-11-11 04:08:32 +0000
+++ breezy/tests/test_errors.py 2019-03-04 01:57:09 +0000
@@ -388,20 +388,6 @@
388 "you wish to keep, and delete it when you are done.",388 "you wish to keep, and delete it when you are done.",
389 str(err))389 str(err))
390390
391 def test_unable_create_symlink(self):
392 err = errors.UnableCreateSymlink()
393 self.assertEqual(
394 "Unable to create symlink on this platform",
395 str(err))
396 err = errors.UnableCreateSymlink(path=u'foo')
397 self.assertEqual(
398 "Unable to create symlink 'foo' on this platform",
399 str(err))
400 err = errors.UnableCreateSymlink(path=u'\xb5')
401 self.assertEqual(
402 "Unable to create symlink %s on this platform" % repr(u'\xb5'),
403 str(err))
404
405 def test_invalid_url_join(self):391 def test_invalid_url_join(self):
406 """Test the formatting of InvalidURLJoin."""392 """Test the formatting of InvalidURLJoin."""
407 e = urlutils.InvalidURLJoin('Reason', 'base path', ('args',))393 e = urlutils.InvalidURLJoin('Reason', 'base path', ('args',))
408394
=== modified file 'breezy/tests/test_memorytree.py'
--- breezy/tests/test_memorytree.py 2018-11-11 04:08:32 +0000
+++ breezy/tests/test_memorytree.py 2019-03-04 01:57:09 +0000
@@ -46,21 +46,17 @@
46 rev_id = tree.commit('first post')46 rev_id = tree.commit('first post')
47 tree.unlock()47 tree.unlock()
48 tree = MemoryTree.create_on_branch(branch)48 tree = MemoryTree.create_on_branch(branch)
49 tree.lock_read()49 with tree.lock_read():
50 self.assertEqual([rev_id], tree.get_parent_ids())50 self.assertEqual([rev_id], tree.get_parent_ids())
51 with tree.get_file('foo') as f:51 with tree.get_file('foo') as f:
52 self.assertEqual(b'contents of foo\n', f.read())52 self.assertEqual(b'contents of foo\n', f.read())
53 tree.unlock()
5453
55 def test_get_root_id(self):54 def test_get_root_id(self):
56 branch = self.make_branch('branch')55 branch = self.make_branch('branch')
57 tree = MemoryTree.create_on_branch(branch)56 tree = MemoryTree.create_on_branch(branch)
58 tree.lock_write()57 with tree.lock_write():
59 try:
60 tree.add([''])58 tree.add([''])
61 self.assertIsNot(None, tree.get_root_id())59 self.assertIsNot(None, tree.get_root_id())
62 finally:
63 tree.unlock()
6460
65 def test_lock_tree_write(self):61 def test_lock_tree_write(self):
66 """Check we can lock_tree_write and unlock MemoryTrees."""62 """Check we can lock_tree_write and unlock MemoryTrees."""
@@ -73,9 +69,8 @@
73 """Check that we error when trying to upgrade a read lock to write."""69 """Check that we error when trying to upgrade a read lock to write."""
74 branch = self.make_branch('branch')70 branch = self.make_branch('branch')
75 tree = MemoryTree.create_on_branch(branch)71 tree = MemoryTree.create_on_branch(branch)
76 tree.lock_read()72 with tree.lock_read():
77 self.assertRaises(errors.ReadOnlyError, tree.lock_tree_write)73 self.assertRaises(errors.ReadOnlyError, tree.lock_tree_write)
78 tree.unlock()
7974
80 def test_lock_write(self):75 def test_lock_write(self):
81 """Check we can lock_write and unlock MemoryTrees."""76 """Check we can lock_write and unlock MemoryTrees."""
@@ -88,58 +83,63 @@
88 """Check that we error when trying to upgrade a read lock to write."""83 """Check that we error when trying to upgrade a read lock to write."""
89 branch = self.make_branch('branch')84 branch = self.make_branch('branch')
90 tree = MemoryTree.create_on_branch(branch)85 tree = MemoryTree.create_on_branch(branch)
91 tree.lock_read()86 with tree.lock_read():
92 self.assertRaises(errors.ReadOnlyError, tree.lock_write)87 self.assertRaises(errors.ReadOnlyError, tree.lock_write)
93 tree.unlock()
9488
95 def test_add_with_kind(self):89 def test_add_with_kind(self):
96 branch = self.make_branch('branch')90 branch = self.make_branch('branch')
97 tree = MemoryTree.create_on_branch(branch)91 tree = MemoryTree.create_on_branch(branch)
98 tree.lock_write()92 with tree.lock_write():
99 tree.add(['', 'afile', 'adir'], None,93 tree.add(['', 'afile', 'adir'], None,
100 ['directory', 'file', 'directory'])94 ['directory', 'file', 'directory'])
101 self.assertEqual('afile', tree.id2path(tree.path2id('afile')))95 self.assertEqual('afile', tree.id2path(tree.path2id('afile')))
102 self.assertEqual('adir', tree.id2path(tree.path2id('adir')))96 self.assertEqual('adir', tree.id2path(tree.path2id('adir')))
103 self.assertFalse(tree.has_filename('afile'))97 self.assertFalse(tree.has_filename('afile'))
104 self.assertFalse(tree.has_filename('adir'))98 self.assertFalse(tree.has_filename('adir'))
105 tree.unlock()
10699
107 def test_put_new_file(self):100 def test_put_new_file(self):
108 branch = self.make_branch('branch')101 branch = self.make_branch('branch')
109 tree = MemoryTree.create_on_branch(branch)102 tree = MemoryTree.create_on_branch(branch)
110 tree.lock_write()103 with tree.lock_write():
111 tree.add(['', 'foo'], ids=[b'root-id', b'foo-id'],104 tree.add(['', 'foo'], ids=[b'root-id', b'foo-id'],
112 kinds=['directory', 'file'])105 kinds=['directory', 'file'])
113 tree.put_file_bytes_non_atomic('foo', b'barshoom')106 tree.put_file_bytes_non_atomic('foo', b'barshoom')
114 self.assertEqual(b'barshoom', tree.get_file('foo').read())107 with tree.get_file('foo') as f:
115 tree.unlock()108 self.assertEqual(b'barshoom', f.read())
116109
117 def test_put_existing_file(self):110 def test_put_existing_file(self):
118 branch = self.make_branch('branch')111 branch = self.make_branch('branch')
119 tree = MemoryTree.create_on_branch(branch)112 tree = MemoryTree.create_on_branch(branch)
120 tree.lock_write()113 with tree.lock_write():
121 tree.add(['', 'foo'], ids=[b'root-id', b'foo-id'],114 tree.add(['', 'foo'], ids=[b'root-id', b'foo-id'],
122 kinds=['directory', 'file'])115 kinds=['directory', 'file'])
123 tree.put_file_bytes_non_atomic('foo', b'first-content')116 tree.put_file_bytes_non_atomic('foo', b'first-content')
124 tree.put_file_bytes_non_atomic('foo', b'barshoom')117 tree.put_file_bytes_non_atomic('foo', b'barshoom')
125 self.assertEqual(b'barshoom', tree.get_file('foo').read())118 self.assertEqual(b'barshoom', tree.get_file('foo').read())
126 tree.unlock()
127119
128 def test_add_in_subdir(self):120 def test_add_in_subdir(self):
129 branch = self.make_branch('branch')121 branch = self.make_branch('branch')
130 tree = MemoryTree.create_on_branch(branch)122 tree = MemoryTree.create_on_branch(branch)
131 tree.lock_write()123 with tree.lock_write():
132 self.addCleanup(tree.unlock)124 tree.add([''], [b'root-id'], ['directory'])
133 tree.add([''], [b'root-id'], ['directory'])125 # Unfortunately, the only way to 'mkdir' is to call 'tree.mkdir', but
134 # Unfortunately, the only way to 'mkdir' is to call 'tree.mkdir', but126 # that *always* adds the directory as well. So if you want to create a
135 # that *always* adds the directory as well. So if you want to create a127 # file in a subdirectory, you have to split out the 'mkdir()' calls
136 # file in a subdirectory, you have to split out the 'mkdir()' calls128 # from the add and put_file_bytes_non_atomic calls. :(
137 # from the add and put_file_bytes_non_atomic calls. :(129 tree.mkdir('adir', b'dir-id')
138 tree.mkdir('adir', b'dir-id')130 tree.add(['adir/afile'], [b'file-id'], ['file'])
139 tree.add(['adir/afile'], [b'file-id'], ['file'])131 self.assertEqual('adir/afile', tree.id2path(b'file-id'))
140 self.assertEqual('adir/afile', tree.id2path(b'file-id'))132 self.assertEqual('adir', tree.id2path(b'dir-id'))
141 self.assertEqual('adir', tree.id2path(b'dir-id'))133 tree.put_file_bytes_non_atomic('adir/afile', b'barshoom')
142 tree.put_file_bytes_non_atomic('adir/afile', b'barshoom')134
135 def test_add_symlink(self):
136 branch = self.make_branch('branch')
137 tree = MemoryTree.create_on_branch(branch)
138 with tree.lock_write():
139 tree._file_transport.symlink('bar', 'foo')
140 tree.add(['', 'foo'])
141 self.assertEqual('symlink', tree.kind('foo'))
142 self.assertEqual('bar', tree.get_symlink_target('foo'))
143143
144 def test_commit_trivial(self):144 def test_commit_trivial(self):
145 """Smoke test for commit on a MemoryTree.145 """Smoke test for commit on a MemoryTree.
@@ -149,40 +149,35 @@
149 """149 """
150 branch = self.make_branch('branch')150 branch = self.make_branch('branch')
151 tree = MemoryTree.create_on_branch(branch)151 tree = MemoryTree.create_on_branch(branch)
152 tree.lock_write()152 with tree.lock_write():
153 tree.add(['', 'foo'], ids=[b'root-id', b'foo-id'],153 tree.add(['', 'foo'], ids=[b'root-id', b'foo-id'],
154 kinds=['directory', 'file'])154 kinds=['directory', 'file'])
155 tree.put_file_bytes_non_atomic('foo', b'barshoom')155 tree.put_file_bytes_non_atomic('foo', b'barshoom')
156 revision_id = tree.commit('message baby')156 revision_id = tree.commit('message baby')
157 # the parents list for the tree should have changed.157 # the parents list for the tree should have changed.
158 self.assertEqual([revision_id], tree.get_parent_ids())158 self.assertEqual([revision_id], tree.get_parent_ids())
159 tree.unlock()
160 # and we should have a revision that is accessible outside the tree lock159 # and we should have a revision that is accessible outside the tree lock
161 revtree = tree.branch.repository.revision_tree(revision_id)160 revtree = tree.branch.repository.revision_tree(revision_id)
162 revtree.lock_read()161 with revtree.lock_read(), revtree.get_file('foo') as f:
163 self.addCleanup(revtree.unlock)
164 with revtree.get_file('foo') as f:
165 self.assertEqual(b'barshoom', f.read())162 self.assertEqual(b'barshoom', f.read())
166163
167 def test_unversion(self):164 def test_unversion(self):
168 """Some test for unversion of a memory tree."""165 """Some test for unversion of a memory tree."""
169 branch = self.make_branch('branch')166 branch = self.make_branch('branch')
170 tree = MemoryTree.create_on_branch(branch)167 tree = MemoryTree.create_on_branch(branch)
171 tree.lock_write()168 with tree.lock_write():
172 tree.add(['', 'foo'], ids=[b'root-id', b'foo-id'],169 tree.add(['', 'foo'], ids=[b'root-id', b'foo-id'],
173 kinds=['directory', 'file'])170 kinds=['directory', 'file'])
174 tree.unversion(['foo'])171 tree.unversion(['foo'])
175 self.assertFalse(tree.is_versioned('foo'))172 self.assertFalse(tree.is_versioned('foo'))
176 self.assertFalse(tree.has_id(b'foo-id'))173 self.assertFalse(tree.has_id(b'foo-id'))
177 tree.unlock()
178174
179 def test_last_revision(self):175 def test_last_revision(self):
180 """There should be a last revision method we can call."""176 """There should be a last revision method we can call."""
181 tree = self.make_branch_and_memory_tree('branch')177 tree = self.make_branch_and_memory_tree('branch')
182 tree.lock_write()178 with tree.lock_write():
183 tree.add('')179 tree.add('')
184 rev_id = tree.commit('first post')180 rev_id = tree.commit('first post')
185 tree.unlock()
186 self.assertEqual(rev_id, tree.last_revision())181 self.assertEqual(rev_id, tree.last_revision())
187182
188 def test_rename_file(self):183 def test_rename_file(self):
189184
=== modified file 'breezy/tests/test_osutils.py'
--- breezy/tests/test_osutils.py 2018-11-17 18:49:41 +0000
+++ breezy/tests/test_osutils.py 2019-03-04 01:57:09 +0000
@@ -62,6 +62,8 @@
6262
63term_ios_feature = features.ModuleAvailableFeature('termios')63term_ios_feature = features.ModuleAvailableFeature('termios')
6464
65psutil_feature = features.ModuleAvailableFeature('psutil')
66
6567
66def _already_unicode(s):68def _already_unicode(s):
67 return s69 return s
@@ -2340,3 +2342,22 @@
2340 import pywintypes2342 import pywintypes
2341 self.assertTrue(osutils.is_environment_error(2343 self.assertTrue(osutils.is_environment_error(
2342 pywintypes.error(errno.EINVAL, "Invalid parameter", "Caller")))2344 pywintypes.error(errno.EINVAL, "Invalid parameter", "Caller")))
2345
2346
2347class SupportsExecutableTests(tests.TestCaseInTempDir):
2348
2349 def test_returns_bool(self):
2350 self.assertIsInstance(osutils.supports_executable(self.test_dir), bool)
2351
2352
2353class SupportsSymlinksTests(tests.TestCaseInTempDir):
2354
2355 def test_returns_bool(self):
2356 self.assertIsInstance(osutils.supports_symlinks(self.test_dir), bool)
2357
2358
2359class GetFsTypeTests(tests.TestCaseInTempDir):
2360
2361 def test_returns_string(self):
2362 self.requireFeature(psutil_feature)
2363 self.assertIsInstance(osutils.get_fs_type(self.test_dir), str)
23432364
=== modified file 'breezy/tests/test_transform.py'
--- breezy/tests/test_transform.py 2019-02-04 19:39:30 +0000
+++ breezy/tests/test_transform.py 2019-03-04 01:57:09 +0000
@@ -16,6 +16,7 @@
1616
17import codecs17import codecs
18import errno18import errno
19from io import BytesIO, StringIO
19import os20import os
20import sys21import sys
21import time22import time
@@ -806,22 +807,18 @@
806 u'\N{Euro Sign}wizard2',807 u'\N{Euro Sign}wizard2',
807 u'b\N{Euro Sign}hind_curtain')808 u'b\N{Euro Sign}hind_curtain')
808809
809 def test_unable_create_symlink(self):810 def test_unsupported_symlink_no_conflict(self):
810 def tt_helper():811 def tt_helper():
811 wt = self.make_branch_and_tree('.')812 wt = self.make_branch_and_tree('.')
812 tt = TreeTransform(wt) # TreeTransform obtains write lock813 tt = TreeTransform(wt)
813 try:814 self.addCleanup(tt.finalize)
814 tt.new_symlink('foo', tt.root, 'bar')815 tt.new_symlink('foo', tt.root, 'bar')
815 tt.apply()816 result = tt.find_conflicts()
816 finally:817 self.assertEqual([], result)
817 wt.unlock()
818 os_symlink = getattr(os, 'symlink', None)818 os_symlink = getattr(os, 'symlink', None)
819 os.symlink = None819 os.symlink = None
820 try:820 try:
821 err = self.assertRaises(errors.UnableCreateSymlink, tt_helper)821 tt_helper()
822 self.assertEqual(
823 "Unable to create symlink 'foo' on this platform",
824 str(err))
825 finally:822 finally:
826 if os_symlink:823 if os_symlink:
827 os.symlink = os_symlink824 os.symlink = os_symlink
@@ -1598,6 +1595,24 @@
1598 self.addCleanup(wt.unlock)1595 self.addCleanup(wt.unlock)
1599 self.assertEqual(wt.kind("foo"), "symlink")1596 self.assertEqual(wt.kind("foo"), "symlink")
16001597
1598 def test_file_to_symlink_unsupported(self):
1599 wt = self.make_branch_and_tree('.')
1600 self.build_tree(['foo'])
1601 wt.add(['foo'])
1602 wt.commit("one")
1603 self.overrideAttr(osutils, 'supports_symlinks', lambda p: False)
1604 tt = TreeTransform(wt)
1605 self.addCleanup(tt.finalize)
1606 foo_trans_id = tt.trans_id_tree_path("foo")
1607 tt.delete_contents(foo_trans_id)
1608 log = BytesIO()
1609 trace.push_log_file(log)
1610 tt.create_symlink("bar", foo_trans_id)
1611 tt.apply()
1612 self.assertContainsRe(
1613 log.getvalue(),
1614 'Unable to create symlink "foo" on this filesystem')
1615
1601 def test_dir_to_file(self):1616 def test_dir_to_file(self):
1602 wt = self.make_branch_and_tree('.')1617 wt = self.make_branch_and_tree('.')
1603 self.build_tree(['foo/', 'foo/bar'])1618 self.build_tree(['foo/', 'foo/bar'])
@@ -2809,6 +2824,35 @@
2809 # 3 lines of diff administrivia2824 # 3 lines of diff administrivia
2810 self.assertEqual(lines[4], b"+content B")2825 self.assertEqual(lines[4], b"+content B")
28112826
2827 def test_unsupported_symlink_diff(self):
2828 self.requireFeature(SymlinkFeature)
2829 tree = self.make_branch_and_tree('.')
2830 self.build_tree_contents([('a', 'content 1')])
2831 tree.set_root_id('TREE_ROOT')
2832 tree.add('a', 'a-id')
2833 os.symlink('a', 'foo')
2834 tree.add('foo', 'foo-id')
2835 tree.commit('rev1', rev_id='rev1')
2836 revision_tree = tree.branch.repository.revision_tree('rev1')
2837 preview = TransformPreview(revision_tree)
2838 self.addCleanup(preview.finalize)
2839 preview.delete_versioned(preview.trans_id_tree_path('foo'))
2840 preview_tree = preview.get_preview_tree()
2841 out = StringIO()
2842 log = BytesIO()
2843 trace.push_log_file(log)
2844 os_symlink = getattr(os, 'symlink', None)
2845 os.symlink = None
2846 try:
2847 show_diff_trees(revision_tree, preview_tree, out)
2848 lines = out.getvalue().splitlines()
2849 finally:
2850 if os_symlink:
2851 os.symlink = os_symlink
2852 self.assertContainsRe(
2853 log.getvalue(),
2854 'Ignoring "foo" as symlinks are not supported on this filesystem')
2855
2812 def test_transform_conflicts(self):2856 def test_transform_conflicts(self):
2813 revision_tree = self.create_tree()2857 revision_tree = self.create_tree()
2814 preview = TransformPreview(revision_tree)2858 preview = TransformPreview(revision_tree)
28152859
=== modified file 'breezy/tests/test_workingtree.py'
--- breezy/tests/test_workingtree.py 2018-11-12 01:41:38 +0000
+++ breezy/tests/test_workingtree.py 2019-03-04 01:57:09 +0000
@@ -15,9 +15,13 @@
15# along with this program; if not, write to the Free Software15# along with this program; if not, write to the Free Software
16# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA16# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
1717
18from io import BytesIO
19import os
20
18from .. import (21from .. import (
19 conflicts,22 conflicts,
20 errors,23 errors,
24 trace,
21 transport,25 transport,
22 workingtree,26 workingtree,
23 )27 )
@@ -37,6 +41,7 @@
37 TreeLink,41 TreeLink,
38 )42 )
3943
44from .features import SymlinkFeature
4045
41class TestTreeDirectory(TestCaseWithTransport):46class TestTreeDirectory(TestCaseWithTransport):
4247
@@ -443,6 +448,34 @@
443 resolved)448 resolved)
444 self.assertPathDoesNotExist('this/hello.BASE')449 self.assertPathDoesNotExist('this/hello.BASE')
445450
451 def test_unsupported_symlink_auto_resolve(self):
452 self.requireFeature(SymlinkFeature)
453 base = self.make_branch_and_tree('base')
454 self.build_tree_contents([('base/hello', 'Hello')])
455 base.add('hello', 'hello_id')
456 base.commit('commit 0')
457 other = base.controldir.sprout('other').open_workingtree()
458 self.build_tree_contents([('other/hello', 'Hello')])
459 os.symlink('other/hello', 'other/foo')
460 other.add('foo', 'foo_id')
461 other.commit('commit symlink')
462 this = base.controldir.sprout('this').open_workingtree()
463 self.assertPathExists('this/hello')
464 self.build_tree_contents([('this/hello', 'Hello')])
465 this.commit('commit 2')
466 log = BytesIO()
467 trace.push_log_file(log)
468 os_symlink = getattr(os, 'symlink', None)
469 os.symlink = None
470 try:
471 this.merge_from_branch(other.branch)
472 finally:
473 if os_symlink:
474 os.symlink = os_symlink
475 self.assertContainsRe(
476 log.getvalue(),
477 b'Unable to create symlink "foo" on this filesystem')
478
446 def test_auto_resolve_dir(self):479 def test_auto_resolve_dir(self):
447 tree = self.make_branch_and_tree('tree')480 tree = self.make_branch_and_tree('tree')
448 self.build_tree(['tree/hello/'])481 self.build_tree(['tree/hello/'])
449482
=== modified file 'breezy/transform.py'
--- breezy/transform.py 2019-02-02 15:13:30 +0000
+++ breezy/transform.py 2019-03-04 01:57:09 +0000
@@ -52,17 +52,16 @@
52""")52""")
53from .errors import (DuplicateKey, MalformedTransform,53from .errors import (DuplicateKey, MalformedTransform,
54 ReusingTransform, CantMoveRoot,54 ReusingTransform, CantMoveRoot,
55 ImmortalLimbo, NoFinalPath,55 ImmortalLimbo, NoFinalPath)
56 UnableCreateSymlink)
57from .filters import filtered_output_bytes, ContentFilterContext56from .filters import filtered_output_bytes, ContentFilterContext
58from .mutabletree import MutableTree57from .mutabletree import MutableTree
59from .osutils import (58from .osutils import (
60 delete_any,59 delete_any,
61 file_kind,60 file_kind,
62 has_symlinks,
63 pathjoin,61 pathjoin,
64 sha_file,62 sha_file,
65 splitpath,63 splitpath,
64 supports_symlinks,
66 )65 )
67from .progress import ProgressPhase66from .progress import ProgressPhase
68from .sixish import (67from .sixish import (
@@ -653,6 +652,9 @@
653 conflicts = []652 conflicts = []
654 for trans_id in self._new_id:653 for trans_id in self._new_id:
655 kind = self.final_kind(trans_id)654 kind = self.final_kind(trans_id)
655 if kind == 'symlink' and not self._tree.supports_symlinks():
656 # Ignore symlinks as they are not supported on this platform
657 continue
656 if kind is None:658 if kind is None:
657 conflicts.append(('versioning no contents', trans_id))659 conflicts.append(('versioning no contents', trans_id))
658 continue660 continue
@@ -1201,8 +1203,7 @@
1201class DiskTreeTransform(TreeTransformBase):1203class DiskTreeTransform(TreeTransformBase):
1202 """Tree transform storing its contents on disk."""1204 """Tree transform storing its contents on disk."""
12031205
1204 def __init__(self, tree, limbodir, pb=None,1206 def __init__(self, tree, limbodir, pb=None, case_sensitive=True):
1205 case_sensitive=True):
1206 """Constructor.1207 """Constructor.
1207 :param tree: The tree that will be transformed, but not necessarily1208 :param tree: The tree that will be transformed, but not necessarily
1208 the output tree.1209 the output tree.
@@ -1226,6 +1227,7 @@
1226 # List of transform ids that need to be renamed from limbo into place1227 # List of transform ids that need to be renamed from limbo into place
1227 self._needs_rename = set()1228 self._needs_rename = set()
1228 self._creation_mtime = None1229 self._creation_mtime = None
1230 self._create_symlinks = osutils.supports_symlinks(self._limbodir)
12291231
1230 def finalize(self):1232 def finalize(self):
1231 """Release the working tree lock, if held, clean up limbo dir.1233 """Release the working tree lock, if held, clean up limbo dir.
@@ -1263,8 +1265,7 @@
12631265
1264 def _limbo_supports_executable(self):1266 def _limbo_supports_executable(self):
1265 """Check if the limbo path supports the executable bit."""1267 """Check if the limbo path supports the executable bit."""
1266 # FIXME: Check actual file system capabilities of limbodir1268 return osutils.supports_executable(self._limbodir)
1267 return osutils.supports_executable()
12681269
1269 def _limbo_name(self, trans_id):1270 def _limbo_name(self, trans_id):
1270 """Generate the limbo name of a file"""1271 """Generate the limbo name of a file"""
@@ -1396,15 +1397,19 @@
1396 target is a bytestring.1397 target is a bytestring.
1397 See also new_symlink.1398 See also new_symlink.
1398 """1399 """
1399 if has_symlinks():1400 if self._create_symlinks:
1400 os.symlink(target, self._limbo_name(trans_id))1401 os.symlink(target, self._limbo_name(trans_id))
1401 unique_add(self._new_contents, trans_id, 'symlink')
1402 else:1402 else:
1403 try:1403 try:
1404 path = FinalPaths(self).get_path(trans_id)1404 path = FinalPaths(self).get_path(trans_id)
1405 except KeyError:1405 except KeyError:
1406 path = None1406 path = None
1407 raise UnableCreateSymlink(path=path)1407 trace.warning(
1408 'Unable to create symlink "%s" on this filesystem.' % (path,))
1409 # We add symlink to _new_contents even if they are unsupported
1410 # and not created. These entries are subsequently used to avoid
1411 # conflicts on platforms that don't support symlink
1412 unique_add(self._new_contents, trans_id, 'symlink')
14081413
1409 def cancel_creation(self, trans_id):1414 def cancel_creation(self, trans_id):
1410 """Cancel the creation of new file contents."""1415 """Cancel the creation of new file contents."""
14111416
=== modified file 'breezy/transport/local.py'
--- breezy/transport/local.py 2018-11-11 04:08:32 +0000
+++ breezy/transport/local.py 2019-03-04 01:57:09 +0000
@@ -532,7 +532,7 @@
532 except (IOError, OSError) as e:532 except (IOError, OSError) as e:
533 self._translate_error(e, source)533 self._translate_error(e, source)
534534
535 if osutils.has_symlinks():535 if getattr(os, 'symlink', None) is not None:
536 def symlink(self, source, link_name):536 def symlink(self, source, link_name):
537 """See Transport.symlink."""537 """See Transport.symlink."""
538 abs_link_dirpath = urlutils.dirname(self.abspath(link_name))538 abs_link_dirpath = urlutils.dirname(self.abspath(link_name))
539539
=== modified file 'breezy/transport/memory.py'
--- breezy/transport/memory.py 2018-11-17 16:53:10 +0000
+++ breezy/transport/memory.py 2019-03-04 01:57:09 +0000
@@ -26,9 +26,10 @@
26from io import (26from io import (
27 BytesIO,27 BytesIO,
28 )28 )
29import itertools
29import os30import os
30import errno31import errno
31from stat import S_IFREG, S_IFDIR, S_IFLNK32from stat import S_IFREG, S_IFDIR, S_IFLNK, S_ISDIR
3233
33from .. import (34from .. import (
34 transport,35 transport,
@@ -50,20 +51,16 @@
5051
51class MemoryStat(object):52class MemoryStat(object):
5253
53 def __init__(self, size, kind, perms):54 def __init__(self, size, kind, perms=None):
54 self.st_size = size55 self.st_size = size
55 if kind == 'file':56 if not S_ISDIR(kind):
56 if perms is None:57 if perms is None:
57 perms = 0o64458 perms = 0o644
58 self.st_mode = S_IFREG | perms59 self.st_mode = kind | perms
59 elif kind == 'directory':60 else:
60 if perms is None:61 if perms is None:
61 perms = 0o75562 perms = 0o755
62 self.st_mode = S_IFDIR | perms63 self.st_mode = kind | perms
63 elif kind == 'symlink':
64 self.st_mode = S_IFLNK | 0o644
65 else:
66 raise AssertionError('unknown kind %r' % kind)
6764
6865
69class MemoryTransport(transport.Transport):66class MemoryTransport(transport.Transport):
@@ -80,7 +77,7 @@
80 self._scheme = url[:split]77 self._scheme = url[:split]
81 self._cwd = url[split:]78 self._cwd = url[split:]
82 # dictionaries from absolute path to file mode79 # dictionaries from absolute path to file mode
83 self._dirs = {'/': None}80 self._dirs = {'/':None}
84 self._symlinks = {}81 self._symlinks = {}
85 self._files = {}82 self._files = {}
86 self._locks = {}83 self._locks = {}
@@ -111,7 +108,7 @@
111108
112 def append_file(self, relpath, f, mode=None):109 def append_file(self, relpath, f, mode=None):
113 """See Transport.append_file()."""110 """See Transport.append_file()."""
114 _abspath = self._abspath(relpath)111 _abspath = self._resolve_symlinks(relpath)
115 self._check_parent(_abspath)112 self._check_parent(_abspath)
116 orig_content, orig_mode = self._files.get(_abspath, (b"", None))113 orig_content, orig_mode = self._files.get(_abspath, (b"", None))
117 if mode is None:114 if mode is None:
@@ -128,16 +125,20 @@
128 def has(self, relpath):125 def has(self, relpath):
129 """See Transport.has()."""126 """See Transport.has()."""
130 _abspath = self._abspath(relpath)127 _abspath = self._abspath(relpath)
131 return ((_abspath in self._files)128 for container in (self._files, self._dirs, self._symlinks):
132 or (_abspath in self._dirs)129 if _abspath in container.keys():
133 or (_abspath in self._symlinks))130 return True
131 return False
134132
135 def delete(self, relpath):133 def delete(self, relpath):
136 """See Transport.delete()."""134 """See Transport.delete()."""
137 _abspath = self._abspath(relpath)135 _abspath = self._abspath(relpath)
138 if _abspath not in self._files:136 if _abspath in self._files:
137 del self._files[_abspath]
138 elif _abspath in self._symlinks:
139 del self._symlinks[_abspath]
140 else:
139 raise NoSuchFile(relpath)141 raise NoSuchFile(relpath)
140 del self._files[_abspath]
141142
142 def external_url(self):143 def external_url(self):
143 """See breezy.transport.Transport.external_url."""144 """See breezy.transport.Transport.external_url."""
@@ -147,8 +148,8 @@
147148
148 def get(self, relpath):149 def get(self, relpath):
149 """See Transport.get()."""150 """See Transport.get()."""
150 _abspath = self._abspath(relpath)151 _abspath = self._resolve_symlinks(relpath)
151 if _abspath not in self._files:152 if not _abspath in self._files:
152 if _abspath in self._dirs:153 if _abspath in self._dirs:
153 return LateReadError(relpath)154 return LateReadError(relpath)
154 else:155 else:
@@ -157,15 +158,20 @@
157158
158 def put_file(self, relpath, f, mode=None):159 def put_file(self, relpath, f, mode=None):
159 """See Transport.put_file()."""160 """See Transport.put_file()."""
160 _abspath = self._abspath(relpath)161 _abspath = self._resolve_symlinks(relpath)
161 self._check_parent(_abspath)162 self._check_parent(_abspath)
162 raw_bytes = f.read()163 raw_bytes = f.read()
163 self._files[_abspath] = (raw_bytes, mode)164 self._files[_abspath] = (raw_bytes, mode)
164 return len(raw_bytes)165 return len(raw_bytes)
165166
167 def symlink(self, source, target):
168 _abspath = self._resolve_symlinks(target)
169 self._check_parent(_abspath)
170 self._symlinks[_abspath] = self._abspath(source)
171
166 def mkdir(self, relpath, mode=None):172 def mkdir(self, relpath, mode=None):
167 """See Transport.mkdir()."""173 """See Transport.mkdir()."""
168 _abspath = self._abspath(relpath)174 _abspath = self._resolve_symlinks(relpath)
169 self._check_parent(_abspath)175 self._check_parent(_abspath)
170 if _abspath in self._dirs:176 if _abspath in self._dirs:
171 raise FileExists(relpath)177 raise FileExists(relpath)
@@ -183,13 +189,13 @@
183 return True189 return True
184190
185 def iter_files_recursive(self):191 def iter_files_recursive(self):
186 for file in self._files:192 for file in itertools.chain(self._files, self._symlinks):
187 if file.startswith(self._cwd):193 if file.startswith(self._cwd):
188 yield urlutils.escape(file[len(self._cwd):])194 yield urlutils.escape(file[len(self._cwd):])
189195
190 def list_dir(self, relpath):196 def list_dir(self, relpath):
191 """See Transport.list_dir()."""197 """See Transport.list_dir()."""
192 _abspath = self._abspath(relpath)198 _abspath = self._resolve_symlinks(relpath)
193 if _abspath != '/' and _abspath not in self._dirs:199 if _abspath != '/' and _abspath not in self._dirs:
194 raise NoSuchFile(relpath)200 raise NoSuchFile(relpath)
195 result = []201 result = []
@@ -197,7 +203,7 @@
197 if not _abspath.endswith('/'):203 if not _abspath.endswith('/'):
198 _abspath += '/'204 _abspath += '/'
199205
200 for path_group in self._files, self._dirs:206 for path_group in self._files, self._dirs, self._symlinks:
201 for path in path_group:207 for path in path_group:
202 if path.startswith(_abspath):208 if path.startswith(_abspath):
203 trailing = path[len(_abspath):]209 trailing = path[len(_abspath):]
@@ -207,8 +213,8 @@
207213
208 def rename(self, rel_from, rel_to):214 def rename(self, rel_from, rel_to):
209 """Rename a file or directory; fail if the destination exists"""215 """Rename a file or directory; fail if the destination exists"""
210 abs_from = self._abspath(rel_from)216 abs_from = self._resolve_symlinks(rel_from)
211 abs_to = self._abspath(rel_to)217 abs_to = self._resolve_symlinks(rel_to)
212218
213 def replace(x):219 def replace(x):
214 if x == abs_from:220 if x == abs_from:
@@ -233,21 +239,25 @@
233 # fail differently depending on dict order. So work on copy, fail on239 # fail differently depending on dict order. So work on copy, fail on
234 # error on only replace dicts if all goes well.240 # error on only replace dicts if all goes well.
235 renamed_files = self._files.copy()241 renamed_files = self._files.copy()
242 renamed_symlinks = self._symlinks.copy()
236 renamed_dirs = self._dirs.copy()243 renamed_dirs = self._dirs.copy()
237 do_renames(renamed_files)244 do_renames(renamed_files)
245 do_renames(renamed_symlinks)
238 do_renames(renamed_dirs)246 do_renames(renamed_dirs)
239 # We may have been cloned so modify in place247 # We may have been cloned so modify in place
240 self._files.clear()248 self._files.clear()
241 self._files.update(renamed_files)249 self._files.update(renamed_files)
250 self._symlinks.clear()
251 self._symlinks.update(renamed_symlinks)
242 self._dirs.clear()252 self._dirs.clear()
243 self._dirs.update(renamed_dirs)253 self._dirs.update(renamed_dirs)
244254
245 def rmdir(self, relpath):255 def rmdir(self, relpath):
246 """See Transport.rmdir."""256 """See Transport.rmdir."""
247 _abspath = self._abspath(relpath)257 _abspath = self._resolve_symlinks(relpath)
248 if _abspath in self._files:258 if _abspath in self._files:
249 self._translate_error(IOError(errno.ENOTDIR, relpath), relpath)259 self._translate_error(IOError(errno.ENOTDIR, relpath), relpath)
250 for path in self._files:260 for path in itertools.chain(self._files, self._symlinks):
251 if path.startswith(_abspath + '/'):261 if path.startswith(_abspath + '/'):
252 self._translate_error(IOError(errno.ENOTEMPTY, relpath),262 self._translate_error(IOError(errno.ENOTEMPTY, relpath),
253 relpath)263 relpath)
@@ -262,13 +272,13 @@
262 def stat(self, relpath):272 def stat(self, relpath):
263 """See Transport.stat()."""273 """See Transport.stat()."""
264 _abspath = self._abspath(relpath)274 _abspath = self._abspath(relpath)
265 if _abspath in self._files:275 if _abspath in self._files.keys():
266 return MemoryStat(len(self._files[_abspath][0]), 'file',276 return MemoryStat(len(self._files[_abspath][0]), S_IFREG,
267 self._files[_abspath][1])277 self._files[_abspath][1])
268 elif _abspath in self._dirs:278 elif _abspath in self._dirs.keys():
269 return MemoryStat(0, 'directory', self._dirs[_abspath])279 return MemoryStat(0, S_IFDIR, self._dirs[_abspath])
270 elif _abspath in self._symlinks:280 elif _abspath in self._symlinks.keys():
271 return MemoryStat(0, 'symlink', 0)281 return MemoryStat(0, S_IFLNK)
272 else:282 else:
273 raise NoSuchFile(_abspath)283 raise NoSuchFile(_abspath)
274284
@@ -280,6 +290,12 @@
280 """See Transport.lock_write()."""290 """See Transport.lock_write()."""
281 return _MemoryLock(self._abspath(relpath), self)291 return _MemoryLock(self._abspath(relpath), self)
282292
293 def _resolve_symlinks(self, relpath):
294 path = self._abspath(relpath)
295 while path in self._symlinks.keys():
296 path = self._symlinks[path]
297 return path
298
283 def _abspath(self, relpath):299 def _abspath(self, relpath):
284 """Generate an internal absolute path."""300 """Generate an internal absolute path."""
285 relpath = urlutils.unescape(relpath)301 relpath = urlutils.unescape(relpath)
@@ -336,6 +352,7 @@
336 def start_server(self):352 def start_server(self):
337 self._dirs = {'/': None}353 self._dirs = {'/': None}
338 self._files = {}354 self._files = {}
355 self._symlinks = {}
339 self._locks = {}356 self._locks = {}
340 self._scheme = "memory+%s:///" % id(self)357 self._scheme = "memory+%s:///" % id(self)
341358
@@ -344,6 +361,7 @@
344 result = memory.MemoryTransport(url)361 result = memory.MemoryTransport(url)
345 result._dirs = self._dirs362 result._dirs = self._dirs
346 result._files = self._files363 result._files = self._files
364 result._symlinks = self._symlinks
347 result._locks = self._locks365 result._locks = self._locks
348 return result366 return result
349 self._memory_factory = memory_factory367 self._memory_factory = memory_factory
350368
=== modified file 'breezy/tree.py'
--- breezy/tree.py 2019-02-02 15:13:30 +0000
+++ breezy/tree.py 2019-03-04 01:57:09 +0000
@@ -141,6 +141,11 @@
141 """141 """
142 return True142 return True
143143
144 def supports_symlinks(self):
145 """Does this tree support symbolic links?
146 """
147 return osutils.has_symlinks()
148
144 def changes_from(self, other, want_unchanged=False, specific_files=None,149 def changes_from(self, other, want_unchanged=False, specific_files=None,
145 extra_trees=None, require_versioned=False, include_root=False,150 extra_trees=None, require_versioned=False, include_root=False,
146 want_unversioned=False):151 want_unversioned=False):
@@ -181,7 +186,8 @@
181 """See InterTree.iter_changes"""186 """See InterTree.iter_changes"""
182 intertree = InterTree.get(from_tree, self)187 intertree = InterTree.get(from_tree, self)
183 return intertree.iter_changes(include_unchanged, specific_files, pb,188 return intertree.iter_changes(include_unchanged, specific_files, pb,
184 extra_trees, require_versioned, want_unversioned=want_unversioned)189 extra_trees, require_versioned,
190 want_unversioned=want_unversioned)
185191
186 def conflicts(self):192 def conflicts(self):
187 """Get a list of the conflicts in the tree.193 """Get a list of the conflicts in the tree.
188194
=== modified file 'breezy/workingtree.py'
--- breezy/workingtree.py 2019-02-14 22:18:59 +0000
+++ breezy/workingtree.py 2019-03-04 01:57:09 +0000
@@ -65,9 +65,6 @@
65from .trace import mutter, note65from .trace import mutter, note
6666
6767
68ERROR_PATH_NOT_FOUND = 3 # WindowsError errno code, equivalent to ENOENT
69
70
71class SettingFileIdUnsupported(errors.BzrError):68class SettingFileIdUnsupported(errors.BzrError):
7269
73 _fmt = "This format does not support setting file ids."70 _fmt = "This format does not support setting file ids."
@@ -123,6 +120,9 @@
123 def control_transport(self):120 def control_transport(self):
124 return self._transport121 return self._transport
125122
123 def supports_symlinks(self):
124 return osutils.supports_symlinks(self.basedir)
125
126 def is_control_filename(self, filename):126 def is_control_filename(self, filename):
127 """True if filename is the name of a control file in this tree.127 """True if filename is the name of a control file in this tree.
128128
@@ -153,10 +153,7 @@
153 return self._format.supports_merge_modified153 return self._format.supports_merge_modified
154154
155 def _supports_executable(self):155 def _supports_executable(self):
156 if sys.platform == 'win32':156 return osutils.supports_executable(self.basedir)
157 return False
158 # FIXME: Ideally this should check the file system
159 return True
160157
161 def break_lock(self):158 def break_lock(self):
162 """Break a lock if one is present from another instance.159 """Break a lock if one is present from another instance.
163160
=== modified file 'doc/en/release-notes/brz-3.0.txt'
--- doc/en/release-notes/brz-3.0.txt 2019-03-02 22:31:28 +0000
+++ doc/en/release-notes/brz-3.0.txt 2019-03-04 01:57:09 +0000
@@ -158,6 +158,11 @@
158 ``RevisionTree.annotate_iter`` have been added. (Jelmer Vernooij,158 ``RevisionTree.annotate_iter`` have been added. (Jelmer Vernooij,
159 #897781)159 #897781)
160160
161 * Branches with symlinks are now supported on Windows. Symlinks are
162 ignored by operations like branch, diff etc. with a warning as Symlinks
163 are not created on Windows.
164 (Parth Malwankar, #81689)
165
161 * New ``lp+bzr://`` URL scheme for Bazaar-only branches on Launchpad.166 * New ``lp+bzr://`` URL scheme for Bazaar-only branches on Launchpad.
162 (Jelmer Vernooij)167 (Jelmer Vernooij)
163168

Subscribers

People subscribed via source and target branches