Last active
December 29, 2025 18:40
-
-
Save hrstoyanov/d8abc366600fd1670e9b56d84dd4243f to your computer and use it in GitHub Desktop.
wflow.sh: Multiple ai code agents setup and workflow
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| ################################################################################ | |
| # WFLOW - Air-Gapped Git Workflow for AI Coding Agents | |
| ################################################################################ | |
| # | |
| # ARCHITECTURAL OVERVIEW | |
| # ---------------------- | |
| # This script implements a strictly isolated Hub-and-Spoke workflow. | |
| # | |
| # [ Remote Server (GitHub/GitLab) ] | |
| # ^ (origin) | |
| # | | |
| # [ Hub Workspace (Your IDE/Main Repo) ] <--- The Single Point of Truth | |
| # ^ (Local FS Remote) | |
| # | | |
| # [ Agent 1 Clone ] [ Agent 2 Clone ] [ Agent N Clone ] | |
| # | |
| # DESIGN PHILOSOPHY: PHYSICAL ISOLATION & MANUAL SYNC | |
| # --------------------------------------------------- | |
| # 1. PHYSICAL ISOLATION: Separate clones prevent workspace corruption. | |
| # 2. AIR-GAPPED: Agents have NO connection to the remote server. | |
| # 3. MANUAL SUBMODULE SYNC: Submodule objects are moved manually from Spoke | |
| # to Hub to prevent Git from trying to fetch local commits from GitLab. | |
| # 4. BRANCH ATTACHMENT: Submodules are forced to 'main' and synced to the | |
| # parent repo's expected commit using 'checkout -B'. | |
| # | |
| ################################################################################ | |
| set -e | |
| set -o pipefail | |
| HUB_BRANCH="main" | |
| ################################################################################ | |
| # Utility Functions | |
| ################################################################################ | |
| log_info() { echo "[INFO] $1"; } | |
| log_success() { echo "[SUCCESS] $1"; } | |
| log_warning() { echo "[WARNING] $1"; } | |
| log_error() { echo "[ERROR] $1"; } | |
| die() { log_error "$1"; exit 1; } | |
| check_git_repo() { | |
| git rev-parse --git-dir > /dev/null 2>&1 || die "Not in a git repository" | |
| } | |
| get_repo_root() { | |
| git rev-parse --show-toplevel | |
| } | |
| # Robust check for uncommitted changes in repo (including submodules). | |
| has_uncommitted_changes() { | |
| [ -n "$(git status --porcelain 2>/dev/null | grep -v '^??')" ] | |
| } | |
| # Hub version checks only parent repo | |
| has_parent_uncommitted_changes() { | |
| [ -n "$(git status --porcelain --ignore-submodules 2>/dev/null | grep -v '^??')" ] | |
| } | |
| is_hub_workspace() { | |
| # If a remote named origin exists, it's the Hub. | |
| git remote | grep -q "^origin$" | |
| } | |
| get_workspace_type() { | |
| if is_hub_workspace; then echo "hub"; else echo "agent"; fi | |
| } | |
| ################################################################################ | |
| # Submodule Helpers | |
| ################################################################################ | |
| # Force submodules to point to the HUB_BRANCH and match the parent index | |
| attach_submodules() { | |
| log_info "Synchronizing submodules to '$HUB_BRANCH'வுகளை..." | |
| export B_NAME="$HUB_BRANCH" | |
| git submodule foreach --recursive " | |
| echo \"[INFO] Syncing submodule \$name to branch \$B_NAME\" | |
| git checkout -B \"\$B_NAME\" | |
| " | |
| } | |
| # Repair submodule remotes to point to local Hub instead of origin | |
| repair_agent_submodules() { | |
| local hub_path="$1" | |
| log_info "Rewiring agent submodule remotes to local Hub..." | |
| export HUB_ROOT="$hub_path" | |
| git submodule foreach --recursive ' | |
| git remote set-url hub "$HUB_ROOT/$sm_path" 2>/dev/null || git remote add hub "$HUB_ROOT/$sm_path" | |
| git remote remove origin 2>/dev/null || true | |
| git config uploadpack.allowReachableSHA1InWant true | |
| ' | |
| } | |
| ################################################################################ | |
| # Hub Preparation | |
| ################################################################################ | |
| prepare_hub_if_needed() { | |
| log_info "Ensuring Hub is ready..." | |
| local current_branch=$(git symbolic-ref --short HEAD 2>/dev/null) | |
| if [ "$current_branch" != "$HUB_BRANCH" ]; then | |
| log_warning "Hub is on branch '$current_branch', switching to '$HUB_BRANCH'..." | |
| git checkout "$HUB_BRANCH" || die "Failed to checkout $HUB_BRANCH" | |
| fi | |
| git submodule update --init --recursive | |
| attach_submodules | |
| } | |
| ################################################################################ | |
| # Command: launch [HUB] | |
| ################################################################################ | |
| cmd_launch() { | |
| local agent_name="$1" | |
| local agent_path="$2" | |
| if [ -z "$agent_name" ] || [ -z "$agent_path" ]; then | |
| die "Usage: wflow launch <agent-name> <local-path>" | |
| fi | |
| check_git_repo | |
| [ "$(get_workspace_type)" != "hub" ] && die "Must run 'launch' from Hub. Type is: $(get_workspace_type)" | |
| prepare_hub_if_needed | |
| local abs_agent_path | |
| abs_agent_path=$(python3 -c "import os; print(os.path.abspath('$agent_path'))") | |
| if [ -d "$abs_agent_path" ] && [ -n "$(ls -A "$abs_agent_path" 2>/dev/null)" ]; then | |
| die "Path '$abs_agent_path' is not empty. Please provide a new subfolder." | |
| fi | |
| local hub_path=$(get_repo_root) | |
| log_info "Cloning Hub to create isolated Agent workspace '$agent_name'..." | |
| git clone --recurse-submodules "$hub_path" "$abs_agent_path" | |
| pushd "$abs_agent_path" > /dev/null | |
| git remote rename origin hub | |
| git config user.name "Agent $agent_name" | |
| git config uploadpack.allowReachableSHA1InWant true | |
| log_info "Configuring agent submodules..." | |
| git submodule update --init --recursive | |
| repair_agent_submodules "$hub_path" | |
| attach_submodules | |
| popd > /dev/null | |
| git remote add "$agent_name" "$abs_agent_path" | |
| log_success "Agent '$agent_name' created at $abs_agent_path" | |
| } | |
| ################################################################################ | |
| # Command: save [HUB/AGENT] | |
| ################################################################################ | |
| cmd_save() { | |
| local message="$1" | |
| [ -z "$message" ] && die "Error: save command requires a commit message." | |
| check_git_repo | |
| log_info "Saving work locally..." | |
| export COMMIT_MSG="$message" | |
| export B_NAME="$HUB_BRANCH" | |
| git submodule foreach --recursive '\ | |
| if [ -n "$(git status --porcelain)" ]; then | |
| echo "[INFO] Saving submodule $name..." | |
| git checkout -B "$B_NAME" | |
| git add -A | |
| git commit -m "$COMMIT_MSG (submodule)" | |
| fi | |
| ' | |
| if has_parent_uncommitted_changes || [ -n "$(git status --porcelain)" ]; then | |
| git add -A | |
| git commit -m "$message" | |
| log_success "Changes saved to local '$HUB_BRANCH' branch" | |
| else | |
| log_info "No changes to save" | |
| fi | |
| } | |
| ################################################################################ | |
| # Command: harvest [HUB] | |
| ################################################################################ | |
| cmd_harvest() { | |
| local agent_name="$1" | |
| if [ -z "$agent_name" ]; then | |
| die "Usage: wflow harvest <agent-name>" | |
| fi | |
| check_git_repo | |
| [ "$(get_workspace_type)" != "hub" ] && die "Must run 'harvest' from Hub." | |
| if has_parent_uncommitted_changes; then | |
| die "Hub has uncommitted changes in parent repository. Save them first." | |
| fi | |
| local agent_path=$(git remote get-url "$agent_name" 2>/dev/null || echo "") | |
| [ -z "$agent_path" ] && die "Agent '$agent_name' remote not found." | |
| log_info "Harvesting changes from agent '$agent_name'வுகளை..." | |
| # 1. MANUAL SUBMODULE SIPHON | |
| export ABS_AGENT_PATH=$(cd "$agent_path" && pwd) | |
| export B_NAME="$HUB_BRANCH" | |
| git config --file .gitmodules --get-regexp path | while read -r key sub_path; do | |
| local agent_sub_path="$ABS_AGENT_PATH/$sub_path" | |
| if [ -e "$agent_sub_path/.git" ]; then | |
| log_info "Siphoning objects for submodule: $sub_path" | |
| (cd "$sub_path" && git fetch "$agent_sub_path" "$B_NAME") || true | |
| fi | |
| done | |
| # 2. REPAIR WORKSPACE | |
| log_info "Repairing submodule pointers..." | |
| git submodule update --init --recursive || true | |
| attach_submodules | |
| # 3. HARVEST PARENT | |
| log_info "Fetching parent repo changes..." | |
| git -c submodule.recurse=false fetch "$agent_name" "$HUB_BRANCH" | |
| log_info "Merging agent changes into Hub '$HUB_BRANCH'வுகளை..." | |
| if git -c submodule.recurse=false merge FETCH_HEAD --no-ff --no-edit -m "Integrate work from agent $agent_name"; then | |
| log_info "Finalizing working tree..." | |
| git submodule update --init --recursive | |
| attach_submodules | |
| log_success "Successfully harvested changes from agent '$agent_name'" | |
| else | |
| log_error "Merge conflict detected. Resolve conflicts in your IDE and commit." | |
| exit 1 | |
| fi | |
| } | |
| ################################################################################ | |
| # Command: refresh [AGENT] | |
| ################################################################################ | |
| cmd_refresh() { | |
| check_git_repo | |
| [ "$(get_workspace_type)" != "agent" ] && die "Must run 'refresh' from Agent." | |
| if has_uncommitted_changes; then | |
| die "Uncommitted changes detected. Run 'wflow save' before refreshing." | |
| fi | |
| log_info "Refreshing agent workspace from Hub..." | |
| local hub_path=$(git remote get-url hub) | |
| repair_agent_submodules "$hub_path" | |
| # Update submodules from Hub | |
| export B_NAME="$HUB_BRANCH" | |
| git submodule foreach --recursive " | |
| git fetch hub \$B_NAME | |
| git rebase FETCH_HEAD || (echo '[ERROR] Submodule rebase failed' && exit 1) | |
| " | |
| # Update parent from Hub | |
| git -c submodule.recurse=false fetch hub "$HUB_BRANCH" | |
| if git rebase FETCH_HEAD; then | |
| log_info "Synchronizing working tree..." | |
| git submodule update --init --recursive --force | |
| attach_submodules | |
| log_success "Agent workspace refreshed" | |
| else | |
| die "Rebase failed! Resolve manually and run: git rebase --continue" | |
| fi | |
| } | |
| ################################################################################ | |
| # Command: reset [AGENT] | |
| ################################################################################ | |
| cmd_reset() { | |
| check_git_repo | |
| [ "$(get_workspace_type)" != "agent" ] && die "Must run 'reset' from Agent." | |
| log_info "Resetting agent workspace to Hub state (DESTRUCTIVE)..." | |
| # 1. Sync parent repo | |
| git fetch hub "$HUB_BRANCH" | |
| git reset --hard "hub/$HUB_BRANCH" | |
| git clean -fd | |
| # 2. Sync submodules | |
| local hub_path=$(git remote get-url hub) | |
| repair_agent_submodules "$hub_path" | |
| git submodule update --init --recursive --force | |
| attach_submodules | |
| log_success "Agent workspace has been hard-reset to Hub state." | |
| } | |
| ################################################################################ | |
| # Command: push [HUB] | |
| ################################################################################ | |
| cmd_push() { | |
| check_git_repo | |
| [ "$(get_workspace_type)" != "hub" ] && die "Only Hub can push." | |
| log_info "Finalizing workspace state..." | |
| git submodule update --init --recursive | |
| attach_submodules | |
| if has_parent_uncommitted_changes; then | |
| die "Hub has uncommitted changes in parent repo. Save them first." | |
| fi | |
| log_info "Checking remote state..." | |
| git fetch origin "$HUB_BRANCH" > /dev/null 2>&1 | |
| local behind | |
| behind=$(git rev-list --count "$HUB_BRANCH..origin/$HUB_BRANCH" 2>/dev/null || echo "0") | |
| if [ "$behind" -gt 0 ]; then | |
| die "Diverged: You are $behind commit(s) behind origin. Run 'git pull --rebase' first." | |
| fi | |
| log_info "Pushing everything to remote server..." | |
| git submodule foreach --recursive "git push origin $HUB_BRANCH" | |
| git push origin "$HUB_BRANCH" | |
| log_success "Successfully pushed to remote server" | |
| } | |
| ################################################################################ | |
| # Command: status [HUB/AGENT] | |
| ################################################################################ | |
| cmd_status() { | |
| check_git_repo | |
| local type=$(get_workspace_type) | |
| echo "Workspace Status ($type)" | |
| echo "==============================" | |
| if [ "$type" = "hub" ]; then | |
| echo "Hub Workspace:" | |
| git remote -v | grep origin || true | |
| echo "" | |
| echo "Known Agents:" | |
| git remote | grep -v "origin" | xargs -I {} echo " - {}" || echo " None" | |
| else | |
| echo "Agent Workspace:" | |
| git remote -v | grep hub || true | |
| fi | |
| echo "" | |
| echo "Submodules:" | |
| git submodule status --recursive | |
| } | |
| ################################################################################ | |
| # Command: cleanup [HUB] | |
| ################################################################################ | |
| cmd_cleanup() { | |
| local agent_name="$1" | |
| local agent_path="$2" | |
| if [ -z "$agent_name" ]; then | |
| die "Usage: wflow cleanup <agent-name> [local-path]" | |
| fi | |
| check_git_repo | |
| [ "$(get_workspace_type)" != "hub" ] && die "Must run 'cleanup' from Hub." | |
| echo "WARNING: This will remove agent '$agent_name' from Hub configuration." | |
| [ -n "$agent_path" ] && echo "Agent path: $agent_path" | |
| read -p "Are you sure? (yes/no): " confirm | |
| [ "$confirm" != "yes" ] && die "Cleanup cancelled" | |
| git remote remove "$agent_name" || log_warning "Remote '$agent_name' not found." | |
| if [ -n "$agent_path" ]; then | |
| local abs_to_del | |
| abs_to_del=$(python3 -c "import os; print(os.path.abspath('$agent_path'))") | |
| local cur_dir=$(pwd) | |
| if [[ "$cur_dir" == "$abs_to_del"* ]]; then | |
| die "Error: You are sitting inside $abs_to_del. Move out before deleting." | |
| fi | |
| if [ -d "$abs_to_del" ]; then | |
| log_info "Deleting agent workspace at $abs_to_del..." | |
| rm -rf "$abs_to_del" | |
| fi | |
| fi | |
| log_success "Agent '$agent_name' cleaned up." | |
| } | |
| ################################################################################ | |
| # Main | |
| ################################################################################ | |
| show_usage() { | |
| cat << EOF | |
| WFLOW - Air-Gapped Git Workflow for AI Coding Agents | |
| Usage: wflow <command> [options] | |
| Hub Commands: | |
| launch <agent-name> <path> [HUB] Create isolated AGENT clone at path | |
| harvest <agent-name> [HUB] Pull work from AGENT's filesystem | |
| push [HUB] Push everything to Remote Server | |
| status [HUB/AGENT] Show state | |
| cleanup <agent-name> [path] [HUB] Remove agent | |
| save <message> [HUB/AGENT] Commit locally (parent + submodules) | |
| Agent Commands: | |
| save <message> [HUB/AGENT] Commit locally (parent + submodules) | |
| refresh [AGENT] Pull latest from HUB | |
| reset [AGENT] Force-reset to Hub state (DESTRUCTIVE) | |
| status [HUB/AGENT] Check state | |
| EOF | |
| } | |
| case "$1" in | |
| launch) cmd_launch "$2" "$3" ;; | |
| save) cmd_save "$2" ;; | |
| harvest) cmd_harvest "$2" ;; | |
| refresh) cmd_refresh ;; | |
| reset) cmd_reset ;; | |
| push) cmd_push ;; | |
| status) cmd_status ;; | |
| cleanup) cmd_cleanup "$2" "$3" ;; | |
| *) show_usage ;; | |
| esac |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This follows the "hub-and-spokes" paradigm implemented via git repo clones fo maximum robustness and isolation. Works with git submodules too! You basically "launch" a workspace where your agent can work, and then "harvest" the changes into your main/hub repo.