chiark / gitweb /
[AN] status for newly added files
[stgit] / stgit / git.py
1 """Python GIT interface
2 """
3
4 __copyright__ = """
5 Copyright (C) 2005, Catalin Marinas <catalin.marinas@gmail.com>
6
7 This program is free software; you can redistribute it and/or modify
8 it under the terms of the GNU General Public License version 2 as
9 published by the Free Software Foundation.
10
11 This program is distributed in the hope that it will be useful,
12 but WITHOUT ANY WARRANTY; without even the implied warranty of
13 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 GNU General Public License for more details.
15
16 You should have received a copy of the GNU General Public License
17 along with this program; if not, write to the Free Software
18 Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
19 """
20
21 import sys, os, glob, popen2
22
23 from stgit.utils import *
24
25 # git exception class
26 class GitException(Exception):
27     pass
28
29
30 # Different start-up variables read from the environment
31 if 'GIT_DIR' in os.environ:
32     base_dir = os.environ['GIT_DIR']
33 else:
34     base_dir = '.git'
35
36 head_link = os.path.join(base_dir, 'HEAD')
37
38 #
39 # Classes
40 #
41 class Commit:
42     """Handle the commit objects
43     """
44     def __init__(self, id_hash):
45         self.__id_hash = id_hash
46
47         lines = _output_lines('git-cat-file commit %s' % id_hash)
48         for i in range(len(lines)):
49             line = lines[i]
50             if line == '\n':
51                 break
52             field = line.strip().split(' ', 1)
53             if field[0] == 'tree':
54                 self.__tree = field[1]
55             elif field[0] == 'parent':
56                 self.__parent = field[1]
57             if field[0] == 'author':
58                 self.__author = field[1]
59             if field[0] == 'comitter':
60                 self.__committer = field[1]
61         self.__log = ''.join(lines[i:])
62
63     def get_id_hash(self):
64         return self.__id_hash
65
66     def get_tree(self):
67         return self.__tree
68
69     def get_parent(self):
70         return self.__parent
71
72     def get_author(self):
73         return self.__author
74
75     def get_committer(self):
76         return self.__committer
77
78
79 #
80 # Functions
81 #
82 def get_conflicts():
83     """Return the list of file conflicts
84     """
85     conflicts_file = os.path.join(base_dir, 'conflicts')
86     if os.path.isfile(conflicts_file):
87         f = file(conflicts_file)
88         names = [line.strip() for line in f.readlines()]
89         f.close()
90         return names
91     else:
92         return None
93
94 def _output(cmd):
95     p=popen2.Popen3(cmd)
96     string = p.fromchild.read()
97     if p.wait():
98         raise GitException, '%s failed' % str(cmd)
99     return string
100
101 def _output_one_line(cmd):
102     p=popen2.Popen3(cmd)
103     string = p.fromchild.readline().strip()
104     if p.wait():
105         raise GitException, '%s failed' % str(cmd)
106     return string
107
108 def _output_lines(cmd):
109     p=popen2.Popen3(cmd)
110     lines = p.fromchild.readlines()
111     if p.wait():
112         raise GitException, '%s failed' % str(cmd)
113     return lines
114
115 def __run(cmd, args=None):
116     """__run: runs cmd using spawnvp.
117
118     Runs cmd using spawnvp.  The shell is avoided so it won't mess up
119     our arguments.  If args is very large, the command is run multiple
120     times; args is split xargs style: cmd is passed on each
121     invocation.  Unlike xargs, returns immediately if any non-zero
122     return code is received.  
123     """
124     
125     args_l=cmd.split()
126     if args is None:
127         args = []
128     for i in range(0, len(args)+1, 100):
129         r=os.spawnvp(os.P_WAIT, args_l[0], args_l + args[i:min(i+100, len(args))])
130     if r:
131         return r
132     return 0
133
134 def __check_base_dir():
135     return os.path.isdir(base_dir)
136
137 def __tree_status(files = [], tree_id = 'HEAD', unknown = False):
138     """Returns a list of pairs - [status, filename]
139     """
140     os.system('git-update-cache --refresh > /dev/null')
141
142     cache_files = []
143
144     # unknown files
145     if unknown:
146         exclude_file = os.path.join(base_dir, 'exclude')
147         extra_exclude = []
148         if os.path.exists(exclude_file):
149             extra_exclude.append('--exclude-from=%s' % exclude_file)
150         lines = _output_lines(['git-ls-files', '--others',
151                         '--exclude=*.[ao]', '--exclude=.*'
152                         '--exclude=TAGS', '--exclude=tags', '--exclude=*~',
153                         '--exclude=#*'] + extra_exclude)
154         cache_files += [('?', line.strip()) for line in lines]
155
156     # conflicted files
157     conflicts = get_conflicts()
158     if not conflicts:
159         conflicts = []
160     cache_files += [('C', filename) for filename in conflicts]
161
162     # the rest
163     for line in _output_lines(['git-diff-cache', '-r', tree_id] + files):
164         fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
165         if fs[1] not in conflicts:
166             cache_files.append(fs)
167
168     return cache_files
169
170 def local_changes():
171     """Return true if there are local changes in the tree
172     """
173     return len(__tree_status()) != 0
174
175 def get_head():
176     """Returns a string representing the HEAD
177     """
178     return read_string(head_link)
179
180 def get_head_file():
181     """Returns the name of the file pointed to by the HEAD link
182     """
183     # valid link
184     if os.path.islink(head_link) and os.path.isfile(head_link):
185         return os.path.basename(os.readlink(head_link))
186     else:
187         raise GitException, 'Invalid .git/HEAD link. Git tree not initialised?'
188
189 def __set_head(val):
190     """Sets the HEAD value
191     """
192     write_string(head_link, val)
193
194 def add(names):
195     """Add the files or recursively add the directory contents
196     """
197     # generate the file list
198     files = []
199     for i in names:
200         if not os.path.exists(i):
201             raise GitException, 'Unknown file or directory: %s' % i
202
203         if os.path.isdir(i):
204             # recursive search. We only add files
205             for root, dirs, local_files in os.walk(i):
206                 for name in [os.path.join(root, f) for f in local_files]:
207                     if os.path.isfile(name):
208                         files.append(os.path.normpath(name))
209         elif os.path.isfile(i):
210             files.append(os.path.normpath(i))
211         else:
212             raise GitException, '%s is not a file or directory' % i
213
214     if files:
215         if __run('git-update-cache --add --', files):
216             raise GitException, 'Unable to add file'
217
218 def rm(files, force = False):
219     """Remove a file from the repository
220     """
221     if force:
222         git_opt = '--force-remove'
223     else:
224         git_opt = '--remove'
225
226     if not force:
227         for f in files:
228             if os.path.exists(f):
229                 raise GitException, '%s exists. Remove it first' %f
230         if files:
231             __run('git-update-cache --remove --', files)
232     else:
233         if files:
234             __run('git-update-cache --force-remove --', files)
235
236 def update_cache(files):
237     """Update the cache information for the given files
238     """
239     files_here = []
240     files_gone = []
241
242     for f in files:
243         if os.path.exists(f):
244             files_here.append(f)
245         else:
246             files_gone.append(f)
247
248     if files_here:
249         __run('git-update-cache --', files_here)
250     if files_gone:
251         __run('git-update-cache --remove --', files_gone)
252
253 def commit(message, files = [], parents = [], allowempty = False,
254            author_name = None, author_email = None, author_date = None,
255            committer_name = None, committer_email = None):
256     """Commit the current tree to repository
257     """
258     first = (parents == [])
259
260     # Get the tree status
261     if not first:
262         cache_files = __tree_status(files)
263
264     if not first and len(cache_files) == 0 and not allowempty:
265         raise GitException, 'No changes to commit'
266
267     # check for unresolved conflicts
268     if not first and len(filter(lambda x: x[0] not in ['M', 'N', 'D'],
269                                 cache_files)) != 0:
270         raise GitException, 'Commit failed: unresolved conflicts'
271
272     # get the commit message
273     f = file('.commitmsg', 'w+')
274     if message[-1] == '\n':
275         f.write(message)
276     else:
277         print >> f, message
278     f.close()
279
280     # update the cache
281     if not first:
282         add_files=[]
283         rm_files=[]
284         m_files=[]
285         for f in cache_files:
286             if f[0] == 'N':
287                 add_files.append(f[1])
288             elif f[0] == 'D':
289                 rm_files.append(f[1])
290             else:
291                 m_files.append(f[1])
292
293     if add_files:
294         if __run('git-update-cache --add --', add_files):
295             raise GitException, 'Failed git-update-cache --add'
296     if rm_files:
297         if __run('git-update-cache --force-remove --', rm_files):
298             raise GitException, 'Failed git-update-cache --rm'
299     if m_files:
300         if __run('git-update-cache --', m_files):
301             raise GitException, 'Failed git-update-cache'
302
303     # write the index to repository
304     tree_id = _output_one_line('git-write-tree')
305
306     # the commit
307     cmd = ''
308     if author_name:
309         cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
310     if author_email:
311         cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
312     if author_date:
313         cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
314     if committer_name:
315         cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
316     if committer_email:
317         cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
318     cmd += 'git-commit-tree %s' % tree_id
319
320     # get the parents
321     for p in parents:
322         cmd += ' -p %s' % p
323
324     cmd += ' < .commitmsg'
325
326     commit_id = _output_one_line(cmd)
327     __set_head(commit_id)
328     os.remove('.commitmsg')
329
330     return commit_id
331
332 def merge(base, head1, head2):
333     """Perform a 3-way merge between base, head1 and head2 into the
334     local tree
335     """
336     if __run('git-read-tree -u -m', [base, head1, head2]) != 0:
337         raise GitException, 'git-read-tree failed (local changes maybe?)'
338
339     # this can fail if there are conflicts
340     if os.system('git-merge-cache -o gitmergeonefile.py -a') != 0:
341         raise GitException, 'git-merge-cache failed (possible conflicts)'
342
343     # this should not fail
344     if os.system('git-checkout-cache -f -a') != 0:
345         raise GitException, 'Failed git-checkout-cache'
346
347 def status(files = [], modified = False, new = False, deleted = False,
348            conflict = False, unknown = False):
349     """Show the tree status
350     """
351     cache_files = __tree_status(files, unknown = True)
352     all = not (modified or new or deleted or conflict or unknown)
353
354     if not all:
355         filestat = []
356         if modified:
357             filestat.append('M')
358         if new:
359             filestat.append('A')
360             filestat.append('N')
361         if deleted:
362             filestat.append('D')
363         if conflict:
364             filestat.append('C')
365         if unknown:
366             filestat.append('?')
367         cache_files = filter(lambda x: x[0] in filestat, cache_files)
368
369     for fs in cache_files:
370         if all:
371             print '%s %s' % (fs[0], fs[1])
372         else:
373             print '%s' % fs[1]
374
375 def diff(files = [], rev1 = 'HEAD', rev2 = None, out_fd = None):
376     """Show the diff between rev1 and rev2
377     """
378     os.system('git-update-cache --refresh > /dev/null')
379
380     if rev2:
381         diff_str = _output(['git-diff-tree', '-p', rev1, rev2] + files)
382     else:
383         diff_str = _output(['git-diff-cache', '-p', rev1] + files)
384
385     if out_fd:
386         out_fd.write(diff_str)
387     else:
388         return diff_str
389
390 def diffstat(files = [], rev1 = 'HEAD', rev2 = None):
391     """Return the diffstat between rev1 and rev2
392     """
393
394     os.system('git-update-cache --refresh > /dev/null')
395     p=popen2.Popen3('git-apply --stat')
396     diff(files, rev1, rev2, p.tochild)
397     p.tochild.close()
398     str = p.fromchild.read().rstrip()
399     if p.wait():
400         raise GitException, 'git.diffstat failed'
401     return str
402
403 def files(rev1, rev2):
404     """Return the files modified between rev1 and rev2
405     """
406     os.system('git-update-cache --refresh > /dev/null')
407
408     str = ''
409     for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
410         str += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
411
412     return str.rstrip()
413
414 def checkout(files = [], force = False):
415     """Check out the given or all files
416     """
417     git_flags = 'git-checkout-cache -q -u'
418     if force:
419         git_flags += ' -f'
420     if len(files) == 0:
421         git_flags += ' -a'
422     else:
423         git_flags += ' --'
424
425     if __run(git_flags, files) != 0:
426         raise GitException, 'Failed git-checkout-cache'
427
428 def switch(tree_id):
429     """Switch the tree to the given id
430     """
431     to_delete = filter(lambda x: x[0] == 'N', __tree_status(tree_id = tree_id))
432
433     if __run('git-read-tree -m', [tree_id]) != 0:
434         raise GitException, 'Failed git-read-tree -m %s' % tree_id
435
436     checkout(force = True)
437     __set_head(tree_id)
438
439     # checkout doesn't remove files
440     for fs in to_delete:
441         os.remove(fs[1])
442
443 def fetch(location, head = None, tag = None):
444     """Fetch changes from the remote repository. At the moment, just
445     use the 'git fetch' scripts
446     """
447     args = [location]
448     if head:
449         args += [head]
450     elif tag:
451         args += ['tag', tag]
452
453     if __run('git fetch', args) != 0:
454         raise GitException, 'Failed "git fetch %s"' % location
455
456     return read_string(os.path.join(base_dir, 'FETCH_HEAD'))