#!/usr/bin/env bash # journal.sh — atomic backup-and-write journal for Larry-Anywhere v3. # # Every modification to a file goes through this journal: # 1. Snapshot the original (if it exists) to a session-scoped backup dir. # 2. Compute a unified diff between original and new content. # 3. Append a record to $LARRY_HOME/journal/index.tsv. # 4. Append a human-readable entry to $LARRY_HOME/journal//manifest.md. # 5. Atomically write the new content to the target. # # Rollback support: `larry-rollback.sh` (sibling script) can restore any subset. # # This script is SOURCEABLE — Larry-Anywhere's tool_write_file sources it and # calls `journal_write`. It can also be invoked directly as a CLI. # # CLI: # journal.sh write # snapshot, diff, write # journal.sh list [--session S] # list entries # journal.sh show # print diff for one entry # journal.sh session-manifest [] # print this/given session's manifest # # Env: # LARRY_HOME (required) where the journal lives # LARRY_SESSION_ID (optional) defaults to a sortable timestamp set -u set -o pipefail LARRY_HOME="${LARRY_HOME:-$HOME/.larry}" LARRY_SESSION_ID="${LARRY_SESSION_ID:-$(date +%Y-%m-%d-%H%M%S)-$$}" JOURNAL_ROOT="$LARRY_HOME/journal" JOURNAL_INDEX="$JOURNAL_ROOT/index.tsv" SESSION_DIR="$JOURNAL_ROOT/$LARRY_SESSION_ID" SESSION_FILES="$SESSION_DIR/files" SESSION_MANIFEST="$SESSION_DIR/manifest.md" _journal_init() { mkdir -p "$SESSION_FILES" 2>/dev/null || return 1 if [ ! -f "$JOURNAL_INDEX" ]; then printf 'timestamp\tsession\tentry_id\ttarget\taction\torig_sha256\tnew_sha256\tbackup_path\tdiff_path\n' > "$JOURNAL_INDEX" fi if [ ! -f "$SESSION_MANIFEST" ]; then { echo "# Larry-Anywhere journal — session $LARRY_SESSION_ID" echo "- started: $(date -Iseconds 2>/dev/null || date)" echo "- host: $(hostname 2>/dev/null || echo unknown)" echo "- larry-version: $(cat "$LARRY_HOME/VERSION" 2>/dev/null || echo unknown)" echo "" echo "Every write below has a backup at \`files/NNN_.orig\` and a diff at \`files/NNN_.diff\`." echo "Roll back with: \`larry-rollback.sh --session $LARRY_SESSION_ID\` (or per-entry)." echo "" } > "$SESSION_MANIFEST" fi } _sha() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' else cksum "$1" | awk '{print $1"-"$2}' fi } _next_seq() { # Number of *.orig|*.new files in session_files / 2, rounded up. # v0.7.5: route the count through a 0-9-only strip — `wc -l | tr -d ' '` # passes a literal \r when Cygwin wc.exe is in PATH, which then crashes # `$(( (n / 2) + 1 ))` with "invalid arithmetic operator". local raw n raw=$(find "$SESSION_FILES" -maxdepth 1 -name '*.orig' -o -name '*.new' 2>/dev/null | wc -l) n=$(printf '%s' "$raw" | tr -cd '0-9') : "${n:=0}" printf '%03d' $(( (n / 2) + 1 )) } # Main entry. $1=target file path, $2=path to file containing new content. # Returns the entry_id on stdout. Prints user-facing diff on stderr. journal_write() { local target="$1" local newfile="$2" [ -n "$target" ] || { echo "journal_write: missing target" >&2; return 2; } [ -f "$newfile" ] || { echo "journal_write: new-content file not found: $newfile" >&2; return 2; } _journal_init local seq base entry_id seq=$(_next_seq) base=$(basename "$target") entry_id="${LARRY_SESSION_ID}/${seq}_${base}" local backup="$SESSION_FILES/${seq}_${base}.orig" local newcopy="$SESSION_FILES/${seq}_${base}.new" local diffp="$SESSION_FILES/${seq}_${base}.diff" local action="create" local orig_sha="" if [ -e "$target" ]; then cp -p "$target" "$backup" || return 3 orig_sha=$(_sha "$backup") action="modify" else : > "$backup" # placeholder for rollback (empty file) orig_sha="" fi cp -p "$newfile" "$newcopy" || return 3 local new_sha; new_sha=$(_sha "$newcopy") diff -u "$backup" "$newcopy" > "$diffp" 2>/dev/null || true # Atomically write the new content local target_dir; target_dir=$(dirname "$target") mkdir -p "$target_dir" 2>/dev/null cp -p "$newfile" "$target.larry-tmp.$$" && mv -f "$target.larry-tmp.$$" "$target" # Append index printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ "$(date -Iseconds 2>/dev/null || date)" \ "$LARRY_SESSION_ID" "$seq" "$target" "$action" \ "$orig_sha" "$new_sha" "$backup" "$diffp" >> "$JOURNAL_INDEX" # Append session manifest { printf '\n## %s %s `%s`\n' "$seq" "$action" "$target" printf -- '- orig sha256: %s\n' "${orig_sha:-(none — new file)}" printf -- '- new sha256: %s\n' "$new_sha" printf -- '- backup: `files/%s_%s.orig`\n' "$seq" "$base" printf -- '- diff: `files/%s_%s.diff`\n' "$seq" "$base" if [ "$action" = "modify" ]; then printf -- '- change summary: %s lines added, %s lines removed\n' \ "$(grep -c '^+[^+]' "$diffp" 2>/dev/null || echo 0)" \ "$(grep -c '^-[^-]' "$diffp" 2>/dev/null || echo 0)" fi } >> "$SESSION_MANIFEST" # User-facing: print the entry id (small) on stdout printf '%s\n' "$entry_id" } journal_list() { local filter_session="" while [ $# -gt 0 ]; do case "$1" in --session) shift; filter_session="$1" ;; esac shift done [ -f "$JOURNAL_INDEX" ] || { echo "(no journal yet)"; return 0; } if [ -n "$filter_session" ]; then awk -F'\t' -v s="$filter_session" 'NR==1 || $2==s' "$JOURNAL_INDEX" else cat "$JOURNAL_INDEX" fi } journal_show() { local entry_id="$1" local session seq session="${entry_id%/*}" seq="${entry_id##*/}" seq="${seq%%_*}" local f f=$(find "$JOURNAL_ROOT/$session/files" -maxdepth 1 -name "${seq}_*.diff" 2>/dev/null | head -1) [ -n "$f" ] || { echo "no such entry: $entry_id" >&2; return 2; } cat "$f" } journal_session_manifest() { local session="${1:-$LARRY_SESSION_ID}" local f="$JOURNAL_ROOT/$session/manifest.md" [ -f "$f" ] || { echo "no manifest for session $session" >&2; return 2; } cat "$f" } # CLI dispatch (only when invoked directly, not sourced) if [ "${BASH_SOURCE[0]:-$0}" = "${0}" ]; then cmd="${1:-help}" case "$cmd" in write) [ $# -ge 3 ] || { echo "usage: $0 write " >&2; exit 2; }; journal_write "$2" "$3" ;; list) shift; journal_list "$@" ;; show) [ $# -ge 2 ] || { echo "usage: $0 show " >&2; exit 2; }; journal_show "$2" ;; session-manifest) journal_session_manifest "${2:-}" ;; help|-h|--help) sed -n '2,25p' "$0" ;; *) echo "unknown subcommand: $cmd" >&2; exit 2 ;; esac fi