eecd46ffa7f7523bfd4185cc446950c528fb80f0
[portage.git] / pym / repoman / utilities.py
1 # repoman: Utilities
2 # Copyright 2007-2011 Gentoo Foundation
3 # Distributed under the terms of the GNU General Public License v2
4
5 """This module contains utility functions to help repoman find ebuilds to
6 scan"""
7
8 from __future__ import print_function
9
10 __all__ = [
11         "detect_vcs_conflicts",
12         "editor_is_executable",
13         "FindPackagesToScan",
14         "FindPortdir",
15         "FindVCS",
16         "format_qa_output",
17         "get_commit_message_with_editor",
18         "get_commit_message_with_stdin",
19         "get_committer_name",
20         "have_profile_dir",
21         "parse_metadata_use",
22         "UnknownHerdsError",
23         "check_metadata",
24         "UpdateChangeLog"
25 ]
26
27 import errno
28 import io
29 from itertools import chain
30 import logging
31 import pwd
32 import re
33 import sys
34 import time
35 import textwrap
36 import difflib
37 import shutil
38 from tempfile import mkstemp
39
40 from portage import os
41 from portage import subprocess_getstatusoutput
42 from portage import _encodings
43 from portage import _unicode_decode
44 from portage import _unicode_encode
45 from portage import output
46 from portage.localization import _
47 from portage.output import red, green
48 from portage.process import find_binary
49 from portage import exception
50 from portage import util
51 normalize_path = util.normalize_path
52 util.initialize_logger()
53
54 if sys.hexversion >= 0x3000000:
55         basestring = str
56
57 def detect_vcs_conflicts(options, vcs):
58         """Determine if the checkout has problems like cvs conflicts.
59         
60         If you want more vcs support here just keep adding if blocks...
61         This could be better.
62         
63         TODO(antarus): Also this should probably not call sys.exit() as
64         repoman is run on >1 packages and one failure should not cause
65         subsequent packages to fail.
66         
67         Args:
68                 vcs - A string identifying the version control system in use
69         Returns:
70                 None (calls sys.exit on fatal problems)
71         """
72         retval = ("","")
73         if vcs == 'cvs':
74                 logging.info("Performing a " + output.green("cvs -n up") + \
75                         " with a little magic grep to check for updates.")
76                 retval = subprocess_getstatusoutput("cvs -n up 2>/dev/null | " + \
77                         "egrep '^[^\?] .*' | " + \
78                         "egrep -v '^. .*/digest-[^/]+|^cvs server: .* -- ignored$'")
79         if vcs == 'svn':
80                 logging.info("Performing a " + output.green("svn status -u") + \
81                         " with a little magic grep to check for updates.")
82                 retval = subprocess_getstatusoutput("svn status -u 2>&1 | " + \
83                         "egrep -v '^.  +.*/digest-[^/]+' | " + \
84                         "head -n-1")
85
86         if vcs in ['cvs', 'svn']:
87                 mylines = retval[1].splitlines()
88                 myupdates = []
89                 for line in mylines:
90                         if not line:
91                                 continue
92                         if line[0] not in " UPMARD": # unmodified(svn),Updates,Patches,Modified,Added,Removed/Replaced(svn),Deleted(svn)
93                                 # Stray Manifest is fine, we will readd it anyway.
94                                 if line[0] == '?' and line[1:].lstrip() == 'Manifest':
95                                         continue
96                                 logging.error(red("!!! Please fix the following issues reported " + \
97                                         "from cvs: ")+green("(U,P,M,A,R,D are ok)"))
98                                 logging.error(red("!!! Note: This is a pretend/no-modify pass..."))
99                                 logging.error(retval[1])
100                                 sys.exit(1)
101                         elif vcs == 'cvs' and line[0] in "UP":
102                                 myupdates.append(line[2:])
103                         elif vcs == 'svn' and line[8] == '*':
104                                 myupdates.append(line[9:].lstrip(" 1234567890"))
105
106                 if myupdates:
107                         logging.info(green("Fetching trivial updates..."))
108                         if options.pretend:
109                                 logging.info("(" + vcs + " update " + " ".join(myupdates) + ")")
110                                 retval = os.EX_OK
111                         else:
112                                 retval = os.system(vcs + " update " + " ".join(myupdates))
113                         if retval != os.EX_OK:
114                                 logging.fatal("!!! " + vcs + " exited with an error. Terminating.")
115                                 sys.exit(retval)
116
117
118 def have_profile_dir(path, maxdepth=3, filename="profiles.desc"):
119         """ 
120         Try to figure out if 'path' has a profiles/
121         dir in it by checking for the given filename.
122         """
123         while path != "/" and maxdepth:
124                 if os.path.exists(os.path.join(path, "profiles", filename)):
125                         return normalize_path(path)
126                 path = normalize_path(path + "/..")
127                 maxdepth -= 1
128
129 def parse_metadata_use(xml_tree):
130         """
131         Records are wrapped in XML as per GLEP 56
132         returns a dict with keys constisting of USE flag names and values
133         containing their respective descriptions
134         """
135         uselist = {}
136
137         usetags = xml_tree.findall("use")
138         if not usetags:
139                 return uselist
140
141         # It's possible to have multiple 'use' elements.
142         for usetag in usetags:
143                 flags = usetag.findall("flag")
144                 if not flags:
145                         # DTD allows use elements containing no flag elements.
146                         continue
147
148                 for flag in flags:
149                         pkg_flag = flag.get("name")
150                         if pkg_flag is None:
151                                 raise exception.ParseError("missing 'name' attribute for 'flag' tag")
152                         flag_restrict = flag.get("restrict")
153
154                         # emulate the Element.itertext() method from python-2.7
155                         inner_text = []
156                         stack = []
157                         stack.append(flag)
158                         while stack:
159                                 obj = stack.pop()
160                                 if isinstance(obj, basestring):
161                                         inner_text.append(obj)
162                                         continue
163                                 if isinstance(obj.text, basestring):
164                                         inner_text.append(obj.text)
165                                 if isinstance(obj.tail, basestring):
166                                         stack.append(obj.tail)
167                                 stack.extend(reversed(obj))
168
169                         if pkg_flag not in uselist:
170                                 uselist[pkg_flag] = {}
171
172                         # (flag_restrict can be None)
173                         uselist[pkg_flag][flag_restrict] = " ".join("".join(inner_text).split())
174
175         return uselist
176
177 class UnknownHerdsError(ValueError):
178         def __init__(self, herd_names):
179                 _plural = len(herd_names) != 1
180                 super(UnknownHerdsError, self).__init__(
181                         'Unknown %s %s' % (_plural and 'herds' or 'herd',
182                         ','.join('"%s"' % e for e in herd_names)))
183
184
185 def check_metadata_herds(xml_tree, herd_base):
186         herd_nodes = xml_tree.findall('herd')
187         unknown_herds = [name for name in
188                         (e.text.strip() for e in herd_nodes if e.text is not None)
189                         if not herd_base.known_herd(name)]
190
191         if unknown_herds:
192                 raise UnknownHerdsError(unknown_herds)
193
194 def check_metadata(xml_tree, herd_base):
195         if herd_base is not None:
196                 check_metadata_herds(xml_tree, herd_base)
197
198 def FindPackagesToScan(settings, startdir, reposplit):
199         """ Try to find packages that need to be scanned
200         
201         Args:
202                 settings - portage.config instance, preferably repoman_settings
203                 startdir - directory that repoman was run in
204                 reposplit - root of the repository
205         Returns:
206                 A list of directories to scan
207         """
208         
209         
210         def AddPackagesInDir(path):
211                 """ Given a list of dirs, add any packages in it """
212                 ret = []
213                 pkgdirs = os.listdir(path)
214                 for d in pkgdirs:
215                         if d == 'CVS' or d.startswith('.'):
216                                 continue
217                         p = os.path.join(path, d)
218
219                         if os.path.isdir(p):
220                                 cat_pkg_dir = os.path.join(*p.split(os.path.sep)[-2:])
221                                 logging.debug('adding %s to scanlist' % cat_pkg_dir)
222                                 ret.append(cat_pkg_dir)
223                 return ret
224         
225         scanlist = []
226         repolevel = len(reposplit)
227         if repolevel == 1: # root of the tree, startdir = repodir
228                 for cat in settings.categories:
229                         path = os.path.join(startdir, cat)
230                         if not os.path.isdir(path):
231                                 continue
232                         pkgdirs = os.listdir(path)
233                         scanlist.extend(AddPackagesInDir(path))
234         elif repolevel == 2: # category level, startdir = catdir
235                 # we only want 1 segment of the directory, is why we use catdir instead of startdir
236                 catdir = reposplit[-2]
237                 if catdir not in settings.categories:
238                         logging.warn('%s is not a valid category according to profiles/categories, ' \
239                                 'skipping checks in %s' % (catdir, catdir))
240                 else:
241                         scanlist = AddPackagesInDir(catdir)
242         elif repolevel == 3: # pkgdir level, startdir = pkgdir
243                 catdir = reposplit[-2]
244                 pkgdir = reposplit[-1]
245                 if catdir not in settings.categories:
246                         logging.warn('%s is not a valid category according to profiles/categories, ' \
247                         'skipping checks in %s' % (catdir, catdir))
248                 else:
249                         path = os.path.join(catdir, pkgdir)
250                         logging.debug('adding %s to scanlist' % path)
251                         scanlist.append(path)
252         return scanlist
253
254
255 def format_qa_output(formatter, stats, fails, dofull, dofail, options, qawarnings):
256         """Helper function that formats output properly
257         
258         Args:
259                 formatter - a subclass of Formatter
260                 stats - a dict of qa status items
261                 fails - a dict of qa status failures
262                 dofull - boolean to print full results or a summary
263                 dofail - boolean to decide if failure was hard or soft
264         
265         Returns:
266                 None (modifies formatter)
267         """
268         full = options.mode == 'full'
269         # we only want key value pairs where value > 0 
270         for category, number in \
271                 filter(lambda myitem: myitem[1] > 0, iter(stats.items())):
272                 formatter.add_literal_data(_unicode_decode("  " + category.ljust(30)))
273                 if category in qawarnings:
274                         formatter.push_style("WARN")
275                 else:
276                         formatter.push_style("BAD")
277                 formatter.add_literal_data(_unicode_decode(str(number)))
278                 formatter.pop_style()
279                 formatter.add_line_break()
280                 if not dofull:
281                         if not full and dofail and category in qawarnings:
282                                 # warnings are considered noise when there are failures
283                                 continue
284                         fails_list = fails[category]
285                         if not full and len(fails_list) > 12:
286                                 fails_list = fails_list[:12]
287                         for failure in fails_list:
288                                 formatter.add_literal_data(_unicode_decode("   " + failure))
289                                 formatter.add_line_break()
290
291
292 def editor_is_executable(editor):
293         """
294         Given an EDITOR string, validate that it refers to
295         an executable. This uses shlex_split() to split the
296         first component and do a PATH lookup if necessary.
297
298         @param editor: An EDITOR value from the environment.
299         @type: string
300         @rtype: bool
301         @returns: True if an executable is found, False otherwise.
302         """
303         editor_split = util.shlex_split(editor)
304         if not editor_split:
305                 return False
306         filename = editor_split[0]
307         if not os.path.isabs(filename):
308                 return find_binary(filename) is not None
309         return os.access(filename, os.X_OK) and os.path.isfile(filename)
310
311
312 def get_commit_message_with_editor(editor, message=None):
313         """
314         Execute editor with a temporary file as it's argument
315         and return the file content afterwards.
316
317         @param editor: An EDITOR value from the environment
318         @type: string
319         @param message: An iterable of lines to show in the editor.
320         @type: iterable
321         @rtype: string or None
322         @returns: A string on success or None if an error occurs.
323         """
324         fd, filename = mkstemp()
325         try:
326                 os.write(fd, _unicode_encode(_(
327                         "\n# Please enter the commit message " + \
328                         "for your changes.\n# (Comment lines starting " + \
329                         "with '#' will not be included)\n"),
330                         encoding=_encodings['content'], errors='backslashreplace'))
331                 if message:
332                         os.write(fd, b"#\n")
333                         for line in message:
334                                 os.write(fd, _unicode_encode("#" + line,
335                                         encoding=_encodings['content'], errors='backslashreplace'))
336                 os.close(fd)
337                 retval = os.system(editor + " '%s'" % filename)
338                 if not (os.WIFEXITED(retval) and os.WEXITSTATUS(retval) == os.EX_OK):
339                         return None
340                 try:
341                         mylines = io.open(_unicode_encode(filename,
342                                 encoding=_encodings['fs'], errors='strict'),
343                                 mode='r', encoding=_encodings['content'], errors='replace'
344                                 ).readlines()
345                 except OSError as e:
346                         if e.errno != errno.ENOENT:
347                                 raise
348                         del e
349                         return None
350                 return "".join(line for line in mylines if not line.startswith("#"))
351         finally:
352                 try:
353                         os.unlink(filename)
354                 except OSError:
355                         pass
356
357
358 def get_commit_message_with_stdin():
359         """
360         Read a commit message from the user and return it.
361
362         @rtype: string or None
363         @returns: A string on success or None if an error occurs.
364         """
365         print("Please enter a commit message. Use Ctrl-d to finish or Ctrl-c to abort.")
366         commitmessage = []
367         while True:
368                 commitmessage.append(sys.stdin.readline())
369                 if not commitmessage[-1]:
370                         break
371         commitmessage = "".join(commitmessage)
372         return commitmessage
373
374
375 def FindPortdir(settings):
376         """ Try to figure out what repo we are in and whether we are in a regular
377         tree or an overlay.
378         
379         Basic logic is:
380         
381         1. Determine what directory we are in (supports symlinks).
382         2. Build a list of directories from / to our current location
383         3. Iterate over PORTDIR_OVERLAY, if we find a match, search for a profiles directory
384                  in the overlay.  If it has one, make it portdir, otherwise make it portdir_overlay.
385         4. If we didn't find an overlay in PORTDIR_OVERLAY, see if we are in PORTDIR; if so, set
386                  portdir_overlay to PORTDIR.  If we aren't in PORTDIR, see if PWD has a profiles dir, if
387                  so, set portdir_overlay and portdir to PWD, else make them False.
388         5. If we haven't found portdir_overlay yet, it means the user is doing something odd, report
389                  an error.
390         6. If we haven't found a portdir yet, set portdir to PORTDIR.
391         
392         Args:
393                 settings - portage.config instance, preferably repoman_settings
394         Returns:
395                 list(portdir, portdir_overlay, location)
396         """
397
398         portdir = None
399         portdir_overlay = None
400         location = os.getcwd()
401         pwd = os.environ.get('PWD', '')
402         if pwd and pwd != location and os.path.realpath(pwd) == location:
403                 # getcwd() returns the canonical path but that makes it hard for repoman to
404                 # orient itself if the user has symlinks in their portage tree structure.
405                 # We use os.environ["PWD"], if available, to get the non-canonical path of
406                 # the current working directory (from the shell).
407                 location = pwd
408
409         location = normalize_path(location)
410
411         path_ids = {}
412         p = location
413         s = None
414         while True:
415                 s = os.stat(p)
416                 path_ids[(s.st_dev, s.st_ino)] = p
417                 if p == "/":
418                         break
419                 p = os.path.dirname(p)
420         if location[-1] != "/":
421                 location += "/"
422
423         for overlay in settings["PORTDIR_OVERLAY"].split():
424                 overlay = os.path.realpath(overlay)
425                 try:
426                         s = os.stat(overlay)
427                 except OSError:
428                         continue
429                 overlay = path_ids.get((s.st_dev, s.st_ino))
430                 if overlay is None:
431                         continue
432                 if overlay[-1] != "/":
433                         overlay += "/"
434                 if True:
435                         portdir_overlay = overlay
436                         subdir = location[len(overlay):]
437                         if subdir and subdir[-1] != "/":
438                                 subdir += "/"
439                         if have_profile_dir(location, subdir.count("/")):
440                                 portdir = portdir_overlay
441                         break
442
443         # Couldn't match location with anything from PORTDIR_OVERLAY,
444         # so fall back to have_profile_dir() checks alone. Assume that
445         # an overlay will contain at least a "repo_name" file while a
446         # master repo (portdir) will contain at least a "profiles.desc"
447         # file.
448         if not portdir_overlay:
449                 portdir_overlay = have_profile_dir(location, filename="repo_name")
450                 if portdir_overlay:
451                         subdir = location[len(portdir_overlay):]
452                         if subdir and subdir[-1] != os.sep:
453                                 subdir += os.sep
454                         if have_profile_dir(location, subdir.count(os.sep)):
455                                 portdir = portdir_overlay
456
457         if not portdir_overlay:
458                 if (settings["PORTDIR"] + os.path.sep).startswith(location):
459                         portdir_overlay = settings["PORTDIR"]
460                 else:
461                         portdir_overlay = have_profile_dir(location)
462                 portdir = portdir_overlay
463         
464         if not portdir_overlay:
465                 msg = 'Repoman is unable to determine PORTDIR or PORTDIR_OVERLAY' + \
466                         ' from the current working directory'
467                 logging.critical(msg)
468                 return (None, None, None)
469
470         if not portdir:
471                 portdir = settings["PORTDIR"]
472
473         if not portdir_overlay.endswith('/'):
474                 portdir_overlay += '/'
475         
476         if not portdir.endswith('/'):
477                 portdir += '/'
478
479         return [normalize_path(x) for x in (portdir, portdir_overlay, location)]
480
481 def FindVCS():
482         """ Try to figure out in what VCS' working tree we are. """
483
484         outvcs = []
485
486         def seek(depth = None):
487                 """ Seek for VCSes that have a top-level data directory only. """
488                 retvcs = []
489                 pathprep = ''
490
491                 while depth is None or depth > 0:
492                         if os.path.isdir(os.path.join(pathprep, '.git')):
493                                 retvcs.append('git')
494                         if os.path.isdir(os.path.join(pathprep, '.bzr')):
495                                 retvcs.append('bzr')
496                         if os.path.isdir(os.path.join(pathprep, '.hg')):
497                                 retvcs.append('hg')
498                         if os.path.isdir(os.path.join(pathprep, '.svn')):  # >=1.7
499                                 retvcs.append('svn')
500
501                         if retvcs:
502                                 break
503                         pathprep = os.path.join(pathprep, '..')
504                         if os.path.realpath(pathprep).strip('/') == '':
505                                 break
506                         if depth is not None:
507                                 depth = depth - 1
508
509                 return retvcs
510
511         # Level zero VCS-es.
512         if os.path.isdir('CVS'):
513                 outvcs.append('cvs')
514         if os.path.isdir('.svn'):  # <1.7
515                 outvcs.append('svn')
516
517         # If we already found one of 'level zeros', just take a quick look
518         # at the current directory. Otherwise, seek parents till we get
519         # something or reach root.
520         if outvcs:
521                 outvcs.extend(seek(1))
522         else:
523                 outvcs = seek()
524
525         return outvcs
526
527 _copyright_re1 = re.compile(br'^(# Copyright \d\d\d\d)-\d\d\d\d ')
528 _copyright_re2 = re.compile(br'^(# Copyright )(\d\d\d\d) ')
529
530
531 class _copyright_repl(object):
532         __slots__ = ('year',)
533         def __init__(self, year):
534                 self.year = year
535         def __call__(self, matchobj):
536                 if matchobj.group(2) == self.year:
537                         return matchobj.group(0)
538                 else:
539                         return matchobj.group(1) + matchobj.group(2) + \
540                                 b'-' + self.year + b' '
541
542 def _update_copyright_year(year, line):
543         """
544         These two regexes are taken from echangelog
545         update_copyright(), except that we don't hardcode
546         1999 here (in order to be more generic).
547         """
548         is_bytes = isinstance(line, bytes)
549         if is_bytes:
550                 if not line.startswith(b'# Copyright '):
551                         return line
552         else:
553                 if not line.startswith('# Copyright '):
554                         return line
555
556         year = _unicode_encode(year)
557         line = _unicode_encode(line)
558
559         line = _copyright_re1.sub(br'\1-' + year + b' ', line)
560         line = _copyright_re2.sub(_copyright_repl(year), line)
561         if not is_bytes:
562                 line = _unicode_decode(line)
563         return line
564
565 def update_copyright(fn_path, year, pretend):
566         """
567         Check file for a Copyright statement, and update its year.  The
568         patterns used for replacing copyrights are taken from echangelog.
569         Only the first lines of each file that start with a hash ('#') are
570         considered, until a line is found that doesn't start with a hash.
571         Files are read and written in binary mode, so that this function
572         will work correctly with files encoded in any character set, as
573         long as the copyright statements consist of plain ASCII.
574         """
575
576         try:
577                 fn_hdl = io.open(_unicode_encode(fn_path,
578                         encoding=_encodings['fs'], errors='strict'),
579                         mode='rb')
580         except EnvironmentError:
581                 return
582
583         orig_header = []
584         new_header = []
585
586         for line in fn_hdl:
587                 line_strip = line.strip()
588                 orig_header.append(line)
589                 if not line_strip or line_strip[:1] != b'#':
590                         new_header.append(line)
591                         break
592
593                 line = _update_copyright_year(year, line)
594                 new_header.append(line)
595
596         difflines = 0
597         for line in difflib.unified_diff(
598                 [_unicode_decode(line) for line in orig_header],
599                 [_unicode_decode(line) for line in new_header],
600                         fromfile=fn_path, tofile=fn_path, n=0):
601                 util.writemsg_stdout(line, noiselevel=-1)
602                 difflines += 1
603         util.writemsg_stdout("\n", noiselevel=-1)
604
605         # unified diff has three lines to start with
606         if difflines > 3 and not pretend:
607                 # write new file with changed header
608                 f, fnnew_path = mkstemp()
609                 f = io.open(f, mode='wb')
610                 for line in new_header:
611                         f.write(line)
612                 for line in fn_hdl:
613                         f.write(line)
614                 f.close()
615                 try:
616                         fn_stat = os.stat(fn_path)
617                 except OSError:
618                         fn_stat = None
619
620                 shutil.move(fnnew_path, fn_path)
621
622                 if fn_stat is None:
623                         util.apply_permissions(fn_path, mode=0o644)
624                 else:
625                         util.apply_stat_permissions(fn_path, fn_stat)
626         fn_hdl.close()
627
628 def get_committer_name(env=None):
629         """Generate a committer string like echangelog does."""
630         if env is None:
631                 env = os.environ
632         if 'GENTOO_COMMITTER_NAME' in env and \
633                 'GENTOO_COMMITTER_EMAIL' in env:
634                 user = '%s <%s>' % (env['GENTOO_COMMITTER_NAME'],
635                         env['GENTOO_COMMITTER_EMAIL'])
636         elif 'GENTOO_AUTHOR_NAME' in env and \
637                         'GENTOO_AUTHOR_EMAIL' in env:
638                 user = '%s <%s>' % (env['GENTOO_AUTHOR_NAME'],
639                         env['GENTOO_AUTHOR_EMAIL'])
640         elif 'ECHANGELOG_USER' in env:
641                 user = env['ECHANGELOG_USER']
642         else:
643                 pwd_struct = pwd.getpwuid(os.getuid())
644                 gecos = pwd_struct.pw_gecos.split(',')[0]  # bug #80011
645                 user = '%s <%s@gentoo.org>' % (gecos, pwd_struct.pw_name)
646         return user
647
648 def UpdateChangeLog(pkgdir, user, msg, skel_path, category, package,
649         new=(), removed=(), changed=(), pretend=False):
650         """
651         Write an entry to an existing ChangeLog, or create a new one.
652         Updates copyright year on changed files, and updates the header of
653         ChangeLog with the contents of skel.ChangeLog.
654         """
655
656         if '<root@' in user:
657                 err = 'Please set ECHANGELOG_USER or run as non-root'
658                 logging.critical(err)
659                 return None
660
661         # ChangeLog times are in UTC
662         gmtime = time.gmtime()
663         year = time.strftime('%Y', gmtime)
664         date = time.strftime('%d %b %Y', gmtime)
665
666         # check modified files and the ChangeLog for copyright updates
667         # patches and diffs (identified by .patch and .diff) are excluded
668         for fn in chain(new, changed):
669                 if fn.endswith('.diff') or fn.endswith('.patch'):
670                         continue
671                 update_copyright(os.path.join(pkgdir, fn), year, pretend)
672
673         cl_path = os.path.join(pkgdir, 'ChangeLog')
674         clold_lines = []
675         clnew_lines = []
676         old_header_lines = []
677         header_lines = []
678
679         try:
680                 clold_file = io.open(_unicode_encode(cl_path,
681                         encoding=_encodings['fs'], errors='strict'),
682                         mode='r', encoding=_encodings['repo.content'], errors='replace')
683         except EnvironmentError:
684                 clold_file = None
685
686         clskel_file = None
687         if clold_file is None:
688                 # we will only need the ChangeLog skeleton if there is no
689                 # ChangeLog yet
690                 try:
691                         clskel_file = io.open(_unicode_encode(skel_path,
692                                 encoding=_encodings['fs'], errors='strict'),
693                                 mode='r', encoding=_encodings['repo.content'],
694                                 errors='replace')
695                 except EnvironmentError:
696                         pass
697
698         f, clnew_path = mkstemp()
699
700         # construct correct header first
701         try:
702                 if clold_file is not None:
703                         # retain header from old ChangeLog
704                         for line in clold_file:
705                                 line_strip =  line.strip()
706                                 if line_strip and line[:1] != "#":
707                                         clold_lines.append(line)
708                                         break
709                                 old_header_lines.append(line)
710                                 header_lines.append(_update_copyright_year(year, line))
711                                 if not line_strip:
712                                         break
713
714                 elif clskel_file is not None:
715                         # read skel.ChangeLog up to first empty line
716                         for line in clskel_file:
717                                 line_strip = line.strip()
718                                 if not line_strip:
719                                         break
720                                 line = line.replace('<CATEGORY>', category)
721                                 line = line.replace('<PACKAGE_NAME>', package)
722                                 line = _update_copyright_year(year, line)
723                                 header_lines.append(line)
724                         header_lines.append(_unicode_decode('\n'))
725                         clskel_file.close()
726
727                 # write new ChangeLog entry
728                 clnew_lines.extend(header_lines)
729                 newebuild = False
730                 for fn in new:
731                         if not fn.endswith('.ebuild'):
732                                 continue
733                         ebuild = fn.split(os.sep)[-1][0:-7] 
734                         clnew_lines.append(_unicode_decode('*%s (%s)\n' % (ebuild, date)))
735                         newebuild = True
736                 if newebuild:
737                         clnew_lines.append(_unicode_decode('\n'))
738                 trivial_files = ('ChangeLog', 'Manifest')
739                 display_new = ['+' + elem for elem in new
740                         if elem not in trivial_files]
741                 display_removed = ['-' + elem for elem in removed]
742                 display_changed = [elem for elem in changed
743                         if elem not in trivial_files]
744                 if not (display_new or display_removed or display_changed):
745                         # If there's nothing else to display, show one of the
746                         # trivial files.
747                         for fn in trivial_files:
748                                 if fn in new:
749                                         display_new = ['+' + fn]
750                                         break
751                                 elif fn in changed:
752                                         display_changed = [fn]
753                                         break
754
755                 mesg = '%s; %s %s:' % (date, user, ', '.join(chain(
756                         display_new, display_removed, display_changed)))
757                 for line in textwrap.wrap(mesg, 80, \
758                                 initial_indent='  ', subsequent_indent='  ', \
759                                 break_on_hyphens=False):
760                         clnew_lines.append(_unicode_decode('%s\n' % line))
761                 for line in textwrap.wrap(msg, 80, \
762                                 initial_indent='  ', subsequent_indent='  '):
763                         clnew_lines.append(_unicode_decode('%s\n' % line))
764                 clnew_lines.append(_unicode_decode('\n'))
765
766                 f = io.open(f, mode='w', encoding=_encodings['repo.content'],
767                         errors='backslashreplace')
768
769                 for line in clnew_lines:
770                         f.write(line)
771
772                 # append stuff from old ChangeLog
773                 if clold_file is not None:
774                         # clold_lines may contain a saved non-header line
775                         # that we want to write first.
776                         # Also, append this line to clnew_lines so that the
777                         # unified_diff call doesn't show it as removed.
778                         for line in clold_lines:
779                                 f.write(line)
780                                 clnew_lines.append(line)
781
782                         # Now prepend old_header_lines to clold_lines, for use
783                         # in the unified_diff call below.
784                         clold_lines = old_header_lines + clold_lines
785
786                         # ensure that there is no more than one blank
787                         # line after our new entry
788                         for line in clold_file:
789                                 if line.strip():
790                                         f.write(line)
791                                         break
792
793                         for line in clold_file:
794                                 f.write(line)
795                         clold_file.close()
796                 f.close()
797
798                 # show diff (do we want to keep on doing this, or only when
799                 # pretend?)
800                 for line in difflib.unified_diff(clold_lines, clnew_lines,
801                         fromfile=cl_path, tofile=cl_path, n=0):
802                         util.writemsg_stdout(line, noiselevel=-1)
803                 util.writemsg_stdout("\n", noiselevel=-1)
804
805                 if pretend:
806                         # remove what we've done
807                         os.remove(clnew_path)
808                 else:
809                         # rename to ChangeLog, and set permissions
810                         try:
811                                 clold_stat = os.stat(cl_path)
812                         except OSError:
813                                 clold_stat = None
814
815                         shutil.move(clnew_path, cl_path)
816
817                         if clold_stat is None:
818                                 util.apply_permissions(cl_path, mode=0o644)
819                         else:
820                                 util.apply_stat_permissions(cl_path, clold_stat)
821
822                 if clold_file is None:
823                         return True
824                 else:
825                         return False
826         except IOError as e:
827                 err = 'Repoman is unable to create/write to Changelog.new file: %s' % (e,)
828                 logging.critical(err)
829                 # try to remove if possible
830                 try:
831                         os.remove(clnew_path)
832                 except OSError:
833                         pass
834                 return None
835