chiark / gitweb /
squash: Make commit message editing more convenient
[stgit] / stgit / utils.py
1 """Common utility functions
2 """
3
4 import errno, os, os.path, re, sys
5 from stgit.exception import *
6 from stgit.config import config
7 from stgit.out import *
8
9 __copyright__ = """
10 Copyright (C) 2005, Catalin Marinas <catalin.marinas@gmail.com>
11
12 This program is free software; you can redistribute it and/or modify
13 it under the terms of the GNU General Public License version 2 as
14 published by the Free Software Foundation.
15
16 This program is distributed in the hope that it will be useful,
17 but WITHOUT ANY WARRANTY; without even the implied warranty of
18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
19 GNU General Public License for more details.
20
21 You should have received a copy of the GNU General Public License
22 along with this program; if not, write to the Free Software
23 Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
24 """
25
26 def mkdir_file(filename, mode):
27     """Opens filename with the given mode, creating the directory it's
28     in if it doesn't already exist."""
29     create_dirs(os.path.dirname(filename))
30     return file(filename, mode)
31
32 def read_strings(filename):
33     """Reads the lines from a file
34     """
35     f = file(filename, 'r')
36     lines = [line.strip() for line in f.readlines()]
37     f.close()
38     return lines
39
40 def read_string(filename, multiline = False):
41     """Reads the first line from a file
42     """
43     f = file(filename, 'r')
44     if multiline:
45         result = f.read()
46     else:
47         result = f.readline().strip()
48     f.close()
49     return result
50
51 def write_strings(filename, lines):
52     """Write 'lines' sequence to file
53     """
54     f = file(filename, 'w+')
55     f.writelines([line + '\n' for line in lines])
56     f.close()
57
58 def write_string(filename, line, multiline = False):
59     """Writes 'line' to file and truncates it
60     """
61     f = mkdir_file(filename, 'w+')
62     if multiline:
63         f.write(line)
64     else:
65         print >> f, line
66     f.close()
67
68 def append_strings(filename, lines):
69     """Appends 'lines' sequence to file
70     """
71     f = mkdir_file(filename, 'a+')
72     for line in lines:
73         print >> f, line
74     f.close()
75
76 def append_string(filename, line):
77     """Appends 'line' to file
78     """
79     f = mkdir_file(filename, 'a+')
80     print >> f, line
81     f.close()
82
83 def insert_string(filename, line):
84     """Inserts 'line' at the beginning of the file
85     """
86     f = mkdir_file(filename, 'r+')
87     lines = f.readlines()
88     f.seek(0); f.truncate()
89     print >> f, line
90     f.writelines(lines)
91     f.close()
92
93 def create_empty_file(name):
94     """Creates an empty file
95     """
96     mkdir_file(name, 'w+').close()
97
98 def list_files_and_dirs(path):
99     """Return the sets of filenames and directory names in a
100     directory."""
101     files, dirs = [], []
102     for fd in os.listdir(path):
103         full_fd = os.path.join(path, fd)
104         if os.path.isfile(full_fd):
105             files.append(fd)
106         elif os.path.isdir(full_fd):
107             dirs.append(fd)
108     return files, dirs
109
110 def walk_tree(basedir):
111     """Starting in the given directory, iterate through all its
112     subdirectories. For each subdirectory, yield the name of the
113     subdirectory (relative to the base directory), the list of
114     filenames in the subdirectory, and the list of directory names in
115     the subdirectory."""
116     subdirs = ['']
117     while subdirs:
118         subdir = subdirs.pop()
119         files, dirs = list_files_and_dirs(os.path.join(basedir, subdir))
120         for d in dirs:
121             subdirs.append(os.path.join(subdir, d))
122         yield subdir, files, dirs
123
124 def strip_prefix(prefix, string):
125     """Return string, without the prefix. Blow up if string doesn't
126     start with prefix."""
127     assert string.startswith(prefix)
128     return string[len(prefix):]
129
130 def strip_suffix(suffix, string):
131     """Return string, without the suffix. Blow up if string doesn't
132     end with suffix."""
133     assert string.endswith(suffix)
134     return string[:-len(suffix)]
135
136 def remove_file_and_dirs(basedir, file):
137     """Remove join(basedir, file), and then remove the directory it
138     was in if empty, and try the same with its parent, until we find a
139     nonempty directory or reach basedir."""
140     os.remove(os.path.join(basedir, file))
141     try:
142         os.removedirs(os.path.join(basedir, os.path.dirname(file)))
143     except OSError:
144         # file's parent dir may not be empty after removal
145         pass
146
147 def create_dirs(directory):
148     """Create the given directory, if the path doesn't already exist."""
149     if directory and not os.path.isdir(directory):
150         create_dirs(os.path.dirname(directory))
151         try:
152             os.mkdir(directory)
153         except OSError, e:
154             if e.errno != errno.EEXIST:
155                 raise e
156
157 def rename(basedir, file1, file2):
158     """Rename join(basedir, file1) to join(basedir, file2), not
159     leaving any empty directories behind and creating any directories
160     necessary."""
161     full_file2 = os.path.join(basedir, file2)
162     create_dirs(os.path.dirname(full_file2))
163     os.rename(os.path.join(basedir, file1), full_file2)
164     try:
165         os.removedirs(os.path.join(basedir, os.path.dirname(file1)))
166     except OSError:
167         # file1's parent dir may not be empty after move
168         pass
169
170 class EditorException(StgException):
171     pass
172
173 def get_editor():
174     for editor in [os.environ.get('GIT_EDITOR'),
175                    config.get('stgit.editor'), # legacy
176                    config.get('core.editor'),
177                    os.environ.get('VISUAL'),
178                    os.environ.get('EDITOR'),
179                    'vi']:
180         if editor:
181             return editor
182
183 def call_editor(filename):
184     """Run the editor on the specified filename."""
185     cmd = '%s %s' % (get_editor(), filename)
186     out.start('Invoking the editor: "%s"' % cmd)
187     err = os.system(cmd)
188     if err:
189         raise EditorException, 'editor failed, exit code: %d' % err
190     out.done()
191
192 def edit_string(s, filename):
193     f = file(filename, 'w')
194     f.write(s)
195     f.close()
196     call_editor(filename)
197     f = file(filename)
198     s = f.read()
199     f.close()
200     os.remove(filename)
201     return s
202
203 def append_comment(s, comment, separator = '---'):
204     return ('%s\n\n%s\nEverything following the line with "%s" will be'
205             ' ignored\n\n%s' % (s, separator, separator, comment))
206
207 def strip_comment(s, separator = '---'):
208     try:
209         return s[:s.index('\n%s\n' % separator)]
210     except ValueError:
211         return s
212
213 def find_patch_name(patchname, unacceptable):
214     """Find a patch name which is acceptable."""
215     if unacceptable(patchname):
216         suffix = 0
217         while unacceptable('%s-%d' % (patchname, suffix)):
218             suffix += 1
219         patchname = '%s-%d' % (patchname, suffix)
220     return patchname
221
222 def patch_name_from_msg(msg):
223     """Return a string to be used as a patch name. This is generated
224     from the top line of the string passed as argument."""
225     if not msg:
226         return None
227
228     name_len = config.getint('stgit.namelength')
229     if not name_len:
230         name_len = 30
231
232     subject_line = msg.split('\n', 1)[0].lstrip().lower()
233     return re.sub('[\W]+', '-', subject_line).strip('-')[:name_len]
234
235 def make_patch_name(msg, unacceptable, default_name = 'patch'):
236     """Return a patch name generated from the given commit message,
237     guaranteed to make unacceptable(name) be false. If the commit
238     message is empty, base the name on default_name instead."""
239     patchname = patch_name_from_msg(msg)
240     if not patchname:
241         patchname = default_name
242     return find_patch_name(patchname, unacceptable)
243
244 # any and all functions are builtin in Python 2.5 and higher, but not
245 # in 2.4.
246 if not 'any' in dir(__builtins__):
247     def any(bools):
248         for b in bools:
249             if b:
250                 return True
251         return False
252 if not 'all' in dir(__builtins__):
253     def all(bools):
254         for b in bools:
255             if not b:
256                 return False
257         return True
258
259 def add_sign_line(desc, sign_str, name, email):
260     if not sign_str:
261         return desc
262     sign_str = '%s: %s <%s>' % (sign_str, name, email)
263     if sign_str in desc:
264         return desc
265     desc = desc.rstrip()
266     if not any(s in desc for s in ['\nSigned-off-by:', '\nAcked-by:']):
267         desc = desc + '\n'
268     return '%s\n%s\n' % (desc, sign_str)
269
270 def parse_name_email(address):
271     """Return a tuple consisting of the name and email parsed from a
272     standard 'name <email>' or 'email (name)' string."""
273     address = re.sub(r'[\\"]', r'\\\g<0>', address)
274     str_list = re.findall(r'^(.*)\s*<(.*)>\s*$', address)
275     if not str_list:
276         str_list = re.findall(r'^(.*)\s*\((.*)\)\s*$', address)
277         if not str_list:
278             return None
279         return (str_list[0][1], str_list[0][0])
280     return str_list[0]
281
282 def parse_name_email_date(address):
283     """Return a tuple consisting of the name, email and date parsed
284     from a 'name <email> date' string."""
285     address = re.sub(r'[\\"]', r'\\\g<0>', address)
286     str_list = re.findall('^(.*)\s*<(.*)>\s*(.*)\s*$', address)
287     if not str_list:
288         return None
289     return str_list[0]
290
291 # Exit codes.
292 STGIT_SUCCESS = 0        # everything's OK
293 STGIT_GENERAL_ERROR = 1  # seems to be non-command-specific error
294 STGIT_COMMAND_ERROR = 2  # seems to be a command that failed
295 STGIT_CONFLICT = 3       # merge conflict, otherwise OK
296 STGIT_BUG_ERROR = 4      # a bug in StGit
297
298 def add_dict(d1, d2):
299     """Return a new dict with the contents of both d1 and d2. In case of
300     conflicting mappings, d2 takes precedence."""
301     d = dict(d1)
302     d.update(d2)
303     return d