Skip to content
88 changes: 57 additions & 31 deletions lib/dispatchers/teach-deploy-enhanced.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ _deploy_preflight_checks() {
# Only check .qmd files changed between draft and production
local _math_blanks_files _repo_root
_repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
_math_blanks_files=$(git diff --name-only "$DEPLOY_PROD_BRANCH".."$DEPLOY_DRAFT_BRANCH" -- '*.qmd' 2>/dev/null)
_math_blanks_files=$(git diff --name-only "$DEPLOY_PROD_BRANCH"..."$DEPLOY_DRAFT_BRANCH" -- '*.qmd' 2>/dev/null)
if [[ -n "$_math_blanks_files" ]]; then
local _math_blank_files=() _math_unclosed_files=() _abs_path _qmd_file
while IFS= read -r _qmd_file; do
Expand Down Expand Up @@ -194,6 +194,7 @@ _deploy_step() {
done) printf " ${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} [%d/%d] %s\n" "$step" "$total" "$label" ;;
active) printf " ${FLOW_COLORS[info]}⏳${FLOW_COLORS[reset]} [%d/%d] %s\n" "$step" "$total" "$label" ;;
fail) printf " ${FLOW_COLORS[error]}✗${FLOW_COLORS[reset]} [%d/%d] %s\n" "$step" "$total" "$label" ;;
skip) printf " ${FLOW_COLORS[warn]}—${FLOW_COLORS[reset]} [%d/%d] %s ${FLOW_COLORS[dim]}(skipped)${FLOW_COLORS[reset]}\n" "$step" "$total" "$label" ;;
esac
}

Expand Down Expand Up @@ -236,6 +237,22 @@ _deploy_summary_box() {
printf "╯\n"
}

# ============================================================================
# COMMIT FAILURE GUIDANCE (DRY helper)
# ============================================================================

_deploy_commit_failure_guidance() {
echo ""
_teach_error "Commit failed (likely pre-commit hook)"
echo ""
echo " ${FLOW_COLORS[dim]}Options:${FLOW_COLORS[reset]}"
echo " 1. Fix the issues above, then run ${FLOW_COLORS[info]}teach deploy${FLOW_COLORS[reset]} again"
echo " 2. Skip validation: ${FLOW_COLORS[info]}QUARTO_PRE_COMMIT_RENDER=0 teach deploy ...${FLOW_COLORS[reset]}"
echo " 3. Force commit: ${FLOW_COLORS[info]}git commit --no-verify -m \"message\"${FLOW_COLORS[reset]}"
echo ""
echo " ${FLOW_COLORS[dim]}Your changes are still staged. Nothing was lost.${FLOW_COLORS[reset]}"
}

# ============================================================================
# DIRECT MERGE MODE
# ============================================================================
Expand All @@ -251,7 +268,7 @@ _deploy_direct_merge() {
local ci_mode="${4:-false}"
local start_time=$SECONDS

local total_steps=5
local total_steps=6

# Safety: always return to draft branch on error or signal
trap "git checkout '$draft_branch' 2>/dev/null" EXIT INT TERM
Expand Down Expand Up @@ -335,6 +352,15 @@ _deploy_direct_merge() {
}
_deploy_step 5 $total_steps "Switch back to $draft_branch" done

# Back-merge: keep draft in sync with production (prevents #372 recurrence)
git fetch origin "$prod_branch" --quiet 2>/dev/null
if git merge "origin/$prod_branch" --ff-only 2>/dev/null; then
_deploy_step 6 $total_steps "Sync $draft_branch with $prod_branch" done
else
_deploy_step 6 $total_steps "Sync $draft_branch with $prod_branch" skip
echo " ${FLOW_COLORS[dim]}Draft has new commits — run 'teach deploy --sync' later${FLOW_COLORS[reset]}"
fi

local elapsed=$(( SECONDS - start_time ))

# Collect diff stats for summary
Expand Down Expand Up @@ -566,6 +592,26 @@ _teach_deploy_enhanced() {
_deploy_history_list "$history_count"
return $?
;;
--sync)
shift
# Quick branch sync: merge production into draft (ff-only first, then regular)
local _sync_config=".flow/teach-config.yml"
local _sync_prod
_sync_prod=$(yq '.git.production_branch // .branches.production // "main"' "$_sync_config" 2>/dev/null) || _sync_prod="main"
echo ""
echo "${FLOW_COLORS[info]} Syncing with $_sync_prod...${FLOW_COLORS[reset]}"
git fetch origin "$_sync_prod" --quiet 2>/dev/null
if git merge "origin/$_sync_prod" --ff-only 2>/dev/null; then
echo "${FLOW_COLORS[success]} [ok]${FLOW_COLORS[reset]} Fast-forward sync with $_sync_prod"
elif git merge "origin/$_sync_prod" --no-edit 2>/dev/null; then
echo "${FLOW_COLORS[success]} [ok]${FLOW_COLORS[reset]} Merged $_sync_prod (merge commit created)"
else
_teach_error "Sync failed — merge conflicts" \
"Resolve conflicts manually, then commit"
return 1
fi
return 0
;;
--help|-h|help)
_teach_deploy_enhanced_help
return 0
Expand Down Expand Up @@ -597,6 +643,7 @@ _teach_deploy_enhanced() {
# ============================================
# PRE-FLIGHT CHECKS (shared function)
# ============================================
{

_deploy_preflight_checks "$ci_mode" || return 1

Expand Down Expand Up @@ -637,15 +684,7 @@ _teach_deploy_enhanced() {
*)
git add -A
if ! git commit -m "$smart_msg"; then
echo ""
_teach_error "Commit failed (likely pre-commit hook)"
echo ""
echo " ${FLOW_COLORS[dim]}Options:${FLOW_COLORS[reset]}"
echo " 1. Fix issues, then ${FLOW_COLORS[info]}teach deploy${FLOW_COLORS[reset]} again"
echo " 2. Skip: ${FLOW_COLORS[info]}QUARTO_PRE_COMMIT_RENDER=0 teach deploy ...${FLOW_COLORS[reset]}"
echo " 3. Force: ${FLOW_COLORS[info]}git commit --no-verify -m \"message\"${FLOW_COLORS[reset]}"
echo ""
echo " ${FLOW_COLORS[dim]}Changes are still staged.${FLOW_COLORS[reset]}"
_deploy_commit_failure_guidance
return 1
fi
echo " ${FLOW_COLORS[success]}[ok]${FLOW_COLORS[reset]} Committed: $smart_msg"
Expand Down Expand Up @@ -804,15 +843,7 @@ _teach_deploy_enhanced() {

git add "${uncommitted_files[@]}"
if ! git commit -m "$commit_msg"; then
echo ""
_teach_error "Commit failed (likely pre-commit hook)"
echo ""
echo " ${FLOW_COLORS[dim]}Options:${FLOW_COLORS[reset]}"
echo " 1. Fix the issues above, then run ${FLOW_COLORS[info]}teach deploy${FLOW_COLORS[reset]} again"
echo " 2. Skip validation: ${FLOW_COLORS[info]}QUARTO_PRE_COMMIT_RENDER=0 teach deploy ...${FLOW_COLORS[reset]}"
echo " 3. Force commit: ${FLOW_COLORS[info]}git commit --no-verify -m \"message\"${FLOW_COLORS[reset]}"
echo ""
echo " ${FLOW_COLORS[dim]}Your changes are still staged. Nothing was lost.${FLOW_COLORS[reset]}"
_deploy_commit_failure_guidance
return 1
fi

Expand All @@ -828,15 +859,7 @@ _teach_deploy_enhanced() {

git add "${uncommitted_files[@]}"
if ! git commit -m "$commit_msg"; then
echo ""
_teach_error "Commit failed (likely pre-commit hook)"
echo ""
echo " ${FLOW_COLORS[dim]}Options:${FLOW_COLORS[reset]}"
echo " 1. Fix the issues above, then run ${FLOW_COLORS[info]}teach deploy${FLOW_COLORS[reset]} again"
echo " 2. Skip validation: ${FLOW_COLORS[info]}QUARTO_PRE_COMMIT_RENDER=0 teach deploy ...${FLOW_COLORS[reset]}"
echo " 3. Force commit: ${FLOW_COLORS[info]}git commit --no-verify -m \"message\"${FLOW_COLORS[reset]}"
echo ""
echo " ${FLOW_COLORS[dim]}Your changes are still staged. Nothing was lost.${FLOW_COLORS[reset]}"
_deploy_commit_failure_guidance
return 1
fi

Expand Down Expand Up @@ -1004,7 +1027,6 @@ _teach_deploy_enhanced() {
"${DEPLOY_SHORT_HASH:-$(git rev-parse --short=8 HEAD 2>/dev/null)}" \
"$site_url"

_deploy_cleanup_globals
return 0
fi

Expand Down Expand Up @@ -1226,7 +1248,10 @@ _teach_deploy_enhanced() {

# Clear trap before normal return (don't interfere with caller)
trap - EXIT INT TERM
_deploy_cleanup_globals

} always {
_deploy_cleanup_globals
}
}

# Clean up DEPLOY_* global variables to avoid polluting the shell environment
Expand Down Expand Up @@ -1297,6 +1322,7 @@ ${_C_BLUE}📋 HISTORY & ROLLBACK${_C_NC}:
${_C_CYAN}teach deploy --history 20${_C_NC} Show last 20 deployments
${_C_CYAN}teach deploy --rollback${_C_NC} Interactive rollback picker
${_C_CYAN}teach deploy --rollback 1${_C_NC} Rollback most recent deploy
${_C_CYAN}teach deploy --sync${_C_NC} Sync draft with production

${_C_MAGENTA}💡 TIP${_C_NC}: Direct merge (-d) is best for solo instructors.
${_C_DIM}PR mode (default) is better for team-reviewed courses.${_C_NC}
Expand Down
32 changes: 17 additions & 15 deletions lib/git-helpers.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -656,26 +656,27 @@ _git_push_current_branch() {

# =============================================================================
# Function: _git_detect_production_conflicts
# Purpose: Check if production branch has commits that could cause conflicts
# Purpose: Check if production branch has real content divergence from draft
# =============================================================================
# Arguments:
# $1 - (required) Draft/development branch name
# $2 - (required) Production branch name
#
# Returns:
# 0 - No conflicts (production hasn't diverged)
# 1 - Potential conflicts (production has new commits)
# 0 - No conflicts (production has no new content commits)
# 1 - Potential conflicts (production has non-merge commits not in draft)
#
# Example:
# if ! _git_detect_production_conflicts "draft" "main"; then
# echo "Warning: Production has new commits"
# echo "Consider rebasing before PR"
# echo "Consider running 'teach deploy --sync'"
# fi
#
# Notes:
# - Fetches from remote before checking
# - Uses merge-base to find common ancestor
# - Returns 1 if production has commits since divergence
# - Uses --is-ancestor fast path when draft is already merged
# - Excludes merge commits (--no-merges) to avoid false positives
# from --no-ff merge commits created by direct deploy
# =============================================================================
_git_detect_production_conflicts() {
local draft_branch="$1"
Expand All @@ -684,17 +685,18 @@ _git_detect_production_conflicts() {
# Fetch latest from remote
git fetch origin "$prod_branch" --quiet 2>/dev/null || return 1

# Get merge base (common ancestor)
local merge_base=$(git merge-base "$draft_branch" "origin/$prod_branch" 2>/dev/null)
# Fast path: draft already merged into production — no conflicts possible
if git merge-base --is-ancestor "$draft_branch" "origin/$prod_branch" 2>/dev/null; then
return 0
fi

# Check if production branch has commits ahead of merge base
local commits_ahead=$(git rev-list --count "${merge_base}..origin/${prod_branch}" 2>/dev/null || echo 0)
# Only flag actual content commits, not merge commits from --no-ff deploys
local prod_only
prod_only=$(git log --oneline --no-merges "origin/${prod_branch}" --not "$draft_branch" 2>/dev/null)

if [[ $commits_ahead -gt 0 ]]; then
return 1 # Conflicts detected (production has new commits)
else
return 0 # No conflicts
fi
[[ -z "$prod_only" ]] && return 0

return 1
}

# =============================================================================
Expand Down
107 changes: 107 additions & 0 deletions tests/dogfood-teach-deploy-v2.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -1069,6 +1069,113 @@ run_test "Full deploy summary box has Mode, Files, Duration, Commit fields" '

echo ""

# ============================================================================
# SECTION 12: Back-Merge, --sync & Conflict Detection (#372)
# ============================================================================
echo "${CYAN}--- Section 12: Back-Merge, --sync & Conflict Detection (#372) ---${RESET}"

run_test "New helper _deploy_commit_failure_guidance is loaded" '
typeset -f _deploy_commit_failure_guidance >/dev/null 2>&1 || return 1
'

run_test "Help output includes --sync flag" '
local output
output=$(_teach_deploy_enhanced_help 2>&1)
[[ "$output" == *"--sync"* ]] || return 1
'

run_test "_deploy_step supports skip status" '
local output
output=$(_deploy_step 1 3 "Sync branch" skip 2>&1)
[[ "$output" == *"skipped"* ]] || return 1
'

run_test "_deploy_commit_failure_guidance produces expected output" '
local output
output=$(_deploy_commit_failure_guidance 2>&1)
[[ "$output" == *"Commit failed"* ]] || return 1
[[ "$output" == *"QUARTO_PRE_COMMIT_RENDER"* ]] || return 1
[[ "$output" == *"still staged"* ]] || return 1
'

run_test "--sync flag is accepted (no Unknown flag error)" '
[[ "$_YQ_AVAILABLE" == "true" ]] || return 77
local tmpdir=$(_create_demo_repo)
local output
output=$(cd "$tmpdir" && _teach_deploy_enhanced --sync 2>&1)
[[ "$output" != *"Unknown flag"* ]] || return 1
'

run_test "--sync on already-synced branches succeeds (ff-only)" '
[[ "$_YQ_AVAILABLE" == "true" ]] || return 77
local tmpdir=$(_create_demo_repo)
# Deploy first so branches have something to sync
(cd "$tmpdir" && _teach_deploy_enhanced --direct --ci) >/dev/null 2>&1
local output
output=$(cd "$tmpdir" && _teach_deploy_enhanced --sync 2>&1)
[[ "$output" == *"[ok]"* ]] || return 1
'

run_test "Direct deploy with demo course includes back-merge step" '
[[ "$_YQ_AVAILABLE" == "true" ]] || return 77
local tmpdir=$(_create_demo_repo)
local output
output=$(cd "$tmpdir" && _teach_deploy_enhanced --direct --ci 2>&1)
local rc=$?
[[ $rc -eq 0 ]] || return 1
# Output should contain step 6/6 (the back-merge sync step)
[[ "$output" == *"[6/6]"* ]] || return 1
[[ "$output" == *"Sync"* ]] || return 1
'

run_test "Conflict detection returns 0 after demo course deploy" '
[[ "$_YQ_AVAILABLE" == "true" ]] || return 77
local tmpdir=$(_create_demo_repo)
(cd "$tmpdir" && _teach_deploy_enhanced --direct --ci) >/dev/null 2>&1
local result
result=$(
cd "$tmpdir"
_git_detect_production_conflicts "draft" "main"
echo $?
)
[[ "$result" == "0" ]] || return 1
'

_test_multiple_deploys() {
[[ "$_YQ_AVAILABLE" == "true" ]] || return 77
local tmpdir=$(_create_demo_repo)
# Deploy once
(cd "$tmpdir" && _teach_deploy_enhanced --direct --ci) >/dev/null 2>&1
# Add more content and deploy again
(
cd "$tmpdir"
echo "## Week 2 update" >> lectures/week-01.qmd
git add -A && git commit -q -m "update week-01"
) >/dev/null 2>&1
(cd "$tmpdir" && _teach_deploy_enhanced --direct --ci) >/dev/null 2>&1
# After two deploys, conflict detection should pass
local result
result=$(
cd "$tmpdir"
_git_detect_production_conflicts "draft" "main"
echo $?
)
[[ "$result" == "0" ]] || return 1
}
run_test "Multiple deploys of demo course produce no false positives" '_test_multiple_deploys'

_test_three_dot_diff() {
local src="${PROJECT_ROOT}/lib/dispatchers/teach-deploy-enhanced.zsh"
local line
line=$(grep "math_blanks_files.*git diff" "$src" 2>/dev/null)
[[ -n "$line" ]] || return 1
# Must contain three-dot syntax
[[ "$line" == *'...'* ]] || return 1
}
run_test "Three-dot diff in preflight uses correct syntax" '_test_three_dot_diff'

echo ""

# ============================================================================
# Summary
# ============================================================================
Expand Down
Loading