Subject: [StGIT PATCH] Refactor message printing

From: Karl Hasselström <[email-masked]>
Date: Sat, 19 May 2007 13:54:14 +0200

Feed all those little progress and status messages through a central
place where such things as newlines, indentation, and suppressing
output when not on a TTY can be taken care of once and for all.

This patch takes care of almost, but not quite, all output. The most
notable leftover is the printing of help messages.

Signed-off-by: Karl Hasselström <[email-masked]>
---

 stgit/commands/applied.py    |    4 +-
 stgit/commands/assimilate.py |    6 +--
 stgit/commands/branch.py     |   55 +++++++++++++--------------
 stgit/commands/clean.py      |    7 +--
 stgit/commands/commit.py     |    5 +-
 stgit/commands/common.py     |   48 ++++++++++--------------
 stgit/commands/delete.py     |    2 -
 stgit/commands/diff.py       |    2 -
 stgit/commands/export.py     |    8 ++--
 stgit/commands/files.py      |    6 +--
 stgit/commands/fold.py       |    7 +--
 stgit/commands/hide.py       |    2 -
 stgit/commands/id.py         |    2 -
 stgit/commands/imprt.py      |   10 +----
 stgit/commands/log.py        |    2 -
 stgit/commands/mail.py       |   14 +++----
 stgit/commands/patches.py    |    2 -
 stgit/commands/pick.py       |   31 +++++++--------
 stgit/commands/pull.py       |    4 +-
 stgit/commands/push.py       |    7 +--
 stgit/commands/refresh.py    |   14 +++----
 stgit/commands/rename.py     |    5 +-
 stgit/commands/series.py     |    8 ++--
 stgit/commands/sync.py       |   14 ++-----
 stgit/commands/top.py        |    2 -
 stgit/commands/unapplied.py  |    4 +-
 stgit/commands/uncommit.py   |    8 +---
 stgit/commands/unhide.py     |    2 -
 stgit/git.py                 |   18 ++++-----
 stgit/gitmergeonefile.py     |   45 ++++++++++------------
 stgit/main.py                |   15 +++----
 stgit/stack.py               |   36 +++++++++---------
 stgit/utils.py               |   85 +++++++++++++++++++++++++++++++++++++++++-
 33 files changed, 259 insertions(+), 221 deletions(-)

diff --git a/stgit/commands/applied.py b/stgit/commands/applied.py
index f131d62..0925de0 100644
--- a/stgit/commands/applied.py
+++ b/stgit/commands/applied.py
@@ -47,7 +47,7 @@ def func(parser, options, args):
     applied = crt_series.get_applied()
 
     if options.count:
-        print len(applied)
+        out.stdout(len(applied))
     else:
         for p in applied:
-            print p
+            out.stdout(p)
diff --git a/stgit/commands/assimilate.py b/stgit/commands/assimilate.py
index a8b3bfe..e5ebb55 100644
--- a/stgit/commands/assimilate.py
+++ b/stgit/commands/assimilate.py
@@ -42,7 +42,7 @@ def func(parser, options, args):
     """
 
     def nothing_to_do():
-        print 'No commits to assimilate'
+        out.info('No commits to assimilate')
 
     top_patch = crt_series.get_current_patch()
     if not top_patch:
@@ -79,8 +79,8 @@ def func(parser, options, args):
 
     victims.reverse()
     for victim in victims:
-        print ('Creating patch "%s" from commit %s'
-               % (patch2name[victim], victim))
+        out.info('Creating patch "%s" from commit %s'
+                 % (patch2name[victim], victim))
         aname, amail, adate = name_email_date(victim.get_author())
         cname, cmail, cdate = name_email_date(victim.get_committer())
         crt_series.new_patch(
diff --git a/stgit/commands/branch.py b/stgit/commands/branch.py
index 5e7b0df..07bdca8 100644
--- a/stgit/commands/branch.py
+++ b/stgit/commands/branch.py
@@ -83,8 +83,8 @@ def __print_branch(branch_name, length):
         current = '>'
     if branch.get_protected():
         protected = 'p'
-    print current + ' ' + initialized + protected + '\t' + \
-          branch_name.ljust(length) + '  | ' + branch.get_description()
+    out.stdout(current + ' ' + initialized + protected + '\t'
+               + branch_name.ljust(length) + '  | ' + branch.get_description())
 
 def __delete_branch(doomed_name, force = False):
     doomed = stack.Series(doomed_name)
@@ -92,8 +92,7 @@ def __delete_branch(doomed_name, force = False):
     if doomed.get_protected():
         raise CmdException, 'This branch is protected. Delete is not permitted'
 
-    print 'Deleting branch "%s"...' % doomed_name,
-    sys.stdout.flush()
+    out.start('Deleting branch "%s"' % doomed_name)
 
     if __is_current_branch(doomed_name):
         check_local_changes()
@@ -108,7 +107,7 @@ def __delete_branch(doomed_name, force = False):
     if doomed_name != 'master':
         git.delete_branch(doomed_name)
 
-    print 'done'
+    out.done()
 
 def func(parser, options, args):
 
@@ -127,17 +126,20 @@ def func(parser, options, args):
                 if git.rev_parse(args[1]) == git.rev_parse('refs/heads/' + args[1]):
                     # we are for sure referring to a branch
                     parentbranch = 'refs/heads/' + args[1]
-                    print 'Recording "%s" as parent branch.' % parentbranch
+                    out.info('Recording "%s" as parent branch' % parentbranch)
                 elif git.rev_parse(args[1]) and re.search('/', args[1]):
                     # FIXME: should the test be more strict ?
                     parentbranch = args[1]
                 else:
                     # Note: this includes refs to StGIT patches
-                    print 'Don\'t know how to determine parent branch from "%s".' % args[1]
+                    out.info('Don\'t know how to determine parent branch'
+                             ' from "%s"' % args[1])
                     parentbranch = None
             except git.GitException:
-                # should use a more specific exception to catch only non-git refs ?
-                print 'Don\'t know how to determine parent branch from "%s".' % args[1]
+                # should use a more specific exception to catch only
+                # non-git refs ?
+                out.info('Don\'t know how to determine parent branch'
+                         ' from "%s"' % args[1])
                 parentbranch = None
 
             tree_id = git_id(args[1])
@@ -148,9 +150,10 @@ def func(parser, options, args):
         if parentbranch:
             parentremote = git.identify_remote(parentbranch)
             if parentremote:
-                print 'Using "%s" remote to pull parent from.' % parentremote
+                out.info('Using remote "%s" to pull parent from'
+                         % parentremote)
             else:
-                print 'Recording as a local branch.'
+                out.info('Recording as a local branch')
         else:
             # no known parent branch, can't guess the remote
             parentremote = None
@@ -159,7 +162,7 @@ def func(parser, options, args):
                                    parent_remote = parentremote,
                                    parent_branch = parentbranch)
 
-        print 'Branch "%s" created.' % args[0]
+        out.info('Branch "%s" created' % args[0])
         return
 
     elif options.clone:
@@ -176,10 +179,9 @@ def func(parser, options, args):
         check_conflicts()
         check_head_top_equal()
 
-        print 'Cloning current branch to "%s"...' % clone,
-        sys.stdout.flush()
+        out.start('Cloning current branch to "%s"' % clone)
         crt_series.clone(clone)
-        print 'done'
+        out.done()
 
         return
 
@@ -202,12 +204,12 @@ def func(parser, options, args):
         branches.sort()
 
         if branches:
-            print 'Available branches:'
+            out.info('Available branches:')
             max_len = max([len(i) for i in branches])
             for i in branches:
                 __print_branch(i, max_len)
         else:
-            print 'No branches'
+            out.info('No branches')
         return
 
     elif options.protect:
@@ -224,10 +226,9 @@ def func(parser, options, args):
             raise CmdException, 'Branch "%s" is not controlled by StGIT' \
                   % branch_name
 
-        print 'Protecting branch "%s"...' % branch_name,
-        sys.stdout.flush()
+        out.start('Protecting branch "%s"' % branch_name)
         branch.protect()
-        print 'done'
+        out.done()
 
         return
 
@@ -241,7 +242,7 @@ def func(parser, options, args):
 
         stack.Series(args[0]).rename(args[1])
 
-        print 'Renamed branch "%s" as "%s".' % (args[0], args[1])
+        out.info('Renamed branch "%s" to "%s"' % (args[0], args[1]))
 
         return
 
@@ -259,10 +260,9 @@ def func(parser, options, args):
             raise CmdException, 'Branch "%s" is not controlled by StGIT' \
                   % branch_name
 
-        print 'Unprotecting branch "%s"...' % branch_name,
-        sys.stdout.flush()
+        out.info('Unprotecting branch "%s"' % branch_name)
         branch.unprotect()
-        print 'done'
+        out.done()
 
         return
 
@@ -294,12 +294,9 @@ def func(parser, options, args):
         check_conflicts()
         check_head_top_equal()
 
-        print 'Switching to branch "%s"...' % args[0],
-        sys.stdout.flush()
-
+        out.start('Switching to branch "%s"' % args[0])
         git.switch_branch(args[0])
-
-        print 'done'
+        out.done()
         return
 
     # default action: print the current branch
diff --git a/stgit/commands/clean.py b/stgit/commands/clean.py
index 8f5c606..7b57526 100644
--- a/stgit/commands/clean.py
+++ b/stgit/commands/clean.py
@@ -43,14 +43,11 @@ def __delete_empty(patches, applied):
     """
     for p in patches:
         if crt_series.empty_patch(p):
