bash utility

edits.sh

Pull remote files over SFTP, edit them in your local environment, and push changes back automatically on save.

What it does

edits.sh connects to a remote server via SFTP, pulls files into a local temp directory, and opens them in your preferred editor. When you save and close a file, it is pushed back to the remote. Files closed without saving are skipped. The temp directory is cleaned up on exit.

edits.sh means you use your prefered editor with your personal configurations: your keybindings, themes, plugins, LSP, and snippets. It is a lightweight alternative to configuring a full remote development environment for quick edits on servers you don't control or connect to very often.

Usage

01Download the script and make it executable: chmod +x edits.sh
02Optionally install it globally: sudo cp edits.sh /usr/local/bin/edits
03Run it with a remote path: edits user@host:/path/to/dir to edit a directory, or edits user@host:/path/file.txt to target a single file. Run with no argument to be prompted.
04Edit each file in your local editor. Save and close to upload. Close without saving to skip. All changed files are pushed in a single SFTP session.

Flags

-e ext Extension(s) to pull and edit. Defaults to txt. Pass a comma-separated list to match multiple types: -e sh,py,conf.
-r Recurse into subdirectories. Pulls matching files from the full directory tree under the given remote path.
-w Watch mode. After the initial edit session, watches the local file(s) and auto-uploads on every save. Requires inotifywait (Linux) or fswatch (macOS). Press Ctrl-C to stop watching.
-f Force single-file mode. Use when the remote path points to a file but lacks an extension, bypassing the directory detection heuristic.
-y Skip the confirmation prompt and begin editing immediately.
-h Print usage help and exit.
$EDITOR Respects the $EDITOR environment variable. Defaults to nano if unset.

Source

edits.sh
#!/usr/bin/env bash
# edits.sh - pull remote files, edit locally, push back on save
# chmod +x edits.sh - make script executable
# sudo cp edits.sh /usr/local/bin/edits
# credit - barney matthews (www.barney.me)

set -euo pipefail

# ── Colors ────────────────────────────────────────────────────────────────────
if [[ -t 1 ]]; then
  GRN=''; YLW=''; BLU=''
  CYN=''; RED=''; DIM=''; RST=''
else
  GRN=''; YLW=''; BLU=''; CYN=''; RED=''; DIM=''; RST=''
fi

info()    { echo -e "${BLU}→${RST} $*"; }
ok()      { echo -e "${GRN}✔${RST} $*"; }
skip()    { echo -e "${DIM}–${RST} ${DIM}$*${RST}"; }
warn()    { echo -e "${YLW}⚠${RST} $*"; }
err()     { echo -e "${RED}✖${RST} $*" >&2; }
upload()  { echo -e "${CYN}↑${RST} $*"; }

# ── Usage ─────────────────────────────────────────────────────────────────────
usage() {
  echo ""
  echo "  edits  [-e ext[,ext]] [-r] [-w] [-f] [-y] [-h] [user@host:/path]"
  echo ""
  echo "  -e ext   Extension(s) to edit, comma-separated (default: txt)"
  echo "  -r       Recurse into subdirectories"
  echo "  -w       Watch mode: auto-upload on every save, keep watching until Ctrl-C"
  echo "  -f       Force single-file mode (skip directory detection heuristic)"
  echo "  -y       Skip confirmation prompt"
  echo "  -h       Show this help"
  echo ""
  echo "  Save and close a file to upload it. Close without saving to skip."
  echo "  In watch mode (-w) the file is uploaded on every detected save."
  echo ""
}

# ── Defaults ──────────────────────────────────────────────────────────────────
EDITOR="${EDITOR:-nano}"
EXTS="txt"
RECURSIVE=false
WATCH=false
SINGLE_FILE=false
YES=false
REMOTE=""

# ── Parse flags ───────────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
  case "$1" in
    -e) EXTS="$2";         shift 2 ;;
    -r) RECURSIVE=true;    shift   ;;
    -w) WATCH=true;        shift   ;;
    -f) SINGLE_FILE=true; shift   ;;
    -y) YES=true;          shift   ;;
    -h) usage; exit 0               ;;
    *)  REMOTE="$1";       shift   ;;
  esac
done

# ── Prompt if no remote provided ──────────────────────────────────────────────
if [[ -z "$REMOTE" ]]; then
  read -rp "Remote path (user@host:/path): " REMOTE
fi

REMOTE_HOST="${REMOTE%%:*}"
REMOTE_PATH="${REMOTE#*:}"

WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT

# ── Upload helper (single file) ───────────────────────────────────────────────
push_file() {
  local local_file="$1"
  local remote_dir="$2"
  sftp -q "$REMOTE_HOST" <<SFTP
cd "$remote_dir"
put "$local_file"
SFTP
}

