#!/usr/bin/perl # push - A code deployment tool # Created by Jeremy Jongsma (jeremy@jongsma.org) use strict; use POSIX qw(mktime); use XML::Simple; use Getopt::Long; use LWP::UserAgent; use File::Basename; # Define these manually if running on Windows # On unix-like systems, they will be found if they're in the path my $RSYNC_PATH; my $SSH_PATH; my $FTPSYNC_PATH; # Find rsync if(!$RSYNC_PATH && open(WHICHOUT,"which rsync |")) { $RSYNC_PATH = ; close WHICHOUT; chomp $RSYNC_PATH; } # Find ssh if(!$SSH_PATH && open(WHICHOUT,"which ssh |")) { $SSH_PATH = ; close WHICHOUT; chomp $SSH_PATH; } my $start_time = time; my $version = "0.1b"; my $target; my $help; my $partial; my $fullpartial; my $config; my $config_dir; my $config_test; my $clean; my $force; my $debug; my $options = GetOptions( "help" => \$help, "configtest" => \$config_test, "clean" => \$clean, "force" => \$force, "debug" => \$debug, "partial=s" => sub { shift; $partial=shift; $fullpartial = $ENV{'PWD'}.'/'.$partial; } ); my $username = 'default'; if ($^O !~ /Win/) { my $username = `whoami`; chomp($username); } my $globals = {"commandsets" => {}, "serversets" => {}, "properties" => { "user" => $username } }; my @completed_targets; if($help) { &showHelp(); } else { print "Looking for configuration file..."; my $config_file = &find_parent_file("push.xml"); chomp($config_dir = dirname($config_file)); if(-r $config_file) { print "found.\n"; print "Pushfile: $config_file\n"; my $global_config = &normalize_paths($ENV{"HOME"}."/.push-global.xml"); if(-r $global_config) { print "\nglobal:\n"; print &label("include") . "Importing settings from ".$global_config."\n"; my $inccfg = XMLin($global_config,forcearray=>1); &load_properties(\$inccfg,1); } } else { print "not found.\n"; &die_custom("Configuration file not found."); } $config = XMLin($config_file,forcearray=>1); # Load global properties print "\nload:\n"; &load_properties(\$config); my $success = 1; if(scalar(@ARGV) == 0) { $success = &exec_target(\$config); } else { foreach $target (@ARGV) { # TODO : clear $globals, re-initialize local overrides, clear @completed_targets # @completed_targets = (); $success = (&exec_target(\$config,$target) && $success); } } if($success) { print "\nPUSH SUCCESSFUL\n"; print ×tamp(); } else { &die_custom("One or more actions failed to execute."); } } # Find a file in self or parent directories sub find_parent_file { my $filename = shift; my $cwd = $ENV{"PWD"}; chomp($cwd = dirname($cwd)) while($cwd ne "/" && !(-r "$cwd/$filename")); return &normalize_paths("$cwd/$filename"); } sub normalize_paths { my $pathname = shift; $pathname=~s|/\./|/| while($pathname=~m|/\./|); $pathname=~s|/[^/]*/\.\./|/| while($pathname=~m|/[^/]*/\.\./|); $pathname; } # Process a element sub exec_target { my $cfgfile = shift; my $target = shift; my $succeeded = 1; !$target && ($$cfgfile->{default} && ($target = $$cfgfile->{default})); # If target exists, check dependencies if($target && $$cfgfile->{target}->{$target}) { my @depends = split(/,/,$$cfgfile->{target}->{$target}->{depends}); foreach my $depend(@depends) { $succeeded = (&exec_target($cfgfile,$depend) && $succeeded) if(!&in_array($depend,@completed_targets)); } push(@completed_targets,$target) if (!&in_array($target,@completed_targets)); $cfgfile = \$$cfgfile->{target}->{$target}; } else { if($target) { &die_custom("Target not found: \"$target\"") } else { &die_custom("No target specified and default target not found.") } } if($cfgfile) { print "\n$target:\n"; &load_properties($cfgfile); $succeeded = (&do_actions($cfgfile) && $succeeded); } $succeeded; } sub load_properties { my $prop = shift; my $override = shift; # Import properties from files, recursively call &load_properties if($$prop->{"include-file"}) { foreach my $file(@{$$prop->{"include-file"}}) { my $xmlimport = ""; my $dispfilename = $file; if ($file !~ /^[a-z-]*:\/+.+/) { # local file $dispfilename = basename($file); chomp($dispfilename); if($file=~m/^\.\.\.\/(.*)$/) { $file = &find_parent_file($1) } else { $file = &normalize_paths($config_dir."/".$file); } $xmlimport = $file; } else { # remote file my $ua = LWP::UserAgent->new(timeout=>30); my $res = $ua->get($file); $xmlimport = $res->content; } print &label("include") . "Importing settings from ".$file."\n"; my $inccfg = XMLin($xmlimport,forcearray=>1); &load_properties(\$inccfg); } } # Load specific properties if($$prop->{property}) { print &label("property") . "Loading properties\n"; foreach my $key(keys(%{$$prop->{property}})) { if($key eq "root") { print &label("property") . "Warning: ignoring reserved property: \${root}\n"; } else { $globals->{properties}->{$key} = &rp($$prop->{property}->{$key}->{value}); } } } # Load commandsets if($$prop->{commandset}) { print &label("commandset") . "Loading command sets\n"; foreach my $key(keys(%{$$prop->{commandset}})) { $$prop->{commandset}->{$key}->{override} = "true" if($override); if(!$globals->{commandsets}->{$key} || $globals->{commandsets}->{$key}->{override} ne "true") { $globals->{commandsets}->{$key} = $$prop->{commandset}->{$key}; } } } # Load serversets if($$prop->{serverset}) { print &label("serverset") . "Loading server sets\n"; foreach my $key(keys(%{$$prop->{serverset}})) { $$prop->{serverset}->{$key}->{override} = "true" if($override); if(!$globals->{serversets}->{$key} || $globals->{serversets}->{$key}->{override} ne "true") { $globals->{serversets}->{$key} = $$prop->{serverset}->{$key}; } } } } sub do_actions { my $actions = shift; my $retval = 1; if($$actions->{sync}) { $retval = &do_sync(\$$actions->{sync}); } # Backwards compatibility if($$actions->{rsync}) { $retval = &do_sync(\$$actions->{rsync}); } if($$actions->{echo}) { foreach my $echo (@{$$actions->{echo}}) { print &label("echo") . &rp($echo) . "\n"; } } $retval; } sub do_sync { my $sync_actions = shift; my $retval = 1; my %sync_types = ( 'ssh' => \&do_rsync, 'rsync' => \&do_rsync, 'ftp' => \&do_ftp, ); foreach my $sync (@{$$sync_actions}) { my @prepcmds = @{$sync->{prepcmd}} if $sync->{prepcmd}; my @postcmds = @{$sync->{postcmd}} if $sync->{postcmd}; # Execute pre-push group commands &exec_group_cmds(@prepcmds); my @serversets = @{$sync->{serverset}}; foreach my $serverset (@serversets) { # Load serversets my $currset = $globals->{serversets}->{$serverset}; if ($currset) { # Backwards compatibility $currset->{user} = $currset->{sshuser} if $currset->{sshuser}; $currset->{path} = $currset->{module} if $currset->{module}; if (defined $sync_types{$currset->{mode}}) { $retval = $sync_types{$currset->{mode}}->($sync, $currset) && $retval; } } } # Execute post-push group commands &exec_group_cmds(@postcmds); } $retval; } # Sync over normal FTP, depending on mod time and file size sub do_ftp { my $sync = shift; my $currset = shift; # Load sync settings my $local = $sync->{localdir} ? &rp($sync->{localdir}) : ""; my $remote = $sync->{remotedir} ? &rp($sync->{remotedir}) : ""; my @prepcmds = @{$sync->{prepcmd}} if $sync->{prepcmd}; my @postcmds = @{$sync->{postcmd}} if $sync->{postcmd}; my $doclean = $clean ? $clean : ($sync->{clean} eq "true"); my $retval = 1; my $pushedpartial = 0; my $local = $config_dir.($local ? '/'.$local : ''); $local =~ s|/$||g; my $remote = $currset->{path}.($remote ? '/'.$remote : ''); $remote =~ s|/$||g; my $pushfile; if($partial) { $doclean = 0; if(-r $fullpartial) { # Does not exist in localdir, try next job return 0 if (substr($fullpartial, 0, length($local)) ne $local); $pushedpartial = 1; $pushfile = $fullpartial; $pushfile =~ s/^$local\///; } else { print &label("ftpsync")."File not found: $partial\n"; &die_custom("Partial push not completed."); } } my $username = $currset->{user}; my $password = $currset->{password}; my $path = &rp($currset->{path}); my $serverdesc = $currset->{description}; foreach my $server (@{$currset->{server}}) { # Execute pre-push single commands &exec_single_cmds($server,'',@prepcmds); print &label("ftpsync") . "Pushing "; if($partial) { print ($partial eq "."?"current directory":"\"".$partial."\""); } else { print $config->{name}; } print " to $server...\n"; my @remotefiles; my @localfiles; my @updatefiles; my @deletefiles; my $prefix; my $ftpcmd; if (-d "$local/$pushfile") { $prefix = $pushfile ? $pushfile.'/' : ''; $ftpcmd = "quote USER $username\n"; $ftpcmd .= "quote PASS $password\n"; $ftpcmd .= "cd $remote/$prefix\n"; $ftpcmd .= "ls -lR\n"; $ftpcmd .= "quit"; if ($pushfile) { my @rootentry = ($pushfile, 0, 0, 'd'); push @remotefiles, \@rootentry; } } else { $ftpcmd = "quote USER $username\n"; $ftpcmd .= "quote PASS $password\n"; $ftpcmd .= "cd $remote\n"; $ftpcmd .= "ls $pushfile\n"; $ftpcmd .= "quit"; } # Get remote file list my $parent = ''; open(FTPOUT, "echo \"$ftpcmd\" | ftp -n $server 2>&1 |"); while (my $outline=) { next if ($outline =~ /^$/ || $outline =~ /^total [0-9]/); if ($outline =~ m/^[\.\/]*(.*):$/) { $parent = $1.'/'; next; } my @filedata = &parse_file_data($prefix.$parent, $outline); push(@remotefiles, \@filedata) if (scalar @filedata && !&in_path($filedata[0], $sync->{"path-exclude"})); } close(FTPOUT); # Get local file list @localfiles = &get_local_files($local, $pushfile, $sync->{"path-exclude"}); # Create FTP script my $syncscript = "quote USER $username\n"; $syncscript .= "quote PASS $password\n"; $syncscript .= "binary\n"; $syncscript .= "cd $remote\n"; # Files to update @updatefiles = &get_changed_files(\@localfiles, $local, \@remotefiles, $remote, 0); my $lastdir = ''; foreach (@updatefiles) { my $filespec = $_; my $path = $$filespec[0]; my $file = basename($path); my $dir = dirname($path); $dir = !$dir || $dir eq '.' ? '' : '/'.$dir; if ($$filespec[3] eq 'd') { $syncscript .= "cd $remote$dir\n"; $syncscript .= "mkdir $file\n"; $syncscript .= "cd $file\n"; $syncscript .= "lcd $local$dir/$file\n"; $lastdir = "$dir/$file"; } else { if ($dir ne $lastdir) { if ($dir eq '.' || $dir eq '') { $syncscript .= "cd $remote\n"; $syncscript .= "lcd $local\n"; } else { $syncscript .= "cd $remote$dir\n"; $syncscript .= "lcd $local$dir\n"; } } $syncscript .= "put $file\n"; $lastdir = $dir; } } if ($doclean) { # Files to delete @deletefiles = &get_missing_files(\@remotefiles, \@localfiles); foreach (reverse @deletefiles) { my $filespec = $_; my $path = $$filespec[0]; my $file = basename($path); my $dir = dirname($path); $dir = !$dir || $dir eq '.' ? '' : '/'.$dir; if ($$filespec[3] eq 'd') { $syncscript .= "cd $remote$dir\n" if ($lastdir ne $dir); $syncscript .= "rmdir $file\n"; $lastdir = $dir; } else { if ($dir ne $lastdir) { if ($dir eq '.' || $dir eq '') { $syncscript .= "cd $remote\n"; $syncscript .= "lcd $local\n"; } else { $syncscript .= "cd $remote$dir\n"; $syncscript .= "lcd $local$dir\n"; } } $syncscript .= "delete $file\n"; $lastdir = $dir; } } } $syncscript .= "quit"; print $syncscript if ($debug); # Execute FTP script if (scalar @updatefiles + scalar @deletefiles) { open(FTPOUT, "echo \"$syncscript\" | ftp -n $server 2>&1 |"); while (my $outline=) { } close(FTPOUT); if($? != 0) { print &label("ftpsync")."ftpsync failed with status code: ".$retval."\n"; $retval = 0; } } # Execute post-push commands &exec_single_cmds($server,'',@postcmds); } if ($partial && $pushedpartial == 0) { print &label("ftpsync")."\"$partial\" does not exist in this target.\n"; } $retval; } sub parse_file_data { my $parent = shift; my $filelist = shift; my @filedata; # 0 = path, 1 = size, 2 = modtime, 3 = type ('f' or 'd') my %months = ('Jan' => 1, 'Feb' => 2, 'Mar' => 3, 'Apr' => 4, 'May' => 5, 'Jun' => 6, 'Jul' => 7, 'Aug' => 8, 'Sep' => 9, 'Oct' => 10, 'Nov' => 11, 'Dec' => 12); # Typical unix-based FTP servers if ($filelist =~ m/^([-d])[-rwxSsdt]{9}\s+[0-9]+\s+\w+\s+\w+\s+([0-9]+)\s+(\w{3,}\s+[0-9]*\s+[0-9:]+)\s+(.*)$/) { $filedata[0] = $parent.$4; chomp($filedata[0]); $filedata[1] = $2; $filedata[2] = $3; $filedata[3] = $1 eq 'd' ? 'd' : 'f'; # Alternate unix format } elsif ($filelist =~ m/^([-d])[-rwxSsdt]{9}\s+[0-9]+\s+\w+\s+\w+\s+([0-9]+)\s+([0-9]{4}-[0-9]{2}-[0-9]{2}\s+[0-9]{2}:[0-9]{2})\s+(.*)$/) { $filedata[0] = $parent.$4; chomp($filedata[0]); $filedata[1] = $2; $filedata[2] = $3; $filedata[3] = $1 eq 'd' ? 'd' : 'f'; } else { # TODO: Parse other server response formats return; } if ($filedata[2] =~ m/(\w{3,})\s+([0-9]+)\s+([0-9]{4})/) { my $mnum = $months{$1} - 1; my $timestamp = POSIX::mktime(0, 0, 0, $2, $mnum, $3-1900, 0, 0, -1); $filedata[2] = $timestamp; } elsif ($filedata[2] =~ m/(\w{3,})\s+([0-9]+)\s+([0-9]{2}):([0-9]{2})/) { my @now = localtime(); my $mnum = $months{$1} - 1; my $timestamp = POSIX::mktime(0, $4, $3, $2, $mnum, $now[5], 0, 0, -1); $filedata[2] = $timestamp; } elsif ($filedata[2] =~ m/([0-9]{4})-([0-9]{2})-([0-9]{2})\s+([0-9]{2}):([0-9]{2})/) { my $timestamp = POSIX::mktime(0, $5, $4, $3, $2-1, $1-1900, 0, 0, -1); $filedata[2] = $timestamp; my @gt = gmtime($filedata[2]); $filedata[2] = POSIX::mktime($gt[0], $gt[1], $gt[2], $gt[3], $gt[4], $gt[5], 0, 0, -1); } return @filedata; } sub in_path { my $file = shift; my $exclusions = shift; foreach (@$exclusions) { return 1 if ($file =~ /^$_/); } return 0; } sub get_local_files { my $base = shift; my $path = shift; my $exclusions = shift; my @fileinfo; $path = '' if ($path eq '.'); my $isdir = -d $base.'/'.$path; my @fstat = stat($base.'/'.$path); my @gt = gmtime($fstat[9]); my $mtime = POSIX::mktime($gt[0], $gt[1], $gt[2], $gt[3], $gt[4], $gt[5], 0, 0, -1); my @filedata = ($path, $fstat[7], $mtime, $isdir ? 'd' : 'f'); if (!&in_path($path, $exclusions)) { push @fileinfo, \@filedata if ($path); if ($isdir) { local(*DIR); opendir(DIR, $base.'/'.$path); while (my $file = readdir(DIR)) { push @fileinfo, &get_local_files($base, ($path ? $path.'/' : '').$file, $exclusions) if ($file !~ /^\./); } closedir(DIR); } } return @fileinfo; } sub get_changed_files { my $fileset1 = shift; my $dirbase1 = shift; my $fileset2 = shift; my $dirbase2 = shift; my $ignoretimes = shift; my @changedlist; foreach (@$fileset1) { my $file1 = $_; my $changed = 1; foreach (@$fileset2) { my $file2 = $_; if ($$file1[0] eq $$file2[0]) { $changed = !-d $dirbase1.'/'.$$file1[0] && ($$file1[1] != $$file2[1] || (!$ignoretimes && $$file1[2] > $$file2[2])); last; } } push(@changedlist, $file1) if ($changed); } return sort {$$a[0] cmp $$b[0]} @changedlist; } sub get_missing_files { my $fileset1 = shift; my $fileset2 = shift; my @missinglist; foreach (@$fileset1) { my $file1 = $_; my $missing = 1; foreach (@$fileset2) { my $file2 = $_; if ($$file1[0] eq $$file2[0]) { $missing = 0; last; } } push(@missinglist, $file1) if ($missing); } return sort {$$a[0] cmp $$b[0]} @missinglist; } sub do_rsync { my $sync = shift; my $currset = shift; # Load sync settings my $filespec = $sync->{localdir} ? &rp($sync->{localdir}) : ""; my $rfilespec = $sync->{remotedir} ? &rp($sync->{remotedir}) : ""; my @prepcmds = @{$sync->{prepcmd}} if $sync->{prepcmd}; my @postcmds = @{$sync->{postcmd}} if $sync->{postcmd}; my $doclean = $clean ? $clean : ($sync->{clean} eq "true"); my $retval = 1; my $pushedpartial = 0; if($RSYNC_PATH ne "") { my $fullspec = $config_dir.($filespec ? '/'.$filespec : ''); if($partial) { $doclean = 0; if(-r $fullpartial) { # Does not exist in localdir, try next rsync job next if (substr($fullpartial, 0, length($fullspec)) ne $fullspec); $pushedpartial = 1; my $local = $fullpartial; my $remote = $local; $remote=~s/^$config_dir\///; $remote=~s/^$filespec/$rfilespec/; chomp($remote=dirname($remote)); $rfilespec = $remote; $filespec = $local; } else { print &label("rsync")."File not found: $partial\n"; &die_custom("Partial push not completed."); } } else { $filespec = $fullspec; $filespec .= "/" if(-d $filespec && $filespec!~/[\/\.]$/); } my $sshuser = $currset->{user}; my $usessh = &rp($currset->{mode} eq "ssh"); my $path = &rp($currset->{path}); my $serverdesc = $currset->{description}; foreach my $server (@{$currset->{server}}) { # Create rsync command for server my $rsyncstr = $RSYNC_PATH . " -Cva"; $rsyncstr .= " -e ssh" if($usessh); $rsyncstr .= " ".$filespec." "; $rsyncstr .= "$sshuser\@" if($usessh); $rsyncstr .= "$server:"; $rsyncstr .= ":" if(!$usessh); $rsyncstr .= $path; $rsyncstr .= "/".($rfilespec ? $rfilespec."/" : ""); $rsyncstr .= " --exclude \"push.xml\""; # Deprecated, use element instead if ($sync->{exclude}) { $rsyncstr .= " --exclude \"".$sync->{exclude}."\"" if ($sync->{exclude}); print &label("rsync") . "Notice: \@exclude is a deprecated attribute, please use \n"; } if ($sync->{"path-exclude"}) { foreach my $exclusion(@{$sync->{"path-exclude"}}) { $rsyncstr .= " --exclude \"".$exclusion."\""; } } $rsyncstr .= " --include core/"; $rsyncstr .= " --include *.so"; $rsyncstr .= " --delete" if($doclean); # Execute pre-push single commands &exec_single_cmds($server,$sshuser,@prepcmds); print &label("rsync") . "Pushing "; if($partial) { print ($partial eq "."?"current directory":"\"".$partial."\""); } else { print $config->{name}; } print " to $server...\n"; open(RSYNCOUT,$rsyncstr." 2>&1 |"); while() { print &label("rsync") . $_; } close RSYNCOUT; if($? != 0) { print &label("rsync")."rsync failed with status code: ".$?."\n"; $retval = 0; #&die_custom("Rsync terminated abnormally."); } # Execute post-push commands &exec_single_cmds($server,$sshuser,@postcmds); } if ($partial && $pushedpartial == 0) { print &label("rsync")."\"$partial\" does not exist in this target.\n"; } $retval; } else { &die_custom("Rsync not found in path."); } } sub exec_group_cmds { my (@cmds) = @_; my $gcmd; foreach my $cmd (@cmds) { my $cmdset = $globals->{commandsets}->{$cmd}; if($cmdset->{scope} eq "group") { foreach $gcmd (@{$cmdset->{command}}) { print &label("gcmd") . "Executing \"" . $gcmd . "\"\n"; open(CMDOUT,$gcmd." 2>&1 |"); while() { print &label("gcmd") . $_; } close(CMDOUT); if($? != 0) { print &label("gcmd") . "Error: local execution of \"" . $cmdset->{description} . "\" failed."; &die_custom("A system call exited abnormally."); } } } } } sub exec_single_cmds { my $server = shift; my $user = shift; my @cmds = @_; foreach my $cmd (@cmds) { my $cmdset = $globals->{commandsets}->{$cmd}; if($cmdset->{scope} eq "local" || ($cmdset->{scope} ne "group" && $cmdset->{execute} ne "remote")) { foreach my $lcmd (@{$cmdset->{command}}) { print &label("lcmd") . "Executing \"" . $lcmd . "\"\n"; open(CMDOUT,$lcmd." 2>&1 |"); while() { print &label("lcmd") . $_; } close(CMDOUT); if($? != 0 && ($cmdset->{"allow-fail"} ne "true")) { print &label("lcmd") . "Error: local execution of \"" . $cmdset->{description} . "\" failed."; &die_custom("A system call exited abnormally."); } } } elsif($cmdset->{scope} eq "remote" || ($cmdset->{scope} ne "group" && $cmdset->{execute} eq "remote")) { if($SSH_PATH ne "") { foreach my $rcmd (@{$cmdset->{command}}) { my $sout; my $serr; my $exit; my @soutlist; my @serrlist; my $cmdlabel = &label("rcmd"); print &label("rcmd") . "Executing \"" . $rcmd . "\"\n"; open(CMDOUT,"$SSH_PATH -x $user\@$server \"$rcmd\" 2>&1 |"); while(my $cmdline=) { print &label("rcmd") . $cmdline if ($cmdline ne "\r\n" && $cmdline ne "\n"); } close(CMDOUT); if($? != 0 && !$cmdset->{"allow-fail"}) { print &label("rcmd") . "Error: remote execution of \"" . $cmdset->{description} . "\" failed."; &die_custom("A system call exited abnormally."); &die_custom("A remote system call exited abnormally."); } } } else { &die_custom("SSH not found in path."); } } } } sub die_custom { my $errmessage = shift; print "\n"; print STDERR "PUSH FAILED\n"; print STDERR $errmessage . " " if($errmessage); print STDERR "See output for details.\n\n" if($errmessage); print STDERR ×tamp; exit 1; } sub timestamp { my $total_time = (time - $start_time); my $total_str = ""; my $seconds = $total_time%60; my $minutes = ($total_time-$seconds)/60; if($minutes > 0) { $total_str = "$minutes minute"; $total_str .= "s" if($minutes != 1); $total_str .= " "; } $total_str .= "$seconds second"; $total_str .= "s" if($seconds != 1); return "Total time: " . $total_str . "\n"; } sub rp { return &replace_properties(@_); } sub replace_properties { my $string = shift; while($string =~ m/\${([A-Za-z0-9_\.]*)}/) { if($1 eq "root") { $string =~ s/\${$1}/$config_dir/g; } elsif($globals->{properties}->{$1}) { my $property = $globals->{properties}->{$1}; $string =~ s/\${$1}/$property/g; } else { &die_custom("Property not found: \${".$1."}"); } } return $string; } sub in_array { my ($tmember,@marray) = @_; my $retval = 0; foreach my $member(@marray) { $retval = 1 if($member eq $tmember); } return $retval; } sub label { my $title = shift; my $pad = (12 - length($title)); my $i = 0; my $retval = ""; while($i++<$pad) { $retval .= " "; } $retval .= "[".$title."] "; } sub path_replace { my $src = shift; my $match = shift; my $replace = shift; $src=~s|$match|$replace|; $src; } sub showHelp { print < Only push the specified file or directory. --partial= -c, --clean Remove remote files and directories that don't exist locally (same as in config). -f, --force Force update all remote files, even if sizes and modtimes match (may take a long time). -h, --help Print this help message. EOH ; }