Ask git about unmerged files
[stgit] / stgit / git.py
CommitLineData
41a6d859
CM
1"""Python GIT interface
2"""
3
4__copyright__ = """
5Copyright (C) 2005, Catalin Marinas <catalin.marinas@gmail.com>
6
7This program is free software; you can redistribute it and/or modify
8it under the terms of the GNU General Public License version 2 as
9published by the Free Software Foundation.
10
11This program is distributed in the hope that it will be useful,
12but WITHOUT ANY WARRANTY; without even the implied warranty of
13MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14GNU General Public License for more details.
15
16You should have received a copy of the GNU General Public License
17along with this program; if not, write to the Free Software
18Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
19"""
20
f0de3f92 21import sys, os, re, gitmergeonefile
d5ae2173 22from shutil import copyfile
41a6d859 23
87c93eab 24from stgit.exception import *
170f576b 25from stgit import basedir
41a6d859 26from stgit.utils import *
5e888f30 27from stgit.out import *
f0de3f92 28from stgit.run import *
b3bfa120 29from stgit.config import config
41a6d859
CM
30
31# git exception class
87c93eab 32class GitException(StgException):
41a6d859
CM
33 pass
34
f0de3f92
KH
35# When a subprocess has a problem, we want the exception to be a
36# subclass of GitException.
37class GitRunException(GitException):
38 pass
39class GRun(Run):
40 exc = GitRunException
1576d681
CM
41 def __init__(self, *cmd):
42 """Initialise the Run object and insert the 'git' command name.
43 """
44 Run.__init__(self, 'git', *cmd)
41a6d859 45
6c76a033
DK
46class GitConflictException(GitException):
47 def __init__(self, conflicts):
48 GitException.__init__(self)
49 self.conflicts = conflicts
50 def __str__(self):
51 return "%d conflicts" % len(self.conflicts)
52 def list(self):
53 out.info(*self.conflicts)
41a6d859 54
41a6d859
CM
55#
56# Classes
57#
9e3f506f
KH
58
59class Person:
60 """An author, committer, etc."""
61 def __init__(self, name = None, email = None, date = '',
62 desc = None):
5cd9e87f 63 self.name = self.email = self.date = None
9e3f506f
KH
64 if name or email or date:
65 assert not desc
66 self.name = name
67 self.email = email
68 self.date = date
69 elif desc:
70 assert not (name or email or date)
71 def parse_desc(s):
72 m = re.match(r'^(.+)<(.+)>(.*)$', s)
73 assert m
74 return [x.strip() or None for x in m.groups()]
75 self.name, self.email, self.date = parse_desc(desc)
76 def set_name(self, val):
77 if val:
78 self.name = val
79 def set_email(self, val):
80 if val:
81 self.email = val
82 def set_date(self, val):
83 if val:
84 self.date = val
85 def __str__(self):
86 if self.name and self.email:
87 return '%s <%s>' % (self.name, self.email)
88 else:
89 raise GitException, 'not enough identity data'
90
41a6d859
CM
91class Commit:
92 """Handle the commit objects
93 """
94 def __init__(self, id_hash):
95 self.__id_hash = id_hash
41a6d859 96
1576d681 97 lines = GRun('cat-file', 'commit', id_hash).output_lines()
26dba451
BL
98 for i in range(len(lines)):
99 line = lines[i]
f0de3f92
KH
100 if not line:
101 break # we've seen all the header fields
102 key, val = line.split(' ', 1)
103 if key == 'tree':
104 self.__tree = val
105 elif key == 'author':
106 self.__author = val
107 elif key == 'committer':
108 self.__committer = val
109 else:
110 pass # ignore other headers
111 self.__log = '\n'.join(lines[i+1:])
41a6d859
CM
112
113 def get_id_hash(self):
114 return self.__id_hash
115
116 def get_tree(self):
117 return self.__tree
118
119 def get_parent(self):
64354a2d
CM
120 parents = self.get_parents()
121 if parents:
122 return parents[0]
123 else:
124 return None
37a4d1bf
CM
125
126 def get_parents(self):
1576d681 127 return GRun('rev-list', '--parents', '--max-count=1', self.__id_hash
f0de3f92 128 ).output_one_line().split()[1:]
41a6d859
CM
129
130 def get_author(self):
131 return self.__author
132
133 def get_committer(self):
134 return self.__committer
135
37a4d1bf
CM
136 def get_log(self):
137 return self.__log
138
4d0ba818
KH
139 def __str__(self):
140 return self.get_id_hash()
141
8e29bcd2
CM
142# dictionary of Commit objects, used to avoid multiple calls to git
143__commits = dict()
41a6d859
CM
144
145#
146# Functions
147#
bae29ddd 148
8e29bcd2
CM
149def get_commit(id_hash):
150 """Commit objects factory. Save/look-up them in the __commits
151 dictionary
152 """
3237b6e4
CM
153 global __commits
154
8e29bcd2
CM
155 if id_hash in __commits:
156 return __commits[id_hash]
157 else:
158 commit = Commit(id_hash)
159 __commits[id_hash] = commit
160 return commit
161
41a6d859
CM
162def get_conflicts():
163 """Return the list of file conflicts
164 """
d4f2b3e9
DK
165 names = []
166 for line in GRun('ls-files', '-z', '--unmerged'
167 ).raw_output().split('\0')[:-1]:
168 stat, path = line.split('\t', 1)
169 # Look for entries in stage 2 (could equally well use 3)
170 if stat.endswith(' 2'):
171 names.append(path)
172 return names
41a6d859 173
2f830c0c
KH
174def exclude_files():
175 files = [os.path.join(basedir.get(), 'info', 'exclude')]
176 user_exclude = config.get('core.excludesfile')
177 if user_exclude:
178 files.append(user_exclude)
179 return files
180
d4356ac6
CM
181def ls_files(files, tree = None, full_name = True):
182 """Return the files known to GIT or raise an error otherwise. It also
183 converts the file to the full path relative the the .git directory.
184 """
185 if not files:
186 return []
187
188 args = []
189 if tree:
190 args.append('--with-tree=%s' % tree)
191 if full_name:
192 args.append('--full-name')
193 args.append('--')
194 args.extend(files)
195 try:
1576d681 196 return GRun('ls-files', '--error-unmatch', *args).output_lines()
d4356ac6 197 except GitRunException:
1576d681 198 # just hide the details of the 'git ls-files' command we use
d4356ac6
CM
199 raise GitException, \
200 'Some of the given paths are either missing or not known to GIT'
201
d436b1da 202def tree_status(files = None, tree_id = 'HEAD', unknown = False,
2ace36ab 203 noexclude = True, verbose = False, diff_flags = []):
70028538
DK
204 """Get the status of all changed files, or of a selected set of
205 files. Returns a list of pairs - (status, filename).
206
d4356ac6 207 If 'not files', it will check all files, and optionally all
70028538
DK
208 unknown files. If 'files' is a list, it will only check the files
209 in the list.
41a6d859 210 """
d4356ac6 211 assert not files or not unknown
70028538 212
27ac2b7e
KH
213 if verbose:
214 out.start('Checking for changes in the working directory')
b6c37f44 215
f8fb5747 216 refresh_index()
41a6d859
CM
217
218 cache_files = []
219
220 # unknown files
221 if unknown:
1576d681 222 cmd = ['ls-files', '-z', '--others', '--directory',
6d0d7ee6 223 '--no-empty-directory']
14c88aa0
DK
224 if not noexclude:
225 cmd += ['--exclude=%s' % s for s in
226 ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
227 cmd += ['--exclude-per-directory=.gitignore']
228 cmd += ['--exclude-from=%s' % fn
229 for fn in exclude_files()
230 if os.path.exists(fn)]
231
232 lines = GRun(*cmd).raw_output().split('\0')
6d0d7ee6 233 cache_files += [('?', line) for line in lines if line]
41a6d859
CM
234
235 # conflicted files
236 conflicts = get_conflicts()
70028538 237 cache_files += [('C', filename) for filename in conflicts
d4356ac6 238 if not files or filename in files]
ca66756b 239 reported_files = set(conflicts)
41a6d859 240
ca66756b 241 # files in the index
70028538 242 args = diff_flags + [tree_id]
d4356ac6 243 if files:
70028538 244 args += ['--'] + files
1576d681 245 for line in GRun('diff-index', *args).output_lines():
26dba451 246 fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
ca66756b 247 if fs[1] not in reported_files:
41a6d859 248 cache_files.append(fs)
ca66756b
CM
249 reported_files.add(fs[1])
250
251 # files in the index but changed on (or removed from) disk
252 args = list(diff_flags)
253 if files:
254 args += ['--'] + files
255 for line in GRun('diff-files', *args).output_lines():
256 fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
257 if fs[1] not in reported_files:
258 cache_files.append(fs)
259 reported_files.add(fs[1])
41a6d859 260
27ac2b7e
KH
261 if verbose:
262 out.done()
b6c37f44 263
41a6d859
CM
264 return cache_files
265
06848fab 266def local_changes(verbose = True):
41a6d859
CM
267 """Return true if there are local changes in the tree
268 """
d436b1da 269 return len(tree_status(verbose = verbose)) != 0
41a6d859 270
262d31dc
KH
271def get_heads():
272 heads = []
216a1524 273 hr = re.compile(r'^[0-9a-f]{40} refs/heads/(.+)$')
1576d681 274 for line in GRun('show-ref', '--heads').output_lines():
216a1524 275 m = hr.match(line)
262d31dc
KH
276 heads.append(m.group(1))
277 return heads
278
aa01a285
CM
279# HEAD value cached
280__head = None
281
41a6d859 282def get_head():
3097799d 283 """Verifies the HEAD and returns the SHA1 id that represents it
41a6d859 284 """
aa01a285
CM
285 global __head
286
287 if not __head:
288 __head = rev_parse('HEAD')
289 return __head
41a6d859 290
acf901c1
KH
291class DetachedHeadException(GitException):
292 def __init__(self):
293 GitException.__init__(self, 'Not on any branch')
294
41a6d859 295def get_head_file():
acf901c1
KH
296 """Return the name of the file pointed to by the HEAD symref.
297 Throw an exception if HEAD is detached."""
298 try:
299 return strip_prefix(
1576d681 300 'refs/heads/', GRun('symbolic-ref', '-q', 'HEAD'
acf901c1
KH
301 ).output_one_line())
302 except GitRunException:
303 raise DetachedHeadException()
41a6d859 304
b99a02b0
CL
305def set_head_file(ref):
306 """Resets HEAD to point to a new ref
307 """
24eede72
CM
308 # head cache flushing is needed since we might have a different value
309 # in the new head
310 __clear_head_cache()
f0de3f92 311 try:
1576d681 312 GRun('symbolic-ref', 'HEAD', 'refs/heads/%s' % ref).run()
f0de3f92 313 except GitRunException:
b99a02b0
CL
314 raise GitException, 'Could not set head to "%s"' % ref
315
262d31dc
KH
316def set_ref(ref, val):
317 """Point ref at a new commit object."""
f0de3f92 318 try:
1576d681 319 GRun('update-ref', ref, val).run()
f0de3f92 320 except GitRunException:
262d31dc
KH
321 raise GitException, 'Could not update %s to "%s".' % (ref, val)
322
e71d766b 323def set_branch(branch, val):
262d31dc 324 set_ref('refs/heads/%s' % branch, val)
e71d766b 325
41a6d859
CM
326def __set_head(val):
327 """Sets the HEAD value
328 """
aa01a285
CM
329 global __head
330
ba1a4550 331 if not __head or __head != val:
262d31dc 332 set_ref('HEAD', val)
ba1a4550
CM
333 __head = val
334
510d1442
CM
335 # only allow SHA1 hashes
336 assert(len(__head) == 40)
337
ba1a4550
CM
338def __clear_head_cache():
339 """Sets the __head to None so that a re-read is forced
340 """
341 global __head
342
343 __head = None
41a6d859 344
f8fb5747
CL
345def refresh_index():
346 """Refresh index with stat() information from the working directory.
347 """
1576d681 348 GRun('update-index', '-q', '--unmerged', '--refresh').run()
f8fb5747 349
d1eb3f85 350def rev_parse(git_id):
3097799d 351 """Parse the string and return a verified SHA1 id
d1eb3f85 352 """
84fcbc3b 353 try:
1576d681 354 return GRun('rev-parse', '--verify', git_id
d9d460a1 355 ).discard_stderr().output_one_line()
f0de3f92 356 except GitRunException:
84fcbc3b 357 raise GitException, 'Unknown revision: %s' % git_id
d1eb3f85 358
262d31dc
KH
359def ref_exists(ref):
360 try:
361 rev_parse(ref)
362 return True
363 except GitException:
364 return False
365
2b4a8aa5 366def branch_exists(branch):
262d31dc 367 return ref_exists('refs/heads/%s' % branch)
2b4a8aa5
CL
368
369def create_branch(new_branch, tree_id = None):
370 """Create a new branch in the git repository
371 """