OLD | NEW |
(Empty) | |
| 1 #!/bin/bash -p |
| 2 |
| 3 # Copyright (c) 2011 The Chromium Authors. All rights reserved. |
| 4 # Use of this source code is governed by a BSD-style license that can be |
| 5 # found in the LICENSE file. |
| 6 |
| 7 # usage: dirdiffer.sh old_dir new_dir patch_dir |
| 8 # |
| 9 # dirdiffer creates a patch directory patch_dir that represents the difference |
| 10 # between old_dir and new_dir. patch_dir can be used with dirpatcher to |
| 11 # recreate new_dir given old_dir. |
| 12 # |
| 13 # dirdiffer operates recursively, properly handling ordinary files, symbolic |
| 14 # links, and directories, as they are found in new_dir. Symbolic links and |
| 15 # directories are always replicated as-is in patch_dir. Ordinary files will |
| 16 # be represented at the appropriate location in patch_dir by one of the |
| 17 # following: |
| 18 # |
| 19 # - a binary diff prepared by goobsdiff that can transform the file at the |
| 20 # same position in old_dir to the version in new_dir, but only when such a |
| 21 # file already exists in old_dir and is an ordinary file. These files are |
| 22 # given a "$gbs" suffix. |
| 23 # - a bzip2-compressed copy of the new file from new_dir; in patch_dir, the |
| 24 # new file will have a "$bz2" suffix. |
| 25 # - a gzip-compressed copy of the new file from new_dir; in patch_dir, the |
| 26 # new file will have a "$gz" suffix. |
| 27 # - an xz/lzma2-compressed copy of the new file from new_dir; in patch_dir, |
| 28 # the new file will have an "$xz" suffix. |
| 29 # - an uncompressed copy of the new file from new_dir; in patch_dir, the |
| 30 # new file will have a "$raw" suffix. |
| 31 # |
| 32 # The unconventional suffixes are used because they aren't likely to occur in |
| 33 # filenames. |
| 34 # |
| 35 # Of these options, the smallest possible representation is chosen. Note that |
| 36 # goobsdiff itself will also compress various sections of a binary diff with |
| 37 # bzip2, gzip, or xz/lzma2, or leave them uncompressed, according to which is |
| 38 # smallest. The approach of choosing the smallest possible representation is |
| 39 # time-consuming but given the choices of compressors results in an overall |
| 40 # size reduction of about 3%-5% relative to using bzip2 as the only |
| 41 # compressor; bzip2 is generally more effective for these data sets than gzip, |
| 42 # and xz/lzma2 more effective than bzip2. |
| 43 # |
| 44 # For large input files, goobsdiff is also very time-consuming and |
| 45 # memory-intensive. The overall "wall clock time" spent preparing a patch_dir |
| 46 # representing the differences between Google Chrome's 6.0.422.0 and 6.0.427.0 |
| 47 # versioned directories from successive weekly dev channel releases on a |
| 48 # 2.53GHz dual-core 4GB MacBook Pro is 3 minutes. Reconstructing new_dir with |
| 49 # dirpatcher is much quicker; in the above configuration, only 10 seconds are |
| 50 # needed for reconstruction. |
| 51 # |
| 52 # After creating a full patch_dir structure, but before returning, dirpatcher |
| 53 # is invoked to attempt to recreate new_dir in a temporary location given |
| 54 # old_dir and patch_dir. The recreated new_dir is then compared against the |
| 55 # original new_dir as a verification step. Should verification fail, dirdiffer |
| 56 # exits with a nonzero status, and patch_dir should not be used. |
| 57 # |
| 58 # Environment variables: |
| 59 # DIRDIFFER_EXCLUDE |
| 60 # When an entry in new_dir matches this regular expression, it will not be |
| 61 # included in patch_dir. All prospective paths in new_dir will be matched |
| 62 # against this regular expression, including directories. If a directory |
| 63 # matches this pattern, dirdiffer will also ignore the directory's contents. |
| 64 # DIRDIFFER_NO_DIFF |
| 65 # When an entry in new_dir matches this regular expression, it will not be |
| 66 # represented in patch_dir by a $gbs file prepared by goobsdiff. It will only |
| 67 # appear as a $bz2, $gz, or $raw file. Only files in new_dir, not |
| 68 # directories, will be matched against this regular expression. |
| 69 # |
| 70 # Exit codes: |
| 71 # 0 OK |
| 72 # 1 Unknown failure |
| 73 # 2 Incorrect number of parameters |
| 74 # 3 Input directories do not exist or are not directories |
| 75 # 4 Output directory already exists |
| 76 # 5 Parent of output directory does not exist or is not a directory |
| 77 # 6 An input or output directories contains another |
| 78 # 7 Could not create output directory |
| 79 # 8 File already exists in output directory |
| 80 # 9 Found an irregular file (non-directory, file, or symbolic link) in input |
| 81 # 10 Could not create symbolic link |
| 82 # 11 File copy failed |
| 83 # 12 bzip2 compression failed |
| 84 # 13 gzip compression failed |
| 85 # 14 xz/lzma2 compression failed |
| 86 # 15 Patch creation failed |
| 87 # 16 Verification failed |
| 88 # 17 Could not set mode (permissions) |
| 89 # 18 Could not set modification time |
| 90 # 19 Invalid regular expression (irregular expression?) |
| 91 |
| 92 set -eu |
| 93 |
| 94 # Environment sanitization. Set a known-safe PATH. Clear environment variables |
| 95 # that might impact the interpreter's operation. The |bash -p| invocation |
| 96 # on the #! line takes the bite out of BASH_ENV, ENV, and SHELLOPTS (among |
| 97 # other features), but clearing them here ensures that they won't impact any |
| 98 # shell scripts used as utility programs. SHELLOPTS is read-only and can't be |
| 99 # unset, only unexported. |
| 100 export PATH="/usr/bin:/bin:/usr/sbin:/sbin" |
| 101 unset BASH_ENV CDPATH ENV GLOBIGNORE IFS POSIXLY_CORRECT |
| 102 export -n SHELLOPTS |
| 103 |
| 104 shopt -s dotglob nullglob |
| 105 |
| 106 # find_tool looks for an executable file named |tool_name|: |
| 107 # - in the same directory as this script, |
| 108 # - if this script is located in a Chromium source tree, at the expected |
| 109 # Release output location in the Mac out directory, |
| 110 # - as above, but in the Debug output location |
| 111 # If found in any of the above locations, the script's path is output. |
| 112 # Otherwise, this function outputs |tool_name| as a fallback, allowing it to |
| 113 # be found (or not) by an ordinary ${PATH} search. |
| 114 find_tool() { |
| 115 local tool_name="${1}" |
| 116 |
| 117 local script_dir |
| 118 script_dir="$(dirname "${0}")" |
| 119 |
| 120 local tool="${script_dir}/${tool_name}" |
| 121 if [[ -f "${tool}" ]] && [[ -x "${tool}" ]]; then |
| 122 echo "${tool}" |
| 123 return |
| 124 fi |
| 125 |
| 126 local script_dir_phys |
| 127 script_dir_phys="$(cd "${script_dir}" && pwd -P)" |
| 128 if [[ "${script_dir_phys}" =~ ^(.*)/src/chrome/installer/mac$ ]]; then |
| 129 tool="${BASH_REMATCH[1]}/src/out/Release/${tool_name}" |
| 130 if [[ -f "${tool}" ]] && [[ -x "${tool}" ]]; then |
| 131 echo "${tool}" |
| 132 return |
| 133 fi |
| 134 |
| 135 tool="${BASH_REMATCH[1]}/src/out/Debug/${tool_name}" |
| 136 if [[ -f "${tool}" ]] && [[ -x "${tool}" ]]; then |
| 137 echo "${tool}" |
| 138 return |
| 139 fi |
| 140 fi |
| 141 |
| 142 echo "${tool_name}" |
| 143 } |
| 144 |
| 145 ME="$(basename "${0}")" |
| 146 readonly ME |
| 147 DIRPATCHER="$(dirname "${0}")/dirpatcher.sh" |
| 148 readonly DIRPATCHER |
| 149 GOOBSDIFF="$(find_tool goobsdiff)" |
| 150 readonly GOOBSDIFF |
| 151 readonly BZIP2="bzip2" |
| 152 readonly GZIP="gzip" |
| 153 XZ="$(find_tool xz)" |
| 154 readonly XZ |
| 155 readonly GBS_SUFFIX='$gbs' |
| 156 readonly BZ2_SUFFIX='$bz2' |
| 157 readonly GZ_SUFFIX='$gz' |
| 158 readonly XZ_SUFFIX='$xz' |
| 159 readonly PLAIN_SUFFIX='$raw' |
| 160 |
| 161 # Workaround for http://code.google.com/p/chromium/issues/detail?id=83180#c3 |
| 162 # In bash 4.0, "declare VAR" no longer initializes VAR if not already set. |
| 163 : ${DIRDIFFER_EXCLUDE:=} |
| 164 : ${DIRDIFFER_NO_DIFF:=} |
| 165 |
| 166 err() { |
| 167 local error="${1}" |
| 168 |
| 169 echo "${ME}: ${error}" >& 2 |
| 170 } |
| 171 |
| 172 declare -a g_cleanup g_verify_exclude |
| 173 cleanup() { |
| 174 local status=${?} |
| 175 |
| 176 trap - EXIT |
| 177 trap '' HUP INT QUIT TERM |
| 178 |
| 179 if [[ ${status} -ge 128 ]]; then |
| 180 err "Caught signal $((${status} - 128))" |
| 181 fi |
| 182 |
| 183 if [[ "${#g_cleanup[@]}" -gt 0 ]]; then |
| 184 rm -rf "${g_cleanup[@]}" |
| 185 fi |
| 186 |
| 187 exit ${status} |
| 188 } |
| 189 |
| 190 copy_mode_and_time() { |
| 191 local new_file="${1}" |
| 192 local patch_file="${2}" |
| 193 |
| 194 local mode |
| 195 mode="$(stat "-f%OMp%OLp" "${new_file}")" |
| 196 if ! chmod -h "${mode}" "${patch_file}"; then |
| 197 exit 17 |
| 198 fi |
| 199 |
| 200 if ! [[ -L "${patch_file}" ]]; then |
| 201 # Symbolic link modification times can't be copied because there's no |
| 202 # shell tool that provides direct access to lutimes. Instead, the symbolic |
| 203 # link was created with rsync, which already copied the timestamp with |
| 204 # lutimes. |
| 205 if ! touch -r "${new_file}" "${patch_file}"; then |
| 206 exit 18 |
| 207 fi |
| 208 fi |
| 209 } |
| 210 |
| 211 file_size() { |
| 212 local file="${1}" |
| 213 |
| 214 stat -f %z "${file}" |
| 215 } |
| 216 |
| 217 make_patch_file() { |
| 218 local old_file="${1}" |
| 219 local new_file="${2}" |
| 220 local patch_file="${3}" |
| 221 |
| 222 local uncompressed_file="${patch_file}${PLAIN_SUFFIX}" |
| 223 if ! cp "${new_file}" "${uncompressed_file}"; then |
| 224 exit 11 |
| 225 fi |
| 226 local uncompressed_size |
| 227 uncompressed_size="$(file_size "${new_file}")" |
| 228 |
| 229 local keep_file="${uncompressed_file}" |
| 230 local keep_size="${uncompressed_size}" |
| 231 |
| 232 local bz2_file="${patch_file}${BZ2_SUFFIX}" |
| 233 if [[ -e "${bz2_file}" ]]; then |
| 234 err "${bz2_file} already exists" |
| 235 exit 8 |
| 236 fi |
| 237 if ! "${BZIP2}" -9c < "${new_file}" > "${bz2_file}"; then |
| 238 err "couldn't compress ${new_file} to ${bz2_file} with ${BZIP2}" |
| 239 exit 12 |
| 240 fi |
| 241 local bz2_size |
| 242 bz2_size="$(file_size "${bz2_file}")" |
| 243 |
| 244 if [[ "${bz2_size}" -ge "${keep_size}" ]]; then |
| 245 rm -f "${bz2_file}" |
| 246 else |
| 247 rm -f "${keep_file}" |
| 248 keep_file="${bz2_file}" |
| 249 keep_size="${bz2_size}" |
| 250 fi |
| 251 |
| 252 local gz_file="${patch_file}${GZ_SUFFIX}" |
| 253 if [[ -e "${gz_file}" ]]; then |
| 254 err "${gz_file} already exists" |
| 255 exit 8 |
| 256 fi |
| 257 if ! "${GZIP}" -9cn < "${new_file}" > "${gz_file}"; then |
| 258 err "couldn't compress ${new_file} to ${gz_file} with ${GZIP}" |
| 259 exit 13 |
| 260 fi |
| 261 local gz_size |
| 262 gz_size="$(file_size "${gz_file}")" |
| 263 |
| 264 if [[ "${gz_size}" -ge "${keep_size}" ]]; then |
| 265 rm -f "${gz_file}" |
| 266 else |
| 267 rm -f "${keep_file}" |
| 268 keep_file="${gz_file}" |
| 269 keep_size="${gz_size}" |
| 270 fi |
| 271 |
| 272 local xz_flags=("-c") |
| 273 |
| 274 # If the file looks like a Mach-O file, including a universal/fat file, add |
| 275 # the x86 BCJ filter, which results in slightly better compression of x86 |
| 276 # and x86_64 executables. Mach-O files might contain other architectures, |
| 277 # but they aren't currently expected in Chrome. |
| 278 local file_output |
| 279 file_output="$(file "${new_file}" 2> /dev/null || true)" |
| 280 if [[ "${file_output}" =~ Mach-O ]]; then |
| 281 xz_flags+=("--x86") |
| 282 fi |
| 283 |
| 284 # Use an lzma2 encoder. This is equivalent to xz -9 -e, but allows filters |
| 285 # to precede the compressor. |
| 286 xz_flags+=("--lzma2=preset=9e") |
| 287 |
| 288 local xz_file="${patch_file}${XZ_SUFFIX}" |
| 289 if [[ -e "${xz_file}" ]]; then |
| 290 err "${xz_file} already exists" |
| 291 exit 8 |
| 292 fi |
| 293 if ! "${XZ}" "${xz_flags[@]}" < "${new_file}" > "${xz_file}"; then |
| 294 err "couldn't compress ${new_file} to ${xz_file} with ${XZ}" |
| 295 exit 14 |
| 296 fi |
| 297 local xz_size |
| 298 xz_size="$(file_size "${xz_file}")" |
| 299 |
| 300 if [[ "${xz_size}" -ge "${keep_size}" ]]; then |
| 301 rm -f "${xz_file}" |
| 302 else |
| 303 rm -f "${keep_file}" |
| 304 keep_file="${xz_file}" |
| 305 keep_size="${xz_size}" |
| 306 fi |
| 307 |
| 308 if [[ -f "${old_file}" ]] && ! [[ -L "${old_file}" ]] && |
| 309 ! [[ "${new_file}" =~ ${DIRDIFFER_NO_DIFF} ]]; then |
| 310 local gbs_file="${patch_file}${GBS_SUFFIX}" |
| 311 if [[ -e "${gbs_file}" ]]; then |
| 312 err "${gbs_file} already exists" |
| 313 exit 8 |
| 314 fi |
| 315 if ! "${GOOBSDIFF}" "${old_file}" "${new_file}" "${gbs_file}"; then |
| 316 err "couldn't create ${gbs_file} by comparing ${old_file} to ${new_file}" |
| 317 exit 15 |
| 318 fi |
| 319 local gbs_size |
| 320 gbs_size="$(file_size "${gbs_file}")" |
| 321 |
| 322 if [[ "${gbs_size}" -ge "${keep_size}" ]]; then |
| 323 rm -f "${gbs_file}" |
| 324 else |
| 325 rm -f "${keep_file}" |
| 326 keep_file="${gbs_file}" |
| 327 keep_size="${gbs_size}" |
| 328 fi |
| 329 fi |
| 330 |
| 331 copy_mode_and_time "${new_file}" "${keep_file}" |
| 332 } |
| 333 |
| 334 make_patch_symlink() { |
| 335 local new_file="${1}" |
| 336 local patch_file="${2}" |
| 337 |
| 338 # local target |
| 339 # target="$(readlink "${new_file}")" |
| 340 # ln -s "${target}" "${patch_file}" |
| 341 |
| 342 # Use rsync instead of the above, as it's the only way to preserve the |
| 343 # timestamp of a symbolic link using shell tools. |
| 344 if ! rsync -lt "${new_file}" "${patch_file}"; then |
| 345 exit 10 |
| 346 fi |
| 347 |
| 348 copy_mode_and_time "${new_file}" "${patch_file}" |
| 349 } |
| 350 |
| 351 make_patch_dir() { |
| 352 local old_dir="${1}" |
| 353 local new_dir="${2}" |
| 354 local patch_dir="${3}" |
| 355 |
| 356 if ! mkdir "${patch_dir}"; then |
| 357 exit 7 |
| 358 fi |
| 359 |
| 360 local new_file |
| 361 for new_file in "${new_dir}/"*; do |
| 362 local file="${new_file:${#new_dir} + 1}" |
| 363 local old_file="${old_dir}/${file}" |
| 364 local patch_file="${patch_dir}/${file}" |
| 365 |
| 366 if [[ "${new_file}" =~ ${DIRDIFFER_EXCLUDE} ]]; then |
| 367 g_verify_exclude+=("${new_file}") |
| 368 continue |
| 369 fi |
| 370 |
| 371 if [[ -e "${patch_file}" ]]; then |
| 372 err "${patch_file} already exists" |
| 373 exit 8 |
| 374 fi |
| 375 |
| 376 if [[ -L "${new_file}" ]]; then |
| 377 make_patch_symlink "${new_file}" "${patch_file}" |
| 378 elif [[ -d "${new_file}" ]]; then |
| 379 make_patch_dir "${old_file}" "${new_file}" "${patch_file}" |
| 380 elif [[ ! -f "${new_file}" ]]; then |
| 381 err "can't handle irregular file ${new_file}" |
| 382 exit 9 |
| 383 else |
| 384 make_patch_file "${old_file}" "${new_file}" "${patch_file}" |
| 385 fi |
| 386 done |
| 387 |
| 388 copy_mode_and_time "${new_dir}" "${patch_dir}" |
| 389 } |
| 390 |
| 391 verify_patch_dir() { |
| 392 local old_dir="${1}" |
| 393 local new_dir="${2}" |
| 394 local patch_dir="${3}" |
| 395 |
| 396 local verify_temp_dir verify_dir |
| 397 verify_temp_dir="$(mktemp -d -t "${ME}")" |
| 398 g_cleanup+=("${verify_temp_dir}") |
| 399 verify_dir="${verify_temp_dir}/patched" |
| 400 |
| 401 if ! "${DIRPATCHER}" "${old_dir}" "${patch_dir}" "${verify_dir}"; then |
| 402 err "patch application for verification failed" |
| 403 exit 16 |
| 404 fi |
| 405 |
| 406 # rsync will print a line for any file, directory, or symbolic link that |
| 407 # differs or exists only in one directory. As used here, it correctly |
| 408 # considers link targets, file contents, permissions, and timestamps. |
| 409 local rsync_command=(rsync -clprt --delete --out-format=%n \ |
| 410 "${new_dir}/" "${verify_dir}") |
| 411 if [[ ${#g_verify_exclude[@]} -gt 0 ]]; then |
| 412 local exclude |
| 413 for exclude in "${g_verify_exclude[@]}"; do |
| 414 # ${g_verify_exclude[@]} contains paths in ${new_dir}. Strip off |
| 415 # ${new_dir} from the beginning of each, but leave a leading "/" so that |
| 416 # rsync treats them as being at the root of the "transfer." |
| 417 rsync_command+=("--exclude" "${exclude:${#new_dir}}") |
| 418 done |
| 419 fi |
| 420 |
| 421 local rsync_output |
| 422 if ! rsync_output="$("${rsync_command[@]}")"; then |
| 423 err "rsync for verification failed" |
| 424 exit 16 |
| 425 fi |
| 426 |
| 427 rm -rf "${verify_temp_dir}" |
| 428 unset g_cleanup[${#g_cleanup[@]}] |
| 429 |
| 430 if [[ -n "${rsync_output}" ]]; then |
| 431 err "verification failed" |
| 432 exit 16 |
| 433 fi |
| 434 } |
| 435 |
| 436 # shell_safe_path ensures that |path| is safe to pass to tools as a |
| 437 # command-line argument. If the first character in |path| is "-", "./" is |
| 438 # prepended to it. The possibly-modified |path| is output. |
| 439 shell_safe_path() { |
| 440 local path="${1}" |
| 441 if [[ "${path:0:1}" = "-" ]]; then |
| 442 echo "./${path}" |
| 443 else |
| 444 echo "${path}" |
| 445 fi |
| 446 } |
| 447 |
| 448 dirs_contained() { |
| 449 local dir1="${1}/" |
| 450 local dir2="${2}/" |
| 451 |
| 452 if [[ "${dir1:0:${#dir2}}" = "${dir2}" ]] || |
| 453 [[ "${dir2:0:${#dir1}}" = "${dir1}" ]]; then |
| 454 return 0 |
| 455 fi |
| 456 |
| 457 return 1 |
| 458 } |
| 459 |
| 460 usage() { |
| 461 echo "usage: ${ME} old_dir new_dir patch_dir" >& 2 |
| 462 } |
| 463 |
| 464 main() { |
| 465 local old_dir new_dir patch_dir |
| 466 old_dir="$(shell_safe_path "${1}")" |
| 467 new_dir="$(shell_safe_path "${2}")" |
| 468 patch_dir="$(shell_safe_path "${3}")" |
| 469 |
| 470 trap cleanup EXIT HUP INT QUIT TERM |
| 471 |
| 472 if ! [[ -d "${old_dir}" ]] || ! [[ -d "${new_dir}" ]]; then |
| 473 err "old_dir and new_dir must exist and be directories" |
| 474 usage |
| 475 exit 3 |
| 476 fi |
| 477 |
| 478 if [[ -e "${patch_dir}" ]]; then |
| 479 err "patch_dir must not exist" |
| 480 usage |
| 481 exit 4 |
| 482 fi |
| 483 |
| 484 local patch_dir_parent |
| 485 patch_dir_parent="$(dirname "${patch_dir}")" |
| 486 if ! [[ -d "${patch_dir_parent}" ]]; then |
| 487 err "patch_dir parent directory must exist and be a directory" |
| 488 usage |
| 489 exit 5 |
| 490 fi |
| 491 |
| 492 # The weird conditional structure is because the status of the RE comparison |
| 493 # needs to be available in ${?} without conflating it with other conditions |
| 494 # or negating it. Only a status of 2 from the =~ operator indicates an |
| 495 # invalid regular expression. |
| 496 |
| 497 if [[ -n "${DIRDIFFER_EXCLUDE}" ]]; then |
| 498 if [[ "" =~ ${DIRDIFFER_EXCLUDE} ]]; then |
| 499 true |
| 500 elif [[ ${?} -eq 2 ]]; then |
| 501 err "DIRDIFFER_EXCLUDE contains an invalid regular expression" |
| 502 exit 19 |
| 503 fi |
| 504 fi |
| 505 |
| 506 if [[ -n "${DIRDIFFER_NO_DIFF}" ]]; then |
| 507 if [[ "" =~ ${DIRDIFFER_NO_DIFF} ]]; then |
| 508 true |
| 509 elif [[ ${?} -eq 2 ]]; then |
| 510 err "DIRDIFFER_NO_DIFF contains an invalid regular expression" |
| 511 exit 19 |
| 512 fi |
| 513 fi |
| 514 |
| 515 local old_dir_phys new_dir_phys patch_dir_parent_phys patch_dir_phys |
| 516 old_dir_phys="$(cd "${old_dir}" && pwd -P)" |
| 517 new_dir_phys="$(cd "${new_dir}" && pwd -P)" |
| 518 patch_dir_parent_phys="$(cd "${patch_dir_parent}" && pwd -P)" |
| 519 patch_dir_phys="${patch_dir_parent_phys}/$(basename "${patch_dir}")" |
| 520 |
| 521 if dirs_contained "${old_dir_phys}" "${new_dir_phys}" || |
| 522 dirs_contained "${old_dir_phys}" "${patch_dir_phys}" || |
| 523 dirs_contained "${new_dir_phys}" "${patch_dir_phys}"; then |
| 524 err "directories must not contain one another" |
| 525 usage |
| 526 exit 6 |
| 527 fi |
| 528 |
| 529 g_cleanup[${#g_cleanup[@]}]="${patch_dir}" |
| 530 |
| 531 make_patch_dir "${old_dir}" "${new_dir}" "${patch_dir}" |
| 532 |
| 533 verify_patch_dir "${old_dir}" "${new_dir}" "${patch_dir}" |
| 534 |
| 535 unset g_cleanup[${#g_cleanup[@]}] |
| 536 trap - EXIT |
| 537 } |
| 538 |
| 539 if [[ ${#} -ne 3 ]]; then |
| 540 usage |
| 541 exit 2 |
| 542 fi |
| 543 |
| 544 main "${@}" |
| 545 exit ${?} |
OLD | NEW |