#!/bin/bash
set -u
declare ThisScript=${0##*/}
declare ThisUser=
declare ThisHost=${HOSTNAME%%.*}
declare Version=1.1.0
declare Args="$*"
declare CmdLine="$0 $Args"
declare GrepExpression=
declare AppHome=${PWD%/*}
declare FileList=
declare Log=
declare LogsDir=
declare LogLink=
declare LogTimestamp=
declare -a SedExpressionList
declare -a SedArgs
declare -i SedExpressionCount=0
declare -i Debug=${DEBUG:-0}
declare -i FileCount=0
declare -i CheckoutsOK=0
declare -i NoP4=0
declare -i CheckedOut=0
declare -i LogIdx=0
#declare -i NoOp=0
declare -i LineCount=0
declare -i FileCountOK=0
declare -i FileCountBad=0
declare -i ErrorCount=0
declare -i WarningCount=0
declare TicketExpiration=
declare MinTicketExpiration=600
declare H1="=============================================================================="
#==============================================================================
# Local Functions
function msg () { echo -e "$*"; }
function msgn () { echo -e -n "$*"; }
function msg_green { msg "${GREEN}$*${RESET}"; }
function msg_yellow { msg "${YELLOW}$*${RESET}"; }
function msg_red { msg "${RED}$*${RESET}"; }
function dbg () { [[ "$Debug" -eq 0 ]] || msg "DEBUG: $*"; }
function dbg2 () { [[ "$Debug" -lt 2 ]] || msg "DEBUG: $*"; }
function errmsg () { msg_red "\nError: ${1:-Unknown Error}\n"; ErrorCount+=1; }
function warnmsg () { msg_yellow "\nWarning: ${1:-Unknown Warning}\n"; WarningCount+=1; }
function bail () { errmsg "${1:-Unknown Error}"; exit "${2:-1}"; }
#------------------------------------------------------------------------------
# Function: get_old_log_timestamp ($log)
#
# Get the last modified timestamp of the old log in a cross-platform manner.
# If we don't get a correct value using 'stat' (which varies across the
# UNIX/Linux/MacOSX spectrum), use the current time as a fallback. In that
# case, the timestamp will reflect the time the log was moved rather than when
# it was last modified, but that's still reasonable. The file timestamp will
# still have the correct last-modified time.
#------------------------------------------------------------------------------
function get_old_log_timestamp () {
local log=${1:-}
local oldLogTimestamp=
[[ -n "$log" ]] || return
if [[ "$(uname -s)" == "Darwin" ]]; then
oldLogTimestamp=$(stat -L -f %Sm -t '%Y-%m-%d-%H%M%S' "$log" 2>/dev/null)
else
oldLogTimestamp="$(stat -L -c '%10y' "$log" | sed -e 's@[.].*$@@g' -e 's@:@@g' -e 's@ @-@g')"
fi
[[ "$oldLogTimestamp" =~ ^[2-9]{1}[0-9]{3}- ]] ||
oldLogTimestamp=$(date +'%Y-%m-%d-%H%M%S')
echo "$oldLogTimestamp"
}
#------------------------------------------------------------------------------
# Function: terminate
# shellcheck disable=SC2317,SC2154
function terminate ()
{
# Disable signal trapping.
trap - EXIT SIGINT SIGTERM
[[ "$Log" == "off" ]] || msg "\nLog is: $Log\n${H1}"
# With the trap removed, exit.
exit "$ErrorCount"
}
#------------------------------------------------------------------------------
# Function: usage (required function)
#
# Input:
# $1 - style, either -h (for short form) or -man (for man-page like format).
# The default is -h.
#
# $2 - error message (optional). Specify this if usage() is called due to a
# user error, in which case the given message displayed first, followed by the
# standard usage message (short or long depending on $1). If displaying an
# error, usually $1 should be -h so that the longer usage message doesn't
# obscure the error message.
#
# Sample Usage:
# usage
# usage -h
# usage -man
# usage -h "Missing required parameter <RequiredParameter>."
#------------------------------------------------------------------------------
function usage
{
local style=${1:--h}
local usageErrorMessage=${2:-Unset}
if [[ "$usageErrorMessage" != Unset ]]; then
msg "\n\nUsage Error:\n\n$usageErrorMessage\n\n"
fi
msg "USAGE for $ThisScript v$Version:
$ThisScript [-dir <dir>] [-co] [-nop4] [-L <log>] [-d|-D] <GrepExpression> <SedExpression1> [<SedExpression2> ...]
or
$ThisScript [-h|-man|-V]
"
if [[ $style == -man ]]; then
msg "
DESCRIPTION:
Finds files containing text matching <GrepExpression>, checks each one out
in Perforce, and applies one or more sed substitution expressions to it.
Each <SedExpression> is passed to a single 'sed -E -i' invocation per file,
one per '-e' option, so all substitutions are applied to a file in one
pass (rather than running a separate sed command per expression). This
works with both GNU sed and the BSD/macOS sed, auto-detected at runtime.
For example, this can be used to fix a systemic pattern across many files
at once, such as converting literal two-character backslash-n and
backslash-t escape sequences (as might be produced by a tool that failed
to interpret them) into real newline and tab characters.
By default, the directory tree searched and edited is the parent directory
of the directory the script is run from (one level above \$PWD). Use
'-dir <dir>' to operate on a different directory tree instead, which is
also useful for testing this script against a scratch directory.
Before editing any file, this script (unless '-nop4' is in effect):
1. Verifies the current P4 ticket has enough time left to complete the
run (see \$MinTicketExpiration in the script).
2. Aborts if any candidate file is already checked out (opened) by
anyone, unless '-co' is given to skip this check.
3. Runs 'p4 edit' on each candidate file before editing it.
OPTIONS:
-dir <dir>
Specify the directory tree to search and operate on. All files under this
directory (recursively) that match <GrepExpression> are candidates for
editing. Overrides the default of one level above the current working
directory (i.e. the parent of \$PWD).
-co
Skip the pre-flight scan that aborts if any candidate file is already
checked out (opened) in Perforce by anyone. Use with care, since a
concurrent edit to a file opened elsewhere could be clobbered.
-nop4
Disable all Perforce interactions: the P4 ticket expiration check, the
scan for already-opened files, and the 'p4 edit' checkout of each file
before editing it. Candidate files are edited in place directly, so they
must already be writable.
This is implied automatically (with a warning) if 'p4 info' does not
respond, so it typically only needs to be given explicitly to force this
behavior, e.g. when testing against a non-Perforce scratch directory that
happens to be run from within a Perforce workspace.
-L <log>
Specify the path to a log file, or the special value 'off' to disable
logging. By default, all output (stdout and stderr) goes to a timestamped
log file under \$LOGS (or /tmp if \$LOGS is unset).
NOTE: This script is self-logging. That is, output displayed on the screen
is simultaneously captured in the log file. Using redirection operators like
like '> log' or '2>&1' or using 'tee' are unnecessary (but harmless).
-d
Enable normal debugging messages (also preserves the temporary file list
instead of deleting it on exit).
-D
Set extreme debugging verbosity (bash 'set -x').
HELP OPTIONS:
-h Display short help message.
-man Display man-style help message.
-V Display version info for this script.
FILES:
A temporary file list is created via 'mktemp' to hold the list of files
matching <GrepExpression>; it is removed on exit unless '-d'/'-D' is given.
EXAMPLES:
$ThisScript -dir /tmp/testarea 'TODO' 's@TODO@DONE@g'
Search /tmp/testarea recursively for files containing 'TODO' and replace
all occurrences with 'DONE' in each matching file.
$ThisScript -nop4 -dir /tmp/testarea 'TODO' 's@TODO@DONE@g'
Same as above, but skip all Perforce interactions (ticket check, opened
scan, and 'p4 edit'); files are edited in place directly.
SEE ALSO:
"
fi
exit 2
}
#==============================================================================
# Command Line Processing
declare -i ShiftArgs=0
set +u
while [[ $# -gt 0 ]]; do
case $1 in
(-co) CheckoutsOK=1;;
(-dir) AppHome="$2"; ShiftArgs=1;;
(-nop4) NoP4=1;;
(-h) usage -h;;
(-man|--help) usage -man;;
(-V|--version) msg "$ThisScript version $Version"; exit 0;;
(-L) Log="$2"; ShiftArgs=1;;
#(-n) NoOp=1;;
#(-y) NoOp=0;; ### EDITME: Use '-n' or '-y', not both. See NoOp initialization in Declarations.
(-d) Debug=1;;
(-D) Debug=1; set -x;; # Use bash 'set -x' extreme debug mode.
(-*) usage -h "Unknown option ($1).";;
(*)
if [[ -z "$GrepExpression" ]]; then
GrepExpression="$1"
else
SedExpressionList[SedExpressionCount]="$1"
SedExpressionCount+=1
fi
;;
esac
# Shift (modify $#) the appropriate number of times.
shift; while [[ $ShiftArgs -gt 0 ]]; do
[[ $# -eq 0 ]] && usage -h "Incorrect number of arguments."
ShiftArgs=$ShiftArgs-1
shift
done
done
set -u
#==============================================================================
# Command Line Verification
[[ -n "$GrepExpression" ]] ||\
usage -h "Missing required '<GrepExression>'."
[[ "${#SedExpressionList[@]}" -gt 0 ]] || \
usage -h "Missing required list of sed expressions."
[[ -d "$AppHome" ]] || \
usage -h "Directory specified with '-dir' does not exist: [$AppHome]."
for SedExpression in "${SedExpressionList[@]}"; do
SedArgs+=(-e "$SedExpression")
done
if [[ -n "$Log" ]]; then
if [[ "$Log" == */* ]]; then
LogsDir="${Log%/*}"
else
LogsDir="${LOGS:-/tmp}"
fi
else
LogsDir="${LOGS:-/tmp}"
fi
#==============================================================================
# Main Program
trap terminate EXIT SIGINT SIGTERM
# Detect support for colors
if command -v tput >/dev/null 2>&1 \
&& [[ -t 1 ]] \
&& [[ "$(tput colors)" -ge 8 ]]; then
RED="$(tput setaf 1)"
GREEN="$(tput setaf 2)"
YELLOW="$(tput setaf 3)"
RESET="$(tput sgr0)"
else
RED=; GREEN=; YELLOW=; RESET=
fi
# Logging setup. See the Logging section of SDP_CodingStandard_bash.adoc
# for the full description of the log file naming, LogLink symlink, atomic
# creation, and redirection strategy implemented below.
# Summary:
# Default log: $LOGS/<ScriptName>.<YYYY-MM-DD-HHMMSS>.log
# LogLink: $LOGS/<ScriptName>.log (symlink to most recent log)
# -L <file>: Write to specified file instead; no LogLink created.
# -L off: Logging disabled entirely.
if [[ "$Log" != off ]]; then
# If $Log is not yet defined, set it to a reasonable default.
if [[ -z "$Log" ]]; then
[[ -d "${LogsDir}" ]] ||\
bail "Log directory does not exist: [${LogsDir}]."
LogTimestamp=$(date +'%Y-%m-%d-%H%M%S')
Log="$LogsDir/${ThisScript%.sh}.${LogTimestamp}.log"
# Atomically create the log file using noclobber to avoid a race
# condition when the script is invoked multiple times concurrently.
# If the seconds-granularity filename is already taken (concurrent
# invocation in the same second), append an incrementing integer suffix.
# Note: Millisecond precision is intentionally avoided here because the
# %3N date format specifier is a GNU extension not supported on macOS.
until ( set -C; : > "$Log" ) 2>/dev/null; do
Log="$LOGS/${ThisScript%.sh}.${LogTimestamp}.${LogIdx}.log"
LogIdx+=1
done
fi
# The LogLink symlink has no timestamp. It points to the most recent log file.
LogLink="${LogsDir}/${ThisScript%.sh}.log"
if [[ -e "$LogLink" ]]; then
if [[ -L "$LogLink" ]]; then
rm -f "$LogLink"
else
# If the name that should be a symlink is not a symlink, move it aside before
# creating the symlink.
OldLogTimestamp=$(get_old_log_timestamp "$LogLink")
mv -f "$LogLink" "${LogLink%.log}.${OldLogTimestamp}.log" ||\
bail "Could not move old log file aside; tried: mv -f \"$LogLink\" \"${LogLink%.log}.${OldLogTimestamp}.log\""
fi
fi
(
cd "${LogsDir}" || bail "Could not do: cd \"${LogsDir}\""
ln -sf "$Log" "${LogLink%%*/}" ||\
bail "Could not create log symlink in $PWD with: ln -sf \"$Log\" \"${LogLink%%*/}\""
)
# Redirect stdout and stderr to a log file. When redirecting to a log,
# suppress color codes because they detract from readability in logs.
if [[ -n "$GREEN" ]]; then
exec > >( tee \
>(sed -r \
-e 's/\x1B\[[0-9;]*[a-zA-Z]//g' \
-e 's/\x1B\(B//g' >>"$Log"))
else
exec > >(tee -a "$Log")
fi
exec 2>&1
msg "${H1}\nLog is: $Log\n"
fi
ThisUser=$(id -n -u)
msg "Starting $ThisScript v$Version as $ThisUser@$ThisHost on $(date) with:\n$CmdLine"
if [[ "$NoP4" -eq 0 ]]; then
dbg "Checking for a responsive P4 server."
if ! p4 info >/dev/null 2>&1; then
warnmsg "'p4 info' did not respond; implying '-nop4' for the rest of this run."
NoP4=1
fi
fi
if [[ "$NoP4" -eq 0 ]]; then
dbg "Checking P4 Ticket Expiration."
TicketExpiration=$(p4 -ztag -F %TicketExpiration% login -s 2>&1)
if [[ "$TicketExpiration" =~ ^[0-9]+$ ]]; then
if [[ "$TicketExpiration" -gt "$MinTicketExpiration" ]]; then
dbg "Verified: P4 Ticket is good for at least $MinTicketExpiration seconds."
else
bail "P4 Ticket expiration of $TicketExpiration seconds is not long enough. It must be at least $MinTicketExpiration seconds."
fi
else
bail "Could not determine P4 ticket expiration. Aborting."
fi
else
dbg "Skipping P4 ticket check due to '-nop4'."
fi
cd "${AppHome}" || bail "Could not cd to AppHome with: cd \"${AppHome}\""
FileList=$(mktemp)
grep -rl "$GrepExpression" > "$FileList"
if [[ "$NoP4" -eq 1 ]]; then
dbg "Skipping scan for checkouts due to '-nop4'."
elif [[ "$CheckoutsOK" -eq 0 ]]; then
if [[ "$(p4 -s -x "$FileList" opened 2>&1 | grep -v 'not opened on this client')" == "exit: 0" ]]; then
dbg "Verified: No affected files checked out."
else
errmsg "Aborting because Some files are checked out:"
p4 -s -x "$FileList" opened 2>&1 | grep -v 'not opened on this client'
exit 1
fi
else
dbg "Skipping scan for checkouts due to '-co'."
fi
while read -r File; do
LineCount+=1
if [[ -r "$File" ]]; then
FileCount+=1
# A P4-controlled file is normally read-only until checked out, so the
# checkout (or '-nop4' writability check) must happen before testing
# '-w', not after -- otherwise a normal read-only file would never be
# checked out at all.
CheckedOut=1
if [[ "$NoP4" -eq 1 ]]; then
dbg "Skipping checkout of $File due to '-nop4'."
else
msg "Checking out: $File"
if p4 -s edit "$File"; then
CheckedOut=1
else
errmsg "Could not checkout file: $File"
CheckedOut=0
fi
fi
if [[ "$CheckedOut" -eq 1 ]]; then
if [[ -w "$File" ]]; then
if sed --version >/dev/null 2>&1; then
if sed -E -i "${SedArgs[@]}" "${File}"; then
msg "Substituion OK in file: $File"
FileCountOK+=1
else
errmsg "Substituion failed in file: $File"
FileCountBad+=1
fi
else
if sed -E -i '' "${SedArgs[@]}" "${File}"; then
msg "Substituion OK in file: $File"
FileCountOK+=1
else
errmsg "Substituion failed in file: $File"
FileCountBad+=1
fi
fi
else
errmsg "File is not writable; skipping: $File"
fi
fi
else
errmsg "Skipping text [$File] that is not a file on line $LineCount of $FileList".
fi
done < "$FileList"
msg "Summary:
Files Processed: $FileCount
Files OK: $FileCountOK
Files Bad: $FileCountBad
Errors: $ErrorCount
"
if [[ "$Debug" -eq 0 ]]; then
rm -f "$FileList"
else
dbg "File list file is: $FileList"
fi
| # | Change | User | Description | Committed | |
|---|---|---|---|---|---|
| #1 | 33528 | C. Thomas Tyler |
Add sed_fixer.sh: bulk grep+sed file editor with P4 checkout integration Finds files matching a grep expression and applies one or more sed substitution expressions to each (all passed as -e options to a single sed invocation, not a separate command per expression), checking out each file via p4 edit first. Works with both GNU and BSD/macOS sed. Adds '-dir <dir>' to operate on a directory tree other than the default (one level above the invocation directory), and '-nop4' to disable all Perforce interactions (auto-implied if 'p4 info' does not respond), so the script can be tested against a scratch directory or DVCS repo without touching a real server workspace. |