-            print 'Deleting patch "%s"...' % p,
-            sys.stdout.flush()
-
+            out.start('Deleting patch "%s"' % p)
             if applied and crt_series.patch_applied(p):
                 crt_series.pop_patch(p)
             crt_series.delete_patch(p)
-
-            print 'done'
+            out.done()
         elif applied and crt_series.patch_unapplied(p):
             crt_series.push_patch(p)
 
diff --git a/stgit/commands/commit.py b/stgit/commands/commit.py
index 8af6651..2b8d7ce 100644
--- a/stgit/commands/commit.py
+++ b/stgit/commands/commit.py
@@ -54,8 +54,7 @@ def func(parser, options, args):
 
     crt_head = git.get_head()
 
-    print 'Committing %d patches...' % len(applied),
-    sys.stdout.flush()
+    out.start('Committing %d patches' % len(applied))
 
     crt_series.pop_patch(applied[0])
     git.switch(crt_head)
@@ -63,4 +62,4 @@ def func(parser, options, args):
     for patch in applied:
         crt_series.delete_patch(patch)
 
-    print 'done'
+    out.done()
diff --git a/stgit/commands/common.py b/stgit/commands/common.py
index 28026da..22c78ae 100644
--- a/stgit/commands/common.py
+++ b/stgit/commands/common.py
@@ -131,9 +131,9 @@ def print_crt_patch(branch = None):
         patch = stack.Series(branch).get_current()
 
     if patch:
-        print 'Now at patch "%s"' % patch
+        out.info('Now at patch "%s"' % patch)
     else:
-        print 'No patches applied'
+        out.info('No patches applied')
 
 def resolved(filename, reset = None):
     if reset:
@@ -163,58 +163,53 @@ def push_patches(patches, check_merged = False):
     """
     forwarded = crt_series.forward_patches(patches)
     if forwarded > 1:
-        print 'Fast-forwarded patches "%s" - "%s"' % (patches[0],
-                                                      patches[forwarded - 1])
+        out.info('Fast-forwarded patches "%s" - "%s"'
+                 % (patches[0], patches[forwarded - 1]))
     elif forwarded == 1:
-        print 'Fast-forwarded patch "%s"' % patches[0]
+        out.info('Fast-forwarded patch "%s"' % patches[0])
 
     names = patches[forwarded:]
 
     # check for patches merged upstream
     if names and check_merged:
-        print 'Checking for patches merged upstream...',
-        sys.stdout.flush()
+        out.start('Checking for patches merged upstream')
 
         merged = crt_series.merged_patches(names)
 
-        print 'done (%d found)' % len(merged)
+        out.done('%d found' % len(merged))
     else:
         merged = []
 
     for p in names:
-        print 'Pushing patch "%s"...' % p,
-        sys.stdout.flush()
+        out.start('Pushing patch "%s"' % p)
 
         if p in merged:
             crt_series.push_patch(p, empty = True)
-            print 'done (merged upstream)'
+            out.done('merged upstream')
         else:
             modified = crt_series.push_patch(p)
 
             if crt_series.empty_patch(p):
-                print 'done (empty patch)'
+                out.done('empty patch')
             elif modified:
-                print 'done (modified)'
+                out.done('modified')
             else:
-                print 'done'
+                out.done()
 
 def pop_patches(patches, keep = False):
     """Pop the patches in the list from the stack. It is assumed that
     the patches are listed in the stack reverse order.
     """
     if len(patches) == 0:
-        print 'nothing to push/pop'
+        out.info('Nothing to push/pop')
     else:
         p = patches[-1]
         if len(patches) == 1:
-            print 'Popping patch "%s"...' % p,
+            out.start('Popping patch "%s"' % p)
         else:
-            print 'Popping "%s" - "%s" patches...' % (patches[0], p),
-        sys.stdout.flush()
-
+            out.start('Popping patches "%s" - "%s"' % (patches[0], p))
         crt_series.pop_patch(p, keep)
-
-        print 'done'
+        out.done()
 
 def parse_patches(patch_args, patch_list, boundary = 0, ordered = False):
     """Parse patch_args list for patch names in patch_list and return
