#!/bin/sh
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2010-2013 Hudson River Trading LLC
# Written by: John H. Baldwin <jhb@FreeBSD.org>
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# 1. Redistributions of source code must retain the above copyright
#    notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
#    notice, this list of conditions and the following disclaimer in the
#    documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
# SUCH DAMAGE.
#

# This is a tool to manage updating files that are not updated as part
# of 'make installworld' such as files in /etc.  Unlike other tools,
# this one is specifically tailored to assisting with mass upgrades.
# To that end it does not require user intervention while running.
#
# Theory of operation:
#
# The most reliable way to update changes to files that have local
# modifications is to perform a three-way merge between the original
# unmodified file, the new version of the file, and the modified file.
# This requires having all three versions of the file available when
# performing an update.
#
# To that end, etcupdate uses a strategy where the current unmodified
# tree is kept in WORKDIR/current and the previous unmodified tree is
# kept in WORKDIR/old.  When performing a merge, a new tree is built
# if needed and then the changes are merged into DESTDIR.  Any files
# with unresolved conflicts after the merge are left in a tree rooted
# at WORKDIR/conflicts.
#
# To provide extra flexibility, etcupdate can also build tarballs of
# root trees that can later be used.  It can also use a tarball as the
# source of a new tree instead of building it from /usr/src.

# Global settings.  These can be adjusted by config files and in some
# cases by command line options.

# TODO:
# - automatable conflict resolution

usage()
{
	cat <<EOF
usage: etcupdate [-npBFN] [-d workdir] [-r | -s source | -t tarball]
                 [-A patterns] [-D destdir] [-I patterns] [-L logfile]
                 [-M options] [-m make]
       etcupdate build [-BN] [-d workdir] [-s source] [-L logfile] [-M options]
                 [-m make] <tarball>
       etcupdate diff [-d workdir] [-D destdir] [-I patterns] [-L logfile]
       etcupdate extract [-BN] [-d workdir] [-s source | -t tarball]
                 [-D destdir] [-L logfile] [-M options] [-m make]
       etcupdate resolve [-p] [-d workdir] [-D destdir] [-L logfile]
       etcupdate revert [-d workdir] [-D destdir] [-L logfile] file ...
       etcupdate status [-d workdir] [-D destdir]
EOF
	exit 1
}

# Used to write a message prepended with '>>>' to the logfile.
log()
{
	echo ">>>" "$@" >&3
}

# Used for assertion conditions that should never happen.
panic()
{
	echo "PANIC:" "$@"
	exit 10
}

# Used to write a warning message.  These are saved to the WARNINGS
# file with "  " prepended.
warn()
{
	echo -n "  " >> $WARNINGS
	echo "$@" >> $WARNINGS
}

# Output a horizontal rule using the passed-in character.  Matches the
# length used for Index lines in CVS and SVN diffs.
#
# $1 - character
rule()
{
	jot -b "$1" -s "" 67
}

# Output a text description of a specified file's type.
#
# $1 - file pathname.
file_type()
{
	stat -f "%HT" $1 | tr "[:upper:]" "[:lower:]"
}

# Returns true (0) if a file exists
#
# $1 - file pathname.
exists()
{
	[ -e $1 -o -L $1 ]
}

# Returns true (0) if a file should be ignored, false otherwise.
#
# $1 - file pathname
ignore()
{
	local pattern -

	set -o noglob
	for pattern in $IGNORE_FILES; do
		set +o noglob
		case $1 in
			$pattern)
				return 0
				;;
		esac
		set -o noglob
	done

	# Ignore /.cshrc and /.profile if they are hardlinked to the
	# same file in /root.  This ensures we only compare those
	# files once in that case.
	case $1 in
		/.cshrc|/.profile)
			if [ ${DESTDIR}$1 -ef ${DESTDIR}/root$1 ]; then
				return 0
			fi
			;;
		*)
			;;
	esac

	return 1
}

# Returns true (0) if the new version of a file should always be
# installed rather than attempting to do a merge.
#
# $1 - file pathname
always_install()
{
	local pattern -

	set -o noglob
	for pattern in $ALWAYS_INSTALL; do
		set +o noglob
		case $1 in
			$pattern)
				return 0
				;;
		esac
		set -o noglob
	done

	return 1
}

# Build a new tree.  This runs inside a subshell to trap SIGINT.
#
# $1 - directory to store new tree in
build_tree()
(
	local destdir dir file make autogenfiles metatmp

	make="$MAKE_CMD $MAKE_OPTIONS -DNO_FILEMON"

	if [ -n "$noroot" ]; then
		make="$make -DNO_ROOT"
		metatmp=`mktemp $WORKDIR/etcupdate-XXXXXXX`
		: > $metatmp
		trap "rm -f $metatmp; trap '' EXIT; return 1" INT
		trap "rm -f $metatmp" EXIT
	else
		metatmp="/dev/null"
		trap "return 1" INT
	fi

	log "Building tree at $1 with $make"

	exec >&3 2>&1

	mkdir -p $1/usr/obj
	destdir=`realpath $1`

	if [ -n "$preworld" ]; then
		# Build a limited tree that only contains files that are
		# crucial to installworld.
		for file in $PREWORLD_FILES; do
			name=$(basename $file)
			mkdir -p $1/etc || return 1
			cp -p $SRCDIR/$file $1/etc/$name || return 1
		done
	else
		(
			cd $SRCDIR || exit 1
			if ! [ -n "$nobuild" ]; then
				export MAKEOBJDIRPREFIX=$destdir/usr/obj
				if [ -n "$($make -V.ALLTARGETS:Mbuildetc)" ]; then
					$make buildetc || exit 1
				else
					$make _obj SUBDIR_OVERRIDE=etc || exit 1
					$make everything SUBDIR_OVERRIDE=etc || exit 1
				fi
			fi
			if [ -n "$($make -V.ALLTARGETS:Minstalletc)" ]; then
				$make DESTDIR=$destdir installetc || exit 1
			else
				$make DESTDIR=$destdir distrib-dirs || exit 1
				$make DESTDIR=$destdir distribution || exit 1
			fi
		) || return 1
	fi
	chflags -R noschg $1 || return 1
	rm -rf $1/usr/obj || return 1

	# Purge auto-generated files.  Only the source files need to
	# be updated after which these files are regenerated.
	autogenfiles="./etc/*.db ./etc/passwd ./var/db/services.db"
	(cd $1 && printf '%s\n' $autogenfiles >> $metatmp && \
	    rm -f $autogenfiles) || return 1

	# Remove empty files.  These just clutter the output of 'diff'.
	(cd $1 && find . -type f -size 0 -delete -print >> $metatmp) || \
	    return 1

	# Trim empty directories.
	(cd $1 && find . -depth -type d -empty -delete -print >> $metatmp) || \
	    return 1

	if [ -n "$noroot" ]; then
		# Rewrite the METALOG to exclude the files (and directories)
		# removed above. $metatmp contains the list of files to delete,
		# and we append #METALOG# as a delimiter followed by the
		# original METALOG. This lets us scan through $metatmp in awk
		# building up a table of names to delete until we reach the
		# delimiter, then emit all the entries of the original METALOG
		# after it that aren't in that table. We also exclude ./usr/obj
		# and its children explicitly for simplicity rather than
		# building up that list (and in practice only ./usr/obj itself
		# will be in the METALOG since nothing is installed there).
		echo '#METALOG#' >> $metatmp || return 1
		cat $1/METALOG >> $metatmp || return 1
		awk '/^#METALOG#$/ { metalog = 1; next }
		    { f=$1; gsub(/\/\/+/, "/", f) }
		    !metalog { rm[f] = 1; next }
		    !rm[f] && f !~ /^\.\/usr\/obj(\/|$)/ { print }' \
		    $metatmp > $1/METALOG || return 1
	fi

	return 0
)

