Skip to content

Instantly share code, notes, and snippets.

Created March 5, 2012 13:15
Show Gist options
  • Save andris9/1978266 to your computer and use it in GitHub Desktop.
Save andris9/1978266 to your computer and use it in GitHub Desktop.
#!/bin/sh -e
#git-cache-meta -- simple file meta data caching and applying.
#Simpler than etckeeper, metastore, setgitperms, etc.
#modified by n1k
# - save all files metadata not only from other users
# - save numeric uid and gid
# 2012-03-05 - added filetime, andris9
: ${GIT_CACHE_META_FILE=.git_cache_meta}
case $@ in
case $1 in --store) exec > $GIT_CACHE_META_FILE; esac
find $(git ls-files)\
\( -printf 'chown %U %p\n' \) \
\( -printf 'chgrp %G %p\n' \) \
\( -printf 'touch -c -d "%AY-%Am-%Ad %AH:%AM:%AS" %p\n' \) \
\( -printf 'chmod %#m %p\n' \) ;;
--apply) sh -e $GIT_CACHE_META_FILE;;
*) 1>&2 echo "Usage: $0 --store|--stdout|--apply"; exit 1;;


git-cache-meta --store


git-cache-meta --apply



cat > ~/.jgit
accesskey: aws access key
secretkey: aws secret access key

Setup repo

git remote add origin amazon-s3://


jgit push origin master


jgit clone amazon-s3://


jgit fetch
git merge origin/master
Copy link


Will git-store-meta support git hooks to automatically version changes in file metadata on every commit?

Yes. Read the manual for details, bro.

Copy link

danimesq commented Oct 18, 2021


Yes. Read the manual for details, bro.

Interesting. An native version, in the same language as git, makes more sense.

Although I'll personally stick with the sh/bash version for simplicity (and for diversification).

Now it can be initiated in a repo for performing metadata versioning on every commit: 01VCS/git-meta@cf30ef0 (automatically)

Next step, maybe, is having an individual git repository for the metadata, inside .git/meta (will make things more organized and magical)?

Copy link

Arcitec commented Oct 5, 2022

I was inspired by the script but saw some severe issues in it.

  1. %p is just the path without any special quoting of special characters in filenames, such as leading - which would be interpreted as a parameter, or spaces in the filename which would be interpreted as separate parameters. This breaks SEVERELY if the filenames are weird in any way whatsoever.
  2. The %A is the ACCESS TIME of the file. Why the F is it being tracked? I think you meant to use %T which is the last MODIFICATION TIME of the file. Most people these days don't even use access times anymore, and disable them completely or make them relative to some other time. It's definitely NOT what you intended to copy over.
  3. You're writing the time in human format while totally ignoring a little thing known as TIME ZONES. The dates it restores will be totally wrong.
  4. Why on earth are you using chmod at all? GIT PRESERVES FILE MODE BITS ALREADY! At least if core.filemode in the Git config is true, which it is by default. It makes NO SENSE to save modification bits via your script. It's pointless.
  5. You're grabbing %U and %G which are the NUMERIC USER/GROUP IDs. You should be using %u and %g which are the HUMAN-READABLE user/group, which is way more portable to other machines.
  6. Instead of outputting separate chown and chgrp commands, you should output ONE chown command, since it's able to take chown user:group -- FILE as parameters.
  7. Speaking of --... You should be using -- in every command, to tell them that there are no more flags, and that the rest of the command is the arguments. This is necessary to avoid the risk of filenames being interpreted as parameters.
  8. The "metadata restoration script" you generate has no error-checking whatsoever. So it gives a false sense of security, since it runs but might fail to do anything, but it will just happily continue executing all lines even if there are severe errors (such as not having any write-permissions to the directory it's running in).
  9. Pretty much all of the "variants" above suffer the exact same bugs.

Anyway that's just a few of the issues, there are probably more, but I was only really focused on the "file time" aspect which is what I am interested in saving/restoring in my repo...

So, I was investigating how to rapidly produce quoted (safe) filenames, in universal UNIX TIME format.

The following techniques are what I came up with:

  1. TERRIBLE: find . -type f -printf '%p %T@\n': This outputs the %T@ (Unix modification time with milisecond precision, which on most filesystems leads to trailing .000000). The reason it's terrible is because %p is not quoted, and because the trailing zeroes after every timestamp is just stupid and wasteful.
  2. TERRIBLE: find . -type f -printf '%P %T@\n': Almost the same as the previous one, but I wanted to mention that %P outputs the paths without the leading folder (the . argument in this case), which is very useful if you're trying to be portable. But we still have the HUGE issue that filenames are not quoted. And no, we can't simply slap "%P" around it, since quoting DOESN'T WORK THAT WAY.
  3. KINDA GOOD: stat --printf='touch -mcd "@%Y" -- %N\n' **/*: Alright, now we're getting somewhere. This uses stat which supports %N which is the properly quoted/escaped path to the file. And its %Y outputs the Unix timestamp without ridiculous trailing milliseconds. That's a pretty nice evolution. But the globbing **/* is bad because it CAN'T HANDLE INVISIBLE FILES and also grabs every file and FOLDER, rather than just files.
  4. GREAT BUT SLOW: cd "somefolder" && find . -type f -exec stat --printf='touch -mcd "@%Y" -- %N\n' "{}" \; && cd ..: Alright this is getting close to perfection. It enters a folder, uses find to only look at files, executes stat on the file to get the Unix timestamp and quoted filename. So why is it bad? Well, it's super slow due to spawning stat once per file. Even small collections take a long time. But we can improve this...
  5. PERFECTION: cd "somefolder" && find . -type f -print0 | xargs -0 stat --printf='touch -mcd "@%Y" -- %N\n' && cd ..: With this we've finally achieved perfection. We're using find to discover all files rapidly, and since we're using find you can add other conditions like "all files ending in .x" or "skip all files named foobar"), and the -0 argument is used to output them with NULL separator (so that we support complex filenames, including spaces and even special characters such as newlines in the filename). Next, we use xargs with NULL separator to pass ALL of the discovered files SIMULTANEOUSLY into ONE execution of stat. This gives INSTANT RESULTS, which are all perfectly formatted and escaped.

TL;DR: Solution 5 is BY FAR the best way to back up modification times of files.

Oh and if you're wondering how we're setting the date: Type man date to read about supported DATE formats. Specifically, we're using Unix timestamps which are supported by prepending an @ before the numbers, as seen in this DATE manual example:

Convert seconds since the Epoch (1970-01-01 UTC) to a date

$ date --date='@2147483647'

Here's the "core" of what we're going to do:

cd "Parent Folder" && find . -type f -not -name "metadata-cache" -print0 | sort -z | xargs -0 stat --printf='touch -mcd "@%Y" -- %N\n' > "./metadata-cache" && cd ..

This enters the parent folder to ensure that all paths become relative to that parent. This is actually the full path to my parent folder, I just changed it to "Parent Folder" for this demo.

Next, it lists all regular files except any named "metadata-cache", to avoid listing the cache itself.

Then it sorts the NUL-terminated filenames to ensure that they end up in a nice order (this just makes the metadata file easier to diff and compare).

Then it executes "stat" to safely print their UNIX timestamp commands and their quoted paths.

It pipes that output into a file named "metadata-cache" which ends up inside the parent folder.

The end result is a very clean file which can now be executed to apply all modification times, when necessary.

The reason for && between all commands is to make the sequence fail with an error if any part of the command-chain fails. This means that you can check $? after executing this one-liner, to see if any part of the chain failed. So if [[ $? -ne 0 ]]; then echo "OH NO IT FAILED"; fi means there was an error.

But you MUST do that check immediately after this one-liner, because if you run any other commands first, then the value of $? (last command status) will change. Keep that in mind! :)

Also keep in mind that if the chain fails before the cd .. then you will be stuck in the "Parent Folder" location that you cd-ed into. But personally I don't care since my script will exit if any part failed.

But... to make things even better, it's possible to save the result of $PWD (Bash's always-up-to-date pwd equivalent variable), before we cd at all, which will allow us to restore the current working dir at the end no matter where you came from originally. That's what we'll do in the final functions below.

