#! /usr/bin/perl -w

# This script simulates some cvs commands even for readonly or diconnected
# CVS repositories.

require 5.004;
use Time::Local;
use Getopt::Long;
use strict;

use vars qw($force_mode $entries_tmp);

# Print message and exit (like "die", but without raising an exception).
# Newline is added at the end.
sub error ($)
{
	print STDERR "cvsdo: ERROR: " . shift(@_) . "\n";
	cleanup ();
	exit 1;
}

# Print a warning message.
# Newline is added at the end.
sub warning ($)
{
	print STDERR "cvsdo: WARNING: " . shift(@_) . "\n";
}

# Print message and force UNIX-style newline. Useful for diffs.
sub unix_print ($)
{
	my $msg = shift(@_);
	chomp $msg;
	print $msg . "\012";
}

# Process a single file (add and remove commands).
# Arguments: file, command.
sub process_file ($$)
{
	my $short_file;
	my $entries;
	my $file_exists = 0;
	my $file_listed = 0;

	my $file = shift (@_);
	my $command = shift (@_);

	my $cmd_add = ($command eq 'add');
	my $cmd_remove = ($command eq 'remove');

	if (-e $file) {
		unless (-f $file) {
			error ("File $file is not a plain file");
		}
		$file_exists = 1;
	}

	if ( $cmd_add && ! $file_exists && ! $force_mode ) {
		error ("File $file doesn't exist");
	} elsif ( $cmd_remove && $file_exists && ! $force_mode ) {
		error ("Won't remove existing file $file");
	}

	$entries = $file;
	$entries =~ s{^(([^ ]+/)?)([^/ ]+)$}{${1}CVS/Entries};
	$short_file = $3;

	unless ($entries) {
		error("Wrong filename $file");
	}

	$entries_tmp = $entries . ".tmp";

	open(NEW_ENTRIES, "> $entries_tmp") ||
		error("Cannot open $entries_tmp for writing");

	open(ENTRIES, "< $entries") ||
		error("Cannot open $entries for reading");

	while(<ENTRIES>) {
		if ( m{^(/([^/]+)/)([^/])(.*$)} && $2 eq $short_file ) {
			$file_listed = 1;
			last if $cmd_add;
			if ( $3 eq '-' ) {
				error("File $file is already removed");
			} else {
				print NEW_ENTRIES "$1-$3$4\n";
			}
		} else {
			print NEW_ENTRIES $_;
		}
	}

	if ( $cmd_add && $file_listed ) {
		error("File $file is already listed in $entries");
	}

	if ( $cmd_remove && ! $file_listed ) {
		error("File $file is not listed in $entries");
	}

	if ( $cmd_add ) {
		print NEW_ENTRIES "/$short_file/0/dummy timestamp//\n";
	}

	close (ENTRIES);
	close (NEW_ENTRIES);

	rename $entries_tmp, $entries ||
		error ("Cannot rename $entries_tmp to $entries");

	$cmd_remove && $file_exists &&
		( unlink $file || error ("Cannot delete file $file") );
}

# Handle added files (diff).
sub handle_added ($)
{
	my $file = shift(@_);
	open(DIFFOUT, "diff -u -L /dev/null -L $file /dev/null $file |") ||
		error ("Cannot read output of diff: $!");
	unix_print ("Index: $file");
	while (<DIFFOUT>) {
		unix_print ($_);
	}
}

# Handle removed files (diff).
sub handle_removed ($)
{
	my $file = shift(@_);
	# FIXME: scan for backup copies, as in handle_modified()
	# Any ideas about how to make `patch' erase that file?
	unix_print ("File $file should be removed!\n");
}

