├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── LICENSE ├── README.md └── git-recover /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [ethomson] 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - run: shellcheck git-recover 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) Edward Thomson. All rights reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | git-recover: recover deleted files in your repository 2 | =========== 3 | 4 | [![CI](https://github.com/ethomson/git-recover/workflows/CI/badge.svg)](https://github.com/ethomson/git-recover/actions?query=workflow%3ACI) 5 | 6 | `git-recover` allows you to recover some files that you've accidentally deleted 7 | from your working directory. It helps you find files that exist in the 8 | repository's object database - because you ran `git add` - but were never 9 | committed. 10 | 11 | Getting Started 12 | --------------- 13 | 14 | The simplest way to use `git-recover` is in interactive mode - simply run 15 | `git-recover -i` and it will show you all the files that you can recover 16 | and prompt you to act. 17 | 18 | Using git-recover 19 | ----------------- 20 | 21 | Running `git-recover` without any arguments will list all the files (git 22 | "blobs") that were recently orphaned, by their ID. (Their filename is not 23 | known.) 24 | 25 | You can examine these blobs by running `git show `. If you 26 | find one that you want to recover, you can provide the ID as the argument 27 | to `git-recover`. You can specify the `--filename` option to write the 28 | file out and apply any filters that are set up in the repository. For 29 | example: 30 | 31 | git-recover 38762cf7f55934b34d179ae6a4c80cadccbb7f0a --filename shattered.pdf 32 | 33 | You can also specify multiple files to recover, each with an optional output 34 | filename: 35 | 36 | git-recover 38762c --filename one.txt cafebae --filename bae.txt 37 | 38 | If you want to recover _all_ the orphaned blobs in your repository, run 39 | `git-recover --all`. This will write all the orphaned files to the current 40 | working directory, so it's best to run this inside a temporary directory 41 | beneath your working directory. For example: 42 | 43 | mkdir _tmp && cd _tmp && git-recover --all 44 | 45 | By default, `git-recover` limits itself to recently created orphaned blobs. 46 | If you want to see _all_ orphaned files that have been created in your 47 | repository (but haven't yet been garbage collected), you can run: 48 | 49 | git-recover --full 50 | 51 | Options 52 | ------- 53 | git-recover [-a] [-i] [--full] [ [-f ] ...] 54 | 55 | `-a`, `--all` 56 | Write all orphaned blobs to the current working directory. Each file will 57 | be named using its 40 character object ID. 58 | 59 | `-i`, `--interactive` 60 | Display information about each orphaned blob and prompt to recover it. 61 | 62 | `--full` 63 | List or recover all orphaned blobs, even those that are in packfiles. By 64 | default, `git-recover` will only look at loose object files, which limits 65 | it to the most recently created files. Examining packfiles may be slow, 66 | especially in large repositories. 67 | 68 | `` 69 | The object ID (or its abbreviation) to recover. The file will be written to 70 | the current working directory and named using its 40 character object ID, 71 | unless the `-f` option is specified. 72 | 73 | `-f `, `--filename ` 74 | When specified after an object ID, the file written will use this filename. 75 | In addition, any filters (for example: CRLF conversion or Git-LFS) will be 76 | run according to the `gitattributes` configuration. 77 | 78 | Getting Help and Contributing 79 | ----------------------------- 80 | To report bugs, get assistance or provide a bug fix to this program, 81 | check it out on [GitHub](https://github.com/ethomson/git-recover/). 82 | 83 | Copyright (c) [Edward Thomson](http://edwardthomson.com/). All rights reserved. 84 | 85 | git-recover is open source software and is available under the MIT license. 86 | Please see the included `LICENSE` file for more information. 87 | -------------------------------------------------------------------------------- /git-recover: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # git-recover: recover deleted files in your repo 4 | # Copyright (c) Edward Thomson. All rights reserved. 5 | # Available under the MIT license; see the included LICENSE file. 6 | # 7 | # vim: set expandtab:ts=4:sw=4:number 8 | 9 | set -e 10 | 11 | IFS=$'\n' 12 | 13 | PROGNAME=$(echo "$0" | sed -e 's/.*\///') 14 | GIT_DIR=$(git rev-parse --git-dir) 15 | 16 | DO_RECOVER=0 17 | DO_FULL=0 18 | DO_INTERACTIVE=0 19 | BLOBS=() 20 | FILENAMES=() 21 | 22 | function print_usage { 23 | echo "usage: $PROGNAME [-a] [-i] [--full] [ [-f ] ...]" 24 | } 25 | 26 | function show_help { 27 | print_usage 28 | 29 | echo "" 30 | echo "Recover deleted files in your git repository" 31 | echo "" 32 | echo "Options:" 33 | echo " -a, --all Write all orphaned blobs to the current working" 34 | echo " directory. Each file will be named using its 40" 35 | echo " character object ID." 36 | echo " -i, --interactive Display information about each orphaned blob and then" 37 | echo " prompt to recover it." 38 | echo " --full List or recover all orphaned blobs, even those that" 39 | echo " are in packfiles. By default, git-recover will only" 40 | echo " look at loose object files, which limits it to the" 41 | echo " most recently created files. Examining packfiles may" 42 | echo " be slow, especially in large repositories." 43 | echo " The object ID (or its abbreviation) to recover. The" 44 | echo " file will be written to the current working directory" 45 | echo " and named using its 40 character object ID, unless the" 46 | echo " -f option is specified." 47 | echo " -f, --filename name When specified after an object ID, the file written" 48 | echo " will use this filename. In addition, any filters" 49 | echo " (for example: CRLF conversion or Git-LFS) will be run" 50 | echo " according to the gitattributes configuration." 51 | 52 | exit 0 53 | } 54 | 55 | function die_usage { 56 | print_usage >&2 57 | exit 1 58 | } 59 | 60 | while [[ $# -gt 0 ]]; do 61 | case "$1" in 62 | -a|--all) 63 | DO_RECOVER=1 64 | ;; 65 | -i|--interactive) 66 | DO_INTERACTIVE=1 67 | ;; 68 | --full) 69 | DO_FULL=1 70 | ;; 71 | -h|--help) 72 | show_help 73 | ;; 74 | *) 75 | if [ "${1:0:1}" == "-" ]; then 76 | echo "$PROGNAME: unknown argument: $1" >&2 77 | die_usage 78 | fi 79 | BLOBS+=("$1") 80 | 81 | shift 82 | if [ "$1" == "-f" ] || [ "$1" == "--filename" ]; then 83 | shift 84 | if [ $# == 0 ]; then 85 | die_usage 86 | fi 87 | FILENAMES+=("$1") 88 | shift 89 | else 90 | FILENAMES+=("") 91 | fi 92 | continue 93 | ;; 94 | esac 95 | shift 96 | done 97 | 98 | if [ ${#BLOBS[@]} != 0 ] && [ $DO_RECOVER == 1 ]; then 99 | die_usage 100 | elif [ ${#BLOBS[@]} != 0 ]; then 101 | DO_RECOVER=1 102 | fi 103 | 104 | case "$OSTYPE" in 105 | darwin*|freebsd*) IS_BSD=1 ;; 106 | *) IS_BSD=0 ;; 107 | esac 108 | 109 | function expand_given_blobs() { 110 | for i in "${!BLOBS[@]}"; do 111 | ID=$(git rev-parse --verify "${BLOBS[$i]}" 2>/dev/null || true) 112 | 113 | if [ -z "$ID" ]; then 114 | echo "$PROGNAME: ${BLOBS[$i]} is not a valid object." 1>&2 115 | exit 1 116 | fi 117 | 118 | TYPE=$(git cat-file -t "${ID}" 2>/dev/null || true) 119 | 120 | if [ "$TYPE" != "blob" ]; then 121 | echo "$PROGNAME: ${BLOBS[$i]} is not a blob." 1>&2 122 | exit 123 | fi 124 | 125 | BLOBS[i]=$ID 126 | done 127 | } 128 | 129 | # find all the unreachable blobs 130 | function find_unreachable() { 131 | FULLNESS="--no-full" 132 | 133 | if [ $DO_FULL == 1 ]; then FULLNESS="--full"; fi 134 | 135 | # shellcheck disable=SC2207 136 | BLOBS=($(git fsck --unreachable --no-reflogs "${FULLNESS}" \ 137 | --no-progress 2>/dev/null | \ 138 | sed -E -ne 's/^unreachable blob |dangling blob //p')) 139 | } 140 | 141 | function read_one_file { 142 | BLOB=$1 143 | FILTER_NAME=$2 144 | ARGS=() 145 | 146 | if [ -z "$FILTER_NAME" ]; then 147 | ARGS+=("blob") 148 | else 149 | ARGS+=("--filters" "--path=$FILTER_NAME") 150 | fi 151 | 152 | git cat-file "${ARGS[@]}" "$BLOB" 153 | } 154 | 155 | function write_one_file { 156 | BLOB=$1 157 | FILTER_NAME=$2 158 | OUTPUT_NAME=$3 159 | 160 | ABBREV=$(git rev-parse --short "${BLOB}") 161 | 162 | echo -n "Writing $ABBREV: " 163 | read_one_file "$BLOB" "$FILTER_NAME" > "$OUTPUT_NAME" 164 | echo "$OUTPUT_NAME." 165 | } 166 | 167 | function unique_filename { 168 | if [ ! -f "${BLOB}" ]; then 169 | echo "$BLOB" 170 | else 171 | cnt=1 172 | while true 173 | do 174 | fn="${BLOB}~${cnt}" 175 | if [ ! -f "${fn}" ]; then 176 | echo "${fn}" 177 | break 178 | fi 179 | cnt=$((cnt+1)) 180 | done 181 | fi 182 | } 183 | 184 | function write_recoverable { 185 | for i in "${!BLOBS[@]}"; do 186 | BLOB=${BLOBS[$i]} 187 | FILTER_NAME=${FILENAMES[$i]} 188 | OUTPUT_NAME=${FILENAMES[$i]:-$(unique_filename)} 189 | 190 | write_one_file "$BLOB" "$FILTER_NAME" "$OUTPUT_NAME" 191 | done 192 | } 193 | 194 | function file_time { 195 | if [ $IS_BSD == 1 ]; then 196 | stat -f %c "$1" 197 | else 198 | stat -c %Y "$1" 199 | fi 200 | } 201 | 202 | function timestamp_to_s { 203 | if [ $IS_BSD == 1 ]; then 204 | date -r "$1" 205 | else 206 | date -d @"$1" 207 | fi 208 | } 209 | 210 | function sort_by_timestamp { 211 | # sort blobs in loose objects by their timestamp (packed blobs last) 212 | # shellcheck disable=SC2207 213 | BLOB_AND_TIMESTAMPS=($(for BLOB in "${BLOBS[@]}"; do 214 | LOOSE="${BLOB::2}/${BLOB:2}" 215 | TIME=$(file_time "$GIT_DIR/objects/$LOOSE" 2>/dev/null || true) 216 | echo "$BLOB $TIME" 217 | done | sort -k2 -r)) 218 | } 219 | 220 | function print_recoverable { 221 | echo "Recoverable orphaned git blobs:" 222 | echo "" 223 | 224 | sort_by_timestamp 225 | for BLOB_AND_TIMESTAMP in "${BLOB_AND_TIMESTAMPS[@]}"; do 226 | BLOB=${BLOB_AND_TIMESTAMP::40} 227 | TIME=${BLOB_AND_TIMESTAMP:41} 228 | DATE=$([ -n "$TIME" ] && timestamp_to_s "$TIME" || echo "(Unknown)") 229 | 230 | echo "$BLOB $DATE" 231 | done 232 | } 233 | 234 | function prompt_for_filename { 235 | while true 236 | do 237 | echo -n "Filename (return to skip): " 238 | read -r FILENAME 239 | 240 | if [ -f "$FILENAME" ]; then 241 | echo -n "File exists, overwrite? [y,N]: " 242 | read -r overwrite 243 | 244 | case "$overwrite" in 245 | [yY]*) 246 | return 0 247 | ;; 248 | esac 249 | 250 | echo 251 | else 252 | return 0 253 | fi 254 | done 255 | } 256 | 257 | function view_file { 258 | read_one_file "${BLOB}" | ${PAGER:-less} 259 | } 260 | 261 | function show_summary { 262 | FILETYPE=$(read_one_file "${BLOB}" | file -b -) 263 | IS_TEXT=$(echo "${FILETYPE}" | grep -c ' text$' 2>/dev/null || true) 264 | 265 | if [ "$IS_TEXT" == "1" ]; then 266 | read_one_file "${BLOB}" 267 | else 268 | read_one_file "${BLOB}" | hexdump -C 269 | fi 270 | } 271 | 272 | function interactive { 273 | echo "Recoverable orphaned git blobs:" 274 | 275 | sort_by_timestamp 276 | for BLOB_AND_TIMESTAMP in "${BLOB_AND_TIMESTAMPS[@]}"; do 277 | echo 278 | 279 | BLOB=${BLOB_AND_TIMESTAMP::40} 280 | TIME=${BLOB_AND_TIMESTAMP:41} 281 | DATE=$([ -n "$TIME" ] && timestamp_to_s "$TIME" || echo "(Unknown)") 282 | 283 | echo "$BLOB ($DATE)" 284 | show_summary "${BLOB}" | head -4 | sed -e 's/^/> /' 285 | echo 286 | 287 | while true 288 | do 289 | echo -n "Recover this file? [y,n,v,f,q,?]: " 290 | read -r ans || return 1 291 | 292 | case "$ans" in 293 | [yY]*) 294 | write_one_file "${BLOB}" "" "$(unique_filename)" 295 | break 296 | ;; 297 | [nN]*) 298 | break 299 | ;; 300 | [vV]*) 301 | view_file "${BLOB}" 302 | echo 303 | ;; 304 | [fF]*) 305 | prompt_for_filename 306 | 307 | if [ "$FILENAME" == "" ]; then 308 | break 309 | fi 310 | 311 | write_one_file "${BLOB}" "${FILENAME}" "${FILENAME}" 312 | break 313 | ;; 314 | \?*) 315 | echo 316 | echo "Do you want to recover this file?" 317 | echo " y: yes, write the file to ${BLOB}" 318 | echo " n: no, skip this file and see the next orphaned file" 319 | echo " v: view the file" 320 | echo " f: prompt for a filename to use for recovery" 321 | echo " q: quit" 322 | echo 323 | ;; 324 | [qQ]*) 325 | return 0 326 | ;; 327 | esac 328 | done 329 | done 330 | } 331 | 332 | 333 | if [ ${#BLOBS[@]} != 0 ]; then 334 | expand_given_blobs 335 | else 336 | find_unreachable 337 | fi 338 | 339 | if [ ${#BLOBS[@]} == 0 ]; then 340 | echo "$PROGNAME: no recoverable orphaned blobs." 341 | exit 342 | fi 343 | 344 | if [ $DO_INTERACTIVE == 1 ]; then 345 | interactive 346 | elif [ $DO_RECOVER == 1 ]; then 347 | write_recoverable 348 | else 349 | print_recoverable 350 | fi 351 | 352 | --------------------------------------------------------------------------------