# Generate a new tree.  If tarball is set, then the tree is
# extracted from the tarball.  Otherwise the tree is built from a
# source tree.
#
# $1 - directory to store new tree in
extract_tree()
{
	local files

	# If we have a tarball, extract that into the new directory.
	if [ -n "$tarball" ]; then
		files=
		if [ -n "$preworld" ]; then
			files="$PREWORLD_FILES"
		fi
		if ! (mkdir -p $1 && tar xf $tarball -C $1 $files) \
		    >&3 2>&1; then
			echo "Failed to extract new tree."
			remove_tree $1
			exit 1
		fi
	else
		if ! build_tree $1; then
			echo "Failed to build new tree."
			remove_tree $1
			exit 1
		fi
	fi
}

# Forcefully remove a tree.  Returns true (0) if the operation succeeds.
#
# $1 - path to tree
remove_tree()
{

	rm -rf $1 >&3 2>&1
	if [ -e $1 ]; then
		chflags -R noschg $1 >&3 2>&1
		rm -rf $1 >&3 2>&1
	fi
	[ ! -e $1 ]
}

# Return values for compare()
COMPARE_EQUAL=0
COMPARE_ONLYFIRST=1
COMPARE_ONLYSECOND=2
COMPARE_DIFFTYPE=3
COMPARE_DIFFLINKS=4
COMPARE_DIFFFILES=5

# Compare two files/directories/symlinks.  Note that this does not
# recurse into subdirectories.  Instead, if two nodes are both
# directories, they are assumed to be equivalent.
#
# Returns true (0) if the nodes are identical.  If only one of the two
# nodes are present, return one of the COMPARE_ONLY* constants.  If
# the nodes are different, return one of the COMPARE_DIFF* constants
# to indicate the type of difference.
#
# $1 - first node
# $2 - second node
compare()
{
	local first second

	# If the first node doesn't exist, then check for the second
	# node.  Note that -e will fail for a symbolic link that
	# points to a missing target.
	if ! exists $1; then
		if exists $2; then
			return $COMPARE_ONLYSECOND
		else
			return $COMPARE_EQUAL
		fi
	elif ! exists $2; then
		return $COMPARE_ONLYFIRST
	fi

	# If the two nodes are different file types fail.
	first=`stat -f "%Hp" $1`
	second=`stat -f "%Hp" $2`
	if [ "$first" != "$second" ]; then
		return $COMPARE_DIFFTYPE
	fi

	# If both are symlinks, compare the link values.
	if [ -L $1 ]; then
		first=`readlink $1`
		second=`readlink $2`
		if [ "$first" = "$second" ]; then
			return $COMPARE_EQUAL
		else
			return $COMPARE_DIFFLINKS
		fi
	fi

	# If both are files, compare the file contents.
	if [ -f $1 ]; then
		if cmp -s $1 $2; then
			return $COMPARE_EQUAL
		else
			return $COMPARE_DIFFFILES
		fi
	fi

	# As long as the two nodes are the same type of file, consider
	# them equivalent.
	return $COMPARE_EQUAL
}

# Returns true (0) if the only difference between two regular files is a
# change in the FreeBSD ID string.
#
# $1 - path of first file
# $2 - path of second file
fbsdid_only()
{

	diff -qI '\$FreeBSD.*\$' $1 $2 >/dev/null 2>&1
}

# This is a wrapper around compare that will return COMPARE_EQUAL if
# the only difference between two regular files is a change in the
# FreeBSD ID string.  It only makes this adjustment if the -F flag has
# been specified.
#
# $1 - first node
# $2 - second node
compare_fbsdid()
{
	local cmp

	compare $1 $2
	cmp=$?

	if [ -n "$FREEBSD_ID" -a "$cmp" -eq $COMPARE_DIFFFILES ] && \
	    fbsdid_only $1 $2; then
		return $COMPARE_EQUAL
	fi

	return $cmp
}

# Returns true (0) if a directory is empty.
#
# $1 - pathname of the directory to check
empty_dir()
{
	local contents

	contents=`ls -A $1`
	[ -z "$contents" ]
}

# Returns true (0) if one directories contents are a subset of the
# other.  This will recurse to handle subdirectories and compares
# individual files in the trees.  Its purpose is to quiet spurious
# directory warnings for dryrun invocations.
#
# $1 - first directory (sub)
# $2 - second directory (super)
dir_subset()
{
	local contents file

	if ! [ -d $1 -a -d $2 ]; then
		return 1
	fi

	# Ignore files that are present in the second directory but not
	# in the first.
	contents=`ls -A $1`
	for file in $contents; do
		if ! compare $1/$file $2/$file; then
			return 1
		fi

		if [ -d $1/$file ]; then
			if ! dir_subset $1/$file $2/$file; then
				return 1
			fi
		fi
	done
	return 0
}

# Returns true (0) if a directory in the destination tree is empty.
# If this is a dryrun, then this returns true as long as the contents
# of the directory are a subset of the contents in the old tree
# (meaning that the directory would be empty in a non-dryrun when this
# was invoked) to quiet spurious warnings.
#
# $1 - pathname of the directory to check relative to DESTDIR.
empty_destdir()
{

	if [ -n "$dryrun" ]; then
		dir_subset $DESTDIR/$1 $OLDTREE/$1
		return
	fi

	empty_dir $DESTDIR/$1
}

# Output a diff of two directory entries with the same relative name
# in different trees.  Note that as with compare(), this does not
# recurse into subdirectories.  If the nodes are identical, nothing is
# output.
#
# $1 - first tree
# $2 - second tree
# $3 - node name
# $4 - label for first tree
# $5 - label for second tree
diffnode()
{
	local first second file old new diffargs

	if [ -n "$FREEBSD_ID" ]; then
		diffargs="-I \\\$FreeBSD.*\\\$"
	else
		diffargs=""
	fi

	compare_fbsdid $1/$3 $2/$3
	case $? in
		$COMPARE_EQUAL)
			;;
		$COMPARE_ONLYFIRST)
			echo
			echo "Removed: $3"
			echo
			;;
		$COMPARE_ONLYSECOND)
			echo
			echo "Added: $3"
			echo
			;;
		$COMPARE_DIFFTYPE)
			first=`file_type $1/$3`
			second=`file_type $2/$3`
			echo
			echo "Node changed from a $first to a $second: $3"
			echo
			;;
		$COMPARE_DIFFLINKS)
			first=`readlink $1/$file`
			second=`readlink $2/$file`
			echo
			echo "Link changed: $file"
			rule "="
			echo "-$first"
			echo "+$second"
			echo
			;;
		$COMPARE_DIFFFILES)
			echo "Index: $3"
			rule "="
			diff -u $diffargs -L "$3 ($4)" $1/$3 -L "$3 ($5)" $2/$3
			;;
	esac
}

# Run one-off commands after an update has completed.  These commands
# are not tied to a specific file, so they cannot be handled by
# post_install_file().
post_update()
{
	local args

	# None of these commands should be run for a pre-world update.
	if [ -n "$preworld" ]; then
		return
	fi

	# If /etc/localtime exists and is not a symlink and /var/db/zoneinfo
	# exists, run tzsetup -r to refresh /etc/localtime.
	if [ -f ${DESTDIR}/etc/localtime -a \
	    ! -L ${DESTDIR}/etc/localtime ]; then
		if [ -f ${DESTDIR}/var/db/zoneinfo ]; then
			if [ -n "${DESTDIR}" ]; then
				args="-C ${DESTDIR}"
			else
				args=""
			fi
			log "tzsetup -r ${args}"
			if [ -z "$dryrun" ]; then
				tzsetup -r ${args} >&3 2>&1
			fi
		else
			warn "Needs update: /etc/localtime (required" \
			    "manual update via tzsetup(8))"
		fi
	fi
}

# Create missing parent directories of a node in a target tree
# preserving the owner, group, and permissions from a specified
# template tree.
#
# $1 - template tree
# $2 - target tree
# $3 - pathname of the node (relative to both trees)
install_dirs()
{
	local args dir

	dir=`dirname $3`

	# Nothing to do if the parent directory exists.  This also
	# catches the degenerate cases when the path is just a simple
	# filename.
	if [ -d ${2}$dir ]; then
		return 0
	fi

	# If non-directory file exists with the desired directory
	# name, then fail.
	if exists ${2}$dir; then
		# If this is a dryrun and we are installing the
		# directory in the DESTDIR and the file in the DESTDIR
		# matches the file in the old tree, then fake success
		# to quiet spurious warnings.
		if [ -n "$dryrun" -a "$2" = "$DESTDIR" ]; then
			if compare $OLDTREE/$dir $DESTDIR/$dir; then
				return 0
			fi
		fi

		args=`file_type ${2}$dir`
		warn "Directory mismatch: ${2}$dir ($args)"
		return 1
	fi

	# Ensure the parent directory of the directory is present
	# first.
	if ! install_dirs $1 "$2" $dir; then
		return 1
	fi

	# Format attributes from template directory as install(1)
	# arguments.
	args=`stat -f "-o %Su -g %Sg -m %0Mp%0Lp" $1/$dir`

	log "install -d $args ${2}$dir"
	if [ -z "$dryrun" ]; then
		install -d $args ${2}$dir >&3 2>&1
	fi
	return 0
}

