Add --template option to export
[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 __check_local_changes()
367 __check_conflicts()
368 __check_head_top_equal()
369
370 crt_series.delete_patch(args[0])
371 print 'Patch "%s" successfully deleted' % args[0]
372 __print_crt_patch()
373
374 delete_cmd = \
375 Command(delete,
376 'remove the topmost or any unapplied patch',
377 '%prog <name>',
378 [])
379
380
381 def push(parser, options, args):
382 """Pushes the given patch or all onto the series
383 """
384 # If --undo is passed, do the work and exit
385 if options.undo:
386 patch = crt_series.get_current()
387 if not patch:
388 raise MainException, 'No patch to undo'
389
390 print 'Undoing the "%s" push...' % patch,
391 sys.stdout.flush()
392 __resolved_all()
393 crt_series.undo_push()
394 print 'done'
395 __print_crt_patch()
396
397 return
398
399 __check_local_changes()
400 __check_conflicts()
401 __check_head_top_equal()
402
403 unapplied = crt_series.get_unapplied()
404 if not unapplied:
405 raise MainException, 'No more patches to push'
406
407 if options.to:
408 boundaries = options.to.split(':')
409 if len(boundaries) == 1:
410 if boundaries[0] not in unapplied:
411 raise MainException, 'Patch "%s" not unapplied' % boundaries[0]
412 patches = unapplied[:unapplied.index(boundaries[0])+1]
413 elif len(boundaries) == 2:
414 if boundaries[0] not in unapplied:
415 raise MainException, 'Patch "%s" not unapplied' % boundaries[0]
416 if boundaries[1] not in unapplied:
417 raise MainException, 'Patch "%s" not unapplied' % boundaries[1]
418 lb = unapplied.index(boundaries[0])
419 hb = unapplied.index(boundaries[1])
420 if lb > hb:
421 raise MainException, 'Patch "%s" after "%s"' \
422 % (boundaries[0], boundaries[1])
423 patches = unapplied[lb:hb+1]
424 else:
425 raise MainException, 'incorrect parameters to "--to"'
426 elif options.number:
427 patches = unapplied[:options.number]
428 elif options.all:
429 patches = unapplied
430 elif len(args) == 0:
431 patches = [unapplied[0]]
432 elif len(args) == 1:
433 patches = [args[0]]
434 else:
435 parser.error('incorrect number of arguments')
436
437 if patches == []:
438 raise MainException, 'No patches to push'
439
440 if options.reverse:
441 patches.reverse()
442
443 for p in patches:
444 print 'Pushing patch "%s"...' % p,
445 sys.stdout.flush()
446
447 crt_series.push_patch(p)
448
449 if crt_series.empty_patch(p):
450 print 'done (empty patch)'
451 else:
452 print 'done'
453 __print_crt_patch()
454
455 push_cmd = \
456 Command(push,
457 'push a patch on top of the series',
458 '%prog [options] [<name>]',
459 [make_option('-a', '--all',
460 help = 'push all the unapplied patches',
461 action = 'store_true'),
462 make_option('-n', '--number', type = 'int',
463 help = 'push the specified number of patches'),
464 make_option('-t', '--to', metavar = 'PATCH1[:PATCH2]',
465 help = 'push all patches to PATCH1 or between '
466 'PATCH1 and PATCH2'),
467 make_option('--reverse',
468 help = 'push the patches in reverse order',
469 action = 'store_true'),
470 make_option('--undo',
471 help = 'undo the last push operation',
472 action = 'store_true')])
473
474
475 def pop(parser, options, args):
476 if len(args) != 0:
477 parser.error('incorrect number of arguments')
478
479 __check_local_changes()
480 __check_conflicts()
481 __check_head_top_equal()
482
483 applied = crt_series.get_applied()
484 if not applied:
485 raise MainException, 'No patches applied'
486 applied.reverse()
487
488 if options.to:
489 if options.to not in applied:
490 raise MainException, 'Patch "%s" not applied' % options.to
491 patches = applied[:applied.index(options.to)]
492 elif options.number:
493 patches = applied[:options.number]
494 elif options.all:
495 patches = applied
496 else:
497 patches = [applied[0]]
498
499 if patches == []:
500 raise MainException, 'No patches to pop'
501
502 # pop everything to the given patch
503 p = patches[-1]
504 if len(patches) == 1:
505 print 'Popping patch "%s"...' % p,
506 else:
507 print 'Popping "%s" - "%s" patches...' % (patches[0], p),
508 sys.stdout.flush()
509
510 crt_series.pop_patch(p)
511
512 print 'done'
513 __print_crt_patch()
514
515 pop_cmd = \
516 Command(pop,
517 'pop the top of the series',
518 '%prog [options]',
519 [make_option('-a', '--all',
520 help = 'pop all the applied patches',
521 action = 'store_true'),
522 make_option('-n', '--number', type = 'int',
523 help = 'pop the specified number of patches'),
524 make_option('-t', '--to', metavar = 'PATCH',
525 help = 'pop all patches up to PATCH')])
526
527
528 def __resolved(filename):
529 for ext in ['.local', '.older', '.remote']:
530 fn = filename + ext
531 if os.path.isfile(fn):
532 os.remove(fn)
533
534 def __resolved_all():
535 conflicts = git.get_conflicts()
536 if conflicts:
537 for filename in conflicts:
538 __resolved(filename)
539 os.remove(os.path.join(git.base_dir, 'conflicts'))
540
541 def resolved(parser, options, args):
542 if options.all:
543 __resolved_all()
544 return
545
546 if len(args) == 0:
547 parser.error('incorrect number of arguments')
548
549 conflicts = git.get_conflicts()
550 if not conflicts:
551 raise MainException, 'No more conflicts'
552 # check for arguments validity
553 for filename in args:
554 if not filename in conflicts:
555 raise MainException, 'No conflicts for "%s"' % filename
556 # resolved
557 for filename in args:
558 __resolved(filename)
559 del conflicts[conflicts.index(filename)]
560
561 # save or remove the conflicts file
562 if conflicts == []:
563 os.remove(os.path.join(git.base_dir, 'conflicts'))
564 else:
565 f = file(os.path.join(git.base_dir, 'conflicts'), 'w+')
566 f.writelines([line + '\n' for line in conflicts])
567 f.close()
568
569 resolved_cmd = \
570 Command(resolved,
571 'mark a file conflict as solved',
572 '%prog [options] [<file>[ <file>]]',
573 [make_option('-a', '--all',
574 help = 'mark all conflicts as solved',
575 action = 'store_true')])
576
577
578 def series(parser, options, args):
579 if len(args) != 0:
580 parser.error('incorrect number of arguments')
581
582 applied = crt_series.get_applied()
583 if len(applied) > 0:
584 for p in applied [0:-1]:
585 if crt_series.empty_patch(p):
586 print '0', p
587 else:
588 print '+', p
589 p = applied[-1]
590
591 if crt_series.empty_patch(p):
592 print '0>%s' % p
593 else:
594 print '> %s' % p
595
596 for p in crt_series.get_unapplied():
597 if crt_series.empty_patch(p):
598 print '0', p
599 else:
600 print '-', p
601
602 series_cmd = \
603 Command(series,
604 'print the patch series',
605 '%prog',
606 [])
607
608
609 def applied(parser, options, args):
610 if len(args) != 0:
611 parser.error('incorrect number of arguments')
612
613 for p in crt_series.get_applied():
614 print p
615
616 applied_cmd = \
617 Command(applied,
618 'print the applied patches',
619 '%prog',
620 [])
621
622
623 def unapplied(parser, options, args):
624 if len(args) != 0:
625 parser.error('incorrect number of arguments')
626
627 for p in crt_series.get_unapplied():
628 print p
629
630 unapplied_cmd = \
631 Command(unapplied,
632 'print the unapplied patches',
633 '%prog',
634 [])
635
636
637 def top(parser, options, args):
638 if len(args) != 0:
639 parser.error('incorrect number of arguments')
640
641 name = crt_series.get_current()
642 if name:
643 print name
644 else:
645 raise MainException, 'No patches applied'
646
647 top_cmd = \
648 Command(top,
649 'print the name of the top patch',
650 '%prog',
651 [])
652
653
654 def export(parser, options, args):
655 if len(args) == 0:
656 dirname = 'patches'
657 elif len(args) == 1:
658 dirname = args[0]
659 else:
660 parser.error('incorrect number of arguments')
661
662 if git.local_changes():
663 print 'Warning: local changes in the tree. ' \
664 'You might want to commit them first'
665
666 if not os.path.isdir(dirname):
667 os.makedirs(dirname)
668 series = file(os.path.join(dirname, 'series'), 'w+')
669
670 patches = crt_series.get_applied()
671 num = len(patches)
672 zpadding = len(str(num))
673 if zpadding < 2:
674 zpadding = 2
675
676 patch_no = 1;
677 for p in patches:
678 pname = p
679 if options.diff:
680 pname = '%s.diff' % pname
681 if options.numbered:
682 pname = '%s-%s' % (str(patch_no).zfill(zpadding), pname)
683 pfile = os.path.join(dirname, pname)
684 print >> series, pname
685
686 # get the template
687 if options.template:
688 patch_tmpl = options.template
689 else:
690 patch_tmpl = os.path.join(git.base_dir, 'patchexport.tmpl')
691 if os.path.isfile(patch_tmpl):
692 tmpl = file(patch_tmpl).read()
693 else:
694 tmpl = ''
695
696 # get the patch description
697 patch = crt_series.get_patch(p)
698
699 tmpl_dict = {'description': patch.get_description().rstrip(),
700 'diffstat': git.diffstat(rev1 = __git_id('%s/bottom' % p),
701 rev2 = __git_id('%s/top' % p)),
702 'authname': patch.get_authname(),
703 'authemail': patch.get_authemail(),
704 'authdate': patch.get_authdate(),
705 'commname': patch.get_commname(),
706 'commemail': patch.get_commemail()}
707 for key in tmpl_dict:
708 if not tmpl_dict[key]:
709 tmpl_dict[key] = ''
710
711 try:
712 descr = tmpl % tmpl_dict
713 except KeyError, err:
714 raise MainException, 'Unknown patch template variable: %s' \
715 % err
716 except TypeError:
717 raise MainException, 'Only "%(name)s" variables are ' \
718 'supported in the patch template'
719 f = open(pfile, 'w+')
720 f.write(descr)
721 f.close()
722
723 # write the diff
724 git.diff(rev1 = __git_id('%s/bottom' % p),
725 rev2 = __git_id('%s/top' % p),
726 output = pfile, append = True)
727 patch_no += 1
728
729 series.close()
730
731 export_cmd = \
732 Command(export,
733 'exports a series of patches to <dir> (or patches)',
734 '%prog [options] [<dir>]',
735 [make_option('-n', '--numbered',
736 help = 'number the patch names',
737 action = 'store_true'),
738 make_option('-d', '--diff',
739 help = 'append .diff to the patch names',
740 action = 'store_true'),
741 make_option('-t', '--template', metavar = 'FILE',
742 help = 'Use FILE as a template')])
743
744 #
745 # The commands map
746 #
747 commands = {
748 'init': init_cmd,
749 'add': add_cmd,
750 'rm': rm_cmd,
751 'status': status_cmd,
752 'diff': diff_cmd,
753 'files': files_cmd,
754 'new': new_cmd,
755 'delete': delete_cmd,
756 'push': push_cmd,
757 'pop': pop_cmd,
758 'resolved': resolved_cmd,
759 'series': series_cmd,
760 'applied': applied_cmd,
761 'unapplied':unapplied_cmd,
762 'top': top_cmd,
763 'refresh': refresh_cmd,
764 'export': export_cmd,
765 }
766
767 def print_help():
768 print 'usage: %s <command> [options]' % os.path.basename(sys.argv[0])
769 print
770 print 'commands:'
771 print ' help print this message'
772
773 cmds = commands.keys()
774 cmds.sort()
775 for cmd in cmds:
776 print ' ' + cmd + ' ' * (12 - len(cmd)) + commands[cmd].help
777
778 #
779 # The main function (command dispatcher)
780 #
781 def main():
782 """The main function
783 """
784 global crt_series
785
786 prog = os.path.basename(sys.argv[0])
787
788 if len(sys.argv) < 2:
789 print >> sys.stderr, 'Unknown command'
790 print >> sys.stderr, \
791 ' Try "%s help" for a list of supported commands' % prog
792 sys.exit(1)
793
794 cmd = sys.argv[1]
795
796 if cmd in ['-h', '--help', 'help']:
797 print_help()
798 sys.exit(0)
799 if cmd in ['-v', '--version']:
800 print '%s %s' % (prog, version)
801 sys.exit(0)
802 if not cmd in commands:
803 print >> sys.stderr, 'Unknown command: %s' % cmd
804 print >> sys.stderr, ' Try "%s help" for a list of supported commands' \
805 % prog
806 sys.exit(1)
807
808 # re-build the command line arguments
809 sys.argv[0] += ' %s' % cmd
810 del(sys.argv[1])
811
812 command = commands[cmd]
813 parser = OptionParser(usage = command.usage,
814 option_list = command.option_list)
815 options, args = parser.parse_args()
816 try:
817 crt_series = stack.Series()
818 command.func(parser, options, args)
819 except (IOError, MainException, stack.StackException, git.GitException), \
820 err:
821 print >> sys.stderr, '%s %s: %s' % (prog, cmd, err)
822 sys.exit(2)
823
824 sys.exit(0)