commit 1249b6c36c8aad8241ab97257fb6b68b66f38da8 Author: Lutz Finsterle Date: Sat Feb 21 16:44:57 2026 +0100 Initial commit diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..973babe --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,88 @@ +# CLAUDE.md — Project Context for AI Assistants + +## What This Project Is + +A set of shell scripts for bidirectional git repository synchronization between +two networks (home ↔ enterprise) that have **no direct network connection**. +The transport medium is a **shared file system**, mounted at `~/fifiletrans` on +both sides (path configurable via `$SYNC_SHARE` or as a CLI argument). + +## Network Context + +- **Home network**: has a **Gitea** instance (local git hosting + mirror/backup) +- **Office network**: has a **GitLab** instance +- There is **no direct sync** between Gitea and GitLab — bundle files via the + shared filesystem are the only transport between the two networks + +## Scripts + +| Script | Role | +|---|---| +| `sync-push.sh` | Incremental export: bundles new commits → shared folder | +| `sync-pull.sh` | Import: picks a bundle from shared folder, fetches branches, integrates | +| `sync-init-export.sh` | One-time bootstrap: full bundle + copies scripts → shared folder | +| `sync-init-import.sh` | One-time bootstrap: clones from full bundle into a new directory | +| `gitea-setup.sh` | Registers a local repo (git or unversioned) with the home Gitea instance | + +## Design Decisions + +- **`git bundle`** chosen over `git format-patch`: handles multi-branch and + arbitrary commit ranges in a single file; no dependency on branch topology. +- **Incremental tracking** via `git config sync.exported.` (per branch, + local git config). Avoids polluting branches/tags namespace. +- **Incoming commits** fetched to `refs/sync/incoming/` — not merged + automatically. User inspects and chooses ff / merge / rebase / skip. +- **Semi-automated**: scripts triggered manually; minimal prompts only where + a decision is needed (integration strategy, bundle selection). +- **Multi-branch**: all scripts iterate all local branches. Checkpoints are + independent per branch. +- **Gitea auto-push** is opt-in (`git config sync.gitea.autopush true`), toggled + per repo, and runs automatically at the end of `sync-push.sh` when enabled. +- **Initial bootstrap** (`sync-init-export/import`) handles the case where the + receiving machine has no directory at all — creates the repo from scratch and + leaves both sides in identical state with checkpoints pre-set. + +## Git Config Keys (all local, per repo) + +| Key | Written by | Purpose | +|---|---|---| +| `sync.exported.` | `sync-push.sh` | Last exported commit hash per branch | +| `sync.gitea.autopush` | `gitea-setup.sh` | `true`/`false` auto-push after bundle export | +| `sync.gitea.remote` | `gitea-setup.sh` | Gitea remote name (default: `gitea`) | + +Reset one branch checkpoint: `git config --unset sync.exported.` +Reset all checkpoints: `git config --remove-section sync.exported` + +## Bundle Filename Convention + +``` +-from--.bundle ← ongoing sync +-INIT-from--.bundle ← initial bootstrap +``` + +Pull scripts filter by `` so multiple repos can share one folder. + +## External Dependencies + +- `git` (standard, all features used are in git ≥ 2.x) +- `bash` ≥ 4 (for associative-array-free scripts; uses indexed arrays only) +- `tea` (Gitea CLI) — only required for `gitea-setup.sh` +- Standard POSIX tools: `find`, `awk`, `cut`, `du`, `hostname`, `date` + +## Platform + +Home side runs on **Linux / Raspberry Pi** (aarch64). Scripts use no +architecture-specific features and should work on any POSIX system. + +## When Modifying These Scripts + +- Keep `set -euo pipefail` — fail fast is intentional. +- The "nothing new to export" pre-check in `sync-push.sh` must run *before* + `git bundle create` — git errors on an empty bundle. +- `sync-pull.sh` must not auto-merge without user confirmation. +- `sync-init-import.sh` must set `sync.exported.` checkpoints for all + imported branches so the first `sync-push` from the new machine is incremental. +- `gitea-setup.sh` parses `tea login list --output csv`; column order is: + `NAME, URL, SSH-URL, USER, ACTIVE`. The active login has `true` in column 5. +- Test each script against both first-run (no checkpoints) and incremental scenarios. +- Test `gitea-setup.sh` against both: folder already a git repo, and raw unversioned folder. diff --git a/README.md b/README.md new file mode 100644 index 0000000..273e4a3 --- /dev/null +++ b/README.md @@ -0,0 +1,281 @@ +# Git Bundle Sync — Air-Gap Repository Synchronization + +Bidirectional git sync between a **home** and an **enterprise** network using a +shared file system. No direct network connection between the two networks is +required. + +## Problem & Solution + +Two git repositories on separate, non-routable networks need to stay in sync. +The only common ground is a shared file system (mounted at `~/fifiletrans` on +both sides by default). + +Solution: `git bundle` — a built-in git feature that packs any range of commits +into a single portable binary file. The bundle is written to the shared folder +by one side and picked up by the other. + +``` +Home repo ──── sync-push.sh ────► ~/fifiletrans/ ◄──── sync-pull.sh ──── Office repo +Home repo ◄─── sync-pull.sh ──── ~/fifiletrans/ ◄──── sync-push.sh ──── Office repo +``` + +**Network context:** +- Home network has a **Gitea** instance — used as a local mirror/backup +- Office network has a **GitLab** instance — no direct sync between the two is possible +- Bundle files via shared filesystem are the only transport between the two networks + +--- + +## Scripts Overview + +| Script | Purpose | +|---|---| +| `sync-push.sh` | Export new commits as a bundle into the shared folder | +| `sync-pull.sh` | Import a bundle from the shared folder and integrate it | +| `sync-init-export.sh` | **One-time**: create full bootstrap bundle + copy scripts for new machine | +| `sync-init-import.sh` | **One-time**: clone from bootstrap bundle into a brand-new directory | +| `gitea-setup.sh` | Initialize a local folder (git or not) and push it to Gitea | + +--- + +## Initial Setup of a New Machine + +Use this when setting up the repo on a machine for the first time (no existing +directory, no git history). + +### On the source machine + +```bash +cd /path/to/your/repo +./sync-init-export.sh +``` + +This places in `~/fifiletrans/`: +- `-INIT-from--.bundle` — complete git history +- `sync-push.sh`, `sync-pull.sh`, `sync-init-import.sh` — scripts for the other side +- `INIT-HOWTO-.txt` — printed instructions + +### On the receiving machine + +```bash +bash ~/fifiletrans/sync-init-import.sh ~/fifiletrans /path/to/new/repo +``` + +This will: +1. Clone the full bundle into `/path/to/new/repo` (directory must not exist) +2. Check out all branches +3. Install `sync-push.sh` and `sync-pull.sh` into the new repo +4. Set export checkpoints so the first `sync-push` is incremental + +After this, **both machines are in sync** and ready for the ongoing workflow. + +--- + +## Ongoing Sync Workflow + +### Send changes (on either machine) + +```bash +cd /path/to/your/repo +./sync-push.sh +``` + +Creates an incremental bundle (only commits since last push). First run after +init creates a full bundle. Output example: + +``` +Repository : myrepo +Host : homepc +Branches : main +Mode : incremental +New commits: 3 + main: 3 new commit(s) + a1b2c3d Add feature X + e4f5g6h Fix bug in Y + i7j8k9l Refactor Z + +Bundle created : /home/user/fifiletrans/myrepo-from-homepc-20260221-143022.bundle (12K) +``` + +### Receive changes (on the other machine) + +```bash +cd /path/to/your/repo +./sync-pull.sh +``` + +The script: +1. Lists all available bundles for this repo in the shared folder +2. Defaults to the newest one (press Enter to accept) +3. Verifies bundle integrity +4. Shows exactly which commits are incoming and whether branches have diverged +5. Offers **fast-forward**, **merge**, **rebase**, or **skip** per branch + +### Typical round-trip + +```bash +# ── At home, after finishing work ────────────────────────────────────────── +cd ~/projects/myrepo +git commit -m "..." +./sync-push.sh +# → writes ~/fifiletrans/myrepo-from-homepc-TIMESTAMP.bundle + +# ── At office, pick up home changes ──────────────────────────────────────── +cd ~/projects/myrepo +./sync-pull.sh +# → lists bundles, shows new commits, fast-forwards main + +# ── At office, continue working ──────────────────────────────────────────── +git commit -m "..." +./sync-push.sh +# → writes ~/fifiletrans/myrepo-from-officepc-TIMESTAMP.bundle + +# ── At home, pick up office changes ──────────────────────────────────────── +./sync-pull.sh +``` + +--- + +## Gitea Integration (Home Network) + +`gitea-setup.sh` registers a local repository with the home Gitea instance. +Works for both cases: +- **Folder already a git repo** — creates Gitea repo and pushes +- **Folder not yet versioned** — runs `git init`, stages all files, commits, then pushes + +### Prerequisites + +```bash +# Install tea (Gitea CLI) if not already present +# See https://gitea.com/gitea/tea/releases + +# Configure your Gitea login (once per machine) +tea login add +``` + +### Usage + +```bash +./gitea-setup.sh [options] [] + +Options: + -n, --name NAME Gitea repo name (default: directory name) + -d, --desc DESC Repository description + -o, --org ORG Create under organization + -p, --private Create as private repo + -l, --login LOGIN tea login profile (default: active login) + --auto-push Enable Gitea auto-push after sync-push.sh + --no-auto-push Disable Gitea auto-push (default) +``` + +### Example + +```bash +# New, unversioned project +./gitea-setup.sh --name my-project --private ~/projects/my-project + +# Existing git repo, enable auto-push to Gitea +cd ~/projects/existing-repo +./gitea-setup.sh --auto-push +``` + +### Gitea Auto-Push + +When enabled, every `sync-push.sh` run also pushes all branches and tags to the +Gitea remote automatically. + +```bash +# Enable +git config sync.gitea.autopush true + +# Disable +git config sync.gitea.autopush false + +# Check status +git config sync.gitea.autopush +``` + +The Gitea remote name defaults to `gitea`. Set a different one with: +```bash +git config sync.gitea.remote +``` + +--- + +## Configuration Reference + +All settings are stored in the repo's local git config (`.git/config`). + +| Key | Set by | Purpose | +|---|---|---| +| `sync.exported.` | `sync-push.sh` | Last exported commit hash per branch | +| `sync.gitea.autopush` | `gitea-setup.sh` | `true`/`false` — auto-push to Gitea after bundle export | +| `sync.gitea.remote` | `gitea-setup.sh` | Name of the Gitea git remote (default: `gitea`) | + +--- + +## Bundle Filename Convention + +``` +-from--.bundle ← ongoing sync +-INIT-from--.bundle ← initial bootstrap +``` + +The pull scripts filter by `` automatically, so multiple repos can +safely share the same folder. + +--- + +## Multiple Branches + +All scripts handle multiple branches automatically: + +- `sync-push.sh` / `sync-init-export.sh` — export all local branches; incremental + checkpoint is tracked independently per branch +- `sync-pull.sh` / `sync-init-import.sh` — import all branches from the bundle + and prompt for integration of each one individually + +--- + +## Important Notes + +**Apply incremental bundles in order.** +An incremental bundle depends on commits already present in the receiving repo. +If you skip a bundle, the next pull will fail verification. Apply bundles in +chronological order (oldest timestamp first) when catching up after a gap. + +**Checkpoints are local and per-machine.** +`git config sync.exported.` tracks what *this* machine has sent. It is +not shared and not affected by imports. + +**Incoming refs persist.** +Fetched commits are stored under `refs/sync/incoming/` and remain +available between pull runs. Inspect them anytime: + +```bash +git log refs/sync/incoming/main +git diff main refs/sync/incoming/main +``` + +**Conflict resolution.** +When branches have diverged and you choose merge or rebase, the script hands +off to standard git. Resolve conflicts normally, then: +```bash +git rebase --continue # or +git merge --continue +``` + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| "Bundle verification failed" | Incremental bundle missing prerequisites | Apply missing earlier bundles first, or force full re-export (see below) | +| "Nothing new to export" | No commits since last push on any branch | Nothing to do | +| No bundles listed by sync-pull | Bundle filename mismatch (repo dir name differs) | Check repo directory name matches on both machines | +| Gitea push fails | Remote URL wrong or auth issue | `git remote -v` and `tea login list` to verify | +| `tea: command not found` | Gitea CLI not installed | Install from https://gitea.com/gitea/tea/releases | +| Want to reset one branch checkpoint | — | `git config --unset sync.exported.` | +| Want to force a full re-export | — | `git config --remove-section sync.exported` | +| Skipped integration, want to redo | — | Run `./sync-pull.sh` again and pick the same bundle | diff --git a/gitea-setup.sh b/gitea-setup.sh new file mode 100755 index 0000000..5e6c8a3 --- /dev/null +++ b/gitea-setup.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +# gitea-setup.sh — Initialize a local folder as a git repo and push to Gitea. +# +# Handles two cases: +# A) Folder is already a git repo → just create Gitea repo and push +# B) Folder is not yet a git repo → git init, stage all, commit, then push +# +# Usage: +# ./gitea-setup.sh [options] [] +# +# Options: +# -n, --name NAME Gitea repo name (default: directory name) +# -d, --desc DESC Repository description (default: empty) +# -o, --org ORG Create under organization instead of personal account +# -p, --private Create as private repo +# -l, --login LOGIN tea login profile (default: active login) +# --auto-push Enable Gitea auto-push after sync-push.sh +# --no-auto-push Disable Gitea auto-push (default) +# -h, --help Show this help +# +# Prerequisites: +# - Gitea CLI (tea) installed and at least one login configured +# (run 'tea login add' if not done yet) + +set -euo pipefail + +# ── Defaults ────────────────────────────────────────────────────────────────── +REPO_PATH="." +REPO_NAME="" +DESCRIPTION="" +ORG="" +PRIVATE=false +TEA_LOGIN_FLAG="" +AUTO_PUSH=false +AUTO_PUSH_SET=false + +# ── Argument parsing ────────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case "$1" in + -n|--name) REPO_NAME="$2"; shift 2 ;; + -d|--desc) DESCRIPTION="$2"; shift 2 ;; + -o|--org) ORG="$2"; shift 2 ;; + -p|--private) PRIVATE=true; shift ;; + -l|--login) TEA_LOGIN_FLAG="--login $2"; shift 2 ;; + --auto-push) AUTO_PUSH=true; AUTO_PUSH_SET=true; shift ;; + --no-auto-push) AUTO_PUSH=false; AUTO_PUSH_SET=true; shift ;; + -h|--help) + sed -n '2,/^set /p' "$0" | grep '^#' | sed 's/^# \{0,1\}//' + exit 0 ;; + -*) echo "Unknown option: $1" >&2; exit 1 ;; + *) REPO_PATH="$1"; shift ;; + esac +done + +# ── Sanity checks ───────────────────────────────────────────────────────────── +if ! command -v tea &>/dev/null; then + echo "ERROR: 'tea' (Gitea CLI) is not installed or not in PATH." >&2 + echo " Install it from https://gitea.com/gitea/tea/releases" >&2 + exit 1 +fi + +if ! tea login list &>/dev/null || [ "$(tea login list --output csv 2>/dev/null | wc -l)" -lt 2 ]; then + echo "ERROR: No Gitea login configured. Run: tea login add" >&2 + exit 1 +fi + +# Resolve absolute path (directory may not exist yet — only warn) +REPO_PATH="$(realpath -m "$REPO_PATH")" +if [ ! -d "$REPO_PATH" ]; then + echo "Directory does not exist, creating: $REPO_PATH" + mkdir -p "$REPO_PATH" +fi + +[ -z "$REPO_NAME" ] && REPO_NAME="$(basename "$REPO_PATH")" + +# ── Detect active tea login info ────────────────────────────────────────────── +# CSV columns: NAME,URL,SSH-URL,USER,ACTIVE +ACTIVE_LOGIN_LINE=$(tea login list --output csv 2>/dev/null \ + | awk -F',' 'NR>1 && $5=="true" {print; exit}') + +if [ -z "$ACTIVE_LOGIN_LINE" ]; then + # Fallback: take first non-header line + ACTIVE_LOGIN_LINE=$(tea login list --output csv 2>/dev/null \ + | awk -F',' 'NR==2 {print; exit}') +fi + +if [ -z "$ACTIVE_LOGIN_LINE" ]; then + echo "ERROR: Could not determine active Gitea login. Run: tea login list" >&2 + exit 1 +fi + +GITEA_URL=$(echo "$ACTIVE_LOGIN_LINE" | cut -d',' -f2 | tr -d '"' | sed 's|/$||') +GITEA_USER=$(echo "$ACTIVE_LOGIN_LINE" | cut -d',' -f4 | tr -d '"') + +echo "Gitea instance : $GITEA_URL" +echo "Gitea user : $GITEA_USER" +echo "Repo name : $REPO_NAME" +echo "Path : $REPO_PATH" +[ -n "$ORG" ] && echo "Organization : $ORG" +$PRIVATE && echo "Visibility : private" || echo "Visibility : public" +echo "" + +# ── Step 1: Ensure it is a git repo ─────────────────────────────────────────── +cd "$REPO_PATH" + +if git rev-parse --git-dir &>/dev/null; then + echo "[1/4] Already a git repository — skipping init." + IS_NEW_REPO=false +else + echo "[1/4] Initializing git repository..." + git init + git checkout -b main 2>/dev/null || true + + # Stage everything + git add . + STAGED=$(git diff --cached --name-only | wc -l) + if [ "$STAGED" -gt 0 ]; then + echo " Staging $STAGED file(s)..." + read -rp " Commit message [Initial commit]: " MSG + MSG="${MSG:-Initial commit}" + git commit -m "$MSG" + else + echo " Directory is empty — creating an empty initial commit." + git commit --allow-empty -m "Initial commit" + fi + IS_NEW_REPO=true +fi + +# ── Step 2: Create repo on Gitea ─────────────────────────────────────────────── +echo "" +echo "[2/4] Creating Gitea repository '$REPO_NAME'..." + +PRIVATE_FLAG="" +$PRIVATE && PRIVATE_FLAG="--private" + +ORG_FLAG="" +[ -n "$ORG" ] && ORG_FLAG="--owner $ORG" + +# shellcheck disable=SC2086 +if ! tea repo create \ + --name "$REPO_NAME" \ + --description "$DESCRIPTION" \ + $PRIVATE_FLAG \ + $ORG_FLAG \ + $TEA_LOGIN_FLAG; then + echo "" + echo "ERROR: Failed to create Gitea repository." >&2 + echo " The repo may already exist. To push to an existing repo," >&2 + echo " add the remote manually and push:" >&2 + if [ -n "$ORG" ]; then + echo " git remote add gitea ${GITEA_URL}/${ORG}/${REPO_NAME}.git" >&2 + else + echo " git remote add gitea ${GITEA_URL}/${GITEA_USER}/${REPO_NAME}.git" >&2 + fi + exit 1 +fi + +# Construct remote URL +if [ -n "$ORG" ]; then + REMOTE_URL="${GITEA_URL}/${ORG}/${REPO_NAME}.git" +else + REMOTE_URL="${GITEA_URL}/${GITEA_USER}/${REPO_NAME}.git" +fi + +# ── Step 3: Add remote and push ──────────────────────────────────────────────── +echo "" +echo "[3/4] Adding remote 'gitea' → $REMOTE_URL" + +if git remote get-url gitea &>/dev/null; then + echo " Remote 'gitea' already exists — updating URL." + git remote set-url gitea "$REMOTE_URL" +else + git remote add gitea "$REMOTE_URL" +fi + +echo " Pushing all branches and tags..." +git push gitea --all +git push gitea --tags + +# ── Step 4: Configure auto-push ─────────────────────────────────────────────── +echo "" +echo "[4/4] Configuring sync settings..." +git config sync.gitea.remote "gitea" + +if $AUTO_PUSH_SET; then + git config sync.gitea.autopush "$AUTO_PUSH" + $AUTO_PUSH \ + && echo " Auto-push to Gitea after sync-push.sh: ENABLED" \ + || echo " Auto-push to Gitea after sync-push.sh: DISABLED" +else + # Ask interactively + read -rp " Enable automatic Gitea push after sync-push.sh? [y/N] " ans + ans="${ans:-N}" + if [[ "$ans" =~ ^[Yy] ]]; then + git config sync.gitea.autopush "true" + echo " Auto-push enabled. Toggle later with:" + echo " git config sync.gitea.autopush false" + else + git config sync.gitea.autopush "false" + echo " Auto-push disabled. Enable later with:" + echo " git config sync.gitea.autopush true" + fi +fi + +# ── Done ─────────────────────────────────────────────────────────────────────── +echo "" +echo "Done. Repository is live at:" +echo " $REMOTE_URL" +echo "" +echo "Next steps:" +echo " - Run sync-push.sh to export commits to the shared folder" +echo " - Run sync-init-export.sh to prepare a bootstrap bundle for the other machine" diff --git a/sync-init-export.sh b/sync-init-export.sh new file mode 100755 index 0000000..3bd03cd --- /dev/null +++ b/sync-init-export.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# sync-init-export.sh — Create a full bootstrap bundle so the other machine can +# clone the repo from scratch via the shared folder. +# +# Usage: ./sync-init-export.sh [] +# Default share dir: $SYNC_SHARE or ~/fifiletrans +# +# Places in the shared folder: +# -INIT-from--.bundle complete git history, all branches +# sync-push.sh (copy of this repo's sync scripts) +# sync-pull.sh +# sync-init-import.sh +# +# The receiving side runs sync-init-import.sh from the shared folder. +# After that, both sides are identical and ready to use sync-push / sync-pull. + +set -euo pipefail + +# ── Config ──────────────────────────────────────────────────────────────────── +SHARE_DIR="${1:-${SYNC_SHARE:-$HOME/fifiletrans}}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ── Sanity checks ───────────────────────────────────────────────────────────── +if ! git rev-parse --git-dir &>/dev/null; then + echo "ERROR: Not inside a git repository." >&2 + exit 1 +fi + +REPO_ROOT=$(git rev-parse --show-toplevel) +REPO_NAME=$(basename "$REPO_ROOT") +HOST=$(hostname -s) +TIMESTAMP=$(date +%Y%m%d-%H%M%S) + +BUNDLE_FILE="${REPO_NAME}-INIT-from-${HOST}-${TIMESTAMP}.bundle" +BUNDLE_PATH="${SHARE_DIR}/${BUNDLE_FILE}" + +mkdir -p "$SHARE_DIR" + +# ── Warn if an INIT bundle already exists for this repo ─────────────────────── +EXISTING=$(find "$SHARE_DIR" -maxdepth 1 -name "${REPO_NAME}-INIT-from-*.bundle" 2>/dev/null | sort) +if [ -n "$EXISTING" ]; then + echo "Warning: existing INIT bundle(s) found in $SHARE_DIR:" + echo "$EXISTING" | sed 's/^/ /' + echo "" + read -rp "Continue and create a new one? [y/N] " ans + ans="${ans:-N}" + [[ "$ans" =~ ^[Yy] ]] || exit 0 +fi + +# ── Show what will be bundled ────────────────────────────────────────────────── +echo "Repository : $REPO_NAME" +echo "Host : $HOST" +echo "Mode : FULL (all branches, complete history)" +echo "" + +BRANCHES=() +while IFS= read -r b; do + BRANCHES+=("$b") +done < <(git branch --format='%(refname:short)') + +for branch in "${BRANCHES[@]}"; do + count=$(git log --oneline "$branch" | wc -l) + tip=$(git log -1 --format="%h %s" "$branch") + echo " $branch : $count commit(s) [tip: $tip]" +done +echo "" + +# ── Create the full bundle ──────────────────────────────────────────────────── +echo "Creating bundle..." +git bundle create "$BUNDLE_PATH" --all + +SIZE=$(du -sh "$BUNDLE_PATH" | cut -f1) +echo "Bundle : $BUNDLE_PATH ($SIZE)" + +# ── Copy sync scripts into the shared folder ────────────────────────────────── +echo "" +echo "Copying sync scripts to shared folder..." + +SCRIPTS=("sync-push.sh" "sync-pull.sh" "sync-init-import.sh") +for script in "${SCRIPTS[@]}"; do + src="${SCRIPT_DIR}/${script}" + if [ -f "$src" ]; then + cp "$src" "${SHARE_DIR}/${script}" + chmod +x "${SHARE_DIR}/${script}" + echo " Copied : $script" + else + echo " WARNING: $script not found at $src — skipping." >&2 + fi +done + +# ── Write a HOWTO marker file ───────────────────────────────────────────────── +HOWTO="${SHARE_DIR}/INIT-HOWTO-${REPO_NAME}.txt" +cat > "$HOWTO" <] [] +# +# Shared folder containing the bundle (default: $SYNC_SHARE or ~/fifiletrans) +# Where to create the new repo (default: prompted interactively) +# +# After completion: +# - A fully functional git repo exists at +# - All branches and tags from the bundle are present +# - sync-push.sh and sync-pull.sh are copied into +# - Export checkpoints are set so the first sync-push is incremental +# - Both machines are now in sync + +set -euo pipefail + +# ── Config ──────────────────────────────────────────────────────────────────── +SHARE_DIR="${1:-${SYNC_SHARE:-$HOME/fifiletrans}}" +TARGET_DIR="${2:-}" + +# ── Sanity checks ───────────────────────────────────────────────────────────── +if [ ! -d "$SHARE_DIR" ]; then + echo "ERROR: Share directory not found: $SHARE_DIR" >&2 + exit 1 +fi + +# ── Find INIT bundles ───────────────────────────────────────────────────────── +BUNDLES=() +while IFS= read -r f; do + BUNDLES+=("$f") +done < <(find "$SHARE_DIR" -maxdepth 1 -name "*-INIT-from-*.bundle" | sort) + +if [ ${#BUNDLES[@]} -eq 0 ]; then + echo "ERROR: No INIT bundle found in $SHARE_DIR" >&2 + echo " (looking for: *-INIT-from-*.bundle)" >&2 + echo " Run sync-init-export.sh on the source machine first." >&2 + exit 1 +fi + +# ── Let user pick a bundle ───────────────────────────────────────────────────── +echo "Available INIT bundles:" +echo "" +for i in "${!BUNDLES[@]}"; do + b="${BUNDLES[$i]}" + size=$(du -sh "$b" | cut -f1) + name=$(basename "$b") + echo " [$((i+1))] $name ($size)" +done +echo "" + +DEFAULT=${#BUNDLES[@]} +read -rp "Pick bundle [1-${#BUNDLES[@]}, default $DEFAULT]: " CHOICE +CHOICE="${CHOICE:-$DEFAULT}" + +if ! [[ "$CHOICE" =~ ^[0-9]+$ ]] || [ "$CHOICE" -lt 1 ] || [ "$CHOICE" -gt "${#BUNDLES[@]}" ]; then + echo "Invalid choice." >&2 + exit 1 +fi + +BUNDLE_PATH="${BUNDLES[$((CHOICE-1))]}" +BUNDLE_NAME=$(basename "$BUNDLE_PATH") + +# Derive a default target dir name from bundle name: -INIT-from-... +REPO_NAME=$(echo "$BUNDLE_NAME" | sed 's/-INIT-from-.*//') + +echo "" +echo "Using bundle : $BUNDLE_NAME" + +# ── Determine target directory ───────────────────────────────────────────────── +if [ -z "$TARGET_DIR" ]; then + read -rp "Target directory [$HOME/$REPO_NAME]: " TARGET_DIR + TARGET_DIR="${TARGET_DIR:-$HOME/$REPO_NAME}" +fi + +TARGET_DIR="$(realpath -m "$TARGET_DIR")" +echo "Target dir : $TARGET_DIR" +echo "" + +if [ -e "$TARGET_DIR" ]; then + echo "ERROR: Target already exists: $TARGET_DIR" >&2 + echo " Remove it first or choose a different path." >&2 + exit 1 +fi + +# ── Verify the bundle ────────────────────────────────────────────────────────── +echo "Verifying bundle..." +# INIT bundles are full — they have no prerequisites, so verify should always pass +git bundle verify "$BUNDLE_PATH" +echo "" + +# ── List branches in bundle ──────────────────────────────────────────────────── +echo "Branches in bundle:" +BUNDLE_BRANCHES=() +DEFAULT_BRANCH="" +while IFS= read -r line; do + ref=$(echo "$line" | awk '{print $2}') + if [[ "$ref" == refs/heads/* ]]; then + branch="${ref#refs/heads/}" + BUNDLE_BRANCHES+=("$branch") + echo " $branch" + # Prefer 'main', then 'master' as default checkout + if [ -z "$DEFAULT_BRANCH" ] || [ "$branch" = "main" ] || \ + { [ "$DEFAULT_BRANCH" != "main" ] && [ "$branch" = "master" ]; }; then + DEFAULT_BRANCH="$branch" + fi + fi +done < <(git bundle list-heads "$BUNDLE_PATH") + +if [ ${#BUNDLE_BRANCHES[@]} -eq 0 ]; then + echo "ERROR: No branches found in bundle." >&2 + exit 1 +fi + +echo "" +echo "Will check out: $DEFAULT_BRANCH" +echo "" + +# ── Create and populate the repository ─────────────────────────────────────── +echo "[1/4] Initializing repository at $TARGET_DIR..." +mkdir -p "$TARGET_DIR" +cd "$TARGET_DIR" +git init +git checkout -b "$DEFAULT_BRANCH" 2>/dev/null || true + +echo "" +echo "[2/4] Fetching all branches and tags from bundle..." +git fetch "$BUNDLE_PATH" 'refs/heads/*:refs/heads/*' 'refs/tags/*:refs/tags/*' + +# Check out the default branch (fetch leaves us detached or on empty branch) +git checkout "$DEFAULT_BRANCH" + +# Report what we got +echo "" +echo " Branches:" +git branch | sed 's/^/ /' +echo " Tags:" +git tag | sed 's/^/ /' || echo " (none)" + +# ── Copy sync scripts into the new repo ─────────────────────────────────────── +echo "" +echo "[3/4] Installing sync scripts..." + +SCRIPTS=("sync-push.sh" "sync-pull.sh") +for script in "${SCRIPTS[@]}"; do + src="${SHARE_DIR}/${script}" + if [ -f "$src" ]; then + cp "$src" "${TARGET_DIR}/${script}" + chmod +x "${TARGET_DIR}/${script}" + echo " Installed : $script" + else + echo " WARNING: $script not found in $SHARE_DIR — copy it manually." >&2 + fi +done + +# ── Set export checkpoints so first sync-push is incremental ────────────────── +echo "" +echo "[4/4] Setting sync checkpoints..." +for branch in "${BUNDLE_BRANCHES[@]}"; do + tip=$(git rev-parse "$branch") + git config "sync.exported.${branch}" "$tip" + echo " sync.exported.$branch = ${tip:0:8}..." +done + +# ── Done ─────────────────────────────────────────────────────────────────────── +echo "" +echo "══════════════════════════════════════════════════════" +echo " Repository ready at: $TARGET_DIR" +echo "══════════════════════════════════════════════════════" +echo "" +echo "Both machines are now in sync." +echo "" +echo "Ongoing workflow:" +echo " cd $TARGET_DIR" +echo " ./sync-push.sh # after committing — export changes" +echo " ./sync-pull.sh # to receive changes from the other machine" +echo "" +echo "Tip: verify everything looks correct:" +echo " cd $TARGET_DIR && git log --oneline --graph --all" diff --git a/sync-pull.sh b/sync-pull.sh new file mode 100755 index 0000000..5713239 --- /dev/null +++ b/sync-pull.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +# sync-pull.sh — Import a git bundle from the shared folder into the current repo. +# +# Usage: ./sync-pull.sh [] +# Default share dir: $SYNC_SHARE or ~/fifiletrans +# +# The script lists available bundles for this repo, lets you choose one, +# fetches all branches from it into refs/sync/incoming/, shows +# what's new, and offers to merge or rebase each branch. + +set -euo pipefail + +# ── Config ──────────────────────────────────────────────────────────────────── +SHARE_DIR="${1:-${SYNC_SHARE:-$HOME/fifiletrans}}" + +# ── Sanity checks ───────────────────────────────────────────────────────────── +if ! git rev-parse --git-dir &>/dev/null; then + echo "ERROR: Not inside a git repository." >&2 + exit 1 +fi + +if [ ! -d "$SHARE_DIR" ]; then + echo "ERROR: Share directory not found: $SHARE_DIR" >&2 + exit 1 +fi + +REPO_ROOT=$(git rev-parse --show-toplevel) +REPO_NAME=$(basename "$REPO_ROOT") + +# ── Find bundles for this repo ──────────────────────────────────────────────── +BUNDLES=() +while IFS= read -r f; do + BUNDLES+=("$f") +done < <(find "$SHARE_DIR" -maxdepth 1 -name "${REPO_NAME}-from-*.bundle" | sort) + +if [ ${#BUNDLES[@]} -eq 0 ]; then + echo "No bundles found for repo '$REPO_NAME' in $SHARE_DIR" + echo " (looking for: ${REPO_NAME}-from-*.bundle)" + exit 0 +fi + +# ── Let user pick a bundle ───────────────────────────────────────────────────── +echo "Available bundles for '$REPO_NAME':" +echo "" +for i in "${!BUNDLES[@]}"; do + b="${BUNDLES[$i]}" + size=$(du -sh "$b" | cut -f1) + name=$(basename "$b") + echo " [$((i+1))] $name ($size)" +done +echo "" + +# Default: newest bundle (last in sorted list) +DEFAULT=${#BUNDLES[@]} +read -rp "Pick bundle [1-${#BUNDLES[@]}, default $DEFAULT]: " CHOICE +CHOICE="${CHOICE:-$DEFAULT}" + +if ! [[ "$CHOICE" =~ ^[0-9]+$ ]] || [ "$CHOICE" -lt 1 ] || [ "$CHOICE" -gt "${#BUNDLES[@]}" ]; then + echo "Invalid choice." >&2 + exit 1 +fi + +BUNDLE_PATH="${BUNDLES[$((CHOICE-1))]}" +BUNDLE_NAME=$(basename "$BUNDLE_PATH") +echo "" +echo "Using: $BUNDLE_NAME" + +# ── Verify the bundle ────────────────────────────────────────────────────────── +echo "" +echo "Verifying bundle..." +if ! git bundle verify "$BUNDLE_PATH"; then + echo "" + echo "ERROR: Bundle verification failed." >&2 + echo "This usually means the bundle is incremental and your repo is missing" >&2 + echo "the prerequisite commits. Make sure you have applied earlier bundles first." >&2 + exit 1 +fi + +# ── List refs in bundle ──────────────────────────────────────────────────────── +echo "" +echo "Branches in bundle:" +BUNDLE_REFS=() +while IFS= read -r line; do + ref=$(echo "$line" | awk '{print $2}') + BUNDLE_REFS+=("$ref") + branch="${ref#refs/heads/}" + echo " $branch" +done < <(git bundle list-heads "$BUNDLE_PATH" | grep 'refs/heads/') + +if [ ${#BUNDLE_REFS[@]} -eq 0 ]; then + echo "No branch refs found in bundle." >&2 + exit 1 +fi + +# ── Fetch each branch into refs/sync/incoming/ ──────────────────────── +echo "" +echo "Fetching..." +INCOMING_BRANCHES=() +for ref in "${BUNDLE_REFS[@]}"; do + branch="${ref#refs/heads/}" + incoming_ref="refs/sync/incoming/${branch}" + git fetch "$BUNDLE_PATH" "${ref}:${incoming_ref}" + INCOMING_BRANCHES+=("$branch") + echo " Fetched: $branch -> $incoming_ref" +done + +# ── Show what's new per branch ──────────────────────────────────────────────── +echo "" +echo "── New commits ──────────────────────────────────────────────────────────" +for branch in "${INCOMING_BRANCHES[@]}"; do + incoming_ref="refs/sync/incoming/${branch}" + if git rev-parse --verify "$branch" &>/dev/null; then + # Branch exists locally + ahead=$(git log --oneline "${branch}..${incoming_ref}" | wc -l) + behind=$(git log --oneline "${incoming_ref}..${branch}" | wc -l) + if [ "$ahead" -eq 0 ]; then + echo " $branch: already up to date" + continue + fi + echo " $branch: $ahead new commit(s) incoming, $behind local-only commit(s)" + git log --oneline "${branch}..${incoming_ref}" | sed 's/^/ + /' + if [ "$behind" -gt 0 ]; then + echo " (your local commits not in bundle:)" + git log --oneline "${incoming_ref}..${branch}" | sed 's/^/ * /' + fi + else + n=$(git log --oneline "${incoming_ref}" | wc -l) + echo " $branch: new branch with $n commit(s)" + git log --oneline "${incoming_ref}" | head -10 | sed 's/^/ + /' + fi +done +echo "" + +# ── Integrate each branch ───────────────────────────────────────────────────── +CURRENT_BRANCH=$(git branch --show-current) + +for branch in "${INCOMING_BRANCHES[@]}"; do + incoming_ref="refs/sync/incoming/${branch}" + + if ! git rev-parse --verify "$branch" &>/dev/null; then + # New branch — just create it + read -rp "Create new local branch '$branch' from bundle? [Y/n] " ans + ans="${ans:-Y}" + if [[ "$ans" =~ ^[Yy] ]]; then + git branch "$branch" "$incoming_ref" + echo " Created branch '$branch'" + fi + continue + fi + + ahead=$(git log --oneline "${branch}..${incoming_ref}" | wc -l) + if [ "$ahead" -eq 0 ]; then + continue # Nothing to do + fi + + behind=$(git log --oneline "${incoming_ref}..${branch}" | wc -l) + + echo "── Integrating: $branch ─────────────────────────────────────────────" + + if [ "$behind" -eq 0 ]; then + # Fast-forward possible + echo " Fast-forward possible." + read -rp " Fast-forward '$branch'? [Y/n] " ans + ans="${ans:-Y}" + if [[ "$ans" =~ ^[Yy] ]]; then + # Switch to branch if needed + if [ "$CURRENT_BRANCH" != "$branch" ]; then + git checkout "$branch" + CURRENT_BRANCH="$branch" + fi + git merge --ff-only "$incoming_ref" + echo " Done." + fi + else + # Diverged — offer merge or rebase + echo " Branches have diverged (you have $behind local commit(s))." + echo " Options:" + echo " [m] merge — creates a merge commit" + echo " [r] rebase — replays your commits on top of incoming" + echo " [s] skip — leave for manual handling" + read -rp " Choice [m/r/s, default s]: " ans + ans="${ans:-s}" + case "$ans" in + m|M) + if [ "$CURRENT_BRANCH" != "$branch" ]; then + git checkout "$branch" + CURRENT_BRANCH="$branch" + fi + git merge "$incoming_ref" --no-edit + echo " Merged." + ;; + r|R) + if [ "$CURRENT_BRANCH" != "$branch" ]; then + git checkout "$branch" + CURRENT_BRANCH="$branch" + fi + git rebase "$incoming_ref" + echo " Rebased." + ;; + *) + echo " Skipped. Run manually:" + echo " git checkout $branch && git merge refs/sync/incoming/$branch" + ;; + esac + fi +done + +echo "" +echo "Import complete." +echo "" +echo "Tip: The fetched commits remain in refs/sync/incoming/ until" +echo "the next sync-pull run. You can inspect them anytime with:" +echo " git log refs/sync/incoming/" diff --git a/sync-push.sh b/sync-push.sh new file mode 100755 index 0000000..898749f --- /dev/null +++ b/sync-push.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# sync-push.sh — Export new commits as a git bundle to the shared folder. +# +# Usage: ./sync-push.sh [] +# Default share dir: $SYNC_SHARE or ~/fifiletrans +# +# On first run: creates a full bundle of all branches. +# On subsequent runs: creates an incremental bundle (only new commits). +# Checkpoint per branch is stored in git config: sync.exported. + +set -euo pipefail + +# ── Config ──────────────────────────────────────────────────────────────────── +SHARE_DIR="${1:-${SYNC_SHARE:-$HOME/fifiletrans}}" + +# ── Sanity checks ───────────────────────────────────────────────────────────── +if ! git rev-parse --git-dir &>/dev/null; then + echo "ERROR: Not inside a git repository." >&2 + exit 1 +fi + +REPO_ROOT=$(git rev-parse --show-toplevel) +REPO_NAME=$(basename "$REPO_ROOT") +HOST=$(hostname -s) +TIMESTAMP=$(date +%Y%m%d-%H%M%S) +BUNDLE_FILE="${REPO_NAME}-from-${HOST}-${TIMESTAMP}.bundle" +BUNDLE_PATH="${SHARE_DIR}/${BUNDLE_FILE}" + +mkdir -p "$SHARE_DIR" + +# ── Collect branches ─────────────────────────────────────────────────────────── +BRANCHES=() +while IFS= read -r branch; do + BRANCHES+=("$branch") +done < <(git branch --format='%(refname:short)') + +if [ ${#BRANCHES[@]} -eq 0 ]; then + echo "ERROR: No local branches found." >&2 + exit 1 +fi + +# ── Check if there is anything new to export ─────────────────────────────────── +has_new=false +for branch in "${BRANCHES[@]}"; do + checkpoint=$(git config "sync.exported.${branch}" 2>/dev/null || true) + branch_tip=$(git rev-parse "$branch") + if [ -z "$checkpoint" ] || ! git rev-parse --verify "$checkpoint" &>/dev/null || [ "$branch_tip" != "$checkpoint" ]; then + has_new=true + break + fi +done + +if ! $has_new; then + echo "Nothing new to export — all branches match the last export checkpoint." + exit 0 +fi + +# ── Build rev-list args ──────────────────────────────────────────────────────── +# Include all branches; exclude commits already exported (per branch checkpoint) +EXCLUSIONS=() +for branch in "${BRANCHES[@]}"; do + checkpoint=$(git config "sync.exported.${branch}" 2>/dev/null || true) + if [ -n "$checkpoint" ] && git rev-parse --verify "$checkpoint" &>/dev/null; then + EXCLUSIONS+=("^${checkpoint}") + fi +done + +# ── Report ───────────────────────────────────────────────────────────────────── +echo "Repository : $REPO_NAME" +echo "Host : $HOST" +echo "Branches : ${BRANCHES[*]}" + +if [ ${#EXCLUSIONS[@]} -eq 0 ]; then + echo "Mode : full (first export)" + NEW_COUNT=$(git log --oneline --all | wc -l) +else + echo "Mode : incremental" + NEW_COUNT=$(git log --oneline "${BRANCHES[@]}" "${EXCLUSIONS[@]}" | wc -l) +fi +echo "New commits: $NEW_COUNT" + +# ── Show what will be exported ───────────────────────────────────────────────── +for branch in "${BRANCHES[@]}"; do + checkpoint=$(git config "sync.exported.${branch}" 2>/dev/null || true) + if [ -z "$checkpoint" ] || ! git rev-parse --verify "$checkpoint" &>/dev/null; then + n=$(git log --oneline "$branch" | wc -l) + else + n=$(git log --oneline "${checkpoint}..${branch}" | wc -l) + fi + if [ "$n" -gt 0 ]; then + echo " $branch: $n new commit(s)" + git log --oneline "${checkpoint:-}${checkpoint:+..}${branch}" | sed 's/^/ /' + fi +done +echo "" + +# ── Create bundle ────────────────────────────────────────────────────────────── +git bundle create "$BUNDLE_PATH" "${BRANCHES[@]}" "${EXCLUSIONS[@]}" + +# ── Update checkpoints ───────────────────────────────────────────────────────── +for branch in "${BRANCHES[@]}"; do + tip=$(git rev-parse "$branch") + git config "sync.exported.${branch}" "$tip" +done + +# ── Done ─────────────────────────────────────────────────────────────────────── +SIZE=$(du -sh "$BUNDLE_PATH" | cut -f1) +echo "Bundle created : $BUNDLE_PATH ($SIZE)" + +# ── Optional Gitea auto-push ─────────────────────────────────────────────────── +if [ "$(git config sync.gitea.autopush 2>/dev/null)" = "true" ]; then + GITEA_REMOTE=$(git config sync.gitea.remote 2>/dev/null || echo "gitea") + echo "" + echo "Auto-pushing to Gitea remote '$GITEA_REMOTE'..." + git push "$GITEA_REMOTE" --all --tags + echo "Gitea push done." +fi + +echo "" +echo "Next step on the other machine:" +echo " ./sync-pull.sh $SHARE_DIR" diff --git a/tea-0.12.0-linux-arm64 b/tea-0.12.0-linux-arm64 new file mode 100755 index 0000000..51a1814 Binary files /dev/null and b/tea-0.12.0-linux-arm64 differ