2

I have 11 files with spaces in its name in a folder and I want to copy the newest 10. I used ls -t | head -n 10 to get only the newest 10 files. When I want to use the expression in a cp statement I get an error that the files could not be found because of the space in the name.

E.g.: cp: cannot stat ‘10’: No such file or directory for a file named 10 abc.pdf

The cp statement: cp $(ls -t | head -n 10) ~/...

How do I get this working?

Marius K.
  • 165

2 Answers2

2

If you're using Bash and you want a 100% safe method (which, I guess, you want, now that you've learned the hard way that you must handle filenames seriously), here you go:

shopt -s nullglob
while IFS= read -r file; do
    file=${file#* }
    eval "file=$file"
    cp "$file" Destination
done < <(
    for f in *; do
        printf '%s %q\n' "$(date -r "$f" +'%s')" "$f"
    done | sort -rn | head -n10
)

Let's have a look at its several parts:

for f in *; do
    printf '%s %q\n' "$(date -r "$f" +'%s')" "$f"
done

This will print to stdout terms of the form

Timestamp Filename

where timestamp is the modification date (obtained with -r's option to date) in seconds since Epoch and Filename is a quoted version of the filename that can be reused as shell input (see help printf and the %q format specifier). These lines are then sorted (numerically, in reverse order) with sort, and only the first 10 ones are kept.

This is then fed to the while loop. The timestamps are removed with the file=${file# *} assignment (this gets rid of everything up to and including the first space), then the apparently dangerous line eval "file=$file" gets rid of the escape characters introduced by printf %q, and finally we can safely copy the file.

Probably not the best approach or implementation, but 100% guaranteed safe regarding any possible filenames, and gets the job done. Though, this will treat regular files, directories, etc. all the same. If you want to restrict to regular files, add [[ -f $f ]] || continue just after the for f in *; do line. Also, it will not consider hidden files. If you want hidden files (but not . nor .., of course), add shopt -s dotglob.


Another 100% Bash solution is to use Bash directly to sort the files. Here's an approach using a quicksort:

quicksort() {
    # quicksorts the filenames passed as positional parameters
    # wrt modification time, newest first
    # return array is quicksort_ret
    if (($#==0)); then
        quicksort_ret=()
        return
    fi
    local pivot=$1 oldest=() newest=() f
    shift
    for f; do
        if [[ $f -nt $pivot ]]; then
            newest+=( "$f" )
        else
            oldest+=( "$f" )
        fi
    done
    quicksort "${oldest[@]}"
    oldest=( "${quicksort_ret[@]}" )
    quicksort "${newest[@]}"
    quicksort_ret+=( "$pivot" "${oldest[@]}" )
}

Then, sort them out, keep the first 10 ones, and copy them to your destination:

$ shopt -s nullglob
$ quicksort *
$ cp -- "${quicksort_ret[@]:0:10}" Destination

Same as the previous method, this will treat regular files, directories, etc. all the same and skip hidden files.


For another approach: if your ls has the i and q arguments, you can use something along these lines:

ls -tiq | head -n10 | cut -d ' ' -f1 | xargs -I X find -inum X -exec cp {} Destination \; -quit

This will show the file's inode, and find can perform commands on files refered to by their inode.

Same thing, this will also treat directories, etc., not just regular files. I don't really like this one, as it relies too much on ls's output format…

  • It did work! But the script copied itself to the destination, too (overall 11 files). Also I added shopt -u nullglob to unset the variable again. – Marius K. Jun 23 '14 at 17:30
  • @MariusK. Don't put the script in the same directory. Put it, e.g., in your bin/ directory and make it accept the directory as a parameter. – gniourf_gniourf Jun 23 '14 at 17:32
0

For any group of files in a directory this will always leave out the oldest:

less_oldest() {
    while [ -e "$1" ] ; do
        for f do [ "$f" -ot "$1" ] && break
        done && { set -- "$@" "$1"
            shift ; continue
        } ; shift ; break
    done
    printf '///%s///\n' "$@" |
        sed "s|'"'|&"&"&|g;'"s|///|'|g"
}

It's fully portable and it prints a safely shell-quoted array of filenames regardless of what characters they might return. In your case, because you only need to dro pone, you only need to run it once like:

{   printf 'cp -t ~"/..."'
    less_oldest "${dir_of_11_files}/"*
} | sh
mikeserv
  • 58,310