Initial commit

This commit is contained in:
2026-02-21 16:44:57 +01:00
commit 1249b6c36c
8 changed files with 1212 additions and 0 deletions

88
CLAUDE.md Normal file
View File

@@ -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.<branch>` (per branch,
local git config). Avoids polluting branches/tags namespace.
- **Incoming commits** fetched to `refs/sync/incoming/<branch>` — 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.<branch>` | `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.<branch>`
Reset all checkpoints: `git config --remove-section sync.exported`
## Bundle Filename Convention
```
<repo-name>-from-<hostname>-<YYYYMMDD-HHMMSS>.bundle ← ongoing sync
<repo-name>-INIT-from-<hostname>-<YYYYMMDD-HHMMSS>.bundle ← initial bootstrap
```
Pull scripts filter by `<repo-name>` 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.<branch>` 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.

281
README.md Normal file
View File

@@ -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/`:
- `<repo>-INIT-from-<host>-<timestamp>.bundle` — complete git history
- `sync-push.sh`, `sync-pull.sh`, `sync-init-import.sh` — scripts for the other side
- `INIT-HOWTO-<repo>.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] [<repo-path>]
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 <remote-name>
```
---
## Configuration Reference
All settings are stored in the repo's local git config (`.git/config`).
| Key | Set by | Purpose |
|---|---|---|
| `sync.exported.<branch>` | `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
```
<repo-name>-from-<hostname>-<YYYYMMDD-HHMMSS>.bundle ← ongoing sync
<repo-name>-INIT-from-<hostname>-<YYYYMMDD-HHMMSS>.bundle ← initial bootstrap
```
The pull scripts filter by `<repo-name>` 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.<branch>` 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/<branch>` 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.<branch>` |
| 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 |

212
gitea-setup.sh Executable file
View File

@@ -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] [<repo-path>]
#
# 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"

117
sync-init-export.sh Executable file
View File

@@ -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 [<share-dir>]
# Default share dir: $SYNC_SHARE or ~/fifiletrans
#
# Places in the shared folder:
# <repo>-INIT-from-<host>-<timestamp>.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" <<EOF
Bootstrap instructions for: $REPO_NAME
Generated : $(date)
From host : $HOST
Bundle : $BUNDLE_FILE
On the receiving machine:
1. Make sure this folder ($(basename "$SHARE_DIR")) is accessible at the same path,
or adjust the path in the command below.
2. Run:
bash ${SHARE_DIR}/sync-init-import.sh ${SHARE_DIR} /path/to/new/repo
3. After the import completes, both machines are in sync.
Use sync-push.sh and sync-pull.sh for ongoing updates.
EOF
echo " Written : $(basename "$HOWTO")"
# ── Done ───────────────────────────────────────────────────────────────────────
echo ""
echo "Shared folder contents:"
ls -lh "$SHARE_DIR" | grep -E "(${REPO_NAME}|sync-)" | sed 's/^/ /'
echo ""
echo "On the other machine, run:"
echo " bash ${SHARE_DIR}/sync-init-import.sh ${SHARE_DIR} /path/to/new/$(basename "$REPO_ROOT")"

180
sync-init-import.sh Executable file
View File

@@ -0,0 +1,180 @@
#!/usr/bin/env bash
# sync-init-import.sh — Bootstrap a repository from scratch using an INIT bundle
# placed in the shared folder by sync-init-export.sh.
#
# Usage: ./sync-init-import.sh [<share-dir>] [<target-dir>]
#
# <share-dir> Shared folder containing the bundle (default: $SYNC_SHARE or ~/fifiletrans)
# <target-dir> Where to create the new repo (default: prompted interactively)
#
# After completion:
# - A fully functional git repo exists at <target-dir>
# - All branches and tags from the bundle are present
# - sync-push.sh and sync-pull.sh are copied into <target-dir>
# - 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: <repo>-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"

213
sync-pull.sh Executable file
View File

@@ -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 [<share-dir>]
# 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/<branch>, 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/<branch> ────────────────────────
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/<branch> until"
echo "the next sync-pull run. You can inspect them anytime with:"
echo " git log refs/sync/incoming/<branch>"

121
sync-push.sh Executable file
View File

@@ -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 [<share-dir>]
# 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.<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
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"

BIN
tea-0.12.0-linux-arm64 Executable file

Binary file not shown.