@@ -334,19 +329,18 @@ def prepare_rebase(real_rebase, force=None):
     # pop all patches
     applied = crt_series.get_applied()
     if len(applied) > 0:
-        print 'Popping all applied patches...',
-        sys.stdout.flush()
+        out.start('Popping all applied patches')
         crt_series.pop_patch(applied[0])
-        print 'done'
+        out.done()
     return applied
 
 def rebase(target):
     if target == git.get_head():
-        print 'Already at "%s", no need for rebasing.' % target
+        out.info('Already at "%s", no need for rebasing.' % target)
         return
-    
-    print 'Rebasing to "%s"...' % target
+    out.start('Rebasing to "%s"' % target)
     git.reset(tree_id = git_id(target))
+    out.done()
 
 def post_rebase(applied, nopush, merged):
     # memorize that we rebased to here
diff --git a/stgit/commands/delete.py b/stgit/commands/delete.py
index e1a70c9..a9e2744 100644
--- a/stgit/commands/delete.py
+++ b/stgit/commands/delete.py
@@ -74,7 +74,7 @@ def func(parser, options, args):
     # delete the patches
     for patch in applied + patches:
         crt_series.delete_patch(patch)
-        print 'Patch "%s" successfully deleted' % patch
+        out.info('Patch "%s" successfully deleted' % patch)
 
     if not options.branch:
         print_crt_patch()
diff --git a/stgit/commands/diff.py b/stgit/commands/diff.py
index d3e1190..f56cbeb 100644
--- a/stgit/commands/diff.py
+++ b/stgit/commands/diff.py
@@ -80,7 +80,7 @@ def func(parser, options, args):
         rev2 = None
 
     if options.stat:
-        print git.diffstat(args, git_id(rev1), git_id(rev2))
+        out.stdout_raw(git.diffstat(args, git_id(rev1), git_id(rev2)) + '\n')
     else:
         diff_str = git.diff(args, git_id(rev1), git_id(rev2),
                             binary = options.binary)
diff --git a/stgit/commands/export.py b/stgit/commands/export.py
index 20d8f67..cafcbe3 100644
--- a/stgit/commands/export.py
+++ b/stgit/commands/export.py
@@ -79,8 +79,8 @@ def func(parser, options, args):
         dirname = 'patches-%s' % crt_series.get_branch()
 
     if not options.branch and git.local_changes():
-        print 'Warning: local changes in the tree. ' \
-              'You might want to commit them first'
+        out.warn('Local changes in the tree;'
+                 ' you might want to commit them first')
 
     if not options.stdout:
         if not os.path.isdir(dirname):
@@ -166,9 +166,9 @@ def func(parser, options, args):
             f = open(pfile, 'w+')
 
         if options.stdout and num > 1:
-            print '-------------------------------------------------------------------------------'
+            print '-'*79
             print patch.get_name()
-            print '-------------------------------------------------------------------------------'
+            print '-'*79
 
         # write description
         f.write(descr)
diff --git a/stgit/commands/files.py b/stgit/commands/files.py
index b33bd2a..59893d8 100644
--- a/stgit/commands/files.py
+++ b/stgit/commands/files.py
@@ -57,8 +57,8 @@ def func(parser, options, args):
     rev2 = git_id('%s//top' % patch)
 
     if options.stat:
-        print git.diffstat(rev1 = rev1, rev2 = rev2)
+        out.stdout_raw(git.diffstat(rev1 = rev1, rev2 = rev2) + '\n')
     elif options.bare:
-        print git.barefiles(rev1, rev2)
+        out.stdout_raw(git.barefiles(rev1, rev2) + '\n')
     else:
-        print git.files(rev1, rev2)
+        out.stdout_raw(git.files(rev1, rev2) + '\n')
diff --git a/stgit/commands/fold.py b/stgit/commands/fold.py
index e87ded2..297dfbf 100644
--- a/stgit/commands/fold.py
+++ b/stgit/commands/fold.py
@@ -61,12 +61,11 @@ def func(parser, options, args):
 
     if filename:
         if os.path.exists(filename):
-            print 'Folding patch "%s"...' % filename,
+            out.start('Folding patch "%s"' % filename)
         else:
             raise CmdException, 'No such file: %s' % filename
     else:
-        print 'Folding patch from stdin...',
-    sys.stdout.flush()
+        out.start('Folding patch from stdin')
 
     if options.threeway:
         crt_patch = crt_series.get_patch(current)
@@ -77,4 +76,4 @@ def func(parser, options, args):
     else:
         git.apply_patch(filename = filename)
 
-    print 'done'
+    out.done()
diff --git a/stgit/commands/hide.py b/stgit/commands/hide.py
index 9ef948d..3cb08e8 100644
--- a/stgit/commands/hide.py
+++ b/stgit/commands/hide.py
@@ -46,4 +46,4 @@ def func(parser, options, args):
 
     for patch in patches:
         crt_series.hide_patch(patch)
-        print 'Patch "%s" hidden' % patch
+        out.info('Patch "%s" hidden' % patch)
diff --git a/stgit/commands/id.py b/stgit/commands/id.py
index 4226adf..8c717e8 100644
--- a/stgit/commands/id.py
+++ b/stgit/commands/id.py
@@ -46,4 +46,4 @@ def func(parser, options, args):
     else:
         parser.error('incorrect number of arguments')
 
