15

I have many files that are ordered by file name in a directory. I wish to copy the final N (say, N=4) files to my home directory. How should I do it?

cp ./<the final 4 files> ~/

  • 1
    On all answers below, you may need to add a "LC_ALL=..." in front of the commands using the ranges, so that their ranges use the right locale for you (locales can change, and the order of characters then can change a lot. Ex, in some locales, [a-z] can contain all letters of the alphabet except for "Z", as the letters are ordered: aAbBcC....zZ. Other variations exist (accentuated letters, and even more exotic orderings). A usual choice is: LC_ALL=C – Olivier Dulac Jan 26 '15 at 13:05

6 Answers6

27

This can be easily done with bash/ksh93/zsh arrays:

a=(*)
cp -- "${a[@]: -4}" ~/

This works for all non-hidden file names even if they contain spaces, tabs, newlines, or other difficult characters (assuming there are at least 4 non-hidden files in the current directory with bash).

How it works

  • a=(*)

    This creates an array a with all the file names. The file names returned by bash are alphabetically sorted. (I assume that this is what you mean by "ordered by file name.")

  • ${a[@]: -4}

    This returns the last four elements of array a (provided the array contains at least 4 elements with bash).

  • cp -- "${a[@]: -4}" ~/

    This copies the last four file names to your home directory.

To copy and rename at the same time

This will copy the last four files only to the home directory and, at the same time, prepend the string a_ to the name of the copied file:

a=(*)
for fname in "${a[@]: -4}"; do cp -- "$fname" ~/a_"$fname"; done

Copy from a different directory and also rename

If we use a=(./some_dir/*) instead of a=(*), then we have the issue of the directory being attached to the filename. One solution is:

a=(./some_dir/*) 
for f in "${a[@]: -4}"; do cp "$f"  ~/a_"${f##*/}"; done

Another solution is to use a subshell and cd to the directory in the subshell:

(cd ./some_dir && a=(*) && for f in "${a[@]: -4}"; do cp -- "$f" ~/a_"$f"; done) 

When the subshell completes, the shell returns us to the original directory.

Making sure that the ordering is consistent

The question asks for files "ordered by file name". That order, Olivier Dulac points out in the comments, will vary from one locale to another. If it is important to have fixed results independent of machine settings, then it is best to specify the locale explicitly when the array a is defined. For example:

LC_ALL=C a=(*)

You can find out which locale you are currently in by running the locale command.

John1024
  • 74,655
9

If you are using zsh you can enclose in parenthesis () a list of so called glob qualifiers which select desired files. In your case, that would be

cp *(On[1,4]) ~/

Here On sorts file names alphabetically in reverse order and [1,4] takes only first 4 of them.

You can make this more robust by selecting only plain files (excluding directories, pipes etc.) with ., and also by appending -- to cp command in order to treat files which names begin with - properly, so:

cp -- *(.On[1,4]) ~

Add the D qualifier if you also want to consider hidden files (dot-files):

cp -- *(D.On[1,4]) ~
jimmij
  • 47,140
  • 2
  • 2
    @StéphaneChazelas yes, lets clarify for future readers that sorting alphabetically in normal direction (on) is default behaviour, so no need to specify this, and - in front of the numbers counts filenames from the bottom. – jimmij Jan 26 '15 at 13:23
7

Here is a solution using extremely simple bash commands.

find . -maxdepth 1 -type f | sort | tail -n 4 | while read -r file; do cp "$file" ~/; done

Explanation:

find . -maxdepth 1 -type f

Finds all files in current directory.

sort

Sorts alphabetically.

tail -n 4

Only show last 4 lines.

while read -r file; do cp "$file" ~/; done

Loops over each line performing the copy command.

Mikel
  • 57,299
  • 15
  • 134
  • 153
gswong
  • 79
6

So long as you find the shell sort agreeable, you can just do:

set -- /path/to/source/dir/*
[ "$#" -le 4 ] || shift "$(($#-4))"
cp "$@" /path/to/target/dir

This is very similar to the bash-specific array solution offered, but should be portable to any POSIX-compatible shell. Some notes about both methods:

  1. It is important that you lead your cp arguments w/ cp -- or you get one of either . a dot or / at the head of each name. If you fail to do this you risk a leading - dash in cp's first argument which can be interpreted as an option and cause the operation to fail or to otherwise render unintended results.

    • Even if working with the current directory as the source directory this is easily done like... set -- ./* or array=(./*).
  2. It is important when using both methods to ensure you have at least as many items in your arg array as you attempt to remove - I do that here with a math expansion. The shift only happens if there are at least 4 items in the arg array - and then only shifts away those first args that make a surplus of 4..

    • So in a set 1 2 3 4 5 case it will shift the 1 away, but in a set 1 2 3 case, it will shift nothing.
    • For example: a=(1 2 3); echo "${a[@]: -4}" prints a blank line.

If you are copying from one directory to another, you can use pax:

set -- /path/to/source/dir/*
[ "$#" -le 4 ] || shift "$(($#-4))"
pax -rws '|.*/|new_prefix|' "$@" /path/to/target/dir

...which would apply a sed-style substitution to all filenames as it copies them.

mikeserv
  • 58,310
4

If there are only files and their names do not contain whitespace or newline (and you've not modified $IFS) or glob characters (or some non-printable characters with some implementations of ls), and don't start with ., then you can do this:

cp -- $(ls | tail -n 4) ~/
Hauke Laging
  • 90,279
  • This is called a command substitution and it's really handy. http://tldp.org/LDP/abs/html/commandsub.html#CSPARENS – iyrin Jan 26 '15 at 05:45
  • Thanks! It works. Is there away to quickly prepend a prefix a_ to ALL four files? I tried echo "a_$(ls | tail -n 4)", but it only prepends the first file. – Sibbs Gambling Jan 26 '15 at 06:29
  • @SibbsGambling My approach is not suitable for that but John1024's answer can easily be adapted to that. – Hauke Laging Jan 26 '15 at 06:32
  • 5
    In general parsing ls output is considered bad form because it typically fails horribly on file names that contain not only spaces, but also tabs, newlines, or other valid but "difficult" characters. – HBruijn Jan 26 '15 at 06:56
  • "If there are only files and their names do not contain whitespace..." makes it a bad solution and surely inferior to others... – glglgl Jan 26 '15 at 08:28
  • @glglgl You are mixing up bad with inferior. It is a bad solution for the general case but in 99% of the real-life cases this is known not to be a problem at all and probably much easier to remember than "${a[@]: -4}" (which not even the author of that answer knew). – Hauke Laging Jan 26 '15 at 08:49
  • 2
    @HaukeLaging Even if it is currently not a problem (because I don't have "complicated" firenames in my directory), I might have in 2 years, and suddenly things fall on my feet... – glglgl Jan 26 '15 at 11:05
1

This is a simple bash solution without any loop code. Copy the last 4 files from the current dir to destination:

$ find . -maxdepth 1 -type f |tail -n -4|xargs cp -t "$destdir"
  • 1
    How do you ensure that find gives you the files in the wanted order? How do you ensure that files containing whitespace characters (including newlines) in their names are handled correctly? – Kusalananda Jan 22 '19 at 17:20