#!/usr/bin/env perl

=pod

=head1 NAME

push-notification - Run this hook before performing the "git pull" for git-deploy clients.

=head1 SYNOPSIS

In hooks/pre-read:

  system "hooks/push-notification";

=head1 DESCRIPTION

Releases other push-notification sleeper coming from the same REMOTE_ADDR, if any.
If there has already been a "git pull" since the most recent repo change,
then wait until the next push before proceeding.

=cut

use strict;
use warnings;
use Fcntl qw(O_CREAT O_RDWR LOCK_EX LOCK_NB);
use IO::Handle; # autoflush

# Maximum number of seconds to wait for the next worker to take over.
my $MAX_PATIENCE = 7200;
# Minimum delay after push that is required to wait before sleeping.
my $MIN_PUSH_DELAY = 60;
my $who = ($ENV{REMOTE_USER} || die "Auth required\n")."\@$ENV{REMOTE_ADDR}";
my $base = $ENV{GIT_DIR} or die "GIT hook ENV malfunction!\n";
my $lock_dir = "$base/logs";
mkdir $lock_dir, 0755 unless -d $lock_dir;
$SIG{PIPE} = sub { exit 1; };
my $deploy_branch = "";
my $client_command = "git-deploy";
if (my $xmods = $ENV{XMODIFIERS}) {
    if ($xmods =~ /\bdeploy_patience=(\d+)/) {
        $MAX_PATIENCE = $1 || 1;
        warn localtime().": [$who] git-server: Modified Deploy Patience: $MAX_PATIENCE Seconds\n";
    }
    $deploy_branch = $1 if $xmods =~ /\bpull_branch=(\S+)/;
    $client_command = $1 if $xmods =~ m{^client=.*/(git-\w+)}m;
}
my $msg = $deploy_branch ? " [git-deploy --branch $deploy_branch]" : "";
$0 = "push-notification - $who:$base$msg";
my $lock_file = my $kill_glob = "$lock_dir/$ENV{REMOTE_ADDR}-$ENV{REMOTE_USER}.lock";
my $safe_branch = $deploy_branch ? "-$deploy_branch" : "";
$safe_branch =~ s/[^\w\-]+/_/g;
$safe_branch ? ($lock_file=~s/(\.lock)$/$safe_branch$1/) : ($kill_glob=~s/(\.lock)$/-*$1/);
# If all clients are using at least v0.038 of git-client or git-deploy, then $kill_glob will always be empty.
# But for backward compatibility, notify ALL sleeping clients instead of only those matching $deploy_branch:
foreach my $other_lock (glob $kill_glob) {
    if (open my $fh, "<", $other_lock) {
        sleep 1 if 0.0007 > -M $other_lock; # Give time for other process to stash the PIDs.
        if (defined(my $worker = <$fh>) and defined(my $sleeper = <$fh>)) {
            chomp $worker; chomp $sleeper;
            $worker and $sleeper and kill 0, $worker and kill 9, $sleeper and
            warn localtime().": [$who] git-server: PUSH NOTIFICATION: Releasing worker (pid=$worker) by killing sleeper (pid=$sleeper) ...\n";
        }
        close $fh;
    }
}
sysopen my $fh, $lock_file, O_CREAT | O_RDWR;
my $released_worker = 0;
if (!flock $fh, LOCK_NB | LOCK_EX) {
    # Someone else is still working?
    # Well, it looks like his shift is over so he needs to be released.
    # Now it's my turn to clock in and wait for the push notification.
    sleep 1; # Wait for the other process to stash PIDs into lock file.
    # Hopefully, UNIX will allow me to read a file even though it's locked by someone else.
    chomp (my $worker = <$fh>);
    chomp (my $sleeper = <$fh>);
    # Rewind back to the beginning
    seek $fh, 0, 0;
    if ($worker and $sleeper and kill 0, $worker) {
        # Still running?
        warn localtime().": [$who] git-server: PUSH NOTIFICATION: Releasing worker (pid=$worker) by killing sleeper (pid=$sleeper) ...\n";
        $released_worker = kill 9, $sleeper; # WAKE UP!
    }
    if ($released_worker) {
        # SIGKILL was sent, so now patiently wait for him die,
        # and pry the lock out of his cold dead fingers.
        $released_worker = 0;
        my $tries = 3;
        while ($tries-- and sleep 1) {
            last if $released_worker = flock $fh, LOCK_NB | LOCK_EX;
        }
    }
    if (!$released_worker) {
        close $fh; # Let go of lock file
        # Failed to release someone else or someone else still has the lock?
        # Then we can't take the flock() baton.
        # Just git out of here!
        $worker ||= "[unknown]";
        $sleeper ||= "[gone]";
        warn localtime().": [$who] git-server: PUSH NOTIFICATION: Unable to steal pull lock from worker (pid=$worker) with sleeper (pid=$sleeper) You may proceed.\n";
        exit;
    }
    # Acquired Exclusive Lock!
    # Safe to continue
}

