2 # -*-mode: python; coding: utf-8 -*-
4 # Inspired from svn-import.py by astrand@cendio.se (ref :
5 # http://svn.haxx.se/users/archive-2006-10/0857.shtml)
7 # svn-merge-vendor.py (v1.0.1) - Import a new release, such as a vendor drop.
9 # The "Vendor branches" chapter of "Version Control with Subversion"
10 # describes how to do a new vendor drop with:
12 # >The goal here is to make our current directory contain only the
13 # >libcomplex 1.1 code, and to ensure that all that code is under version
14 # >control. Oh, and we want to do this with as little version control
15 # >history disturbance as possible.
17 # This utility tries to take you to this goal - automatically. Files
18 # new in this release is added to version control, and files removed
19 # in this new release are removed from version control. It will
20 # detect the moved files by looking in the svn log to find the
21 # "copied-from" path !
23 # Compared to svn_load_dirs.pl, this utility:
25 # * DETECTS THE MOVED FILES !!
26 # * Does not hard-code commit messages
27 # * Allows you to fine-tune the import before commit, which
28 # allows you to turn adds+deletes into moves.
31 # * support --username and --password
33 # This tool is provided under GPL license. Please read
34 # http://www.gnu.org/licenses/gpl.html for the original text.
37 # $LastChangedRevision$
51 from StringIO
import StringIO
52 # lxml module can be found here : http://codespeak.net/lxml/
53 from lxml
import etree
56 prog_name
= os
.path
.basename(sys
.argv
[0])
57 orig_svn_subroot
= None
58 base_copied_paths
= []
63 entries_to_delete
= []
67 def del_temp_tree(tmpdir
):
68 """Delete tree, standring in the root"""
70 logger
.info("Deleting tmpdir "+tmpdir
)
75 print logger
.warn("Couldn't delete tmpdir %s. Don't forget to remove it manually." % (tmpdir
))
78 def checkout(url
, revision
=None):
79 """Checks out the given URL at the given revision, using HEAD if not defined. Returns the working copy directory"""
81 # Create a temp dir to hold our working copy
82 wc_dir
= tempfile
.mkdtemp(prefix
=prog_name
)
83 atexit
.register(del_temp_tree
, wc_dir
)
89 logger
.info("Checking out "+url
+" to "+wc_dir
)
90 returncode
= call_cmd(["svn", "checkout", url
, wc_dir
])
97 def merge(wc_dir
, revision_from
, revision_to
):
98 """Merges repo_url from revision revision_from to revision revision_to into wc_dir"""
100 logger
.info("Merging between revisions %s and %s into %s" % (revision_from
, revision_to
, wc_dir
))
102 return call_cmd(["svn", "merge", "-r", revision_from
+":"+revision_to
, wc_dir
])
104 def treat_status(wc_dir_orig
, wc_dir
):
105 """Copies modification from official vendor branch to wc"""
107 logger
.info("Copying modification from official vendor branch %s to wc %s" % (wc_dir_orig
, wc_dir
))
108 os
.chdir(wc_dir_orig
)
109 status_tree
= call_cmd_xml_tree_out(["svn", "status", "--xml"])
110 global entries_to_treat
, entries_to_delete
111 entries_to_treat
= status_tree
.xpath("/status/target/entry")
112 entries_to_delete
= []
114 while len(entries_to_treat
) > 0:
115 entry
= entries_to_treat
.pop(0)
116 entry_type
= get_entry_type(entry
)
117 file = get_entry_path(entry
)
118 if entry_type
== 'added':
119 if is_entry_copied(entry
):
120 check_exit(copy(wc_dir_orig
, wc_dir
, file), "Error during copy")
122 check_exit(add(wc_dir_orig
, wc_dir
, file), "Error during add")
123 elif entry_type
== 'deleted':
124 entries_to_delete
.append(entry
)
125 elif entry_type
== 'modified' or entry_type
== 'replaced':
126 check_exit(update(wc_dir_orig
, wc_dir
, file), "Error during update")
127 elif entry_type
== 'normal':
128 logger
.info("File %s has a 'normal' state (unchanged). Ignoring." % (file))
130 logger
.error("Status not understood : '%s' not supported (file : %s)" % (entry_type
, file))
132 # We then treat the left deletions
133 for entry
in entries_to_delete
:
134 check_exit(delete(wc_dir_orig
, wc_dir
, get_entry_path(entry
)), "Error during delete")
138 def get_entry_type(entry
):
139 return get_xml_text_content(entry
, "wc-status/@item")
141 def get_entry_path(entry
):
142 return get_xml_text_content(entry
, "@path")
144 def is_entry_copied(entry
):
145 return get_xml_text_content(entry
, "wc-status/@copied") == 'true'
147 def copy(wc_dir_orig
, wc_dir
, file):
149 logger
.info("A+ %s" % (file))
151 # Retreiving the original URL
152 os
.chdir(wc_dir_orig
)
153 info_tree
= call_cmd_xml_tree_out(["svn", "info", "--xml", os
.path
.join(wc_dir_orig
, file)])
154 url
= get_xml_text_content(info_tree
, "/info/entry/url")
156 # Detecting original svn root
157 global orig_svn_subroot
158 if not orig_svn_subroot
:
159 orig_svn_root
= get_xml_text_content(info_tree
, "/info/entry/repository/root")
160 #print >>sys.stderr, "url : %s" % (url)
161 sub_url
= url
.split(orig_svn_root
)[-1]
162 sub_url
= os
.path
.normpath(sub_url
)
163 #print >>sys.stderr, "sub_url : %s" % (sub_url)
164 if sub_url
.startswith(os
.path
.sep
):
165 sub_url
= sub_url
[1:]
167 orig_svn_subroot
= '/'+sub_url
.split(file)[0].replace(os
.path
.sep
, '/')
168 #print >>sys.stderr, "orig_svn_subroot : %s" % (orig_svn_subroot)
172 # Detecting original file copy path
173 os
.chdir(wc_dir_orig
)
174 orig_svn_root_subroot
= get_xml_text_content(info_tree
, "/info/entry/repository/root") + orig_svn_subroot
175 real_from
= str(int(r_from
)+1)
176 logger
.info("Retreiving log of the original trunk %s between revisions %s and %s ..." % (orig_svn_root_subroot
, real_from
, r_to
))
177 log_tree
= call_cmd_xml_tree_out(["svn", "log", "--xml", "-v", "-r", "%s:%s" % (real_from
, r_to
), orig_svn_root_subroot
])
179 # Detecting the path of the original moved or copied file
180 orig_url_file
= orig_svn_subroot
+file.replace(os
.path
.sep
, '/')
181 orig_url_file_old
= None
182 #print >>sys.stderr, " orig_url_file : %s" % (orig_url_file)
184 orig_url_file_old
= orig_url_file
185 orig_url_file
= get_xml_text_content(log_tree
, "//path[(@action='R' or @action='A') and text()='%s']/@copyfrom-path" % (orig_url_file
))
186 logger
.debug("orig_url_file : %s" % (orig_url_file
))
187 orig_url_file
= orig_url_file_old
189 # Getting the relative url for the original url file
191 orig_file
= convert_relative_url_to_path(orig_url_file
)
194 global base_copied_paths
, added_paths
195 # If there is no "moved origin" for that file, or the origin doesn't exist in the working directory, or the origin is the same as the given file, or the origin is an added file
196 if not orig_url_file
or (orig_file
and (not os
.path
.exists(os
.path
.join(wc_dir
, orig_file
)) or orig_file
== file or orig_file
in added_paths
)):
197 # Check if the file is within a recently copied path
198 for path
in base_copied_paths
:
199 if file.startswith(path
):
200 logger
.warn("The path %s to add is a sub-path of recently copied %s. Ignoring the A+." % (file, path
))
202 # Simple add the file
203 logger
.warn("Log paths for the file %s don't correspond with any file in the wc. Will do a simple A." % (file))
204 return add(wc_dir_orig
, wc_dir
, file)
206 # We catch the relative URL for the original file
207 orig_file
= convert_relative_url_to_path(orig_url_file
)
209 # Detect if it's a move
211 global entries_to_treat
, entries_to_delete
212 if search_and_remove_delete_entry(entries_to_treat
, orig_file
) or search_and_remove_delete_entry(entries_to_delete
, orig_file
):
213 # It's a move, removing the delete, and treating it as a move
216 logger
.info("%s from %s" % (cmd
, orig_url_file
))
217 returncode
= call_cmd(["svn", cmd
, os
.path
.join(wc_dir
, orig_file
), os
.path
.join(wc_dir
, file)])
219 if os
.path
.isdir(os
.path
.join(wc_dir
, orig_file
)):
220 base_copied_paths
.append(file)
222 # Copy the last version of the file from the original repository
223 shutil
.copy(os
.path
.join(wc_dir_orig
, file), os
.path
.join(wc_dir
, file))
226 def search_and_remove_delete_entry(entries
, orig_file
):
227 for entry
in entries
:
228 if get_entry_type(entry
) == 'deleted' and get_entry_path(entry
) == orig_file
:
229 entries
.remove(entry
)
233 def convert_relative_url_to_path(url
):
234 global orig_svn_subroot
235 return os
.path
.normpath(url
.split(orig_svn_subroot
)[-1])
237 def new_added_path(returncode
, file):
238 if not is_returncode_bad(returncode
):
240 added_paths
.append(file)
242 def add(wc_dir_orig
, wc_dir
, file):
244 logger
.info("A %s" % (file))
245 if os
.path
.exists(os
.path
.join(wc_dir
, file)):
246 logger
.warn("Target file %s already exists. Will do a simple M" % (file))
247 return update(wc_dir_orig
, wc_dir
, file)
249 if os
.path
.isdir(os
.path
.join(wc_dir_orig
, file)):
250 returncode
= call_cmd(["svn", "mkdir", file])
251 new_added_path(returncode
, file)
254 shutil
.copy(os
.path
.join(wc_dir_orig
, file), os
.path
.join(wc_dir
, file))
255 returncode
= call_cmd(["svn", "add", file])
256 new_added_path(returncode
, file)
259 def delete(wc_dir_orig
, wc_dir
, file):
261 logger
.info("D %s" % (file))
263 if not os
.path
.exists(file):
264 logger
.warn("File %s doesn't exist. Ignoring D." % (file))
266 return call_cmd(["svn", "delete", file])
268 def update(wc_dir_orig
, wc_dir
, file):
270 logger
.info("M %s" % (file))
271 if os
.path
.isdir(os
.path
.join(wc_dir_orig
, file)):
272 logger
.warn("%s is a directory. Ignoring M." % (file))
274 shutil
.copy(os
.path
.join(wc_dir_orig
, file), os
.path
.join(wc_dir
, file))
277 def fine_tune(wc_dir
):
278 """Gives the user a chance to fine-tune"""
279 alert(["If you want to fine-tune import, do so in working copy located at : %s" % (wc_dir
),
280 "When done, press Enter to commit, or Ctrl-C to abort."])
283 """Wait the user to <ENTER> or abort the program"""
284 for message
in messages
:
285 print >> sys
.stderr
, message
287 return sys
.stdin
.readline()
288 except KeyboardInterrupt:
291 def commit(wc_dir
, message
):
292 """Commits the wc_dir"""
294 cmd
= ["svn", "commit"]
296 cmd
+= ["-m", message
]
299 def tag_wc(repo_url
, current
, tag
, message
):
300 """Tags the wc_dir"""
301 cmd
= ["svn", "copy"]
303 cmd
+= ["-m", message
]
304 return call_cmd(cmd
+ [repo_url
+"/"+current
, repo_url
+"/"+tag
])
308 logger
.debug(string
.join(cmd
, ' '))
309 return subprocess
.call(cmd
, stdout
=DEVNULL
, stderr
=sys
.stderr
)#subprocess.STDOUT)
311 def call_cmd_out(cmd
):
313 logger
.debug(string
.join(cmd
, ' '))
314 return subprocess
.Popen(cmd
, stdout
=subprocess
.PIPE
, stderr
=sys
.stderr
).stdout
316 def call_cmd_str_out(cmd
):
317 out
= call_cmd_out(cmd
)
319 for line
in out
.readlines():
324 def call_cmd_xml_tree_out(cmd
):
325 return etree
.parse(StringIO(call_cmd_str_out(cmd
)))
327 def get_xml_text_content(xml_doc
, xpath
):
328 result_nodes
= xml_doc
.xpath(xpath
)
330 if type(result_nodes
[0]) == types
.StringType
:
331 return result_nodes
[0]
333 return result_nodes
[0].text
337 def usage(error
= None):
338 """Print usage message and exit"""
339 print >>sys
.stderr
, """%s: Merges the difference between two revisions of the original repository of the vendor, to the vendor branch
340 usage: %s [options] REPO_URL CURRENT_PATH ORIGINAL_REPO_URL -r N:M
342 - REPO_URL : repository URL for the vendor branch (i.e: http://svn.example.com/repos/vendor/libcomplex)
343 - CURRENT_PATH : relative path to the current folder (i.e: current)
344 - ORIGINAL_REPO_URL : original base repository URL
345 - N:M : from revision N to revision M
347 This command executes these steps:
349 1. Check out directory specified by ORIGINAL_REPO_URL@N in a temporary directory.(1)
350 2. Merges changes to revision M.(1)
351 3. Check out directory specified by REPO_URL in a second temporary directory.(2)
352 4. Treat the merge by "svn status" on the working copy of ORIGINAL_REPO_URL. If the history is kept ('+' when svn st), do a move instead of a delete / add.
353 5. Allow user to fine-tune import.
355 7. Optionally tag new release.
356 8. Delete the temporary directories.
358 (1) : if -c wasn't passed
359 (2) : if -w wasn't passed
362 -r [--revision] N:M : specify revisions N to M
363 -h [--help] : show this usage
364 -t [--tag] arg : copy new release to directory ARG, relative to REPO_URL,
365 using automatic commit message. Example:
367 --non-interactive : do no interactive prompting, do not allow manual fine-tune
368 -m [--message] arg : specify commit message ARG
369 -v [--verbose] : verbose mode
370 -c [--merged-vendor] arg : working copy path of the original already merged vendor trunk (skips the steps 1. and 2.)
371 -w [--current-wc] arg : working copy path of the current checked out trunk of the vendor branch (skips the step 3.)
372 """ % ((prog_name
,) * 2)
375 print >>sys
.stder
, "", "Current error : "+error
383 revision_to_parse
= None
387 # Initializing logger
389 logger
= logging
.getLogger('svn-merge-vendor')
390 hdlr
= logging
.StreamHandler(sys
.stderr
)
391 formatter
= logging
.Formatter('%(levelname)-8s %(message)s')
392 hdlr
.setFormatter(formatter
)
393 logger
.addHandler(hdlr
)
394 logger
.setLevel(logging
.INFO
)
397 opts
, args
= getopt
.gnu_getopt(sys
.argv
[1:], "ht:m:vr:c:w:",
398 ["help", "tag", "message", "non-interactive", "verbose", "revision", "merged-vendor", "current-wc"])
399 except getopt
.GetoptError
:
400 # print help information and exit:
404 if o
in ("-h", "--help"):
406 if o
in ("-t", "--tag"):
408 if o
in ("-m", "--message"):
410 if o
in ("--non-interactive"):
412 if o
in ("-v", "--verbose"):
413 logger
.setLevel(logging
.DEBUG
)
414 if o
in ("-r", "--revision"):
415 revision_to_parse
= a
416 if o
in ("-c", "--merged-vendor"):
418 if o
in ("-w", "--current-wc"):
424 repo_url
, current_path
, orig_repo_url
= args
[0:3]
426 if (not revision_to_parse
):
427 usage("the revision numbers are mendatory")
429 r_from
, r_to
= re
.match("(\d+):(\d+)", revision_to_parse
).groups()
431 if not r_from
or not r_to
:
432 usage("the revision numbers are mendatory")
435 r_from_int
= int(r_from
)
438 usage("the revision parameter is not a number")
440 if r_from_int
>= r_to_int
:
441 usage("the 'from revision' must be inferior to the 'to revision'")
443 if not merged_vendor
:
444 if orig_repo_url
.startswith("http://"):
445 wc_dir_orig
= checkout(orig_repo_url
, r_from
)
446 check_exit(wc_dir_orig
, "Error during checkout")
448 check_exit(merge(wc_dir_orig
, r_from
, r_to
), "Error during merge")
450 usage("ORIGINAL_REPO_URL must start with 'http://'")
452 wc_dir_orig
= merged_vendor
455 wc_dir
= checkout(repo_url
+"/"+current_path
)
456 check_exit(wc_dir
, "Error during checkout")
458 check_exit(treat_status(wc_dir_orig
, wc_dir
), "Error during resolving")
464 message
= "New vendor version, upgrading from revision %s to revision %s" % (r_from
, r_to
)
465 alert(["No message was specified to commit, the program will use that default one : '%s'" % (message
),
466 "Press Enter to commit, or Ctrl-C to abort."])
468 check_exit(commit(wc_dir
, message
), "Error during commit")
472 message
= "Tag %s, when upgrading the vendor branch from revision %s to revision %s" % (tag
, r_from
, r_to
)
473 alert(["No message was specified to tag, the program will use that default one : '%s'" % (message
),
474 "Press Enter to tag, or Ctrl-C to abort."])
475 check_exit(tag_wc(repo_url
, current_path
, tag
, message
), "Error during tag")
477 logger
.info("Vendor branch merged, passed from %s to %s !" % (r_from
, r_to
))
479 def is_returncode_bad(returncode
):
480 return returncode
is None or returncode
== 1
482 def check_exit(returncode
, message
):
484 if is_returncode_bad(returncode
):
485 logger
.error(message
)
488 if __name__
== "__main__":
489 if (os
.name
== "nt"):
490 DEVNULL
= open("nul:", "w")
492 DEVNULL
= open("/dev/null", "w")