Delete all but the most recent X files in bash

Viewed 141484

Is there a simple way, in a pretty standard UNIX environment with bash, to run a command to delete all but the most recent X files from a directory?

To give a bit more of a concrete example, imagine some cron job writing out a file (say, a log file or a tar-ed up backup) to a directory every hour. I'd like a way to have another cron job running which would remove the oldest files in that directory until there are less than, say, 5.

And just to be clear, there's only one file present, it should never be deleted.

18 Answers

Remove all but 5 (or whatever number) of the most recent files in a directory.

rm `ls -t | awk 'NR>5'`
(ls -t|head -n 5;ls)|sort|uniq -u|xargs rm

This version supports names with spaces:

(ls -t|head -n 5;ls)|sort|uniq -u|sed -e 's,.*,"&",g'|xargs rm

Simpler variant of thelsdj's answer:

ls -tr | head -n -5 | xargs --no-run-if-empty rm 

ls -tr displays all the files, oldest first (-t newest first, -r reverse).

head -n -5 displays all but the 5 last lines (ie the 5 newest files).

xargs rm calls rm for each selected file.

find . -maxdepth 1 -type f -printf '%T@ %p\0' | sort -r -z -n | awk 'BEGIN { RS="\0"; ORS="\0"; FS="" } NR > 5 { sub("^[0-9]*(.[0-9]*)? ", ""); print }' | xargs -0 rm -f

Requires GNU find for -printf, and GNU sort for -z, and GNU awk for "\0", and GNU xargs for -0, but handles files with embedded newlines or spaces.

All these answers fail when there are directories in the current directory. Here's something that works:

find . -maxdepth 1 -type f | xargs -x ls -t | awk 'NR>5' | xargs -L1 rm

This:

  1. works when there are directories in the current directory

  2. tries to remove each file even if the previous one couldn't be removed (due to permissions, etc.)

  3. fails safe when the number of files in the current directory is excessive and xargs would normally screw you over (the -x)

  4. doesn't cater for spaces in filenames (perhaps you're using the wrong OS?)

Ignoring newlines is ignoring security and good coding. wnoise had the only good answer. Here is a variation on his that puts the filenames in an array $x

while IFS= read -rd ''; do 
    x+=("${REPLY#* }"); 
done < <(find . -maxdepth 1 -printf '%T@ %p\0' | sort -r -z -n )

If the filenames don't have spaces, this will work:

ls -C1 -t| awk 'NR>5'|xargs rm

If the filenames do have spaces, something like

ls -C1 -t | awk 'NR>5' | sed -e "s/^/rm '/" -e "s/$/'/" | sh

Basic logic:

  • get a listing of the files in time order, one column
  • get all but the first 5 (n=5 for this example)
  • first version: send those to rm
  • second version: gen a script that will remove them properly

For Linux (GNU tools), an efficient & robust way to keep the n newest files in the current directory while removing the rest:

n=5

find . -maxdepth 1 -type f -printf '%T@ %p\0' |
sort -z -nrt ' ' -k1,1 |
sed -z -e "1,${n}d" -e 's/[^ ]* //' |
xargs -0r rm -f

For BSD, find doesn't have the -printf predicate, stat can't output NULL bytes, and sed + awk can't handle NULL-delimited records.

Here's a solution that doesn't support newlines in paths but that safeguards against them by filtering them out:

#!/bin/bash
n=5

find . -maxdepth 1 -type f ! -path $'*\n*' -exec stat -f '%.9Fm %N' {} + |
sort -nrt ' ' -k1,1 |
awk -v n="$n" -F'^[^ ]* ' 'NR > n {printf "%s%c", $2, 0}' |
xargs -0 rm -f

note: I'm using bash because of the $'\n' notation. For sh you can define a variable containing a literal newline and use it instead.


Solution for UNIX & Linux (inspired from AIX/HP-UX/SunOS/BSD/Linux ls -b):

Some platforms don't provide find -printf, nor stat, nor support NULL-delimited records with stat/sort/awk/sed/xargs. That's why using perl is probably the most portable way to tackle the problem, because it is available by default in almost every OS.

