]> git.ipfire.org Git - thirdparty/rsync.git/commitdiff
Change some packaging tools into python3 and make a few improvements.
authorWayne Davison <wayned@samba.org>
Sun, 12 Apr 2020 07:13:35 +0000 (00:13 -0700)
committerWayne Davison <wayned@samba.org>
Sun, 12 Apr 2020 07:13:35 +0000 (00:13 -0700)
packaging/branch-from-patch
packaging/git-status.pl [deleted file]
packaging/patch-update
packaging/pkglib.py [new file with mode: 0644]
packaging/release-rsync
support/git-set-file-times

index a66cb16f87630dc5fed70309ab8941f6432b2954..493f557d41f95a6cfe684f4faa2264db8354a9f2 100755 (executable)
-#!/usr/bin/perl
-
-use strict;
-use warnings;
-use Getopt::Long;
-
-&Getopt::Long::Configure('bundling');
-&usage if !&GetOptions(
-    'branch|b=s' => \( my $master_branch = 'master' ),
-    'skip-check' => \( my $skip_branch_check ),
-    'delete' => \( my $delete_local_branches ),
-    'help|h' => \( my $help_opt ),
-);
-&usage if $help_opt;
-
-push @INC, '.';
-
-require 'packaging/git-status.pl';
-check_git_state($master_branch, !$skip_branch_check, 1);
-
-my %local_branch;
-open PIPE, '-|', 'git branch -l' or die "Unable to fork: $!\n";
-while (<PIPE>) {
-    if (m# patch/\Q$master_branch\E/(.*)#o) {
-       $local_branch{$1} = 1;
-    }
-}
-close PIPE;
-
-if ($delete_local_branches) {
-    foreach my $name (sort keys %local_branch) {
-       my $branch = "patch/$master_branch/$name";
-       system 'git', 'branch', '-D', $branch and exit 1;
-    }
-    %local_branch = ( );
-}
-
-my @patch_list;
-foreach (@ARGV) {
-    if (!-f $_) {
-       die "File not found: $_\n";
-    }
-    die "Filename is not a .diff file: $_\n" unless /\.diff$/;
-    push @patch_list, $_;
-}
-
-exit unless @patch_list;
-
-my(%scanned, %created, %info);
-
-foreach my $patch (@patch_list) {
-    my($where, $name) = $patch =~ m{^(.*?)([^/]+)\.diff$};
-    next if $scanned{$name}++;
-
-    open IN, '<', $patch or die "Unable to open $patch: $!\n";
-
-    my $info = '';
-    my $commit;
-    while (<IN>) {
-       if (m#^based-on: (\S+)#) {
-           $commit = $1;
-           last;
-       }
-       last if m#^index .*\.\..* \d#;
-       last if m#^diff --git #;
-       last if m#^--- (old|a)/#;
-       $info .= $_;
-    }
-    close IN;
-
-    $info =~ s/\s+\Z/\n/;
-
-    my $parent = $master_branch;
-    my @patches = $info =~ m#patch -p1 <patches/(\S+)\.diff#g;
-    if (@patches) {
-       if ($patches[-1] eq $name) {
-           pop @patches;
-       } else {
-           warn "No identity patch line in $patch\n";
-       }
-       if (@patches) {
-           $parent = pop @patches;
-           if (!$scanned{$parent}) {
-               unless (-f "$where$parent.diff") {
-                   die "Unknown parent of $patch: $parent\n";
-               }
-               # Add parent to @patch_list so that we will look for the
-               # parent's parent.  Any duplicates will just be ignored.
-               push @patch_list, "$where$parent.diff";
-           }
-       }
-    } else {
-       warn "No patch lines found in $patch\n";
-    }
-
-    $info{$name} = [ $parent, $info, $commit ];
-}
-
-foreach my $patch (@patch_list) {
-    create_branch($patch);
-}
-
-system 'git', 'checkout', $master_branch and exit 1;
-
-exit;
-
-sub create_branch
-{
-    my($patch) = @_;
-    my($where, $name) = $patch =~ m{^(.*?)([^/]+)\.diff$};
-
-    return if $created{$name}++;
-
-    my $ref = $info{$name};
-    my($parent, $info, $commit) = @$ref;
-
-    my $parent_branch;
-    if ($parent eq $master_branch) {
-       $parent_branch = $master_branch;
-       $parent_branch = $commit if defined $commit;
-    } else {
-       create_branch("$where/$parent.diff");
-       $parent_branch = "patch/$master_branch/$parent";
-    }
-
-    my $branch = "patch/$master_branch/$name";
-    print "\n", '=' x 64, "\nProcessing $branch ($parent_branch)\n";
-
-    if ($local_branch{$name}) {
-       system 'git', 'branch', '-D', $branch and exit 1;
-    }
-
-    system 'git', 'checkout', '-b', $branch, $parent_branch and exit 1;
-
-    open OUT, '>', "PATCH.$name" or die $!;
-    print OUT $info;
-    close OUT;
-    system 'git', 'add', "PATCH.$name" and exit 1;
-
-    open IN, '<', $patch or die "Unable to open $patch: $!\n";
-    $_ = join('', <IN>);
-    close IN;
-
-    open PIPE, '|-', 'patch -p1' or die $!;
-    print PIPE $_;
-    close PIPE;
-
-    system 'rm -f *.orig */*.orig';
-
-    while (m#\nnew file mode (\d+)\s+--- /dev/null\s+\Q+++\E b/(.*)#g) {
-       chmod oct($1), $2;
-       system 'git', 'add', $2;
-    }
-
-    while (1) {
-       system 'git status';
-       print 'Press Enter to commit, Ctrl-C to abort, or type a wild-name to add a new file: ';
-       $_ = <STDIN>;
-       last if /^$/;
-       chomp;
-       system "git add $_";
-    }
-
-    while (system 'git', 'commit', '-a', '-m', "Creating branch from $name.diff.") {
-       exit 1 if system '/bin/zsh';
-    }
-}
-
-sub usage
-{
-    die <<EOT;
-Usage branch-from-patch [OPTIONS] patches/DIFF...
-
-Options:
--b, --branch=BRANCH  Create branches relative to BRANCH if no "based-on"
-                     header was found in the patch file.
-    --skip-check     Skip the check that ensures starting with a clean branch.
-    --delete         Delete all the local patch/BASE/* branches, not just the ones
-                     that are being recreated.
--h, --help           Output this help message.
-EOT
-}
+#!/usr/bin/python3 -B
+
+# This script turns one or more diff files in the patches dir (which is
+# expected to be a checkout of the rsync-patches git repo) into a branch
+# in the main rsync git checkout. This allows the applied patch to be
+# merged with the latest rsync changes and tested.  To update the diff
+# with the resulting changes, see the patch-update script.
+
+import os, sys, re, argparse, glob
+
+sys.path = ['packaging'] + sys.path
+
+from pkglib import *
+
+def main():
+    global created, info, local_branch
+
+    cur_branch, args.base_branch = check_git_state(args.base_branch, not args.skip_check, args.patches_dir)
+
+    local_branch = get_patch_branches(args.base_branch)
+
+    if args.delete_local_branches:
+        for name in sorted(local_branch):
+            branch = f"patch/{args.base_branch}/{name}"
+            cmd_chk(['git', 'branch', '-D', branch])
+        local_branch = set()
+
+    if args.add_missing:
+        for fn in sorted(glob.glob(f"{args.patches_dir}/*.diff")):
+            name = re.sub(r'\.diff$', '', re.sub(r'.+/', '', fn))
+            if name not in local_branch and fn not in args.patch_files:
+                args.patch_files.append(fn)
+
+    if not args.patch_files:
+        return
+
+    for fn in args.patch_files:
+        if not fn.endswith('.diff'):
+            die(f"Filename is not a .diff file: {fn}")
+        if not os.path.isfile(fn):
+            die(f"File not found: {fn}")
+
+    scanned = set()
+    info = { }
+
+    patch_list = [ ]
+    for fn in args.patch_files:
+        m = re.match(r'^(?P<dir>.*?)(?P<name>[^/]+)\.diff$', fn)
+        patch = argparse.Namespace(**m.groupdict())
+        if patch.name in scanned:
+            continue
+        patch.fn = fn
+
+        lines = [ ]
+        commit_hash = None
+        with open(patch.fn, 'r', encoding='utf-8') as fh:
+            for line in fh:
+                m = re.match(r'^based-on: (\S+)', line)
+                if m:
+                    commit_hash = m[1]
+                    break
+                if (re.match(r'^index .*\.\..* \d', line)
+                 or re.match(r'^diff --git ', line)
+                 or re.match(r'^--- (old|a)/', line)):
+                    break
+                lines.append(re.sub(r'\s*\Z', "\n", line))
+        info_txt = ''.join(lines).strip() + "\n"
+        lines = None
+
+        parent = args.base_branch
+        patches = re.findall(r'patch -p1 <%s/(\S+)\.diff' % args.patches_dir, info_txt)
+        if patches:
+            last = patches.pop()
+            if last != patch.name:
+                warn(f"No identity patch line in {patch.fn}")
+                patches.append(last)
+            if patches:
+                parent = patches.pop()
+                if parent not in scanned:
+                    diff_fn = patch.dir + parent + '.diff'
+                    if not os.path.isfile(diff_fn):
+                        die(f"Failed to find parent of {patch.fn}: {parent}")
+                    # Add parent to args.patch_files so that we will look for the
+                    # parent's parent.  Any duplicates will be ignored.
+                    args.patch_files.append(diff_fn)
+        else:
+            warn(f"No patch lines found in {patch.fn}")
+
+        info[patch.name] = [ parent, info_txt, commit_hash ]
+
+        patch_list.append(patch)
+
+    created = set()
+    for patch in patch_list:
+        create_branch(patch)
+
+    cmd_chk(['git', 'checkout', args.base_branch])
+
+
+def create_branch(patch):
+    if patch.name in created:
+        return
+    created.add(patch.name)
+
+    parent, info_txt, commit_hash = info[patch.name]
+    parent = argparse.Namespace(dir=patch.dir, name=parent, fn=patch.dir + parent + '.diff')
+
+    if parent.name == args.base_branch:
+        parent_branch = commit_hash if commit_hash else args.base_branch
+    else:
+        create_branch(parent)
+        parent_branch = '/'.join(['patch', args.base_branch, parent.name])
+
+    branch = '/'.join(['patch', args.base_branch, patch.name])
+    print("\n" + '=' * 64)
+    print(f"Processing {branch} ({parent_branch})")
+
+    if patch.name in local_branch:
+        cmd_chk(['git', 'branch', '-D', branch])
+
+    cmd_chk(['git', 'checkout', '-b', branch, parent_branch])
+
+    info_fn = 'PATCH.' + patch.name
+    with open(info_fn, 'w', encoding='utf-8') as fh:
+        fh.write(info_txt)
+    cmd_chk(['git', 'add', info_fn])
+
+    with open(patch.fn, 'r', encoding='utf-8') as fh:
+        patch_txt = fh.read()
+
+    cmd_run('patch -p1'.split(), input=patch_txt)
+
+    for fn in glob.glob('*.orig') + glob.glob('*/*.orig'):
+        os.unlink(fn)
+
+    pos = 0
+    new_file_re = re.compile(r'\nnew file mode (?P<mode>\d+)\s+--- /dev/null\s+\+\+\+ b/(?P<fn>.+)')
+    while True:
+        m = new_file_re.search(patch_txt, pos)
+        if not m:
+            break
+        os.chmod(m['fn'], int(m['mode'], 8))
+        cmd_chk(['git', 'add', m['fn']])
+        pos = m.end()
+
+    while True:
+        cmd_chk('git status'.split())
+        ans = input('Press Enter to commit, Ctrl-C to abort, or type a wild-name to add a new file: ')
+        if ans == '':
+            break
+        cmd_chk("git add " + ans, shell=True)
+
+    while True:
+        s = cmd_run(['git', 'commit', '-a', '-m', f"Creating branch from {patch.name}.diff."])
+        if not s.returncode:
+            break
+        s = cmd_run(['/bin/zsh'])
+        if s.returncode:
+            die('Aborting due to shell error code')
+
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(description="Create a git patch branch from an rsync patch file.", add_help=False)
+    parser.add_argument('--branch', '-b', dest='base_branch', metavar='BASE_BRANCH', default='master', help="The branch the patch is based on. Default: master.")
+    parser.add_argument('--add-missing', '-a', action='store_true', help="Add a branch for every patches/*.diff that doesn't have a branch.")
+    parser.add_argument('--skip-check', action='store_true', help="Skip the check that ensures starting with a clean branch.")
+    parser.add_argument('--delete', dest='delete_local_branches', action='store_true', help="Delete all the local patch/BASE/* branches, not just the ones that are being recreated.")
+    parser.add_argument('--patches-dir', '-p', metavar='DIR', default='patches', help="Override the location of the rsync-patches dir. Default: patches.")
+    parser.add_argument('patch_files', metavar='patches/DIFF_FILE', nargs='*', help="Specify what patch diff files to process. Default: all of them.")
+    parser.add_argument("--help", "-h", action="help", help="Output this help message and exit.")
+    args = parser.parse_args()
+    main()
+
+# vim: sw=4 et
diff --git a/packaging/git-status.pl b/packaging/git-status.pl
deleted file mode 100644 (file)
index f68023a..0000000
+++ /dev/null
@@ -1,52 +0,0 @@
-# Do some git-status checking for the current dir and (optionally)
-# the patches dir.
-
-sub check_git_state
-{
-    my($master_branch, $fatal_unless_clean, $check_patches_dir) = @_;
-
-    my($cur_branch) = check_git_status($fatal_unless_clean);
-    (my $branch = $cur_branch) =~ s{^patch/([^/]+)/[^/]+$}{$1}; # change patch/BRANCH/PATCH_NAME into BRANCH
-    if ($branch ne $master_branch) {
-       print "The checkout is not on the $master_branch branch.\n";
-       exit 1 if $master_branch ne 'master';
-       print "Do you want me to continue with --branch=$branch? [n] ";
-       $_ = <STDIN>;
-       exit 1 unless /^y/i;
-       $_[0] = $master_branch = $branch; # Updates caller's $master_branch too.
-    }
-
-    if ($check_patches_dir && -d 'patches/.git') {
-       ($branch) = check_git_status($fatal_unless_clean, 'patches');
-       if ($branch ne $master_branch) {
-           print "The *patches* checkout is on branch $branch, not branch $master_branch.\n";
-           print "Do you want to change it to branch $master_branch? [n] ";
-           $_ = <STDIN>;
-           exit 1 unless /^y/i;
-           system "cd patches && git checkout '$master_branch'";
-       }
-    }
-
-    return $cur_branch;
-}
-
-sub check_git_status
-{
-    my($fatal_unless_clean, $subdir) = @_;
-    $subdir = '.' unless defined $subdir;
-    my $status = `cd '$subdir' && git status`;
-    my $is_clean = $status =~ /\nnothing to commit.+working (directory|tree) clean/;
-
-    my($cur_branch) = $status =~ /^(?:# )?On branch (.+)\n/;
-    if ($fatal_unless_clean && !$is_clean) {
-       if ($subdir eq '.') {
-           $subdir = '';
-       } else {
-           $subdir = " *$subdir*";
-       }
-       die "The$subdir checkout is not clean:\n", $status;
-    }
-    ($cur_branch, $is_clean, $status);
-}
-
-1;
index e279c6bc614e9f41400e95f6e465caad97d9fcb1..e13679c07421529aa9ff8c70bbc26e10d2804537 100755 (executable)
-#!/usr/bin/perl
+#!/usr/bin/python3 -B
+
 # This script is used to turn one or more of the "patch/BASE/*" branches
 # into one or more diffs in the "patches" directory.  Pass the option
 # --gen if you want generated files in the diffs.  Pass the name of
 # one or more diffs if you want to just update a subset of all the
 # diffs.
 
-use strict;
-use warnings;
-use Getopt::Long;
-
-my $patches_dir = 'patches';
-my $tmp_dir = "patches.$$";
-my $make_gen_cmd = 'make -f prepare-source.mak conf && ./config.status && make gen';
-
-&Getopt::Long::Configure('bundling');
-&usage if !&GetOptions(
-    'branch|b=s' => \( my $master_branch = 'master' ),
-    'skip-check' => \( my $skip_branch_check ),
-    'shell|s' => \( my $launch_shell ),
-    'gen:s' => \( my $incl_generated_files ),
-    'help|h' => \( my $help_opt ),
-);
-&usage if $help_opt;
-
-$ENV{GIT_MERGE_AUTOEDIT} = 'no';
-
-if (defined $incl_generated_files) {
-    $patches_dir = $incl_generated_files if $incl_generated_files ne '';
-    $incl_generated_files = 1;
-}
-
-die "No '$patches_dir' directory was found.\n" unless -d $patches_dir;
-die "No '.git' directory present in the current dir.\n" unless -d '.git';
-
-push @INC, '.';
-
-require 'packaging/git-status.pl';
-my $starting_branch = check_git_state($master_branch, !$skip_branch_check, 1);
-
-my $master_commit;
-open PIPE, '-|', "git log -1 --no-color $master_branch" or die $!;
-while (<PIPE>) {
-    if (/^commit (\S+)/) {
-       $master_commit = $1;
-       last;
-    }
-}
-close PIPE;
-die "Unable to determine commit hash for master branch: $master_branch\n" unless defined $master_commit;
-
-if ($incl_generated_files) {
-    my @extra_files = get_extra_files();
-    die "'$tmp_dir' must not exist in the current directory.\n" if -e $tmp_dir;
-    mkdir($tmp_dir, 0700) or die "Unable to mkdir($tmp_dir): $!\n";
-    system "$make_gen_cmd && rsync -a @extra_files $tmp_dir/master/" and exit 1;
-}
-our $last_touch = time;
-
-my %patches;
-
-# Start by finding all patches so that we can load all possible parents.
-open(PIPE, '-|', 'git', 'branch', '-l') or die $!;
-while (<PIPE>) {
-    if (m# patch/\Q$master_branch\E/(.*)#o) {
-       $patches{$1} = 1;
-    }
-}
-close PIPE;
-
-my @patches = sort keys %patches;
-
-my(%parent, %description);
-foreach my $patch (@patches) {
-    my $branch = "patch/$master_branch/$patch";
-    my $desc = '';
-    open(PIPE, '-|', 'git', 'diff', '-U1000', "$master_branch...$branch", '--', "PATCH.$patch") or die $!;
-    while (<PIPE>) {
-       last if /^@@ /;
-    }
-    while (<PIPE>) {
-       next unless s/^[ +]//;
-       if (m#patch -p1 <patches/(\S+)\.diff# && $1 ne $patch) {
-           my $parent = $parent{$patch} = $1;
-           if (!$patches{$parent}) {
-               die "Parent of $patch is not a local branch: $parent\n";
-           }
-       }
-       $desc .= $_;
-    }
-    close PIPE;
-    $description{$patch} = $desc;
-}
-
-if (@ARGV) {
-    # Limit the list of patches to actually process based on @ARGV.
-    @patches = ( );
-    foreach (@ARGV) {
-       s{^patch(es)?/} {};
-       s{\.diff$} {};
-       if (!$patches{$_}) {
-           die "Local branch not available for patch: $_\n";
-       }
-       push(@patches, $_);
-    }
-}
-
-my %completed;
-foreach my $patch (@patches) {
-    next if $completed{$patch}++;
-    last unless update_patch($patch);
-}
-
-if ($incl_generated_files) {
-    system "rm -rf $tmp_dir";
-}
-
-sleep 1 while $last_touch >= time;
-system "git checkout $starting_branch" and exit 1;
-
-exit;
-
-
-sub update_patch
-{
-    my($patch) = @_;
-
-    my $parent = $parent{$patch};
-    my $based_on;
-    if (defined $parent) {
-       unless ($completed{$parent}++) {
-           update_patch($parent);
-       }
-       $based_on = $parent = "patch/$master_branch/$parent";
-    } else {
-       $parent = $master_branch;
-       $based_on = $master_commit;
-    }
-
-    print "======== $patch ========\n";
-
-    sleep 1 while $incl_generated_files && $last_touch >= time;
-    system "git checkout patch/$master_branch/$patch" and return 0;
-
-    my $ok = system("git merge $based_on") == 0;
-    if (!$ok || $launch_shell) {
-       my($parent_dir) = $parent =~ m{([^/]+)$};
-       print qq|"git merge $based_on" incomplete -- please fix.\n| if !$ok;
-       $ENV{PS1} = "[$parent_dir] $patch: ";
-       while (1) {
-           if (system($ENV{SHELL}) != 0) {
-               print "Abort? [n/y] ";
-               $_ = <STDIN>;
-               next unless /^y/i;
-               return 0;
-           }
-           my($cur_branch, $is_clean, $status) = check_git_status(0);
-           last if $is_clean;
-           print $status;
-       }
-    }
-
-    open(OUT, '>', "$patches_dir/$patch.diff") or die $!;
-    print OUT $description{$patch}, "\nbased-on: $based_on\n";
-
-    my @extra_files;
-    if ($incl_generated_files) {
-       @extra_files = get_extra_files();
-       system "$make_gen_cmd && rsync -a @extra_files $tmp_dir/$patch/" and exit 1;
-    }
-    $last_touch = time;
-
-    open(PIPE, '-|', 'git', 'diff', $based_on) or die $!;
-    DIFF: while (<PIPE>) {
-       while (m{^diff --git a/PATCH}) {
-           while (<PIPE>) {
-               last if m{^diff --git a/};
-           }
-           last DIFF if !defined $_;
-       }
-       next if /^index /;
-       print OUT $_;
-    }
-    close PIPE;
-
-    if ($incl_generated_files) {
-       my $parent_dir;
-       if ($parent eq $master_branch) {
-           $parent_dir = 'master';
-       } else {
-           ($parent_dir) = $parent =~ m{([^/]+)$};
-       }
-       open(PIPE, '-|', 'diff', '-Nurp', "$tmp_dir/$parent_dir", "$tmp_dir/$patch") or die $!;
-       while (<PIPE>) {
-           s#^(diff -Nurp) $tmp_dir/[^/]+/(.*?) $tmp_dir/[^/]+/(.*)#$1 a/$2 b/$3#o;
-           s#^\Q---\E $tmp_dir/[^/]+/([^\t]+)\t.*#--- a/$1#o;
-           s#^\Q+++\E $tmp_dir/[^/]+/([^\t]+)\t.*#+++ b/$1#o;
-           print OUT $_;
-       }
-       close PIPE;
-       unlink @extra_files;
-    }
-
-    close OUT;
-
-    1;
-}
-
-exit;
-
-sub get_extra_files
-{
-    my @extras;
-
-    open(IN, '<', 'Makefile.in') or die "Couldn't open Makefile.in: $!\n";
-    while (<IN>) {
-       if (s/^GENFILES=//) {
-           while (s/\\$//) {
-               $_ .= <IN>;
-           }
-           @extras = split(' ', $_);
-           last;
-       }
-    }
-    close IN;
-
-    return @extras;
-}
-
-sub usage
-{
-    die <<EOT;
-Usage: patch-update [OPTIONS] [patches/DIFF...]
-
-Options:
--b, --branch=BRANCH  The master branch to merge into the patch/BASE/* branches.
-    --gen[=DIR]      Include generated files.  Optional destination DIR
-                     arg overrides the default of using the "patches" dir.
-    --skip-check     Skip the check that ensures starting with a clean branch.
--s, --shell          Launch a shell for every patch/BASE/* branch updated, not
-                     just when a conflict occurs.
--h, --help           Output this help message.
-EOT
-}
+import os, sys, re, argparse, time, shutil
+
+sys.path = ['packaging'] + sys.path
+
+from pkglib import *
+
+MAKE_GEN_CMDS = [
+        'make -f prepare-source.mak conf'.split(),
+        './config.status'.split(),
+        'make gen'.split(),
+        ]
+TMP_DIR = "patches.gen"
+
+os.environ['GIT_MERGE_AUTOEDIT'] = 'no'
+
+def main():
+    global master_commit, parent_patch, description, completed, last_touch
+
+    if not os.path.isdir(args.patches_dir):
+        die(f'No "{args.patches_dir}" directory was found.')
+    if not os.path.isdir('.git'):
+        die('No ".git" directory present in the current dir.')
+
+    starting_branch, args.base_branch = check_git_state(args.base_branch, not args.skip_check, args.patches_dir)
+
+    master_commit = latest_git_hash(args.base_branch)
+
+    if args.gen:
+        if os.path.lexists(TMP_DIR):
+            die(f'"{TMP_DIR}" must not exist in the current directory.')
+        extra_files = get_extra_files()
+        os.mkdir(TMP_DIR, 0o700)
+        for cmd in MAKE_GEN_CMDS:
+            cmd_chk(cmd)
+        cmd_chk(['rsync', '-a', *extra_files, f'{TMP_DIR}/master/'])
+
+    last_touch = time.time()
+
+    # Start by finding all patches so that we can load all possible parents.
+    patches = sorted(list(get_patch_branches(args.base_branch)))
+
+    parent_patch = { }
+    description = { }
+
+    for patch in patches:
+        branch = f"patch/{args.base_branch}/{patch}"
+        desc = ''
+        proc = cmd_pipe(['git', 'diff', '-U1000', f"{args.base_branch}...{branch}", '--', f"PATCH.{patch}"])
+        in_diff = False
+        for line in proc.stdout:
+            if in_diff:
+                if not re.match(r'^[ +]', line):
+                    continue
+                line = line[1:]
+                m = re.search(r'patch -p1 <patches/(\S+)\.diff', line)
+                if m and m[1] != patch:
+                    parpat = parent_patch[patch] = m[1]
+                    if not parpat in patches:
+                        die(f"Parent of {patch} is not a local branch: {parpat}")
+                desc += line
+            elif re.match(r'^@@ ', line):
+                in_diff = True
+        description[patch] = desc
+        proc.communicate()
+
+    if args.patch_files: # Limit the list of patches to actually process
+        valid_patches = patches
+        patches = [ ]
+        for fn in args.patch_files:
+            name = re.sub(r'\.diff$', '', re.sub(r'.+/', '', fn))
+            if name not in valid_patches:
+                die(f"Local branch not available for patch: {name}")
+            patches.append(name)
+
+    completed = set()
+
+    for patch in patches:
+        if patch in completed:
+            continue
+        if not update_patch(patch):
+            break
+
+    if args.gen:
+        shutil.rmtree(TMP_DIR)
+
+    while last_touch >= time.time():
+        time.sleep(1)
+    cmd_chk(['git', 'checkout', starting_branch])
+
+
+def update_patch(patch):
+    global last_touch
+
+    completed.add(patch) # Mark it as completed early to short-circuit any (bogus) dependency loops.
+
+    parent = parent_patch.get(patch, None)
+    if parent:
+        if parent not in completed:
+            if not update_patch(parent):
+                return 0
+        based_on = parent = f"patch/{args.base_branch}/{parent}"
+    else:
+        parent = args.base_branch
+        based_on = master_commit
+
+    print(f"======== {patch} ========")
+
+    while args.gen and last_touch >= time.time():
+        time.sleep(1)
+    s = cmd_run(f"git checkout patch/{args.base_branch}/{patch}".split())
+    if s.returncode != 0:
+        return 0
+
+    s = cmd_run(['git', 'merge', based_on])
+    ok = s.returncode == 0
+    if not ok or args.shell:
+        m = re.search(r'([^/]+)$', parent)
+        parent_dir = m[1]
+        if not ok:
+            print(f'"git merge {based_on}" incomplete -- please fix.')
+        os.environ['PS1'] = f"[{parent_dir}] {patch}: "
+        while True:
+            s = cmd_run([os.environ.get('SHELL', '/bin/sh')])
+            if s.returncode != 0:
+                ans = input("Abort? [n/y] ")
+                if re.match(r'^y', ans, flags=re.I):
+                    return 0
+                continue
+            cur_branch, is_clean, status_txt = check_git_status(0)
+            if is_clean:
+                break
+            print(status_txt, end='')
+
+    with open(f"{args.patches_dir}/{patch}.diff", 'w', encoding='utf-8') as fh:
+        fh.write(description[patch])
+        fh.write(f"\nbased-on: {based_on}\n")
+
+        if args.gen:
+            extra_files = get_extra_files()
+            for cmd in MAKE_GEN_CMDS:
+                cmd_chk(cmd)
+            cmd_chk(['rsync', '-a', *extra_files, f"{TMP_DIR}/{patch}/"])
+        else:
+            extra_files = [ ]
+        last_touch = time.time()
+
+        proc = cmd_pipe(['git', 'diff', based_on])
+        skipping = False
+        for line in proc.stdout:
+            if skipping:
+                if not re.match(r'^diff --git a/', line):
+                    continue
+                skipping = False
+            elif re.match(r'^diff --git a/PATCH', line):
+                skipping = True
+                continue
+            if not re.match(r'^index ', line):
+                fh.write(line)
+        proc.communicate()
+
+        if args.gen:
+            e_tmp_dir = re.escape(TMP_DIR)
+            diff_re  = re.compile(r'^(diff -Nurp) %s/[^/]+/(.*?) %s/[^/]+/(.*)' % (e_tmp_dir, e_tmp_dir))
+            minus_re = re.compile(r'^\-\-\- %s/[^/]+/([^\t]+)\t.*' % e_tmp_dir)
+            plus_re  = re.compile(r'^\+\+\+ %s/[^/]+/([^\t]+)\t.*' % e_tmp_dir)
+
+            if parent == args.base_branch:
+                parent_dir = 'master'
+            else:
+                m = re.search(r'([^/]+)$', parent)
+                parent_dir = m[1]
+
+            proc = cmd_pipe(['diff', '-Nurp', f"{TMP_DIR}/{parent_dir}", f"{TMP_DIR}/{patch}"])
+            for line in proc.stdout:
+                line = diff_re.sub(r'\1 a/\2 b/\3', line)
+                line = minus_re.sub(r'--- a/\1', line)
+                line =  plus_re.sub(r'+++ b/\1', line)
+                fh.write(line)
+            proc.communicate()
+            for fn in extra_files:
+                os.unlink(fn)
+
+    return 1
+
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(description="Turn a git branch back into a diff files in the patches dir.", add_help=False)
+    parser.add_argument('--branch', '-b', dest='base_branch', metavar='BASE_BRANCH', default='master', help="The branch the patch is based on. Default: master.")
+    parser.add_argument('--skip-check', action='store_true', help="Skip the check that ensures starting with a clean branch.")
+    parser.add_argument('--shell', '-s', action='store_true', help="Launch a shell for every patch/BASE/* branch updated, not just when a conflict occurs.")
+    parser.add_argument('--gen', metavar='DIR', nargs='?', const='', help='Include generated files. Optional DIR value overrides the default of using the "patches" dir.')
+    parser.add_argument('--patches-dir', '-p', metavar='DIR', default='patches', help="Override the location of the rsync-patches dir. Default: patches.")
+    parser.add_argument('patch_files', metavar='patches/DIFF_FILE', nargs='*', help="Specify what patch diff files to process. Default: all of them.")
+    parser.add_argument("--help", "-h", action="help", help="Output this help message and exit.")
+    args = parser.parse_args()
+    if args.gen == '':
+        args.gen = args.patches_dir
+    elif args.gen is not None:
+        args.patches_dir = args.gen
+    main()
+
+# vim: sw=4 et
diff --git a/packaging/pkglib.py b/packaging/pkglib.py
new file mode 100644 (file)
index 0000000..fd6359e
--- /dev/null
@@ -0,0 +1,160 @@
+import os, sys, re, subprocess
+
+# This python3 library provides a few helpful routines that are
+# used by the latest packaging scripts.
+
+# Output the msg args to stderr.  Accepts all the args that print() accepts.
+def warn(*msg):
+    print(*msg, file=sys.stderr)
+
+
+# Output the msg args to stderr and die with a non-zero return-code.
+# Accepts all the args that print() accepts.
+def die(*msg):
+    warn(*msg)
+    sys.exit(1)
+
+
+def _tweak_opts(cmd, opts):
+    if type(cmd) == str:
+        opts['shell'] = True
+    if not 'encoding' in opts:
+        if opts.get('raw', False):
+            del opts['raw']
+        else:
+            opts['encoding'] = 'utf-8'
+
+
+# This does a normal subprocess.run() with some auto-args added to make life easier.
+def cmd_run(cmd, **opts):
+    _tweak_opts(cmd, opts)
+    return subprocess.run(cmd, **opts)
+
+
+# Works like cmd_run() with a default check=True specified for you.
+def cmd_chk(cmd, **opts):
+    return cmd_run(cmd, check=True, **opts)
+
+
+# Captures stdout & stderr together in a string and returns the (output, return-code) tuple.
+def cmd_txt(cmd, **opts):
+    opts['stdout'] = subprocess.PIPE
+    opts['stderr'] = subprocess.STDOUT
+    _tweak_opts(cmd, opts)
+    proc = subprocess.Popen(cmd, **opts)
+    out = proc.communicate()[0]
+    return (out, proc.returncode)
+
+
+# Captures stdout & stderr together in a string and returns the output if the command has a 0
+# return-code. Otherwise it throws an exception that indicates the return code and all the
+# captured output.
+def cmd_txt_chk(cmd, **opts):
+    out, rc = cmd_txt(cmd, **opts)
+    if rc != 0:
+        cmd_err = f'Command "{cmd}" returned non-zero exit status "{rc}" and output:\n{out}'
+        raise Exception(cmd_err)
+    return out
+
+
+# Starts a piped-output command of just stdout (by default) and leaves it up to you to do
+# any incremental reading of the output and to call communicate() on the returned object.
+def cmd_pipe(cmd, **opts):
+    opts['stdout'] = subprocess.PIPE
+    _tweak_opts(cmd, opts)
+    return subprocess.Popen(cmd, **opts)
+
+
+# Runs a "git status" command and dies if the checkout is not clean (the
+# arg fatal_unless_clean can be used to make that non-fatal.  Returns a
+# tuple of the current branch, the is_clean flag, and the status text.
+def check_git_status(fatal_unless_clean=True, subdir='.'):
+    status_txt = cmd_txt_chk(f"cd '{subdir}' && git status")
+    is_clean = re.search(r'\nnothing to commit.+working (directory|tree) clean', status_txt) != None
+
+    if not is_clean and fatal_unless_clean:
+        if subdir == '.':
+            subdir = ''
+        else:
+            subdir = f" *{subdir}*"
+        die(f"The{subdir} checkout is not clean:\n" + status_txt)
+
+    m = re.match(r'^(?:# )?On branch (.+)\n', status_txt)
+    cur_branch = m[1] if m else None
+
+    return (cur_branch, is_clean, status_txt)
+
+
+# Calls check_git_status() on the current git checkout and (optionally) a subdir path's
+# checkout. Use fatal_unless_clean to indicate if an unclean checkout is fatal or not.
+# The master_branch arg indicates what branch we want both checkouts to be using, and
+# if the branch is wrong the user is given the option of either switching to the right
+# branch or aborting.
+def check_git_state(master_branch, fatal_unless_clean=True, check_extra_dir=None):
+    cur_branch = check_git_status(fatal_unless_clean)[0]
+    branch = re.sub(r'^patch/([^/]+)/[^/]+$', r'\1', cur_branch) # change patch/BRANCH/PATCH_NAME into BRANCH
+    if branch != master_branch:
+        print(f"The checkout is not on the {master_branch} branch.")
+        if master_branch != 'master':
+            sys.exit(1)
+        ans = input(f"Do you want me to continue with --branch={branch}? [n] ")
+        if not ans or not re.match(r'^y', ans, flags=re.I):
+            sys.exit(1)
+        master_branch = branch
+
+    if check_extra_dir and os.path.isdir(os.path.join(check_extra_dir, '.git')):
+        branch = check_git_status(fatal_unless_clean, check_extra_dir)[0]
+        if branch != master_branch:
+            print(f"The *{check_extra_dir}* checkout is on branch {branch}, not branch {master_branch}.")
+            ans = input(f"Do you want to change it to branch {master_branch}? [n] ")
+            if not ans or not re.match(r'^y', ans, flags=re.I):
+                sys.exit(1)
+            subdir.check_call(f"cd {check_extra_dir} && git checkout '{master_branch}'", shell=True)
+
+    return (cur_branch, master_branch)
+
+
+# Return the git hash of the most recent commit.
+def latest_git_hash(branch):
+    out = cmd_txt_chk(['git', 'log', '-1', '--no-color', branch])
+    m = re.search(r'^commit (\S+)', out, flags=re.M)
+    if not m:
+        die(f"Unable to determine commit hash for master branch: {branch}")
+    return m[1]
+
+
+# Return a set of all branch names that have the format "patch/BASE_BRANCH/NAME"
+# for the given base_branch string.  Just the NAME portion is put into the set.
+def get_patch_branches(base_branch):
+    branches = set()
+    proc = cmd_pipe('git branch -l'.split())
+    for line in proc.stdout:
+        m = re.search(r' patch/([^/]+)/(.+)', line)
+        if m and m[1] == base_branch:
+            branches.add(m[2])
+    proc.communicate()
+    return branches
+
+
+# Snag the GENFILES values out of the Makefile.in file and return them as a list.
+def get_extra_files():
+    cont_re = re.compile(r'\\\n')
+
+    extras = [ ]
+
+    with open('Makefile.in', 'r', encoding='utf-8') as fh:
+        for line in fh:
+            if not extras:
+                chk = re.sub(r'^GENFILES=', '', line)
+                if line == chk:
+                    continue
+                line = chk
+            m = re.search(r'\\$', line)
+            line = re.sub(r'^\s+|\s*\\\n?$|\s+$', '', line)
+            extras += line.split()
+            if not m:
+                break
+
+    return extras
+
+# vim: sw=4 et
index fdb69efc17ecfea9631787eafed58286dad86742..20d8ea0709bb3d582116f76be01b48ba6838822b 100755 (executable)
-#!/usr/bin/perl
+#!/usr/bin/python3 -B
+
 # This script expects the directory ~/samba-rsync-ftp to exist and to be a
 # copy of the /home/ftp/pub/rsync dir on samba.org.  When the script is done,
 # the git repository in the current directory will be updated, and the local
 # ~/samba-rsync-ftp dir will be ready to be rsynced to samba.org.
 
-use strict;
-use warnings;
-use Cwd;
-use Getopt::Long;
-use Term::ReadKey;
-use Date::Format;
-
-my $dest = $ENV{HOME} . '/samba-rsync-ftp';
-my $passfile = $ENV{HOME} . '/.rsyncpass';
-my $path = $ENV{PATH};
-my $make_gen_cmd = 'make -f prepare-source.mak conf && ./config.status && make gen';
-
-&Getopt::Long::Configure('bundling');
-&usage if !&GetOptions(
-    'branch|b=s' => \( my $master_branch = 'master' ),
-    'help|h' => \( my $help_opt ),
-);
-&usage if $help_opt;
-
-my $now = time;
-my $cl_today = time2str('* %a %b %d %Y', $now);
-my $year = time2str('%Y', $now);
-my $ztoday = time2str('%d %b %Y', $now);
-(my $today = $ztoday) =~ s/^0//;
-
-my $curdir = Cwd::cwd;
-
-END {
-    unlink($passfile);
-}
-
-my @extra_files;
-open(IN, '<', 'Makefile.in') or die "Couldn't open Makefile.in: $!\n";
-while (<IN>) {
-    if (s/^GENFILES=//) {
-       while (s/\\$//) {
-           $_ .= <IN>;
-       }
-       @extra_files = split(' ', $_);
-       last;
-    }
-}
-close IN;
-
-my $break = <<EOT;
-==========================================================================
-EOT
-
-print $break, <<EOT, $break, "\n";
+import os, sys, re, argparse, glob, time, shutil, atexit, signal
+from datetime import datetime
+from getpass import getpass
+
+sys.path = ['packaging'] + sys.path
+
+from pkglib import *
+
+dest = os.environ['HOME'] + '/samba-rsync-ftp'
+passfile = os.environ['HOME'] + '/.rsyncpass'
+ORIGINAL_PATH = os.environ['PATH']
+MAKE_GEN_CMDS = [
+        'make -f prepare-source.mak conf'.split(),
+        './config.status'.split(),
+        'make gen'.split(),
+        ]
+REL_RE = re.compile(r'^\s+\S{2}\s\S{3}\s\d{4}\s+(?P<ver>\d+\.\d+\.\d+)\s+(?P<pdate>\d{2} \w{3} \d{4}\s+)?(?P<pver>\d+)$')
+
+def main():
+    now = datetime.now()
+    cl_today = now.strftime('* %a %b %d %Y')
+    year = now.strftime('%Y')
+    ztoday = now.strftime('%d %b %Y')
+    today = ztoday.lstrip('0')
+
+    curdir = os.getcwd()
+
+    atexit.register(remove_passfile)
+    signal.signal(signal.SIGINT, signal_handler)
+
+    extra_files = get_extra_files()
+
+    dash_line = '=' * 74
+
+    print(f"""\
+{dash_line}
 == This will release a new version of rsync onto an unsuspecting world. ==
-EOT
-
-die "$dest does not exist\n" unless -d $dest;
-die "There is no .git dir in the current directory.\n" unless -d '.git';
-die "'a' must not exist in the current directory.\n" if -e 'a';
-die "'b' must not exist in the current directory.\n" if -e 'b';
-
-require 'packaging/git-status.pl';
-check_git_state($master_branch, 1, 1);
-
-my $confversion;
-open(IN, '<', 'configure.ac') or die $!;
-while (<IN>) {
-    if (/^AC_INIT\(\[rsync\],\s*\[(\d.+?)\]/) {
-       $confversion = $1;
-       last;
-    }
-}
-close IN;
-die "Unable to find AC_INIT with version in configure.ac\n" unless defined $confversion;
-
-open(IN, '<', 'OLDNEWS') or die $!;
-$_ = <IN>;
-my($lastversion) = /(\d+\.\d+\.\d+)/;
-my($last_protocol_version, %pdate);
-while (<IN>) {
-    if (my($ver,$pdate,$pver) = /^\s+\S\S\s\S\S\S\s\d\d\d\d\s+(\d+\.\d+\.\d+)\s+(\d\d \w\w\w \d\d\d\d\s+)?(\d+)$/) {
-       $pdate{$ver} = $pdate if defined $pdate;
-       $last_protocol_version = $pver if $ver eq $lastversion;
-    }
-}
-close IN;
-die "Unable to determine protocol_version for $lastversion.\n" unless defined $last_protocol_version;
-
-my $protocol_version;
-open(IN, '<', 'rsync.h') or die $!;
-while (<IN>) {
-    if (/^#define\s+PROTOCOL_VERSION\s+(\d+)/) {
-       $protocol_version = $1;
-       last;
-    }
-}
-close IN;
-die "Unable to determine the current PROTOCOL_VERSION.\n" unless defined $protocol_version;
-
-my $version = $confversion;
-$version =~ s/dev/pre1/ || $version =~ s/pre(\d+)/ 'pre' . ($1 + 1) /e;
-
-print "Please enter the version number of this release: [$version] ";
-chomp($_ = <STDIN>);
-if ($_ eq '.') {
-    $version =~ s/pre\d+//;
-} elsif ($_ ne '') {
-    $version = $_;
-}
-die "Invalid version: `$version'\n" unless $version =~ /^[\d.]+(pre\d+)?$/;
-
-if (`git tag -l v$version` ne '') {
-    print "Tag v$version already exists.\n\nDelete tag or quit? [q/del] ";
-    $_ = <STDIN>;
-    exit 1 unless /^del/i;
-    system "git tag -d v$version";
-}
-
-if ($version =~ s/[-.]*pre[-.]*/pre/ && $confversion !~ /dev$/) {
-    $lastversion = $confversion;
-}
-
-print "Enter the previous version to produce a patch against: [$lastversion] ";
-chomp($_ = <STDIN>);
-$lastversion = $_ if $_ ne '';
-$lastversion =~ s/[-.]*pre[-.]*/pre/;
-
-my $pre = $version =~ /(pre\d+)/ ? $1 : '';
-
-my $release = $pre ? '0.1' : '1';
-print "Please enter the RPM release number of this release: [$release] ";
-chomp($_ = <STDIN>);
-$release = $_ if $_ ne '';
-$release .= ".$pre" if $pre;
-
-(my $finalversion = $version) =~ s/pre\d+//;
-my($proto_changed,$proto_change_date);
-if ($protocol_version eq $last_protocol_version) {
-    $proto_changed = 'unchanged';
-    $proto_change_date = "\t\t";
-} else {
-    $proto_changed = 'changed';
-    if (!defined($proto_change_date = $pdate{$finalversion})) {
-       while (1) {
-           print "On what date did the protocol change to $protocol_version get checked in? (dd Mmm yyyy) ";
-           chomp($_ = <STDIN>);
-           last if /^\d\d \w\w\w \d\d\d\d$/;
-       }
-       $proto_change_date = "$_\t";
-    }
-}
-
-my($srcdir,$srcdiffdir,$lastsrcdir,$skipping);
-if ($lastversion =~ /pre/) {
-    if (!$pre) {
-       die "You should not diff a release version against a pre-release version.\n";
-    }
-    $srcdir = $srcdiffdir = $lastsrcdir = 'src-previews';
-    $skipping = ' ** SKIPPING **';
-} elsif ($pre) {
-    $srcdir = $srcdiffdir = 'src-previews';
-    $lastsrcdir = 'src';
-    $skipping = ' ** SKIPPING **';
-} else {
-    $srcdir = $lastsrcdir = 'src';
-    $srcdiffdir = 'src-diffs';
-    $skipping = '';
-}
-
-print "\n", $break, <<EOT;
-\$version is "$version"
-\$lastversion is "$lastversion"
-\$dest is "$dest"
-\$curdir is "$curdir"
-\$srcdir is "$srcdir"
-\$srcdiffdir is "$srcdiffdir"
-\$lastsrcdir is "$lastsrcdir"
-\$release is "$release"
+{dash_line}
+""")
+
+    if not os.path.isdir(dest):
+        die(dest, "dest does not exist")
+    if not os.path.isdir('.git'):
+        die("There is no .git dir in the current directory.")
+    if os.path.lexists('a'):
+        die('"a" must not exist in the current directory.')
+    if os.path.lexists('b'):
+        die('"b" must not exist in the current directory.')
+    if os.path.lexists('patches.gen'):
+        die('"patches.gen" must not exist in the current directory.')
+
+    check_git_state(args.master_branch, True, 'patches')
+
+    confversion = None
+    with open('configure.ac', 'r', encoding='utf-8') as fh:
+        for line in fh:
+            m = re.match(r'^AC_INIT\(\[rsync\],\s*\[(\d.+?)\]', line)
+            if m:
+                confversion = m[1]
+                break
+    if not confversion:
+        die("Unable to find AC_INIT with version in configure.ac")
+
+    lastversion = last_protocol_version = None
+    pdate = { }
+    with open('OLDNEWS', 'r', encoding='utf-8') as fh:
+        for line in fh:
+            if not lastversion:
+                m = re.search(r'(\d+\.\d+\.\d+)', line)
+                if m:
+                    lastversion = m[1]
+            m = REL_RE.match(line)
+            if m:
+                if m['pdate']:
+                    pdate[m['ver']] = m['pdate']
+                if m['ver'] == lastversion:
+                    last_protocol_version = m['pver']
+    if not last_protocol_version:
+        die(f"Unable to determine protocol_version for {lastversion}.")
+
+    protocol_version = None
+    with open('rsync.h', 'r', encoding='utf-8') as fh:
+        for line in fh:
+            m = re.match(r'^#define\s+PROTOCOL_VERSION\s+(\d+)', line)
+            if m:
+                protocol_version = m[1]
+                break
+    if not protocol_version:
+        die("Unable to determine the current PROTOCOL_VERSION.")
+
+    version = confversion
+    m = re.search(r'pre(\d+)', version)
+    if m:
+        version = re.sub(r'pre\d+', 'pre' + str(int(m[1]) + 1), version)
+    else:
+        version = version.replace('dev', 'pre1')
+
+    ans = input(f"Please enter the version number of this release: [{version}] ")
+    if ans == '.':
+        version = re.sub(r'pre\d+', '', version)
+    elif ans != '':
+        version = ans
+    if not re.match(r'^[\d.]+(pre\d+)?$', version):
+        die(f'Invalid version: "{version}"')
+
+    v_ver = 'v' + version
+    rsync_ver = 'rsync-' + version
+    rsync_lastver = 'rsync-' + lastversion
+
+    if os.path.lexists(rsync_ver):
+        die(f'"{rsync_ver}" must not exist in the current directory.')
+    if os.path.lexists(rsync_lastver):
+        die(f'"{rsync_lastver}" must not exist in the current directory.')
+
+    out = cmd_txt_chk(['git', 'tag', '-l', v_ver])
+    if out != '':
+        print(f"Tag {v_ver} already exists.")
+        ans = input("\nDelete tag or quit? [Q/del] ")
+        if not re.match(r'^del', ans, flags=re.I):
+            die("Aborted")
+        cmd_chk(['git', 'tag', '-d', v_ver])
+
+    version = re.sub(r'[-.]*pre[-.]*', 'pre', version)
+    if 'pre' in version and not confversion.endswith('dev'):
+        lastversion = confversion
+
+    ans = input(f"Enter the previous version to produce a patch against: [{lastversion}] ")
+    if ans != '':
+        lastversion = ans
+    lastversion = re.sub(r'[-.]*pre[-.]*', 'pre', lastversion)
+
+    m = re.search(r'(pre\d+)', version)
+    pre = m[1] if m else ''
+
+    release = '0.1' if pre else '1'
+    ans = input(f"Please enter the RPM release number of this release: [{release}] ")
+    if ans != '':
+        release = ans
+    if pre:
+        release += '.' + pre
+
+    finalversion = re.sub(r'pre\d+', '', version)
+    if protocol_version == last_protocol_version:
+        proto_changed = 'unchanged'
+        proto_change_date = "\t\t"
+    else:
+        proto_changed = 'changed'
+        if finalversion in pdate:
+            proto_change_date = pdate[finalversion]
+        else:
+            while True:
+                ans = input("On what date did the protocol change to {protocol_version} get checked in? (dd Mmm yyyy) ")
+                if re.match(r'^\d\d \w\w\w \d\d\d\d$', ans):
+                    break
+            proto_change_date = ans + "\t"
+
+    if 'pre' in lastversion:
+        if not pre:
+            die("You should not diff a release version against a pre-release version.")
+        srcdir = srcdiffdir = lastsrcdir = 'src-previews'
+        skipping = ' ** SKIPPING **'
+    elif pre:
+        srcdir = srcdiffdir = 'src-previews'
+        lastsrcdir = 'src'
+        skipping = ' ** SKIPPING **'
+    else:
+        srcdir = lastsrcdir = 'src'
+        srcdiffdir = 'src-diffs'
+        skipping = ''
+
+    print(f"""
+{dash_line}
+version is "{version}"
+lastversion is "{lastversion}"
+dest is "{dest}"
+curdir is "{curdir}"
+srcdir is "{srcdir}"
+srcdiffdir is "{srcdiffdir}"
+lastsrcdir is "{lastsrcdir}"
+release is "{release}"
 
 About to:
     - tweak SUBPROTOCOL_VERSION in rsync.h, if needed
@@ -186,236 +193,250 @@ About to:
     - tweak the date in the *.yo files and generate the manpages
     - generate configure.sh, config.h.in, and proto.h
     - page through the differences
-
-EOT
-print "<Press Enter to continue> ";
-$_ = <STDIN>;
-
-my %specvars = ( 'Version:' => $finalversion, 'Release:' => $release,
-                '%define fullversion' => "\%{version}$pre", 'Released' => "$version.",
-                '%define srcdir' => $srcdir );
-my @tweak_files = ( glob('packaging/*.spec'), glob('packaging/*/*.spec'), glob('*.yo'),
-                   qw( configure.ac rsync.h NEWS OLDNEWS options.c ) );
-
-foreach my $fn (@tweak_files) {
-    open(IN, '<', $fn) or die $!;
-    undef $/; $_ = <IN>; $/ = "\n";
-    close IN;
-    if ($fn =~ /configure/) {
-       s/^(AC_INIT\(\[rsync\],\s*\[)\d.+?(\])/$1$version$2/m
-           or die "Unable to update AC_INIT with version in $fn\n";
-    } elsif ($fn =~ /\.spec/) {
-       while (my($str, $val) = each %specvars) {
-           s/^\Q$str\E .*/$str $val/m
-               or die "Unable to update $str in $fn\n";
-       }
-       s/^\* \w\w\w \w\w\w \d\d \d\d\d\d (.*)/$cl_today $1/m
-           or die "Unable to update ChangeLog header in $fn\n";
-    } elsif ($fn =~ /\.yo/) {
-       s/^(manpage\([^)]+\)\(\d+\)\()[^)]+(\).*)/$1$today$2/m
-           or die "Unable to update date in manpage() header in $fn\n";
-       s/^(This man ?page is current for version) \S+ (of rsync)/$1 $version $2/m
-           or die "Unable to update current version info in $fn\n";
-    } elsif ($fn eq 'rsync.h') {
-       s{(#define\s+SUBPROTOCOL_VERSION)\s+(\d+)}
-        { $1 . ' ' . get_subprotocol_version($2) }e
-           or die "Unable to find SUBPROTOCOL_VERSION define in $fn\n";
-    } elsif ($fn eq 'NEWS') {
-       s{^(NEWS for rsync \Q$finalversion\E )(\(UNRELEASED\))\s*(\nProtocol: )(\d+) (\([^)]+\))\n}
-        { $1 . ($pre ? $2 : "($today)") . "$3$protocol_version ($proto_changed)\n" }ei
-           or die "The first 2 lines of $fn are not in the right format.  They must be:\n"
-                . "NEWS for rsync $finalversion (UNRELEASED)\n"
-                . "Protocol: $protocol_version ($proto_changed)\n";
-    } elsif ($fn eq 'OLDNEWS') {
-       s{^(\t\S\S\s\S\S\S\s\d\d\d\d)(\t\Q$finalversion\E\t).*}
-        { ($pre ? $1 : "\t$ztoday") . $2 . $proto_change_date . $protocol_version }em
-           or die "Unable to find \"?? ??? $year\t$finalversion\" line in $fn\n";
-    } elsif ($fn eq 'options.c') {
-       if (s/(Copyright \(C\) 2002-)(\d+)( Wayne Davison)/$1$year$3/
-        && $2 ne $year) {
-           die "Copyright comments need to be updated to $year in all files!\n";
-       }
-       # Adjust the year in the --version output.
-       s/(rprintf\(f, "Copyright \(C\) 1996-)(\d+)/$1$year/
-           or die "Unable to find Copyright string in --version output of $fn\n";
-       next if $2 eq $year;
-    } else {
-       die "Unrecognized file in \@tweak_files: $fn\n";
-    }
-    open(OUT, '>', $fn) or die $!;
-    print OUT $_;
-    close OUT;
-}
-
-print $break;
-system "git diff --color | less -p '^diff .*'";
-
-my $srctar_name = "rsync-$version.tar.gz";
-my $pattar_name = "rsync-patches-$version.tar.gz";
-my $diff_name = "rsync-$lastversion-$version.diffs.gz";
-my $srctar_file = "$dest/$srcdir/$srctar_name";
-my $pattar_file = "$dest/$srcdir/$pattar_name";
-my $diff_file = "$dest/$srcdiffdir/$diff_name";
-my $news_file = "$dest/$srcdir/rsync-$version-NEWS";
-my $lasttar_file = "$dest/$lastsrcdir/rsync-$lastversion.tar.gz";
-
-print $break, <<EOT;
+""")
+    ans = input("<Press Enter to continue> ")
+
+    specvars = {
+        'Version:': finalversion,
+        'Release:': release,
+        '%define fullversion': f'%{{version}}{pre}',
+        'Released': version + '.',
+        '%define srcdir': srcdir,
+        }
+
+    tweak_files = 'configure.ac rsync.h NEWS OLDNEWS'.split()
+    tweak_files += glob.glob('packaging/*.spec')
+    tweak_files += glob.glob('packaging/*/*.spec')
+    tweak_files += glob.glob('*.yo')
+
+    for fn in tweak_files:
+        with open(fn, 'r', encoding='utf-8') as fh:
+            old_txt = txt = fh.read()
+        if 'configure' in fn:
+            x_re = re.compile(r'^(AC_INIT\(\[rsync\],\s*\[)\d.+?(\])', re.M)
+            txt = replace_or_die(x_re, r'\g<1>%s\2' % version, txt, f"Unable to update AC_INIT with version in {fn}")
+        elif '.spec' in fn:
+            for var, val in specvars.items():
+                x_re = re.compile(r'^%s .*' % re.escape(var), re.M)
+                txt = replace_or_die(x_re, var + ' ' + val, txt, f"Unable to update {var} in {fn}")
+            x_re = re.compile(r'^\* \w\w\w \w\w\w \d\d \d\d\d\d (.*)', re.M)
+            txt = replace_or_die(x_re, r'%s \1' % cl_today, txt, f"Unable to update ChangeLog header in {fn}")
+        elif '.yo' in fn:
+            x_re = re.compile(r'^(manpage\([^)]+\)\(\d+\)\()[^)]+(\).*)', re.M)
+            txt = replace_or_die(x_re, r'\g<1>%s\2' % today, txt, f"Unable to update date in manpage() header in {fn}")
+            x_re = re.compile(r'^(This man ?page is current for version) \S+ (of rsync)', re.M)
+            txt = replace_or_die(x_re, r'\1 %s \2' % version, txt, f"Unable to update current version info in {fn}")
+        elif fn == 'rsync.h':
+            x_re = re.compile('(#define\s+SUBPROTOCOL_VERSION)\s+(\d+)')
+            repl = lambda m: m[1] + ' ' + '0' if not pre or proto_changed != 'changed' else 1 if m[2] == '0' else m[2]
+            txt = replace_or_die(x_re, repl, txt, f"Unable to find SUBPROTOCOL_VERSION define in {fn}")
+        elif fn == 'NEWS':
+            x_re = re.compile(
+                    r'^(NEWS for rsync %s )(\(UNRELEASED\))\s*(\nProtocol: )(\d+) (\([^)]+\))\n' % re.escape(finalversion),
+                    re.I)
+            repl = lambda m: m[1] + (m[2] if pre else f"({today})") + m[3] + f"{protocol_version} ({proto_changed})\n"
+            msg = (f"The first 2 lines of {fn} are not in the right format.  They must be:\n"
+                    + f"NEWS for rsync {finalversion} (UNRELEASED)\n"
+                    + f"Protocol: {protocol_version} ({proto_changed})")
+            txt = replace_or_die(x_re, repl, txt, msg)
+        elif fn == 'OLDNEWS':
+            x_re = re.compile(r'^(\t\S\S\s\S\S\S\s\d\d\d\d)(\t%s\t).*' % re.escape(finalversion), re.M)
+            repl = lambda m: (m[1] if pre else "\t" + ztoday) + m[2] + proto_change_date + protocol_version
+            txt = replace_or_die(x_re, repl, txt, f'Unable to find "?? ??? {year}\t{finalversion}" line in {fn}')
+        else:
+            die(f"Unrecognized file in tweak_files: {fn}")
+
+        if txt != old_txt:
+            print(f"Updating {fn}")
+            with open(fn, 'w', encoding='utf-8') as fh:
+                fh.write(txt)
+
+    cmd_chk(['packaging/year-tweak'])
+
+    print(dash_line)
+    cmd_run("git diff --color | less -p '^diff .*'")
+
+    srctar_name = f"{rsync_ver}.tar.gz"
+    pattar_name = f"rsync-patches-{version}.tar.gz"
+    diff_name = f"{rsync_lastver}-{version}.diffs.gz"
+    srctar_file = f"{dest}/{srcdir}/{srctar_name}"
+    pattar_file = f"{dest}/{srcdir}/{pattar_name}"
+    diff_file = f"{dest}/{srcdiffdir}/{diff_name}"
+    news_file = f"{dest}/{srcdir}/{rsync_ver}-NEWS"
+    lasttar_file = f"{dest}/{lastsrcdir}/{rsync_lastver}.tar.gz"
+
+    print(f"""\
+{dash_line}
 
 About to:
     - commit all version changes
-    - merge the $master_branch branch into the patch/$master_branch/* branches
+    - merge the {args.master_branch} branch into the patch/{args.master_branch}/* branches
     - update the files in the "patches" dir and OPTIONALLY
       (if you type 'y') to launch a shell for each patch
+""")
+    ans = input("<Press Enter OR 'y' to continue> ")
 
-EOT
-print "<Press Enter OR 'y' to continue> ";
-my $ans = <STDIN>;
+    s = cmd_run(['git', 'commit', '-a', '-m', f'Preparing for release of {version}'])
+    if s.returncode:
+        die('Aborting')
 
-system "git commit -a -m 'Preparing for release of $version'" and exit 1;
+    print(f'Creating any missing patch branches.')
+    s = cmd_run(f'packaging/branch-from-patch --branch={args.master_branch} --add-missing')
+    if s.returncode:
+        die('Aborting')
 
-print "Updating files in \"patches\" dir ...\n";
-system "packaging/patch-update --branch=$master_branch";
+    print('Updating files in "patches" dir ...')
+    s = cmd_run(f'packaging/patch-update --branch={args.master_branch}')
+    if s.returncode:
+        die('Aborting')
 
-if ($ans =~ /^y/i) {
-    print "\nVisiting all \"patch/$master_branch/*\" branches ...\n";
-    system "packaging/patch-update --branch=$master_branch --skip-check --shell";
-}
+    if re.match(r'^y', ans, re.I):
+        print(f'\nVisiting all "patch/{args.master_branch}/*" branches ...')
+        cmd_run(f"packaging/patch-update --branch={args.master_branch} --skip-check --shell")
 
-if (-d 'patches/.git') {
-    system "cd patches && git commit -a -m 'The patches for $version.'" and exit 1;
-}
+    if os.path.isdir('patches/.git'):
+        s = cmd_run(f"cd patches && git commit -a -m 'The patches for {version}.'")
+        if s.returncode:
+            die('Aborting')
 
-print $break, <<EOT;
+    print(f"""\
+{dash_line}
 
 About to:
-    - create signed tag for this release: v$version
-    - create release diffs, "$diff_name"
-    - create release tar, "$srctar_name"
-    - generate rsync-$version/patches/* files
-    - create patches tar, "$pattar_name"
+    - create signed tag for this release: {v_ver}
+    - create release diffs, "{diff_name}"
+    - create release tar, "{srctar_name}"
+    - generate {rsync_ver}/patches/* files
+    - create patches tar, "{pattar_name}"
     - update top-level README, *NEWS, TODO, and ChangeLog
     - update top-level rsync*.html manpages
     - gpg-sign the release files
-    - update hard-linked top-level release files$skipping
-
-EOT
-print "<Press Enter to continue> ";
-$_ = <STDIN>;
-
-# We want to use our passphrase-providing "gpg" script, so modify the PATH.
-$ENV{PATH} = "$curdir/packaging/bin:$path";
-
-my $passphrase;
-while (1) {
-    ReadMode('noecho');
-    print "\nEnter your GPG pass-phrase: ";
-    chomp($passphrase = <STDIN>);
-    ReadMode(0);
-    print "\n";
-
-    # Briefly create a temp file with the passphrase for git's tagging use.
-    my $oldmask = umask 077;
-    unlink($passfile);
-    open(OUT, '>', $passfile) or die $!;
-    print OUT $passphrase, "\n";
-    close OUT;
-    umask $oldmask;
-    $ENV{'GPG_PASSFILE'} = $passfile;
-
-    $_ = `git tag -s -m 'Version $version.' v$version 2>&1`;
-    print $_;
-    next if /bad passphrase/;
-    exit 1 if /failed/;
-
-    if (-d 'patches/.git') {
-       $_ = `cd patches && git tag -s -m 'Version $version.' v$version 2>&1`;
-       print $_;
-       exit 1 if /bad passphrase|failed/;
-    }
-
-    unlink($passfile);
-    last;
-}
-
-$ENV{PATH} = $path;
-
-# Extract the generated files from the old tar.
-@_ = @extra_files;
-map { s#^#rsync-$lastversion/# } @_;
-system "tar xzf $lasttar_file @_";
-rename("rsync-$lastversion", 'a');
-
-print "Creating $diff_file ...\n";
-system "$make_gen_cmd && rsync -a @extra_files b/" and exit 1;
-my $sed_script = 's:^((---|\+\+\+) [ab]/[^\t]+)\t.*:\1:';
-system "(git diff v$lastversion v$version; diff -upN a b | sed -r '$sed_script') | gzip -9 >$diff_file";
-system "rm -rf a";
-rename('b', "rsync-$version");
-
-print "Creating $srctar_file ...\n";
-system "git archive --format=tar --prefix=rsync-$version/ v$version | tar xf -";
-system "support/git-set-file-times --quiet --prefix=rsync-$version/";
-system "fakeroot tar czf $srctar_file rsync-$version; rm -rf rsync-$version";
-
-print "Updating files in \"rsync-$version/patches\" dir ...\n";
-mkdir("rsync-$version", 0755);
-mkdir("rsync-$version/patches", 0755);
-system "packaging/patch-update --skip-check --branch=$master_branch --gen=rsync-$version/patches";
-
-print "Creating $pattar_file ...\n";
-system "fakeroot tar chzf $pattar_file rsync-$version/patches; rm -rf rsync-$version";
-
-print "Updating the other files in $dest ...\n";
-system "rsync -a README NEWS OLDNEWS TODO $dest";
-unlink($news_file);
-link("$dest/NEWS", $news_file);
-system "git log --name-status | gzip -9 >$dest/ChangeLog.gz";
-
-system "yodl2html -o $dest/rsync.html rsync.yo";
-system "yodl2html -o $dest/rsyncd.conf.html rsyncd.conf.yo";
-
-foreach my $fn ($srctar_file, $pattar_file, $diff_file) {
-    unlink("$fn.asc");
-    open(GPG, '|-', "gpg --batch --passphrase-fd=0 -ba $fn") or die $!;
-    print GPG $passphrase, "\n";
-    close GPG;
-}
-
-if (!$pre) {
-    system "rm $dest/rsync-*.gz $dest/rsync-*.asc $dest/rsync-*-NEWS $dest/src-previews/rsync-*diffs.gz*";
-
-    foreach my $fn ($srctar_file, "$srctar_file.asc",
-                   $pattar_file, "$pattar_file.asc",
-                   $diff_file, "$diff_file.asc", $news_file) {
-       (my $top_fn = $fn) =~ s#/src(-\w+)?/#/#;
-       link($fn, $top_fn);
-    }
-}
-
-print $break, <<'EOT';
+    - update hard-linked top-level release files{skipping}
+""")
+    ans = input("<Press Enter to continue> ")
+
+    # We want to use our passphrase-providing "gpg" script, so modify the PATH.
+    os.environ['PATH'] = f"{curdir}/packaging/bin:{ORIGINAL_PATH}"
+
+    while True:
+        passphrase = getpass("\nEnter your GPG pass-phrase: ")
+
+        # Briefly create a temp file with the passphrase for git's tagging use.
+        oldmask = os.umask(0o077)
+        if os.path.lexists(passfile):
+            os.unlink(passfile)
+        with open(passfile, 'w', encoding='utf-8') as fh:
+            fh.write(passphrase + "\n")
+        os.umask(oldmask)
+        os.environ['GPG_PASSFILE'] = passfile
+
+        out = cmd_txt(f"git tag -s -m 'Version {version}.' {v_ver}")[0]
+        print(out, end='')
+        if 'bad passphrase' in out:
+            continue
+        if 'failed' in out:
+            die('Aborting')
+
+        if os.path.isdir('patches/.git'):
+            out = cmd_txt(f"cd patches && git tag -s -m 'Version {version}.' {v_ver}")[0]
+            print(out, end='')
+            if 'bad passphrase' in out or 'failed' in out:
+                die('Aborting')
+
+        os.unlink(passfile)
+        break
+
+    os.environ['PATH'] = ORIGINAL_PATH
+
+    # Extract the generated files from the old tar.
+    tweaked_extra_files = [ f"{rsync_lastver}/{x}" for x in extra_files ]
+    cmd_chk(['tar', 'xzf', lasttar_file, *tweaked_extra_files])
+    os.rename(rsync_lastver, 'a')
+
+    print(f"Creating {diff_file} ...")
+    for cmd in MAKE_GEN_CMDS:
+        cmd_chk(cmd)
+    cmd_chk(['rsync', '-a', *extra_files, 'b/'])
+
+    sed_script = r's:^((---|\+\+\+) [ab]/[^\t]+)\t.*:\1:' # CAUTION: must not contain any single quotes!
+    cmd_chk(f"(git diff v{lastversion} {v_ver}; diff -upN a b | sed -r '{sed_script}') | gzip -9 >{diff_file}")
+    shutil.rmtree('a')
+    os.rename('b', rsync_ver)
+
+    print(f"Creating {srctar_file} ...")
+    cmd_chk(f"git archive --format=tar --prefix={rsync_ver}/ {v_ver} | tar xf -")
+    cmd_chk(f"support/git-set-file-times --quiet --prefix={rsync_ver}/")
+    cmd_chk(['fakeroot', 'tar', 'czf', srctar_file, rsync_ver])
+    shutil.rmtree(rsync_ver)
+
+    print(f'Updating files in "{rsync_ver}/patches" dir ...')
+    os.mkdir(rsync_ver, 0o755)
+    os.mkdir(f"{rsync_ver}/patches", 0o755)
+    cmd_chk(f"packaging/patch-update --skip-check --branch={args.master_branch} --gen={rsync_ver}/patches")
+
+    print(f"Creating {pattar_file} ...")
+    cmd_chk(f"fakeroot tar chzf {pattar_file} {rsync_ver}/patches")
+    shutil.rmtree(rsync_ver)
+
+    print(f"Updating the other files in {dest} ...")
+    cmd_chk('rsync -a README NEWS OLDNEWS TODO'.split() + [dest])
+    if os.path.lexists(news_file):
+        os.unlink(news_file)
+    os.link(f"{dest}/NEWS", news_file)
+    cmd_chk(f"git log --name-status | gzip -9 >{dest}/ChangeLog.gz")
+
+    cmd_chk(f"yodl2html -o {dest}/rsync.html rsync.yo")
+    cmd_chk(f"yodl2html -o {dest}/rsyncd.conf.html rsyncd.conf.yo")
+
+    for fn in (srctar_file, pattar_file, diff_file):
+        asc_fn = fn + '.asc'
+        if os.path.lexists(asc_fn):
+            os.unlink(asc_fn)
+        cmd_chk(['gpg', '--batch', '--passphrase-fd=0', '-ba', fn], input=passphrase)
+
+    if not pre:
+        for find in f'{dest}/rsync-*.gz {dest}/rsync-*.asc {dest}/rsync-*-NEWS {dest}/src-previews/rsync-*diffs.gz*'.split():
+            for fn in glob.glob(find):
+                os.unlink(fn)
+        top_link = [
+                srctar_file, f"{srctar_file}.asc",
+                pattar_file, f"{pattar_file}.asc",
+                diff_file, f"{diff_file}.asc",
+                news_file,
+                ]
+        for fn in top_link:
+            os.link(fn, re.sub(r'/src(-\w+)?/', '/', fn))
+
+    print(f"""\
+{dash_line}
 
 Local changes are done.  When you're satisfied, push the git repository
 and rsync the release files.  Remember to announce the release on *BOTH*
 rsync-announce@lists.samba.org and rsync@lists.samba.org (and the web)!
-EOT
-
-exit;
-
-sub get_subprotocol_version
-{
-    my($subver) = @_;
-    if ($pre && $proto_changed eq 'changed') {
-       return $subver == 0 ? 1 : $subver;
-    }
-    0;
-}
-
-sub usage
-{
-    die <<EOT;
-Usage: release-rsync [OPTIONS]
-
--b, --branch=BRANCH   The branch to release (default: master)
--h, --help            Display this help message
-EOT
-}
+""")
+
+
+def replace_or_die(regex, repl, txt, die_msg):
+    m = regex.search(txt)
+    if not m:
+        die(die_msg)
+    return regex.sub(repl, txt, 1)
+
+
+def remove_passfile():
+    if passfile and os.path.lexists(passfile):
+        os.unlink(passfile)
+
+
+def signal_handler(sig, frame):
+    die("\nAborting due to SIGINT.")
+
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(description="Prepare a new release of rsync in the git repo & ftp dir.", add_help=False)
+    parser.add_argument('--branch', '-b', dest='master_branch', default='master', help="The branch to release. Default: master.")
+    parser.add_argument("--help", "-h", action="help", help="Output this help message and exit.")
+    args = parser.parse_args()
+    main()
+
+# vim: sw=4 et
index 6fe641a39e9a6d000bcf1abe9e2abff220278c37..4a20d441beaaec0c69d6d16dadbc110024900efb 100755 (executable)
@@ -8,7 +8,7 @@ NULL_COMMIT_RE = re.compile(r'\0\0commit [a-f0-9]{40}$|\0$')
 def main():
     if not args.git_dir:
         cmd = 'git rev-parse --show-toplevel 2>/dev/null || echo .'
-        top_dir = subprocess.check_output(cmd, shell=True).decode('utf-8').strip()
+        top_dir = subprocess.check_output(cmd, shell=True, encoding='utf-8').strip()
         args.git_dir = os.path.join(top_dir, '.git')
         if not args.prefix:
             os.chdir(top_dir)
@@ -20,19 +20,31 @@ def main():
     else:
         cmd = git + 'ls-files -z'.split()
 
-    proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
-    out = proc.communicate()[0].decode('utf-8')
+    proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, encoding='utf-8')
+    out = proc.communicate()[0]
     ls = set(out.split('\0'))
     ls.discard('')
 
+    if not args.tree:
+        # All modified files keep their current mtime.
+        proc = subprocess.Popen(git + 'ls-files -m -z'.split(), stdout=subprocess.PIPE, encoding='utf-8')
+        out = proc.communicate()[0]
+        for fn in out.split('\0'):
+            if fn == '':
+                continue
+            if args.list:
+                mtime = os.lstat(fn).st_mtime
+                print_line(fn, mtime, mtime)
+            ls.discard(fn)
+
     cmd = git + 'log -r --name-only --no-color --pretty=raw --no-renames -z'.split()
     if args.tree:
         cmd.append(args.tree)
     cmd += ['--'] + args.files
 
-    proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
+    proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, encoding='utf-8')
     for line in proc.stdout:
-        line = line.decode('utf-8').strip()
+        line = line.strip()
         m = re.match(r'^committer .*? (\d+) [-+]\d+$', line)
         if m:
             commit_time = int(m[1])
@@ -46,12 +58,7 @@ def main():
                     fn = args.prefix + fn
                 mtime = os.lstat(fn).st_mtime
                 if args.list:
-                    if args.list > 1:
-                        ts = str(commit_time).rjust(10)
-                    else:
-                        ts = datetime.utcfromtimestamp(commit_time).strftime("%Y-%m-%d %H:%M:%S")
-                    chg = '.' if mtime == commit_time else '*'
-                    print(chg, ts, fn)
+                    print_line(fn, mtime, commit_time)
                 elif mtime != commit_time:
                     if not args.quiet:
                         print(f"Setting {fn}")
@@ -62,14 +69,24 @@ def main():
     proc.communicate()
 
 
+def print_line(fn, mtime, commit_time):
+    if args.list > 1:
+        ts = str(commit_time).rjust(10)
+    else:
+        ts = datetime.utcfromtimestamp(commit_time).strftime("%Y-%m-%d %H:%M:%S")
+    chg = '.' if mtime == commit_time else '*'
+    print(chg, ts, fn)
+
+
 if __name__ == '__main__':
-    parser = argparse.ArgumentParser(description="Set the times of the current git checkout to their last-changed time.")
+    parser = argparse.ArgumentParser(description="Set the times of the current git checkout to their last-changed time.", add_help=False)
     parser.add_argument('--git-dir', metavar='GIT_DIR', help="The git dir to query (defaults to affecting the current git checkout).")
     parser.add_argument('--tree', metavar='TREE-ISH', help="The tree-ish to query (defaults to the current branch).")
     parser.add_argument('--prefix', metavar='PREFIX_STR', help="Prepend the PREFIX_STR to each filename we tweak.")
     parser.add_argument('--quiet', '-q', action='store_true', help="Don't output the changed-file information.")
-    parser.add_argument('--list', '-l', action='count', help="List the files and their dates instead of changing them. Repeat for Unix Time instead of human reable.")
+    parser.add_argument('--list', '-l', action='count', help="List files & times instead of changing them. Repeat for Unix timestamp instead of human readable.")
     parser.add_argument('files', metavar='FILE', nargs='*', help="Specify a subset of checked-out files to tweak.")
+    parser.add_argument("--help", "-h", action="help", help="Output this help message and exit.")
     args = parser.parse_args()
     main()