my $last_branch = "";
my $last_pushed = "$lock_dir/pushed";
my $last_pulled = "$lock_dir/$ENV{REMOTE_ADDR}-$ENV{REMOTE_USER}.pulled";
my $WHEN_last_pulled = (stat $last_pulled)[9] || 0;
my $WHEN_last_pushed = (stat $last_pushed)[9] || 0;

if ($deploy_branch and open my $last_pull, "<", $last_pulled) {
    $last_branch = $1 if (<$last_pull> || "") =~ /\[git-deploy --branch (\S+)\]/;
    close $last_pull;
}

my $should_wait =
    # Do NOT wait if this is the first time ever doing a pull (probably clone)
    !-e $last_pulled ? 0 :

    # Do NOT wait if the client is only switching to checkout a different branch than last time
    $deploy_branch && $last_branch && $deploy_branch ne $last_branch ? 0 :

    # Never wait if called from "git-client" wrapper without going through "git-deploy"
    $client_command =~ /git-client/ ? 0 :

    # Always pause if there's never been any push yet
    !-e $last_pushed ? 1 :

    # Always wait if someone else was barely released to run the git pull
    $released_worker ? 1 :

    # Only wait if pulled much more recently than the last push
    $WHEN_last_pulled > $WHEN_last_pushed + $MIN_PUSH_DELAY;

# Update Last Pull Timestamp
open my $pull, ">", $last_pulled or die "$last_pulled: open: $!";
print $pull localtime().": [$ENV{REMOTE_ADDR} $ENV{REMOTE_PORT} => $ENV{SERVER_ADDR} $ENV{SERVER_PORT}] Pull initiated$msg\n";
close $pull;

if (my $sleeper = fork()) {
    # Parent needs to log the sleeper
    $fh->autoflush(1);
    print $fh "$$\n";
    print $fh "$sleeper\n";
    print $fh "$deploy_branch\n" if $deploy_branch;
    truncate $fh, tell $fh;
    warn localtime().": [$who] git-server: PUSH NOTIFICATION: Starting worker (pid=$$) waiting for sleeper (pid=$sleeper) to finish ...\n";
    waitpid $sleeper, 0;
    my $rbits = "\x01"; # 2 ^ (fileno(STDIN)) = 1
    if (select $rbits, undef, undef, 0.1) {
        # Quick probe to make sure we are still connected to the other side
        # Normally, a "read" operation will never send anything.
        # So if STDIN is ready to say something, then there must be a problem.
        # We need to prevent the real git pull from actually running.
        die "STDIN Broken Pipe or defective git client.\n";
    }
}
else {
    # Child
    close $fh; # Let go of the lock before sleeping
    if ($should_wait) {
        # Just be patient. Hopefully someone will kill me one day.
        exec sleep => $MAX_PATIENCE or sleep 1;
    }
    # Otherwise, just exit normally;
    exit;
}

# Wait for push to fall far enough away to provide safely distinct timestamps
while (time - ((stat $last_pushed)[9] || 0) < 1) {
    warn localtime().": [$who] git-server: PUSH NOTIFICATION: Waiting for recent commit to complete ...\n";
    sleep 1;
}

# Throw a warning if a push was too recent.
if (!$should_wait) {
    warn localtime().": [$who] git-server: PUSH NOTIFICATION: Releasing pull due to recent commit. Please pull again ...\n";
    # git client will probably see the following spewage:
    # Cannot rebase onto multiple branches
}