I could have written the whole thing in perl but I didn't. I only use it for substituting stat and for encoding-decoding-escaping the filenames. The core logic is the same than the other solutions above and is implemented with POSIX tools.

BTW, perl's default stat has a resolution of a second, but starting from perl-5.8.9 you can get sub-second resolution with the the stat function of the module Time::HiRes (when the OS and the filesystem support it). That's what I'm using here; if your perl doesn't provide it then you can remove the ‑MTime::HiRes=stat from the command line.

n=5

find . '(' -name '.' -o -prune ')' -type f \
-exec perl -MTime::HiRes=stat -l -e '
    foreach (@ARGV) {
        @st = stat($_);
        if ( @st > 0 ) {
            s/([\\\n])/sprintf( "\\%03o", ord($1) )/ge;
            print sprintf( "%.9f %s", $st[9], $_ );
        }
        else { print STDERR "stat: $_: $!"; }
    }
' {} + |

sort -nrt ' ' -k1,1 |

sed -e "1,${n}d" -e 's/[^ ]* //' |

perl -l -ne '
    s/\\([0-7]{3})/chr(oct($1))/ge;
    s/(["\n])/"\\$1"/g;
    print "\"$_\""; 
' |

xargs -E '' sh -c '[ "$#" -gt 0 ] && rm -f "$@"' sh

Explanations:

  • For each file found, the first perl gets the modification time and outputs it along the encoded filename (each newline and backslash characters are replaced with the literals \n and \\ respectively).

  • Now each time filename is guaranteed to be single-line, so POSIX sort and sed can safely work with this stream.

  • The second perl decodes the filenames and escapes them for POSIX xargs.

  • Lastly, xargs calls rm for deleting the files. The sh command is a trick that prevents xargs from running rm when there's no files to delete.

Adaptation of @mklement0's excellent answer with some parameters and without needing to navigate to the folder containing the files to be deleted...

TARGET_FOLDER="/my/folder/path"
FILES_KEEP=5
ls -tp "$TARGET_FOLDER"**/* | grep -v '/$' | tail -n +$((FILES_KEEP+1)) | xargs -d '\n' -r rm --

[Ref(s).: https://stackoverflow.com/a/3572628/3223785 ]

Thanks!

I needed an elegant solution for the busybox (router), all xargs or array solutions were useless to me - no such command available there. find and mtime is not the proper answer as we are talking about 10 items and not necessarily 10 days. Espo's answer was the shortest and cleanest and likely the most unversal one.

Error with spaces and when no files are to be deleted are both simply solved the standard way:

rm "$(ls -td *.tar | awk 'NR>7')" 2>&-

Bit more educational version: We can do it all if we use awk differently. Normally, I use this method to pass (return) variables from the awk to the sh. As we read all the time that can not be done, I beg to differ: here is the method.

Example for .tar files with no problem regarding the spaces in the filename. To test, replace "rm" with the "ls".

eval $(ls -td *.tar | awk 'NR>7 { print "rm \"" $0 "\""}')

Explanation:

ls -td *.tar lists all .tar files sorted by the time. To apply to all the files in the current folder, remove the "d *.tar" part

awk 'NR>7... skips the first 7 lines

print "rm \"" $0 "\"" constructs a line: rm "file name"

eval executes it

Since we are using rm, I would not use the above command in a script! Wiser usage is:

(cd /FolderToDeleteWithin && eval $(ls -td *.tar | awk 'NR>7 { print "rm \"" $0 "\""}'))

In the case of using ls -t command will not do any harm on such silly examples as: touch 'foo " bar' and touch 'hello * world'. Not that we ever create files with such names in real life!

Sidenote. If we wanted to pass a variable to the sh this way, we would simply modify the print (simple form, no spaces tolerated):

print "VarName="$1

to set the variable VarName to the value of $1. Multiple variables can be created in one go. This VarName becomes a normal sh variable and can be normally used in a script or shell afterwards. So, to create variables with awk and give them back to the shell:

eval $(ls -td *.tar | awk 'NR>7 { print "VarName=\""$1"\""  }'); echo "$VarName"
Related