Final, reliable functions, hereby placed in the Public Domain:

#!/usr/bin/env bash

function write_metadata() {
    # Writes a robust metadata file containing sorted, fully-escaped paths, with
    # the full UNIX modification timestamp of each file.
    cd "${WHATEVER_DIR}" && find . -type f -not -name "metadata-cache" -print0 | sort -z | xargs -0 stat --printf='touch -mcd "@%Y" -- %N || exit 1\n' > "./metadata-cache"
    if [[ $? -ne 0 ]]; then echo "Error while writing metadata cache. Aborting..."; exit 1; fi
    cd "${CURRENT_PWD}"
    if [[ $? -ne 0 ]]; then echo "Error while accessing previous working directory. Aborting..."; exit 1; fi

function read_metadata() {
    # Applying the metadata again is a simple matter of going into the target
    # folder if it exists, and then executing the metadata file as a script.
    if [[ ! -f "${WHATEVER_DIR}/metadata-cache" ]]; then return 0; fi
    cd "${WHATEVER_DIR}" && env bash -- "./metadata-cache"
    if [[ $? -ne 0 ]]; then echo "Error while reading metadata cache. Aborting..."; exit 1; fi
    cd "${CURRENT_PWD}"
    if [[ $? -ne 0 ]]; then echo "Error while accessing previous working directory. Aborting..."; exit 1; fi

The "${WHATEVER_DIR}" is just whatever folder you want to scan/restore. Replace that with whatever your own variable is called, where you store the path to the target directory.

If you want to make things harder for yourself, you may even decide to make the functions modular by taking $1 as a dynamic parameter of what directory to scan. But then you'll have to call the function with parameters everywhere in your code, so the choice is yours. :) I personally don't think anyone is gonna need modularity enough to warrant all the risks/drawbacks of taking a random parameters instead, so I went with the hardcoded path variables.

One thing to be aware of is that we're using if [[ ... ]]; then ...; fi instead of the [[ ... ]] && { ... } shorthand that many people like, because the shorthand is treated as a command rather than an if-statement, and Bash functions will automatically return the value of the last executed command, which will be false (if everything was successful and the "check for errors" "failed"), which therefore looks like the function gave an error even though it didn't. So to avoid having a return-value bug, we must explicitly use if-statements in all checks in our functions.

There's another small but important thing to be aware of in read_metadata(): The metadata restoration script is executed in a sub-shell, and the metadata-cache script is written to contain || exit 1 after each statement, so that it exits with an error code if there were any errors in any of the commands. This means that we'll detect if anything went wrong while restoring metadata. But if you prefer letting the metadata file ignore errors, you can remove that part of the lines created by write_metadata(). :) However, you most likely WANT to keep these error checks, because it lets you discover when stat found a file but failed to modify its timestamp (such as lacking permissions to modify the folder). If a file doesn't exist, stat simply returns success, so you don't have to worry about missing files triggering those error handlers. They will only trigger on actual errors with the metadata restoration process!


Copy link

Hi @Bananaman! In what version your implementation is based off?

Copy link

@Bananaman if you used pushd "${WHATEVER_DIR}" >/dev/null and then popd >/dev/null if any error occurred, you would still end up in the "original" directory without the extra output messages from those commands polluting the output.

Copy link

Explorer09 commented Jan 10, 2023


if you used pushd "${WHATEVER_DIR}" >/dev/null and then popd >/dev/null if any error occurred, you would still end up in the "original" directory without the extra output messages from those commands polluting the output.

Just to inform that pushd and popd are bashism and thus unportable. There is one more problem with pushd is that you do need to handle when pushd itself fails (e.g. when directory doesn't exist), and if you are not careful you would end up popping one more directory from the stack than needed (which could become a security vulnerability in certain applications).

If you need to return to the original directory, the safest as well as the simplest approach is to cd in a subshell, and exit the subshell when you are done or an error occurred in that directory. Like this:

func1 () {
    # Process current directory
        set -e
        cd "$new_dir"
        # Process $new_dir
        # When an error occurs, this subshell exits because of the "set -e" command
    ) || return
    # Back to the directory that was current when entering the function

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment