#!/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 # Create branch, switch to it # archeflow-git.sh commit [files...] # Stage + commit # archeflow-git.sh phase-commit # Commit all phase artifacts # archeflow-git.sh merge [--squash|--no-ff] # Merge to base branch # archeflow-git.sh rollback --to # Reset to end of phase # archeflow-git.sh status # Show branch status # archeflow-git.sh cleanup # 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//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 --to " ;; esac done if [[ -z "$target_phase" ]]; then die "Missing --to . Usage: rollback --to " 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 [args...]" >&2 echo "" >&2 echo "Commands:" >&2 echo " init Create branch and switch to it" >&2 echo " commit [files] Stage relevant files and commit" >&2 echo " phase-commit Commit all phase artifacts" >&2 echo " merge [--squash|--no-ff] Merge run branch to base" >&2 echo " rollback --to Reset to end of phase" >&2 echo " status Show branch status and commits" >&2 echo " cleanup 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 "$@"