Skip to content

Instantly share code, notes, and snippets.

@hrstoyanov
Last active December 29, 2025 18:40
Show Gist options
  • Select an option

  • Save hrstoyanov/d8abc366600fd1670e9b56d84dd4243f to your computer and use it in GitHub Desktop.

Select an option

Save hrstoyanov/d8abc366600fd1670e9b56d84dd4243f to your computer and use it in GitHub Desktop.
wflow.sh: Multiple ai code agents setup and workflow
#!/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
@hrstoyanov
Copy link
Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment