#!/usr/bin/perl

use strict;
use warnings;
use English qw( -no_match_vars );    # Avoids regex performance penalty
use Data::Dumper;
use FindBin qw($RealBin $RealScript);
use Getopt::Long;
use Cwd qw(realpath getcwd);
use File::Spec;
use File::Copy;
use File::Basename;
use Pod::Usage;

our $VERSION = 'v0.7.4';

my %opts;
my $shellrc_filename;
my $shellrc_load_filename;
my $repo_dir;
my $home;

my $command_aliases = {
    'mi'  => 'mergeandinstall',
    'umi' => 'updatemergeandinstall',
    'un'  => 'uninstall',
    'im'  => 'import',
    'in'  => 'install'
};

my $commands = {
    'install' => sub {
        DEBUG("Running in [$RealBin] and installing in [$home]");

        # install files
        install( $home, $repo_dir );
    },
    'updates' => sub {
        my $argv = shift;

        GetOptionsFromArray( $argv, \%opts, 'no-fetch' );

        fetch_updates( \%opts );
    },
    'mergeandinstall' => sub {
        my $argv = shift;

        GetOptionsFromArray( $argv, \%opts, 'merge', 'rebase' );

        merge_and_install( \%opts );
    },
    'updatemergeandinstall' => sub {
        my $argv = shift;

        GetOptionsFromArray( $argv, \%opts, 'merge', 'no-fetch' );

        fetch_updates( \%opts );
        merge_and_install( \%opts );
    },
    'uninstall' => sub {
        my $argv = shift;

        # uninstall files
        uninstall($home, $repo_dir);
    },
    'import' => sub {
        my $argv = shift;

        GetOptionsFromArray( $argv, \%opts, 'message=s', 'no-commit|n' );

        # import files
        import_files( _abs_repo_path( $home, $repo_dir ), $home, $argv );
    },
    'help' => sub {
        my $argv = shift;

        my $command = shift @$argv;

        if ($command) {
            $command = $command_aliases->{$command} || $command;

            my %options = (
                -verbose    => 99,
                -exitstatus => 0,
                -sections   => uc($command),
            );

            # if run as part of test, add option to point
            # to real script source
            if ( $RealScript eq '04.misc.t' ) {
                $options{'-input'} = '../dfm';
            }

            pod2usage(%options);
        }
        else {
            pod2usage(2);
        }
    },
};

run_dfm( $RealBin, @ARGV ) unless defined caller;

sub run_dfm {
    my ( $realbin, @argv ) = @_;

    # set options to nothing so that running multiple times in tests
    # does not reuse options
    %opts = ();
    $shellrc_filename = undef;
    $shellrc_load_filename = undef;
    $repo_dir = undef;
    $home = undef;

    my $command;

    if ( scalar(@argv) == 0 || $argv[0] =~ /^-/ ) {

        # check to make sure there's not a dfm subcommand later in the arg list
        if ( grep { exists $commands->{$_} } @argv ) {
            ERROR("The command should be first.");
            exit(-2);
        }
        $command = 'help';
    }
    else {
        $command = $argv[0];
    }

    $command = $command_aliases->{$command} || $command;

    if ( exists $commands->{$command} ) {

        # parse global options first
        Getopt::Long::Configure('pass_through');
        GetOptionsFromArray( \@argv, \%opts, 'verbose', 'quiet', 'dry-run', 'help', 'version' );
        Getopt::Long::Configure('no_pass_through');
    }

    $home = realpath( $ENV{HOME} );
    if ( !$home ) {
        ERROR("unable to determine 'realpath' for $ENV{HOME}");
        exit(-2);
    }

    if ( $ENV{'DFM_REPO'} ) {
        $repo_dir = $ENV{'DFM_REPO'};
        $repo_dir =~ s/$home\///;
    }
    elsif ( -e "$realbin/t/02.updates_mergeandinstall.t" ) {

        # dfm is being invoked from its own repo, not a dotfiles repo; try and
        # figure out what repo in the users's homedir is the dotfiles repo
        #
        # TODO: alternate strategy: see if there are files in $home that are
        # already symlinked and use those as a guide
        foreach my $potential_dotfiles_repo (qw(.dotfiles dotfiles)) {
            if (   -d "$home/$potential_dotfiles_repo"
                && -d "$home/$potential_dotfiles_repo/.git" )
            {
                $repo_dir = "$home/$potential_dotfiles_repo";
                $repo_dir =~ s/$home\///;
            }
        }

        if ( !$repo_dir ) {
            ERROR("unable to discover dotfiles repo and dfm is running from its own repo");
            exit(-2);
        }
    }
    else {
        $repo_dir = $realbin;
        $repo_dir =~ s/$home\///;
        $repo_dir =~ s/\/bin//;
    }

    DEBUG("Repo dir: $repo_dir");

    # extract the shell name from env
    my $shell = basename( $ENV{SHELL} );
    $shellrc_filename = '.' . $shell . 'rc';

    DEBUG("Shell: $shell, Shell RC filename: $shellrc_filename");

    # shellrc in MacOS is ~/.profile
    if ( lc($OSNAME) eq 'darwin' and $shell eq 'bash' ) {
        $shellrc_filename = '.profile';
    }

    if ( exists $commands->{$command} ) {
        if ( $opts{'help'} ) {
            $commands->{'help'}->( [$command] );
        }
        elsif ( $opts{'version'} ) {
            show_version();
        }
        else {
            shift(@argv);    # remove the command from the array
            $commands->{$command}->( \@argv );
        }
    }
    else {

        # assume it's a git command and call accordingly
        _run_git(@argv);
    }
}

sub my_symlink {
    my $target = shift;
    my $link   = shift;

    if ($^O eq "cygwin")
    {
        my $flags = "";
        if (-d $target) { $flags = "/D" };

        $target = `cygpath -w $target`;
        $link   = `cygpath -w $link`;

        chomp $target;
        chomp $link;

        my $command = "cmd /c mklink $flags \"$link\" \"$target\"";
        system($command);
    }
    else
    {
        symlink($target,$link);
    }
}

sub get_changes {
    my $what = shift;

    return `git log --pretty='format:%h: %s' $what`;
}

sub get_current_branch {
    my $current_branch = `git symbolic-ref HEAD`;
    chomp $current_branch;

    # convert 'refs/heads/personal' to 'personal'
    $current_branch =~ s/^.+\///g;

    DEBUG("current branch: $current_branch");

    return $current_branch;
}

sub check_remote_branch {
    my $branch        = shift;
    my $branch_remote = `git config branch.$branch.remote`;
    chomp $branch_remote;

    DEBUG("remote for branch $branch: $branch_remote");

    if ( $branch_remote eq "" ) {
        WARN("no remote found for branch $branch");
        exit(-1);
    }
}

# a few log4perl-alikes
sub ERROR {
    print "ERROR: @_\n";
}

sub WARN {
    print "WARN: @_\n";
}

sub INFO {
    print "INFO: @_\n" if !$opts{quiet};
}

sub DEBUG {
    print "DEBUG: @_\n" if $opts{verbose};
}

sub fetch_updates {
    my $opts = shift;

    chdir( _abs_repo_path( $home, $repo_dir ) );

    if ( !$opts->{'no-fetch'} ) {
        DEBUG('fetching changes');
        system("git fetch") if !$opts->{'dry-run'};
    }

    my $current_branch = get_current_branch();
    check_remote_branch($current_branch);

    print get_changes("$current_branch..$current_branch\@{u}"), "\n";
}

sub merge_and_install {
    my $opts = shift;

    chdir( _abs_repo_path( $home, $repo_dir ) );

    my $current_branch = get_current_branch();
    check_remote_branch($current_branch);

    my $sync_command = $opts->{'rebase'} ? 'rebase' : 'merge';

    if ( get_changes("$current_branch..$current_branch\@{u}") ) {

        # check for local commits
        if ( my $local_changes = get_changes("$current_branch\@{u}..$current_branch") ) {

            # if a decision wasn't made about how to deal with local commits
            if ( !$opts->{'merge'} && !$opts->{'rebase'} ) {
                WARN("local changes detected, run with either --merge or --rebase");
                print $local_changes, "\n";
                exit;
            }
        }

        INFO("using $sync_command to bring in changes");
        system("git $sync_command $current_branch\@{u}")
            if !$opts->{'dry-run'};

        INFO("re-installing dotfiles");
        install( $home, $repo_dir ) if !$opts->{'dry-run'};
    }
    else {
        INFO("no changes to merge");
    }
}

sub install {
    my ( $home, $repo_dir ) = @_;

    INFO( "Installing dotfiles..." . ( $opts{'dry-run'} ? ' (dry run)' : '' ) );

    DEBUG("Running in [$RealBin] and installing in [$home]");

    install_files( _abs_repo_path( $home, $repo_dir ), $home );

    $shellrc_load_filename = '';

    # link in the shell loader
    if ( -e _abs_repo_path( $home, $repo_dir ) . "/.shellrc.load" ) {
        $shellrc_load_filename = '.shellrc.load';
    }
    elsif ( -e _abs_repo_path( $home, $repo_dir ) . "/.bashrc.load" ) {
        $shellrc_load_filename = '.bashrc.load';
    }

    if ($shellrc_load_filename) {
        configure_shell_loader();
    }
}

sub uninstall {
    my ( $home, $repo_dir ) = @_;

    INFO( "Uninstalling dotfiles..." . ( $opts{'dry-run'} ? ' (dry run)' : '' ) );

    DEBUG("Running in [$RealBin] and installing in [$home]");

    # uninstall files
    uninstall_files( _abs_repo_path( $home, $repo_dir ), $home );

    # link in the shell loader
    if ( -e _abs_repo_path( $home, $repo_dir ) . "/.shellrc.load" ) {
        $shellrc_load_filename = '.shellrc.load';
    }
    elsif ( -e _abs_repo_path( $home, $repo_dir ) . "/.bashrc.load" ) {
        $shellrc_load_filename = '.bashrc.load';
    }

    if ($shellrc_load_filename) {
        unconfigure_shell_loader();
    }
}

