|
1 # Copyright 2009 the Melange authors. |
|
2 # |
|
3 # Licensed under the Apache License, Version 2.0 (the "License"); |
|
4 # you may not use this file except in compliance with the License. |
|
5 # You may obtain a copy of the License at |
|
6 # |
|
7 # http://www.apache.org/licenses/LICENSE-2.0 |
|
8 # |
|
9 # Unless required by applicable law or agreed to in writing, software |
|
10 # distributed under the License is distributed on an "AS IS" BASIS, |
|
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|
12 # See the License for the specific language governing permissions and |
|
13 # limitations under the License. |
|
14 |
|
15 """Subversion commandline wrapper. |
|
16 |
|
17 This module provides access to a restricted subset of the Subversion |
|
18 commandline tool. The main functionality offered is an object wrapping |
|
19 a working copy, providing version control operations within that |
|
20 working copy. |
|
21 |
|
22 A few standalone commands are also implemented to extract data from |
|
23 arbitrary remote repositories. |
|
24 """ |
|
25 |
|
26 __authors__ = [ |
|
27 # alphabetical order by last name, please |
|
28 '"David Anderson" <dave@natulte.net>', |
|
29 ] |
|
30 |
|
31 import error |
|
32 import util |
|
33 |
|
34 |
|
35 def export(url, revision, dest_path): |
|
36 """Export the contents of a repository to a local path. |
|
37 |
|
38 Note that while the underlying 'svn export' only requires a URL, we |
|
39 require that both a URL and a revision be specified, to fully |
|
40 qualify the data to export. |
|
41 |
|
42 Args: |
|
43 url: The repository URL to export. |
|
44 revision: The revision to export. |
|
45 dest_path: The destination directory for the export. Note that |
|
46 this is an absolute path, NOT a working copy relative |
|
47 path. |
|
48 """ |
|
49 assert os.path.isabs(dest_path) |
|
50 if os.path.exists(dest_path): |
|
51 raise error.ObstructionError('Cannot export to obstructed path %s' % |
|
52 dest_path) |
|
53 util.run(['svn', 'export', '-r', str(revision), url, dest_path]) |
|
54 |
|
55 |
|
56 def find_tag_rev(url): |
|
57 """Return the revision at which a remote tag was created. |
|
58 |
|
59 Since tags are immutable by convention, usually the HEAD of a tag |
|
60 should be the tag creation revision. However, mistakes can happen, |
|
61 so this function will walk the history of the given tag URL, |
|
62 stopping on the first revision that was created by copy. |
|
63 |
|
64 This detection is not foolproof. For example: it will be fooled by a |
|
65 tag that was created, deleted, and recreated by copy at a different |
|
66 revision. It is not clear what the desired behavior in these edge |
|
67 cases are, and no attempt is made to handle them. You should request |
|
68 user confirmation before using the result of this function. |
|
69 |
|
70 Args: |
|
71 url: The repository URL of the tag to examine. |
|
72 """ |
|
73 try: |
|
74 output = util.run(['svn', 'log', '-q', '--stop-on-copy', url], |
|
75 capture=True) |
|
76 except util.SubprocessFailed: |
|
77 raise error.ExpectationFailed('No tag at URL ' + url) |
|
78 first_rev_line = output[-2] |
|
79 first_rev = int(first_rev_line.split()[0][1:]) |
|
80 return first_rev |
|
81 |
|
82 |
|
83 def diff(url, revision): |
|
84 """Retrieve a revision from a remote repository as a unified diff. |
|
85 |
|
86 Args: |
|
87 url: The repository URL on which to perform the diff. |
|
88 revision: The revision to extract at the given url. |
|
89 |
|
90 Returns: |
|
91 A string containing the changes extracted from the remote |
|
92 repository, in unified diff format suitable for application using |
|
93 'patch'. |
|
94 """ |
|
95 try: |
|
96 return util.run(['svn', 'diff', '-c', str(revision), url], |
|
97 capture=True, split_capture=False) |
|
98 except util.SubprocessFailed: |
|
99 raise error.ExpectationFailed('Could not get diff for r%d ' |
|
100 'from remote repository' % revision) |
|
101 |
|
102 |
|
103 class WorkingCopy(util.Paths): |
|
104 """Wrapper for operations on a Subversion working copy. |
|
105 |
|
106 An instance of this class is bound to a specific working copy |
|
107 directory, and provides an API to perform various Subversion |
|
108 operations on this working copy. |
|
109 |
|
110 Some methods take a 'depth' argument. Depth in Subversion is a |
|
111 feature that allows the creation of arbitrarily shallow or deep |
|
112 working copies on a per-directory basis. Possible values are |
|
113 'none' (no files or directories), 'files' (only files in .), |
|
114 'immediates' (files and directories in ., directories checked out |
|
115 at depth 'none') or 'infinity' (a normal working copy with |
|
116 everything). |
|
117 |
|
118 Note that this wrapper also doubles as a Paths object, offering an |
|
119 easy way to get or check the existence of paths in the working |
|
120 copy. |
|
121 """ |
|
122 |
|
123 def __init__(self, wc_dir): |
|
124 util.Paths.__init__(self, wc_dir) |
|
125 |
|
126 def _unknownAndMissing(self, path): |
|
127 """Returns lists of unknown and missing files in the working copy. |
|
128 |
|
129 Args: |
|
130 path: The working copy path to scan. |
|
131 |
|
132 Returns: |
|
133 |
|
134 Two lists. The first is a list of all unknown paths |
|
135 (subversion has no knowledge of them), the second is a list |
|
136 of missing paths (subversion knows about them, but can't |
|
137 find them). Paths in either list are relative to the input |
|
138 path. |
|
139 """ |
|
140 assert self.exists() |
|
141 unknown = [] |
|
142 missing = [] |
|
143 for line in self.status(path): |
|
144 if not line.strip(): |
|
145 continue |
|
146 if line[0] == '?': |
|
147 unknown.append(line[7:]) |
|
148 elif line[0] == '!': |
|
149 missing.append(line[7:]) |
|
150 return unknown, missing |
|
151 |
|
152 def checkout(self, url, depth='infinity'): |
|
153 """Check out a working copy from the given URL. |
|
154 |
|
155 Args: |
|
156 url: The Subversion repository URL to check out. |
|
157 depth: The depth of the working copy root. |
|
158 """ |
|
159 assert not self.exists() |
|
160 util.run(['svn', 'checkout', '--depth=' + depth, url, self.path()]) |
|
161 |
|
162 def update(self, path='', depth=None): |
|
163 """Update a working copy path, optionally changing depth. |
|
164 |
|
165 Args: |
|
166 path: The working copy path to update. |
|
167 depth: If set, change the depth of the path before updating. |
|
168 """ |
|
169 assert self.exists() |
|
170 if depth is None: |
|
171 util.run(['svn', 'update', self.path(path)]) |
|
172 else: |
|
173 util.run(['svn', 'update', '--set-depth=' + depth, self.path(path)]) |
|
174 |
|
175 def revert(self, path=''): |
|
176 """Recursively revert a working copy path. |
|
177 |
|
178 Note that this command is more zealous than the 'svn revert' |
|
179 command, as it will also delete any files which subversion |
|
180 does not know about. |
|
181 """ |
|
182 util.run(['svn', 'revert', '-R', self.path(path)]) |
|
183 |
|
184 unknown, missing = self._unknownAndMissing(path) |
|
185 unknown = [os.path.join(self.path(path), p) for p in unknown] |
|
186 |
|
187 if unknown: |
|
188 # rm -rf makes me uneasy. Verify that all paths to be deleted |
|
189 # are within the release working copy. |
|
190 for p in unknown: |
|
191 assert p.startswith(self.path()) |
|
192 |
|
193 util.run(['rm', '-rf', '--'] + unknown) |
|
194 |
|
195 def ls(self, dir=''): |
|
196 """List the contents of a working copy directory. |
|
197 |
|
198 Note that this returns the contents of the directory as seen |
|
199 by the server, not constrained by the depth settings of the |
|
200 local path. |
|
201 """ |
|
202 assert self.exists() |
|
203 return util.run(['svn', 'ls', self.path(dir)], capture=True) |
|
204 |
|
205 def copy(self, src, dest): |
|
206 """Copy a working copy path. |
|
207 |
|
208 The copy is only scheduled for commit, not committed. |
|
209 |
|
210 Args: |
|
211 src: The source working copy path. |
|
212 dst: The destination working copy path. |
|
213 """ |
|
214 assert self.exists() |
|
215 util.run(['svn', 'cp', self.path(src), self.path(dest)]) |
|
216 |
|
217 def propget(self, prop_name, path): |
|
218 """Get the value of a property on a working copy path. |
|
219 |
|
220 Args: |
|
221 prop_name: The property name, eg. 'svn:externals'. |
|
222 path: The working copy path on which the property is set. |
|
223 """ |
|
224 assert self.exists() |
|
225 return util.run(['svn', 'propget', prop_name, self.path(path)], |
|
226 capture=True) |
|
227 |
|
228 def propset(self, prop_name, prop_value, path): |
|
229 """Set the value of a property on a working copy path. |
|
230 |
|
231 The property change is only scheduled for commit, not committed. |
|
232 |
|
233 Args: |
|
234 prop_name: The property name, eg. 'svn:externals'. |
|
235 prop_value: The value that should be set. |
|
236 path: The working copy path on which to set the property. |
|
237 """ |
|
238 assert self.exists() |
|
239 util.run(['svn', 'propset', prop_name, prop_value, self.path(path)]) |
|
240 |
|
241 def add(self, paths): |
|
242 """Schedule working copy paths for addition. |
|
243 |
|
244 The paths are only scheduled for addition, not committed. |
|
245 |
|
246 Args: |
|
247 paths: The list of working copy paths to add. |
|
248 """ |
|
249 assert self.exists() |
|
250 paths = [self.path(p) for p in paths] |
|
251 util.run(['svn', 'add'] + paths) |
|
252 |
|
253 def remove(self, paths): |
|
254 """Schedule working copy paths for deletion. |
|
255 |
|
256 The paths are only scheduled for deletion, not committed. |
|
257 |
|
258 Args: |
|
259 paths: The list of working copy paths to delete. |
|
260 """ |
|
261 assert self.exists() |
|
262 paths = [self.path(p) for p in paths] |
|
263 util.run(['svn', 'rm'] + paths) |
|
264 |
|
265 def status(self, path=''): |
|
266 """Return the status of a working copy path. |
|
267 |
|
268 The status returned is the verbatim output of 'svn status' on |
|
269 the path. |
|
270 |
|
271 Args: |
|
272 path: The path to examine. |
|
273 """ |
|
274 assert self.exists() |
|
275 return util.run(['svn', 'status', self.path(path)], capture=True) |
|
276 |
|
277 def addRemove(self, path=''): |
|
278 """Perform an "addremove" operation a working copy path. |
|
279 |
|
280 An "addremove" runs 'svn status' and schedules all the unknown |
|
281 paths (listed as '?') for addition, and all the missing paths |
|
282 (listed as '!') for deletion. Its main use is to synchronize |
|
283 working copy state after applying a patch in unified diff |
|
284 format. |
|
285 |
|
286 Args: |
|
287 path: The path under which unknown/missing files should be |
|
288 added/removed. |
|
289 """ |
|
290 assert self.exists() |
|
291 unknown, missing = self._unknownAndMissing(path) |
|
292 if unknown: |
|
293 self.add(unknown) |
|
294 if missing: |
|
295 self.remove(missing) |
|
296 |
|
297 def commit(self, message, path=''): |
|
298 """Commit scheduled changes to the source repository. |
|
299 |
|
300 Args: |
|
301 message: The commit message to use. |
|
302 path: The path to commit. |
|
303 """ |
|
304 assert self.exists() |
|
305 util.run(['svn', 'commit', '-m', message, self.path(path)]) |