chiark / gitweb /
ebbff64953d6f153f22100179a22f9c33b71ff05
[stgit] / stgit / main.py
1 """Basic quilt-like functionality
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
22 from optparse import OptionParser, make_option
23
24 from stgit.utils import *
25 from stgit import stack, git
26 from stgit.version import version
27 from stgit.config import config
28
29
30 # Main exception class
31 class MainException(Exception):
32     pass
33
34
35 # Utility functions
36 def __git_id(string):
37     """Return the GIT id
38     """
39     if not string:
40         return None
41     
42     string_list = string.split('/')
43
44     if len(string_list) == 1:
45         patch_name = None
46         git_id = string_list[0]
47
48         if git_id == 'HEAD':
49             return git.get_head()
50         if git_id == 'base':
51             return read_string(crt_series.get_base_file())
52
53         for path in [os.path.join(git.base_dir, 'refs', 'heads'),
54                      os.path.join(git.base_dir, 'refs', 'tags')]:
55             id_file = os.path.join(path, git_id)
56             if os.path.isfile(id_file):
57                 return read_string(id_file)
58     elif len(string_list) == 2:
59         patch_name = string_list[0]
60         if patch_name == '':
61             patch_name = crt_series.get_current()
62         git_id = string_list[1]
63
64         if not patch_name:
65             raise MainException, 'No patches applied'
66         elif not (patch_name in crt_series.get_applied()
67                 + crt_series.get_unapplied()):
68             raise MainException, 'Unknown patch "%s"' % patch_name
69
70         if git_id == 'bottom':
71             return crt_series.get_patch(patch_name).get_bottom()
72         if git_id == 'top':
73             return crt_series.get_patch(patch_name).get_top()
74
75     raise MainException, 'Unknown id: %s' % string
76
77 def __check_local_changes():
78     if git.local_changes():
79         raise MainException, \
80               'local changes in the tree. Use "refresh" to commit them'
81
82 def __check_head_top_equal():
83     if not crt_series.head_top_equal():
84         raise MainException, \
85               'HEAD and top are not the same. You probably committed\n' \
86               '  changes to the tree ouside of StGIT. If you know what you\n' \
87               '  are doing, use the "refresh -f" command'
88
89 def __check_conflicts():
90     if os.path.exists(os.path.join(git.base_dir, 'conflicts')):
91         raise MainException, 'Unsolved conflicts. Please resolve them first'
92
93 def __print_crt_patch():
94     patch = crt_series.get_current()
95     if patch:
96         print 'Now at patch "%s"' % patch
97     else:
98         print 'No patches applied'
99
100
101 #
102 # Command functions
103 #
104 class Command:
105     """This class is used to store the command details
106     """
107     def __init__(self, func, help, usage, option_list):
108         self.func = func
109         self.help = help
110         self.usage = usage
111         self.option_list = option_list
112
113
114 def init(parser, options, args):
115     """Performs the repository initialisation
116     """
117     if len(args) != 0:
118         parser.error('incorrect number of arguments')
119
120     crt_series.init()
121
122 init_cmd = \
123          Command(init,
124                  'initialise the tree for use with StGIT',
125                  '%prog',
126                  [])
127
128
129 def add(parser, options, args):
130     """Add files or directories to the repository
131     """
132     if len(args) < 1:
133         parser.error('incorrect number of arguments')
134
135     git.add(args)
136
137 add_cmd = \
138         Command(add,
139                 'add files or directories to the repository',
140                 '%prog <files/dirs...>',
141                 [])
142
143
144 def rm(parser, options, args):
145     """Remove files from the repository
146     """
147     if len(args) < 1:
148         parser.error('incorrect number of arguments')
149
150     git.rm(args, options.force)
151
152 rm_cmd = \
153        Command(rm,
154                'remove files from the repository',
155                '%prog [options] <files...>',
156                [make_option('-f', '--force',
157                             help = 'force removing even if the file exists',
158                             action = 'store_true')])
159
160
161 def status(parser, options, args):
162     """Show the tree status
163     """
164     git.status(args, options.modified, options.new, options.deleted,
165                options.conflict, options.unknown)
166
167 status_cmd = \
168            Command(status,
169                    'show the tree status',
170                    '%prog [options] [<files...>]',
171                    [make_option('-m', '--modified',
172                                 help = 'show modified files only',
173                                 action = 'store_true'),
174                     make_option('-n', '--new',
175                                 help = 'show new files only',
176                                 action = 'store_true'),
177                     make_option('-d', '--deleted',
178                                 help = 'show deleted files only',
179                                 action = 'store_true'),
180                     make_option('-c', '--conflict',
181                                 help = 'show conflict files only',
182                                 action = 'store_true'),
183                     make_option('-u', '--unknown',
184                                 help = 'show unknown files only',
185                                 action = 'store_true')])
186
187
188 def diff(parser, options, args):
189     """Show the tree diff
190     """
191     if options.revs:
192         rev_list = options.revs.split(':')
193         rev_list_len = len(rev_list)
194         if rev_list_len == 1:
195             if rev_list[0][-1] == '/':
196                 # the whole patch
197                 rev1 = rev_list[0] + 'bottom'
198                 rev2 = rev_list[0] + 'top'
199             else:
200                 rev1 = rev_list[0]
201                 rev2 = None
202         elif rev_list_len == 2:
203             rev1 = rev_list[0]
204             rev2 = rev_list[1]
205             if rev2 == '':
206                 rev2 = 'HEAD'
207         else:
208             parser.error('incorrect parameters to -r')
209     else:
210         rev1 = 'HEAD'
211         rev2 = None
212
213     if options.stat:
214         print git.diffstat(args, __git_id(rev1), __git_id(rev2))
215     else:
216         git.diff(args, __git_id(rev1), __git_id(rev2))
217
218 diff_cmd = \
219            Command(diff,
220                    'show the tree diff',
221                    '%prog [options] [<files...>]\n\n'
222                    'The revision format is "([patch]/[bottom | top]) | <tree-ish>"',
223                    [make_option('-r', metavar = 'rev1[:[rev2]]', dest = 'revs',
224                                 help = 'show the diff between revisions'),
225                     make_option('-s', '--stat',
226                                 help = 'show the stat instead of the diff',
227                                 action = 'store_true')])
228
229
230 def files(parser, options, args):
231     """Show the files modified by a patch (or the current patch)
232     """
233     if len(args) == 0:
234         patch = ''
235     elif len(args) == 1:
236         patch = args[0]
237     else:
238         parser.error('incorrect number of arguments')
239
240     rev1 = __git_id('%s/bottom' % patch)
241     rev2 = __git_id('%s/top' % patch)
242
243     if options.stat:
244         print git.diffstat(rev1 = rev1, rev2 = rev2)
245     else:
246         print git.files(rev1, rev2)
247
248 files_cmd = \
249           Command(files,
250                   'show the files modified by a patch (or the current patch)',
251                   '%prog [options] [<patch>]',
252                   [make_option('-s', '--stat',
253                                help = 'show the diff stat',
254                                action = 'store_true')])
255
256
257 def refresh(parser, options, args):
258     if len(args) != 0:
259         parser.error('incorrect number of arguments')
260
261     if config.has_option('stgit', 'autoresolved'):
262         autoresolved = config.get('stgit', 'autoresolved')
263     else:
264         autoresolved = 'no'
265
266     if autoresolved != 'yes':
267         __check_conflicts()
268
269     patch = crt_series.get_current()
270     if not patch:
271         raise MainException, 'No patches applied'
272
273     if not options.force:
274         __check_head_top_equal()
275
276     if git.local_changes() \
277            or not crt_series.head_top_equal() \
278            or options.edit or options.message \
279            or options.authname or options.authemail or options.authdate \
280            or options.commname or options.commemail:
281         print 'Refreshing patch "%s"...' % patch,
282         sys.stdout.flush()
283
284         if autoresolved == 'yes':
285             __resolved_all()
286         crt_series.refresh_patch(message = options.message,
287                                  edit = options.edit,
288                                  author_name = options.authname,
289                                  author_email = options.authemail,
290                                  author_date = options.authdate,
291                                  committer_name = options.commname,
292                                  committer_email = options.commemail)
293
294         print 'done'
295     else:
296         print 'Patch "%s" is already up to date' % patch
297
298 refresh_cmd = \
299             Command(refresh,
300                     'generate a new commit for the current patch',
301                     '%prog [options]',
302                     [make_option('-f', '--force',
303                                  help = 'force the refresh even if HEAD and '\
304                                  'top differ',
305                                  action = 'store_true'),
306                      make_option('-e', '--edit',
307                                  help = 'invoke an editor for the patch '\
308                                  'description',
309                                  action = 'store_true'),
310                      make_option('-m', '--message',
311                                  help = 'use MESSAGE as the patch ' \
312                                  'description'),
313                      make_option('--authname',
314                                  help = 'use AUTHNAME as the author name'),
315                      make_option('--authemail',
316                                  help = 'use AUTHEMAIL as the author e-mail'),
317                      make_option('--authdate',
318                                  help = 'use AUTHDATE as the author date'),
319                      make_option('--commname',
320                                  help = 'use COMMNAME as the committer name'),
321                      make_option('--commemail',
322                                  help = 'use COMMEMAIL as the committer ' \
323                                  'e-mail')])
324
325
326 def new(parser, options, args):
327     """Creates a new patch
328     """
329     if len(args) != 1:
330         parser.error('incorrect number of arguments')
331
332     __check_local_changes()
333     __check_conflicts()
334     __check_head_top_equal()
335
336     crt_series.new_patch(args[0], message = options.message,
337                          author_name = options.authname,
338                          author_email = options.authemail,
339                          author_date = options.authdate,
340                          committer_name = options.commname,
341                          committer_email = options.commemail)
342
343 new_cmd = \
344         Command(new,
345                 'create a new patch and make it the topmost one',
346                 '%prog [options] <name>',
347                 [make_option('-m', '--message',
348                              help = 'use MESSAGE as the patch description'),
349                  make_option('--authname',
350                              help = 'use AUTHNAME as the author name'),
351                  make_option('--authemail',
352                              help = 'use AUTHEMAIL as the author e-mail'),
353                  make_option('--authdate',
354                              help = 'use AUTHDATE as the author date'),
355                  make_option('--commname',
356                              help = 'use COMMNAME as the committer name'),
357                  make_option('--commemail',
358                              help = 'use COMMEMAIL as the committer e-mail')])
359
360 def delete(parser, options, args):
361     """Deletes a patch
362     """
363     if len(args) != 1:
364         parser.error('incorrect number of arguments')
365
366     if args[0] == crt_series.get_current():
367         __check_local_changes()
368         __check_conflicts()
369         __check_head_top_equal()
370
371     crt_series.delete_patch(args[0])
372     print 'Patch "%s" successfully deleted' % args[0]
373     __print_crt_patch()
374
375 delete_cmd = \
376            Command(delete,
377                    'remove the topmost or any unapplied patch',
378                    '%prog <name>',
379                    [])
380
381
382 def push(parser, options, args):
383     """Pushes the given patch or all onto the series
384     """
385     # If --undo is passed, do the work and exit
386     if options.undo:
387         patch = crt_series.get_current()
388         if not patch:
389             raise MainException, 'No patch to undo'
390
391         print 'Undoing the "%s" push...' % patch,
392         sys.stdout.flush()
393         __resolved_all()
394         crt_series.undo_push()
395         print 'done'
396         __print_crt_patch()
397
398         return
399
400     __check_local_changes()
401     __check_conflicts()
402     __check_head_top_equal()
403
404     unapplied = crt_series.get_unapplied()
405     if not unapplied:
406         raise MainException, 'No more patches to push'
407
408     if options.to:
409         boundaries = options.to.split(':')
410         if len(boundaries) == 1:
411             if boundaries[0] not in unapplied:
412                 raise MainException, 'Patch "%s" not unapplied' % boundaries[0]
413             patches = unapplied[:unapplied.index(boundaries[0])+1]
414         elif len(boundaries) == 2:
415             if boundaries[0] not in unapplied:
416                 raise MainException, 'Patch "%s" not unapplied' % boundaries[0]
417             if boundaries[1] not in unapplied:
418                 raise MainException, 'Patch "%s" not unapplied' % boundaries[1]
419             lb = unapplied.index(boundaries[0])
420             hb = unapplied.index(boundaries[1])
421             if lb > hb:
422                 raise MainException, 'Patch "%s" after "%s"' \
423                       % (boundaries[0], boundaries[1])
424             patches = unapplied[lb:hb+1]
425         else:
426             raise MainException, 'incorrect parameters to "--to"'
427     elif options.number:
428         patches = unapplied[:options.number]
429     elif options.all:
430         patches = unapplied
431     elif len(args) == 0:
432         patches = [unapplied[0]]
433     elif len(args) == 1:
434         patches = [args[0]]
435     else:
436         parser.error('incorrect number of arguments')
437
438     if patches == []:
439         raise MainException, 'No patches to push'
440
441     if options.reverse:
442         patches.reverse()
443
444     for p in patches:
445         print 'Pushing patch "%s"...' % p,
446         sys.stdout.flush()
447
448         crt_series.push_patch(p)
449
450         if crt_series.empty_patch(p):
451             print 'done (empty patch)'
452         else:
453             print 'done'
454     __print_crt_patch()
455
456 push_cmd = \
457          Command(push,
458                  'push a patch on top of the series',
459                  '%prog [options] [<name>]',
460                  [make_option('-a', '--all',
461                               help = 'push all the unapplied patches',
462                               action = 'store_true'),
463                   make_option('-n', '--number', type = 'int',
464                               help = 'push the specified number of patches'),
465                   make_option('-t', '--to', metavar = 'PATCH1[:PATCH2]',
466                               help = 'push all patches to PATCH1 or between '
467                               'PATCH1 and PATCH2'),
468                   make_option('--reverse',
469                               help = 'push the patches in reverse order',
470                               action = 'store_true'),
471                   make_option('--undo',
472                               help = 'undo the last push operation',
473                               action = 'store_true')])
474
475
476 def pop(parser, options, args):
477     if len(args) != 0:
478         parser.error('incorrect number of arguments')
479
480     __check_local_changes()
481     __check_conflicts()
482     __check_head_top_equal()
483
484     applied = crt_series.get_applied()
485     if not applied:
486         raise MainException, 'No patches applied'
487     applied.reverse()
488
489     if options.to:
490         if options.to not in applied:
491             raise MainException, 'Patch "%s" not applied' % options.to
492         patches = applied[:applied.index(options.to)]
493     elif options.number:
494         patches = applied[:options.number]
495     elif options.all:
496         patches = applied
497     else:
498         patches = [applied[0]]
499
500     if patches == []:
501         raise MainException, 'No patches to pop'
502
503     # pop everything to the given patch
504     p = patches[-1]
505     if len(patches) == 1:
506         print 'Popping patch "%s"...' % p,
507     else:
508         print 'Popping "%s" - "%s" patches...' % (patches[0], p),
509     sys.stdout.flush()
510
511     crt_series.pop_patch(p)
512
513     print 'done'
514     __print_crt_patch()
515
516 pop_cmd = \
517         Command(pop,
518                 'pop the top of the series',
519                 '%prog [options]',
520                 [make_option('-a', '--all',
521                              help = 'pop all the applied patches',
522                              action = 'store_true'),
523                  make_option('-n', '--number', type = 'int',
524                              help = 'pop the specified number of patches'),
525                  make_option('-t', '--to', metavar = 'PATCH',
526                              help = 'pop all patches up to PATCH')])
527
528
529 def __resolved(filename):
530     for ext in ['.local', '.older', '.remote']:
531         fn = filename + ext
532         if os.path.isfile(fn):
533             os.remove(fn)
534
535 def __resolved_all():
536     conflicts = git.get_conflicts()
537     if conflicts:
538         for filename in conflicts:
539             __resolved(filename)
540         os.remove(os.path.join(git.base_dir, 'conflicts'))
541
542 def resolved(parser, options, args):
543     if options.all:
544         __resolved_all()
545         return
546
547     if len(args) == 0:
548         parser.error('incorrect number of arguments')
549
550     conflicts = git.get_conflicts()
551     if not conflicts:
552         raise MainException, 'No more conflicts'
553     # check for arguments validity
554     for filename in args:
555         if not filename in conflicts:
556             raise MainException, 'No conflicts for "%s"' % filename
557     # resolved
558     for filename in args:
559         __resolved(filename)
560         del conflicts[conflicts.index(filename)]
561
562     # save or remove the conflicts file
563     if conflicts == []:
564         os.remove(os.path.join(git.base_dir, 'conflicts'))
565     else:
566         f = file(os.path.join(git.base_dir, 'conflicts'), 'w+')
567         f.writelines([line + '\n' for line in conflicts])
568         f.close()
569
570 resolved_cmd = \
571              Command(resolved,
572                      'mark a file conflict as solved',
573                      '%prog [options] [<file>[ <file>]]',
574                      [make_option('-a', '--all',
575                                   help = 'mark all conflicts as solved',
576                                   action = 'store_true')])
577
578
579 def series(parser, options, args):
580     if len(args) != 0:
581         parser.error('incorrect number of arguments')
582
583     applied = crt_series.get_applied()
584     if len(applied) > 0:
585         for p in applied [0:-1]:
586             if crt_series.empty_patch(p):
587                 print '0', p
588             else:
589                 print '+', p
590         p = applied[-1]
591
592         if crt_series.empty_patch(p):
593             print '0>%s' % p
594         else:
595             print '> %s' % p
596
597     for p in crt_series.get_unapplied():
598         if crt_series.empty_patch(p):
599             print '0', p
600         else:
601             print '-', p
602
603 series_cmd = \
604            Command(series,
605                    'print the patch series',
606                    '%prog',
607                    [])
608
609
610 def applied(parser, options, args):
611     if len(args) != 0:
612         parser.error('incorrect number of arguments')
613
614     for p in crt_series.get_applied():
615         print p
616
617 applied_cmd = \
618             Command(applied,
619                     'print the applied patches',
620                     '%prog',
621                     [])
622
623
624 def unapplied(parser, options, args):
625     if len(args) != 0:
626         parser.error('incorrect number of arguments')
627
628     for p in crt_series.get_unapplied():
629         print p
630
631 unapplied_cmd = \
632               Command(unapplied,
633                       'print the unapplied patches',
634                       '%prog',
635                       [])
636
637
638 def top(parser, options, args):
639     if len(args) != 0:
640         parser.error('incorrect number of arguments')
641
642     name = crt_series.get_current()
643     if name:
644         print name
645     else:
646         raise MainException, 'No patches applied'
647
648 top_cmd = \
649         Command(top,
650                 'print the name of the top patch',
651                 '%prog',
652                 [])
653
654
655 def export(parser, options, args):
656     if len(args) == 0:
657         dirname = 'patches'
658     elif len(args) == 1:
659         dirname = args[0]
660     else:
661         parser.error('incorrect number of arguments')
662
663     if git.local_changes():
664         print 'Warning: local changes in the tree. ' \
665               'You might want to commit them first'
666
667     if not os.path.isdir(dirname):
668         os.makedirs(dirname)
669     series = file(os.path.join(dirname, 'series'), 'w+')
670
671     patches = crt_series.get_applied()
672     num = len(patches)
673     zpadding = len(str(num))
674     if zpadding < 2:
675         zpadding = 2
676
677     patch_no = 1;
678     for p in patches:
679         pname = p
680         if options.diff:
681             pname = '%s.diff' % pname
682         if options.numbered:
683             pname = '%s-%s' % (str(patch_no).zfill(zpadding), pname)
684         pfile = os.path.join(dirname, pname)
685         print >> series, pname
686
687         # get the template
688         if options.template:
689             patch_tmpl = options.template
690         else:
691             patch_tmpl = os.path.join(git.base_dir, 'patchexport.tmpl')
692         if os.path.isfile(patch_tmpl):
693             tmpl = file(patch_tmpl).read()
694         else:
695             tmpl = ''
696
697         # get the patch description
698         patch = crt_series.get_patch(p)
699
700         tmpl_dict = {'description': patch.get_description().rstrip(),
701                      'diffstat': git.diffstat(rev1 = __git_id('%s/bottom' % p),
702                                               rev2 = __git_id('%s/top' % p)),
703                      'authname': patch.get_authname(),
704                      'authemail': patch.get_authemail(),
705                      'authdate': patch.get_authdate(),
706                      'commname': patch.get_commname(),
707                      'commemail': patch.get_commemail()}
708         for key in tmpl_dict:
709             if not tmpl_dict[key]:
710                 tmpl_dict[key] = ''
711
712         try:
713             descr = tmpl % tmpl_dict
714         except KeyError, err:
715             raise MainException, 'Unknown patch template variable: %s' \
716                   % err
717         except TypeError:
718             raise MainException, 'Only "%(name)s" variables are ' \
719                   'supported in the patch template'
720         f = open(pfile, 'w+')
721         f.write(descr)
722         f.close()
723
724         # write the diff
725         git.diff(rev1 = __git_id('%s/bottom' % p),
726                  rev2 = __git_id('%s/top' % p),
727                  output = pfile, append = True)
728         patch_no += 1
729
730     series.close()
731
732 export_cmd = \
733            Command(export,
734                    'exports a series of patches to <dir> (or patches)',
735                    '%prog [options] [<dir>]',
736                    [make_option('-n', '--numbered',
737                                 help = 'number the patch names',
738                                 action = 'store_true'),
739                     make_option('-d', '--diff',
740                                 help = 'append .diff to the patch names',
741                                 action = 'store_true'),
742                     make_option('-t', '--template', metavar = 'FILE',
743                                 help = 'Use FILE as a template')])
744
745 #
746 # The commands map
747 #
748 commands = {
749     'init':     init_cmd,
750     'add':      add_cmd,
751     'rm':       rm_cmd,
752     'status':   status_cmd,
753     'diff':     diff_cmd,
754     'files':    files_cmd,
755     'new':      new_cmd,
756     'delete':   delete_cmd,
757     'push':     push_cmd,
758     'pop':      pop_cmd,
759     'resolved': resolved_cmd,
760     'series':   series_cmd,
761     'applied':  applied_cmd,
762     'unapplied':unapplied_cmd,
763     'top':      top_cmd,
764     'refresh':  refresh_cmd,
765     'export':   export_cmd,
766     }
767
768 def print_help():
769     print 'usage: %s <command> [options]' % os.path.basename(sys.argv[0])
770     print
771     print 'commands:'
772     print '  help        print this message'
773
774     cmds = commands.keys()
775     cmds.sort()
776     for cmd in cmds:
777         print '  ' + cmd + ' ' * (12 - len(cmd)) + commands[cmd].help
778
779 #
780 # The main function (command dispatcher)
781 #
782 def main():
783     """The main function
784     """
785     global crt_series
786
787     prog = os.path.basename(sys.argv[0])
788
789     if len(sys.argv) < 2:
790         print >> sys.stderr, 'Unknown command'
791         print >> sys.stderr, \
792               '  Try "%s help" for a list of supported commands' % prog
793         sys.exit(1)
794
795     cmd = sys.argv[1]
796
797     if cmd in ['-h', '--help', 'help']:
798         print_help()
799         sys.exit(0)
800     if cmd in ['-v', '--version']:
801         print '%s %s' % (prog, version)
802         sys.exit(0)
803     if not cmd in commands:
804         print >> sys.stderr, 'Unknown command: %s' % cmd
805         print >> sys.stderr, '  Try "%s help" for a list of supported commands' \
806               % prog
807         sys.exit(1)
808
809     # re-build the command line arguments
810     sys.argv[0] += ' %s' % cmd
811     del(sys.argv[1])
812
813     command = commands[cmd]
814     parser = OptionParser(usage = command.usage,
815                           option_list = command.option_list)
816     options, args = parser.parse_args()
817     try:
818         crt_series = stack.Series()
819         command.func(parser, options, args)
820     except (IOError, MainException, stack.StackException, git.GitException), \
821                err:
822         print >> sys.stderr, '%s %s: %s' % (prog, cmd, err)
823         sys.exit(2)
824
825     sys.exit(0)