# Handle modified files (diff)
sub handle_modified ($)
{
	my $file = shift(@_);
	# split into directory and file name
	$file =~ m{^((.*/)?)([^/]+)};
	my $short_file = $3;
	my $dir = $1;
	my %months = (
		"Jan" => 0,
		"Feb" => 1,
		"Mar" => 2,
		"Apr" => 3,
		"May" => 4,
		"Jun" => 5,
		"Jul" => 6,
		"Aug" => 7,
		"Sep" => 8,
		"Oct" => 9,
		"Nov" => 10,
		"Dec" => 11
	);

	# Lookup the original timestamp in CVS/Entries.
	open (ENTRIES, "< ${dir}CVS/Entries")
		|| error ("couldn't open ${dir}CVS/Entries: $!");
	my $date_str;
	while (<ENTRIES>) {
		if ( m{^/$short_file/[^/]*/([^/]+)/} ) {
			$date_str = $1;
			last;
		}
	}
	unless (defined $date_str) {
		error ("$file is not listed in ${dir}CVS/Entries");
	}
	close (ENTRIES);

	unless ($date_str =~ m{^(...) (...) (..) (..):(..):(..) (....)$}) {
		error ("Invalid timestamp for $file: $date_str");
	}

	my $basetime = timegm($6, $5, $4, $3, $months{$2}, $7 - 1900);

	# Scan the directory for similar files.
	my $backup_file;
	opendir (DIR, $dir eq "" ? "." : $dir) ||
		error ("Cannot open directory $dir: $!");
	foreach (readdir (DIR)) {
		m{$short_file} || next;;
		my $candidate = $dir . $_;
		stat ($candidate) || next;
		if ($basetime == (stat _) [9]) {
			$backup_file = $candidate;
			last;
		}
	}
	closedir (DIR);

	unless (defined $backup_file) {
		warning ("Backup file for $file not found");
		return;
	}

	my $diff_opts = "-u";
	if ($short_file eq "ChangeLog") {
		$diff_opts = "-u1";
	}

	open(DIFFOUT,
	     "diff $diff_opts -L $file -L $file $backup_file $file |") ||
		error ("Cannot read output of diff: $!");
	unix_print ("Index: $file");
	while (<DIFFOUT>) {
		unix_print ($_);
	}
}

# Handle `diff' command.
sub handle_diff ()
{
	if ($#ARGV >= 0)
	{
		usage ();
	}

	open(CVSADM, "cvsu --ignore --types AMRO |") ||
		error ("Cannot read output of cvsu: $!");

	while (<CVSADM>) {
		chomp;
		if ($_ !~ m{^([AMRO]) (.*)$}) {
			error ("Unrecognized output from cvsu");
		}
		my $type = $1;
		my $file = $2;
		if ($type eq "A") {
			handle_added ($file);
		}
		elsif ($type eq "R") {
			handle_removed ($file);
		}
		else {
			handle_modified ($file);
		}
	}
}

# Print usage information and exit.
sub usage ()
{
	print "Usage: cvsdo COMMAND FLAGS FILES\n" .
	"Simulate cvs commands without accessing the CVS server\n" .
	"Commands supported:\n" .
	"	add		Add a new file\n" .
	"	  -f | --force	  Don't check whether the file exists\n" .
	"	remove		Remove a file\n" .
	"	  -f | --force	  Delete existing files\n" .
	"	diff		Create a diff\n";
	exit 1;
}

# Print version information and exit.
sub version ()
{
	print "cvsdo - CVS Disconnected Operation, version -VERSION-\n";
	exit 0;
}

# Remove temporary files.
sub cleanup ()
{
	(defined $entries_tmp) && (-e $entries_tmp) &&
		( unlink $entries_tmp ||
			error ("Cannot delete file $entries_tmp") );
}

# Parse command line.
sub Main ()
{
	$force_mode = 0;	# Forced operation
	my $want_help = 0;	# Print help and exit
	my $want_ver = 0;	# Print version and exit

	my %options = (
		"force"	   => \$force_mode,
		"help"     => \$want_help,
		"version"  => \$want_ver
	);

	GetOptions(%options);

	usage() if $want_help;
	version() if $want_ver;

	my $command = shift (@ARGV);

	if ( $want_ver || !$command || ($command !~ /(add|remove|diff)/) ) {
		usage();
	}

	if ($command =~ /diff/) {
		handle_diff ();
	} else {
		if ( $#ARGV < 0 ) {
			error ("No files specified");
		}

		foreach (@ARGV) {
			process_file ($_, $command);
		}
	}

	cleanup();
}

Main ();

