#!/usr/bin/env bash # # sessions.sh - create a private GitHub repo on first run, then keep the # session folders new1, new2, new3, ... pushed to it on a timer. # # bash <(curl -fsSL https://.pages.dev/sessions.sh) # start # bash <(curl -fsSL https://.pages.dev/sessions.sh) --once # one pass # bash <(curl -fsSL https://.pages.dev/sessions.sh) --status # bash <(curl -fsSL https://.pages.dev/sessions.sh) --logs # bash <(curl -fsSL https://.pages.dev/sessions.sh) --stop # bash <(curl -fsSL https://.pages.dev/sessions.sh) --help # # What it does: # # * First run creates the private repo (BACKUP_REPO, default # cloudshell-sessions) and the session folders new1 and new2 in $HOME. # * Every BACKUP_INTERVAL seconds, the contents of every folder matching # new are committed to the repo root (new1/, new2/, ...) and pushed. # A folder added later (new3, new4, ...) is picked up automatically. # * After IDLE_LIMIT consecutive passes with no changes, pushes pause. The # daemon stays alive watching the folders, and the next change resumes # pushing on its own. # # The git directory lives in $HOME/.cloudshell-sessions/repo.git and is driven # with --git-dir/--work-tree, so no .git ever appears inside the session # folders and `git status` inside any project stays about that project. # # Environment variables: # BACKUP_REPO private repo name [default: cloudshell-sessions] # BACKUP_REMOTE full remote URL; skips `gh repo create` # BACKUP_INTERVAL seconds between passes [default: 180] # IDLE_LIMIT idle passes before pausing [default: 3] # BACKUP_MAX_FILE_MB largest file to take [default: 40] # BACKUP_BRANCH branch to push [default: main] # SESSIONS_ROOT where the new folders live [default: $HOME] # SESSIONS_TMUX_SESSION tmux session name [default: sessions] # # GitHub auth: the daemon pushes with `gh` credentials. Log in first with # `gh auth login`, or let install.sh / cloudshell.sh handle GH_TOKEN. # # NEVER add `set -x` to this script. set -euo pipefail BACKUP_REPO="${BACKUP_REPO:-cloudshell-sessions}" BACKUP_INTERVAL="${BACKUP_INTERVAL:-180}" BACKUP_MAX_FILE_MB="${BACKUP_MAX_FILE_MB:-40}" BACKUP_BRANCH="${BACKUP_BRANCH:-main}" IDLE_LIMIT="${IDLE_LIMIT:-3}" FOLDERS_ROOT="${SESSIONS_ROOT:-$HOME}" FOLDER_PATTERN='new[0-9]*' FOLDER_RE='^new[0-9]+$' TMUX_SESSION="${SESSIONS_TMUX_SESSION:-sessions}" STATE_DIR="${SESSIONS_STATE_DIR:-$FOLDERS_ROOT/.cloudshell-sessions}" GIT_DIR="$STATE_DIR/repo.git" EXCLUDES="$STATE_DIR/exclude" TOOBIG="$STATE_DIR/toobig" LOG="$STATE_DIR/sessions.log" LOCK="$STATE_DIR/.lock" MARKER="$STATE_DIR/.activity" IDLE_FILE="$STATE_DIR/idle-count" PAUSED_FLAG="$STATE_DIR/.paused" PAUSED_FOLDERS="$STATE_DIR/.paused-folders" RC_FILE="$STATE_DIR/.last-rc" SELF="$HOME/.local/bin/cloudshell-sessions" mkdir -p "$STATE_DIR" log() { printf '[%s] %s\n' "$(date -u +'%Y-%m-%d %H:%M:%SZ')" "$*"; } note() { log "$*" | tee -a "$LOG"; } warn() { printf 'warning: %s\n' "$*" >&2; log "warning: $*" >>"$LOG"; } die() { printf 'error: %s\n' "$*" >&2; log "error: $*" >>"$LOG"; exit 1; } usage() { cat <<'EOF' Push session folders (new1, new2, new3, ...) to a private GitHub repo, on a timer. bash <(curl -fsSL /sessions.sh) start the 3-minute daemon bash <(curl -fsSL /sessions.sh) --once take one snapshot now bash <(curl -fsSL /sessions.sh) --status is it running, is it paused bash <(curl -fsSL /sessions.sh) --logs follow the log (Ctrl-C to leave) bash <(curl -fsSL /sessions.sh) --stop stop the daemon bash <(curl -fsSL /sessions.sh) --help Behavior: * first run creates a private repo and the folders new1 and new2 in $HOME * every pass commits the contents of every new folder to the repo root and pushes; new3, new4, ... are picked up automatically * after 3 consecutive passes with no changes, pushes pause; the daemon keeps watching and resumes on the next change, with no action needed Environment variables: BACKUP_REPO private repo name (default: cloudshell-sessions) BACKUP_REMOTE full remote URL; skips `gh repo create` BACKUP_INTERVAL seconds between passes (default: 180) IDLE_LIMIT idle passes before pausing (default: 3) BACKUP_MAX_FILE_MB largest file to take (default: 40) BACKUP_BRANCH branch to push (default: main) SESSIONS_ROOT where the new folders live (default: $HOME) SESSIONS_TMUX_SESSION tmux session name (default: sessions) EOF } case "${1:-}" in ""|--start) action="start" ;; --once) action="once" ;; --status) action="status" ;; --logs) action="logs" ;; -s|--stop) action="stop" ;; -l|--loop) action="loop" ;; -h|--help) usage; exit 0 ;; *) die "unknown argument '$1' (try --help)" ;; esac command -v git >/dev/null 2>&1 || die "git is not on PATH on this VM, so nothing can be pushed." case "$BACKUP_INTERVAL" in ''|*[!0-9]*) die "BACKUP_INTERVAL must be a number of seconds, got '$BACKUP_INTERVAL'." ;; esac [ "$BACKUP_INTERVAL" -ge 5 ] || die "BACKUP_INTERVAL must be at least 5 seconds, got $BACKUP_INTERVAL." case "$IDLE_LIMIT" in ''|*[!0-9]*) die "IDLE_LIMIT must be a number, got '$IDLE_LIMIT'." ;; esac [ "$IDLE_LIMIT" -ge 1 ] || die "IDLE_LIMIT must be at least 1, got $IDLE_LIMIT." case "$BACKUP_REPO" in ''|*[!A-Za-z0-9._-]*) die "BACKUP_REPO must look like a repo name, got '$BACKUP_REPO'." ;; esac case "$BACKUP_MAX_FILE_MB" in ''|*[!0-9]*) die "BACKUP_MAX_FILE_MB must be a number, got '$BACKUP_MAX_FILE_MB'." ;; esac # ------------------------------------------------------------------ internals -- # git, always aimed at the out-of-tree repo. Never create .git inside the # session folders. g() { git --git-dir="$GIT_DIR" --work-tree="$FOLDERS_ROOT" "$@"; } install_self() { local src="$0" [ -r "$src" ] || die "cannot read '$src' to install it" mkdir -p "$(dirname "$SELF")" if ! cmp -s "$src" "$SELF" 2>/dev/null; then cat "$src" >"$SELF" chmod +x "$SELF" fi } # new1 and new2 always exist; an empty folder gets a .gitkeep so the first # commit has something in it. ensure_folders() { local n=1 d while [ "$n" -le 2 ]; do d="$FOLDERS_ROOT/new$n" if [ ! -d "$d" ]; then mkdir -p "$d" note "created session folder $d" fi if [ -z "$(ls -A "$d" 2>/dev/null)" ] && [ ! -e "$d/.gitkeep" ]; then : >"$d/.gitkeep" fi n=$((n + 1)) done } discover_folders() { find "$FOLDERS_ROOT" -maxdepth 1 -type d -name "$FOLDER_PATTERN" -printf '%f\n' 2>/dev/null \ | grep -E "$FOLDER_RE" | sort -V || true } write_excludes() { { cat <<'EOF' # Generated by sessions.sh - rewritten on every pass, so edit the script instead. .git/ node_modules/ __pycache__/ *.pyc *.pyo .venv/ venv/ .pytest_cache/ .mypy_cache/ .ruff_cache/ .tox/ .next/ .nuxt/ .svelte-kit/ .turbo/ .parcel-cache/ .esbuild/ .gradle/ .cargo/registry/ target/ .DS_Store *.swp *.swo *~ EOF if [ -s "$TOOBIG" ]; then while IFS= read -r big; do [ -n "$big" ] && printf '/%s\n' "$big" done <"$TOOBIG" fi } >"$EXCLUDES" } # Anything GitHub would reject on push is listed in TOOBIG and excluded on the # next pass, so one large artefact cannot stall every later push. scan_toobig() { local folder rel : >"$TOOBIG.tmp" while IFS= read -r folder; do [ -n "$folder" ] || continue find "$FOLDERS_ROOT/$folder" -xdev -type f -size "+${BACKUP_MAX_FILE_MB}M" 2>/dev/null \ | while IFS= read -r f; do rel="${f#"$FOLDERS_ROOT"/}" printf '%s\n' "$rel" done >>"$TOOBIG.tmp" done < <(discover_folders) sort -u -o "$TOOBIG.tmp" "$TOOBIG.tmp" 2>/dev/null || true if [ -s "$TOOBIG.tmp" ] && ! cmp -s "$TOOBIG.tmp" "$TOOBIG" 2>/dev/null; then note "skipping files over ${BACKUP_MAX_FILE_MB} MB (GitHub would reject them):" sed 's/^/ /' "$TOOBIG.tmp" | while IFS= read -r line; do note "$line"; done fi mv -f "$TOOBIG.tmp" "$TOOBIG" } ensure_repo() { if [ ! -d "$GIT_DIR" ]; then note "creating the local push repository in $GIT_DIR" git init --quiet --bare "$GIT_DIR" || { warn "git init failed in $GIT_DIR"; return 1; } g config core.bare false || true g config core.worktree "$FOLDERS_ROOT" || true g config core.autocrlf false || true g config core.safecrlf false || true git --git-dir="$GIT_DIR" symbolic-ref HEAD "refs/heads/$BACKUP_BRANCH" \ || { warn "could not point HEAD at $BACKUP_BRANCH"; return 1; } elif ! git --git-dir="$GIT_DIR" rev-parse --git-dir >/dev/null 2>&1; then warn "$GIT_DIR exists but is not a usable git repository; remove it and retry." return 1 fi g config core.excludesFile "$EXCLUDES" || true g config gc.auto 6700 || true g config push.default simple || true if [ -z "$(g config user.email || true)" ]; then g config user.name "${BACKUP_AUTHOR_NAME:-cloudshell-sessions}" || true g config user.email "${BACKUP_AUTHOR_EMAIL:-cloudshell-sessions@users.noreply.github.com}" || true fi if command -v gh >/dev/null 2>&1; then g config credential.helper '!gh auth git-credential' || true fi } ensure_remote() { if g remote get-url origin >/dev/null 2>&1; then return 0 fi local url="" login repo if [ -n "${BACKUP_REMOTE:-}" ]; then url="$BACKUP_REMOTE" else command -v gh >/dev/null 2>&1 || { warn "no gh CLI and no BACKUP_REMOTE: cannot work out where to push." return 1 } login="$(gh api user --jq .login 2>/dev/null || true)" [ -n "$login" ] || { warn "gh is not authenticated (run: gh auth login, or pass GH_TOKEN to install.sh)." return 1 } repo="$login/$BACKUP_REPO" if ! gh repo view "$repo" >/dev/null 2>&1; then note "creating private repo $repo" gh repo create "$repo" --private \ --description "Cloud Shell session folders, pushed every ${BACKUP_INTERVAL}s" \ >/dev/null 2>&1 || warn "could not create $repo (it may already exist)" fi url="https://github.com/$repo.git" fi g remote add origin "$url" || { warn "could not add remote origin ($url)"; return 1; } note "pushing session folders to $url" } push_commit() { if g push --quiet origin "$BACKUP_BRANCH" 2>>"$LOG"; then return 0 fi warn "push failed, checking whether the remote simply moved ahead of us" g fetch --quiet origin "$BACKUP_BRANCH" 2>>"$LOG" || return 1 if git --git-dir="$GIT_DIR" merge-base --is-ancestor "origin/$BACKUP_BRANCH" HEAD 2>/dev/null; then g push --quiet origin "$BACKUP_BRANCH" 2>>"$LOG" return $? fi warn "remote $BACKUP_BRANCH has commits this VM does not; not forcing over them." return 1 } # Return codes: 0 = pushed, 2 = nothing to push, 1 = failed. sync_once() { ensure_folders ensure_repo || return 1 ensure_remote || return 1 scan_toobig write_excludes mapfile -t folders < <(discover_folders) if [ "${#folders[@]}" -eq 0 ]; then warn "no session folders (new1, new2, ...) found in $FOLDERS_ROOT" return 1 fi local before after before="$(g rev-parse --verify --quiet HEAD || true)" # A session folder deleted from disk must still disappear from the repo, but # `git add` cannot name a pathspec that no longer exists - unstage it instead. local tracked present tracked="$(g ls-tree --name-only HEAD 2>/dev/null | grep -E "$FOLDER_RE" || true)" present="$(printf '%s\n' "${folders[@]}")" while IFS= read -r t; do [ -z "$t" ] && continue if ! printf '%s\n' "$present" | grep -qx "$t"; then g rm -r --cached --quiet --ignore-unmatch -- "$t" 2>>"$LOG" \ || warn "could not stage the removal of $t" note "session folder $t was deleted; removing it from the repo" fi done <<<"$tracked" cd "$FOLDERS_ROOT" || { warn "cannot cd to $FOLDERS_ROOT"; return 1; } g add -A -- "${folders[@]}" 2>>"$LOG" || { warn "git add failed"; return 1; } if g diff --cached --quiet; then note "no changes" return 2 fi if ! g commit --quiet --no-verify \ -m "sessions: $(date -u +'%Y-%m-%d %H:%M:%SZ') from $(hostname 2>/dev/null || echo cloudshell)" \ 2>>"$LOG"; then warn "git commit failed" return 1 fi after="$(g rev-parse HEAD 2>/dev/null)" || { warn "cannot read HEAD after committing"; return 1; } if push_commit; then if [ -z "$before" ]; then note "pushed initial commit -> $(g rev-parse --short HEAD)" else note "pushed $(( $(g rev-list --count "$before..$after" 2>/dev/null || echo 1) )) commit(s) -> $(g rev-parse --short HEAD)" fi else warn "commit $(g rev-parse --short HEAD) is local only; it will be retried next pass." return 1 fi } # One scheduled pass, serialized with flock; the pass's rc lands in RC_FILE so # the idle/pause bookkeeping can read it outside the subshell. tick_full() { touch "$MARKER" ( flock -n 9 || { note "previous pass still running, skipping this tick"; exit 0; } p_rc=0 sync_once || p_rc=$? printf '%s\n' "$p_rc" >"$RC_FILE" ) 9>>"$LOCK" || true local rc rc="$(cat "$RC_FILE" 2>/dev/null || echo 1)" case "$rc" in 0) printf '0\n' >"$IDLE_FILE" rm -f "$PAUSED_FLAG" ;; 2) local idle idle=$(( $(cat "$IDLE_FILE" 2>/dev/null || echo 0) + 1 )) printf '%s\n' "$idle" >"$IDLE_FILE" if [ "$idle" -ge "$IDLE_LIMIT" ] && [ ! -f "$PAUSED_FLAG" ]; then touch "$PAUSED_FLAG" discover_folders | tr '\n' ' ' >"$PAUSED_FOLDERS" note "no changes on $idle consecutive checks - pausing pushes; watching $FOLDERS_ROOT/${FOLDER_PATTERN} for changes (resumes automatically)" fi ;; 1) warn "pass failed (rc $rc); retrying next tick" ;; 9) ;; esac } # Cheap watch while paused: a changed folder list, or any file newer than the # last pass, counts as activity. Polls on the same cadence as a normal pass. watch_idle() { local prev current f local -a abs prev="$(cat "$PAUSED_FOLDERS" 2>/dev/null || true)" while :; do sleep "$BACKUP_INTERVAL" current="$(discover_folders | tr '\n' ' ')" if [ "$current" != "$prev" ]; then return 0 fi abs=() for f in $current; do abs+=("$FOLDERS_ROOT/$f"); done if [ "${#abs[@]}" -gt 0 ] && find "${abs[@]}" -xdev -newer "$MARKER" -type f \ -not -path '*/node_modules/*' \ -not -path '*/.git/*' \ -not -path '*/__pycache__/*' \ -not -path '*/.venv/*' \ -not -path '*/venv/*' \ -print -quit 2>/dev/null | grep -q .; then return 0 fi done } # ------------------------------------------------------------------- actions -- case "$action" in once) ensure_folders p_rc=0 sync_once || p_rc=$? if [ "$p_rc" = "1" ]; then exit 1; fi exit 0 ;; status) log "root $FOLDERS_ROOT" log "repository $GIT_DIR" local_folders="$(discover_folders | tr '\n' ' ')" log "folders ${local_folders:-none}" if command -v tmux >/dev/null 2>&1 && tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then log "daemon running (tmux session '$TMUX_SESSION', every ${BACKUP_INTERVAL}s)" else log "daemon NOT running" fi if [ -f "$PAUSED_FLAG" ]; then log "state paused - no changes $IDLE_LIMIT times; watching for changes" else log "state active" fi if [ -d "$GIT_DIR" ]; then if g remote get-url origin >/dev/null 2>&1; then log "remote $(g remote get-url origin)" else log "remote (not set up yet - no gh auth?)" fi if g rev-parse --verify --quiet HEAD >/dev/null 2>&1; then log "last local $(g log -1 --format='%h %ad %s' --date=iso 2>/dev/null)" log "commits $(g rev-list --count HEAD) total" else log "last local (nothing committed yet)" fi fi if [ -s "$LOG" ]; then log "--- last 8 log lines ---" tail -n 8 "$LOG" | while IFS= read -r line; do log "$line"; done fi exit 0 ;; logs) [ -s "$LOG" ] || { log "no log yet at $LOG"; exit 0; } log "following $LOG (Ctrl-C to leave)" exec tail -n 40 -f "$LOG" ;; stop) if command -v tmux >/dev/null 2>&1 && tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then tmux kill-session -t "$TMUX_SESSION" note "daemon stopped." else log "nothing was running." fi exit 0 ;; loop) # A daemon must not die because one internal command failed: drop errexit # for the loop body, and log any unexpected exit rather than vanishing. set +e trap 'rc=$?; if [ "$rc" -ne 0 ]; then note "daemon exited unexpectedly (rc $rc)"; fi' EXIT # One pass immediately, then every BACKUP_INTERVAL seconds. Paused is a # file, not a variable, so --status can see it from outside. printf '0\n' >"$IDLE_FILE" rm -f "$PAUSED_FLAG" "$PAUSED_FOLDERS" touch "$MARKER" note "daemon started (pid $$, tracking $FOLDERS_ROOT/${FOLDER_PATTERN})" while :; do if [ -f "$PAUSED_FLAG" ]; then if watch_idle; then rm -f "$PAUSED_FLAG" "$PAUSED_FOLDERS" printf '0\n' >"$IDLE_FILE" note "change detected - resuming pushes" tick_full fi else tick_full fi sleep "$BACKUP_INTERVAL" done ;; start) command -v tmux >/dev/null 2>&1 || die "tmux is missing on this VM, so the daemon cannot be kept alive." install_self ensure_folders if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then log "already running (tmux session '$TMUX_SESSION')." log "status: $SELF --status" exit 0 fi note "starting sessions daemon: $FOLDERS_ROOT/${FOLDER_PATTERN} -> private GitHub '$BACKUP_REPO', every ${BACKUP_INTERVAL}s" # tmux hands a new session the SERVER's environment, not this shell's, so # anything exported here would be silently lost. Bake the config in. tmux_env="HOME='$HOME' SESSIONS_ROOT='$FOLDERS_ROOT' SESSIONS_STATE_DIR='$STATE_DIR'" tmux_env="$tmux_env BACKUP_REPO='$BACKUP_REPO' BACKUP_REMOTE='${BACKUP_REMOTE:-}'" tmux_env="$tmux_env BACKUP_INTERVAL='$BACKUP_INTERVAL' IDLE_LIMIT='$IDLE_LIMIT'" tmux_env="$tmux_env BACKUP_MAX_FILE_MB='$BACKUP_MAX_FILE_MB' BACKUP_BRANCH='$BACKUP_BRANCH'" tmux_env="$tmux_env PATH='$PATH'" tmux new-session -d -s "$TMUX_SESSION" "exec env $tmux_env '$SELF' --loop 2>>'$LOG'" tries=0 while [ "$tries" -lt 5 ]; do sleep 1 tries=$((tries + 1)) tmux has-session -t "$TMUX_SESSION" 2>/dev/null && break done if ! tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then log "the sessions daemon exited during startup. Last log lines:" tail -n 25 "$LOG" 2>/dev/null | while IFS= read -r line; do log "$line"; done die "startup failed - see the log above and $LOG (also try: $SELF --once)" fi log "sessions daemon is running (tmux session '$TMUX_SESSION')." log "folders: $(discover_folders | tr '\n' ' ')" log "first snapshot is being taken now; watch it with: $SELF --logs" log "after $IDLE_LIMIT checks with no changes it pauses and resumes on its own when a folder changes." exit 0 ;; esac