-    print git_id(id_str)
+    out.stdout(git_id(id_str))
diff --git a/stgit/commands/imprt.py b/stgit/commands/imprt.py
index 6fcdc62..0089a8b 100644
--- a/stgit/commands/imprt.py
+++ b/stgit/commands/imprt.py
@@ -255,7 +255,7 @@ def __create_patch(filename, message, author_name, author_email,
         raise CmdException, 'No diff found inside the patch'
 
     if options.ignore and patch in crt_series.get_applied():
-        print 'Ignoring already applied patch "%s"' % patch
+        out.info('Ignoring already applied patch "%s"' % patch)
         return
     if options.replace and patch in crt_series.get_unapplied():
         crt_series.delete_patch(patch)
@@ -289,18 +289,14 @@ def __create_patch(filename, message, author_name, author_email,
                          committer_name = committer_name,
                          committer_email = committer_email)
 
-    print 'Importing patch "%s"...' % patch,
-    sys.stdout.flush()
-
+    out.start('Importing patch "%s"' % patch)
     if options.base:
         git.apply_patch(diff = diff, base = git_id(options.base))
     else:
         git.apply_patch(diff = diff)
-
     crt_series.refresh_patch(edit = options.edit,
                              show_patch = options.showpatch)
-
-    print 'done'    
+    out.done()
 
 def __import_file(filename, options, patch = None):
     """Import a patch from a file or standard input
diff --git a/stgit/commands/log.py b/stgit/commands/log.py
index a21789e..e3e17f9 100644
--- a/stgit/commands/log.py
+++ b/stgit/commands/log.py
@@ -70,7 +70,7 @@ def show_log(log, show_patch):
             secs, tz = author_date.split()
             date = '%s %s' % (time.ctime(int(secs)), tz)
 
-            print descr, date
+            out.stdout('%s %s' % (descr, date))
 
         parent = commit.get_parent()
         if parent:
diff --git a/stgit/commands/mail.py b/stgit/commands/mail.py
index 2fcaa5f..1ca6ba2 100644
--- a/stgit/commands/mail.py
+++ b/stgit/commands/mail.py
@@ -500,13 +500,12 @@ def func(parser, options, args):
             ref_id = msg_id
 
         if options.mbox:
-            print msg_string
+            out.stdout_raw(msg_string + '\n')
         else:
-            print 'Sending the cover message...',
-            sys.stdout.flush()
+            out.start('Sending the cover message')
             __send_message(smtpserver, from_addr, to_addr_list, msg_string,
                            sleep, smtpuser, smtppassword)
-            print 'done'
+            out.done()
 
     # send the patches
     if options.template:
@@ -529,10 +528,9 @@ def func(parser, options, args):
             ref_id = msg_id
 
         if options.mbox:
-            print msg_string
+            out.stdout_raw(msg_string + '\n')
         else:
-            print 'Sending patch "%s"...' % p,
-            sys.stdout.flush()
+            out.start('Sending patch "%s"' % p)
             __send_message(smtpserver, from_addr, to_addr_list, msg_string,
                            sleep, smtpuser, smtppassword)
-            print 'done'
+            out.done()
diff --git a/stgit/commands/patches.py b/stgit/commands/patches.py
index dcfbd98..a8fb008 100644
--- a/stgit/commands/patches.py
+++ b/stgit/commands/patches.py
@@ -75,7 +75,7 @@ def func(parser, options, args):
                                   git.diff(args, patch.get_bottom(),
                                            patch.get_top()))
             else:
-                print patch.get_name()
+                out.stdout(patch.get_name())
 
     if options.diff:
         pager(diff_output)
diff --git a/stgit/commands/pick.py b/stgit/commands/pick.py
index ea0756b..a47b201 100644
--- a/stgit/commands/pick.py
+++ b/stgit/commands/pick.py
@@ -91,33 +91,30 @@ def func(parser, options, args):
         top = parent
 
     if options.fold:
-        print 'Folding commit %s...' % commit_id,
-        sys.stdout.flush()
+        out.start('Folding commit %s' % commit_id)
 
         # try a direct git-apply first
         if not git.apply_diff(bottom, top):
             git.merge(bottom, git.get_head(), top, recursive = True)
 
-        print 'done'
+        out.done()
     elif options.update:
         rev1 = git_id('//bottom')
         rev2 = git_id('//top')
         files = git.barefiles(rev1, rev2).split('\n')
 
-        print 'Updating with commit %s...' % commit_id,
-        sys.stdout.flush()
+        out.start('Updating with commit %s' % commit_id)
 
         if not git.apply_diff(bottom, top, files = files):
             raise CmdException, 'Patch updating failed'
 
-        print 'done'
+        out.done()
     else:
         message = commit.get_log()
         author_name, author_email, author_date = \
                      name_email_date(commit.get_author())
 
-        print 'Importing commit %s...' % commit_id,
-        sys.stdout.flush()
+        out.start('Importing commit %s' % commit_id)
 
         newpatch = crt_series.new_patch(patchname, message = message, can_edit = False,
                                         unapplied = True, bottom = bottom, top = top,
@@ -137,23 +134,23 @@ def func(parser, options, args):
                 refseries = crt_series
             patch = refseries.get_patch(refpatchname)
             if patch.get_log():
-                print"log was %s" % newpatch.get_log()
-                print "setting log to %s\n" %  patch.get_log()
+                out.info("Log was %s" % newpatch.get_log())
+                out.info("Setting log to %s\n" %  patch.get_log())
                 newpatch.set_log(patch.get_log())
-                print"log is now %s" % newpatch.get_log()
+                out.info("Log is now %s" % newpatch.get_log())
             else:
-                print "no log for %s\n" % patchname
- 
+                out.info("No log for %s\n" % patchname)
+
         if not options.unapplied:
             modified = crt_series.push_patch(patchname)
         else:
             modified = False
 
         if crt_series.empty_patch(patchname):
-            print 'done (empty patch)'
+            out.done('empty patch')
         elif modified:
-            print 'done (modified)'
+            out.done('modified')
         else:
-            print 'done'
-        
+            out.done()
+
     print_crt_patch()
diff --git a/stgit/commands/pull.py b/stgit/commands/pull.py
index f551b98..beaa7b5 100644
--- a/stgit/commands/pull.py
+++ b/stgit/commands/pull.py
@@ -90,10 +90,10 @@ def func(parser, options, args):
 
     # pull the remote changes
     if policy == 'pull':
-        print 'Pulling from "%s"...' % repository
+        out.info('Pulling from "%s"' % repository)
         git.pull(repository)
     elif policy == 'fetch-rebase':
-        print 'Fetching from "%s"...' % repository
+        out.info('Fetching from "%s"' % repository)
         git.fetch(repository)
         rebase(git.fetch_head())
     elif policy == 'rebase':
diff --git a/stgit/commands/push.py b/stgit/commands/push.py
index a636ad2..17b32f6 100644
--- a/stgit/commands/push.py
+++ b/stgit/commands/push.py
@@ -64,13 +64,12 @@ def func(parser, options, args):
         if not patch:
             raise CmdException, 'No patch to undo'
 
-        print 'Undoing the "%s" push...' % patch,
-        sys.stdout.flush()
+        out.start('Undoing push of "%s"' % patch)
         resolved_all()
         if crt_series.undo_push():
-            print 'done'
+            out.done()
         else:
-            print 'done (patch unchanged)'
+            out.done('patch unchanged')
         print_crt_patch()
 
         return
diff --git a/stgit/commands/refresh.py b/stgit/commands/refresh.py
index 4cf09ae..77dcbda 100644
--- a/stgit/commands/refresh.py
+++ b/stgit/commands/refresh.py
@@ -98,10 +98,9 @@ def func(parser, options, args):
         check_head_top_equal()
 
     if options.undo:
-        print 'Undoing the "%s" refresh...' % patch,
-        sys.stdout.flush()
+        out.start('Undoing the refresh of "%s"' % patch)
         crt_series.undo_refresh()
-        print 'done'
+        out.done()
         return
 
     if options.author:
@@ -128,8 +127,7 @@ def func(parser, options, args):
             between = applied[:applied.index(patch):-1]
             pop_patches(between, keep = True)
 
-        print 'Refreshing patch "%s"...' % patch,
-        sys.stdout.flush()
+        out.start('Refreshing patch "%s"' % patch)
 
         if autoresolved == 'yes':
             resolved_all()
@@ -145,12 +143,12 @@ def func(parser, options, args):
                                  backup = True, sign_str = sign_str)
 
         if crt_series.empty_patch(patch):
-            print 'done (empty patch)'
+            out.done('empty patch')
         else:
-            print 'done'
+            out.done()
 
         if options.patch:
             between.reverse()
             push_patches(between)
     else:
-        print 'Patch "%s" is already up to date' % patch
+        out.info('Patch "%s" is already up to date' % patch)
diff --git a/stgit/commands/rename.py b/stgit/commands/rename.py
index ca799c3..d6c53be 100644
--- a/stgit/commands/rename.py
+++ b/stgit/commands/rename.py
@@ -38,7 +38,6 @@ def func(parser, options, args):
     if len(args) != 2:
         parser.error('incorrect number of arguments')
 
-    print 'Renaming patch "%s" -> "%s"...' % (args[0], args[1]),
-    sys.stdout.flush()
+    out.start('Renaming patch "%s" to "%s"' % (args[0], args[1]))
     crt_series.rename_patch(args[0], args[1])
-    print 'done'
+    out.done()
diff --git a/stgit/commands/series.py b/stgit/commands/series.py
index 7777201..b699902 100644
--- a/stgit/commands/series.py
+++ b/stgit/commands/series.py
@@ -102,11 +102,11 @@ def __print_patch(patch, hidden, branch_str, prefix, empty_prefix, length,
         patch_str = patch_str.ljust(length)
 
     if options.description:
-        print prefix + patch_str + ' | ' + __get_description(patch)
+        out.stdout(prefix + patch_str + ' | ' + __get_description(patch))
     elif options.author:
-        print prefix + patch_str + ' | ' + __get_author(patch)
+        out.stdout(prefix + patch_str + ' | ' + __get_author(patch))
     else:
-        print prefix + patch_str
+        out.stdout(prefix + patch_str)
 
 def func(parser, options, args):
     """Show the patch series
@@ -157,7 +157,7 @@ def func(parser, options, args):
     patches = applied + unapplied
 
     if options.count:
-        print len(patches)
+        out.stdout(len(patches))
         return
 
     if not patches:
diff --git a/stgit/commands/sync.py b/stgit/commands/sync.py
index db52bcd..8359061 100644
--- a/stgit/commands/sync.py
+++ b/stgit/commands/sync.py
@@ -74,13 +74,10 @@ def func(parser, options, args):
                   '--undo cannot be specified with --branch or --series'
         __check_all()
 
-        print 'Undoing the "%s" sync...' % crt_series.get_current(),
-        sys.stdout.flush()
-
+        out.start('Undoing the sync of "%s"' % crt_series.get_current())
         crt_series.undo_refresh()
         git.reset()
-
-        print 'done'
+        out.done()
         return
 
     if options.branch:
@@ -147,8 +144,7 @@ def func(parser, options, args):
             del popped[:idx]
 
         # the actual sync
-        print 'Synchronising "%s"...' % p,
-        sys.stdout.flush()
+        out.start('Synchronising "%s"' % p)
 
         patch = crt_series.get_patch(p)
         bottom = patch.get_bottom()
@@ -167,9 +163,9 @@ def func(parser, options, args):
             # backup information was already reset above
             crt_series.refresh_patch(cache_update = False, backup = False,
                                      log = 'sync')
-            print 'done (updated)'
+            out.done('updated')
         else:
-            print 'done'
+            out.done()
 
     # push the remaining patches
     if popped:
diff --git a/stgit/commands/top.py b/stgit/commands/top.py
index f36a0b0..7cc92ca 100644
--- a/stgit/commands/top.py
+++ b/stgit/commands/top.py
@@ -41,6 +41,6 @@ def func(parser, options, args):
 
     name = crt_series.get_current()
     if name:
-        print name
+        out.stdout(name)
     else:
         raise CmdException, 'No patches applied'
diff --git a/stgit/commands/unapplied.py b/stgit/commands/unapplied.py
index cbac052..0d330a1 100644
--- a/stgit/commands/unapplied.py
+++ b/stgit/commands/unapplied.py
@@ -46,7 +46,7 @@ def func(parser, options, args):
     unapplied = crt_series.get_unapplied()
 
     if options.count:
-        print len(unapplied)
+        out.stdout(len(unapplied))
     else:
         for p in unapplied:
-            print p
+            out.stdout(p)
diff --git a/stgit/commands/uncommit.py b/stgit/commands/uncommit.py
index d5f64da..f611d29 100644
--- a/stgit/commands/uncommit.py
+++ b/stgit/commands/uncommit.py
@@ -99,13 +99,13 @@ def func(parser, options, args):
     commits = []
     next_commit = crt_series.get_base()
     if patch_nr:
-        print 'Uncommitting %d patches...' % patch_nr,
+        out.start('Uncommitting %d patches' % patch_nr)
         for i in xrange(patch_nr):
             commit, commit_id, parent = get_commit(next_commit)
             commits.append((commit, commit_id, parent))
             next_commit = parent
     else:
-        print 'Uncommitting to %s...' % to_commit
+        out.start('Uncommitting to %s' % to_commit)
         while True:
             commit, commit_id, parent = get_commit(next_commit)
             commits.append((commit, commit_id, parent))
@@ -114,7 +114,6 @@ def func(parser, options, args):
             next_commit = parent
         patch_nr = len(commits)
 
-    sys.stdout.flush()
     for (commit, commit_id, parent), patchname in \
         zip(commits, patchnames or [None for i in xrange(len(commits))]):
         author_name, author_email, author_date = \
@@ -127,5 +126,4 @@ def func(parser, options, args):
                              author_email = author_email,
                              author_date = author_date)
 
-
-    print 'done'
+    out.done()
diff --git a/stgit/commands/unhide.py b/stgit/commands/unhide.py
index f2db05f..74e4743 100644
--- a/stgit/commands/unhide.py
+++ b/stgit/commands/unhide.py
@@ -46,4 +46,4 @@ def func(parser, options, args):
 
     for patch in patches:
         crt_series.unhide_patch(patch)
-        print 'Patch "%s" unhidden' % patch
+        out.info('Patch "%s" unhidden' % patch)
diff --git a/stgit/git.py b/stgit/git.py
index 86630ce..845c712 100644
--- a/stgit/git.py
+++ b/stgit/git.py
@@ -220,9 +220,8 @@ def __tree_status(files = None, tree_id = 'HEAD', unknown = False,
                   noexclude = True, verbose = False):
     """Returns a list of pairs - [status, filename]
     """
-    if verbose and sys.stdout.isatty():
-        print 'Checking for changes in the working directory...',
-        sys.stdout.flush()
+    if verbose:
+        out.start('Checking for changes in the working directory')
 
     refresh_index()
 
@@ -260,8 +259,8 @@ def __tree_status(files = None, tree_id = 'HEAD', unknown = False,
         if fs[1] not in conflicts:
             cache_files.append(fs)
 
-    if verbose and sys.stdout.isatty():
-        print 'done'
+    if verbose:
+        out.done()
 
     return cache_files
 
@@ -449,8 +448,7 @@ def __copy_single(source, target, target2=''):
         for f in [f.strip() for f in realfiles]:
             m = prefix_regexp.match(f)
             if not m:
-                print '"%s" does not match "%s"' % (f, re_string)
-                assert(m)
+                raise Exception, '"%s" does not match "%s"' % (f, re_string)
             newname = target+target2+'/'+m.group(1)
             if not os.path.exists(os.path.dirname(newname)):
                 os.makedirs(os.path.dirname(newname))
@@ -767,9 +765,9 @@ def status(files = None, modified = False, new = False, deleted = False,
         if files and not fs[1] in files:
             continue
         if all:
-            print '%s %s' % (fs[0], fs[1])
+            out.stdout('%s %s' % (fs[0], fs[1]))
         else:
-            print '%s' % fs[1]
+            out.stdout('%s' % fs[1])
 
 def diff(files = None, rev1 = 'HEAD', rev2 = None, out_fd = None,
          binary = False):
@@ -958,7 +956,7 @@ def apply_patch(filename = None, diff = None, base = None,
             f = file('.stgit-failed.patch', 'w+')
             f.write(diff)
             f.close()
-            print >> sys.stderr, 'Diff written to the .stgit-failed.patch file'
+            out.warn('Diff written to the .stgit-failed.patch file')
 
         raise
 
diff --git a/stgit/gitmergeonefile.py b/stgit/gitmergeonefile.py
index 6f4c1d7..303e5f7 100644
--- a/stgit/gitmergeonefile.py
+++ b/stgit/gitmergeonefile.py
@@ -21,7 +21,7 @@ Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
 import sys, os
 from stgit import basedir
 from stgit.config import config, file_extensions, ConfigOption
-from stgit.utils import append_string
+from stgit.utils import append_string, out

 class GitMergeException(Exception):
@@ -133,8 +133,8 @@ def interactive_merge(filename):
 
     mtime = os.path.getmtime(filename)
 
-    print 'Trying the interractive %s merge' % \
-          (three_way and 'three-way' or 'two-way')
+    out.info('Trying the interactive %s merge'
+             % (three_way and 'three-way' or 'two-way'))
 
     err = os.system(imerger % files_dict)
     if err != 0:
@@ -165,16 +165,15 @@ def merge(orig_hash, file1_hash, file2_hash,
             if file1_hash == file2_hash:
                 if os.system('git-update-index --cacheinfo %s %s %s'
                              % (file1_mode, file1_hash, path)) != 0:
-                    print >> sys.stderr, 'Error: git-update-index failed'
+                    out.error('git-update-index failed')
                     __conflict(path)
                     return 1
                 if os.system('git-checkout-index -u -f -- %s' % path):
-                    print >> sys.stderr, 'Error: git-checkout-index failed'
+                    out.error('git-checkout-index failed')
                     __conflict(path)
                     return 1
                 if file1_mode != file2_mode:
-                    print >> sys.stderr, \
-                          'Error: File added in both, permissions conflict'
+                    out.error('File added in both, permissions conflict')
                     __conflict(path)
                     return 1
             # 3-way merge
@@ -189,9 +188,8 @@ def merge(orig_hash, file1_hash, file2_hash,
                     __remove_files(orig_hash, file1_hash, file2_hash)
                     return 0
                 else:
-                    print >> sys.stderr, \
-                          'Error: three-way merge tool failed for file "%s"' \
-                          % path
+                    out.error('Three-way merge tool failed for file "%s"'
+                              % path)
                     # reset the cache to the first branch
                     os.system('git-update-index --cacheinfo %s %s %s'
                               % (file1_mode, file1_hash, path))
@@ -201,7 +199,7 @@ def merge(orig_hash, file1_hash, file2_hash,
                             interactive_merge(path)
                         except GitMergeException, ex:
                             # interactive merge failed
-                            print >> sys.stderr, str(ex)
+                            out.error(ex)
                             if str(keeporig) != 'yes':
                                 __remove_files(orig_hash, file1_hash,
                                                file2_hash)
@@ -260,23 +258,21 @@ def merge(orig_hash, file1_hash, file2_hash,
             if file1_hash == file2_hash:
                 if os.system('git-update-index --add --cacheinfo %s %s %s'
                              % (file1_mode, file1_hash, path)) != 0:
-                    print >> sys.stderr, 'Error: git-update-index failed'
+                    out.error('git-update-index failed')
                     __conflict(path)
                     return 1
                 if os.system('git-checkout-index -u -f -- %s' % path):
-                    print >> sys.stderr, 'Error: git-checkout-index failed'
+                    out.error('git-checkout-index failed')
                     __conflict(path)
                     return 1
                 if file1_mode != file2_mode:
-                    print >> sys.stderr, \
-                          'Error: File "s" added in both, ' \
-                          'permissions conflict' % path
+                    out.error('File "s" added in both, permissions conflict'
+                              % path)
                     __conflict(path)
                     return 1
             # files added in both but different
             else:
-                print >> sys.stderr, \
-                      'Error: File "%s" added in branches but different' % path
+                out.error('File "%s" added in branches but different' % path)
                 # reset the cache to the first branch
                 os.system('git-update-index --cacheinfo %s %s %s'
                           % (file1_mode, file1_hash, path))
@@ -286,7 +282,7 @@ def merge(orig_hash, file1_hash, file2_hash,
                         interactive_merge(path)
                     except GitMergeException, ex:
                         # interactive merge failed
-                        print >> sys.stderr, str(ex)
+                        out.error(ex)
                         if str(keeporig) != 'yes':
                             __remove_files(orig_hash, file1_hash,
                                            file2_hash)
@@ -312,17 +308,16 @@ def merge(orig_hash, file1_hash, file2_hash,
                 obj = file2_hash
             if os.system('git-update-index --add --cacheinfo %s %s %s'
                          % (mode, obj, path)) != 0:
-                print >> sys.stderr, 'Error: git-update-index failed'
+                out.error('git-update-index failed')
                 __conflict(path)
                 return 1
             __remove_files(orig_hash, file1_hash, file2_hash)
             return os.system('git-checkout-index -u -f -- %s' % path)
 
     # Unhandled case
-    print >> sys.stderr, 'Error: Unhandled merge conflict: ' \
-          '"%s" "%s" "%s" "%s" "%s" "%s" "%s"' \
-          % (orig_hash, file1_hash, file2_hash,
-             path,
-             orig_mode, file1_mode, file2_mode)
+    out.error('Unhandled merge conflict: "%s" "%s" "%s" "%s" "%s" "%s" "%s"'
+              % (orig_hash, file1_hash, file2_hash,
+                 path,
+                 orig_mode, file1_mode, file2_mode))
     __conflict(path)
     return 1
diff --git a/stgit/main.py b/stgit/main.py
index 1a1f534..bcadbad 100644
--- a/stgit/main.py
+++ b/stgit/main.py
@@ -36,14 +36,12 @@ class Commands(dict):
         candidates = [cmd for cmd in self.keys() if cmd.startswith(key)]
 
         if not candidates:
-            print >> sys.stderr, 'Unknown command: %s' % key
-            print >> sys.stderr, '  Try "%s help" for a list of ' \
-                  'supported commands' % prog
+            out.error('Unknown command: %s' % key,
+                      'Try "%s help" for a list of supported commands' % prog)
             sys.exit(1)
         elif len(candidates) > 1:
-            print >> sys.stderr, 'Ambiguous command: %s' % key
-            print >> sys.stderr, '  Candidates are: %s' \
-                  % ', '.join(candidates)
+            out.error('Ambiguous command: %s' % key,
+                      'Candidates are: %s' % ', '.join(candidates))
             sys.exit(1)
 
         return candidates[0]
@@ -220,8 +218,7 @@ def main():
         if len(sys.argv) == 3 and not sys.argv[2] in ['-h', '--help']:
             cmd = commands.canonical_cmd(sys.argv[2])
             if not cmd in commands:
-                print >> sys.stderr, '%s help: "%s" command unknown' \
-                      % (prog, cmd)
+                out.error('%s help: "%s" command unknown' % (prog, cmd))
                 sys.exit(1)
 
             sys.argv[0] += ' %s' % cmd
@@ -267,7 +264,7 @@ def main():
     except KeyError:
         debug_level = 0
     except ValueError:
-        print >> sys.stderr, 'Invalid STGIT_DEBUG_LEVEL environment variable'
+        out.error('Invalid STGIT_DEBUG_LEVEL environment variable')
         sys.exit(1)
 
     try:
diff --git a/stgit/stack.py b/stgit/stack.py
index 223f3ee..63076af 100644
--- a/stgit/stack.py
+++ b/stgit/stack.py
@@ -307,6 +307,7 @@ def update_to_current_format_version(branch, git_dir):
             # The branch doesn't seem to be initialized at all.
             return None
     def set_format_version(v):
+        out.info('Upgraded branch %s to format version %d' % (branch, v))
         config.set(format_version_key(branch), '%d' % v)
     def mkdir(d):
         if not os.path.isdir(d):
@@ -491,10 +492,11 @@ class Series(StgitObject):
         if value:
             return value
         elif 'origin' in git.remotes_list():
-            print 'Notice: no parent remote declared for stack "%s", ' \
-                  'defaulting to "origin". Consider setting "branch.%s.remote" ' \
-                  'and "branch.%s.merge" with "git repo-config".' \
-                  % (self.__name, self.__name, self.__name)
+            out.note(('No parent remote declared for stack "%s",'
+                      ' defaulting to "origin".' % self.__name),
+                     ('Consider setting "branch.%s.remote" and'
+                      ' "branch.%s.merge" with "git repo-config".'
+                      % (self.__name, self.__name)))
             return 'origin'
         else:
             raise StackException, 'Cannot find a parent remote for "%s"' % self.__name
@@ -507,10 +509,10 @@ class Series(StgitObject):
         if value:
             return value
         elif git.rev_parse('heads/origin'):
-            print 'Notice: no parent branch declared for stack "%s", ' \
-                  'defaulting to "heads/origin". Consider setting ' \
-                  '"branch.%s.stgit.parentbranch" with "git repo-config".' \
-                  % (self.__name, self.__name)
+            out.note(('No parent branch declared for stack "%s",'
+                      ' defaulting to "heads/origin".' % self.__name),
+                     ('Consider setting "branch.%s.stgit.parentbranch"'
+                      ' with "git repo-config".' % self.__name))
             return 'heads/origin'
         else:
             raise StackException, 'Cannot find a parent branch for "%s"' % self.__name
@@ -646,10 +648,10 @@ class Series(StgitObject):
                                             author_email = patch.get_authemail(),
                                             author_date = patch.get_authdate())
             if patch.get_log():
-                print "setting log to %s" %  patch.get_log()
+                out.info('Setting log to %s' %  patch.get_log())
                 newpatch.set_log(patch.get_log())
             else:
-                print "no log for %s" % p
+                out.info('No log for %s' % p)
 
         # fast forward the cloned series to self's top
         new_series.forward_patches(applied)
@@ -697,17 +699,18 @@ class Series(StgitObject):
             if not os.listdir(self.__patch_dir):
                 os.rmdir(self.__patch_dir)
             else:
-                print 'Patch directory %s is not empty.' % self.__patch_dir
+                out.warn('Patch directory %s is not empty' % self.__patch_dir)
 
             try:
                 os.removedirs(self._dir())
             except OSError:
-                raise StackException, 'Series directory %s is not empty.' % self._dir()
+                raise StackException('Series directory %s is not empty'
+                                     % self._dir())
 
             try:
                 os.removedirs(self.__refs_dir)
             except OSError:
-                print 'Refs directory %s is not empty.' % self.__refs_dir
+                out.warn('Refs directory %s is not empty' % self.__refs_dir)
 
         # Cleanup parent informations
         # FIXME: should one day make use of git-config --section-remove,
@@ -1046,10 +1049,9 @@ class Series(StgitObject):
                 try:
                     git.merge(bottom, head, top, recursive = True)
                 except git.GitException, ex:
-                    print >> sys.stderr, \
-                          'The merge failed during "push". ' \
-                          'Use "refresh" after fixing the conflicts or ' \
-                          'revert the operation with "push --undo".'
+                    out.error('The merge failed during "push".',
+                              'Use "refresh" after fixing the conflicts or'
+                              ' revert the operation with "push --undo".')
 
         append_string(self.__applied_file, name)
 
diff --git a/stgit/utils.py b/stgit/utils.py
index fbfe748..8df9103 100644
--- a/stgit/utils.py
+++ b/stgit/utils.py
@@ -21,6 +21,86 @@ along with this program; if not, write to the Free Software
 Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
 """
 
+class MessagePrinter(object):
+    def __init__(self):
+        class Output(object):
+            def __init__(self, write, flush):
+                self.write = write
+                self.flush = flush
+                self.at_start_of_line = True
+                self.level = 0
+            def new_line(self):
+                """Ensure that we're at the beginning of a line."""
+                if not self.at_start_of_line:
+                    self.write('\n')
+                    self.at_start_of_line = True
+            def single_line(self, msg, print_newline = True,
+                            need_newline = True):
+                """Write a single line. Newline before and after are
+                separately configurable."""
+                if need_newline:
+                    self.new_line()
+                if self.at_start_of_line:
+                    self.write('  '*self.level)
+                self.write(msg)
+                if print_newline:
+                    self.write('\n')
+                    self.at_start_of_line = True
+                else:
+                    self.flush()
+                    self.at_start_of_line = False
+            def tagged_lines(self, tag, lines):
+                tag += ': '
+                for line in lines:
+                    self.single_line(tag + line)
+                    tag = ' '*len(tag)
+            def write_line(self, line):
+                """Write one line of text on a lines of its own, not
+                indented."""
+                self.new_line()
+                self.write('%s\n' % line)
+                self.at_start_of_line = True
+            def write_raw(self, string):
+                """Write an arbitrary string, possibly containing
+                newlines."""
+                self.new_line()
+                self.write(string)
+                self.at_start_of_line = string.endswith('\n')
+        self.__stdout = Output(sys.stdout.write, sys.stdout.flush)
+        if sys.stdout.isatty():
+            self.__out = self.__stdout
+        else:
+            self.__out = Output(lambda msg: None, lambda: None)
+    def stdout(self, line):
+        """Write a line to stdout."""
+        self.__stdout.write_line(line)
+    def stdout_raw(self, string):
+        """Write a string possibly containing newlines to stdout."""
+        self.__stdout.write_raw(string)
+    def info(self, *msgs):
+        for msg in msgs:
+            self.__out.single_line(msg)
+    def note(self, *msgs):
+        self.__out.tagged_lines('Notice', msgs)
+    def warn(self, *msgs):
+        self.__out.tagged_lines('Warning', msgs)
+    def error(self, *msgs):
+        self.__out.tagged_lines('Error', msgs)
+    def start(self, msg):
+        """Start a long-running operation."""
+        self.__out.single_line('%s ... ' % msg, print_newline = False)
+        self.__out.level += 1
+    def done(self, extramsg = None):
+        """Finish long-running operation."""
+        self.__out.level -= 1
+        if extramsg:
+            msg = 'done (%s)'
+        else:
+            msg = 'done'
+        self.__out.single_line(msg, need_newline = False)
+
+out = MessagePrinter()
+
 def mkdir_file(filename, mode):
     """Opens filename with the given mode, creating the directory it's
     in if it doesn't already exist."""
@@ -166,12 +246,11 @@ def call_editor(filename):
         editor = 'vi'
     editor += ' %s' % filename
 
-    print 'Invoking the editor: "%s"...' % editor,
-    sys.stdout.flush()
+    out.start('Invoking the editor: "%s"' % editor)
     err = os.system(editor)
     if err:
         raise EditorException, 'editor failed, exit code: %d' % err
-    print 'done'
+    out.done()
 
 def patch_name_from_msg(msg):
     """Return a string to be used as a patch name. This is generated