├── .github └── workflows │ └── shellcheck.yml ├── LICENSE ├── README.md └── macbac.sh /.github/workflows/shellcheck.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | 7 | shellcheck: 8 | name: Shell 9 | runs-on: ubuntu-latest 10 | steps: 11 | - 12 | uses: actions/checkout@v2 13 | - 14 | name: shellcheck 15 | uses: reviewdog/action-shellcheck@v1 16 | with: 17 | github_token: ${{ secrets.github_token }} 18 | reporter: github-pr-review 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Niels Hofmans 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 all 13 | 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 THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # macbac 3 | 4 | Lists, controls and schedules efficient APFS snapshots for your convenience. 5 | 6 | ```shell 7 | # let's take space efficient APFS snapshots every hour 8 | # knowing they get cleaned up if necessary 9 | % macbac schedule hourly 10 | Installing daemon config to /Users/user/Library/LaunchAgents/com.hazcod.macbac.plist 11 | Loading config to enable schedule... 12 | Scheduled hourly snapshots! 13 | 14 | # show our current snapshots 15 | % macbac list 16 | /Volumes/SSD 17 | > 2020/12/30 10:23 18 | > 2020/12/30 11:04 19 | > 2020/12/30 11:05 20 | 21 | # take a manual snapshot 22 | % macbac snapshot 23 | Assuming / is the volume we would like to snapshot. 24 | Created local snapshot with date: 2020-12-30-111728 25 | Snapshotted volume / 26 | 27 | # let's remove snapshots but ensure we keep the 3 most recent 28 | % macbac prune 3 29 | Pruning 1 of 4 snapshots for / 30 | Pruning snapshot 2021-02-04-155845 (1/1) 31 | ``` 32 | 33 | ## How does it work? 34 | 35 | It's a convenient wrapper around `tmutil` and basically replicates the local snapshot feature Time Machine would perform when you are backing up to a remote disk. 36 | 37 | ## Installation 38 | 39 | Installation can be done straight from [my Homebrew tap](https://github.com/hazcod/homebrew-hazcod) via `brew install hazcod/homebrew-hazcod/macbac` or just copy `macbac.sh` to your filesystem. 40 | 41 | ## Setup 42 | 43 | 1. First enable Time Machine by running `macbac enable`. 44 | You may need to add `Terminal.app` to Full Disk Access in Security & Privacy. 45 | 46 | 2. Now schedule your snapshots e.g. hourly via `macbac schedule hourly`. 47 | 48 | ## Usage 49 | 50 | `Usage: macbac <...>` 51 | 52 | To view Time Machine status: `macbac status` 53 | 54 | To take a snapshot: `macbac snapshot` 55 | 56 | To take a snapshot and keep 3: `macbac snapshot 3` 57 | 58 | To prune but keep 5: `macbac prune 5` 59 | 60 | To schedule hourly snapshots, keeping 24: `macbac schedule hourly` 61 | 62 | To schedule hourly snapshots, keeping 3: `macbac schedule hourly 3` 63 | 64 | To schedule daily snapshots, keeping 7: `macbac schedule daily` 65 | 66 | To view when the next snapshot is going to be taken: `macbac next` 67 | -------------------------------------------------------------------------------- /macbac.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | PACKAGE_NAME="com.hazcod.macbac" 4 | PLIST_PATH="$HOME/Library/LaunchAgents/${PACKAGE_NAME}.plist" 5 | 6 | RED='\033[0;31m' 7 | GREEN='\033[0;32m' 8 | YELLOW='\033[1;33m' 9 | NC='\033[0m' 10 | BOLD='\033[1m' 11 | 12 | # 13 | #-------------------------------------------------------------------------------------------------------------------------------------- 14 | # 15 | 16 | usage() { 17 | # show command cli usage help 18 | echo "Usage: $0 <...>" 19 | exit 1 20 | } 21 | 22 | error() { 23 | # show an error message and exit 24 | >&2 echo -e "${RED}ERROR:${N} ${1}${NC}" 25 | exit 1 26 | } 27 | 28 | realpath() { 29 | OURPWD=$PWD 30 | cd "$(dirname "$1")" 31 | LINK=$(readlink "$(basename "$1")") 32 | while [ "$LINK" ]; do 33 | cd "$(dirname "$LINK")" 34 | LINK=$(readlink "$(basename "$1")") 35 | done 36 | REALPATH="$PWD/$(basename "$1")" 37 | cd "$OURPWD" 38 | echo "$REALPATH" 39 | } 40 | 41 | deschedule() { 42 | if [ ! -f "${PLIST_PATH}" ]; then 43 | error "a schedule was not configured" 44 | exit 1 45 | fi 46 | 47 | launchctl stop "${PACKAGE_NAME}" 2>/dev/null 48 | 49 | if ! launchctl unload "$PLIST_PATH" || ! rm "${PLIST_PATH}"; then 50 | error "could not disable the schedule" 51 | exit 1 52 | fi 53 | 54 | echo -e "Removed previous snapshot schedule." 55 | } 56 | 57 | showNext() { 58 | parts=$(grep StartCalendarInterval "$PLIST_PATH" -C2 | tail -n1) 59 | mode=$(echo "$parts" | cut -d '>' -f 2 | cut -d '<' -f 1) 60 | number=$(echo "$parts" | cut -d '>' -f 4 | cut -d '<' -f 1) 61 | 62 | if [[ "$mode" == "" ]]; then 63 | error "No schedule detected. Did you run 'schedule'?" 64 | exit 1 65 | fi 66 | 67 | local now 68 | local unit 69 | 70 | if [[ "$mode" == "Minute" ]]; then 71 | now=$(date +'%M') 72 | unit="minutes" 73 | fi 74 | 75 | if [[ "$mode" == "Hour" ]]; then 76 | now=$(date +'%H') 77 | unit="hours" 78 | fi 79 | 80 | diff="$((number - now))" 81 | if (( diff < 0 )); then 82 | diff=$((diff+60)) 83 | fi 84 | 85 | echo "Your next snapshot is scheduled in the next ${diff} ${unit}." 86 | return 87 | } 88 | 89 | schedule() { 90 | local mode="$1" 91 | local keep="$2" 92 | 93 | interval="" 94 | if [ "$mode" == "hourly" ]; then 95 | interval="Minute$(($(date +%M) +1))" 96 | if [ -z "$keep" ]; then 97 | keep="24" 98 | fi 99 | elif [ "$mode" == "daily" ]; then 100 | interval="Hour$(($(date +%H) -1))" 101 | if [ -z "$keep" ]; then 102 | keep="7" 103 | fi 104 | else 105 | error "invalid schedule mode: ${mode}" 106 | exit 1 107 | fi 108 | 109 | if [ -f "$PLIST_PATH" ]; then 110 | echo "Removing previous schedule..." 111 | deschedule 112 | fi 113 | 114 | mkdir -p "$HOME/Library/LaunchAgents/" 115 | 116 | echo "Installing daemon config to ${PLIST_PATH}" 117 | cat >"$PLIST_PATH" < 119 | 120 | 121 | 122 | Label 123 | ${PACKAGE_NAME} 124 | Nice 125 | 20 126 | StandardOutPath 127 | /tmp/macbac.log 128 | StandardErrorPath 129 | /tmp/macbac.err 130 | ProgramArguments 131 | 132 | $(realpath $0) 133 | snapshot 134 | ${keep} 135 | 136 | StartCalendarInterval 137 | 138 | ${interval} 139 | 140 | 141 | 142 | EOL 143 | 144 | echo "Loading config to enable schedule..." 145 | if ! launchctl load "$PLIST_PATH" || ! launchctl start "${PACKAGE_NAME}"; then 146 | error "Could not enable the schedule" 147 | exit 1 148 | fi 149 | 150 | echo -e "${GREEN}Scheduled ${mode} snapshots!${NC}" 151 | } 152 | 153 | prune() { 154 | local volume="$1" 155 | local amount="$2" 156 | 157 | if [[ ${amount} -le 0 ]]; then 158 | exit 0 159 | fi 160 | 161 | IFS=$'\n' read -r -d '' -a snapshots < <(tmutil listlocalsnapshotdates "$volume" | tail -n +2 | sort) 162 | 163 | if [[ ${#snapshots[@]} -eq 0 ]]; then 164 | echo "No snapshots to purge" 165 | exit 0 166 | fi 167 | 168 | if [[ ${amount} -gt ${#snapshots[@]} ]]; then 169 | amount=0 170 | else 171 | amount=$((${#snapshots[@]} - amount)) 172 | fi 173 | 174 | echo "Pruning ${amount} of ${#snapshots[@]} snapshots for ${volume}" 175 | 176 | counter="$amount" 177 | for snapshot in "${snapshots[@]}"; do 178 | if [[ $counter -le 0 ]]; then 179 | break 180 | fi 181 | 182 | echo "Pruning snapshot ${snapshot} (${counter}/${amount})" 183 | 184 | if ! tmutil deletelocalsnapshots "${snapshot}" >/dev/null; then 185 | error "could not prune local snapshot ${snapshot}" 186 | exit 1 187 | fi 188 | 189 | counter=$((counter -1)) 190 | done 191 | } 192 | 193 | snapshot() { 194 | local volume="$1" 195 | local pruneAmount="$2" 196 | 197 | if [ -z "$volume" ]; then 198 | echo "Assuming / is the volume we would like to snapshot." 199 | volume="/" 200 | fi 201 | 202 | if ! tmutil localsnapshot "${volume}" | grep -v '^NOTE:'; then 203 | error "Could not snapshot ${volume}" 204 | exit 1 205 | fi 206 | 207 | echo -e "${GREEN}Snapshotted volume ${volume}${NC}" 208 | 209 | if [ -n "$pruneAmount" ] && (( pruneAmount > 0 )); then 210 | prune "$volume" "$pruneAmount" 211 | fi 212 | } 213 | 214 | enable() { 215 | if ! sudo tmutil enable; then 216 | error "Could not enable backups" 217 | exit 1 218 | fi 219 | 220 | echo -e "${GREEN}Enabled backups.${NC}" 221 | } 222 | 223 | disable() { 224 | if ! sudo tmutil disable; then 225 | error "Could not enable backups" 226 | exit 1 227 | fi 228 | 229 | echo -e "${YELLOW}Disabled backups.${NC}" 230 | } 231 | 232 | getVolumes() { 233 | ls -1d /Volumes/* 234 | } 235 | 236 | getSnapshots() { 237 | local volume="$1" 238 | tmutil listlocalsnapshotdates "$volume" | tail -n +2 | xargs 239 | #listlocalsnapshots / | tail -n +2 | xargs | sed 's/com\.apple\.TimeMachine\.//g' | sed 's/\.local//' 240 | } 241 | 242 | listStatus() { 243 | status="$(tmutil currentphase)" 244 | 245 | if [ "$status" == "BackupNotRunning" ]; then 246 | echo -e "${BOLD}Backup Status${NC}: Inactive" 247 | return 248 | fi 249 | 250 | if [ "$status" == "BackupError" ]; then 251 | echo -e "${BOLD}Backup Status${NC}: ${RED}Error during backup${NC}" 252 | return 253 | fi 254 | 255 | echo -e "${BOLD}Backup Status${NC}: ${GREEN}Running${NC}" 256 | } 257 | 258 | listSnapshots() { 259 | volumes=($(getVolumes)) 260 | 261 | for volume in "${volumes[@]}"; do 262 | # show current volume 263 | echo -e "${BOLD}${volume}${NC}" 264 | 265 | local snapshots 266 | 267 | # retrieve snapshots 268 | if ! snapshots="$(getSnapshots "$volume")"; then 269 | error "could not retrieve snapshots" 270 | exit 1 271 | fi 272 | 273 | # list all snapshots 274 | for snapshot in $snapshots; do 275 | IFS='-' read -ra parts <<< "$snapshot" 276 | dateStr="${parts[3]:0:2}:${parts[3]:2:2}:${parts[3]:4:2}" 277 | echo "> ${parts[0]}/${parts[1]}/${parts[2]} $dateStr" 278 | done 279 | 280 | # add extra space when we have multiple rows 281 | if (( ${#volumes[@]} > 1 )); then 282 | echo "" 283 | fi 284 | done 285 | } 286 | 287 | # 288 | #-------------------------------------------------------------------------------------------------------------------------------------- 289 | # 290 | 291 | if [ $# -lt 1 ]; then 292 | usage 293 | fi 294 | 295 | case "$1" in 296 | "status") 297 | if [[ "$#" -gt 1 ]]; then 298 | usage 299 | fi 300 | 301 | listStatus 302 | ;; 303 | 304 | "list") 305 | if [[ "$#" -gt 1 ]]; then 306 | usage 307 | fi 308 | 309 | listSnapshots 310 | ;; 311 | 312 | "snapshot") 313 | if [[ "$#" -gt 2 ]]; then 314 | usage 315 | fi 316 | 317 | snapshot "/" "$2" 318 | ;; 319 | 320 | "enable") 321 | if [[ "$#" -gt 1 ]]; then 322 | usage 323 | fi 324 | 325 | enable 326 | ;; 327 | 328 | "disable") 329 | if [[ "$#" -gt 1 ]]; then 330 | usage 331 | fi 332 | 333 | disable 334 | ;; 335 | 336 | "schedule") 337 | if [[ "$#" -gt 3 ]]; then 338 | usage 339 | fi 340 | 341 | schedule "$2" "$3" 342 | ;; 343 | 344 | "deschedule") 345 | if [[ "$#" -gt 1 ]]; then 346 | usage 347 | fi 348 | 349 | deschedule 350 | ;; 351 | 352 | "prune") 353 | if [[ "$#" -gt 2 ]]; then 354 | usage 355 | fi 356 | 357 | prune "/" "$2" 358 | ;; 359 | 360 | "next") 361 | if [[ "$#" -gt 1 ]]; then 362 | usage 363 | fi 364 | 365 | showNext 366 | ;; 367 | 368 | *) 369 | usage 370 | ;; 371 | esac --------------------------------------------------------------------------------