sed_fixer.sh #1

  • //
  • p4-hms/
  • dev/
  • tools/
  • sed_fixer.sh
  • View
  • Commits
  • Open Download .zip Download (16 KB)
#!/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.