From ef995fd2d1a4b51cf212d9e7cdb2302541636559 Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Fri, 3 Apr 2026 11:40:51 +0200 Subject: [PATCH] feat: add archeflow-git.sh for per-phase commits and rollback --- lib/archeflow-git.sh | 603 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 603 insertions(+) create mode 100755 lib/archeflow-git.sh diff --git a/lib/archeflow-git.sh b/lib/archeflow-git.sh new file mode 100755 index 0000000..bf50d57 --- /dev/null +++ b/lib/archeflow-git.sh @@ -0,0 +1,603 @@ +#!/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 "$@"