Files
claude-archeflow-plugin/lib/archeflow-git.sh

604 lines
18 KiB
Bash
Executable File

#!/usr/bin/env bash
# archeflow-git.sh — Git-per-phase commit strategy for ArcheFlow runs.
#
# Creates a branch per run, commits after each phase/agent, merges on success,
# and supports rollback to any phase boundary.
#
# Usage:
# archeflow-git.sh init <run_id> # Create branch, switch to it
# archeflow-git.sh commit <run_id> <phase> <msg> [files...] # Stage + commit
# archeflow-git.sh phase-commit <run_id> <phase> # Commit all phase artifacts
# archeflow-git.sh merge <run_id> [--squash|--no-ff] # Merge to base branch
# archeflow-git.sh rollback <run_id> --to <phase> # Reset to end of phase
# archeflow-git.sh status <run_id> # Show branch status
# archeflow-git.sh cleanup <run_id> # Delete branch after merge
#
# Configuration is read from .archeflow/config.yaml if it exists.
# All operations respect ArcheFlow safety rules: no force-push, no main modification.
set -euo pipefail
# ---------------------------------------------------------------------------
# Globals
# ---------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ARCHEFLOW_DIR=".archeflow"
CONFIG_FILE="${ARCHEFLOW_DIR}/config.yaml"
# Defaults (overridden by config if present)
BRANCH_PREFIX="archeflow/"
COMMIT_STYLE="conventional" # conventional | simple
MERGE_STRATEGY="squash" # squash | no-ff | rebase
AUTO_PUSH="false"
SIGNING_KEY=""
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
die() {
echo "[archeflow-git] ERROR: $*" >&2
exit 1
}
info() {
echo "[archeflow-git] $*" >&2
}
# Read a yaml key (simple single-level, no dependencies beyond grep/sed).
# Falls back to default if key not found or file missing.
yaml_get() {
local file="$1" key="$2" default="${3:-}"
if [[ -f "$file" ]]; then
local val
val=$(grep -E "^\s*${key}:" "$file" 2>/dev/null | head -1 | sed 's/^[^:]*:\s*//' | sed 's/\s*#.*//' | sed 's/^"\(.*\)"$/\1/' | sed "s/^'\(.*\)'$/\1/")
if [[ -n "$val" && "$val" != "null" ]]; then
echo "$val"
return
fi
fi
echo "$default"
}
load_config() {
if [[ -f "$CONFIG_FILE" ]]; then
BRANCH_PREFIX=$(yaml_get "$CONFIG_FILE" "branch_prefix" "$BRANCH_PREFIX")
COMMIT_STYLE=$(yaml_get "$CONFIG_FILE" "commit_style" "$COMMIT_STYLE")
MERGE_STRATEGY=$(yaml_get "$CONFIG_FILE" "merge_strategy" "$MERGE_STRATEGY")
AUTO_PUSH=$(yaml_get "$CONFIG_FILE" "auto_push" "$AUTO_PUSH")
SIGNING_KEY=$(yaml_get "$CONFIG_FILE" "signing_key" "$SIGNING_KEY")
fi
}
branch_name() {
local run_id="$1"
echo "${BRANCH_PREFIX}${run_id}"
}
# Get the base branch (the branch we were on before creating the run branch).
# Stored in .archeflow/runs/<run_id>/base-branch during init.
get_base_branch() {
local run_id="$1"
local base_file="${ARCHEFLOW_DIR}/runs/${run_id}/base-branch"
if [[ -f "$base_file" ]]; then
cat "$base_file"
else
echo "main"
fi
}
# Build commit signing args if signing_key is configured.
signing_args() {
if [[ -n "$SIGNING_KEY" ]]; then
echo "-c user.signingkey=${SIGNING_KEY} -c gpg.format=ssh -c commit.gpgsign=true"
fi
}
# Verify we are on the expected branch.
assert_on_branch() {
local expected="$1"
local current
current=$(git branch --show-current 2>/dev/null || true)
if [[ "$current" != "$expected" ]]; then
die "Expected to be on branch '${expected}', but on '${current}'"
fi
}
# Check for uncommitted changes.
has_uncommitted_changes() {
! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null
}
# Format the commit message based on style.
format_message() {
local phase="$1" msg="$2"
if [[ "$COMMIT_STYLE" == "simple" ]]; then
echo "${phase}: ${msg}"
else
echo "archeflow(${phase}): ${msg}"
fi
}
# Push if auto_push is enabled.
maybe_push() {
local branch="$1"
if [[ "$AUTO_PUSH" == "true" ]]; then
info "Pushing ${branch} to remote..."
git push origin "$branch" 2>/dev/null || info "Push failed (non-fatal, remote may not exist)"
fi
}
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
cmd_init() {
local run_id="$1"
local branch
branch=$(branch_name "$run_id")
# Record the current branch as the base branch
local current_branch
current_branch=$(git branch --show-current 2>/dev/null || echo "main")
# Check for existing branch
if git show-ref --verify --quiet "refs/heads/${branch}" 2>/dev/null; then
die "Branch '${branch}' already exists. Use a different run_id or clean up first."
fi
# Stash if dirty
if has_uncommitted_changes; then
info "Stashing uncommitted changes..."
git stash push -m "archeflow-git: auto-stash before run ${run_id}" --quiet
fi
# Create and switch to the run branch
git checkout -b "$branch" --quiet
info "Created and switched to branch: ${branch}"
# Store base branch for later merge
mkdir -p "${ARCHEFLOW_DIR}/runs/${run_id}"
echo "$current_branch" > "${ARCHEFLOW_DIR}/runs/${run_id}/base-branch"
maybe_push "$branch"
info "Init complete for run: ${run_id}"
}
cmd_commit() {
local run_id="$1"
local phase="$2"
local msg="$3"
shift 3
local extra_files=("$@")
local branch
branch=$(branch_name "$run_id")
assert_on_branch "$branch"
# Stage artifact directory for this run
local artifact_dir="${ARCHEFLOW_DIR}/artifacts/${run_id}"
if [[ -d "$artifact_dir" ]]; then
git add "$artifact_dir" 2>/dev/null || true
fi
# Stage the event log
local event_file="${ARCHEFLOW_DIR}/events/${run_id}.jsonl"
if [[ -f "$event_file" ]]; then
git add "$event_file" 2>/dev/null || true
fi
# Stage the run metadata (base-branch file etc.)
local run_meta="${ARCHEFLOW_DIR}/runs/${run_id}"
if [[ -d "$run_meta" ]]; then
git add "$run_meta" 2>/dev/null || true
fi
# Stage any extra files passed as arguments
for f in "${extra_files[@]}"; do
if [[ -e "$f" ]]; then
git add "$f" 2>/dev/null || true
else
info "Warning: file '${f}' does not exist, skipping"
fi
done
# Check if there is anything to commit
if git diff --cached --quiet 2>/dev/null; then
info "Nothing to commit for ${phase}: ${msg}"
return 0
fi
local commit_msg
commit_msg=$(format_message "$phase" "$msg")
# Build signing args
local sign_args
sign_args=$(signing_args)
if [[ -n "$sign_args" ]]; then
# shellcheck disable=SC2086
git $sign_args commit -m "$commit_msg" --quiet
else
git commit -m "$commit_msg" --quiet
fi
info "Committed: ${commit_msg}"
maybe_push "$branch"
}
cmd_phase_commit() {
local run_id="$1"
local phase="$2"
local branch
branch=$(branch_name "$run_id")
assert_on_branch "$branch"
local artifact_dir="${ARCHEFLOW_DIR}/artifacts/${run_id}"
# Determine the next phase for the transition message
local next_phase=""
case "$phase" in
plan) next_phase="do" ;;
do) next_phase="check" ;;
check) next_phase="act" ;;
act) next_phase="complete" ;;
*) next_phase="next" ;;
esac
# Stage all artifacts matching the phase prefix
if [[ -d "$artifact_dir" ]]; then
for f in "${artifact_dir}/${phase}-"*; do
[[ -e "$f" ]] && git add "$f" 2>/dev/null || true
done
fi
# Stage event log
local event_file="${ARCHEFLOW_DIR}/events/${run_id}.jsonl"
if [[ -f "$event_file" ]]; then
git add "$event_file" 2>/dev/null || true
fi
# Check if there is anything to commit
if git diff --cached --quiet 2>/dev/null; then
info "Nothing to commit for phase transition: ${phase}${next_phase}"
return 0
fi
local commit_msg
commit_msg=$(format_message "${phase}${next_phase}" "phase transition")
local sign_args
sign_args=$(signing_args)
if [[ -n "$sign_args" ]]; then
# shellcheck disable=SC2086
git $sign_args commit -m "$commit_msg" --quiet
else
git commit -m "$commit_msg" --quiet
fi
info "Committed phase transition: ${phase}${next_phase}"
maybe_push "$branch"
}
cmd_merge() {
local run_id="$1"
local strategy="${2:---squash}"
# Strip leading -- if present for comparison
strategy="${strategy#--}"
# Validate strategy
case "$strategy" in
squash|no-ff|rebase) ;;
*) die "Unknown merge strategy: ${strategy}. Use --squash, --no-ff, or --rebase." ;;
esac
local branch
branch=$(branch_name "$run_id")
# Verify we are on the run branch
assert_on_branch "$branch"
# Verify no uncommitted changes
if has_uncommitted_changes; then
die "Uncommitted changes on branch '${branch}'. Commit or stash before merging."
fi
local base_branch
base_branch=$(get_base_branch "$run_id")
# Switch to base branch
git checkout "$base_branch" --quiet
info "Switched to base branch: ${base_branch}"
case "$strategy" in
squash)
git merge --squash "$branch" --quiet
# Check if there are changes to commit (squash stages but doesn't commit)
if ! git diff --cached --quiet 2>/dev/null; then
local sign_args
sign_args=$(signing_args)
local commit_msg="feat: archeflow run ${run_id} complete"
if [[ -n "$sign_args" ]]; then
# shellcheck disable=SC2086
git $sign_args commit -m "$commit_msg" --quiet
else
git commit -m "$commit_msg" --quiet
fi
info "Squash-merged ${branch} into ${base_branch}"
else
info "No changes to merge (branch identical to base)"
fi
;;
no-ff)
local sign_args
sign_args=$(signing_args)
if [[ -n "$sign_args" ]]; then
# shellcheck disable=SC2086
git $sign_args merge --no-ff "$branch" -m "feat: archeflow run ${run_id} complete" --quiet
else
git merge --no-ff "$branch" -m "feat: archeflow run ${run_id} complete" --quiet
fi
info "Merged ${branch} into ${base_branch} (no-ff)"
;;
rebase)
git rebase "$branch" --quiet
info "Rebased ${base_branch} onto ${branch}"
;;
esac
info "Merge complete. Branch '${branch}' preserved for inspection."
info "Run 'archeflow-git.sh cleanup ${run_id}' to delete the branch."
}
cmd_rollback() {
local run_id="$1"
shift
local target_phase=""
while [[ $# -gt 0 ]]; do
case "$1" in
--to) target_phase="$2"; shift 2 ;;
*) die "Unknown option: $1. Usage: rollback <run_id> --to <phase>" ;;
esac
done
if [[ -z "$target_phase" ]]; then
die "Missing --to <phase>. Usage: rollback <run_id> --to <phase>"
fi
local branch
branch=$(branch_name "$run_id")
assert_on_branch "$branch"
# Find the target commit by searching commit messages.
# For phase targets like "plan", find the last commit containing that phase.
# For cycle targets like "cycle-2", find the cycle boundary commit.
local search_pattern
case "$target_phase" in
cycle-*)
local cycle_num="${target_phase#cycle-}"
search_pattern="cycle ${cycle_num}"
;;
*)
search_pattern="archeflow(${target_phase}"
if [[ "$COMMIT_STYLE" == "simple" ]]; then
search_pattern="${target_phase}:"
fi
;;
esac
local target_commit
target_commit=$(git log --oneline --format="%H %s" "$branch" | grep -F "$search_pattern" | head -1 | awk '{print $1}')
if [[ -z "$target_commit" ]]; then
die "No commit found for phase '${target_phase}' on branch '${branch}'."
fi
local target_short
target_short=$(git log --oneline -1 "$target_commit")
# Show what will be lost
local commits_after
commits_after=$(git log --oneline "${target_commit}..HEAD")
if [[ -z "$commits_after" ]]; then
info "Already at the target commit. Nothing to roll back."
return 0
fi
echo ""
echo "Rolling back to: ${target_short}"
echo ""
echo "The following commits will be removed:"
echo "$commits_after" | sed 's/^/ /'
echo ""
echo "This operation is destructive on the run branch."
echo "Type 'yes' to confirm:"
read -r confirm
if [[ "$confirm" != "yes" ]]; then
info "Rollback cancelled."
return 1
fi
# Perform the reset
git reset --hard "$target_commit" --quiet
info "Reset to: ${target_short}"
# Trim the events JSONL to match the rollback point.
# Find the commit timestamp and remove events after it.
local event_file="${ARCHEFLOW_DIR}/events/${run_id}.jsonl"
if [[ -f "$event_file" ]]; then
local commit_ts
commit_ts=$(git log -1 --format="%aI" "$target_commit")
# Keep only events with timestamps <= commit timestamp.
# Use jq to filter if available, otherwise leave the file as-is.
if command -v jq &>/dev/null; then
local tmp_file="${event_file}.tmp"
jq -c "select(.ts <= \"${commit_ts}\")" "$event_file" > "$tmp_file" 2>/dev/null || true
if [[ -s "$tmp_file" ]]; then
mv "$tmp_file" "$event_file"
git add "$event_file"
local sign_args
sign_args=$(signing_args)
local commit_msg
commit_msg=$(format_message "rollback" "to ${target_phase}")
if [[ -n "$sign_args" ]]; then
# shellcheck disable=SC2086
git $sign_args commit -m "$commit_msg" --quiet 2>/dev/null || true
else
git commit -m "$commit_msg" --quiet 2>/dev/null || true
fi
info "Trimmed events JSONL to match rollback point"
else
rm -f "$tmp_file"
info "Warning: could not trim events JSONL (file may need manual cleanup)"
fi
else
info "Warning: jq not available, events JSONL not trimmed"
fi
fi
info "Rollback complete. You are now at the end of the '${target_phase}' phase."
}
cmd_status() {
local run_id="$1"
local branch
branch=$(branch_name "$run_id")
# Check if branch exists
if ! git show-ref --verify --quiet "refs/heads/${branch}" 2>/dev/null; then
die "Branch '${branch}' does not exist."
fi
local base_branch
base_branch=$(get_base_branch "$run_id")
local ahead
ahead=$(git rev-list --count "${base_branch}..${branch}" 2>/dev/null || echo "?")
echo "Branch: ${branch}"
echo "Base: ${base_branch} (${ahead} commits ahead)"
echo ""
echo "Commits:"
git log --oneline "${base_branch}..${branch}" 2>/dev/null | sed 's/^/ /' || echo " (none)"
echo ""
# Determine current phase from latest commit message
local latest_msg
latest_msg=$(git log -1 --format="%s" "$branch" 2>/dev/null || echo "")
local current_phase="unknown"
local re_conv='archeflow\(([^)]+)\)'
local re_simple='^([a-z]+):'
if [[ "$latest_msg" =~ $re_conv ]]; then
current_phase="${BASH_REMATCH[1]}"
elif [[ "$latest_msg" =~ $re_simple ]]; then
current_phase="${BASH_REMATCH[1]}"
fi
echo "Current phase: ${current_phase}"
# Count files changed
local files_changed
files_changed=$(git diff --name-only "${base_branch}...${branch}" 2>/dev/null | wc -l | tr -d ' ')
echo "Files changed (total): ${files_changed}"
# Check for uncommitted changes
local current
current=$(git branch --show-current 2>/dev/null || true)
if [[ "$current" == "$branch" ]]; then
if has_uncommitted_changes; then
echo "Uncommitted changes: YES"
else
echo "Uncommitted changes: none"
fi
else
echo "Uncommitted changes: (not on branch, cannot check)"
fi
}
cmd_cleanup() {
local run_id="$1"
local branch
branch=$(branch_name "$run_id")
# Safety: don't delete if we're on the branch
local current
current=$(git branch --show-current 2>/dev/null || true)
if [[ "$current" == "$branch" ]]; then
die "Cannot delete branch '${branch}' while on it. Switch to another branch first."
fi
# Check if branch exists
if ! git show-ref --verify --quiet "refs/heads/${branch}" 2>/dev/null; then
die "Branch '${branch}' does not exist."
fi
# Check if branch is fully merged
local base_branch
base_branch=$(get_base_branch "$run_id")
if ! git merge-base --is-ancestor "$branch" "$base_branch" 2>/dev/null; then
echo "Warning: Branch '${branch}' is not fully merged into '${base_branch}'."
echo "Deleting it will lose unmerged commits."
echo "Type 'yes' to confirm:"
read -r confirm
if [[ "$confirm" != "yes" ]]; then
info "Cleanup cancelled."
return 1
fi
git branch -D "$branch" --quiet
else
git branch -d "$branch" --quiet
fi
# Clean up run metadata
rm -rf "${ARCHEFLOW_DIR}/runs/${run_id}"
info "Deleted branch: ${branch}"
info "Cleaned up run metadata for: ${run_id}"
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
if [[ $# -lt 2 ]]; then
echo "Usage: $0 <command> <run_id> [args...]" >&2
echo "" >&2
echo "Commands:" >&2
echo " init <run_id> Create branch and switch to it" >&2
echo " commit <run_id> <phase> <msg> [files] Stage relevant files and commit" >&2
echo " phase-commit <run_id> <phase> Commit all phase artifacts" >&2
echo " merge <run_id> [--squash|--no-ff] Merge run branch to base" >&2
echo " rollback <run_id> --to <phase> Reset to end of phase" >&2
echo " status <run_id> Show branch status and commits" >&2
echo " cleanup <run_id> Delete branch after merge" >&2
exit 1
fi
local cmd="$1"
local run_id="$2"
shift 2
load_config
case "$cmd" in
init) cmd_init "$run_id" ;;
commit) cmd_commit "$run_id" "$@" ;;
phase-commit) cmd_phase_commit "$run_id" "$@" ;;
merge) cmd_merge "$run_id" "$@" ;;
rollback) cmd_rollback "$run_id" "$@" ;;
status) cmd_status "$run_id" ;;
cleanup) cmd_cleanup "$run_id" ;;
*) die "Unknown command: ${cmd}" ;;
esac
}
main "$@"