# ── Watch a single file ───────────────────────────────────────────────────────
watch_file() {
  local f="$1"
  local remote_dir="$2"
  local watcher=""

  if command -v inotifywait &>/dev/null; then
    watcher="inotify"
  elif command -v fswatch &>/dev/null; then
    watcher="fswatch"
  else
    err "Watch mode requires inotifywait (inotify-tools) or fswatch. Neither found."
    exit 1
  fi

  info "Watching $(basename "$f") — Ctrl-C to stop"

  if [[ "$watcher" == "inotify" ]]; then
    while inotifywait -q -e close_write "$f" 2>/dev/null; do
      upload "$(basename "$f") saved — uploading ..."
      push_file "$f" "$remote_dir"
      ok "Uploaded $(basename "$f")"
    done
  else
    fswatch -0 --event Updated "$f" | while IFS= read -r -d '' _event; do
      upload "$(basename "$f") saved — uploading ..."
      push_file "$f" "$remote_dir"
      ok "Uploaded $(basename "$f")"
    done
  fi
}

# ── Pull files ────────────────────────────────────────────────────────────────
FILES=()

# Single-file mode: explicit -f flag OR path looks like a file (has extension)
IS_SINGLE=false
if [[ "$SINGLE_FILE" == true ]]; then
  IS_SINGLE=true
elif [[ "$REMOTE_PATH" =~ \.[^/]+$ ]]; then
  IS_SINGLE=true
fi

if [[ "$IS_SINGLE" == true ]]; then
  info "Pulling $(basename "$REMOTE_PATH") ..."
  if sftp -q "$REMOTE_HOST" <<SFTP 2>/dev/null
lcd "$WORK_DIR"
get "$REMOTE_PATH"
SFTP
  then
    FILES=("$WORK_DIR/$(basename "$REMOTE_PATH")")
  else
    err "Could not pull $REMOTE_PATH"
    exit 1
  fi
else
  info "Pulling *.$EXTS from $REMOTE_PATH ..."
  SFTP_CMDS="lcd "$WORK_DIR"
cd "$REMOTE_PATH""
  IFS=',' read -ra EXT_LIST <<< "$EXTS"
  for ext in "${EXT_LIST[@]}"; do
    ext="${ext// /}"
    if [[ "$RECURSIVE" == true ]]; then
      SFTP_CMDS+="
find . -name "*.$ext" | while read f; do get "\$f"; done"
    else
      SFTP_CMDS+="
mget *.$ext"
    fi
  done
  sftp -q "$REMOTE_HOST" <<< "$(echo -e "$SFTP_CMDS")" || true

  for ext in "${EXT_LIST[@]}"; do
    ext="${ext// /}"
    while IFS= read -r f; do
      FILES+=("$f")
    done < <(find "$WORK_DIR" -name "*.$ext" -type f)
  done
fi

if [[ ${#FILES[@]} -eq 0 ]]; then
  err "No matching files found."
  exit 1
fi

echo ""
info "Found ${#FILES[@]} file(s): $(basename -a "${FILES[@]}" | tr '\n' ' ')"
echo ""

# ── Watch mode ────────────────────────────────────────────────────────────────
if [[ "$WATCH" == true ]]; then
  if [[ ${#FILES[@]} -gt 1 ]]; then
    warn "Watch mode with multiple files opens each editor sequentially, then watches all."
    warn "For best results, use -w with a single file target."
    echo ""
  fi

  for f in "${FILES[@]}"; do
    info "Opening $(basename "$f") for initial edit ..."
    "$EDITOR" "$f"
  done

  for f in "${FILES[@]}"; do
    watch_file "$f" "$REMOTE_PATH" &
  done
  wait
  exit 0
fi

# ── Normal (one-shot) mode ────────────────────────────────────────────────────
CHANGED=()
for f in "${FILES[@]}"; do
  MTIME_BEFORE="$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f")"
  "$EDITOR" "$f"
  MTIME_AFTER="$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f")"
  if [[ "$MTIME_BEFORE" != "$MTIME_AFTER" ]]; then
    CHANGED+=("$f")
  else
    skip "No changes to $(basename "$f"), skipping."
  fi
done

if [[ ${#CHANGED[@]} -gt 0 ]]; then
  echo ""
  info "Uploading ${#CHANGED[@]} file(s) ..."
  UPLOAD_CMDS="cd "$REMOTE_PATH""
  for f in "${CHANGED[@]}"; do
    upload "$(basename "$f")"
    UPLOAD_CMDS+="
put "$f""
  done
  sftp -q "$REMOTE_HOST" <<< "$(echo -e "$UPLOAD_CMDS")"
  echo ""
  ok "Done."
fi