# function to install files
# possible options:
#   install_only: list of files to install, as opposed to all of them
sub install_files {
    my ( $source_dir, $target_dir, $options ) = @_;

    my $install_only;

    if ( $options->{install_only}
        && scalar @{ $options->{install_only} } > 0 )
    {
        $install_only = $options->{install_only};
    }

    DEBUG("Installing from $source_dir into $target_dir");

    my $symlink_base = _calculate_symlink_base( $source_dir, $target_dir );

    my $backup_dir = $target_dir . '/.backup';
    DEBUG("Backup dir: $backup_dir");

    my $cwd_before_install = getcwd();
    chdir($target_dir);

    my $dfm_install = _load_dfminstall("$source_dir/.dfminstall");

    if ( !-e $backup_dir ) {
        DEBUG("Creating $backup_dir");
        mkdir($backup_dir) if !$opts{'dry-run'};
    }

    my $dirh;
    opendir $dirh, $source_dir;
    foreach my $direntry ( readdir($dirh) ) {

        if ($install_only) {
            next unless grep { $_ eq $direntry } @$install_only;
        }

        # skip vim swap files
        next if $direntry =~ /^\..*\.sw.$/;

        # skip emacs temporary and backup files
        next if $direntry =~ /^\.#.*$/;
        next if $direntry =~ /^.*~$/;

        # skip any other files
        next if $dfm_install->{skip_files}->{$direntry};

        DEBUG(" Working on $direntry");

        if ( !-l $direntry ) {
            if ( -e $direntry ) {
                INFO("  Backing up $direntry.");
                system("mv '$direntry' '$backup_dir/$direntry'")
                    if !$opts{'dry-run'};
            }
            INFO("  Symlinking $direntry ($symlink_base/$direntry).");
            my_symlink( "$symlink_base/$direntry", "$direntry" )
                if !$opts{'dry-run'};
        }
    }

    cleanup_dangling_symlinks( $source_dir, $target_dir, $dfm_install->{skip_files} );

    foreach my $recurse ( @{ $dfm_install->{recurse_files} } ) {
        if ( -d "$source_dir/$recurse" ) {
            DEBUG("recursing into $source_dir/$recurse");
            if ( -l "$target_dir/$recurse" ) {
                DEBUG("removing symlink $target_dir/$recurse");
                unlink("$target_dir/$recurse");
            }
            if ( !-d "$target_dir/$recurse" ) {
                DEBUG("making directory $target_dir/$recurse");
                mkdir("$target_dir/$recurse");
            }

            my $recurse_options;
            if ($install_only) {
                $recurse_options = {
                    install_only => [
                        map { s/^$recurse\///; $_ }
                        grep {/^$recurse/} @$install_only
                    ]
                };
            }
            install_files( "$source_dir/$recurse", "$target_dir/$recurse", $recurse_options );
        }
        else {
            WARN("couldn't recurse into $source_dir/$recurse, not a directory");
        }
    }

    foreach my $execute ( @{ $dfm_install->{execute_files} } ) {
        my $cwd = getcwd();

        if ( -x "$source_dir/$execute" ) {
            DEBUG("Executing $source_dir/$execute in $cwd");
            system("'$source_dir/$execute'");
        }
        elsif ( -o "$source_dir/$execute" ) {
            system("chmod +x '$source_dir/$execute'");

            DEBUG("Executing $source_dir/$execute in $cwd");
            system("'$source_dir/$execute'");
        }
    }

    foreach my $chmod_file ( keys %{ $dfm_install->{chmod_files} } ) {
        my $new_perms = $dfm_install->{chmod_files}->{$chmod_file};

        # TODO maybe skip if perms are already ok
        DEBUG("Setting permissions on $chmod_file to $new_perms");
        chmod oct($new_perms), $chmod_file;
    }

    # restore previous working directory
    chdir($cwd_before_install);
}

sub configure_shell_loader {
    chdir($home);

    my $shellrc_contents = _read_shellrc_contents();

    # check if the loader is in
    if ( $shellrc_contents !~ /$shellrc_load_filename/ ) {
        INFO("Appending loader to $shellrc_filename");
        $shellrc_contents .= "\n. \$HOME/$shellrc_load_filename\n";
    }

    # if the new loader filename (.shellrc.load) is used, but the old loader
    # filename (.bashrc.load) is in the shell rc, remove it
    if ( $shellrc_load_filename =~ m/shellrc/ && $shellrc_contents =~ /\.bashrc\.load/ ) {
        $shellrc_contents =~ s{\n. \$HOME/\.bashrc\.load\n}{}gs;
    }

    _write_shellrc_contents($shellrc_contents);
}

sub uninstall_files {
    my ( $source_dir, $target_dir ) = @_;

    DEBUG("Uninstalling from $target_dir");

    my $backup_dir = $target_dir . '/.backup';
    DEBUG("Backup dir: $backup_dir");

    chdir($target_dir);

    my $dfm_install = _load_dfminstall("$source_dir/.dfminstall");

    my $dirh;
    opendir $dirh, $target_dir;
    foreach my $direntry ( readdir($dirh) ) {

        DEBUG(" Working on $direntry");

        if ( -l $direntry ) {
            my $link_target = readlink($direntry);
            DEBUG("$direntry points a $link_target");
            my ( $volume, @elements ) = File::Spec->splitpath($link_target);
            my $element = pop @elements;

            my $target_base
                = realpath( File::Spec->rel2abs( File::Spec->catpath( '', @elements ) ) );

            DEBUG( "target_base '", defined $target_base ? $target_base : '', "' $source_dir" );
            if ( defined $target_base and $target_base eq $source_dir ) {
                INFO("  Removing $direntry ($link_target).");
                unlink($direntry) if !$opts{'dry-run'};
            }

            my $backup_path = File::Spec->catpath( '', '.backup', $element );
            if ( -e $backup_path ) {
                INFO("  Restoring $direntry from backup.");
                rename( $backup_path, $element ) if !$opts{'dry-run'};
            }
        }
    }

    foreach my $execute ( @{ $dfm_install->{execute_uninstall_files} } ) {
        my $cwd = getcwd();

        if ( -x "$source_dir/$execute" ) {
            DEBUG("Executing $source_dir/$execute in $cwd");
            system("'$source_dir/$execute'");
        }
        elsif ( -o "$source_dir/$execute" ) {
            system("chmod +x '$source_dir/$execute'");

            DEBUG("Executing $source_dir/$execute in $cwd");
            system("'$source_dir/$execute'");
        }
    }

    foreach my $recurse ( @{ $dfm_install->{recurse_files} } ) {
        if ( -d "$target_dir/$recurse" ) {
            DEBUG("recursing into $target_dir/$recurse");
            uninstall_files( "$source_dir/$recurse", "$target_dir/$recurse" );
        }
        else {
            WARN("couldn't recurse into $target_dir/$recurse, not a directory");
        }
    }
}

sub relative_to_target {
    my ( $tryfile, $target_dir ) = @_;

    if ( -l $tryfile ) {
        my ( $volume, $dirs, $lfile ) = File::Spec->splitpath($tryfile);
        return File::Spec->abs2rel( File::Spec->catfile( realpath($dirs), $lfile ), $target_dir );
    }
    else {
        return File::Spec->abs2rel( realpath($tryfile), $target_dir );
    }
}

sub import_files {
    my ( $source_dir, $target_dir, $files ) = @_;

    my $symlink_base = _calculate_symlink_base( $source_dir, $target_dir );

    foreach my $file (@$files) {

        if ( $file =~ m{^/} ) {
            $file = relative_to_target( $file, $target_dir );
        }
        else {
            my $tryfile = File::Spec->rel2abs($file);

            if ( -e $tryfile ) {

                #print "FOUND in cwd\n";
                $file = relative_to_target( $tryfile, $target_dir );
            }
            else {
                my $tryfile = File::Spec->rel2abs( $file, $target_dir );

                if ( -e $tryfile ) {

                    #print "FOUND in home\n";
                    $file = relative_to_target( $tryfile, $target_dir );
                }
            }
        }

        if ( $file =~ /^\.\./ ) {
            ERROR("file $file is not in your home directory");
            return;
        }

        # if dfm import $HOME is called
        if ( $file eq '.' ) {
            ERROR("unable to import your home directory itself");
            return;
        }

        if ( !-e "$target_dir/$file" ) {
            ERROR("file $file not found, unable to import");
            return;
        }

        DEBUG("file path, relative to homedir: $file");

        my ( $in_a_subdir, $subdir )
            = _file_in_tracked_or_untracked( $source_dir, $source_dir, $file );
        if ( $in_a_subdir eq 'untracked' ) {
            ERROR(
                "file $file is in a subdirectory that is not tracked, consider using 'dfm import $subdir'."
            );
            return;
        }
        elsif ( $in_a_subdir eq 'tracked' ) {
            ERROR(
                "file $file is in a subdirectory that is already tracked, consider using 'dfm add $subdir'."
            );
            return;
        }
        elsif ( $in_a_subdir eq 'skip' ) {
            ERROR("file $file is skipped.");
            return;
        }

        # detect file that's already tracked, either by being a symlink that
        # points into the repo or in the repo itself
        if ((   -l "$target_dir/$file"
                && ( readlink("$target_dir/$file") =~ /(\.\.\/)*$symlink_base/ )
            )
            || $file =~ /^$symlink_base/
            )
        {
            ERROR("file $file is already tracked.");
            return;
        }

    }

    my $message = $opts{message} || "importing " . join( ', ', @$files );

    foreach my $file (@$files) {
        INFO( "Importing $file from $target_dir into $source_dir"
                . ( $opts{'dry-run'} ? ' (dry run)' : '' ) );

        DEBUG("moving $file into $source_dir");
        if ( !$opts{'dry-run'} ) {
            move( "$target_dir/$file", "$source_dir/$file" );
        }

        if ( !$opts{'dry-run'} ) {
            _run_git( 'add', $file );
        }
    }

    install_files( _abs_repo_path( $home, $repo_dir ), $home, { install_only => [@$files] } );

    INFO( "Committing with message '$message'" . ( $opts{'dry-run'} ? ' (dry run)' : '' ) );
    if ( !$opts{'dry-run'} ) {
        if ( !$opts{'no-commit'} ) {
            _run_git( 'commit', @$files, '-m', $message );
        }
    }
}

sub cleanup_dangling_symlinks {
    my ( $source_dir, $target_dir, $skip_files ) = @_;
    $skip_files ||= {};

    DEBUG(" Cleaning up dangling symlinks in $target_dir");

    my $dirh;
    opendir $dirh, $target_dir;
    foreach my $direntry ( readdir($dirh) ) {

        DEBUG(" Working on $direntry");

        # if symlink is dangling or is now skipped
        if ( -l $direntry && ( !-e $direntry || $skip_files->{$direntry} ) ) {
            my $link_target = readlink($direntry);
            DEBUG("$direntry points at $link_target");
            my ( $volume, @elements ) = File::Spec->splitpath($link_target);
            my $element = pop @elements;

            my $target_base
                = realpath( File::Spec->rel2abs( File::Spec->catpath( '', @elements ) ) );

            DEBUG( "target_base '", defined $target_base ? $target_base : '', "' $source_dir" );
            if ( defined $target_base and $target_base eq $source_dir ) {
                INFO("  Cleaning up dangling symlink $direntry ($link_target).");
                unlink($direntry) if !$opts{'dry-run'};
            }
        }
    }
}

sub unconfigure_shell_loader {
    chdir($home);

    my $shellrc_contents = _read_shellrc_contents();

    # remove shell loader if found
    $shellrc_contents =~ s{\n. \$HOME/$shellrc_load_filename\n}{}gs;

    _write_shellrc_contents($shellrc_contents);
}

sub _write_shellrc_contents {
    my $shellrc_contents = shift;

    if ( !$opts{'dry-run'} ) {
        open( my $shellrc_out, '>', $shellrc_filename );
        print $shellrc_out $shellrc_contents;
        close $shellrc_out;
    }
}

sub _read_shellrc_contents {
    my $shellrc_contents;
    {
        local $INPUT_RECORD_SEPARATOR = undef;
        if ( open( my $shellrc_in, '<', $shellrc_filename ) ) {
            $shellrc_contents = <$shellrc_in>;
            close $shellrc_in;
        }
        else {
            $shellrc_contents = '';
        }
    }
    return $shellrc_contents;
}

sub _run_git {
    my @args = @_;

    my $cwd_before_git = getcwd();

    DEBUG( 'running git ' . join( ' ', @args ) . " in $home/$repo_dir" );
    chdir( _abs_repo_path( $home, $repo_dir ) );
    system( 'git', @args );

    chdir($cwd_before_git);
}

sub _abs_repo_path {
    my ( $home, $repo ) = @_;

    if ( File::Spec->file_name_is_absolute($repo) ) {
        return $repo;
    }
    else {
        return $home . '/' . $repo;
    }
}

# when symlinking from source_dir into target_dir, figure out if there's a
# relative path between the two
sub _calculate_symlink_base {
    my ( $source_dir, $target_dir ) = @_;

    my $symlink_base;

    # if the paths have no first element in common
    if ( ( File::Spec->splitdir($source_dir) )[1] ne ( File::Spec->splitdir($target_dir) )[1] ) {
        $symlink_base = $source_dir;    # use absolute path
    }
    else {

        # otherwise, calculate the relative path between the two directories
        $symlink_base = File::Spec->abs2rel( $source_dir, $target_dir );
    }

    return $symlink_base;
}

sub _file_in_tracked_or_untracked {
    my ( $orig_source_dir, $source_dir, $file ) = @_;

    # strip the repo dir off the front, in case the file is already tracked
    $file =~ s/$repo_dir\///;

    my $cwd_before_inspection = getcwd();
    chdir($source_dir);

    my $dfm_install = _load_dfminstall("$source_dir/.dfminstall");

    # skip vim swap files
    return ('skip') if $file =~ /.*\.sw.$/;

    # skip any other files
    return ('skip') if $dfm_install->{skip_files}->{$file};

    my @dirs = File::Spec->splitdir($file);
    if ( scalar(@dirs) > 1 ) {
        my $recurse_dir = shift(@dirs);
        if ( grep { $recurse_dir eq $_ } @{ $dfm_install->{recurse_files} } ) {
            chdir($cwd_before_inspection);
            return _file_in_tracked_or_untracked(
                $orig_source_dir,
                File::Spec->catfile( $source_dir, $recurse_dir ),
                File::Spec->catfile(@dirs)
            );
        }
        else {
            my $relative_path = File::Spec->abs2rel( $source_dir, $orig_source_dir );

            my $dir_type = -e $recurse_dir ? 'tracked' : 'untracked';

            chdir($cwd_before_inspection);
            return ( $dir_type,
                ( $relative_path eq '.' )
                ? $recurse_dir
                : File::Spec->catfile( $relative_path, $recurse_dir ) );
        }
    }

    chdir($cwd_before_inspection);
    return ('install');
}

sub _load_dfminstall {
    my ($dfminstall_path) = @_;

    my $dfminstall_info = {
        skip_files => {
            '.'           => 1,
            '..'          => 1,
            '.dfminstall' => 1,
            '.gitignore'  => 1,
            '.git'        => 1,
        },
        recurse_files           => [],
        execute_files           => [],
        execute_uninstall_files => [],
        chmod_files             => {},
    };

    if ( -e $dfminstall_path ) {
        open( my $skip_fh, '<', $dfminstall_path );
        foreach my $line (<$skip_fh>) {
            chomp($line);
            if ( length($line) ) {
                my ( $filename, @options ) = split( q{ }, $line );
                DEBUG(".dfminstall file $filename has @options");
                if ( !defined $options[0] ) {
                    WARN(
                        "using implied recursion in .dfminstall is deprecated, change '$filename' to '$filename recurse' in $dfminstall_path."
                    );
                    push( @{ $dfminstall_info->{recurse_files} }, $filename );
                    $dfminstall_info->{skip_files}->{$filename} = 1;
                }
                elsif ( $options[0] eq 'skip' ) {
                    $dfminstall_info->{skip_files}->{$filename} = 1;
                }
                elsif ( $options[0] eq 'recurse' ) {
                    push( @{ $dfminstall_info->{recurse_files} }, $filename );
                    $dfminstall_info->{skip_files}->{$filename} = 1;
                }
                elsif ( $options[0] eq 'exec' ) {
                    push( @{ $dfminstall_info->{execute_files} }, $filename );
                }
                elsif ( $options[0] eq 'exec-uninstall' ) {
                    push( @{ $dfminstall_info->{execute_uninstall_files} }, $filename );
                }
                elsif ( $options[0] eq 'chmod' ) {
                    if ( !$options[1] ) {
                        ERROR("chmod option requires a mode (e.g. 0600) in $dfminstall_path");
                        exit 1;
                    }
                    if ( $options[1] !~ /^[0-7]{4}$/ ) {
                        ERROR(
                            "bad mode '$options[1]' (should be 4 digit octal, like 0600) in $dfminstall_path"
                        );
                        exit 1;
                    }
                    $dfminstall_info->{chmod_files}->{$filename}
                        = $options[1];
                }
            }
        }
        close($skip_fh);
        $dfminstall_info->{skip_files}->{skip} = 1;

        DEBUG("Skipped file: $_") for keys %{ $dfminstall_info->{skip_files} };
    }

    return $dfminstall_info;
}

sub show_version {
    print "dfm version $VERSION\n";
}

# work-alike for function from perl 5.8.9 and later
# added for compatibility with CentOS 5, which is stuck on 5.8.8
sub GetOptionsFromArray {
    my ( $argv, $opts, @options ) = @_;

    local @ARGV = @$argv;
    GetOptions( $opts, @options );

    # update the passed argv array
    @$argv = @ARGV;
}

1;

__END__

=head1 NAME

    dfm - A script to manage a dotfiles repository

=head1 SYNOPSIS

usage: dfm <command> [--version] [--dry-run] [--verbose] [--quiet] [<args>]

The commands are:

   install    Install dotfiles
   import     Add a new dotfile to the repo
   uninstall  Uninstall dotfiles
   updates    Fetch updates but don't merge them in
   mi         Merge in updates and install dotfiles again
   umi        Fetch updates, merge in and install

See 'dfm help <command>' for more information on a specific command.

Any git command can be run on the dotfiles repository by using the following
syntax:

   dfm [git subcommand] [git options]

=head1 DESCRIPTION

    Manages installing files from and operating on a repository that contains
    dotfiles.

=head1 COMMON OPTIONS

All the subcommands implemented by dfm have the following options:

  --verbose     Show extra information about what dfm is doing
  --quiet       Show as little info as possible.
  --dry-run     Don't do anything.
  --version     Print version information.

=head1 HELP

All Options:

  dfm help <subcommand>
  dfm <subcommand> --help

Examples:

  dfm install --help
  dfm help install

Description:

This shows the help for a particular subcommand.

=head1 INSTALL

All Options:

  dfm install [--verbose|--quiet] [--dry-run]

Examples:

  dfm install
  dfm install --dry-run

Description:

This installs everything in the repository into the current user's home
directory by making symlinks.  To skip any files, add their names to a file
named '.dfminstall'.  For instance, to skip 'README.md', put this in
.dfminstall:

    README.md skip

To recurse into a directory and install files inside rather than symlinking the
directory itself, just add its name to .dfminstall.  For instance, to make 'dfm
install' symlink files inside of ~/.ssh instead of making ~/.ssh a symlink, put
this in .dfminstall:

    .ssh

=head1 UNINSTALL

All Options:

  dfm uninstall [--verbose|--quiet] [--dry-run]
   - or -
  dfm un [--verbose|--quiet] [--dry-run]

Examples:

  dfm uninstall
  dfm uninstall --dry-run

Description:

This removes all traces of dfm and the dotfiles.  It basically is the reverse
of 'dfm install'.

=head1 IMPORT

All Options:

  dfm import [--verbose|--quiet] [--dry-run] [--no-commit] [--message <message>] file1 [file2 ..]
   - or -
  dfm im [--verbose|--quiet] [--dry-run] [--no-commit] [--message <message>] file1 [file2 ..]

Examples

  dfm import ~/.vimrc
  dfm import .tmux.conf --message 'adding my tmux config'

Description:

This command moves each file specified into the dotfiles repository and
symlinks it into $HOME.  Then a commit is made.

Use '--message' to specify a different commit message.

Use '--no-commit' to add the files, but not commit.

=head1 UPDATES

All Options:

  dfm updates [--verbose|--quiet] [--dry-run] [--no-fetch]

Examples:

  dfm updates
  dfm updates --no-fetch

Description:

This fetches any changes from the upstream remote and then shows a shortlog of
what updates would come in if merged into the current branch.  Use '--no-fetch'
to skip the fetch and just show what's new.

=head1 MERGEANDINSTALL

All Options:

  dfm mergeandinstall [--verbose|--quiet] [--dry-run] [--merge|--rebase]
   - or -
  dfm mi [--verbose|--quiet] [--dry-run] [--merge|--rebase]

Examples:

  dfm mergeandinstall
  dfm mi
  dfm mergeandinstall --rebase

Description:

This merges or rebases the upstream changes in and re-installs dotfiiles.

=head1 UPDATEMERGEANDINSTALL

All Options:

  dfm updatemergeandinstall [--verbose|--quiet] [--dry-run] [--merge|--rebase] [--no-fetch]
   - or -
  dfm umi [--verbose|--quiet] [--dry-run] [--merge|--rebase] [--no-fetch]

Examples:

  dfm updatemergeandinstall
  dfm umi
  dfm updatemergeandinstall --no-fetch

Description:

This combines 'updates' and 'mergeandinstall'.

=head1 dfm [git subcommand] [git options]

This runs any git command as if it was inside the dotfiles repository.  For
instance, this makes it easy to commit changes that are made by running 'dfm
commit'.

=head1 AUTHOR

Nate Jones <nate@endot.org>

=head1 COPYRIGHT

Copyright (c) 2010 L</AUTHOR> as listed above.

=head1 LICENSE

This program is free software distributed under the Artistic License 2.0.

=cut