# Perform post-install fixups for a file.  This largely consists of
# regenerating any files that depend on the newly installed file.
#
# $1 - pathname of the updated file (relative to DESTDIR)
post_install_file()
{
	case $1 in
		/etc/mail/aliases)
			# Grr, newaliases only works for an empty DESTDIR.
			if [ -z "$DESTDIR" ]; then
				log "newaliases"
				if [ -z "$dryrun" ]; then
					newaliases >&3 2>&1
				fi
			else
				NEWALIAS_WARN=yes
			fi
			;;
		/usr/share/certs/trusted/* | /usr/share/certs/untrusted/*)
			log "certctl rehash"
			if [ -z "$dryrun" ]; then
				env DESTDIR=${DESTDIR} certctl rehash >&3 2>&1
			fi
			;;
		/etc/login.conf)
			log "cap_mkdb ${DESTDIR}$1"
			if [ -z "$dryrun" ]; then
				cap_mkdb ${DESTDIR}$1 >&3 2>&1
			fi
			;;
		/etc/master.passwd)
			log "pwd_mkdb -p -d $DESTDIR/etc ${DESTDIR}$1"
			if [ -z "$dryrun" ]; then
				pwd_mkdb -p -d $DESTDIR/etc ${DESTDIR}$1 \
				    >&3 2>&1
			fi
			;;
		/etc/motd)
			# /etc/rc.d/motd hardcodes the /etc/motd path.
			# Don't warn about non-empty DESTDIR's since this
			# change is only cosmetic anyway.
			if [ -z "$DESTDIR" ]; then
				log "sh /etc/rc.d/motd start"
				if [ -z "$dryrun" ]; then
					sh /etc/rc.d/motd start >&3 2>&1
				fi
			fi
			;;
		/etc/services)
			log "services_mkdb -q -o $DESTDIR/var/db/services.db" \
			    "${DESTDIR}$1"
			if [ -z "$dryrun" ]; then
				services_mkdb -q -o $DESTDIR/var/db/services.db \
				    ${DESTDIR}$1 >&3 2>&1
			fi
			;;
	esac
}

# Install the "new" version of a file.  Returns true if it succeeds
# and false otherwise.
#
# $1 - pathname of the file to install (relative to DESTDIR)
install_new()
{

	if ! install_dirs $NEWTREE "$DESTDIR" $1; then
		return 1
	fi
	log "cp -Rp ${NEWTREE}$1 ${DESTDIR}$1"
	if [ -z "$dryrun" ]; then
		cp -Rp ${NEWTREE}$1 ${DESTDIR}$1 >&3 2>&1
	fi
	post_install_file $1
	return 0
}

# Install the "resolved" version of a file.  Returns true if it succeeds
# and false otherwise.
#
# $1 - pathname of the file to install (relative to DESTDIR)
install_resolved()
{

	# This should always be present since the file is already
	# there (it caused a conflict).  However, it doesn't hurt to
	# just be safe.
	if ! install_dirs $NEWTREE "$DESTDIR" $1; then
		return 1
	fi

	# Use cat rather than cp to preserve metadata
	log "cat ${CONFLICTS}$1 > ${DESTDIR}$1"
	cat ${CONFLICTS}$1 > ${DESTDIR}$1 2>&3
	post_install_file $1
	return 0
}

# Generate a conflict file when a "new" file conflicts with an
# existing file in DESTDIR.
#
# $1 - pathname of the file that conflicts (relative to DESTDIR)
new_conflict()
{

	if [ -n "$dryrun" ]; then
		return
	fi

	install_dirs $NEWTREE $CONFLICTS $1
	diff --changed-group-format='<<<<<<< (local)
%<=======
%>>>>>>>> (stock)
' $DESTDIR/$1 $NEWTREE/$1 > $CONFLICTS/$1
}

# Remove the "old" version of a file.
#
# $1 - pathname of the old file to remove (relative to DESTDIR)
remove_old()
{
	log "rm -f ${DESTDIR}$1"
	if [ -z "$dryrun" ]; then
		rm -f ${DESTDIR}$1 >&3 2>&1
	fi
	echo "  D $1"
}

# Update a file that has no local modifications.
#
# $1 - pathname of the file to update (relative to DESTDIR)
update_unmodified()
{
	local new old

	# If the old file is a directory, then remove it with rmdir
	# (this should only happen if the file has changed its type
	# from a directory to a non-directory).  If the directory
	# isn't empty, then fail.  This will be reported as a warning
	# later.
	if [ -d $DESTDIR/$1 ]; then
		if empty_destdir $1; then
			log "rmdir ${DESTDIR}$1"
			if [ -z "$dryrun" ]; then
				rmdir ${DESTDIR}$1 >&3 2>&1
			fi
		else
			return 1
		fi

	# If both the old and new files are regular files, leave the
	# existing file.  This avoids breaking hard links for /.cshrc
	# and /.profile.  Otherwise, explicitly remove the old file.
	elif ! [ -f ${DESTDIR}$1 -a -f ${NEWTREE}$1 ]; then
		log "rm -f ${DESTDIR}$1"
		if [ -z "$dryrun" ]; then
			rm -f ${DESTDIR}$1 >&3 2>&1
		fi
	fi

	# If the new file is a directory, note that the old file has
	# been removed, but don't do anything else for now.  The
	# directory will be installed if needed when new files within
	# that directory are installed.
	if [ -d $NEWTREE/$1 ]; then
		if empty_dir $NEWTREE/$1; then
			echo "  D $file"
		else
			echo "  U $file"
		fi
	elif install_new $1; then
		echo "  U $file"
	fi
	return 0
}

# Update the FreeBSD ID string in a locally modified file to match the
# FreeBSD ID string from the "new" version of the file.
#
# $1 - pathname of the file to update (relative to DESTDIR)
update_freebsdid()
{
	local new dest file

	# If the FreeBSD ID string is removed from the local file,
	# there is nothing to do.  In this case, treat the file as
	# updated.  Otherwise, if either file has more than one
	# FreeBSD ID string, just punt and let the user handle the
	# conflict manually.
	new=`grep -c '\$FreeBSD.*\$' ${NEWTREE}$1`
	dest=`grep -c '\$FreeBSD.*\$' ${DESTDIR}$1`
	if [ "$dest" -eq 0 ]; then
		return 0
	fi
	if [ "$dest" -ne 1 -o "$dest" -ne 1 ]; then
		return 1
	fi

	# If the FreeBSD ID string in the new file matches the FreeBSD ID
	# string in the local file, there is nothing to do.
	new=`grep '\$FreeBSD.*\$' ${NEWTREE}$1`
	dest=`grep '\$FreeBSD.*\$' ${DESTDIR}$1`
	if [ "$new" = "$dest" ]; then
		return 0
	fi

	# Build the new file in three passes.  First, copy all the
	# lines preceding the FreeBSD ID string from the local version
	# of the file.  Second, append the FreeBSD ID string line from
	# the new version.  Finally, append all the lines after the
	# FreeBSD ID string from the local version of the file.
	file=`mktemp $WORKDIR/etcupdate-XXXXXXX`
	awk '/\$FreeBSD.*\$/ { exit } { print }' ${DESTDIR}$1 >> $file
	awk '/\$FreeBSD.*\$/ { print }' ${NEWTREE}$1 >> $file
	awk '/\$FreeBSD.*\$/ { ok = 1; next } { if (ok) print }' \
	    ${DESTDIR}$1 >> $file

	# As an extra sanity check, fail the attempt if the updated
	# version of the file has any differences aside from the
	# FreeBSD ID string.
	if ! fbsdid_only ${DESTDIR}$1 $file; then
		rm -f $file
		return 1
	fi

	log "cp $file ${DESTDIR}$1"
	if [ -z "$dryrun" ]; then
		cp $file ${DESTDIR}$1 >&3 2>&1
	fi
	rm -f $file
	post_install_file $1
	echo "  M $1"
	return 0
}

# Attempt to update a file that has local modifications.  This routine
# only handles regular files.  If the 3-way merge succeeds without
# conflicts, the updated file is installed.  If the merge fails, the
# merged version with conflict markers is left in the CONFLICTS tree.
#
# $1 - pathname of the file to merge (relative to DESTDIR)
merge_file()
{
	local res

	# Try the merge to see if there is a conflict.
	diff3 -E -m ${DESTDIR}$1 ${OLDTREE}$1 ${NEWTREE}$1 > /dev/null 2>&3
	res=$?
	case $res in
		0)
			# No conflicts, so just redo the merge to the
			# real file.
			log "diff3 -E -m ${DESTDIR}$1 ${OLDTREE}$1 ${NEWTREE}$1"
			if [ -z "$dryrun" ]; then
				temp=$(mktemp -t etcupdate)
				diff3 -E -m ${DESTDIR}$1 ${OLDTREE}$1 ${NEWTREE}$1 > ${temp}
				# Use "cat >" to preserve metadata.
				cat ${temp} > ${DESTDIR}$1
				rm -f ${temp}
			fi
			post_install_file $1
			echo "  M $1"
			;;
		1)
			# Conflicts, save a version with conflict markers in
			# the conflicts directory.
			if [ -z "$dryrun" ]; then
				install_dirs $NEWTREE $CONFLICTS $1
				log "diff3 -m ${DESTDIR}$1 ${CONFLICTS}$1"
				diff3 -m -L "yours" -L "original" -L "new" \
				    ${DESTDIR}$1 ${OLDTREE}$1 ${NEWTREE}$1 > \
				    ${CONFLICTS}$1
			fi
			echo "  C $1"
			;;
		*)
			panic "merge failed with status $res"
			;;
	esac
}

# Returns true if a file contains conflict markers from a merge conflict.
#
# $1 - pathname of the file to resolve (relative to DESTDIR)
has_conflicts()
{

	egrep -q '^(<{7}|\|{7}|={7}|>{7}) ' $CONFLICTS/$1
}

# Attempt to resolve a conflict.  The user is prompted to choose an
# action for each conflict.  If the user edits the file, they are
# prompted again for an action.  The process is very similar to
# resolving conflicts after an update or merge with Perforce or
# Subversion.  The prompts are modelled on a subset of the available
# commands for resolving conflicts with Subversion.
#
# $1 - pathname of the file to resolve (relative to DESTDIR)
resolve_conflict()
{
	local command junk

	echo "Resolving conflict in '$1':"
	edit=
	while true; do
		# Only display the resolved command if the file
		# doesn't contain any conflicts.
		echo -n "Select: (p) postpone, (df) diff-full, (e) edit,"
		if ! has_conflicts $1; then
			echo -n " (r) resolved,"
		fi
		echo
		echo -n "        (h) help for more options: "
		read command
		case $command in
			df)
				diff -u ${DESTDIR}$1 ${CONFLICTS}$1
				;;
			e)
				$EDITOR ${CONFLICTS}$1
				;;
			h)
				cat <<EOF
  (p)  postpone    - ignore this conflict for now
  (df) diff-full   - show all changes made to merged file
  (e)  edit        - change merged file in an editor
  (r)  resolved    - accept merged version of file
  (mf) mine-full   - accept local version of entire file (ignore new changes)
  (tf) theirs-full - accept new version of entire file (lose local changes)
  (h)  help        - show this list
EOF
				;;
			mf)
				# For mine-full, just delete the
				# merged file and leave the local
				# version of the file as-is.
				rm ${CONFLICTS}$1
				return
				;;
			p)
				return
				;;
			r)
				# If the merged file has conflict
				# markers, require confirmation.
				if has_conflicts $1; then
					echo "File '$1' still has conflicts," \
					    "are you sure? (y/n) "
					read junk
					if [ "$junk" != "y" ]; then
						continue
					fi
				fi

				if ! install_resolved $1; then
					panic "Unable to install merged" \
					    "version of $1"
				fi
				rm ${CONFLICTS}$1
				return
				;;
			tf)
				# For theirs-full, install the new
				# version of the file over top of the
				# existing file.
				if ! install_new $1; then
					panic "Unable to install new" \
					    "version of $1"
				fi
				rm ${CONFLICTS}$1
				return
				;;
			*)
				echo "Invalid command."
				;;
		esac
	done
}

# Handle a file that has been removed from the new tree.  If the file
# does not exist in DESTDIR, then there is nothing to do.  If the file
# exists in DESTDIR and is identical to the old version, remove it
# from DESTDIR.  Otherwise, whine about the conflict but leave the
# file in DESTDIR.  To handle directories, this uses two passes.  The
# first pass handles all non-directory files.  The second pass handles
# just directories and removes them if they are empty.
#
# If -F is specified, and the only difference in the file in DESTDIR
# is a change in the FreeBSD ID string, then remove the file.
#
# $1 - pathname of the file (relative to DESTDIR)
handle_removed_file()
{
	local dest file

	file=$1
	if ignore $file; then
		log "IGNORE: removed file $file"
		return
	fi

	compare_fbsdid $DESTDIR/$file $OLDTREE/$file
	case $? in
		$COMPARE_EQUAL)
			if ! [ -d $DESTDIR/$file ]; then
				remove_old $file
			fi
			;;
		$COMPARE_ONLYFIRST)
			panic "Removed file now missing"
			;;
		$COMPARE_ONLYSECOND)
			# Already removed, nothing to do.
			;;
		$COMPARE_DIFFTYPE|$COMPARE_DIFFLINKS|$COMPARE_DIFFFILES)
			dest=`file_type $DESTDIR/$file`
			warn "Modified $dest remains: $file"
			;;
	esac
}

# Handle a directory that has been removed from the new tree.  Only
# remove the directory if it is empty.
#
# $1 - pathname of the directory (relative to DESTDIR)
handle_removed_directory()
{
	local dir

	dir=$1
	if ignore $dir; then
		log "IGNORE: removed dir $dir"
		return
	fi

	if [ -d $DESTDIR/$dir -a -d $OLDTREE/$dir ]; then
		if empty_destdir $dir; then
			log "rmdir ${DESTDIR}$dir"
			if [ -z "$dryrun" ]; then
				rmdir ${DESTDIR}$dir >/dev/null 2>&1
			fi
			echo "  D $dir"
		else
			warn "Non-empty directory remains: $dir"
		fi
	fi
}

# Handle a file that exists in both the old and new trees.  If the
# file has not changed in the old and new trees, there is nothing to
# do.  If the file in the destination directory matches the new file,
# there is nothing to do.  If the file in the destination directory
# matches the old file, then the new file should be installed.
# Everything else becomes some sort of conflict with more detailed
# handling.
#
# $1 - pathname of the file (relative to DESTDIR)
handle_modified_file()
{
	local cmp dest file new newdestcmp old

	file=$1
	if ignore $file; then
		log "IGNORE: modified file $file"
		return
	fi

	compare $OLDTREE/$file $NEWTREE/$file
	cmp=$?
	if [ $cmp -eq $COMPARE_EQUAL ]; then
		return
	fi

	if [ $cmp -eq $COMPARE_ONLYFIRST -o $cmp -eq $COMPARE_ONLYSECOND ]; then
		panic "Changed file now missing"
	fi

	compare $NEWTREE/$file $DESTDIR/$file
	newdestcmp=$?
	if [ $newdestcmp -eq $COMPARE_EQUAL ]; then
		return
	fi

	# If the only change in the new file versus the destination
	# file is a change in the FreeBSD ID string and -F is
	# specified, just install the new file.
	if [ -n "$FREEBSD_ID" -a $newdestcmp -eq $COMPARE_DIFFFILES ] && \
	    fbsdid_only $NEWTREE/$file $DESTDIR/$file; then
		if update_unmodified $file; then
			return
		else
			panic "Updating FreeBSD ID string failed"
		fi
	fi

	# If the local file is the same as the old file, install the
	# new file.  If -F is specified and the only local change is
	# in the FreeBSD ID string, then install the new file as well.
	if compare_fbsdid $OLDTREE/$file $DESTDIR/$file; then
		if update_unmodified $file; then
			return
		fi
	fi

	# If the file was removed from the dest tree, just whine.
	if [ $newdestcmp -eq $COMPARE_ONLYFIRST ]; then
		# If the removed file matches an ALWAYS_INSTALL glob,
		# then just install the new version of the file.
		if always_install $file; then
			log "ALWAYS: adding $file"
			if ! [ -d $NEWTREE/$file ]; then
				if install_new $file; then
					echo "  A $file"
				fi
			fi
			return
		fi

		# If the only change in the new file versus the old
		# file is a change in the FreeBSD ID string and -F is
		# specified, don't warn.
		if [ -n "$FREEBSD_ID" -a $cmp -eq $COMPARE_DIFFFILES ] && \
		    fbsdid_only $OLDTREE/$file $NEWTREE/$file; then
			return
		fi

		case $cmp in
			$COMPARE_DIFFTYPE)
				old=`file_type $OLDTREE/$file`
				new=`file_type $NEWTREE/$file`
				warn "Remove mismatch: $file ($old became $new)"
				;;
			$COMPARE_DIFFLINKS)
				old=`readlink $OLDTREE/$file`
				new=`readlink $NEWTREE/$file`
				warn \
		"Removed link changed: $file (\"$old\" became \"$new\")"
				;;
			$COMPARE_DIFFFILES)
				warn "Removed file changed: $file"
				;;
		esac
		return
	fi

	# Treat the file as unmodified and force install of the new
	# file if it matches an ALWAYS_INSTALL glob.  If the update
	# attempt fails, then fall through to the normal case so a
	# warning is generated.
	if always_install $file; then
		log "ALWAYS: updating $file"
		if update_unmodified $file; then
			return
		fi
	fi

	# If the only change in the new file versus the old file is a
	# change in the FreeBSD ID string and -F is specified, just
	# update the FreeBSD ID string in the local file.
	if [ -n "$FREEBSD_ID" -a $cmp -eq $COMPARE_DIFFFILES ] && \
	    fbsdid_only $OLDTREE/$file $NEWTREE/$file; then
		if update_freebsdid $file; then
			continue
		fi
	fi

	# If the file changed types between the old and new trees but
	# the files in the new and dest tree are both of the same
	# type, treat it like an added file just comparing the new and
	# dest files.
	if [ $cmp -eq $COMPARE_DIFFTYPE ]; then
		case $newdestcmp in
			$COMPARE_DIFFLINKS)
				new=`readlink $NEWTREE/$file`
				dest=`readlink $DESTDIR/$file`
				warn \
			"New link conflict: $file (\"$new\" vs \"$dest\")"
				return
				;;
			$COMPARE_DIFFFILES)
				new_conflict $file
				echo "  C $file"
				return
				;;
		esac
	else
		# If the file has not changed types between the old
		# and new trees, but it is a different type in
		# DESTDIR, then just warn.
		if [ $newdestcmp -eq $COMPARE_DIFFTYPE ]; then
			new=`file_type $NEWTREE/$file`
			dest=`file_type $DESTDIR/$file`
			warn "Modified mismatch: $file ($new vs $dest)"
			return
		fi
	fi

	case $cmp in
		$COMPARE_DIFFTYPE)
			old=`file_type $OLDTREE/$file`
			new=`file_type $NEWTREE/$file`
			dest=`file_type $DESTDIR/$file`
			warn "Modified $dest changed: $file ($old became $new)"
			;;
		$COMPARE_DIFFLINKS)
			old=`readlink $OLDTREE/$file`
			new=`readlink $NEWTREE/$file`
			warn \
		"Modified link changed: $file (\"$old\" became \"$new\")"
			;;
		$COMPARE_DIFFFILES)
			merge_file $file
			;;
	esac
}

# Handle a file that has been added in the new tree.  If the file does
# not exist in DESTDIR, simply copy the file into DESTDIR.  If the
# file exists in the DESTDIR and is identical to the new version, do
# nothing.  Otherwise, generate a diff of the two versions of the file
# and mark it as a conflict.
#
# $1 - pathname of the file (relative to DESTDIR)
handle_added_file()
{
	local cmp dest file new

	file=$1
	if ignore $file; then
		log "IGNORE: added file $file"
		return
	fi

	compare $DESTDIR/$file $NEWTREE/$file
	cmp=$?
	case $cmp in
		$COMPARE_EQUAL)
			return
			;;
		$COMPARE_ONLYFIRST)
			panic "Added file now missing"
			;;
		$COMPARE_ONLYSECOND)
			# Ignore new directories.  They will be
			# created as needed when non-directory nodes
			# are installed.
			if ! [ -d $NEWTREE/$file ]; then
				if install_new $file; then
					echo "  A $file"
				fi
			fi
			return
			;;
	esac


	# Treat the file as unmodified and force install of the new
	# file if it matches an ALWAYS_INSTALL glob.  If the update
	# attempt fails, then fall through to the normal case so a
	# warning is generated.
	if always_install $file; then
		log "ALWAYS: updating $file"
		if update_unmodified $file; then
			return
		fi
	fi

	case $cmp in
		$COMPARE_DIFFTYPE)
			new=`file_type $NEWTREE/$file`
			dest=`file_type $DESTDIR/$file`
			warn "New file mismatch: $file ($new vs $dest)"
			;;
		$COMPARE_DIFFLINKS)
			new=`readlink $NEWTREE/$file`
			dest=`readlink $DESTDIR/$file`
			warn "New link conflict: $file (\"$new\" vs \"$dest\")"
			;;
		$COMPARE_DIFFFILES)
			# If the only change in the new file versus
			# the destination file is a change in the
			# FreeBSD ID string and -F is specified, just
			# install the new file.
			if [ -n "$FREEBSD_ID" ] && \
			    fbsdid_only $NEWTREE/$file $DESTDIR/$file; then
				if update_unmodified $file; then
					return
				else
					panic \
					"Updating FreeBSD ID string failed"
				fi
			fi

			new_conflict $file
			echo "  C $file"
			;;
	esac
}

# Main routines for each command

# Build a new tree and save it in a tarball.
build_cmd()
{
	local dir tartree

	if [ $# -ne 1 ]; then
		echo "Missing required tarball."
		echo
		usage
	fi

	log "build command: $1"

	# Create a temporary directory to hold the tree
	dir=`mktemp -d $WORKDIR/etcupdate-XXXXXXX`
	if [ $? -ne 0 ]; then
		echo "Unable to create temporary directory."
		exit 1
	fi
	if ! build_tree $dir; then
		echo "Failed to build tree."
		remove_tree $dir
		exit 1
	fi
	if [ -n "$noroot" ]; then
		tartree=@METALOG
	else
		tartree=.
	fi
	if ! tar cfj $1 -C $dir $tartree >&3 2>&1; then
		echo "Failed to create tarball."
		remove_tree $dir
		exit 1
	fi
	remove_tree $dir
}

# Output a diff comparing the tree at DESTDIR to the current
# unmodified tree.  Note that this diff does not include files that
# are present in DESTDIR but not in the unmodified tree.
diff_cmd()
{
	local file

	if [ $# -ne 0 ]; then
		usage
	fi

	# Requires an unmodified tree to diff against.
	if ! [ -d $NEWTREE ]; then
		echo "Reference tree to diff against unavailable."
		exit 1
	fi

	# Unfortunately, diff alone does not quite provide the right
	# level of options that we want, so improvise.
	for file in `(cd $NEWTREE; find .) | sed -e 's/^\.//'`; do
		if ignore $file; then
			continue
		fi

		diffnode $NEWTREE "$DESTDIR" $file "stock" "local"
	done
}

# Just extract a new tree into NEWTREE either by building a tree or
# extracting a tarball.  This can be used to bootstrap updates by
# initializing the current "stock" tree to match the currently
# installed system.
#
# Unlike 'update', this command does not rotate or preserve an
# existing NEWTREE, it just replaces any existing tree.
extract_cmd()
{

	if [ $# -ne 0 ]; then
		usage
	fi

	log "extract command: tarball=$tarball"

	# Create a temporary directory to hold the tree
	dir=`mktemp -d $WORKDIR/etcupdate-XXXXXXX`
	if [ $? -ne 0 ]; then
		echo "Unable to create temporary directory."
		exit 1
	fi

	extract_tree $dir

	if [ -d $NEWTREE ]; then
		if ! remove_tree $NEWTREE; then
			echo "Unable to remove current tree."
			remove_tree $dir
			exit 1
		fi
	fi

	if ! mv $dir $NEWTREE >&3 2>&1; then
		echo "Unable to rename temp tree to current tree."
		remove_tree $dir
		exit 1
	fi
}

# Resolve conflicts left from an earlier merge.
resolve_cmd()
{
	local conflicts

	if [ $# -ne 0 ]; then
		usage
	fi

	if ! [ -d $CONFLICTS ]; then
		return
	fi

	if ! [ -d $NEWTREE ]; then
		echo "The current tree is not present to resolve conflicts."
		exit 1
	fi

	conflicts=`(cd $CONFLICTS; find . ! -type d) | sed -e 's/^\.//'`
	for file in $conflicts; do
		resolve_conflict $file
	done

	if [ -n "$NEWALIAS_WARN" ]; then
		warn "Needs update: /etc/mail/aliases.db" \
		    "(requires manual update via newaliases(1))"
		echo
		echo "Warnings:"
		echo "  Needs update: /etc/mail/aliases.db" \
		    "(requires manual update via newaliases(1))"
	fi
}

# Restore files to the stock version.  Only files with a local change
# are restored from the stock version.
revert_cmd()
{
	local cmp file

	if [ $# -eq 0 ]; then
		usage
	fi

	for file; do
		log "revert $file"

		if ! [ -e $NEWTREE/$file ]; then
			echo "File $file does not exist in the current tree."
			exit 1
		fi
		if [ -d $NEWTREE/$file ]; then
			echo "File $file is a directory."
			exit 1
		fi

		compare $DESTDIR/$file $NEWTREE/$file
		cmp=$?
		if [ $cmp -eq $COMPARE_EQUAL ]; then
			continue
		fi

		if update_unmodified $file; then
			# If this file had a conflict, clean up the
			# conflict.
			if [ -e $CONFLICTS/$file ]; then
				if ! rm $CONFLICTS/$file >&3 2>&1; then
					echo "Failed to remove conflict " \
					     "for $file".
				fi
			fi
		fi
	done
}

# Report a summary of the previous merge.  Specifically, list any
# remaining conflicts followed by any warnings from the previous
# update.
status_cmd()
{

	if [ $# -ne 0 ]; then
		usage
	fi

	if [ -d $CONFLICTS ]; then
		(cd $CONFLICTS; find . ! -type d) | sed -e 's/^\./  C /'
	fi
	if [ -s $WARNINGS ]; then
		echo "Warnings:"
		cat $WARNINGS
	fi
}

# Perform an actual merge.  The new tree can either already exist (if
# rerunning a merge), be extracted from a tarball, or generated from a
# source tree.
update_cmd()
{
	local dir new old

	if [ $# -ne 0 ]; then
		usage
	fi

	log "update command: rerun=$rerun tarball=$tarball preworld=$preworld"

	if [ `id -u` -ne 0 ]; then
		echo "Must be root to update a tree."
		exit 1
	fi

	# Enforce a sane umask
	umask 022

	# XXX: Should existing conflicts be ignored and removed during
	# a rerun?

	# Trim the conflicts tree.  Whine if there is anything left.
	if [ -e $CONFLICTS ]; then
		find -d $CONFLICTS -type d -empty -delete >&3 2>&1
		rmdir $CONFLICTS >&3 2>&1
	fi
	if [ -d $CONFLICTS ]; then
		echo "Conflicts remain from previous update, aborting."
		exit 1
	fi

	# Save tree names to use for rotation later.
	old=$OLDTREE
	new=$NEWTREE
	if [ -z "$rerun" ]; then
		# Extract the new tree to a temporary directory.  The
	        # trees are only rotated after a successful update to
	        # avoid races if an update command is interrupted
	        # before it completes.
		dir=`mktemp -d $WORKDIR/etcupdate-XXXXXXX`
		if [ $? -ne 0 ]; then
			echo "Unable to create temporary directory."
			exit 1
		fi

		# Populate the new tree.
		extract_tree $dir

		# Compare the new tree against the previous tree.  For
		# the preworld case OLDTREE already points to the
		# current stock tree.
		if [ -z "$preworld" ]; then
			OLDTREE=$NEWTREE
		fi
		NEWTREE=$dir
	fi

	if ! [ -d $OLDTREE ]; then
		cat <<EOF
No previous tree to compare against, a sane comparison is not possible.
EOF
		log "No previous tree to compare against."
		if [ -n "$dir" ]; then
			if [ -n "$rerun" ]; then
				panic "Should not have a temporary directory"
			fi
			remove_tree $dir
		fi
		exit 1
	fi

	# Build lists of nodes in the old and new trees.
	(cd $OLDTREE; find .) | sed -e 's/^\.//' | sort > $WORKDIR/old.files
	(cd $NEWTREE; find .) | sed -e 's/^\.//' | sort > $WORKDIR/new.files

	# Split the files up into three groups using comm.
	comm -23 $WORKDIR/old.files $WORKDIR/new.files > $WORKDIR/removed.files
	comm -13 $WORKDIR/old.files $WORKDIR/new.files > $WORKDIR/added.files
	comm -12 $WORKDIR/old.files $WORKDIR/new.files > $WORKDIR/both.files

	# Initialize conflicts and warnings handling.
	rm -f $WARNINGS
	mkdir -p $CONFLICTS
	if ! chmod 0700 ${CONFLICTS}; then
		panic "Unable to set permissions on conflicts directory"
	fi

	# Ignore removed files for the pre-world case.  A pre-world
	# update uses a stripped-down tree.
	if [ -n "$preworld" ]; then
		> $WORKDIR/removed.files
	fi

	# The order for the following sections is important.  In the
	# odd case that a directory is converted into a file, the
	# existing subfiles need to be removed if possible before the
	# file is converted.  Similarly, in the case that a file is
	# converted into a directory, the file needs to be converted
	# into a directory if possible before the new files are added.

	# First, handle removed files.
	for file in `cat $WORKDIR/removed.files`; do
		handle_removed_file $file
	done

	# For the directory pass, reverse sort the list to effect a
	# depth-first traversal.  This is needed to ensure that if a
	# directory with subdirectories is removed, the entire
	# directory is removed if there are no local modifications.
	for file in `sort -r $WORKDIR/removed.files`; do
		handle_removed_directory $file
	done

	# Second, handle files that exist in both the old and new
	# trees.
	for file in `cat $WORKDIR/both.files`; do
		handle_modified_file $file
	done

	# Finally, handle newly added files.
	for file in `cat $WORKDIR/added.files`; do
		handle_added_file $file
	done

	if [ -n "$NEWALIAS_WARN" ]; then
		warn "Needs update: /etc/mail/aliases.db" \
		    "(requires manual update via newaliases(1))"
	fi

	# Run any special one-off commands after an update has completed.
	post_update

	if [ -s $WARNINGS ]; then
		echo "Warnings:"
		cat $WARNINGS
	fi

	# If this was a dryrun, remove the temporary tree if we built
	# a new one.
	if [ -n "$dryrun" ]; then
		if [ -n "$dir" ]; then
			if [ -n "$rerun" ]; then
				panic "Should not have a temporary directory"
			fi
			remove_tree $dir
		fi
		return
	fi

	# Finally, rotate any needed trees.
	if [ "$new" != "$NEWTREE" ]; then
		if [ -n "$rerun" ]; then
			panic "Should not have a temporary directory"
		fi
		if [ -z "$dir" ]; then
			panic "Should have a temporary directory"
		fi

		# Rotate the old tree if needed
		if [ "$old" != "$OLDTREE" ]; then
			if [ -n "$preworld" ]; then
				panic "Old tree should be unchanged"
			fi

			if ! remove_tree $old; then
				echo "Unable to remove previous old tree."
				exit 1
			fi

			if ! mv $OLDTREE $old >&3 2>&1; then
				echo "Unable to rename old tree."
				exit 1
			fi
		fi

		# Rotate the new tree.  Remove a previous pre-world
		# tree if it exists.
		if [ -d $new ]; then
			if [ -z "$preworld" ]; then
				panic "New tree should be rotated to old"
			fi
			if ! remove_tree $new; then
				echo "Unable to remove previous pre-world tree."
				exit 1
			fi
		fi

		if ! mv $NEWTREE $new >&3 2>&1; then
			echo "Unable to rename current tree."
			exit 1
		fi
	fi
}

# Determine which command we are executing.  A command may be
# specified as the first word.  If one is not specified then 'update'
# is assumed as the default command.
command="update"
if [ $# -gt 0 ]; then
	case "$1" in
		build|diff|extract|status|resolve|revert)
			command="$1"
			shift
			;;
		-*)
			# If first arg is an option, assume the
			# default command.
			;;
		*)
			usage
			;;
	esac
fi

# Set default variable values.

# The path to the source tree used to build trees.
SRCDIR=/usr/src

# The destination directory where the modified files live.
DESTDIR=

# Ignore changes in the FreeBSD ID string.
FREEBSD_ID=

# Files that should always have the new version of the file installed.
ALWAYS_INSTALL=

# Files to ignore and never update during a merge.
IGNORE_FILES=

# The path to the make binary
MAKE_CMD=make

# Flags to pass to 'make' when building a tree.
MAKE_OPTIONS=

# Include a config file if it exists.  Note that command line options
# override any settings in the config file.  More details are in the
# manual, but in general the following variables can be set:
# - ALWAYS_INSTALL
# - DESTDIR
# - EDITOR
# - FREEBSD_ID
# - IGNORE_FILES
# - LOGFILE
# - MAKE_CMD
# - MAKE_OPTIONS
# - SRCDIR
# - WORKDIR
if [ -r /etc/etcupdate.conf ]; then
	. /etc/etcupdate.conf
fi

# Parse command line options
tarball=
rerun=
always=
dryrun=
ignore=
nobuild=
preworld=
noroot=
while getopts "d:m:nprs:t:A:BD:FI:L:M:N" option; do
	case "$option" in
		d)
			WORKDIR=$OPTARG
			;;
		m)
			MAKE_CMD=$OPTARG
			;;
		n)
			dryrun=YES
			;;
		p)
			preworld=YES
			;;
		r)
			rerun=YES
			;;
		s)
			SRCDIR=$OPTARG
			;;
		t)
			tarball=$OPTARG
			;;
		A)
			# To allow this option to be specified
			# multiple times, accumulate command-line
			# specified patterns in an 'always' variable
			# and use that to overwrite ALWAYS_INSTALL
			# after parsing all options.  Need to be
			# careful here with globbing expansion.
			set -o noglob
			always="$always $OPTARG"
			set +o noglob
			;;
		B)
			nobuild=YES
			;;
		D)
			DESTDIR=$OPTARG
			;;
		F)
			FREEBSD_ID=YES
			;;
		I)
			# To allow this option to be specified
			# multiple times, accumulate command-line
			# specified patterns in an 'ignore' variable
			# and use that to overwrite IGNORE_FILES after
			# parsing all options.  Need to be careful
			# here with globbing expansion.
			set -o noglob
			ignore="$ignore $OPTARG"
			set +o noglob
			;;
		L)
			LOGFILE=$OPTARG
			;;
		M)
			MAKE_OPTIONS="$OPTARG"
			;;
		N)
			noroot=YES
			;;
		*)
			echo
			usage
			;;
	esac
done
shift $((OPTIND - 1))

# Allow -A command line options to override ALWAYS_INSTALL set from
# the config file.
set -o noglob
if [ -n "$always" ]; then
	ALWAYS_INSTALL="$always"
fi

# Allow -I command line options to override IGNORE_FILES set from the
# config file.
if [ -n "$ignore" ]; then
	IGNORE_FILES="$ignore"
fi
set +o noglob

# Where the "old" and "new" trees are stored.
WORKDIR=${WORKDIR:-$DESTDIR/var/db/etcupdate}

# Log file for verbose output from program that are run.  The log file
# is opened on fd '3'.
LOGFILE=${LOGFILE:-$WORKDIR/log}

# The path of the "old" tree
OLDTREE=$WORKDIR/old

# The path of the "new" tree
NEWTREE=$WORKDIR/current

# The path of the "conflicts" tree where files with merge conflicts are saved.
CONFLICTS=$WORKDIR/conflicts

# The path of the "warnings" file that accumulates warning notes from an update.
WARNINGS=$WORKDIR/warnings

# Use $EDITOR for resolving conflicts.  If it is not set, default to vi.
EDITOR=${EDITOR:-/usr/bin/vi}

# Files that need to be updated before installworld.
PREWORLD_FILES="etc/master.passwd etc/group"

# Handle command-specific argument processing such as complaining
# about unsupported options.  Since the configuration file is always
# included, do not complain about extra command line arguments that
# may have been set via the config file rather than the command line.
case $command in
	update)
		if [ -n "$rerun" -a -n "$tarball" ]; then
			echo "Only one of -r or -t can be specified."
			echo
			usage
		fi
		if [ -n "$rerun" -a -n "$preworld" ]; then
			echo "Only one of -p or -r can be specified."
			echo
			usage
		fi
		;;
	build|diff|status|revert)
		if [ -n "$dryrun" -o -n "$rerun" -o -n "$tarball" -o \
		     -n "$preworld" ]; then
			usage
		fi
		;;
	resolve)
		if [ -n "$dryrun" -o -n "$rerun" -o -n "$tarball" ]; then
			usage
		fi
		;;
	extract)
		if [ -n "$dryrun" -o -n "$rerun" -o -n "$preworld" ]; then
			usage
		fi
		;;
esac

# Pre-world mode uses a different set of trees.  It leaves the current
# tree as-is so it is still present for a full etcupdate run after the
# world install is complete.  Instead, it installs a few critical files
# into a separate tree.
if [ -n "$preworld" ]; then
	OLDTREE=$NEWTREE
	NEWTREE=$WORKDIR/preworld
fi

# Open the log file.  Don't truncate it if doing a minor operation so
# that a minor operation doesn't lose log info from a major operation.
if ! mkdir -p $WORKDIR 2>/dev/null; then
	echo "Failed to create work directory $WORKDIR"
fi

case $command in
	diff|resolve|revert|status)
		exec 3>>$LOGFILE
		;;
	*)
		exec 3>$LOGFILE
		;;
esac

${command}_cmd "$@"
