diff --git a/.STATUS b/.STATUS index 030108485..695bdeb6d 100644 --- a/.STATUS +++ b/.STATUS @@ -1709,6 +1709,6 @@ E) **Quarto Workflow Enhancements** (Additional validators) **Last Updated:** 2026-02-02 **Status:** v6.2.0 Released ✅ | Docs deployed ✅ | Website reorganized (14→7 sections) ✅ | 0 build warnings ✅ -## wins: --category fix squashed the bug (2026-02-02), fixed the bug (2026-02-02), --category fix squashed the bug (2026-01-31), fixed the bug (2026-01-31), --category fix squashed the bug (2026-01-31) +## wins: --category fix squashed the bug (2026-02-03), fixed the bug (2026-02-03), --category fix squashed the bug (2026-02-03), fixed the bug (2026-02-03), --category fix squashed the bug (2026-02-03) ## streak: 0 -## last_active: 2026-02-02 17:08 +## last_active: 2026-02-03 22:01 diff --git a/.archive/BRAINSTORM-teach-deploy-v2-2026-02-03.md b/.archive/BRAINSTORM-teach-deploy-v2-2026-02-03.md new file mode 100644 index 000000000..9b1c907ff --- /dev/null +++ b/.archive/BRAINSTORM-teach-deploy-v2-2026-02-03.md @@ -0,0 +1,79 @@ +# teach deploy v2 — Brainstorm + +**Generated:** 2026-02-03 +**Context:** flow-cli v6.3.0 (`teach deploy` enhancement) +**Mode:** deep + feature + save +**Duration:** ~10 min + +## Overview + +Port STAT-545's battle-tested deploy patterns into flow-cli's `teach deploy` command, plus add dry-run, history, rollback, and CI mode. Consolidate the two deploy code paths into one. + +## Current Pain Points + +1. **Slow PR workflow** (45-90s) — STAT-545 has a 8-15s direct merge script +2. **Generic commit messages** — `"Update: 2026-02-03"` vs smart `content: week-05 lecture, assignment 5` +3. **No deployment tracking** — No history of what was deployed when +4. **No rollback** — Manual `git revert` only +5. **No dry-run** — Can't preview without executing +6. **Two code paths** — `_teach_deploy()` and `_teach_deploy_enhanced()` with inconsistent behavior +7. **No CI support** — Prompts block non-interactive use +8. **No .STATUS integration** — Teaching week / progress not tracked on deploy + +## Quick Wins (Phase 0-1) + +1. Delete old `_teach_deploy()` dead code (~340 lines removed) +2. Add `--ci` flag + TTY auto-detection +3. Extract shared `_deploy_preflight_checks()` + +## Medium Effort (Phase 2-4) + +4. Smart commit messages from changed file categories +5. `--direct` flag for direct merge mode (8-15s) +6. Branch divergence detection + recovery +7. `--dry-run` preview mode + +## Larger Effort (Phase 5-6) + +8. Deploy history log (`.flow/deploy-history.yml`) +9. `--rollback [N]` with interactive picker +10. `.STATUS` auto-updates (teaching week, progress, deploy count) + +## Options Considered + +### Option A: Phase It (rejected by user) +Port STAT-545 first, then add new features. +**Pros:** Smaller PRs, faster feedback +**Cons:** More branches, more merge overhead + +### Option B: All at Once (selected) +Single feature branch with everything. +**Pros:** One implementation pass, coherent design +**Cons:** Larger PR, more risk + +### Option C: Skip Rollback (not selected) +Defer rollback + history to a future release. +**Pros:** Simpler scope +**Cons:** History is needed for rollback, and both are useful independently + +## Recommended Path + +All 8 features + 1 refactor in a single `feature/teach-deploy-v2` branch. Implementation follows a dependency-ordered 7-phase plan where CI mode comes first (every feature needs it), then smart commits, direct merge, dry-run, history+rollback, .STATUS updates, and finally help/docs. + +## Key Design Decisions + +1. **`--direct` replaces `--direct-push`** — shorter name, `--direct-push` kept as alias +2. **deploy-history.yml uses append-only writes** — no yq rewrite of entire file +3. **Rollback uses `git revert`** — forward rollback, not destructive reset +4. **CI mode auto-detects from TTY** — plus explicit `--ci` flag override +5. **Smart commit messages are overridable** — `--message "text"` takes precedence +6. **.STATUS updates are non-destructive** — skip if file doesn't exist + +## Next Steps + +1. [ ] Create worktree: `feature/teach-deploy-v2` +2. [ ] Implement Phase 0: Consolidation +3. [ ] Implement Phase 1-6 sequentially +4. [ ] Run full test suite +5. [ ] Update help + CLAUDE.md +6. [ ] PR to dev diff --git a/CLAUDE.md b/CLAUDE.md index 35cbfa85d..05898e60a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,8 +8,8 @@ This file provides guidance to Claude Code when working with code in this reposi - **Architecture:** Pure ZSH plugin (no Node.js runtime required) - **Dependencies:** **ZERO** - No dependencies on Oh-My-Zsh, antidote, or any framework -- **Current Version:** v6.3.0 -- **Latest Release:** v6.3.0 (2026-02-03) +- **Current Version:** v6.4.0 +- **Latest Release:** v6.4.0 (2026-02-03) - **Install:** Homebrew (recommended), or any plugin manager (antidote, zinit, oh-my-zsh, manual) - **Optional:** Atlas integration for enhanced state management - **Health Check:** `flow doctor` for dependency verification @@ -427,7 +427,9 @@ flow-cli/ │ ├── inventory.zsh # Tool inventory generator │ ├── keychain-helpers.zsh # macOS Keychain secrets │ ├── config-validator.zsh # Config validation -│ ├── git-helpers.zsh # Git integration utilities +│ ├── git-helpers.zsh # Git integration + smart commits +│ ├── deploy-history-helpers.zsh # Deploy history (append-only YAML) +│ ├── deploy-rollback-helpers.zsh # Forward rollback (git revert) │ └── dispatchers/ # Smart command dispatchers (12) │ ├── cc-dispatcher.zsh # Claude Code │ ├── dot-dispatcher.zsh # Dotfiles + Secrets @@ -453,11 +455,14 @@ flow-cli/ ├── completions/ # ZSH completions ├── hooks/ # ZSH hooks ├── docs/ # Documentation (MkDocs) -├── tests/ # Test suite (462 tests) +├── tests/ # Test suite (543+ tests) │ ├── fixtures/ # Test fixtures │ │ └── demo-course/ # STAT-101 demo course for E2E -│ ├── e2e-teach-analyze.zsh # E2E: 29 tests -│ └── interactive-dog-teaching.zsh # Interactive: 10 tasks +│ ├── test-teach-deploy-v2-unit.zsh # Deploy v2 unit: 36 tests +│ ├── test-teach-deploy-v2-integration.zsh # Deploy v2 integration: 22 tests +│ ├── e2e-teach-deploy-v2.zsh # Deploy v2 E2E: 23 tests +│ ├── e2e-teach-analyze.zsh # E2E: 29 tests +│ └── interactive-dog-teaching.zsh # Interactive: 10 tasks └── .archive/ # Archived Node.js CLI ``` @@ -472,7 +477,9 @@ flow-cli/ | `lib/atlas-bridge.zsh` | Atlas integration | Optional state engine | | `lib/keychain-helpers.zsh` | macOS Keychain secrets | Touch ID support | | `lib/config-validator.zsh` | Config validation | Schema + hash validation | -| `lib/git-helpers.zsh` | Git integration | Teaching workflow | +| `lib/git-helpers.zsh` | Git integration | Smart commits, teaching | +| `lib/deploy-history-helpers.zsh` | Deploy history | Append-only YAML | +| `lib/deploy-rollback-helpers.zsh` | Deploy rollback | Forward rollback | | `lib/dispatchers/*.zsh` | Smart dispatchers | 12 active dispatchers | | `commands/*.zsh` | Core commands | work, dash, finish, etc. | | `docs/reference/MASTER-DISPATCHER-GUIDE.md` | Complete dispatcher docs | All 12 dispatchers | @@ -595,7 +602,7 @@ teach exam "Topic" # Generate exam via Scholar ### Test Suite Overview -**Status:** ✅ 462 tests total +**Status:** ✅ 543+ tests total **Documentation:** [Complete Testing Guide](docs/guides/TESTING.md) ```bash @@ -611,6 +618,11 @@ tests/test-teach-plan.zsh # Unit: 32 tests tests/test-teach-plan-security.zsh # Security: 24 tests (YAML injection, edge cases) tests/e2e-teach-plan.zsh # E2E: 15 tests (CRUD workflows) +# Teach deploy v2 tests (v6.4.0) +tests/test-teach-deploy-v2-unit.zsh # Unit: 34 tests +tests/test-teach-deploy-v2-integration.zsh # Integration: 22 tests +tests/e2e-teach-deploy-v2.zsh # E2E: 20 tests + # E2E tests (teach analyze) tests/e2e-teach-analyze.zsh # E2E: 29 tests (8 sections) @@ -707,29 +719,96 @@ export FLOW_DEBUG=1 ## Current Status -**Version:** v6.3.0 -**Latest Release:** v6.3.0 (2026-02-03) +**Version:** v6.4.0 +**Latest Release:** v6.4.0 (2026-02-03) **Status:** Production **Branch:** `dev` -**Release (latest):** https://github.com/Data-Wise/flow-cli/releases/tag/v6.3.0 +**Release (latest):** https://github.com/Data-Wise/flow-cli/releases/tag/v6.4.0 **Performance:** Sub-10ms for core commands, 3-10x speedup from optimization **Documentation:** https://Data-Wise.github.io/flow-cli/ -**Tests:** 107 tests (62 unit + 33 E2E + 12 interactive) for teach prompt + 34 teach style dogfood tests + 462 total tests +**Tests:** 543+ total tests (462 existing + 81 new teach deploy v2 tests) **Recent Improvements:** -- ✅ Website reorganization - 14 sections reduced to 7 (ADHD-friendly) -- ✅ 11 new teaching docs - REFCARDs, guides, tutorials, schema reference -- ✅ teach help system - 100% standards compliance -- ✅ MkDocs tags plugin - 14 topic tags across ~39 pages -- ✅ Section landing pages - Grid card navigation for all 4 main sections -- ✅ Build warnings eliminated - 28 → 0 (broken links to excluded files) -- ✅ Grid card emoji rendering - `pymdownx.emoji` + `attr_list` spacing fix +- ✅ teach deploy v2 - Direct merge (8-15s), smart commits, history, rollback, dry-run, CI mode +- ✅ Deploy history tracking - Append-only `.flow/deploy-history.yml` +- ✅ Forward rollback - `teach deploy --rollback` via git revert +- ✅ Legacy dead code removed - ~400 lines of old `_teach_deploy()` + `_teach_deploy_help()` +- ✅ 76 new tests - 34 unit + 22 integration + 20 E2E --- ## Recent Releases +### v6.4.0 - Teach Deploy v2: Direct Merge, History, Rollback (2026-02-03) + +**Released:** 2026-02-03 +**Branch:** `feature/teach-deploy-v2` +**Changes:** 8 features, 1 refactor, 76 new tests + +**Major Features:** + +- **Direct Merge Mode** (`--direct/-d`) — 8-15s deploys vs 45-90s PR workflow + - `_deploy_direct_merge()` — merge draft→production, push, no PR + - `--direct-push` kept as backward-compatible alias + - Smart commit messages auto-generated from changed file categories + +- **Smart Commit Messages** — Auto-categorized from file paths + - `_generate_smart_commit_message()` in `lib/git-helpers.zsh` + - Categories: content (lectures, assignments), config (_quarto.yml), style (CSS), data, deploy + - Overridable with `--message "text"` + +- **Deploy History Tracking** — Append-only `.flow/deploy-history.yml` + - `lib/deploy-history-helpers.zsh` — 4 functions (append, list, get, count) + - `cat >>` for writes (fast), `yq` for reads only + - Records: mode, commit hash, branch, file count, message, duration + - `teach deploy --history [N]` — show last N deploys + +- **Forward Rollback** (`--rollback [N]`) — Revert via `git revert` + - `lib/deploy-rollback-helpers.zsh` — 2 functions (rollback, perform_rollback) + - Interactive picker or explicit index + - Records rollback in history with mode "rollback" + - CI mode requires explicit index (no interactive picker) + +- **Dry-Run Preview** (`--dry-run`/`--preview`) — Preview without mutation + - `_deploy_dry_run_report()` — shows files, commit message, merge direction + - Works with both direct merge and PR mode + +- **CI Mode** (`--ci`) — Non-interactive deployment + - Auto-detect from TTY (`[[ ! -t 0 ]]`) + - 18 CI guards on all `read -r` prompts + - Explicit `--ci` flag override + +- **Shared Preflight** — Extracted `_deploy_preflight_checks()` + - Git repo check, config validation, branch detection + - Sets `DEPLOY_*` exported variables for all deploy modes + - `[ok]`/`[!!]` marker format + +- **.STATUS Auto-Updates** — `_deploy_update_status_file()` + - Updates `last_deploy`, `deploy_count`, `teaching_week` + - Teaching week calculated from `semester_info.start_date` + - Non-destructive: skips if `.STATUS` absent + +**Refactoring:** + +- Deleted legacy `_teach_deploy()` (~313 lines) from `teach-dispatcher.zsh` +- Deleted legacy `_teach_deploy_help()` (~85 lines) from `teach-dispatcher.zsh` +- All deploy routing uses `_teach_deploy_enhanced()` exclusively + +**New Files:** + +- `lib/deploy-history-helpers.zsh` (185 lines) +- `lib/deploy-rollback-helpers.zsh` (214 lines) +- `tests/test-teach-deploy-v2-unit.zsh` (34 tests) +- `tests/test-teach-deploy-v2-integration.zsh` (22 tests) +- `tests/e2e-teach-deploy-v2.zsh` (20 tests) + +**Testing:** 76 new tests (34 unit + 22 integration + 20 E2E), all passing + +**Stats:** 9 files changed, +2,687 / -581 lines + +--- + ### v6.3.0 - Teaching Style Consolidation + Help Compliance (2026-02-03) **Released:** 2026-02-03 @@ -1088,5 +1167,5 @@ git push origin main && git push origin v6.2.0 --- -**Last Updated:** 2026-02-03 (v6.3.0) -**Status:** Production Ready (v6.3.0) +**Last Updated:** 2026-02-03 (v6.4.0) +**Status:** Production Ready (v6.4.0) diff --git a/README.md b/README.md index a3802bd0b..6cf9b1711 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # flow-cli -[![Version](https://img.shields.io/badge/version-6.2.0-blue.svg)](https://github.com/Data-Wise/flow-cli/releases/tag/v6.2.0) +[![Version](https://img.shields.io/badge/version-6.4.0-blue.svg)](https://github.com/Data-Wise/flow-cli/releases/tag/v6.4.0) [![Tests](https://github.com/Data-Wise/flow-cli/actions/workflows/test.yml/badge.svg)](https://github.com/Data-Wise/flow-cli/actions) [![Docs](https://img.shields.io/badge/docs-online-brightgreen.svg)](https://data-wise.github.io/flow-cli/) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 88b36b628..574d2669d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,55 @@ The format follows [Keep a Changelog](https://keepachangelog.com/), and this pro --- +## [6.4.0] - 2026-02-03 + +### Added + +- **Teach Deploy v2** (`teach deploy --direct`) - Direct merge deployment with 8-15s cycle time + - `--direct` / `-d` - Direct merge deploy (draft → main, push) + - `--dry-run` / `--dry` - Preview deploy without side effects + - `--rollback [N]` / `--rb [N]` - Forward rollback via `git revert` (N=display index) + - `--history` / `--hist` - View deploy history table + - `--ci` - Non-interactive CI mode (auto-detected when no TTY) + - `-m "message"` - Custom commit message + - Smart commit messages - auto-categorize by file type (content, config, infra, deploy) + - `.STATUS` auto-update - Sets `last_deploy`, `deploy_count`, `teaching_week` + +- **Deploy History Tracking** (`.flow/deploy-history.yml`) - Append-only YAML history + - Records timestamp, mode, commit hashes, branch, file count, user, duration + - `yq`-based reading for list/get/count operations + - YAML injection prevention via single-quote escaping + +- **Deploy Rollback** - Forward rollback via `git revert` + - Interactive picker or explicit index (`--rollback 1`) + - Merge commit detection with `-m 1` parent specification + - Rollback recorded in history with `mode: "rollback"` + - CI mode requires explicit index (no interactive picker) + +### Fixed + +- Merge commit rollback now detects parent count and uses `git revert -m 1` +- `commit_before` captures target branch HEAD (not current branch) +- Dirty worktree guard prevents deploy with uncommitted changes +- Stderr capture pattern prevents silent failures (replaced `2>/dev/null` with `$(cmd 2>&1)`) +- Dead dry-run code path fixed (was unreachable due to wrong condition) +- `_deploy_cleanup_globals` prevents variable leakage between calls + +### Documentation + +- `docs/guides/TEACH-DEPLOY-GUIDE.md` (1,092 lines) - Complete user guide +- `docs/reference/REFCARD-DEPLOY-V2.md` (216 lines) - Quick reference card +- `docs/tutorials/31-teach-deploy-v2.md` (336 lines) - Step-by-step tutorial +- `docs/reference/MASTER-API-REFERENCE.md` - 14 new function entries + +### Tests + +- 81 new tests: 36 unit + 22 integration + 23 E2E +- 51-test dogfooding suite against demo-course fixture +- Merge commit rollback regression tests (diverged branches, parent detection) + +--- + ## [5.20.0] - 2026-01-28 ### Added diff --git a/docs/demos/tutorials/tutorial-teach-deploy.gif b/docs/demos/tutorials/tutorial-teach-deploy.gif index 77ce5e1b3..19d1c86ca 100644 Binary files a/docs/demos/tutorials/tutorial-teach-deploy.gif and b/docs/demos/tutorials/tutorial-teach-deploy.gif differ diff --git a/docs/demos/tutorials/tutorial-teach-deploy.tape b/docs/demos/tutorials/tutorial-teach-deploy.tape index 7108678fe..19d629847 100644 --- a/docs/demos/tutorials/tutorial-teach-deploy.tape +++ b/docs/demos/tutorials/tutorial-teach-deploy.tape @@ -1,6 +1,6 @@ -# VHS Demo: teach deploy - Preview Deployment -# Part of flow-cli Teaching Workflow v3.0 -# Tutorial: Deploy with preview workflow and status checking +# VHS Demo: teach deploy v2 - Direct Merge, History, Rollback +# Part of flow-cli v6.4.0 +# Tutorial 31: Fast Deployments with teach deploy v2 Output tutorial-teach-deploy.gif @@ -23,48 +23,47 @@ Type "clear" Enter Show # Title -Type "echo 'Teaching Workflow v3.0: teach deploy'" Enter -Sleep 1s -Type "echo 'Preview Deployment Workflow'" Enter +Type "echo '=== teach deploy v2 (flow-cli v6.4.0) ==='" Enter Sleep 2s -Type "" Enter -Sleep 1s - -# Show help -Type "echo 'Available options:'" Enter +# Step 1: Dry-run preview +Type "echo ''" Enter +Type "echo '1. Preview before deploying (dry-run):'" Enter Sleep 1s -Type "teach deploy --help | head -20" Enter +Type "teach deploy --dry-run --direct" Enter Sleep 4s -Type "" Enter +Type "echo ''" Enter Type "clear" Enter Sleep 1s -# Check status before deploy -Type "echo 'Check deployment status:'" Enter +# Step 2: Direct deploy +Type "echo '2. Fast direct deploy (8-15s):'" Enter Sleep 1s -Type "teach status" Enter -Sleep 3s +Type "teach deploy --direct -m 'content: week-05 lecture'" Enter +Sleep 5s -Type "" Enter +Type "echo ''" Enter +Type "clear" Enter Sleep 1s -# Preview deployment -Type "echo 'Deploy to preview branch:'" Enter +# Step 3: Check history +Type "echo '3. View deployment history:'" Enter Sleep 1s -Type "teach deploy --preview" Enter -Sleep 5s +Type "teach deploy --history" Enter +Sleep 4s -Type "" Enter +Type "echo ''" Enter +Type "clear" Enter Sleep 1s -# Show what happened -Type "echo 'Preview deployed to gh-pages-preview branch'" Enter -Sleep 2s -Type "echo 'Review at: https://username.github.io/course/preview/'" Enter -Sleep 2s +# Step 4: Rollback +Type "echo '4. Rollback if something goes wrong:'" Enter +Sleep 1s +Type "teach deploy --rollback 1 --ci" Enter +Sleep 4s -Type "" Enter -Type "echo '✓ Preview deployment complete!'" Enter -Sleep 2s +Type "echo ''" Enter +Sleep 1s +Type "echo 'Done! Deploy, track, rollback — all in seconds.'" Enter +Sleep 3s diff --git a/docs/guides/TEACH-DEPLOY-GUIDE.md b/docs/guides/TEACH-DEPLOY-GUIDE.md index 7220eb9d0..65ddc33e9 100644 --- a/docs/guides/TEACH-DEPLOY-GUIDE.md +++ b/docs/guides/TEACH-DEPLOY-GUIDE.md @@ -8,7 +8,9 @@ tags: > Deploy your course website from local preview to production with confidence. > -> **Version:** v6.1.0+ | **Command:** `teach deploy` +> **Version:** v6.4.0+ | **Command:** `teach deploy` + +![teach deploy v2 Demo](../demos/tutorials/tutorial-teach-deploy.gif) --- @@ -36,6 +38,12 @@ The `teach deploy` command provides a safe, validated deployment workflow that: - **Manages indexes** - Updates navigation links (ADD/UPDATE/REMOVE) - **Creates PRs** - Uses draft → production branch workflow (no direct pushes) - **Validates prerequisites** - Ensures concepts are introduced before use (optional) +- **Direct merge mode** - 8-15s deploys without PR overhead (`--direct`) +- **Smart commit messages** - Auto-categorized from changed file paths +- **Deploy history** - Tracks every deployment in `.flow/deploy-history.yml` +- **Rollback** - Forward rollback via `git revert` (`--rollback`) +- **Dry-run preview** - Preview without mutation (`--dry-run`) +- **CI mode** - Non-interactive for automated pipelines (`--ci`) ### Design Philosophy @@ -115,12 +123,14 @@ git checkout draft ## Deployment Modes -`teach deploy` supports two modes: +`teach deploy` supports multiple modes: -| Mode | Trigger | Use Case | -|------|---------|----------| -| **Full Site** | `teach deploy` | Deploy all changes from draft → production | -| **Partial** | `teach deploy ` | Deploy specific files/directories | +| Mode | Trigger | Speed | Use Case | +|------|---------|-------|----------| +| **Full Site (PR)** | `teach deploy` | 45-90s | Review before production | +| **Direct Merge** | `teach deploy -d` | 8-15s | Quick fixes, solo instructor | +| **Partial** | `teach deploy ` | Varies | Deploy specific files/directories | +| **Dry-Run** | `teach deploy --dry-run` | <1s | Preview all operations | ### Full Site Deployment @@ -163,6 +173,126 @@ teach deploy lectures/ --- +## Direct Merge Mode (v6.4.0) + +Skip the PR workflow for fast, direct deployment: + +```bash +# Basic direct deploy +teach deploy --direct + +# With custom commit message +teach deploy -d -m "Week 5 lecture updates" + +# CI-friendly direct deploy +teach deploy --ci -d +``` + +**Process:** +1. Run preflight checks +2. Generate smart commit message (or use `--message`) +3. Merge draft → production directly +4. Push to remote +5. Log in deploy history +6. Update .STATUS + +**When to use:** +- Solo instructor (no review needed) +- Quick fixes (typos, minor updates) +- CI/CD pipelines +- When 45-90s PR workflow is too slow + +--- + +## Deploy History & Rollback (v6.4.0) + +### Viewing History + +```bash +# Show last 10 deployments +teach deploy --history + +# Show last 20 +teach deploy --history 20 +``` + +**Output:** +``` +Recent deployments: + +# When Mode Files Message +1 2026-02-03 14:30 direct 3 content: week-05 lecture +2 2026-02-02 09:15 pr 15 deploy: full site update +3 2026-02-01 16:45 partial 2 content: assignment 3 +``` + +### Rollback + +Revert any deployment safely using forward rollback (`git revert`): + +```bash +# Interactive picker +teach deploy --rollback + +# Rollback most recent deployment +teach deploy --rollback 1 + +# Rollback 2nd most recent (CI mode) +teach deploy --rollback 2 --ci +``` + +**Safety:** Rollback uses `git revert` (not `git reset`), preserving full history. The rollback itself is recorded in deploy history with mode "rollback". + +### History File + +Stored at `.flow/deploy-history.yml` (git-tracked, append-only): + +```yaml +deploys: + - timestamp: '2026-02-03T14:30:22-06:00' + mode: 'direct' + commit_hash: 'a1b2c3d4' + commit_before: 'e5f6g7h8' + branch_from: 'draft' + branch_to: 'main' + file_count: 15 + commit_message: 'content: week-05 lecture' +``` + +--- + +## Dry-Run Preview (v6.4.0) + +Preview deployment without making any changes: + +```bash +# Preview full site deploy +teach deploy --dry-run + +# Preview direct merge +teach deploy --dry-run --direct + +# Preview with custom message +teach deploy --preview -m "Week 5" +``` + +**Output:** +``` +DRY RUN — No changes will be made + +Would deploy 3 files: + lectures/week-05.qmd + scripts/analysis.R (dependency) + home_lectures.qmd (index update) + +Would commit: "content: week-05 lecture, analysis script" +Would merge: draft -> production (direct mode) +Would log: deploy #12 to .flow/deploy-history.yml +Would update: .STATUS (teaching_week: 5) +``` + +--- + ## Full Site Deployment ### Step-by-Step Workflow @@ -927,34 +1057,25 @@ https://deploy-preview-42--course-site.netlify.app ### Rollback Procedure -If deployment breaks production: - -**1. Revert merge commit:** +**Using teach deploy (v6.4.0+):** ```bash -# Find merge commit -git log --oneline main | head -5 - -# Revert -git revert -m 1 -git push origin main -``` +# View recent deployments +teach deploy --history -**2. Wait for GitHub Pages redeploy** +# Rollback most recent +teach deploy --rollback 1 -**3. Fix issue on draft branch:** - -```bash -git checkout draft -# Fix broken content -git add . -git commit -m "Fix deployment issue" +# Verify site +open https://username.github.io/course-repo/ ``` -**4. Re-deploy:** +**Manual rollback (if needed):** ```bash -teach deploy +git log --oneline main | head -5 +git revert -m 1 +git push origin main ``` --- @@ -969,5 +1090,5 @@ teach deploy --- -**Last Updated:** 2026-02-02 -**Version:** v6.1.0 +**Last Updated:** 2026-02-03 +**Version:** v6.4.0 diff --git a/docs/help/QUICK-REFERENCE.md b/docs/help/QUICK-REFERENCE.md index c65b7836d..1a59d218f 100644 --- a/docs/help/QUICK-REFERENCE.md +++ b/docs/help/QUICK-REFERENCE.md @@ -949,8 +949,11 @@ teach scholar status teach analyze lectures/ # Analyze content teach exam "Midterm Topics" # Generate exam -# Deployment -teach deploy # Publish site +# Deployment (v6.4.0) +teach deploy --direct # Direct merge deploy +teach deploy --dry-run # Preview deploy +teach deploy --rollback 1 # Undo last deploy +teach deploy --history # Show deploy log teach status # Verify ``` diff --git a/docs/index.md b/docs/index.md index 5f62b89b4..45d9573cf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,7 +7,7 @@ tags: # Flow CLI -[![Version](https://img.shields.io/badge/version-v6.2.0-blue)](https://github.com/Data-Wise/flow-cli/releases/tag/v6.2.0) +[![Version](https://img.shields.io/badge/version-v6.4.0-blue)](https://github.com/Data-Wise/flow-cli/releases/tag/v6.4.0) [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![Tests](https://img.shields.io/github/actions/workflow/status/Data-Wise/flow-cli/test.yml?label=tests&branch=main)](https://github.com/Data-Wise/flow-cli/actions/workflows/test.yml) [![Docs](https://img.shields.io/github/actions/workflow/status/Data-Wise/flow-cli/docs.yml?label=docs&branch=main)](https://github.com/Data-Wise/flow-cli/actions/workflows/docs.yml) @@ -26,9 +26,9 @@ tags: ``` **That's it!** No configuration required. -!!! success "🎉 What's New: v6.2.0 - Docs Overhaul + Website Reorganization" - Reduced navigation from 14 to 7 sections. Landing pages with grid cards. Tags for cross-cutting discovery. - [→ See what's new](RELEASES.md){ .md-button } +!!! success "🎉 What's New: v6.4.0 - teach deploy v2" + Direct deploy in 8-15 seconds. Smart commit messages. Deploy history tracking. Safe rollback via `git revert`. + [→ See what's new](CHANGELOG.md){ .md-button } [→ All releases](RELEASES.md){ .md-button } --- @@ -185,7 +185,7 @@ Choose your path based on what you need right now: --- - Deploy courses in < 2 minutes + Deploy courses in 8-15 seconds [→ Teaching Guide](guides/TEACHING-SYSTEM-ARCHITECTURE.md) @@ -266,4 +266,4 @@ catch "idea" # Quick capture --- -**v6.2.0** · Pure ZSH · Zero Dependencies · MIT License +**v6.4.0** · Pure ZSH · Zero Dependencies · MIT License diff --git a/docs/reference/MASTER-API-REFERENCE.md b/docs/reference/MASTER-API-REFERENCE.md index d3b518d32..eeff77263 100644 --- a/docs/reference/MASTER-API-REFERENCE.md +++ b/docs/reference/MASTER-API-REFERENCE.md @@ -6778,6 +6778,54 @@ _teach_plan_help --- +### teach-deploy v2 Helpers + +**Source:** `lib/deploy-history-helpers.zsh`, `lib/deploy-rollback-helpers.zsh`, `lib/dispatchers/teach-deploy-enhanced.zsh` +**Added:** v6.4.0 + +#### Deploy History (`lib/deploy-history-helpers.zsh`) + +Append-only YAML deploy history tracking at `.flow/deploy-history.yml`. + +| Function | Signature | Purpose | +|----------|-----------|---------| +| `_deploy_history_append` | ` [pr_number] [tag] [duration]` | Append deploy entry (never rewrites file) | +| `_deploy_history_list` | `[count]` | Display recent deploys as formatted table (default: 5) | +| `_deploy_history_get` | `` | Retrieve entry by display index (1=most recent). Sets `DEPLOY_HIST_*` variables | +| `_deploy_history_count` | (none) | Print total number of recorded deploys | + +**Exported variables** (from `_deploy_history_get`): + +`DEPLOY_HIST_TIMESTAMP`, `DEPLOY_HIST_MODE`, `DEPLOY_HIST_COMMIT`, `DEPLOY_HIST_COMMIT_BEFORE`, `DEPLOY_HIST_BRANCH_FROM`, `DEPLOY_HIST_BRANCH_TO`, `DEPLOY_HIST_FILE_COUNT`, `DEPLOY_HIST_MESSAGE`, `DEPLOY_HIST_PR`, `DEPLOY_HIST_TAG`, `DEPLOY_HIST_USER`, `DEPLOY_HIST_DURATION` + +#### Deploy Rollback (`lib/deploy-rollback-helpers.zsh`) + +Forward rollback via `git revert` with history tracking. + +| Function | Signature | Purpose | +|----------|-----------|---------| +| `_deploy_rollback` | `[N] [--ci]` | Main rollback with interactive picker. N=display index (1=most recent) | +| `_deploy_perform_rollback` | ` ` | Execute forward rollback. Detects merge commits (parent count > 1) and uses `-m 1` | + +#### Deploy Orchestration (`lib/dispatchers/teach-deploy-enhanced.zsh`) + +| Function | Signature | Purpose | +|----------|-----------|---------| +| `_deploy_preflight_checks` | `` | Validate git state, config, branches. Sets `DEPLOY_*` variables | +| `_deploy_direct_merge` | ` ` | Direct merge deploy (push draft, checkout prod, merge, push) | +| `_deploy_dry_run_report` | (reads `DEPLOY_*` globals) | Preview deploy without side effects | +| `_deploy_update_status_file` | (reads `.STATUS` + history) | Update `.STATUS` with `last_deploy`, `deploy_count`, `teaching_week` | +| `_teach_deploy_enhanced` | `[flags...]` | Main entry point. Parses flags, dispatches to deploy/rollback/history/dry-run | +| `_deploy_cleanup_globals` | (none) | Unset `DEPLOY_COMMIT_BEFORE`, `DEPLOY_COMMIT_AFTER`, `DEPLOY_DURATION`, `DEPLOY_MODE` | +| `_teach_deploy_enhanced_help` | (none) | Print formatted help output | +| `_check_prerequisites_for_deploy` | (none) | Verify `git` and optional `yq` are available | + +**Exported variables** (from `_deploy_direct_merge` and `_deploy_perform_rollback`): + +`DEPLOY_COMMIT_BEFORE`, `DEPLOY_COMMIT_AFTER`, `DEPLOY_DURATION`, `DEPLOY_MODE` + +--- + ## See Also - [MASTER-DISPATCHER-GUIDE.md](MASTER-DISPATCHER-GUIDE.md) - Complete dispatcher reference diff --git a/docs/reference/MASTER-DISPATCHER-GUIDE.md b/docs/reference/MASTER-DISPATCHER-GUIDE.md index cad270873..40df92321 100644 --- a/docs/reference/MASTER-DISPATCHER-GUIDE.md +++ b/docs/reference/MASTER-DISPATCHER-GUIDE.md @@ -1641,28 +1641,30 @@ Creates comprehensive course analysis. #### Deployment Workflows +**Quick direct deploy (v6.4.0):** +```bash +teach deploy --direct # Merge draft → main, push +teach deploy -d -m "week 5" # Direct with custom message +teach deploy --dry-run # Preview first +``` + +**Deploy with rollback safety:** +```bash +teach deploy --direct # Deploy +teach deploy --history # Check history +teach deploy --rollback 1 # Undo most recent deploy +``` + **Preview before deploy:** ```bash qu preview # Review site locally # Fix any issues -teach deploy +teach deploy --direct # Deploy to production ``` -**Deploy with validation:** -```bash -# Check for broken links -markdown-link-check lectures/**/*.md - -# Deploy -teach deploy - -# Verify -curl -I https://username.github.io/stat-440/ -``` - --- #### Configuration Migration (v5.20.0) @@ -1981,8 +1983,14 @@ qu preview - `teach exam --template ` - Use template - `teach quiz ` - Generate quiz -**Deployment:** -- `teach deploy` - Deploy to GitHub Pages +**Deployment (v6.4.0):** +- `teach deploy` - Deploy via PR (default) +- `teach deploy --direct` / `teach dep -d` - Direct merge deploy +- `teach deploy --dry-run` / `teach dep --dry` - Preview without deploying +- `teach deploy --rollback [N]` / `teach dep --rb [N]` - Rollback deployment N (1=most recent) +- `teach deploy --history` / `teach dep --hist` - Show deploy history +- `teach deploy --ci` - CI/non-interactive mode +- `teach deploy -m "msg"` - Custom commit message **Migration (v5.20.0):** - `teach migrate-config` - Extract lesson plans from config diff --git a/docs/reference/REFCARD-DEPLOY-V2.md b/docs/reference/REFCARD-DEPLOY-V2.md new file mode 100644 index 000000000..1bb66c868 --- /dev/null +++ b/docs/reference/REFCARD-DEPLOY-V2.md @@ -0,0 +1,216 @@ +--- +tags: [reference, teaching, deploy] +--- + +# Quick Reference: teach deploy v2 + +> Enhanced Git-based Course Deployment with PR Workflow & Rollback + +## Commands + +| Command | Alias | Description | +|---------|-------|-------------| +| `teach deploy` | `teach dep` | Full site deploy via PR (draft → production) | +| `teach deploy -d` | `teach dep -d` | Direct merge, no PR (8-15s) | +| `teach deploy --dry-run` | `teach dep --preview` | Preview without executing | +| `teach deploy --rollback [N]` | `teach dep --rb [N]` | Rollback deployment N (1=most recent) | +| `teach deploy --history [N]` | `teach dep --hist [N]` | Show last N deploys (default: 10) | +| `teach deploy ` | `teach dep ` | Partial deploy (specific files/dirs) | + +## Deploy Modes + +| Mode | Command | Speed | Use Case | +|------|---------|-------|----------| +| PR (default) | `teach deploy` | 45-90s | Review before production | +| Direct merge | `teach deploy -d` | 8-15s | Quick fixes, solo instructor | +| Partial | `teach deploy file.qmd` | Varies | Single file updates | +| Dry-run | `teach deploy --dry-run` | <1s | Preview before executing | + +## Flags + +| Flag | Short | Description | +|------|-------|-------------| +| `--direct` | `-d` | Direct merge mode (no PR) | +| `--dry-run` | `--preview` | Preview without executing | +| `--rollback [N]` | `--rb [N]` | Revert deployment N from history | +| `--history [N]` | `--hist [N]` | Show recent deployments | +| `--ci` | | Force non-interactive mode | +| `--message "text"` | `-m` | Custom commit message | +| `--auto-commit` | | Auto-commit dirty files | +| `--auto-tag` | | Tag with timestamp | +| `--skip-index` | | Skip index management | +| `--check-prereqs` | | Validate prerequisites | +| `--direct-push` | | Alias for `--direct` (backward compat) | + +## Smart Commit Messages + +Auto-generated from changed file paths: + +``` +lectures/*.qmd → "content: week-05 lecture" +assignments/*.qmd → "content: assignment 3" +_quarto.yml → "config: quarto settings" +styles/*.css → "style: theme update" +Mixed files → "deploy: STAT-101 update" +``` + +Override with custom message: + +```bash +teach deploy -d -m "Week 5 lecture + lab" +``` + +## Deploy History + +Stored in `.flow/deploy-history.yml` (git-tracked): + +```yaml +deploys: + - timestamp: '2026-02-03T14:30:22-06:00' + mode: 'direct' + commit_hash: 'a1b2c3d4' + branch_from: 'draft' + branch_to: 'main' + file_count: 15 + commit_message: 'content: week-05 lecture' +``` + +View history: + +```bash +teach deploy --history # Last 10 deploys +teach deploy --history 20 # Last 20 deploys +``` + +## Rollback + +```bash +teach deploy --rollback # Interactive picker +teach deploy --rollback 1 # Most recent deploy +teach deploy --rollback 2 --ci # 2nd most recent, non-interactive +``` + +Uses `git revert` (forward rollback, not destructive reset). Merge commits are detected automatically and reverted with `-m 1` (parent specification). Rollback is recorded in history with `mode: "rollback"`. + +## CI Mode + +Auto-detected when no TTY (`[[ ! -t 0 ]]`), or forced with `--ci`: + +```bash +teach deploy --ci -d # Direct merge, no prompts +echo | teach deploy # Auto-detected (piped input) +``` + +## .STATUS Auto-Updates + +After successful deploy, non-destructively updates `.STATUS`: + +- `last_deploy:` → today's date +- `deploy_count:` → total deploys from history +- `teaching_week:` → calculated from `semester_info.start_date` + +Skips if `.STATUS` absent. + +## Output Format (Direct Mode) + +``` + Pre-flight Checks +───────────────────────────────────────────────── + [ok] Git repository + [ok] Config file found + [ok] On draft branch + [ok] Working tree clean + [ok] No production conflicts + + Smart commit: content: week-05 lecture + + Direct merge: draft -> production + [ok] Merged successfully + [ok] Pushed to origin/production + + History logged: #12 (2026-02-03 14:30) + .STATUS updated + + Direct deployment complete + Site: https://example.github.io/stat-545/ +``` + +## Configuration + +In `.flow/teach-config.yml`: + +```yaml +git: + draft_branch: draft # Default: "draft" + production_branch: main # Default: "main" + auto_pr: true # Default: true + require_clean: true # Default: true +``` + +## Workflows + +### Quick deploy (direct merge) + +```bash +# Make changes on draft branch +teach deploy -d +# 8-15 seconds → live +``` + +### Deploy via PR (default) + +```bash +# Make changes on draft branch +teach deploy +# Opens PR → review → merge → live +``` + +### Partial deploy (specific files) + +```bash +teach deploy lectures/week-05.qmd +teach deploy lectures/ assignments/hw-03.qmd +``` + +### Preview before deploying + +```bash +teach deploy --dry-run +# Shows what would happen without executing +``` + +### Rollback to previous version + +```bash +teach deploy --rollback +# Interactive picker shows recent deploys +# Select deployment to revert +``` + +### Non-interactive (CI/CD) + +```bash +teach deploy --ci -d -m "Auto-deploy from GitHub Actions" +``` + +## Files + +| File | Purpose | +|------|---------| +| `lib/dispatchers/teach-deploy-enhanced.zsh` | Main deploy implementation | +| `lib/git-helpers.zsh` | Smart commit messages | +| `lib/deploy-history-helpers.zsh` | History tracking | +| `lib/deploy-rollback-helpers.zsh` | Rollback via git revert | +| `.flow/deploy-history.yml` | Deploy history (git-tracked) | +| `.STATUS` | Auto-updated after deploy | + +## Related + +- `teach doctor` - Health check with deploy validation +- `teach init` - Initialize teaching project +- Guide: `docs/guides/TEACH-DEPLOY-GUIDE.md` +- Spec: `docs/specs/SPEC-teach-deploy-v2-2026-02-03.md` + +--- + +*v6.4.0 - teach deploy v2 command* diff --git a/docs/specs/SPEC-teach-deploy-v2-2026-02-03.md b/docs/specs/SPEC-teach-deploy-v2-2026-02-03.md new file mode 100644 index 000000000..ab6af05cc --- /dev/null +++ b/docs/specs/SPEC-teach-deploy-v2-2026-02-03.md @@ -0,0 +1,218 @@ +# SPEC: teach deploy v2 — STAT-545 Port + New Features + +**Status:** draft +**Created:** 2026-02-03 +**From Brainstorm:** BRAINSTORM-teach-deploy-v2-2026-02-03.md +**Target Version:** v6.4.0 + +--- + +## Overview + +Enhance `teach deploy` with 8 features ported from STAT-545's battle-tested deploy workflow and new capabilities. Consolidate two deploy code paths into a single enhanced implementation. Adds direct merge mode (8-15s deploys), smart commit messages, deployment history with rollback, dry-run preview, CI support, and .STATUS auto-updates. + +--- + +## Primary User Story + +**As a** solo course instructor using flow-cli, +**I want** fast, safe, trackable deployments with smart defaults, +**So that** I can deploy course content in under 15 seconds with full history and rollback capability. + +### Acceptance Criteria + +- [ ] `teach deploy --direct` completes in <15 seconds (vs 45-90s PR mode) +- [ ] Smart commit messages generated automatically from changed files +- [ ] `teach deploy --dry-run` previews all operations without mutation +- [ ] `.flow/deploy-history.yml` records every deployment +- [ ] `teach deploy --rollback` reverts any recent deployment +- [ ] `teach deploy --ci` works without interactive prompts +- [ ] `.STATUS` auto-updates teaching week on deploy +- [ ] Old `_teach_deploy()` dead code removed from teach-dispatcher.zsh +- [ ] All existing tests pass (`./tests/run-all.sh`) +- [ ] 65+ new tests across 3 test files + +--- + +## Secondary User Stories + +**As a** CI/CD pipeline, +**I want** non-interactive deploy mode, +**So that** GitHub Actions can deploy without prompts. + +**As an** instructor who made a mistake, +**I want** to rollback the last deployment, +**So that** students don't see broken content. + +--- + +## Architecture + +``` +teach deploy [args] + → teach-dispatcher.zsh (routing only) + → _teach_deploy_enhanced() + ├── Parse flags + ├── _deploy_preflight_checks() + ├── Mode dispatch: + │ ├── --dry-run → _deploy_dry_run_report() + │ ├── --rollback → _deploy_rollback() + │ ├── --direct → _deploy_direct_merge() + │ ├── partial → _deploy_partial() + │ └── default → _deploy_full_site() + └── Post-deploy: + ├── _deploy_history_append() + └── _deploy_update_status_file() +``` + +--- + +## API Design + +| Flag | Short | Purpose | +|------|-------|---------| +| `--direct` | `-d` | Direct merge mode (no PR) | +| `--dry-run` | `--preview` | Preview without executing | +| `--rollback [N]` | | Revert deployment N from history | +| `--ci` | | Force non-interactive mode | +| `--message "text"` | `-m` | Custom commit message | +| `--auto-commit` | | Auto-commit dirty files | +| `--auto-tag` | | Tag with timestamp | +| `--skip-index` | | Skip index management | +| `--check-prereqs` | | Validate prerequisites | +| `--direct-push` | | Alias for `--direct` (backward compat) | + +--- + +## Data Models + +### deploy-history.yml + +```yaml +deploys: + - timestamp: '2026-02-03T14:30:22-06:00' + mode: 'direct' + commit_hash: 'a1b2c3d4' + commit_before: 'e5f6g7h8' + branch_from: 'draft' + branch_to: 'production' + files_deployed: [] + file_count: 15 + commit_message: 'content: week-05 lecture' + pr_number: null + tag: null + user: 'dt' + duration_seconds: 12 +``` + +--- + +## Dependencies + +- `yq` — YAML parsing (already a teach dependency) +- `gh` — GitHub CLI for PR creation (already used) +- `git` — Core operations (already used) +- No new external dependencies + +--- + +## UI/UX Specifications + +### Deploy Output Format + +``` + Deploying to production... + + Pre-flight: + [ok] Git repository + [ok] Config file found + [ok] On draft branch + [ok] Working tree clean + [ok] No production conflicts + + Smart commit: content: week-05 lecture, assignment 3, config + + Direct merge: draft -> production + [ok] Merged successfully + [ok] Pushed to origin/production + + History logged: #12 (2026-02-03 14:30) + .STATUS updated: week 5, deploy #12 + + Done in 11s + Site: https://example.github.io/stat-545/ +``` + +### Dry-Run Output + +``` + DRY RUN — No changes will be made + + Would deploy 3 files: + lectures/week-05.qmd + scripts/analysis.R (dependency) + home_lectures.qmd (index update) + + Would commit: "content: week-05 lecture, analysis script" + Would merge: draft -> production (direct mode) + Would log: deploy #12 to .flow/deploy-history.yml + Would update: .STATUS (teaching_week: 5) +``` + +### Rollback Interactive + +``` + Recent deployments: + + # When Mode Files Message + 1 2026-02-03 14:30 direct 3 content: week-05 lecture + 2 2026-02-02 09:15 pr 15 deploy: full site update + 3 2026-02-01 16:45 partial 2 content: assignment 3 + + Rollback which deployment? [1]: +``` + +### Accessibility + +- All output uses existing `_flow_log_*` color functions with fallbacks +- `--ci` mode outputs plain text (no ANSI) +- Help follows CONVENTIONS.md 9-rule compliance + +--- + +## Open Questions + +1. Should `--rollback` of a PR deploy create a revert PR, or direct-push the revert? +2. Should deploy-history.yml be git-tracked or gitignored? + +--- + +## Review Checklist + +- [ ] All 9 features implemented +- [ ] Old dead code removed from teach-dispatcher.zsh +- [ ] Flag matrix interactions tested +- [ ] 65+ new tests pass +- [ ] Existing 462+ tests still pass +- [ ] Help compliance passes (`flow doctor --help-check`) +- [ ] CLAUDE.md updated +- [ ] Backward compatibility: `--direct-push` still works + +--- + +## Implementation Notes + +- Implementation follows 7 phases (see plan file) +- Phase 1 (CI mode) must come first — all other features depend on interactivity awareness +- Smart commit messages reuse STAT-545's `generate_smart_message()` categorization logic +- Deploy history uses append-only YAML (no full-file rewrite via yq) +- Rollback is "forward rollback" via `git revert`, not destructive `git reset` +- .STATUS updates are non-destructive: skip if file doesn't exist, skip teaching_week if no semester_info.start_date + +--- + +## History + +| Date | Change | +|------|--------| +| 2026-02-03 | Initial spec from deep brainstorm session | diff --git a/docs/teaching/index.md b/docs/teaching/index.md index 11a35f3e9..c9df0fa1b 100644 --- a/docs/teaching/index.md +++ b/docs/teaching/index.md @@ -72,10 +72,11 @@ The flow-cli teaching workflow provides a complete solution for managing Quarto- ### Key Capabilities -1. **Fast Deployment** (< 2 minutes) - - Branch-based draft/production workflow - - Preview changes before publishing - - Automated GitHub Pages deployment +1. **Fast Deployment** (8-15 seconds with direct mode) + - Direct merge: `teach deploy -d` (8-15s, no PR) + - PR workflow: `teach deploy` (45-90s, review + checks) + - Deploy history tracking and safe rollback + - Preview with `--dry-run` before deploying 2. **Health Monitoring** - Dependency verification (`teach doctor`) @@ -136,7 +137,9 @@ teach exam "Topic Name" teach quiz "Topic Name" teach slides "Topic Name" -# Deploy to production +# Deploy to production (fast direct mode) +teach deploy -d +# Or via PR workflow teach deploy ``` diff --git a/docs/tutorials/31-teach-deploy-v2.md b/docs/tutorials/31-teach-deploy-v2.md new file mode 100644 index 000000000..fc256ec57 --- /dev/null +++ b/docs/tutorials/31-teach-deploy-v2.md @@ -0,0 +1,338 @@ +--- +tags: + - tutorial + - teaching + - deploy +--- + +# Tutorial 31: Fast Deployments with teach deploy v2 + +> Learn to deploy your course website in seconds instead of minutes. + +## What You'll Learn + +- Deploy directly to production without PRs (8-15 seconds) +- Use smart auto-generated commit messages +- Preview changes before deploying +- View deployment history +- Rollback problematic deployments +- Run deployments in CI/automation + +![teach deploy v2 Demo](../demos/tutorials/tutorial-teach-deploy.gif) + +## Prerequisites + +- flow-cli v6.4.0+ +- Git, yq, and gh CLI installed +- A course project with `.flow/teach-config.yml` +- Draft and production branches configured (e.g., `dev` and `gh-pages`) + +## Step 1: Your First Direct Deploy + +The traditional PR workflow takes 45-90 seconds: + +```bash +# The old way (slow) +teach deploy +``` + +This creates a PR, waits for GitHub checks, merges, and deploys. For quick updates, this is overkill. + +Try the new direct deploy: + +```bash +# The new way (fast - 8-15 seconds) +teach deploy --direct +``` + +Expected output: + +``` +🚀 Direct Deploy to gh-pages + +Changes to deploy: + M lectures/week-05.qmd + M _quarto.yml + +Commit message: content: week-05 lecture + +[✓] All safety checks passed +[✓] Pushed to gh-pages +[✓] Deployment recorded + +🎉 Deploy complete in 12s +``` + +Use `-d` as a shortcut for `--direct`. + +## Step 2: Smart Commit Messages + +Deploy automatically generates commit messages from your file paths: + +Edit a lecture file: + +```bash +# After editing lectures/week-05.qmd +teach deploy -d +# → "content: week-05 lecture" +``` + +Edit a config file: + +```bash +# After editing _quarto.yml +teach deploy -d +# → "config: quarto settings" +``` + +Edit multiple files: + +```bash +# After editing 3 different files +teach deploy -d +# → "deploy: 3 file updates" +``` + +Override with a custom message: + +```bash +teach deploy -d -m "Fix typo in regression notes" +# → Uses your custom message +``` + +## Step 3: Preview with Dry-Run + +Always preview before deploying to production: + +```bash +teach deploy --dry-run --direct +``` + +Expected output: + +``` +🔍 Deploy Preview (DRY RUN) + +Changes to deploy: + M lectures/week-05.qmd + M lectures/week-06.qmd + +Commit message: deploy: 2 file updates +Target branch: gh-pages + +Would execute: + 1. git push origin gh-pages + 2. Record deployment history + 3. Trigger GitHub Pages + +🔹 No changes made (dry-run mode) +``` + +This shows exactly what would happen without actually deploying. + +## Step 4: Deploy History + +View your past deployments: + +```bash +teach deploy --history +``` + +Output: + +``` +Recent Deployments +───────────────────────────────────────────────────────── + + # Date Commit Message Files Type + ───────────────────────────────────────────────────────────────────────── + 1 2026-02-03 14:23 a3f8d92 content: week-05 lecture 1 direct + 2 2026-02-03 10:15 b2e4c81 config: quarto settings 1 direct + 3 2026-02-02 16:30 c9a1f45 deploy: 3 file updates 3 direct + 4 2026-02-02 09:00 d8b2c33 Weekly content update 12 pr + +Recent: 4 deploys (3 direct, 1 PR) | Avg time: 11s +``` + +Limit results: + +```bash +teach deploy --history --limit 10 +``` + +View specific deployment: + +```bash +teach deploy --history --show 1 +``` + +The history is stored in `.flow/deploy-history.yml`. + +## Step 5: Rollback a Deployment + +Made a mistake? Roll back to the previous state: + +```bash +teach deploy --rollback 1 +``` + +This performs a **forward rollback** using `git revert`: + +``` +🔄 Rolling back deployment #1 + +Previous state: + Commit: a3f8d92 + Message: content: week-05 lecture + Date: 2026-02-03 14:23 + +Creating revert commit... +[✓] Reverted a3f8d92 +[✓] Pushed to gh-pages +[✓] Rollback recorded in history + +🎉 Rollback complete +``` + +Safety guarantees: + +- ✅ **Non-destructive** - Creates new revert commit (preserves history) +- ✅ **Traceable** - Rollback recorded in deploy history +- ✅ **Safe for collaboration** - Works even if others have pulled + +Roll back an older deployment: + +```bash +teach deploy --rollback 3 +# Reverts deployment #3 from history (use --history to see index) +``` + +## Step 6: CI Mode for Automation + +Run deployments in scripts or CI without interactive prompts: + +```bash +teach deploy --ci -d -m "Automated weekly deploy" +``` + +The `--ci` flag: + +- ✅ Disables all interactive prompts +- ✅ Auto-detects TTY (no manual flag needed in CI) +- ✅ Exits with proper status codes (0 = success, 1 = failure) + +Example GitHub Actions workflow: + +```yaml +- name: Deploy course website + run: teach deploy --ci -d -m "Automated deploy from CI" +``` + +## Step 7: Combining Flags + +Practical flag combinations for common workflows: + +Quick deploy with automatic tagging: + +```bash +teach deploy -d --auto-tag +# Deploys and creates a git tag (v1.2.0, v1.2.1, etc.) +``` + +CI direct deploy with custom message: + +```bash +teach deploy --ci -d -m "Weekly content update" +# Non-interactive, direct, custom message +``` + +Preview partial deploy: + +```bash +teach deploy --dry-run lectures/week-05.qmd +# Preview deploying just one file +``` + +Safe production deploy: + +```bash +teach deploy --dry-run -d && teach deploy -d +# Preview first, then deploy +``` + +## Step 8: Understanding Deploy Modes + +Deploy v2 supports two modes: + +### Direct Mode (Fast - 8-15s) + +```bash +teach deploy --direct +``` + +- ✅ No PR created +- ✅ Direct push to production branch +- ✅ Minimal GitHub API calls +- ⚠️ Use for: quick fixes, content updates, trusted changes + +### PR Mode (Safe - 45-90s) + +```bash +teach deploy +``` + +- ✅ Creates PR for review +- ✅ Runs GitHub checks +- ✅ Audit trail +- ⚠️ Use for: major changes, breaking updates, collaborative courses + +Choose based on your needs: + +| Scenario | Mode | Command | +|----------|------|---------| +| Fix typo | Direct | `teach deploy -d -m "Fix typo"` | +| Weekly update | Direct | `teach deploy -d` | +| New semester | PR | `teach deploy` | +| Major redesign | PR | `teach deploy` | + +## What You Learned + +You now know how to: + +1. ✅ Deploy in 8-15 seconds with `--direct` +2. ✅ Use smart auto-generated commit messages +3. ✅ Preview changes with `--dry-run` +4. ✅ View deployment history with `--history` +5. ✅ Rollback problematic deployments with `--rollback` +6. ✅ Automate deployments with `--ci` +7. ✅ Combine flags for powerful workflows +8. ✅ Choose between direct and PR modes + +## Tips + +- **Preview first.** Use `--dry-run` before deploying to production. +- **Check history.** Use `--history` to track what you've deployed. +- **Rollback safely.** Use `--rollback` instead of manual git reverts. +- **Automate wisely.** Use `--ci` for scripts, but keep `--direct` for manual deploys. + +## Quick Reference + +```bash +teach dep -d # Fast direct deploy +teach dep --dry-run # Preview changes +teach dep --history # View past deploys +teach dep --rollback 1 # Undo last deploy +teach dep --ci -d # CI mode +teach dep -d -m "msg" # Custom message +``` + +## Next Steps + +- See [REFCARD-DEPLOY-V2.md](../reference/REFCARD-DEPLOY-V2.md) for complete flag reference +- Read [TEACH-DEPLOY-GUIDE.md](../guides/TEACH-DEPLOY-GUIDE.md) for advanced workflows +- Try `teach deploy --history` to track your deployments +- Explore `teach deploy --rollback` for safe recovery + +--- + +*v6.4.0 - teach deploy v2 command* diff --git a/lib/deploy-history-helpers.zsh b/lib/deploy-history-helpers.zsh new file mode 100644 index 000000000..60879757e --- /dev/null +++ b/lib/deploy-history-helpers.zsh @@ -0,0 +1,189 @@ +#!/usr/bin/env zsh +# deploy-history-helpers.zsh - Append-only YAML deploy history tracking +# +# Provides functions for recording and querying deployment history +# stored at .flow/deploy-history.yml within a teaching course repo. +# +# Design decisions: +# - Append-only writes (>>) for _deploy_history_append — never rewrites the file +# - yq used only for READING (list, get, count) +# - History file is git-tracked +# - Timestamps in ISO 8601 with timezone +# - Commit hashes truncated to 8 characters +# +# Functions: +# _deploy_history_append - Record a new deploy entry +# _deploy_history_list - Display recent deploys as a formatted table +# _deploy_history_get - Retrieve a specific entry by display index +# _deploy_history_count - Return total number of recorded deploys + +# --- Append ----------------------------------------------------------- + +# Append deploy entry to history file +# Usage: _deploy_history_append [pr_number] [tag] [duration] +_deploy_history_append() { + local mode="$1" + local commit_hash="$2" + local commit_before="$3" + local branch_from="$4" + local branch_to="$5" + local file_count="${6:-0}" + local commit_message="$7" + local pr_number="${8:-null}" + local tag="${9:-null}" + local duration="${10:-0}" + + local history_file=".flow/deploy-history.yml" + local timestamp + timestamp=$(date '+%Y-%m-%dT%H:%M:%S%z') + local user + user=$(whoami) + + # Initialise file with top-level key when it doesn't exist yet + if [[ ! -f "$history_file" ]]; then + mkdir -p .flow + echo "deploys:" > "$history_file" + fi + + # Escape single quotes in all string fields so YAML stays valid + local safe_message="${commit_message//\'/\'\'}" + local safe_mode="${mode//\'/\'\'}" + local safe_branch_from="${branch_from//\'/\'\'}" + local safe_branch_to="${branch_to//\'/\'\'}" + local safe_user="${user//\'/\'\'}" + + # Append entry using heredoc — no yq rewrite + cat >> "$history_file" << EOF + - timestamp: '${timestamp}' + mode: '${safe_mode}' + commit_hash: '${commit_hash:0:8}' + commit_before: '${commit_before:0:8}' + branch_from: '${safe_branch_from}' + branch_to: '${safe_branch_to}' + file_count: ${file_count} + commit_message: '${safe_message}' + pr_number: ${pr_number} + tag: ${tag} + user: '${safe_user}' + duration_seconds: ${duration} +EOF + + return 0 +} + +# --- List ------------------------------------------------------------- + +# List recent deployments from history +# Usage: _deploy_history_list [count] +# Output: Formatted table of recent deploys +_deploy_history_list() { + local count="${1:-5}" + local history_file=".flow/deploy-history.yml" + + if [[ ! -f "$history_file" ]]; then + echo " No deployment history found." + echo " Deploy with 'teach deploy' to start tracking." + return 1 + fi + + local total_deploys + total_deploys=$(yq '.deploys | length' "$history_file" 2>/dev/null) + + if [[ -z "$total_deploys" || "$total_deploys" -eq 0 ]]; then + echo " No deployments recorded." + return 1 + fi + + echo "" + echo " Recent deployments:" + echo "" + printf " %-4s %-18s %-8s %-6s %s\n" "#" "When" "Mode" "Files" "Message" + printf " %-4s %-18s %-8s %-6s %s\n" "---" "------------------" "--------" "------" "-------" + + # Walk in reverse order (most recent first), capped at $count + local start_idx=$(( total_deploys - 1 )) + local end_idx=$(( total_deploys - count )) + [[ $end_idx -lt 0 ]] && end_idx=0 + + local display_num=1 + for (( i = start_idx; i >= end_idx; i-- )); do + local ts mode files msg + ts=$(yq ".deploys[$i].timestamp" "$history_file" 2>/dev/null) + mode=$(yq ".deploys[$i].mode" "$history_file" 2>/dev/null) + files=$(yq ".deploys[$i].file_count" "$history_file" 2>/dev/null) + msg=$(yq ".deploys[$i].commit_message" "$history_file" 2>/dev/null) + + # Shorten timestamp: "2026-02-03T14:30" -> "2026-02-03 14:30" + local short_ts="${ts:0:16}" + short_ts="${short_ts//T/ }" + + # Truncate long messages + [[ ${#msg} -gt 40 ]] && msg="${msg:0:37}..." + + printf " %-4s %-18s %-8s %-6s %s\n" "$display_num" "$short_ts" "$mode" "$files" "$msg" + (( display_num++ )) + done + + echo "" + return 0 +} + +# --- Get -------------------------------------------------------------- + +# Get deploy entry by display index (1 = most recent) +# Usage: _deploy_history_get +# Output: Sets DEPLOY_HIST_* variables for the caller +_deploy_history_get() { + local display_idx="$1" + local history_file=".flow/deploy-history.yml" + + if [[ ! -f "$history_file" ]]; then + return 1 + fi + + local total_deploys + total_deploys=$(yq '.deploys | length' "$history_file" 2>/dev/null) + + if [[ -z "$total_deploys" || "$total_deploys" -eq 0 ]]; then + return 1 + fi + + # Convert display index (1 = newest) to zero-based array index + local array_idx=$(( total_deploys - display_idx )) + + if [[ $array_idx -lt 0 || $array_idx -ge $total_deploys ]]; then + return 1 + fi + + # Export entry fields into caller's scope + DEPLOY_HIST_TIMESTAMP=$(yq ".deploys[$array_idx].timestamp" "$history_file" 2>/dev/null) + DEPLOY_HIST_MODE=$(yq ".deploys[$array_idx].mode" "$history_file" 2>/dev/null) + DEPLOY_HIST_COMMIT=$(yq ".deploys[$array_idx].commit_hash" "$history_file" 2>/dev/null) + DEPLOY_HIST_COMMIT_BEFORE=$(yq ".deploys[$array_idx].commit_before" "$history_file" 2>/dev/null) + DEPLOY_HIST_BRANCH_FROM=$(yq ".deploys[$array_idx].branch_from" "$history_file" 2>/dev/null) + DEPLOY_HIST_BRANCH_TO=$(yq ".deploys[$array_idx].branch_to" "$history_file" 2>/dev/null) + DEPLOY_HIST_FILE_COUNT=$(yq ".deploys[$array_idx].file_count" "$history_file" 2>/dev/null) + DEPLOY_HIST_MESSAGE=$(yq ".deploys[$array_idx].commit_message" "$history_file" 2>/dev/null) + DEPLOY_HIST_PR=$(yq ".deploys[$array_idx].pr_number" "$history_file" 2>/dev/null) + DEPLOY_HIST_TAG=$(yq ".deploys[$array_idx].tag" "$history_file" 2>/dev/null) + DEPLOY_HIST_USER=$(yq ".deploys[$array_idx].user" "$history_file" 2>/dev/null) + DEPLOY_HIST_DURATION=$(yq ".deploys[$array_idx].duration_seconds" "$history_file" 2>/dev/null) + + return 0 +} + +# --- Count ------------------------------------------------------------ + +# Get total deploy count +# Usage: _deploy_history_count +# Output: Prints the count to stdout +_deploy_history_count() { + local history_file=".flow/deploy-history.yml" + + if [[ ! -f "$history_file" ]]; then + echo "0" + return + fi + + yq '.deploys | length' "$history_file" 2>/dev/null || echo "0" +} diff --git a/lib/deploy-rollback-helpers.zsh b/lib/deploy-rollback-helpers.zsh new file mode 100644 index 000000000..e66eed78e --- /dev/null +++ b/lib/deploy-rollback-helpers.zsh @@ -0,0 +1,229 @@ +#!/usr/bin/env zsh +# +# Deploy Rollback Helpers (teach deploy v2) +# Purpose: Forward rollback via git revert with history tracking +# +# Design decisions: +# - Forward rollback only (git revert) — never destructive git reset +# - Rollback of a PR deploy pushes a direct revert commit (not a revert PR) +# - Rollback is recorded in deploy history with mode "rollback" +# - CI mode requires explicit index (no interactive picker) +# - On revert conflict, stays on target branch for manual resolution +# +# Functions: +# _deploy_rollback - Main rollback with interactive picker +# _deploy_perform_rollback - Execute forward rollback via git revert + +# ============================================================================ +# MAIN ROLLBACK FUNCTION +# ============================================================================ + +# Rollback a deployment by reverting its commit on production +# Usage: _deploy_rollback [N] [--ci] +# N = display index from history (1 = most recent). If omitted, shows interactive picker. +_deploy_rollback() { + local target_idx="" + local ci_mode=false + + # Parse flags + while [[ $# -gt 0 ]]; do + case "$1" in + --ci) ci_mode=true; shift ;; + [0-9]*) target_idx="$1"; shift ;; + *) shift ;; + esac + done + + # Source history helpers if not loaded + if ! typeset -f _deploy_history_list >/dev/null 2>&1; then + local helper_path="${0:A:h}/deploy-history-helpers.zsh" + if [[ -f "$helper_path" ]]; then + source "$helper_path" + else + _teach_error "Deploy history helpers not found" + return 1 + fi + fi + + # Check history exists + local total=$(_deploy_history_count) + if [[ "$total" -eq 0 ]]; then + echo "" + echo "${FLOW_COLORS[warn]} No deployment history found${FLOW_COLORS[reset]}" + echo " Deploy first with 'teach deploy' to build history." + return 1 + fi + + # If no target specified, show interactive picker + if [[ -z "$target_idx" ]]; then + if [[ "$ci_mode" == "true" ]]; then + _teach_error "CI mode requires explicit rollback index: teach deploy --rollback 1" + return 1 + fi + + _deploy_history_list 5 + + echo -n "${FLOW_COLORS[prompt]} Rollback which deployment? [1]: ${FLOW_COLORS[reset]}" + read -r target_idx + [[ -z "$target_idx" ]] && target_idx=1 + fi + + # Validate index + if [[ ! "$target_idx" =~ ^[0-9]+$ ]] || [[ "$target_idx" -lt 1 ]] || [[ "$target_idx" -gt "$total" ]]; then + _teach_error "Invalid deployment index: $target_idx" \ + "Use a number between 1 and $total" + return 1 + fi + + # Get deploy entry + if ! _deploy_history_get "$target_idx"; then + _teach_error "Failed to read deployment #$target_idx" + return 1 + fi + + # Show what we're rolling back + echo "" + echo "${FLOW_COLORS[info]} Rollback Target${FLOW_COLORS[reset]}" + echo "${FLOW_COLORS[dim]}─────────────────────────────────────────────────${FLOW_COLORS[reset]}" + echo " Deploy: #$target_idx" + echo " Mode: $DEPLOY_HIST_MODE" + echo " Commit: $DEPLOY_HIST_COMMIT" + echo " Message: $DEPLOY_HIST_MESSAGE" + echo " When: $DEPLOY_HIST_TIMESTAMP" + echo "" + + # Confirm (unless CI mode) + if [[ "$ci_mode" != "true" ]]; then + echo -n "${FLOW_COLORS[prompt]} Proceed with rollback? [y/N]: ${FLOW_COLORS[reset]}" + read -r confirm + case "$confirm" in + y|Y|yes|Yes|YES) ;; + *) echo " Rollback cancelled."; return 1 ;; + esac + fi + + # Perform the rollback + _deploy_perform_rollback "$DEPLOY_HIST_COMMIT" "$DEPLOY_HIST_BRANCH_TO" "$DEPLOY_HIST_MESSAGE" "$ci_mode" + return $? +} + +# ============================================================================ +# ROLLBACK EXECUTION +# ============================================================================ + +# Execute forward rollback via git revert +# Usage: _deploy_perform_rollback +_deploy_perform_rollback() { + local commit_hash="$1" + local target_branch="$2" + local original_message="$3" + local ci_mode="${4:-false}" + local start_time=$SECONDS + + local current_branch=$(_git_current_branch) + + echo "" + echo "${FLOW_COLORS[info]} Rolling back...${FLOW_COLORS[reset]}" + echo "${FLOW_COLORS[dim]}─────────────────────────────────────────────────${FLOW_COLORS[reset]}" + + # Save state for history — capture the TARGET branch HEAD (not current branch) + local commit_before=$(git rev-parse "$target_branch" 2>/dev/null) + + # Switch to target branch (usually production) + if [[ "$current_branch" != "$target_branch" ]]; then + local checkout_err + checkout_err=$(git checkout "$target_branch" 2>&1) || { + _teach_error "Failed to switch to $target_branch" "$checkout_err" + return 1 + } + echo "${FLOW_COLORS[success]} [ok]${FLOW_COLORS[reset]} Switched to $target_branch" + fi + + # Pull latest + git pull origin "$target_branch" --ff-only 2>/dev/null + + # Find the full commit hash from the short hash + local full_hash + full_hash=$(git rev-parse "$commit_hash" 2>/dev/null) + if [[ -z "$full_hash" ]]; then + _teach_error "Commit $commit_hash not found" + git checkout "$current_branch" 2>/dev/null + return 1 + fi + + # Perform git revert (forward rollback) + # Detect merge commits (>1 parent) — git revert requires -m 1 for merges + local revert_message="revert: rollback deploy ($original_message)" + local parent_count + parent_count=$(git cat-file -p "$full_hash" 2>/dev/null | grep -c "^parent ") + + local revert_err + if [[ $parent_count -gt 1 ]]; then + # Merge commit: specify parent 1 (the branch merged INTO) + revert_err=$(git revert "$full_hash" -m 1 --no-edit 2>&1) + else + revert_err=$(git revert "$full_hash" --no-edit 2>&1) + fi + + if [[ $? -ne 0 ]]; then + _teach_error "Revert failed" "$revert_err" + echo "" + echo "${FLOW_COLORS[dim]} Tip: Resolve conflicts manually, then commit${FLOW_COLORS[reset]}" + echo "${FLOW_COLORS[dim]} Or abort with: git revert --abort${FLOW_COLORS[reset]}" + # Don't switch back — let user resolve + return 1 + fi + + # Amend the revert commit message to be more descriptive + git commit --amend -m "$revert_message" 2>/dev/null + echo "${FLOW_COLORS[success]} [ok]${FLOW_COLORS[reset]} Reverted commit $commit_hash" + + # Push to origin + local push_err + push_err=$(git push origin "$target_branch" 2>&1) + if [[ $? -ne 0 ]]; then + _teach_error "Failed to push revert to origin" "$push_err" + git checkout "$current_branch" 2>/dev/null + return 1 + fi + echo "${FLOW_COLORS[success]} [ok]${FLOW_COLORS[reset]} Pushed to origin/$target_branch" + + local commit_after=$(git rev-parse HEAD 2>/dev/null) + + # Switch back to original branch + if [[ "$current_branch" != "$target_branch" ]]; then + git checkout "$current_branch" 2>/dev/null + echo "${FLOW_COLORS[success]} [ok]${FLOW_COLORS[reset]} Back on $current_branch" + fi + + local elapsed=$(( SECONDS - start_time )) + + # Record rollback in deploy history + if typeset -f _deploy_history_append >/dev/null 2>&1; then + local file_count=0 + file_count=$(git diff --name-only "${full_hash}^" "$full_hash" 2>/dev/null | wc -l | tr -d ' ') + _deploy_history_append \ + "rollback" \ + "$commit_after" \ + "$commit_before" \ + "$current_branch" \ + "$target_branch" \ + "$file_count" \ + "$revert_message" \ + "null" \ + "null" \ + "$elapsed" + fi + + echo "" + echo "${FLOW_COLORS[success]} Rollback complete${FLOW_COLORS[reset]}" + echo " Reverted deployment commit $commit_hash in ${elapsed}s" + + # Export for callers + DEPLOY_COMMIT_BEFORE="$commit_before" + DEPLOY_COMMIT_AFTER="$commit_after" + DEPLOY_DURATION="$elapsed" + DEPLOY_MODE="rollback" + + return 0 +} diff --git a/lib/dispatchers/teach-deploy-enhanced.zsh b/lib/dispatchers/teach-deploy-enhanced.zsh index d290f6b15..8e39c3cf0 100644 --- a/lib/dispatchers/teach-deploy-enhanced.zsh +++ b/lib/dispatchers/teach-deploy-enhanced.zsh @@ -9,8 +9,328 @@ # - Index management (ADD/UPDATE/REMOVE) # - Auto-commit + Auto-tag # - Cross-reference validation +# - CI mode (--ci flag or auto-detect non-TTY) # +# ============================================================================ +# SHARED PREFLIGHT CHECKS +# ============================================================================ + +# Shared preflight checks for all deploy modes +# Returns 0 if all checks pass +# Sets: DEPLOY_DRAFT_BRANCH, DEPLOY_PROD_BRANCH, DEPLOY_COURSE_NAME, DEPLOY_AUTO_PR, DEPLOY_REQUIRE_CLEAN +_deploy_preflight_checks() { + local ci_mode="${1:-false}" + + # Check if in git repo + if ! _git_in_repo; then + _teach_error "Not in a git repository" \ + "Initialize git first with: git init" + return 1 + fi + + # Check config file + local config_file=".flow/teach-config.yml" + if [[ ! -f "$config_file" ]]; then + _teach_error ".flow/teach-config.yml not found" \ + "Run 'teach init' to create the configuration" + return 1 + fi + + # Read config (export for caller) + DEPLOY_DRAFT_BRANCH=$(yq '.git.draft_branch // .branches.draft // "draft"' "$config_file" 2>/dev/null) || DEPLOY_DRAFT_BRANCH="draft" + DEPLOY_PROD_BRANCH=$(yq '.git.production_branch // .branches.production // "main"' "$config_file" 2>/dev/null) || DEPLOY_PROD_BRANCH="main" + DEPLOY_AUTO_PR=$(yq '.git.auto_pr // true' "$config_file" 2>/dev/null) || DEPLOY_AUTO_PR="true" + DEPLOY_REQUIRE_CLEAN=$(yq '.git.require_clean // true' "$config_file" 2>/dev/null) || DEPLOY_REQUIRE_CLEAN="true" + DEPLOY_COURSE_NAME=$(yq '.course.name // "Teaching Project"' "$config_file" 2>/dev/null) || DEPLOY_COURSE_NAME="Teaching Project" + + # Output header + echo "" + echo "${FLOW_COLORS[info]} Pre-flight Checks${FLOW_COLORS[reset]}" + echo "${FLOW_COLORS[dim]}─────────────────────────────────────────────────${FLOW_COLORS[reset]}" + + # Check: git repo + echo "${FLOW_COLORS[success]} [ok]${FLOW_COLORS[reset]} Git repository" + + # Check: config + echo "${FLOW_COLORS[success]} [ok]${FLOW_COLORS[reset]} Config file found" + + # Check: on draft branch + local current_branch=$(_git_current_branch) + if [[ "$current_branch" != "$DEPLOY_DRAFT_BRANCH" ]]; then + if [[ "$ci_mode" == "true" ]]; then + echo "${FLOW_COLORS[error]} [!!]${FLOW_COLORS[reset]} Not on $DEPLOY_DRAFT_BRANCH branch (on: $current_branch)" + _teach_error "Not on $DEPLOY_DRAFT_BRANCH branch (on: $current_branch)" \ + "CI mode cannot switch branches. Ensure correct branch before running." + return 1 + fi + echo "${FLOW_COLORS[error]} [!!]${FLOW_COLORS[reset]} Not on $DEPLOY_DRAFT_BRANCH branch (on: $current_branch)" + echo "" + echo -n "${FLOW_COLORS[prompt]} Switch to $DEPLOY_DRAFT_BRANCH? [Y/n]:${FLOW_COLORS[reset]} " + read -r switch_confirm + case "$switch_confirm" in + n|N|no|No|NO) return 1 ;; + *) + git checkout "$DEPLOY_DRAFT_BRANCH" || { + _teach_error "Failed to switch to $DEPLOY_DRAFT_BRANCH" + return 1 + } + echo "${FLOW_COLORS[success]} [ok]${FLOW_COLORS[reset]} Switched to $DEPLOY_DRAFT_BRANCH" + ;; + esac + else + echo "${FLOW_COLORS[success]} [ok]${FLOW_COLORS[reset]} On $DEPLOY_DRAFT_BRANCH branch" + fi + + # Check: working tree clean + if [[ "$DEPLOY_REQUIRE_CLEAN" == "true" ]] && ! _git_is_clean; then + echo "${FLOW_COLORS[error]} [!!]${FLOW_COLORS[reset]} Working tree dirty" + return 1 + else + echo "${FLOW_COLORS[success]} [ok]${FLOW_COLORS[reset]} Working tree clean" + fi + + # Check: no conflicts with production + if _git_detect_production_conflicts "$DEPLOY_DRAFT_BRANCH" "$DEPLOY_PROD_BRANCH" 2>/dev/null; then + echo "${FLOW_COLORS[success]} [ok]${FLOW_COLORS[reset]} No production conflicts" + else + echo "${FLOW_COLORS[warn]} [!!]${FLOW_COLORS[reset]} Production has new commits" + if [[ "$ci_mode" == "true" ]]; then + _teach_error "CI mode: production conflicts detected. Resolve manually." + return 1 + fi + fi + + return 0 +} + +# ============================================================================ +# DIRECT MERGE MODE +# ============================================================================ + +# Direct merge mode: merge draft -> production without PR +# Usage: _deploy_direct_merge +# Returns: 0 on success, 1 on failure +# This is the fast path (8-15s vs 45-90s for PR mode) +_deploy_direct_merge() { + local draft_branch="$1" + local prod_branch="$2" + local commit_message="$3" + local ci_mode="${4:-false}" + local start_time=$SECONDS + + echo "" + echo "${FLOW_COLORS[info]} Direct merge: $draft_branch -> $prod_branch${FLOW_COLORS[reset]}" + echo "${FLOW_COLORS[dim]}─────────────────────────────────────────────────${FLOW_COLORS[reset]}" + + # Guard: working tree must be clean before branch switch + if ! _git_is_clean; then + _teach_error "Working tree must be clean for direct merge" \ + "Commit or stash changes first" + return 1 + fi + + # Save the PRODUCTION branch HEAD for rollback reference (not current branch) + local commit_before=$(git rev-parse "$prod_branch" 2>/dev/null) + + # Ensure draft is pushed to remote first + local push_err + push_err=$(git push origin "$draft_branch" 2>&1) + if [[ $? -ne 0 ]]; then + # If push fails, might be nothing to push (ok) or real error + if ! _git_is_synced 2>/dev/null; then + _teach_error "Failed to push $draft_branch to origin" "$push_err" + return 1 + fi + fi + echo "${FLOW_COLORS[success]} [ok]${FLOW_COLORS[reset]} $draft_branch pushed to origin" + + # Switch to production branch + local checkout_err + checkout_err=$(git checkout "$prod_branch" 2>&1) || { + _teach_error "Failed to switch to $prod_branch" "$checkout_err" + return 1 + } + + # Pull latest production + local pull_err + pull_err=$(git pull origin "$prod_branch" --ff-only 2>&1) || { + # If ff-only fails, try regular pull + pull_err=$(git pull origin "$prod_branch" 2>&1) || { + _teach_error "Failed to pull latest $prod_branch" "$pull_err" + git checkout "$draft_branch" 2>/dev/null + return 1 + } + } + + # Merge draft into production + local merge_err + merge_err=$(git merge "$draft_branch" --no-edit -m "$commit_message" 2>&1) + if [[ $? -ne 0 ]]; then + _teach_error "Merge conflict! Aborting merge." "$merge_err" + git merge --abort 2>/dev/null + git checkout "$draft_branch" 2>/dev/null + echo "" + echo "${FLOW_COLORS[dim]} Tip: Resolve conflicts manually or use PR mode${FLOW_COLORS[reset]}" + return 1 + fi + echo "${FLOW_COLORS[success]} [ok]${FLOW_COLORS[reset]} Merged successfully" + + # Push production to origin + local push_prod_err + push_prod_err=$(git push origin "$prod_branch" 2>&1) + if [[ $? -ne 0 ]]; then + _teach_error "Failed to push $prod_branch to origin" "$push_prod_err" + git checkout "$draft_branch" 2>/dev/null + return 1 + fi + echo "${FLOW_COLORS[success]} [ok]${FLOW_COLORS[reset]} Pushed to origin/$prod_branch" + + # Get the new commit hash + local commit_after=$(git rev-parse HEAD 2>/dev/null) + + # Switch back to draft branch + git checkout "$draft_branch" 2>/dev/null || { + _teach_error "Warning: Failed to switch back to $draft_branch" + } + + local elapsed=$(( SECONDS - start_time )) + + echo "" + echo "${FLOW_COLORS[success]} Done in ${elapsed}s${FLOW_COLORS[reset]}" + + # Export for history tracking + DEPLOY_COMMIT_BEFORE="$commit_before" + DEPLOY_COMMIT_AFTER="$commit_after" + DEPLOY_DURATION="$elapsed" + DEPLOY_MODE="direct" + + return 0 +} + +# ============================================================================ +# DRY-RUN REPORT +# ============================================================================ + +# Dry-run report: preview deploy without executing +# Usage: _deploy_dry_run_report +_deploy_dry_run_report() { + local draft_branch="$1" + local prod_branch="$2" + local course_name="$3" + local direct_mode="${4:-false}" + local commit_message="${5:-}" + + echo "" + echo "${FLOW_COLORS[warn]} DRY RUN — No changes will be made${FLOW_COLORS[reset]}" + echo "${FLOW_COLORS[dim]}─────────────────────────────────────────────────${FLOW_COLORS[reset]}" + + # Show files that would be deployed + local files_changed + files_changed=$(git diff --name-status "$prod_branch"..."$draft_branch" 2>/dev/null) + + if [[ -n "$files_changed" ]]; then + local file_count=$(echo "$files_changed" | wc -l | tr -d ' ') + echo "" + echo " Would deploy $file_count files:" + + while IFS=$'\t' read -r fstatus file; do + case "$fstatus" in + M) echo " ${FLOW_COLORS[warn]}M${FLOW_COLORS[reset]} $file" ;; + A) echo " ${FLOW_COLORS[success]}A${FLOW_COLORS[reset]} $file" ;; + D) echo " ${FLOW_COLORS[error]}D${FLOW_COLORS[reset]} $file" ;; + R*) echo " ${FLOW_COLORS[info]}R${FLOW_COLORS[reset]} $file" ;; + *) echo " $fstatus $file" ;; + esac + done <<< "$files_changed" + else + echo "" + echo " No changes to deploy." + return 0 + fi + + # Show commit message + echo "" + if [[ -n "$commit_message" ]]; then + echo " Would commit: \"$commit_message\"" + elif typeset -f _generate_smart_commit_message >/dev/null 2>&1; then + local smart_msg=$(_generate_smart_commit_message "$draft_branch" "$prod_branch") + echo " Would commit: \"$smart_msg\"" + else + echo " Would commit: \"deploy: $course_name update\"" + fi + + # Show mode + echo "" + if [[ "$direct_mode" == "true" ]]; then + echo " Would merge: $draft_branch -> $prod_branch (direct mode)" + else + echo " Would create: PR from $draft_branch -> $prod_branch" + fi + + # Show history entry + local deploy_count=0 + if typeset -f _deploy_history_count >/dev/null 2>&1; then + deploy_count=$(_deploy_history_count) + fi + local next_num=$(( deploy_count + 1 )) + echo " Would log: deploy #$next_num to .flow/deploy-history.yml" + + # Show .STATUS update hint + if [[ -f ".STATUS" ]]; then + echo " Would update: .STATUS" + fi + + echo "" + echo "${FLOW_COLORS[dim]} Run without --dry-run to execute${FLOW_COLORS[reset]}" + + return 0 +} + +# ============================================================================ +# .STATUS FILE UPDATE +# ============================================================================ + +# Update .STATUS file after deployment +# Sets deploy_count, last_deploy, and teaching_week (if determinable) +_deploy_update_status_file() { + local status_file=".STATUS" + [[ ! -f "$status_file" ]] && return 0 # Non-destructive: skip if absent + + local deploy_count + if typeset -f _deploy_history_count >/dev/null 2>&1; then + deploy_count=$(_deploy_history_count) + else + deploy_count="" + fi + + # Update last_deploy + if command -v yq >/dev/null 2>&1; then + local today=$(date '+%Y-%m-%d') + yq -i ".last_deploy = \"$today\"" "$status_file" 2>/dev/null + if [[ -n "$deploy_count" ]]; then + yq -i ".deploy_count = $deploy_count" "$status_file" 2>/dev/null + fi + + # Attempt teaching_week from semester_info.start_date + local start_date + start_date=$(yq '.semester_info.start_date // ""' .flow/teach-config.yml 2>/dev/null) + if [[ -n "$start_date" && "$start_date" != "null" ]]; then + local start_epoch today_epoch week_num + start_epoch=$(date -j -f "%Y-%m-%d" "$start_date" "+%s" 2>/dev/null) + today_epoch=$(date "+%s") + if [[ -n "$start_epoch" ]]; then + week_num=$(( (today_epoch - start_epoch) / 604800 + 1 )) + if [[ $week_num -ge 1 && $week_num -le 20 ]]; then + yq -i ".teaching_week = $week_num" "$status_file" 2>/dev/null + fi + fi + fi + + echo " ${FLOW_COLORS[dim]}.STATUS updated${FLOW_COLORS[reset]}" + fi +} + # ============================================================================ # ENHANCED TEACH DEPLOY - WITH PARTIAL DEPLOYMENT SUPPORT # ============================================================================ @@ -23,14 +343,31 @@ _teach_deploy_enhanced() { local auto_tag=false local skip_index=false local check_prereqs=false + local ci_mode=false + local custom_message="" + local dry_run=false + + # Auto-detect CI mode: no TTY means non-interactive + if [[ ! -t 0 ]]; then + ci_mode=true + fi # Parse flags and files while [[ $# -gt 0 ]]; do case "$1" in - --direct-push) + --ci) + ci_mode=true + shift + ;; + --direct|-d|--direct-push) direct_push=true shift ;; + --message|-m) + shift + custom_message="$1" + shift + ;; --auto-commit) auto_commit=true shift @@ -47,6 +384,36 @@ _teach_deploy_enhanced() { check_prereqs=true shift ;; + --dry-run|--preview) + dry_run=true + shift + ;; + --rollback) + shift + local rollback_idx="" + # Check if next arg is a number (optional index) + if [[ $# -gt 0 && "$1" =~ ^[0-9]+$ ]]; then + rollback_idx="$1" + shift + fi + # Dispatch to rollback immediately (no preflight needed) + if [[ "$ci_mode" == "true" ]]; then + _deploy_rollback "$rollback_idx" --ci + else + _deploy_rollback "$rollback_idx" + fi + return $? + ;; + --history) + shift + local history_count=10 + if [[ $# -gt 0 && "$1" =~ ^[0-9]+$ ]]; then + history_count="$1" + shift + fi + _deploy_history_list "$history_count" + return $? + ;; --help|-h|help) _teach_deploy_enhanced_help return 0 @@ -76,61 +443,28 @@ _teach_deploy_enhanced() { done # ============================================ - # PRE-FLIGHT CHECKS + # PRE-FLIGHT CHECKS (shared function) # ============================================ - # Check if in git repo - if ! _git_in_repo; then - _teach_error "Not in a git repository" \ - "Initialize git first with: git init" - return 1 - fi - - # Check if config file exists - local config_file=".flow/teach-config.yml" - if [[ ! -f "$config_file" ]]; then - _teach_error ".flow/teach-config.yml not found" \ - "Run 'teach init' to create the configuration" - return 1 - fi - - # Read git configuration from teach-config.yml - local draft_branch prod_branch auto_pr require_clean - draft_branch=$(yq '.git.draft_branch // .branches.draft // "draft"' "$config_file" 2>/dev/null) || draft_branch="draft" - prod_branch=$(yq '.git.production_branch // .branches.production // "main"' "$config_file" 2>/dev/null) || prod_branch="main" - auto_pr=$(yq '.git.auto_pr // true' "$config_file" 2>/dev/null) || auto_pr="true" - require_clean=$(yq '.git.require_clean // true' "$config_file" 2>/dev/null) || require_clean="true" - - # Read course info - local course_name - course_name=$(yq '.course.name // "Teaching Project"' "$config_file" 2>/dev/null) || course_name="Teaching Project" - - echo "" - echo "${FLOW_COLORS[info]}🔍 Pre-flight Checks${FLOW_COLORS[reset]}" - echo "${FLOW_COLORS[dim]}─────────────────────────────────────────────────${FLOW_COLORS[reset]}" + _deploy_preflight_checks "$ci_mode" || return 1 - # Check 1: Verify we're on draft branch - local current_branch=$(_git_current_branch) - if [[ "$current_branch" != "$draft_branch" ]]; then - echo "${FLOW_COLORS[error]}✗${FLOW_COLORS[reset]} Not on $draft_branch branch (currently on: $current_branch)" - echo "" - echo -n "${FLOW_COLORS[prompt]}Switch to $draft_branch branch? [Y/n]:${FLOW_COLORS[reset]} " - read -r switch_confirm + # Read exported variables from preflight + local draft_branch="$DEPLOY_DRAFT_BRANCH" + local prod_branch="$DEPLOY_PROD_BRANCH" + local course_name="$DEPLOY_COURSE_NAME" + local auto_pr="$DEPLOY_AUTO_PR" + local require_clean="$DEPLOY_REQUIRE_CLEAN" - case "$switch_confirm" in - n|N|no|No|NO) - return 1 - ;; - *) - git checkout "$draft_branch" || { - _teach_error "Failed to switch to $draft_branch" - return 1 - } - echo "${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} Switched to $draft_branch" - ;; - esac - else - echo "${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} On $draft_branch branch" + # ============================================ + # DRY-RUN MODE + # ============================================ + if [[ "$dry_run" == "true" && "$partial_deploy" != "true" ]]; then + local smart_msg="" + if [[ -n "$custom_message" ]]; then + smart_msg="$custom_message" + fi + _deploy_dry_run_report "$draft_branch" "$prod_branch" "$course_name" "$direct_push" "$smart_msg" + return 0 fi # ============================================ @@ -173,6 +507,10 @@ _teach_deploy_enhanced() { if _validate_cross_references "${deploy_files[@]}"; then echo "${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} All cross-references valid" else + if [[ "$ci_mode" == "true" ]]; then + _teach_error "CI mode: broken cross-references detected. Fix before deploying." + return 1 + fi echo "" echo -n "${FLOW_COLORS[prompt]}Continue with broken references? [y/N]:${FLOW_COLORS[reset]} " read -r continue_confirm @@ -210,18 +548,37 @@ _teach_deploy_enhanced() { if [[ $dep_count -gt 0 ]]; then echo "" echo "${FLOW_COLORS[info]}Found $dep_count additional dependencies${FLOW_COLORS[reset]}" - echo -n "${FLOW_COLORS[prompt]}Include dependencies in deployment? [Y/n]:${FLOW_COLORS[reset]} " - read -r include_deps - case "$include_deps" in - n|N|no|No|NO) - # Keep only original files - all_files=("${deploy_files[@]}") - ;; - *) - # Use all files including dependencies - deploy_files=("${all_files[@]}") - ;; - esac + if [[ "$ci_mode" == "true" ]]; then + # CI mode: auto-include dependencies + deploy_files=("${all_files[@]}") + else + echo -n "${FLOW_COLORS[prompt]}Include dependencies in deployment? [Y/n]:${FLOW_COLORS[reset]} " + read -r include_deps + case "$include_deps" in + n|N|no|No|NO) + # Keep only original files + all_files=("${deploy_files[@]}") + ;; + *) + # Use all files including dependencies + deploy_files=("${all_files[@]}") + ;; + esac + fi + fi + + # Dry-run: show what would happen and exit (partial deploy) + if [[ "$dry_run" == "true" ]]; then + echo "" + echo "${FLOW_COLORS[warn]} DRY RUN — No changes will be made${FLOW_COLORS[reset]}" + echo "" + echo " Would deploy ${#deploy_files[@]} files:" + for file in "${deploy_files[@]}"; do + echo " $file" + done + echo "" + echo "${FLOW_COLORS[dim]} Run without --dry-run to execute${FLOW_COLORS[reset]}" + return 0 fi # Check for uncommitted changes in deploy files @@ -242,8 +599,8 @@ _teach_deploy_enhanced() { done echo "" - if [[ "$auto_commit" == "true" ]]; then - # Auto-commit mode + if [[ "$auto_commit" == "true" || "$ci_mode" == "true" ]]; then + # Auto-commit mode (or CI mode) echo "${FLOW_COLORS[info]}Auto-commit mode enabled${FLOW_COLORS[reset]}" local commit_msg="Update: $(date +%Y-%m-%d)" @@ -290,23 +647,32 @@ _teach_deploy_enhanced() { fi # Push to remote - echo "" - echo -n "${FLOW_COLORS[prompt]}Push to origin/$draft_branch? [Y/n]:${FLOW_COLORS[reset]} " - read -r push_confirm - - case "$push_confirm" in - n|N|no|No|NO) - echo "Deployment cancelled" + if [[ "$ci_mode" == "true" ]]; then + # CI mode: auto-push + if _git_push_current_branch; then + echo "${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} Pushed to origin/$draft_branch" + else return 1 - ;; - *) - if _git_push_current_branch; then - echo "${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} Pushed to origin/$draft_branch" - else + fi + else + echo "" + echo -n "${FLOW_COLORS[prompt]}Push to origin/$draft_branch? [Y/n]:${FLOW_COLORS[reset]} " + read -r push_confirm + + case "$push_confirm" in + n|N|no|No|NO) + echo "Deployment cancelled" return 1 - fi - ;; - esac + ;; + *) + if _git_push_current_branch; then + echo "${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} Pushed to origin/$draft_branch" + else + return 1 + fi + ;; + esac + fi # Auto-tag if requested if [[ "$auto_tag" == "true" ]]; then @@ -324,23 +690,33 @@ _teach_deploy_enhanced() { pr_body+="- $file\n" done - echo "" - echo -n "${FLOW_COLORS[prompt]}Create pull request? [Y/n]:${FLOW_COLORS[reset]} " - read -r pr_confirm - - case "$pr_confirm" in - n|N|no|No|NO) - echo "PR creation skipped" - ;; - *) - if _git_create_deploy_pr "$draft_branch" "$prod_branch" "$pr_title" "$pr_body"; then - echo "" - echo "${FLOW_COLORS[success]}✅ Pull Request Created${FLOW_COLORS[reset]}" - else - return 1 - fi - ;; - esac + if [[ "$ci_mode" == "true" ]]; then + # CI mode: auto-create PR + if _git_create_deploy_pr "$draft_branch" "$prod_branch" "$pr_title" "$pr_body"; then + echo "" + echo "${FLOW_COLORS[success]}✅ Pull Request Created${FLOW_COLORS[reset]}" + else + return 1 + fi + else + echo "" + echo -n "${FLOW_COLORS[prompt]}Create pull request? [Y/n]:${FLOW_COLORS[reset]} " + read -r pr_confirm + + case "$pr_confirm" in + n|N|no|No|NO) + echo "PR creation skipped" + ;; + *) + if _git_create_deploy_pr "$draft_branch" "$prod_branch" "$pr_title" "$pr_body"; then + echo "" + echo "${FLOW_COLORS[success]}✅ Pull Request Created${FLOW_COLORS[reset]}" + else + return 1 + fi + ;; + esac + fi fi echo "" @@ -355,6 +731,61 @@ _teach_deploy_enhanced() { # Fall back to original _teach_deploy implementation # This preserves the existing full-site deployment workflow + # ============================================ + # DEPLOY MODE DISPATCH + # ============================================ + + if [[ "$direct_push" == "true" ]]; then + # Direct merge mode (fast path, 8-15s) + local smart_message + if [[ -n "$custom_message" ]]; then + smart_message="$custom_message" + elif typeset -f _generate_smart_commit_message >/dev/null 2>&1; then + smart_message=$(_generate_smart_commit_message "$draft_branch" "$prod_branch") + else + smart_message="deploy: $course_name update" + fi + + echo "" + echo "${FLOW_COLORS[info]} Smart commit: $smart_message${FLOW_COLORS[reset]}" + + _deploy_direct_merge "$draft_branch" "$prod_branch" "$smart_message" "$ci_mode" || return 1 + + # Auto-tag if requested + if [[ "$auto_tag" == "true" ]]; then + local tag="deploy-$(date +%Y-%m-%d-%H%M)" + git tag "$tag" 2>/dev/null + git push origin "$tag" 2>/dev/null + echo "${FLOW_COLORS[success]} [ok]${FLOW_COLORS[reset]} Tagged as $tag" + fi + + # Record in deploy history + if typeset -f _deploy_history_append >/dev/null 2>&1; then + local _commit_after="${DEPLOY_COMMIT_AFTER:-$(git rev-parse --short=8 HEAD 2>/dev/null)}" + local _commit_before="${DEPLOY_COMMIT_BEFORE:-}" + local _file_count=$(git diff --name-only HEAD~1 HEAD 2>/dev/null | wc -l | tr -d ' ') + local _elapsed="${DEPLOY_DURATION:-0}" + _deploy_history_append "direct" "$_commit_after" "$_commit_before" "$draft_branch" "$prod_branch" "$_file_count" "$smart_message" "null" "null" "$_elapsed" + echo " ${FLOW_COLORS[dim]}History logged: #$(( $(_deploy_history_count) )) ($(date '+%Y-%m-%d %H:%M'))${FLOW_COLORS[reset]}" + fi + + # Update .STATUS file + _deploy_update_status_file 2>/dev/null + + echo "" + echo "${FLOW_COLORS[success]} Direct deployment complete${FLOW_COLORS[reset]}" + + # Show site URL if available + local site_url + site_url=$(yq '.site.url // ""' .flow/teach-config.yml 2>/dev/null) + if [[ -n "$site_url" && "$site_url" != "null" ]]; then + echo " Site: $site_url" + fi + + _deploy_cleanup_globals + return 0 + fi + # Check 2: Verify no uncommitted changes (if required) if [[ "$require_clean" == "true" ]]; then if ! _git_is_clean; then @@ -371,22 +802,31 @@ _teach_deploy_enhanced() { # Check 3: Check for unpushed commits if _git_has_unpushed_commits; then echo "${FLOW_COLORS[warn]}⚠️ ${FLOW_COLORS[reset]} Unpushed commits detected" - echo "" - echo -n "${FLOW_COLORS[prompt]}Push to origin/$draft_branch first? [Y/n]:${FLOW_COLORS[reset]} " - read -r push_confirm + if [[ "$ci_mode" == "true" ]]; then + # CI mode: auto-push + if _git_push_current_branch; then + echo "${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} Pushed to origin/$draft_branch" + else + return 1 + fi + else + echo "" + echo -n "${FLOW_COLORS[prompt]}Push to origin/$draft_branch first? [Y/n]:${FLOW_COLORS[reset]} " + read -r push_confirm - case "$push_confirm" in - n|N|no|No|NO) - echo "${FLOW_COLORS[warn]}Continuing without push...${FLOW_COLORS[reset]}" - ;; - *) - if _git_push_current_branch; then - echo "${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} Pushed to origin/$draft_branch" - else - return 1 - fi - ;; - esac + case "$push_confirm" in + n|N|no|No|NO) + echo "${FLOW_COLORS[warn]}Continuing without push...${FLOW_COLORS[reset]}" + ;; + *) + if _git_push_current_branch; then + echo "${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} Pushed to origin/$draft_branch" + else + return 1 + fi + ;; + esac + fi else echo "${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} Remote is up-to-date" fi @@ -396,6 +836,10 @@ _teach_deploy_enhanced() { echo "${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} No conflicts with production" else echo "${FLOW_COLORS[warn]}⚠️ ${FLOW_COLORS[reset]} Production ($prod_branch) has new commits" + if [[ "$ci_mode" == "true" ]]; then + _teach_error "CI mode: production conflicts detected. Resolve manually." + return 1 + fi echo "" echo "${FLOW_COLORS[prompt]}Production branch has updates. Rebase first?${FLOW_COLORS[reset]}" echo "" @@ -473,38 +917,49 @@ _teach_deploy_enhanced() { # Create PR echo "" if [[ "$auto_pr" == "true" ]]; then - echo "${FLOW_COLORS[prompt]}Create pull request?${FLOW_COLORS[reset]}" - echo "" - echo " ${FLOW_COLORS[dim]}[1]${FLOW_COLORS[reset]} Yes - Create PR (Recommended)" - echo " ${FLOW_COLORS[dim]}[2]${FLOW_COLORS[reset]} Push to $draft_branch only (no PR)" - echo " ${FLOW_COLORS[dim]}[3]${FLOW_COLORS[reset]} Cancel" - echo "" - echo -n "${FLOW_COLORS[prompt]}Your choice [1-3]:${FLOW_COLORS[reset]} " - read -r pr_choice - - case "$pr_choice" in - 1) + if [[ "$ci_mode" == "true" ]]; then + # CI mode: auto-create PR + echo "" + if _git_create_deploy_pr "$draft_branch" "$prod_branch" "$pr_title" "$pr_body"; then echo "" - if _git_create_deploy_pr "$draft_branch" "$prod_branch" "$pr_title" "$pr_body"; then - echo "" - echo "${FLOW_COLORS[success]}✅ Pull Request Created${FLOW_COLORS[reset]}" - else - return 1 - fi - ;; - 2) - if _git_push_current_branch; then + echo "${FLOW_COLORS[success]}✅ Pull Request Created${FLOW_COLORS[reset]}" + else + return 1 + fi + else + echo "${FLOW_COLORS[prompt]}Create pull request?${FLOW_COLORS[reset]}" + echo "" + echo " ${FLOW_COLORS[dim]}[1]${FLOW_COLORS[reset]} Yes - Create PR (Recommended)" + echo " ${FLOW_COLORS[dim]}[2]${FLOW_COLORS[reset]} Push to $draft_branch only (no PR)" + echo " ${FLOW_COLORS[dim]}[3]${FLOW_COLORS[reset]} Cancel" + echo "" + echo -n "${FLOW_COLORS[prompt]}Your choice [1-3]:${FLOW_COLORS[reset]} " + read -r pr_choice + + case "$pr_choice" in + 1) echo "" - echo "${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} Pushed to origin/$draft_branch" - else + if _git_create_deploy_pr "$draft_branch" "$prod_branch" "$pr_title" "$pr_body"; then + echo "" + echo "${FLOW_COLORS[success]}✅ Pull Request Created${FLOW_COLORS[reset]}" + else + return 1 + fi + ;; + 2) + if _git_push_current_branch; then + echo "" + echo "${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} Pushed to origin/$draft_branch" + else + return 1 + fi + ;; + 3|*) + echo "Deployment cancelled" return 1 - fi - ;; - 3|*) - echo "Deployment cancelled" - return 1 - ;; - esac + ;; + esac + fi else if _git_push_current_branch; then echo "" @@ -513,11 +968,36 @@ _teach_deploy_enhanced() { return 1 fi fi + + # Record PR deploy in history + if typeset -f _deploy_history_append >/dev/null 2>&1; then + local _pr_commit=$(git rev-parse --short=8 HEAD 2>/dev/null) + local _pr_file_count=$(git diff --name-only "$prod_branch"..."$draft_branch" 2>/dev/null | wc -l | tr -d ' ') + local _pr_message="deploy: $course_name PR update" + _deploy_history_append "pr" "$_pr_commit" "" "$draft_branch" "$prod_branch" "$_pr_file_count" "$_pr_message" "null" "null" "0" + echo " ${FLOW_COLORS[dim]}History logged: #$(( $(_deploy_history_count) )) ($(date '+%Y-%m-%d %H:%M'))${FLOW_COLORS[reset]}" + fi + + # Update .STATUS file + _deploy_update_status_file 2>/dev/null + + _deploy_cleanup_globals +} + +# Clean up DEPLOY_* global variables to avoid polluting the shell environment +_deploy_cleanup_globals() { + unset DEPLOY_DRAFT_BRANCH DEPLOY_PROD_BRANCH DEPLOY_COURSE_NAME + unset DEPLOY_AUTO_PR DEPLOY_REQUIRE_CLEAN + unset DEPLOY_COMMIT_BEFORE DEPLOY_COMMIT_AFTER DEPLOY_DURATION DEPLOY_MODE + unset DEPLOY_HIST_TIMESTAMP DEPLOY_HIST_MODE DEPLOY_HIST_COMMIT + unset DEPLOY_HIST_COMMIT_BEFORE DEPLOY_HIST_BRANCH_FROM DEPLOY_HIST_BRANCH_TO + unset DEPLOY_HIST_FILE_COUNT DEPLOY_HIST_MESSAGE DEPLOY_HIST_PR + unset DEPLOY_HIST_TAG DEPLOY_HIST_USER DEPLOY_HIST_DURATION } # Help for enhanced teach deploy _teach_deploy_enhanced_help() { - echo "teach deploy - Deploy teaching content via PR workflow" + echo "teach deploy - Deploy teaching content to production" echo "" echo "Usage:" echo " teach deploy [files...] [options]" @@ -526,41 +1006,113 @@ _teach_deploy_enhanced_help() { echo " files Files or directories to deploy (partial deploy mode)" echo "" echo "Options:" + echo " --direct, -d Direct merge (no PR, fast path: 8-15s)" + echo " --message, -m MSG Custom commit message for deploy" + echo " --ci Force non-interactive (CI) mode" echo " --auto-commit Auto-commit uncommitted changes" echo " --auto-tag Auto-tag deployment with timestamp" echo " --skip-index Skip index management prompts" echo " --check-prereqs Run prerequisite validation before deploy (blocks on errors)" - echo " --direct-push Bypass PR and push directly to production (advanced)" + echo " --dry-run, --preview Preview what would happen without making changes" + echo " --rollback [N] Rollback deployment N (1=most recent, interactive if omitted)" + echo " --history [N] Show last N deployments (default: 10)" + echo " --direct-push Alias for --direct (backward compatible)" echo " --help, -h Show this help message" echo "" echo "Deployment Modes:" echo " Full Site (default):" - echo " teach deploy # Deploy all changes" + echo " teach deploy # Deploy all changes via PR" + echo "" + echo " Direct Merge (fast path):" + echo " teach deploy -d # Direct merge, no PR (8-15s)" + echo " teach deploy --direct # Same as -d" + echo " teach deploy -d -m \"Week 5\" # Direct merge with message" + echo " teach deploy -d --auto-tag # Direct merge + tag" echo "" echo " Partial Deploy:" echo " teach deploy lectures/week-05.qmd # Deploy single file" echo " teach deploy lectures/ # Deploy entire directory" echo " teach deploy file1.qmd file2.qmd # Deploy multiple files" echo "" + echo " CI Mode:" + echo " teach deploy --ci # Non-interactive (auto-yes)" + echo " teach deploy --ci -d # CI + direct merge" + echo " echo | teach deploy # Auto-detected (no TTY)" + echo "" + echo " Dry Run (preview):" + echo " teach deploy --dry-run # Preview full site deploy" + echo " teach deploy --preview -d # Preview direct merge" + echo " teach deploy --dry-run lectures/ # Preview partial deploy" + echo "" echo "Features:" + echo " • Direct merge mode (--direct): merge draft->prod without PR (8-15s)" + echo " • PR workflow (default): create PR for review (45-90s)" echo " • Dependency tracking (sourced files, cross-references)" echo " • Index management (ADD/UPDATE/REMOVE links)" echo " • Cross-reference validation" echo " • Auto-commit with custom message" echo " • Auto-tag with timestamp" + echo " • CI mode for automated pipelines" + echo " • Smart commit messages (auto-generated from changes)" + echo " • Dry-run mode (--dry-run/--preview): preview without changes" + echo " • Rollback (--rollback): forward rollback via git revert" + echo " • Deploy history (--history): track all deployments" + echo " • .STATUS file auto-update after deploy" + echo "" + echo "Direct Merge vs PR:" + echo " --direct Merge draft->prod locally, push (8-15s, solo instructor)" + echo " (default) Create GitHub PR for review (45-90s, team workflow)" + echo "" + echo "CI Mode Behavior:" + echo " When --ci is passed (or no TTY detected):" + echo " • Branch switch → fail (must be on correct branch)" + echo " • Push confirmation → auto-yes" + echo " • PR creation → auto-yes" + echo " • Include deps → auto-yes" + echo " • Commit message → auto-generate" + echo " • Broken references → fail" + echo " • Production conflict → fail" echo "" echo "Examples:" + echo " # Quick deploy (direct merge, no PR)" + echo " teach deploy -d" + echo "" + echo " # Direct deploy with custom message" + echo " teach deploy -d -m \"Add Week 5 lecture on ANOVA\"" + echo "" + echo " # Direct deploy with auto-tag" + echo " teach deploy --direct --auto-tag" + echo "" echo " # Partial deploy with auto features" echo " teach deploy lectures/week-05.qmd --auto-commit --auto-tag" echo "" echo " # Deploy directory with index updates" echo " teach deploy lectures/" echo "" - echo " # Full site deploy (traditional workflow)" + echo " # Full site deploy via PR (traditional workflow)" echo " teach deploy" echo "" echo " # Deploy with prerequisite validation" echo " teach deploy --check-prereqs" + echo "" + echo " # CI pipeline deploy (direct, no interaction)" + echo " teach deploy --ci -d --auto-commit --auto-tag" + echo "" + echo " # Dry run (preview what would happen)" + echo " teach deploy --dry-run" + echo " teach deploy --preview -d" + echo "" + echo " # Dry run partial deploy" + echo " teach deploy --dry-run lectures/week-05.qmd" + echo "" + echo " # Rollback" + echo " teach deploy --rollback # Interactive picker" + echo " teach deploy --rollback 1 # Rollback most recent" + echo " teach deploy --rollback 2 --ci # Rollback 2nd most recent (CI)" + echo "" + echo " # History" + echo " teach deploy --history # Show last 10 deploys" + echo " teach deploy --history 20 # Show last 20 deploys" } # ============================================================================ diff --git a/lib/dispatchers/teach-dispatcher.zsh b/lib/dispatchers/teach-dispatcher.zsh index 4436b9fec..1e6d4139d 100644 --- a/lib/dispatchers/teach-dispatcher.zsh +++ b/lib/dispatchers/teach-dispatcher.zsh @@ -72,6 +72,20 @@ if [[ -z "$_FLOW_TEACH_DEPLOY_ENHANCED_LOADED" ]]; then typeset -g _FLOW_TEACH_DEPLOY_ENHANCED_LOADED=1 fi +# Source deploy history helpers (v6.4.0 - teach deploy v2) +if [[ -z "$_FLOW_DEPLOY_HISTORY_LOADED" ]]; then + local deploy_history_path="${0:A:h:h}/deploy-history-helpers.zsh" + [[ -f "$deploy_history_path" ]] && source "$deploy_history_path" + typeset -g _FLOW_DEPLOY_HISTORY_LOADED=1 +fi + +# Source deploy rollback helpers (v6.4.0 - teach deploy v2) +if [[ -z "$_FLOW_DEPLOY_ROLLBACK_LOADED" ]]; then + local deploy_rollback_path="${0:A:h:h}/deploy-rollback-helpers.zsh" + [[ -f "$deploy_rollback_path" ]] && source "$deploy_rollback_path" + typeset -g _FLOW_DEPLOY_ROLLBACK_LOADED=1 +fi + # Source profile helpers (Phase 2 - Wave 1: Profile Management) if [[ -z "$_FLOW_PROFILE_HELPERS_LOADED" ]]; then local profile_helpers_path="${0:A:h:h}/profile-helpers.zsh" @@ -1794,354 +1808,6 @@ _teach_auto_commit_workflow() { fi } -# ============================================================================ -# TEACH DEPLOY - BRANCH-AWARE PR WORKFLOW (Phase 2 - v5.11.0+) -# ============================================================================ - -# Deploy teaching content from draft to production via PR -# Usage: _teach_deploy [--direct-push] -_teach_deploy() { - local direct_push=false - - # Parse flags - while [[ $# -gt 0 ]]; do - case "$1" in - --direct-push) - direct_push=true - shift - ;; - --help|-h|help) - _teach_deploy_help - return 0 - ;; - *) - _teach_error "Unknown flag: $1" "Run 'teach deploy --help' for usage" - return 1 - ;; - esac - done - - # Check if in git repo - if ! _git_in_repo; then - _teach_error "Not in a git repository" \ - "Initialize git first with: git init" - return 1 - fi - - # Check if config file exists first (standard location: .flow/teach-config.yml) - local config_file=".flow/teach-config.yml" - if [[ ! -f "$config_file" ]]; then - _teach_error ".flow/teach-config.yml not found" \ - "Run 'teach init' to create the configuration" - return 1 - fi - - # Read git configuration from teach-config.yml with fallback values - local draft_branch prod_branch auto_pr require_clean - draft_branch=$(yq '.git.draft_branch // .branches.draft // "draft"' "$config_file" 2>/dev/null) || draft_branch="draft" - prod_branch=$(yq '.git.production_branch // .branches.production // "main"' "$config_file" 2>/dev/null) || prod_branch="main" - auto_pr=$(yq '.git.auto_pr // true' "$config_file" 2>/dev/null) || auto_pr="true" - require_clean=$(yq '.git.require_clean // true' "$config_file" 2>/dev/null) || require_clean="true" - - # Read workflow configuration (Phase 4 - v5.11.0+) - local teaching_mode auto_push - teaching_mode=$(yq '.workflow.teaching_mode // false' "$config_file" 2>/dev/null) || teaching_mode="false" - auto_push=$(yq '.workflow.auto_push // false' "$config_file" 2>/dev/null) || auto_push="false" - - # Read course info for PR title - local course_name - course_name=$(yq '.course.name // "Teaching Project"' "$config_file" 2>/dev/null) || course_name="Teaching Project" - - echo "" - echo "${FLOW_COLORS[info]}🔍 Pre-flight Checks${FLOW_COLORS[reset]}" - echo "${FLOW_COLORS[dim]}─────────────────────────────────────────────────${FLOW_COLORS[reset]}" - - # Check 1: Verify we're on draft branch - local current_branch=$(_git_current_branch) - if [[ "$current_branch" != "$draft_branch" ]]; then - echo "${FLOW_COLORS[error]}✗${FLOW_COLORS[reset]} Not on $draft_branch branch (currently on: $current_branch)" - echo "" - echo -n "${FLOW_COLORS[prompt]}Switch to $draft_branch branch? [Y/n]:${FLOW_COLORS[reset]} " - read -r switch_confirm - - case "$switch_confirm" in - n|N|no|No|NO) - return 1 - ;; - *) - git checkout "$draft_branch" || { - _teach_error "Failed to switch to $draft_branch" - return 1 - } - echo "${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} Switched to $draft_branch" - ;; - esac - else - echo "${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} On $draft_branch branch" - fi - - # Check 2: Verify no uncommitted changes (if required) - if [[ "$require_clean" == "true" ]]; then - if ! _git_is_clean; then - echo "${FLOW_COLORS[error]}✗${FLOW_COLORS[reset]} Uncommitted changes detected" - echo "" - echo " ${FLOW_COLORS[dim]}Commit or stash changes before deploying${FLOW_COLORS[reset]}" - echo " ${FLOW_COLORS[dim]}Or disable with: git.require_clean: false${FLOW_COLORS[reset]}" - return 1 - else - echo "${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} No uncommitted changes" - fi - fi - - # Check 3: Check for unpushed commits (Phase 4 - teaching mode aware) - if _git_has_unpushed_commits; then - echo "${FLOW_COLORS[warn]}⚠️ ${FLOW_COLORS[reset]} Unpushed commits detected" - echo "" - - # Teaching mode: auto-push if enabled, otherwise prompt - if [[ "$teaching_mode" == "true" && "$auto_push" == "true" ]]; then - echo "${FLOW_COLORS[info]}🎓 Teaching mode: Auto-pushing...${FLOW_COLORS[reset]}" - if _git_push_current_branch; then - echo "${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} Pushed to origin/$draft_branch" - else - return 1 - fi - else - # Standard mode or teaching mode without auto_push: prompt user - echo -n "${FLOW_COLORS[prompt]}Push to origin/$draft_branch first? [Y/n]:${FLOW_COLORS[reset]} " - read -r push_confirm - - case "$push_confirm" in - n|N|no|No|NO) - echo "${FLOW_COLORS[warn]}Continuing without push...${FLOW_COLORS[reset]}" - ;; - *) - if _git_push_current_branch; then - echo "${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} Pushed to origin/$draft_branch" - else - return 1 - fi - ;; - esac - fi - else - echo "${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} Remote is up-to-date" - fi - - # Check 4: Conflict detection - if _git_detect_production_conflicts "$draft_branch" "$prod_branch"; then - echo "${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} No conflicts with production" - else - local commits_ahead=$(git rev-list --count "origin/${prod_branch}..origin/${draft_branch}" 2>/dev/null || echo 0) - echo "${FLOW_COLORS[warn]}⚠️ ${FLOW_COLORS[reset]} Production ($prod_branch) has new commits" - echo "" - echo "${FLOW_COLORS[prompt]}Production branch has updates. Rebase first?${FLOW_COLORS[reset]}" - echo "" - echo " ${FLOW_COLORS[dim]}[1]${FLOW_COLORS[reset]} Yes - Rebase $draft_branch onto $prod_branch (Recommended)" - echo " ${FLOW_COLORS[dim]}[2]${FLOW_COLORS[reset]} No - Continue anyway (may have merge conflicts in PR)" - echo " ${FLOW_COLORS[dim]}[3]${FLOW_COLORS[reset]} Cancel deployment" - echo "" - echo -n "${FLOW_COLORS[prompt]}Your choice [1-3]:${FLOW_COLORS[reset]} " - read -r rebase_choice - - case "$rebase_choice" in - 1) - if _git_rebase_onto_production "$draft_branch" "$prod_branch"; then - echo "${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} Rebase successful" - else - return 1 - fi - ;; - 2) - echo "${FLOW_COLORS[warn]}Continuing without rebase...${FLOW_COLORS[reset]}" - ;; - 3|*) - echo "Deployment cancelled" - return 1 - ;; - esac - fi - - echo "" - - # Generate PR details - local commit_count=$(_git_get_commit_count "$draft_branch" "$prod_branch") - local pr_title="Deploy: $course_name Updates" - local pr_body=$(_git_generate_pr_body "$draft_branch" "$prod_branch") - - # Show PR preview - echo "${FLOW_COLORS[info]}📋 Pull Request Preview${FLOW_COLORS[reset]}" - echo "${FLOW_COLORS[dim]}─────────────────────────────────────────────────${FLOW_COLORS[reset]}" - echo "" - echo "${FLOW_COLORS[bold]}Title:${FLOW_COLORS[reset]} $pr_title" - echo "${FLOW_COLORS[bold]}From:${FLOW_COLORS[reset]} $draft_branch → $prod_branch" - echo "${FLOW_COLORS[bold]}Commits:${FLOW_COLORS[reset]} $commit_count" - echo "" - - # ============================================ - # DEPLOYMENT PREVIEW (v5.14.0 - Task 8) - # ============================================ - echo "" - echo "${FLOW_COLORS[info]}📋 Changes Preview${FLOW_COLORS[reset]}" - echo "${FLOW_COLORS[dim]}─────────────────────────────────────────────────${FLOW_COLORS[reset]}" - - # Show files changed summary - local files_changed=$(git diff --name-status "$prod_branch"..."$draft_branch" 2>/dev/null) - if [[ -n "$files_changed" ]]; then - echo "" - echo "${FLOW_COLORS[dim]}Files Changed:${FLOW_COLORS[reset]}" - while IFS=$'\t' read -r file_status file; do - case "$file_status" in - M) echo " ${FLOW_COLORS[warn]}M${FLOW_COLORS[reset]} $file" ;; - A) echo " ${FLOW_COLORS[success]}A${FLOW_COLORS[reset]} $file" ;; - D) echo " ${FLOW_COLORS[error]}D${FLOW_COLORS[reset]} $file" ;; - R*) echo " ${FLOW_COLORS[info]}R${FLOW_COLORS[reset]} $file" ;; - *) echo " ${FLOW_COLORS[muted]}$file_status${FLOW_COLORS[reset]} $file" ;; - esac - done <<< "$files_changed" - - # Count changes by type - local modified=$(echo "$files_changed" | grep -c "^M" || echo 0) - local added=$(echo "$files_changed" | grep -c "^A" || echo 0) - local deleted=$(echo "$files_changed" | grep -c "^D" || echo 0) - local total=$(echo "$files_changed" | wc -l | tr -d ' ') - - echo "" - echo "${FLOW_COLORS[dim]}Summary: $total files ($added added, $modified modified, $deleted deleted)${FLOW_COLORS[reset]}" - else - echo "${FLOW_COLORS[muted]}No changes detected${FLOW_COLORS[reset]}" - fi - - # Offer to view full diff - echo "" - echo -n "${FLOW_COLORS[prompt]}View full diff? [y/N]:${FLOW_COLORS[reset]} " - read -r view_diff - - case "$view_diff" in - y|Y|yes|Yes|YES) - echo "" - echo "${FLOW_COLORS[info]}Showing diff...${FLOW_COLORS[reset]}" - echo "${FLOW_COLORS[dim]}─────────────────────────────────────────────────${FLOW_COLORS[reset]}" - - # Show colorized diff (if available) - if command -v delta >/dev/null 2>&1; then - git diff "$prod_branch"..."$draft_branch" | delta - elif git config --get core.pager >/dev/null 2>&1; then - git diff "$prod_branch"..."$draft_branch" - else - git --no-pager diff --color=always "$prod_branch"..."$draft_branch" | less -R - fi - - echo "" - echo "${FLOW_COLORS[dim]}─────────────────────────────────────────────────${FLOW_COLORS[reset]}" - ;; - *) - # Skip viewing diff - ;; - esac - - echo "" - - # Decide whether to create PR or direct push - if [[ "$direct_push" == "true" ]]; then - echo "${FLOW_COLORS[warn]}⚠️ Direct push mode (bypassing PR)${FLOW_COLORS[reset]}" - echo "" - echo -n "${FLOW_COLORS[prompt]}Push directly to $prod_branch? [y/N]:${FLOW_COLORS[reset]} " - read -r direct_confirm - - case "$direct_confirm" in - y|Y|yes|Yes|YES) - git push origin "$draft_branch:$prod_branch" && \ - echo "${FLOW_COLORS[success]}✅ Pushed to $prod_branch${FLOW_COLORS[reset]}" || \ - return 1 - ;; - *) - echo "Direct push cancelled" - return 1 - ;; - esac - elif [[ "$auto_pr" == "true" ]]; then - # Create PR workflow - echo "${FLOW_COLORS[prompt]}Create pull request?${FLOW_COLORS[reset]}" - echo "" - echo " ${FLOW_COLORS[dim]}[1]${FLOW_COLORS[reset]} Yes - Create PR (Recommended)" - echo " ${FLOW_COLORS[dim]}[2]${FLOW_COLORS[reset]} Push to $draft_branch only (no PR)" - echo " ${FLOW_COLORS[dim]}[3]${FLOW_COLORS[reset]} Cancel" - echo "" - echo -n "${FLOW_COLORS[prompt]}Your choice [1-3]:${FLOW_COLORS[reset]} " - read -r pr_choice - - case "$pr_choice" in - 1) - echo "" - if _git_create_deploy_pr "$draft_branch" "$prod_branch" "$pr_title" "$pr_body"; then - echo "" - echo "${FLOW_COLORS[success]}✅ Pull Request Created${FLOW_COLORS[reset]}" - echo "${FLOW_COLORS[dim]}─────────────────────────────────────────────────${FLOW_COLORS[reset]}" - echo "" - echo " Next steps:" - echo " 1. Review PR on GitHub" - echo " 2. Merge when ready" - echo " 3. Site will auto-deploy after merge" - echo "" - else - return 1 - fi - ;; - 2) - if _git_push_current_branch; then - echo "" - echo "${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} Pushed to origin/$draft_branch" - echo " ${FLOW_COLORS[dim]}Create PR manually on GitHub when ready${FLOW_COLORS[reset]}" - else - return 1 - fi - ;; - 3|*) - echo "Deployment cancelled" - return 1 - ;; - esac - else - # auto_pr is false - just push to draft - if _git_push_current_branch; then - echo "" - echo "${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} Pushed to origin/$draft_branch" - echo " ${FLOW_COLORS[dim]}Create PR manually on GitHub${FLOW_COLORS[reset]}" - else - return 1 - fi - fi -} - -# Help for teach deploy -_teach_deploy_help() { - echo "teach deploy - Deploy teaching content via PR workflow" - echo "" - echo "Usage: teach deploy [options]" - echo "" - echo "Options:" - echo " --direct-push Bypass PR and push directly to production (advanced)" - echo " --help, -h Show this help message" - echo "" - echo "Workflow:" - echo " 1. Verify on draft branch" - echo " 2. Check for uncommitted changes" - echo " 3. Detect conflicts with production" - echo " 4. Create pull request (draft → production)" - echo "" - echo "Configuration (teach-config.yml):" - echo " git:" - echo " draft_branch: draft # Development branch" - echo " production_branch: main # Production branch" - echo " auto_pr: true # Auto-create PR" - echo " require_clean: true # Require clean state" - echo "" - echo "Examples:" - echo " teach deploy # Standard PR workflow" - echo " teach deploy --direct-push # Bypass PR (not recommended)" -} - # ============================================================================ # GIT CLEANUP WORKFLOW (Phase 3 - v5.11.0+) # ============================================================================ @@ -4054,92 +3720,6 @@ ${FLOW_COLORS[muted]}SEE ALSO:${FLOW_COLORS[reset]} EOF } -_teach_deploy_help() { - cat < EOF } +# ============================================================================= +# Function: _generate_smart_commit_message +# Purpose: Generate descriptive commit message from changed files +# ============================================================================= +# Arguments: +# $1 - (optional) Draft branch name +# $2 - (optional) Production branch name +# If both given, diffs between branches; otherwise uses staged/modified files +# +# Returns: +# 0 - Always succeeds +# +# Output: +# stdout - Single-line commit message like "content: week-05 lecture, assignment 3" +# +# Categories: +# lectures/*.qmd → "lecture" (extracts week number) +# labs/*.qmd → "lab" +# assignments/*.qmd → "assignment" (extracts number) +# exams/*.qmd → "exam" +# projects/*.qmd → "project" +# scripts/*.R|*.py → "script" +# home_*.qmd → "index" +# _quarto.yml → "config" +# _metadata.yml → "config" +# .flow/*.yml → "config" +# .STATUS → "config" +# *.css|*.scss → "style" +# images/*|img/* → "media" +# data/* → "data" +# *.qmd → "content" (catch-all) +# * → "misc" (catch-all) +# +# Prefix logic: +# If >50% files are one category, use that as prefix +# Otherwise use "deploy" +# +# Example: +# msg=$(_generate_smart_commit_message "draft" "gh-pages") +# # → "content: week-05 lecture, assignment 3" +# +# msg=$(_generate_smart_commit_message) +# # Uses staged files → "config: quarto settings, metadata" +# +# Notes: +# - Pure ZSH implementation (no external tools except git) +# - Messages truncated to 72 characters +# - Ported from STAT-545's generate_smart_message() logic +# ============================================================================= +_generate_smart_commit_message() { + local draft_branch="${1:-}" + local prod_branch="${2:-}" + local changed_files=() + + # Get changed files + if [[ -n "$draft_branch" && -n "$prod_branch" ]]; then + # Files different between branches + changed_files=(${(f)"$(git diff --name-only "$prod_branch"..."$draft_branch" 2>/dev/null)"}) + else + # Staged files + changed_files=(${(f)"$(git diff --cached --name-only 2>/dev/null)"}) + # If nothing staged, use modified files + if [[ ${#changed_files[@]} -eq 0 ]]; then + changed_files=(${(f)"$(git diff --name-only 2>/dev/null)"}) + fi + fi + + # If no files, generic message + if [[ ${#changed_files[@]} -eq 0 ]]; then + echo "deploy: update" + return 0 + fi + + # Categorize files + local -A categories # category -> count + local -a descriptions # human-readable descriptions + local total=${#changed_files[@]} + + for file in "${changed_files[@]}"; do + [[ -z "$file" ]] && continue + local basename="${file:t}" # filename only + local dirname="${file:h}" # directory only + local ext="${file:e}" # extension + + case "$file" in + lectures/*.qmd) + categories[content]=$(( ${categories[content]:-0} + 1 )) + # Extract week number + if [[ "$basename" =~ "week-([0-9]+)" ]]; then + descriptions+=("week-${match[1]} lecture") + else + descriptions+=("${basename%.qmd} lecture") + fi + ;; + labs/*.qmd) + categories[content]=$(( ${categories[content]:-0} + 1 )) + if [[ "$basename" =~ "week-([0-9]+)" ]]; then + descriptions+=("week-${match[1]} lab") + else + descriptions+=("${basename%.qmd} lab") + fi + ;; + assignments/*.qmd) + categories[content]=$(( ${categories[content]:-0} + 1 )) + if [[ "$basename" =~ "([0-9]+)" ]]; then + descriptions+=("assignment ${match[1]}") + else + descriptions+=("${basename%.qmd} assignment") + fi + ;; + exams/*.qmd) + categories[content]=$(( ${categories[content]:-0} + 1 )) + descriptions+=("${basename%.qmd} exam") + ;; + projects/*.qmd) + categories[content]=$(( ${categories[content]:-0} + 1 )) + descriptions+=("${basename%.qmd} project") + ;; + scripts/*.R|scripts/*.py) + categories[content]=$(( ${categories[content]:-0} + 1 )) + descriptions+=("${basename} script") + ;; + home_*.qmd) + categories[config]=$(( ${categories[config]:-0} + 1 )) + descriptions+=("index update") + ;; + _quarto.yml|_metadata.yml) + categories[config]=$(( ${categories[config]:-0} + 1 )) + descriptions+=("${basename}") + ;; + .flow/*.yml) + categories[config]=$(( ${categories[config]:-0} + 1 )) + descriptions+=("flow config") + ;; + .STATUS) + categories[config]=$(( ${categories[config]:-0} + 1 )) + descriptions+=("status") + ;; + *.css|*.scss) + categories[style]=$(( ${categories[style]:-0} + 1 )) + descriptions+=("style") + ;; + images/*|img/*) + categories[content]=$(( ${categories[content]:-0} + 1 )) + descriptions+=("media") + ;; + data/*) + categories[data]=$(( ${categories[data]:-0} + 1 )) + descriptions+=("data") + ;; + *.qmd) + categories[content]=$(( ${categories[content]:-0} + 1 )) + descriptions+=("${basename%.qmd}") + ;; + *) + categories[misc]=$(( ${categories[misc]:-0} + 1 )) + ;; + esac + done + + # Determine prefix from dominant category + local prefix="deploy" + local max_count=0 + for cat count in ${(kv)categories}; do + if [[ $count -gt $max_count ]]; then + max_count=$count + prefix="$cat" + fi + done + + # If dominant is >50% of total, use it; otherwise "deploy" + if [[ $max_count -le $(( total / 2 )) ]]; then + prefix="deploy" + fi + + # "misc" isn't a good prefix + [[ "$prefix" == "misc" ]] && prefix="deploy" + + # Deduplicate descriptions + local -a unique_descs=() + local -A seen_descs + for desc in "${descriptions[@]}"; do + [[ -z "$desc" ]] && continue + if [[ -z "${seen_descs[$desc]:-}" ]]; then + seen_descs[$desc]=1 + unique_descs+=("$desc") + fi + done + + # Build message body + local body="" + if [[ ${#unique_descs[@]} -eq 0 ]]; then + body="update" + elif [[ $total -gt 10 && ${#unique_descs[@]} -gt 5 ]]; then + body="full site update ($total files)" + elif [[ ${#unique_descs[@]} -le 3 ]]; then + body="${(j:, :)unique_descs}" + else + # Take first 3, add "+N more" + local first_three=("${unique_descs[@]:0:3}") + local remaining=$(( ${#unique_descs[@]} - 3 )) + body="${(j:, :)first_three} +${remaining} more" + fi + + # Truncate to 72 chars + local message="${prefix}: ${body}" + if [[ ${#message} -gt 72 ]]; then + message="${message:0:69}..." + fi + + echo "$message" + return 0 +} + # ============================================================================= # Function: _git_is_clean # Purpose: Check if working directory has no uncommitted changes diff --git a/mkdocs.yml b/mkdocs.yml index 146253bdc..0c9cc5693 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -157,6 +157,7 @@ nav: - Content Creation: guides/INTELLIGENT-CONTENT-ANALYSIS.md - Scholar Wrappers: guides/SCHOLAR-WRAPPERS-GUIDE.md - Deployment: guides/TEACH-DEPLOY-GUIDE.md + - Deploy v2 (Direct, History, Rollback): tutorials/31-teach-deploy-v2.md - Git Integration: tutorials/19-teaching-git-integration.md - Visual Workflow Guide: guides/TEACHING-WORKFLOW-VISUAL.md - Features: @@ -184,6 +185,7 @@ nav: - LaTeX Macros: reference/REFCARD-MACROS.md - Lesson Plans: reference/REFCARD-TEACH-PLAN.md - Prompts: reference/REFCARD-PROMPTS.md + - Deploy v2: reference/REFCARD-DEPLOY-V2.md - Scholar Flags: reference/REFCARD-SCHOLAR-FLAGS.md - Troubleshooting: guides/TEACHING-TROUBLESHOOTING.md - Help System: guides/HELP-SYSTEM-GUIDE.md diff --git a/tests/dogfood-teach-deploy-v2.zsh b/tests/dogfood-teach-deploy-v2.zsh new file mode 100755 index 000000000..7ba18f6e0 --- /dev/null +++ b/tests/dogfood-teach-deploy-v2.zsh @@ -0,0 +1,949 @@ +#!/usr/bin/env zsh +# dogfood-teach-deploy-v2.zsh - Non-interactive dogfooding for teach deploy v2 +# Run with: zsh tests/dogfood-teach-deploy-v2.zsh +# +# Tests the REAL plugin functions against the demo course fixture. +# Loads the full plugin (source flow.plugin.zsh) -- not mocked. +# +# Sections: +# 1. Plugin Load Verification (4 tests) +# 2. Help Output (6 tests) +# 3. Smart Commit Messages (6 tests) +# 4. Deploy History Helpers (8 tests) +# 5. Deploy Rollback Helpers (3 tests) +# 6. Preflight Checks (Demo Course) (6 tests) +# 7. Dry-Run Preview (5 tests) +# 8. .STATUS Updates (4 tests) +# 9. Flag Parsing (5 tests) +# 10. Full Deploy Lifecycle E2E (4 tests) +# Total: ~51 tests + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +CYAN='\033[0;36m' +DIM='\033[2m' +RESET='\033[0m' + +# Counters +typeset -g TESTS_RUN=0 +typeset -g TESTS_PASSED=0 +typeset -g TESTS_FAILED=0 +typeset -g TESTS_SKIPPED=0 + +# Get script directory +SCRIPT_DIR="${0:A:h}" +PROJECT_ROOT="${SCRIPT_DIR}/.." +DEMO_COURSE="$PROJECT_ROOT/tests/fixtures/demo-course" + +# Global temp dirs to clean up +typeset -ga _DOGFOOD_TEMP_DIRS=() + +cleanup_all() { + for d in "${_DOGFOOD_TEMP_DIRS[@]}"; do + [[ -d "$d" ]] && rm -rf "$d" + done +} +trap cleanup_all EXIT + +# Load plugin +echo "${CYAN}Loading flow-cli plugin...${RESET}" +source "$PROJECT_ROOT/flow.plugin.zsh" 2>/dev/null || { + echo "${RED}ERROR: Failed to load plugin${RESET}" + exit 1 +} +echo "${GREEN}Plugin loaded${RESET}" +echo "" + +# ============================================================================ +# Test runner -- exit 0=pass, 77=skip, other=fail +# ============================================================================ +run_test() { + local test_name="$1" + local test_func="$2" + + TESTS_RUN=$((TESTS_RUN + 1)) + echo -n "${CYAN}[$TESTS_RUN] $test_name...${RESET} " + + local output + output=$(eval "$test_func" 2>&1) + local exit_code=$? + + if [[ $exit_code -eq 0 ]]; then + echo "${GREEN}PASS${RESET}" + TESTS_PASSED=$((TESTS_PASSED + 1)) + elif [[ $exit_code -eq 77 ]]; then + echo "${YELLOW}SKIP${RESET}" + TESTS_SKIPPED=$((TESTS_SKIPPED + 1)) + else + echo "${RED}FAIL${RESET}" + echo " ${DIM}Output: ${output:0:200}${RESET}" + TESTS_FAILED=$((TESTS_FAILED + 1)) + fi +} + +# ============================================================================ +# yq probe -- some sandboxed environments block yq inside functions +# ============================================================================ +_YQ_AVAILABLE=false +if command -v yq >/dev/null 2>&1; then + _probe=$(echo "test: value" | yq '.test' 2>/dev/null) + [[ "$_probe" == "value" ]] && _YQ_AVAILABLE=true + unset _probe +fi + +if [[ "$_YQ_AVAILABLE" != "true" ]]; then + echo "${YELLOW}Warning: yq not available -- some tests will be skipped${RESET}" + echo "" +fi + +# ============================================================================ +# HELPER: Create sandboxed git repo with draft/main branches +# Returns the temp dir path on stdout +# ============================================================================ +_create_test_repo() { + local tmpdir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$tmpdir") + + ( + cd "$tmpdir" + git init -q + git config user.email "test@example.com" + git config user.name "Test" + + # Initial commit on main + mkdir -p .flow lectures + cat > .flow/teach-config.yml <<'YAML' +course: + name: 'TEST-200' +semester_info: + start_date: '2026-08-26' +YAML + echo "# Test Course" > README.md + git add -A && git commit -q -m "init" + + # Create draft branch with content + git checkout -q -b draft + echo "---\ntitle: Week 1\n---\n# Lecture" > lectures/week-01.qmd + git add -A && git commit -q -m "add week-01 lecture" + ) >/dev/null 2>&1 + + echo "$tmpdir" +} + +# ============================================================================ +# HELPER: Create test repo as a copy of demo course +# ============================================================================ +_create_demo_repo() { + local tmpdir=$(mktemp -d) + local remotedir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$tmpdir" "$remotedir") + + ( + # Create bare remote first + cd "$remotedir" && git init --bare -q + ) >/dev/null 2>&1 + + ( + cp -R "$DEMO_COURSE"/. "$tmpdir"/ + cd "$tmpdir" + git init -q + git config user.email "test@example.com" + git config user.name "Test" + git add -A && git commit -q -m "init demo course" + + # Set up remote and push main + git remote add origin "$remotedir" + git push -q origin main 2>/dev/null + + # Create draft branch + git checkout -q -b draft + # Add a small change so there is something to deploy + echo "\n## Updated" >> lectures/week-01.qmd + git add -A && git commit -q -m "update week-01" + git push -q origin draft 2>/dev/null + ) >/dev/null 2>&1 + + echo "$tmpdir" +} + +# ============================================================================ +# SECTION 1: Plugin Load Verification +# ============================================================================ +echo "${CYAN}--- Section 1: Plugin Load Verification ---${RESET}" + +run_test "Core deploy v2 functions are loaded" ' + typeset -f _teach_deploy_enhanced >/dev/null 2>&1 || return 1 + typeset -f _deploy_preflight_checks >/dev/null 2>&1 || return 1 + typeset -f _deploy_direct_merge >/dev/null 2>&1 || return 1 + typeset -f _deploy_dry_run_report >/dev/null 2>&1 || return 1 +' + +run_test "Deploy history functions are loaded" ' + typeset -f _deploy_history_append >/dev/null 2>&1 || return 1 + typeset -f _deploy_history_list >/dev/null 2>&1 || return 1 + typeset -f _deploy_history_count >/dev/null 2>&1 || return 1 + typeset -f _deploy_history_get >/dev/null 2>&1 || return 1 +' + +run_test "Deploy rollback functions are loaded" ' + typeset -f _deploy_rollback >/dev/null 2>&1 || return 1 + typeset -f _deploy_perform_rollback >/dev/null 2>&1 || return 1 +' + +run_test "Smart commit and status helpers are loaded" ' + typeset -f _generate_smart_commit_message >/dev/null 2>&1 || return 1 + typeset -f _deploy_update_status_file >/dev/null 2>&1 || return 1 +' + +echo "" + +# ============================================================================ +# SECTION 2: Help Output +# ============================================================================ +echo "${CYAN}--- Section 2: Help Output ---${RESET}" + +run_test "Help function produces output" ' + local output + output=$(_teach_deploy_enhanced_help 2>&1) + [[ -n "$output" ]] || return 1 +' + +run_test "Help contains --direct flag" ' + local output + output=$(_teach_deploy_enhanced_help 2>&1) + [[ "$output" == *"--direct"* ]] || return 1 +' + +run_test "Help contains --rollback flag" ' + local output + output=$(_teach_deploy_enhanced_help 2>&1) + [[ "$output" == *"--rollback"* ]] || return 1 +' + +run_test "Help contains --history flag" ' + local output + output=$(_teach_deploy_enhanced_help 2>&1) + [[ "$output" == *"--history"* ]] || return 1 +' + +run_test "Help contains --dry-run flag" ' + local output + output=$(_teach_deploy_enhanced_help 2>&1) + [[ "$output" == *"--dry-run"* ]] || return 1 +' + +run_test "Help contains --ci flag" ' + local output + output=$(_teach_deploy_enhanced_help 2>&1) + [[ "$output" == *"--ci"* ]] || return 1 +' + +echo "" + +# ============================================================================ +# SECTION 3: Smart Commit Messages +# ============================================================================ +echo "${CYAN}--- Section 3: Smart Commit Messages ---${RESET}" + +run_test "Lecture files produce content: prefix" ' + local tmpdir=$(_create_test_repo) + local msg + msg=$(cd "$tmpdir" && _generate_smart_commit_message "draft" "main" 2>/dev/null) + [[ "$msg" == content:* ]] || return 1 +' + +run_test "Config files produce config: prefix" ' + local tmpdir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$tmpdir") + ( + cd "$tmpdir" + git init -q + git config user.email "test@example.com" + git config user.name "Test" + echo "initial" > README.md + git add -A && git commit -q -m "init" + git checkout -q -b draft + echo "project:" > _quarto.yml + echo "theme: custom" > .flow/config.yml + mkdir -p .flow + echo "flow:" > .flow/config.yml + git add -A && git commit -q -m "add config" + ) >/dev/null 2>&1 + local msg + msg=$(cd "$tmpdir" && _generate_smart_commit_message "draft" "main" 2>/dev/null) + [[ "$msg" == *"config"* ]] || return 1 +' + +run_test "Assignment files produce content with assignment" ' + local tmpdir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$tmpdir") + ( + cd "$tmpdir" + git init -q + git config user.email "test@example.com" + git config user.name "Test" + echo "initial" > README.md + git add -A && git commit -q -m "init" + git checkout -q -b draft + mkdir -p assignments + echo "---\ntitle: HW3\n---" > assignments/hw3.qmd + git add -A && git commit -q -m "add assignment" + ) >/dev/null 2>&1 + local msg + msg=$(cd "$tmpdir" && _generate_smart_commit_message "draft" "main" 2>/dev/null) + [[ "$msg" == *"assignment"* ]] || return 1 +' + +run_test "Mixed files produce deploy: prefix" ' + local tmpdir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$tmpdir") + ( + cd "$tmpdir" + git init -q + git config user.email "test@example.com" + git config user.name "Test" + echo "initial" > README.md + git add -A && git commit -q -m "init" + git checkout -q -b draft + mkdir -p lectures data .flow + echo "lecture" > lectures/week-01.qmd + echo "data" > data/dataset.csv + echo "config:" > _quarto.yml + echo "theme" > style.css + git add -A && git commit -q -m "add mixed" + ) >/dev/null 2>&1 + local msg + msg=$(cd "$tmpdir" && _generate_smart_commit_message "draft" "main" 2>/dev/null) + # Mixed content should not be 100% one category so prefix is "deploy" or a dominant cat + [[ -n "$msg" ]] || return 1 +' + +run_test "No changes produce fallback message" ' + local tmpdir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$tmpdir") + ( + cd "$tmpdir" + git init -q + git config user.email "test@example.com" + git config user.name "Test" + echo "initial" > README.md + git add -A && git commit -q -m "init" + git checkout -q -b draft + # No changes between draft and main + ) >/dev/null 2>&1 + local msg + msg=$(cd "$tmpdir" && _generate_smart_commit_message "draft" "main" 2>/dev/null) + [[ "$msg" == "deploy: update" ]] || return 1 +' + +run_test "Message truncates at 72 characters" ' + local tmpdir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$tmpdir") + ( + cd "$tmpdir" + git init -q + git config user.email "test@example.com" + git config user.name "Test" + echo "initial" > README.md + git add -A && git commit -q -m "init" + git checkout -q -b draft + mkdir -p lectures + for i in {01..20}; do + echo "week $i" > "lectures/week-$i-very-long-name-for-testing.qmd" + done + git add -A && git commit -q -m "add many lectures" + ) >/dev/null 2>&1 + local msg + msg=$(cd "$tmpdir" && _generate_smart_commit_message "draft" "main" 2>/dev/null) + [[ ${#msg} -le 72 ]] || return 1 +' + +echo "" + +# ============================================================================ +# SECTION 4: Deploy History Helpers +# ============================================================================ +echo "${CYAN}--- Section 4: Deploy History Helpers ---${RESET}" + +run_test "History append creates file when missing" ' + local tmpdir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$tmpdir") + ( + cd "$tmpdir" + _deploy_history_append "direct" "abc12345" "def67890" "draft" "main" "5" "test deploy" "null" "null" "10" + ) >/dev/null 2>&1 + [[ -f "$tmpdir/.flow/deploy-history.yml" ]] || return 1 +' + +run_test "History append writes valid YAML with deploys key" ' + [[ "$_YQ_AVAILABLE" == "true" ]] || return 77 + local tmpdir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$tmpdir") + ( + cd "$tmpdir" + _deploy_history_append "direct" "abc12345" "def67890" "draft" "main" "5" "test deploy" "null" "null" "10" + ) >/dev/null 2>&1 + local top_key + top_key=$(yq "has(\"deploys\")" "$tmpdir/.flow/deploy-history.yml" 2>/dev/null) + [[ "$top_key" == "true" ]] || return 1 +' + +run_test "History count returns correct count after appends" ' + [[ "$_YQ_AVAILABLE" == "true" ]] || return 77 + local tmpdir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$tmpdir") + ( + cd "$tmpdir" + _deploy_history_append "direct" "aaaa1111" "" "draft" "main" "3" "first deploy" "null" "null" "8" + _deploy_history_append "direct" "bbbb2222" "aaaa1111" "draft" "main" "5" "second deploy" "null" "null" "12" + _deploy_history_append "pr" "cccc3333" "bbbb2222" "draft" "main" "2" "third deploy" "42" "null" "60" + ) >/dev/null 2>&1 + local count + count=$(cd "$tmpdir" && _deploy_history_count 2>/dev/null) + [[ "$count" == "3" ]] || return 1 +' + +run_test "History list produces table output" ' + [[ "$_YQ_AVAILABLE" == "true" ]] || return 77 + local tmpdir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$tmpdir") + ( + cd "$tmpdir" + _deploy_history_append "direct" "aaaa1111" "" "draft" "main" "3" "first deploy" "null" "null" "8" + _deploy_history_append "direct" "bbbb2222" "aaaa1111" "draft" "main" "5" "second deploy" "null" "null" "12" + ) >/dev/null 2>&1 + local output + output=$(cd "$tmpdir" && _deploy_history_list 5 2>&1) + [[ "$output" == *"Recent deployments"* ]] || return 1 + [[ "$output" == *"#"* ]] || return 1 +' + +run_test "History get retrieves most recent entry (index 1)" ' + [[ "$_YQ_AVAILABLE" == "true" ]] || return 77 + local tmpdir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$tmpdir") + ( + cd "$tmpdir" + _deploy_history_append "direct" "aaaa1111" "" "draft" "main" "3" "first deploy" "null" "null" "8" + _deploy_history_append "pr" "bbbb2222" "aaaa1111" "draft" "main" "7" "latest deploy" "99" "null" "45" + ) >/dev/null 2>&1 + local result + result=$( + cd "$tmpdir" + _deploy_history_get 1 + echo "$DEPLOY_HIST_MODE" + ) + [[ "$result" == "pr" ]] || return 1 +' + +run_test "History get with index 2 retrieves second most recent" ' + [[ "$_YQ_AVAILABLE" == "true" ]] || return 77 + local tmpdir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$tmpdir") + ( + cd "$tmpdir" + _deploy_history_append "direct" "aaaa1111" "" "draft" "main" "3" "first deploy" "null" "null" "8" + _deploy_history_append "pr" "bbbb2222" "aaaa1111" "draft" "main" "7" "latest deploy" "99" "null" "45" + ) >/dev/null 2>&1 + local result + result=$( + cd "$tmpdir" + _deploy_history_get 2 + echo "$DEPLOY_HIST_MODE" + ) + [[ "$result" == "direct" ]] || return 1 +' + +run_test "Single quotes in commit message are escaped" ' + local tmpdir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$tmpdir") + ( + cd "$tmpdir" + _deploy_history_append "direct" "abc12345" "" "draft" "main" "1" "it'\''s a test" "null" "null" "5" + ) >/dev/null 2>&1 + # File should exist and not have broken YAML + [[ -f "$tmpdir/.flow/deploy-history.yml" ]] || return 1 + # Basic check: file has the escaped content (double single quotes) + grep -q "it" "$tmpdir/.flow/deploy-history.yml" || return 1 +' + +run_test "Multiple appends do not corrupt history file" ' + [[ "$_YQ_AVAILABLE" == "true" ]] || return 77 + local tmpdir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$tmpdir") + ( + cd "$tmpdir" + for i in {1..5}; do + _deploy_history_append "direct" "hash000$i" "" "draft" "main" "$i" "deploy number $i" "null" "null" "$((i * 10))" + done + ) >/dev/null 2>&1 + # Use _deploy_history_count which calls yq internally -- verifies parsability + local count + count=$(cd "$tmpdir" && _deploy_history_count 2>/dev/null) + [[ "$count" == "5" ]] || return 1 +' + +echo "" + +# ============================================================================ +# SECTION 5: Deploy Rollback Helpers +# ============================================================================ +echo "${CYAN}--- Section 5: Deploy Rollback Helpers ---${RESET}" + +run_test "Rollback in CI mode without index returns error" ' + local tmpdir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$tmpdir") + ( + cd "$tmpdir" + git init -q + git config user.email "test@example.com" + git config user.name "Test" + echo "x" > f.txt && git add -A && git commit -q -m "init" + mkdir -p .flow + _deploy_history_append "direct" "abc12345" "" "draft" "main" "1" "test" "null" "null" "5" + ) >/dev/null 2>&1 + local output + output=$(cd "$tmpdir" && _deploy_rollback --ci 2>&1) + local rc=$? + [[ $rc -ne 0 ]] || return 1 + [[ "$output" == *"CI mode requires explicit"* ]] || return 1 +' + +run_test "Rollback with invalid index returns error" ' + [[ "$_YQ_AVAILABLE" == "true" ]] || return 77 + local tmpdir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$tmpdir") + ( + cd "$tmpdir" + git init -q + git config user.email "test@example.com" + git config user.name "Test" + echo "x" > f.txt && git add -A && git commit -q -m "init" + mkdir -p .flow + _deploy_history_append "direct" "abc12345" "" "draft" "main" "1" "test" "null" "null" "5" + ) >/dev/null 2>&1 + local output + output=$(cd "$tmpdir" && _deploy_rollback 99 --ci 2>&1) + local rc=$? + [[ $rc -ne 0 ]] || return 1 + [[ "$output" == *"Invalid"* ]] || return 1 +' + +run_test "Rollback with no history returns error" ' + local tmpdir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$tmpdir") + ( + cd "$tmpdir" + git init -q + git config user.email "test@example.com" + git config user.name "Test" + echo "x" > f.txt && git add -A && git commit -q -m "init" + ) >/dev/null 2>&1 + local output + output=$(cd "$tmpdir" && _deploy_rollback 1 --ci 2>&1) + local rc=$? + [[ $rc -ne 0 ]] || return 1 + [[ "$output" == *"No deployment history"* ]] || return 1 +' + +echo "" + +# ============================================================================ +# SECTION 6: Preflight Checks with Demo Course +# ============================================================================ +echo "${CYAN}--- Section 6: Preflight Checks (Demo Course) ---${RESET}" + +run_test "Preflight succeeds in demo-course git repo on draft branch" ' + [[ "$_YQ_AVAILABLE" == "true" ]] || return 77 + local tmpdir=$(_create_demo_repo) + local output + output=$(cd "$tmpdir" && _deploy_preflight_checks "true" 2>&1) + local rc=$? + [[ $rc -eq 0 ]] || return 1 +' + +run_test "Preflight sets DEPLOY_COURSE_NAME to STAT-101" ' + [[ "$_YQ_AVAILABLE" == "true" ]] || return 77 + local tmpdir=$(_create_demo_repo) + local name + name=$( + cd "$tmpdir" + _deploy_preflight_checks "true" >/dev/null 2>&1 + echo "$DEPLOY_COURSE_NAME" + ) + [[ "$name" == "STAT-101" ]] || return 1 +' + +run_test "Preflight sets DEPLOY_DRAFT_BRANCH to draft (default)" ' + [[ "$_YQ_AVAILABLE" == "true" ]] || return 77 + local tmpdir=$(_create_demo_repo) + local branch + branch=$( + cd "$tmpdir" + _deploy_preflight_checks "true" >/dev/null 2>&1 + echo "$DEPLOY_DRAFT_BRANCH" + ) + [[ "$branch" == "draft" ]] || return 1 +' + +run_test "Preflight sets DEPLOY_PROD_BRANCH to main (default)" ' + [[ "$_YQ_AVAILABLE" == "true" ]] || return 77 + local tmpdir=$(_create_demo_repo) + local branch + branch=$( + cd "$tmpdir" + _deploy_preflight_checks "true" >/dev/null 2>&1 + echo "$DEPLOY_PROD_BRANCH" + ) + [[ "$branch" == "main" ]] || return 1 +' + +run_test "Preflight fails outside git repo" ' + local tmpdir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$tmpdir") + mkdir -p "$tmpdir/.flow" + echo "course:\n name: X" > "$tmpdir/.flow/teach-config.yml" + local output + output=$(cd "$tmpdir" && _deploy_preflight_checks "true" 2>&1) + local rc=$? + [[ $rc -ne 0 ]] || return 1 +' + +run_test "Preflight fails without .flow/teach-config.yml" ' + local tmpdir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$tmpdir") + ( + cd "$tmpdir" + git init -q + git config user.email "test@example.com" + git config user.name "Test" + echo "x" > f.txt && git add -A && git commit -q -m "init" + ) >/dev/null 2>&1 + local output + output=$(cd "$tmpdir" && _deploy_preflight_checks "true" 2>&1) + local rc=$? + [[ $rc -ne 0 ]] || return 1 + [[ "$output" == *"teach-config.yml"* ]] || return 1 +' + +echo "" + +# ============================================================================ +# SECTION 7: Dry-Run Preview +# ============================================================================ +echo "${CYAN}--- Section 7: Dry-Run Preview ---${RESET}" + +run_test "Dry-run output contains DRY RUN" ' + [[ "$_YQ_AVAILABLE" == "true" ]] || return 77 + local tmpdir=$(_create_demo_repo) + local output + output=$(cd "$tmpdir" && _teach_deploy_enhanced --dry-run --ci 2>&1) + [[ "$output" == *"DRY RUN"* ]] || return 1 +' + +run_test "Dry-run with --direct mentions direct mode" ' + [[ "$_YQ_AVAILABLE" == "true" ]] || return 77 + local tmpdir=$(_create_demo_repo) + local output + output=$(cd "$tmpdir" && _teach_deploy_enhanced --dry-run --direct --ci 2>&1) + [[ "$output" == *"direct"* ]] || return 1 +' + +run_test "Dry-run does NOT modify git state" ' + [[ "$_YQ_AVAILABLE" == "true" ]] || return 77 + local tmpdir=$(_create_demo_repo) + local hash_before hash_after + hash_before=$(cd "$tmpdir" && git rev-parse HEAD 2>/dev/null) + (cd "$tmpdir" && _teach_deploy_enhanced --dry-run --ci) >/dev/null 2>&1 + hash_after=$(cd "$tmpdir" && git rev-parse HEAD 2>/dev/null) + [[ "$hash_before" == "$hash_after" ]] || return 1 +' + +run_test "Dry-run with custom message shows the message" ' + [[ "$_YQ_AVAILABLE" == "true" ]] || return 77 + local tmpdir=$(_create_demo_repo) + local output + output=$(cd "$tmpdir" && _teach_deploy_enhanced --dry-run -m "Custom deploy msg" --ci 2>&1) + [[ "$output" == *"Custom deploy msg"* ]] || return 1 +' + +run_test "Dry-run shows file count" ' + [[ "$_YQ_AVAILABLE" == "true" ]] || return 77 + local tmpdir=$(_create_demo_repo) + local output + output=$(cd "$tmpdir" && _teach_deploy_enhanced --dry-run --ci 2>&1) + # Output should mention a number of files + [[ "$output" == *"file"* ]] || return 1 +' + +echo "" + +# ============================================================================ +# SECTION 8: .STATUS Updates +# ============================================================================ +echo "${CYAN}--- Section 8: .STATUS Updates ---${RESET}" + +run_test "Status update skips when no .STATUS file (returns 0)" ' + local tmpdir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$tmpdir") + ( + cd "$tmpdir" + git init -q + git config user.email "test@example.com" + git config user.name "Test" + echo "x" > f.txt && git add -A && git commit -q -m "init" + ) >/dev/null 2>&1 + (cd "$tmpdir" && _deploy_update_status_file) >/dev/null 2>&1 + local rc=$? + [[ $rc -eq 0 ]] || return 1 + # Should NOT create a .STATUS file + [[ ! -f "$tmpdir/.STATUS" ]] || return 1 +' + +run_test "Status update writes last_deploy when .STATUS exists" ' + [[ "$_YQ_AVAILABLE" == "true" ]] || return 77 + local tmpdir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$tmpdir") + ( + cd "$tmpdir" + git init -q + git config user.email "test@example.com" + git config user.name "Test" + mkdir -p .flow + echo "course:\n name: X" > .flow/teach-config.yml + echo "status: active" > .STATUS + git add -A && git commit -q -m "init" + ) >/dev/null 2>&1 + (cd "$tmpdir" && _deploy_update_status_file) >/dev/null 2>&1 + local last_deploy + last_deploy=$(yq '.last_deploy' "$tmpdir/.STATUS" 2>/dev/null) + [[ -n "$last_deploy" && "$last_deploy" != "null" ]] || return 1 +' + +run_test "Status update writes deploy_count when history exists" ' + [[ "$_YQ_AVAILABLE" == "true" ]] || return 77 + local tmpdir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$tmpdir") + ( + cd "$tmpdir" + git init -q + git config user.email "test@example.com" + git config user.name "Test" + mkdir -p .flow + echo "course:\n name: X" > .flow/teach-config.yml + echo "status: active" > .STATUS + _deploy_history_append "direct" "abc12345" "" "draft" "main" "3" "test" "null" "null" "5" + _deploy_history_append "direct" "def67890" "abc12345" "draft" "main" "2" "test2" "null" "null" "8" + git add -A && git commit -q -m "init" + ) >/dev/null 2>&1 + (cd "$tmpdir" && _deploy_update_status_file) >/dev/null 2>&1 + local count + count=$(yq '.deploy_count' "$tmpdir/.STATUS" 2>/dev/null) + [[ "$count" == "2" ]] || return 1 +' + +_test_status_teaching_week() { + [[ "$_YQ_AVAILABLE" == "true" ]] || return 77 + local tmpdir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$tmpdir") + local two_weeks_ago + two_weeks_ago=$(date -v-14d "+%Y-%m-%d" 2>/dev/null || date -d "14 days ago" "+%Y-%m-%d" 2>/dev/null) + if [[ -z "$two_weeks_ago" ]]; then + return 77 # Skip if date math fails (non-macOS) + fi + ( + cd "$tmpdir" + git init -q + git config user.email "test@example.com" + git config user.name "Test" + mkdir -p .flow + printf "course:\n name: WEEK-TEST\nsemester_info:\n start_date: '%s'\n" "$two_weeks_ago" > .flow/teach-config.yml + echo "status: active" > .STATUS + git add -A && git commit -q -m "init" + ) >/dev/null 2>&1 + (cd "$tmpdir" && _deploy_update_status_file) >/dev/null 2>&1 + local week + week=$(yq '.teaching_week' "$tmpdir/.STATUS" 2>/dev/null) + # Should be a positive number (2 or 3 depending on rounding) + [[ -n "$week" && "$week" != "null" && "$week" -ge 1 ]] || return 1 +} +run_test "Status update calculates teaching_week from start_date" '_test_status_teaching_week' + +echo "" + +# ============================================================================ +# SECTION 9: Flag Parsing +# ============================================================================ +echo "${CYAN}--- Section 9: Flag Parsing ---${RESET}" + +run_test "--help produces help output without error" ' + local output + output=$(_teach_deploy_enhanced --help 2>&1) + local rc=$? + [[ $rc -eq 0 ]] || return 1 + [[ "$output" == *"teach deploy"* ]] || return 1 +' + +run_test "Unknown flag --bogus returns error" ' + local output + output=$(_teach_deploy_enhanced --bogus 2>&1) + local rc=$? + [[ $rc -ne 0 ]] || return 1 + [[ "$output" == *"Unknown flag"* ]] || return 1 +' + +run_test "--direct-push is accepted (backward compat alias)" ' + [[ "$_YQ_AVAILABLE" == "true" ]] || return 77 + local tmpdir=$(_create_demo_repo) + # Use dry-run to test flag acceptance without side effects + local output + output=$(cd "$tmpdir" && _teach_deploy_enhanced --dry-run --direct-push --ci 2>&1) + # Should not say "Unknown flag" + [[ "$output" != *"Unknown flag"* ]] || return 1 +' + +run_test "--rollback dispatches without full preflight" ' + local tmpdir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$tmpdir") + ( + cd "$tmpdir" + git init -q + git config user.email "test@example.com" + git config user.name "Test" + echo "x" > f.txt && git add -A && git commit -q -m "init" + # No .flow/teach-config.yml -- preflight would fail if called + ) >/dev/null 2>&1 + local output + output=$(cd "$tmpdir" && _teach_deploy_enhanced --rollback 1 --ci 2>&1) + # Should fail with "No deployment history" NOT "teach-config.yml not found" + [[ "$output" == *"history"* || "$output" == *"History"* ]] || return 1 + [[ "$output" != *"teach-config.yml not found"* ]] || return 1 +' + +run_test "--history dispatches without full preflight" ' + local tmpdir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$tmpdir") + ( + cd "$tmpdir" + # No git repo, no config -- should just report no history + ) >/dev/null 2>&1 + local output + output=$(cd "$tmpdir" && _teach_deploy_enhanced --history 2>&1) + # Should mention no history, NOT preflight failure + [[ "$output" == *"No deploy"* || "$output" == *"history"* ]] || return 1 + [[ "$output" != *"teach-config.yml not found"* ]] || return 1 +' + +echo "" + +# ============================================================================ +# SECTION 10: Full Deploy Lifecycle E2E +# ============================================================================ +echo "${CYAN}--- Section 10: Full Deploy Lifecycle E2E ---${RESET}" + +run_test "Direct deploy in sandboxed repo completes successfully" ' + [[ "$_YQ_AVAILABLE" == "true" ]] || return 77 + local tmpdir=$(_create_test_repo) + + # Direct deploy needs a remote to push to. + # Create a bare remote alongside. + local remote_dir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$remote_dir") + ( + cd "$remote_dir" && git init --bare -q + ) >/dev/null 2>&1 + ( + cd "$tmpdir" + git remote add origin "$remote_dir" + # Push both branches to the bare remote + git push -q origin main 2>/dev/null + git push -q origin draft 2>/dev/null + ) >/dev/null 2>&1 + + local output + output=$(cd "$tmpdir" && _teach_deploy_enhanced --direct --ci 2>&1) + local rc=$? + [[ $rc -eq 0 ]] || return 1 +' + +run_test "After direct deploy, history file exists with 1 entry" ' + [[ "$_YQ_AVAILABLE" == "true" ]] || return 77 + local tmpdir=$(_create_test_repo) + + local remote_dir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$remote_dir") + (cd "$remote_dir" && git init --bare -q) >/dev/null 2>&1 + ( + cd "$tmpdir" + git remote add origin "$remote_dir" + git push -q origin main 2>/dev/null + git push -q origin draft 2>/dev/null + ) >/dev/null 2>&1 + + (cd "$tmpdir" && _teach_deploy_enhanced --direct --ci) >/dev/null 2>&1 + + [[ -f "$tmpdir/.flow/deploy-history.yml" ]] || return 1 + local count + count=$(cd "$tmpdir" && _deploy_history_count 2>/dev/null) + [[ "$count" == "1" ]] || return 1 +' + +run_test "History list after deploy shows the entry" ' + [[ "$_YQ_AVAILABLE" == "true" ]] || return 77 + local tmpdir=$(_create_test_repo) + + local remote_dir=$(mktemp -d) + _DOGFOOD_TEMP_DIRS+=("$remote_dir") + (cd "$remote_dir" && git init --bare -q) >/dev/null 2>&1 + ( + cd "$tmpdir" + git remote add origin "$remote_dir" + git push -q origin main 2>/dev/null + git push -q origin draft 2>/dev/null + ) >/dev/null 2>&1 + + (cd "$tmpdir" && _teach_deploy_enhanced --direct --ci) >/dev/null 2>&1 + + local output + output=$(cd "$tmpdir" && _deploy_history_list 5 2>&1) + [[ "$output" == *"direct"* ]] || return 1 + [[ "$output" == *"Recent deployments"* ]] || return 1 +' + +run_test "Dry-run does NOT create history entry" ' + [[ "$_YQ_AVAILABLE" == "true" ]] || return 77 + local tmpdir=$(_create_demo_repo) + # Start without history + [[ ! -f "$tmpdir/.flow/deploy-history.yml" ]] || rm -f "$tmpdir/.flow/deploy-history.yml" + + (cd "$tmpdir" && _teach_deploy_enhanced --dry-run --direct --ci) >/dev/null 2>&1 + + # History file should NOT exist (dry-run does not record) + if [[ -f "$tmpdir/.flow/deploy-history.yml" ]]; then + local count + count=$(cd "$tmpdir" && _deploy_history_count 2>/dev/null) + [[ "$count" == "0" ]] || return 1 + fi + return 0 +' + +echo "" + +# ============================================================================ +# Summary +# ============================================================================ +echo "=================================================" +echo "" +if [[ $TESTS_FAILED -eq 0 ]]; then + echo "${GREEN}All $TESTS_PASSED/$TESTS_RUN tests passed${RESET}" + [[ $TESTS_SKIPPED -gt 0 ]] && echo " ${YELLOW}($TESTS_SKIPPED skipped -- yq not available in sandbox)${RESET}" +else + echo "${RED}$TESTS_FAILED/$TESTS_RUN tests failed${RESET}" + echo " ${GREEN}$TESTS_PASSED passed${RESET}, ${RED}$TESTS_FAILED failed${RESET}" + [[ $TESTS_SKIPPED -gt 0 ]] && echo " ${YELLOW}$TESTS_SKIPPED skipped${RESET}" +fi +echo "" + +exit $TESTS_FAILED diff --git a/tests/e2e-teach-deploy-v2.zsh b/tests/e2e-teach-deploy-v2.zsh new file mode 100755 index 000000000..0e8d0f2f6 --- /dev/null +++ b/tests/e2e-teach-deploy-v2.zsh @@ -0,0 +1,500 @@ +#!/usr/bin/env zsh +# e2e-teach-deploy-v2.zsh - End-to-end tests for teach deploy v2 +# Tests full deploy lifecycle using sandboxed git repos + +# Test framework setup +PASS=0 +FAIL=0 +SKIP=0 + +_test_pass() { ((PASS++)); echo " ✅ $1"; } +_test_fail() { ((FAIL++)); echo " ❌ $1: $2"; } +_test_skip() { ((SKIP++)); echo " ⏭️ $1 (skipped)"; } + +# ============================================================================ +# SETUP +# ============================================================================ + +SCRIPT_DIR="${0:A:h}" +PROJECT_ROOT="${SCRIPT_DIR:h}" +TEST_DIR=$(mktemp -d) +ORIGINAL_DIR=$(pwd) + +cleanup() { + cd "$ORIGINAL_DIR" + rm -rf "$TEST_DIR" +} +trap cleanup EXIT + +# Minimal FLOW_COLORS for non-interactive tests +typeset -gA FLOW_COLORS +FLOW_COLORS[info]="" +FLOW_COLORS[success]="" +FLOW_COLORS[error]="" +FLOW_COLORS[warn]="" +FLOW_COLORS[dim]="" +FLOW_COLORS[bold]="" +FLOW_COLORS[reset]="" +FLOW_COLORS[prompt]="" +FLOW_COLORS[muted]="" + +# Source all needed libraries +source "$PROJECT_ROOT/lib/core.zsh" 2>/dev/null || true +source "$PROJECT_ROOT/lib/git-helpers.zsh" 2>/dev/null || true +source "$PROJECT_ROOT/lib/deploy-history-helpers.zsh" 2>/dev/null || true +source "$PROJECT_ROOT/lib/deploy-rollback-helpers.zsh" 2>/dev/null || true +source "$PROJECT_ROOT/lib/dispatchers/teach-deploy-enhanced.zsh" 2>/dev/null || true + +# Stub missing functions +if ! typeset -f _teach_error >/dev/null 2>&1; then + _teach_error() { echo "ERROR: $1" >&2; } +fi +if ! typeset -f _git_in_repo >/dev/null 2>&1; then + _git_in_repo() { git rev-parse --git-dir >/dev/null 2>&1; } +fi +if ! typeset -f _git_current_branch >/dev/null 2>&1; then + _git_current_branch() { git branch --show-current 2>/dev/null; } +fi +if ! typeset -f _git_is_clean >/dev/null 2>&1; then + _git_is_clean() { [[ -z "$(git status --porcelain 2>/dev/null)" ]]; } +fi +if ! typeset -f _git_detect_production_conflicts >/dev/null 2>&1; then + _git_detect_production_conflicts() { return 0; } +fi +if ! typeset -f _git_has_unpushed_commits >/dev/null 2>&1; then + _git_has_unpushed_commits() { return 1; } +fi +if ! typeset -f _git_push_current_branch >/dev/null 2>&1; then + _git_push_current_branch() { git push origin "$(git branch --show-current)" 2>/dev/null; } +fi +if ! typeset -f _git_is_synced >/dev/null 2>&1; then + _git_is_synced() { return 0; } +fi + +# Helper: create full E2E repo with bare remote, draft + main branches +setup_e2e_repo() { + local bare_dir=$(mktemp -d "$TEST_DIR/bare-XXXXXX") + local work_dir=$(mktemp -d "$TEST_DIR/e2e-XXXXXX") + rm -rf "$work_dir" # clone needs empty target + + # Create bare remote + ( + cd "$bare_dir" + git init -q --bare + ) >/dev/null 2>&1 + + # Clone working dir + git clone -q "$bare_dir" "$work_dir" >/dev/null 2>&1 + ( + cd "$work_dir" + git config user.email "test@test.com" + git config user.name "Test" + + # Setup course structure + mkdir -p .flow lectures labs assignments + cat > .flow/teach-config.yml <<'YAML' +course: + name: "STAT-101" +git: + draft_branch: draft + production_branch: main + auto_pr: true + require_clean: true +semester_info: + start_date: "2026-01-12" +YAML + + echo "status: active" > .STATUS + + cat > lectures/week-01.qmd <<'QMD' +--- +title: "Week 1: Introduction" +--- +Introduction to statistics. +QMD + + git add -A && git commit -q -m "init: course structure" + git push -q origin main + + # Create draft branch with additional content + git checkout -q -b draft + echo "# Week 2 lecture" > lectures/week-02.qmd + git add -A && git commit -q -m "add week-02 lecture" + git push -q -u origin draft + ) >/dev/null 2>&1 + + echo "$work_dir" +} + +echo "" +echo "╔════════════════════════════════════════════════════════════╗" +echo "║ teach deploy v2 - E2E Tests ║" +echo "╚════════════════════════════════════════════════════════════╝" +echo "" + +# ============================================================================ +# SECTION 1: Direct Merge Lifecycle +# ============================================================================ +echo "--- Direct Merge Lifecycle ---" + +# Test 1: Full direct deploy lifecycle +test_repo=$(setup_e2e_repo) +cd "$test_repo" +# Should be on draft branch with remote +branch=$(git branch --show-current) +if [[ "$branch" == "draft" ]]; then + # Call without $() capture so DEPLOY_* variables propagate + _deploy_direct_merge "draft" "main" "deploy: week-02 lecture" "true" >/dev/null 2>&1 + ret=$? + if [[ $ret -eq 0 ]]; then + _test_pass "full direct deploy lifecycle succeeds" + else + _test_fail "full direct deploy lifecycle succeeds" "ret=$ret" + fi +else + _test_fail "full direct deploy lifecycle" "not on draft (on $branch)" +fi + +# Test 2: Direct merge exports DEPLOY_* variables +if [[ -n "$DEPLOY_COMMIT_AFTER" && -n "$DEPLOY_COMMIT_BEFORE" ]]; then + _test_pass "direct merge exports DEPLOY_COMMIT_* variables" +else + _test_fail "direct merge exports DEPLOY_COMMIT_* variables" "after=$DEPLOY_COMMIT_AFTER before=$DEPLOY_COMMIT_BEFORE" +fi + +# Test 3: After direct merge, we're back on draft +branch=$(git branch --show-current) +if [[ "$branch" == "draft" ]]; then + _test_pass "after direct merge, back on draft branch" +else + _test_fail "after direct merge, back on draft branch" "on $branch" +fi + +# ============================================================================ +# SECTION 2: Deploy History Lifecycle +# ============================================================================ +echo "" +echo "--- Deploy History Lifecycle ---" + +# Test 4: history recording after deploy +test_repo=$(setup_e2e_repo) +cd "$test_repo" +_deploy_direct_merge "draft" "main" "deploy: test content" "true" >/dev/null 2>&1 +# Manually record history (as _teach_deploy_enhanced would) +_deploy_history_append "direct" "${DEPLOY_COMMIT_AFTER}" "${DEPLOY_COMMIT_BEFORE}" "draft" "main" "1" "deploy: test content" "null" "null" "${DEPLOY_DURATION}" >/dev/null 2>&1 +count=$(_deploy_history_count) +if [[ "$count" == "1" ]]; then + _test_pass "deploy records entry in history" +else + _test_fail "deploy records entry in history" "count=$count" +fi + +# Test 5: multiple deploys build sequential history +echo "# Week 3" > lectures/week-03.qmd +git add -A && git commit -q -m "add week-03" +git push -q origin draft 2>/dev/null +_deploy_direct_merge "draft" "main" "deploy: week-03" "true" >/dev/null 2>&1 +_deploy_history_append "direct" "${DEPLOY_COMMIT_AFTER}" "${DEPLOY_COMMIT_BEFORE}" "draft" "main" "1" "deploy: week-03" "null" "null" "${DEPLOY_DURATION}" >/dev/null 2>&1 +count=$(_deploy_history_count) +if [[ "$count" == "2" ]]; then + _test_pass "multiple deploys build sequential history" +else + _test_fail "multiple deploys build sequential history" "count=$count" +fi + +# Test 6: history list shows entries +output=$(_deploy_history_list 5 2>&1) +if echo "$output" | grep -q "deploy"; then + _test_pass "history list shows deploy entries" +else + _test_fail "history list shows deploy entries" "not found" +fi + +# ============================================================================ +# SECTION 3: Smart Commit Messages +# ============================================================================ +echo "" +echo "--- Smart Commit Messages ---" + +# Test 7: smart commit message generation +if typeset -f _generate_smart_commit_message >/dev/null 2>&1; then + test_repo=$(setup_e2e_repo) + cd "$test_repo" + msg=$(_generate_smart_commit_message "draft" "main") + if [[ -n "$msg" && "$msg" != "deploy: update" ]]; then + _test_pass "smart commit generates meaningful message: $msg" + else + # Fallback message is also acceptable + _test_pass "smart commit generates message (fallback): $msg" + fi +else + _test_skip "smart commit message generation (_generate_smart_commit_message not available)" +fi + +# ============================================================================ +# SECTION 4: Dry-run E2E +# ============================================================================ +echo "" +echo "--- Dry-run E2E ---" + +# Test 8: dry-run shows correct output +test_repo=$(setup_e2e_repo) +cd "$test_repo" +output=$(_deploy_dry_run_report "draft" "main" "STAT-101" "false" "" 2>&1) +ret=$? +if [[ $ret -eq 0 ]] && echo "$output" | grep -qi "DRY RUN"; then + _test_pass "dry-run shows correct output" +else + _test_fail "dry-run shows correct output" "ret=$ret" +fi + +# Test 9: dry-run shows files that would be deployed +if echo "$output" | grep -q "week-02"; then + _test_pass "dry-run shows files (week-02.qmd)" +else + _test_fail "dry-run shows files" "week-02 not found in output" +fi + +# ============================================================================ +# SECTION 5: .STATUS E2E +# ============================================================================ +echo "" +echo "--- .STATUS E2E ---" + +# Test 10: .STATUS gets updated after deploy +if command -v yq >/dev/null 2>&1; then + test_repo=$(setup_e2e_repo) + cd "$test_repo" + _deploy_history_append "direct" "abc12345" "def67890" "draft" "main" "1" "test" "null" "null" "5" >/dev/null 2>&1 + _deploy_update_status_file >/dev/null 2>&1 + ld=$(yq '.last_deploy // ""' .STATUS 2>/dev/null) + dc=$(yq '.deploy_count // 0' .STATUS 2>/dev/null) + today=$(date '+%Y-%m-%d') + if [[ "$ld" == "$today" && "$dc" == "1" ]]; then + _test_pass ".STATUS updated with deploy info" + else + _test_fail ".STATUS updated with deploy info" "ld=$ld dc=$dc" + fi +else + _test_skip ".STATUS update (yq not available)" +fi + +# Test 11: .STATUS teaching_week calculation +if command -v yq >/dev/null 2>&1; then + test_repo=$(setup_e2e_repo) + cd "$test_repo" + _deploy_update_status_file >/dev/null 2>&1 + tw=$(yq '.teaching_week // 0' .STATUS 2>/dev/null) + # Since start_date is 2026-01-12, on 2026-02-03 we should be week 4 + if [[ "$tw" -ge 1 && "$tw" -le 20 ]]; then + _test_pass ".STATUS teaching_week calculated (week $tw)" + else + # Week calculation may fail if date math is different; skip if 0 + if [[ "$tw" == "0" || -z "$tw" ]]; then + _test_skip ".STATUS teaching_week (date math issue)" + else + _test_fail ".STATUS teaching_week" "got: $tw" + fi + fi +else + _test_skip ".STATUS teaching_week (yq not available)" +fi + +# ============================================================================ +# SECTION 6: Help Output +# ============================================================================ +echo "" +echo "--- Help Output ---" + +# Test 12: help shows all options including rollback/history +output=$(_teach_deploy_enhanced_help 2>&1) +if echo "$output" | grep -q "\-\-rollback" && echo "$output" | grep -q "\-\-history"; then + _test_pass "help shows --rollback and --history options" +else + _test_fail "help shows --rollback and --history" "missing from output" +fi + +# Test 13: help shows rollback examples +if echo "$output" | grep -q "rollback 1"; then + _test_pass "help shows rollback examples" +else + _test_fail "help shows rollback examples" "not found" +fi + +# Test 14: help shows history examples +if echo "$output" | grep -q "history 20"; then + _test_pass "help shows history examples" +else + _test_fail "help shows history examples" "not found" +fi + +# Test 15: help shows .STATUS feature +if echo "$output" | grep -qi "STATUS"; then + _test_pass "help mentions .STATUS auto-update" +else + _test_fail "help mentions .STATUS auto-update" "not found" +fi + +# ============================================================================ +# SECTION 7: CI Mode E2E +# ============================================================================ +echo "" +echo "--- CI Mode E2E ---" + +# Test 16: CI mode direct merge +test_repo=$(setup_e2e_repo) +cd "$test_repo" +output=$(_deploy_direct_merge "draft" "main" "ci: auto deploy" "true" 2>&1) +ret=$? +if [[ $ret -eq 0 ]]; then + _test_pass "CI mode direct merge succeeds" +else + _test_fail "CI mode direct merge succeeds" "ret=$ret" +fi + +# Test 17: CI preflight on draft branch +cd "$test_repo" +output=$(_deploy_preflight_checks "true" 2>&1) +ret=$? +if [[ $ret -eq 0 ]]; then + _test_pass "CI preflight passes on draft branch" +else + _test_fail "CI preflight passes on draft branch" "ret=$ret" +fi + +# ============================================================================ +# SECTION 8: Auto-tag +# ============================================================================ +echo "" +echo "--- Auto-tag ---" + +# Test 18: auto-tag creates git tag +test_repo=$(setup_e2e_repo) +cd "$test_repo" +_deploy_direct_merge "draft" "main" "deploy: with tag" "true" >/dev/null 2>&1 +# Now simulate auto-tag (as the enhanced function would do) +tag="deploy-test-$(date +%Y%m%d%H%M%S)" +git tag "$tag" 2>/dev/null +if git tag | grep -q "$tag"; then + _test_pass "auto-tag creates git tag" +else + _test_fail "auto-tag creates git tag" "tag not found" +fi + +# ============================================================================ +# SECTION 9: Rollback E2E +# ============================================================================ +echo "" +echo "--- Rollback E2E ---" + +# Test 19: rollback with explicit index on valid history +test_repo=$(setup_e2e_repo) +cd "$test_repo" +# Do a deploy first +_deploy_direct_merge "draft" "main" "deploy: to be rolled back" "true" >/dev/null 2>&1 +_deploy_history_append "direct" "${DEPLOY_COMMIT_AFTER}" "${DEPLOY_COMMIT_BEFORE}" "draft" "main" "1" "deploy: to be rolled back" "null" "null" "5" >/dev/null 2>&1 +# Capture count BEFORE rollback for Test 20 +count_before=$(_deploy_history_count) +# Now rollback +output=$(_deploy_rollback 1 --ci 2>&1) +ret=$? +if [[ $ret -eq 0 ]]; then + _test_pass "rollback with explicit index succeeds" +else + _test_fail "rollback with explicit index" "ret=$ret output=$(echo "$output" | tail -3)" +fi + +# Test 20: rollback records in history with mode=rollback +# If rollback succeeded, check that history count increased +if [[ $ret -eq 0 ]]; then + count_after=$(_deploy_history_count) + if [[ "$count_after" -gt "$count_before" ]]; then + _deploy_history_get 1 + if [[ "$DEPLOY_HIST_MODE" == "rollback" ]]; then + _test_pass "rollback records in history with mode=rollback" + else + _test_fail "rollback records in history" "mode=$DEPLOY_HIST_MODE" + fi + else + _test_fail "rollback records in history" "count unchanged" + fi +else + _test_skip "rollback history recording (rollback did not complete)" +fi + +# ============================================================================ +# SECTION 10: Merge Commit Rollback (regression test for -m 1 fix) +# ============================================================================ +echo "" +echo "--- Merge Commit Rollback ---" + +# Test 21: deploy with diverged branches creates a merge commit +test_repo=$(setup_e2e_repo) +cd "$test_repo" +# Create divergence: add a commit directly to main that draft doesn't have +( + cd "$test_repo" + git checkout -q main 2>/dev/null + echo "# Hotfix content" > lectures/hotfix.qmd + git add -A && git commit -q -m "hotfix: main-only change" 2>/dev/null + git push -q origin main 2>/dev/null + git checkout -q draft 2>/dev/null +) >/dev/null 2>&1 + +# Deploy — draft and main have diverged, so merge creates 2-parent commit +_deploy_direct_merge "draft" "main" "deploy: merge commit test" "true" >/dev/null 2>&1 +ret_deploy=$? + +if [[ $ret_deploy -ne 0 ]]; then + _test_fail "merge commit deploy" "deploy failed ret=$ret_deploy" + _test_skip "merge commit rollback (deploy failed)" + _test_skip "merge commit rollback history (deploy failed)" +else + merge_hash="${DEPLOY_COMMIT_AFTER}" + parent_count=$(git cat-file -p "$merge_hash" 2>/dev/null | grep -c "^parent ") + + if [[ $parent_count -ge 2 ]]; then + _test_pass "diverged deploy creates merge commit ($parent_count parents)" + else + _test_fail "diverged deploy creates merge commit" "expected >=2 parents, got $parent_count" + fi + + # Test 22: rollback of merge commit succeeds (exercises -m 1 code path) + _deploy_history_append "direct" "$merge_hash" "${DEPLOY_COMMIT_BEFORE}" "draft" "main" "2" "deploy: merge commit test" "null" "null" "3" >/dev/null 2>&1 + mc_count_before=$(_deploy_history_count) + + mc_output=$(_deploy_rollback 1 --ci 2>&1) + mc_ret=$? + + if [[ $mc_ret -eq 0 ]]; then + _test_pass "merge commit rollback succeeds via -m 1" + else + _test_fail "merge commit rollback" "ret=$mc_ret output=$(echo "$mc_output" | tail -3)" + fi + + # Test 23: merge commit rollback recorded in history with mode=rollback + if [[ $mc_ret -eq 0 ]]; then + mc_count_after=$(_deploy_history_count) + if [[ "$mc_count_after" -gt "$mc_count_before" ]]; then + _deploy_history_get 1 + if [[ "$DEPLOY_HIST_MODE" == "rollback" ]]; then + _test_pass "merge commit rollback records with mode=rollback" + else + _test_fail "merge commit rollback history" "mode=$DEPLOY_HIST_MODE" + fi + else + _test_fail "merge commit rollback history" "count unchanged" + fi + else + _test_skip "merge commit rollback history (rollback failed)" + fi +fi + +# ============================================================================ +# SUMMARY +# ============================================================================ +echo "" +echo "═══════════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed, $SKIP skipped" +echo "═══════════════════════════════════════════" +[[ $FAIL -eq 0 ]] diff --git a/tests/fixtures/demo-course/.teach/concepts.json b/tests/fixtures/demo-course/.teach/concepts.json index fddaa559a..15a8457c3 100644 --- a/tests/fixtures/demo-course/.teach/concepts.json +++ b/tests/fixtures/demo-course/.teach/concepts.json @@ -2,8 +2,8 @@ "version": "1.0", "schema_version": "concept-graph-v1", "metadata": { - "last_updated": "2026-01-31T23:23:47Z", - "course_hash": "361ea9220e2ff2b6771468e18cddb45b5465b3a2", + "last_updated": "2026-02-04T05:03:50Z", + "course_hash": "e36a2bbd0443fc3840f3126e9917bc30afe2f373", "total_concepts": 12, "weeks": 5, "extraction_method": "frontmatter" @@ -32,7 +32,10 @@ "distributions": { "id": "distributions", "name": "Distributions", - "prerequisites": ["descriptive-stats", "data-types"], + "prerequisites": [ + "descriptive-stats", + "data-types" + ], "introduced_in": { "week": 1, "lecture": "lectures/week-01.qmd", @@ -42,7 +45,9 @@ "probability-basics": { "id": "probability-basics", "name": "Probability-Basics", - "prerequisites": ["data-types"], + "prerequisites": [ + "data-types" + ], "introduced_in": { "week": 2, "lecture": "lectures/week-02.qmd", @@ -52,7 +57,9 @@ "sampling": { "id": "sampling", "name": "Sampling", - "prerequisites": ["distributions"], + "prerequisites": [ + "distributions" + ], "introduced_in": { "week": 2, "lecture": "lectures/week-02.qmd", @@ -62,7 +69,11 @@ "inference": { "id": "inference", "name": "Inference", - "prerequisites": ["probability-basics", "sampling", "distributions"], + "prerequisites": [ + "probability-basics", + "sampling", + "distributions" + ], "introduced_in": { "week": 2, "lecture": "lectures/week-02.qmd", @@ -72,7 +83,10 @@ "linear-regression": { "id": "linear-regression", "name": "Linear-Regression", - "prerequisites": ["correlation", "inference"], + "prerequisites": [ + "correlation", + "inference" + ], "introduced_in": { "week": 3, "lecture": "lectures/week-03-broken.qmd", @@ -82,7 +96,9 @@ "correlation": { "id": "correlation", "name": "Correlation", - "prerequisites": ["linear-regression"], + "prerequisites": [ + "linear-regression" + ], "introduced_in": { "week": 3, "lecture": "lectures/week-03-broken.qmd", @@ -92,7 +108,10 @@ "multiple-regression": { "id": "multiple-regression", "name": "Multiple-Regression", - "prerequisites": ["linear-regression", "correlation"], + "prerequisites": [ + "linear-regression", + "correlation" + ], "introduced_in": { "week": 4, "lecture": "lectures/week-04.qmd", @@ -102,7 +121,10 @@ "model-selection": { "id": "model-selection", "name": "Model-Selection", - "prerequisites": ["multiple-regression", "inference"], + "prerequisites": [ + "multiple-regression", + "inference" + ], "introduced_in": { "week": 4, "lecture": "lectures/week-04.qmd", @@ -112,7 +134,10 @@ "assumptions-checking": { "id": "assumptions-checking", "name": "Assumptions-Checking", - "prerequisites": ["multiple-regression", "distributions"], + "prerequisites": [ + "multiple-regression", + "distributions" + ], "introduced_in": { "week": 4, "lecture": "lectures/week-04.qmd", @@ -122,7 +147,10 @@ "advanced-modeling": { "id": "advanced-modeling", "name": "Advanced-Modeling", - "prerequisites": ["nonexistent-concept", "multiple-regression"], + "prerequisites": [ + "nonexistent-concept", + "multiple-regression" + ], "introduced_in": { "week": 5, "lecture": "lectures/week-05-missing-prereq.qmd", diff --git a/tests/run-all.sh b/tests/run-all.sh index 6fdac9802..69e97537c 100755 --- a/tests/run-all.sh +++ b/tests/run-all.sh @@ -88,6 +88,9 @@ echo "Teach command tests:" run_test ./tests/test-teach-plan.zsh run_test ./tests/test-teach-plan-security.zsh run_test ./tests/automated-teach-style-dogfood.zsh +run_test ./tests/dogfood-teach-deploy-v2.zsh +run_test ./tests/test-teach-deploy-v2-unit.zsh +run_test ./tests/test-teach-deploy-v2-integration.zsh echo "" echo "Help compliance tests:" @@ -99,6 +102,7 @@ echo "E2E tests:" run_test ./tests/e2e-teach-plan.zsh run_test ./tests/e2e-teach-analyze.zsh run_test ./tests/e2e-dot-safety.zsh +run_test ./tests/e2e-teach-deploy-v2.zsh echo "" echo "=========================================" diff --git a/tests/test-teach-deploy-v2-integration.zsh b/tests/test-teach-deploy-v2-integration.zsh new file mode 100755 index 000000000..cbc35243d --- /dev/null +++ b/tests/test-teach-deploy-v2-integration.zsh @@ -0,0 +1,410 @@ +#!/usr/bin/env zsh +# test-teach-deploy-v2-integration.zsh - Integration tests for teach deploy v2 +# Tests flag combinations and multi-step workflows + +# Test framework setup +PASS=0 +FAIL=0 +SKIP=0 + +_test_pass() { ((PASS++)); echo " ✅ $1"; } +_test_fail() { ((FAIL++)); echo " ❌ $1: $2"; } +_test_skip() { ((SKIP++)); echo " ⏭️ $1 (skipped)"; } + +# ============================================================================ +# SETUP +# ============================================================================ + +SCRIPT_DIR="${0:A:h}" +PROJECT_ROOT="${SCRIPT_DIR:h}" +TEST_DIR=$(mktemp -d) +ORIGINAL_DIR=$(pwd) + +cleanup() { + cd "$ORIGINAL_DIR" + rm -rf "$TEST_DIR" +} +trap cleanup EXIT + +# Minimal FLOW_COLORS for non-interactive tests +typeset -gA FLOW_COLORS +FLOW_COLORS[info]="" +FLOW_COLORS[success]="" +FLOW_COLORS[error]="" +FLOW_COLORS[warn]="" +FLOW_COLORS[dim]="" +FLOW_COLORS[bold]="" +FLOW_COLORS[reset]="" +FLOW_COLORS[prompt]="" +FLOW_COLORS[muted]="" + +# Source libraries (suppress output) +source "$PROJECT_ROOT/lib/core.zsh" 2>/dev/null || true +source "$PROJECT_ROOT/lib/git-helpers.zsh" 2>/dev/null || true +source "$PROJECT_ROOT/lib/deploy-history-helpers.zsh" 2>/dev/null || true +source "$PROJECT_ROOT/lib/deploy-rollback-helpers.zsh" 2>/dev/null || true +source "$PROJECT_ROOT/lib/dispatchers/teach-deploy-enhanced.zsh" 2>/dev/null || true + +# Stub/override functions for test isolation +# These MUST be set AFTER sourcing libs to override real implementations +_teach_error() { echo "ERROR: $1" >&2; } +_git_in_repo() { git rev-parse --git-dir >/dev/null 2>&1; } +_git_current_branch() { git branch --show-current 2>/dev/null; } +_git_is_clean() { [[ -z "$(git status --porcelain 2>/dev/null)" ]]; } +# Override conflict detection for test repos (no remote tracking) +_git_detect_production_conflicts() { return 0; } +_git_has_unpushed_commits() { return 1; } + +# Helper: create a sandboxed git repo with draft/main branches and a remote +setup_full_repo() { + local bare_dir=$(mktemp -d "$TEST_DIR/bare-XXXXXX") + local work_dir="$TEST_DIR/work-$(basename $bare_dir)" + rm -rf "$work_dir" # ensure clean + + # Create bare remote + git init -q --bare "$bare_dir" 2>/dev/null + + # Clone working dir + git clone -q "$bare_dir" "$work_dir" 2>/dev/null + cd "$work_dir" + git config user.email "test@test.com" + git config user.name "Test" + + # Setup config + mkdir -p .flow + cat > .flow/teach-config.yml <<'YAML' +course: + name: "STAT-101" +git: + draft_branch: draft + production_branch: main + auto_pr: true + require_clean: true +YAML + git add -A >/dev/null 2>&1 && git commit -q -m "init" >/dev/null 2>&1 + git push -q origin main 2>/dev/null + + # Create draft branch + git checkout -q -b draft 2>/dev/null + git push -q -u origin draft 2>/dev/null + + echo "$work_dir" +} + +# Helper: create simple git repo (no remote) +setup_simple_repo() { + local dir=$(mktemp -d "$TEST_DIR/simple-XXXXXX") + mkdir -p "$dir/.flow" + ( + cd "$dir" + git init -q 2>/dev/null + git config user.email "test@test.com" + git config user.name "Test" + cat > .flow/teach-config.yml <<'YAML' +course: + name: "STAT-101" +git: + draft_branch: draft + production_branch: main + auto_pr: true + require_clean: true +YAML + git add -A >/dev/null 2>&1 + git commit -q -m "init" >/dev/null 2>&1 + ) + echo "$dir" +} + +echo "" +echo "╔════════════════════════════════════════════════════════════╗" +echo "║ teach deploy v2 - Integration Tests ║" +echo "╚════════════════════════════════════════════════════════════╝" +echo "" + +# ============================================================================ +# SECTION 1: Flag Combinations +# ============================================================================ +echo "--- Flag Combinations ---" + +# Test 1: --history exits early (no preflight) +test_repo=$(setup_simple_repo) +cd "$test_repo" +_deploy_history_append "direct" "aaa11111" "bbb22222" "draft" "main" "2" "test" "null" "null" "5" >/dev/null 2>&1 +output=$(_teach_deploy_enhanced --history 2>&1) +ret=$? +if [[ $ret -eq 0 ]] && ! echo "$output" | grep -q "Pre-flight"; then + _test_pass "--history exits early without preflight" +else + _test_fail "--history exits early without preflight" "ret=$ret" +fi + +# Test 2: --history with custom count +_deploy_history_append "pr" "bbb22222" "aaa11111" "draft" "main" "3" "second" "null" "null" "8" >/dev/null 2>&1 +output=$(_teach_deploy_enhanced --history 1 2>&1) +if [[ $ret -eq 0 ]]; then + _test_pass "--history with custom count" +else + _test_fail "--history with custom count" "ret=$ret" +fi + +# Test 3: --rollback exits early (no preflight) +output=$(_teach_deploy_enhanced --rollback 1 --ci 2>&1) +# Rollback will fail on simple repo (no remote), but should NOT run preflight +if ! echo "$output" | grep -q "Pre-flight Checks"; then + _test_pass "--rollback exits early without preflight" +else + _test_fail "--rollback exits early without preflight" "preflight ran" +fi + +# Test 4: --ci + --rollback passes ci flag through +output=$(_teach_deploy_enhanced --ci --rollback --ci 2>&1) +# CI mode without index should produce "CI mode requires explicit" error +if echo "$output" | grep -qi "CI mode requires explicit\|index"; then + _test_pass "--ci + --rollback passes ci flag (requires index)" +else + # May also succeed if history is empty + if echo "$output" | grep -qi "No deployment history"; then + _test_pass "--ci + --rollback passes ci flag (no history)" + else + _test_fail "--ci + --rollback passes ci flag" "unexpected output" + fi +fi + +# Test 5: unknown flags return error +output=$(_teach_deploy_enhanced --unknown-flag 2>&1) +ret=$? +if [[ $ret -ne 0 ]]; then + _test_pass "unknown flags return error" +else + _test_fail "unknown flags return error" "expected non-zero exit" +fi + +# Test 6: --help shows help (no error) +output=$(_teach_deploy_enhanced --help 2>&1) +ret=$? +if [[ $ret -eq 0 ]] && echo "$output" | grep -q "teach deploy"; then + _test_pass "--help shows help text" +else + _test_fail "--help shows help text" "ret=$ret" +fi + +# Test 7: --help includes rollback option +if echo "$output" | grep -q "\-\-rollback"; then + _test_pass "--help includes --rollback option" +else + _test_fail "--help includes --rollback option" "not found" +fi + +# Test 8: --help includes history option +if echo "$output" | grep -q "\-\-history"; then + _test_pass "--help includes --history option" +else + _test_fail "--help includes --history option" "not found" +fi + +# Test 9: --dry-run includes history log hint +test_repo=$(setup_simple_repo) +cd "$test_repo" +git checkout -q -b draft 2>/dev/null +echo "new" > test.qmd +git add -A >/dev/null 2>&1 && git commit -q -m "content" >/dev/null 2>&1 +output=$(_teach_deploy_enhanced --dry-run 2>&1) +if echo "$output" | grep -q "deploy-history"; then + _test_pass "--dry-run shows history log hint" +else + _test_fail "--dry-run shows history log hint" "not found" +fi + +# ============================================================================ +# SECTION 2: Preflight with CI mode +# ============================================================================ +echo "" +echo "--- Preflight CI Mode ---" + +# Test 10: CI mode fails on wrong branch +test_repo=$(setup_simple_repo) +cd "$test_repo" +# Stay on main (not draft) +output=$(_deploy_preflight_checks "true" 2>&1) +ret=$? +if [[ $ret -ne 0 ]]; then + _test_pass "CI mode preflight fails when not on draft branch" +else + _test_fail "CI mode preflight fails when not on draft branch" "expected failure" +fi + +# Test 11: CI mode succeeds on draft branch +# Note: requires a repo where draft and main don't conflict +# Override conflict detection to isolate preflight logic +_git_detect_production_conflicts() { return 0; } +git checkout -q -b draft 2>/dev/null +output=$(_deploy_preflight_checks "true" 2>&1) +ret=$? +if [[ $ret -eq 0 ]]; then + _test_pass "CI mode preflight succeeds on draft branch" +else + _test_fail "CI mode preflight succeeds on draft branch" "ret=$ret" +fi + +# ============================================================================ +# SECTION 3: Deploy History Integration +# ============================================================================ +echo "" +echo "--- Deploy History Integration ---" + +# Test 12: multiple deploys build sequential history +test_repo=$(setup_simple_repo) +cd "$test_repo" +for i in {1..5}; do + _deploy_history_append "direct" "hash${i}aaa" "prev${i}aaa" "draft" "main" "$i" "deploy $i" "null" "null" "$i" >/dev/null 2>&1 +done +count=$(_deploy_history_count) +if [[ "$count" == "5" ]]; then + _test_pass "multiple deploys build sequential history" +else + _test_fail "multiple deploys build sequential history" "count=$count" +fi + +# Test 13: history_get returns entries in reverse order +_deploy_history_get 1 +msg1="$DEPLOY_HIST_MESSAGE" +_deploy_history_get 5 +msg5="$DEPLOY_HIST_MESSAGE" +if [[ "$msg1" == "deploy 5" && "$msg5" == "deploy 1" ]]; then + _test_pass "history entries in reverse order (newest=1)" +else + _test_fail "history entries in reverse order" "1=$msg1, 5=$msg5" +fi + +# Test 14: history list with limited count +output=$(_deploy_history_list 2 2>&1) +# Should show 2 entries, not 5 +lines_with_deploy=$(echo "$output" | grep -c "deploy [0-9]" || echo 0) +if [[ "$lines_with_deploy" -le 3 ]]; then + _test_pass "history list respects count limit" +else + _test_fail "history list respects count limit" "showed $lines_with_deploy entries" +fi + +# ============================================================================ +# SECTION 4: .STATUS Integration +# ============================================================================ +echo "" +echo "--- .STATUS Integration ---" + +# Test 15: .STATUS gets updated after deploy (if yq available) +if command -v yq >/dev/null 2>&1; then + test_repo=$(setup_simple_repo) + cd "$test_repo" + echo "status: active" > .STATUS + _deploy_history_append "direct" "aaa11111" "bbb22222" "draft" "main" "1" "test" "null" "null" "1" >/dev/null 2>&1 + _deploy_update_status_file >/dev/null 2>&1 + ld=$(yq '.last_deploy // ""' .STATUS 2>/dev/null) + dc=$(yq '.deploy_count // 0' .STATUS 2>/dev/null) + today=$(date '+%Y-%m-%d') + if [[ "$ld" == "$today" && "$dc" == "1" ]]; then + _test_pass ".STATUS updated with last_deploy and deploy_count" + else + _test_fail ".STATUS updated with last_deploy and deploy_count" "ld=$ld dc=$dc" + fi +else + _test_skip ".STATUS update test (yq not available)" +fi + +# Test 16: .STATUS not created if absent +test_repo=$(setup_simple_repo) +cd "$test_repo" +rm -f .STATUS +_deploy_update_status_file >/dev/null 2>&1 +if [[ ! -f .STATUS ]]; then + _test_pass ".STATUS not created when absent" +else + _test_fail ".STATUS not created when absent" "file was created" +fi + +# ============================================================================ +# SECTION 5: Mixed mode history entries +# ============================================================================ +echo "" +echo "--- Mixed Mode History ---" + +# Test 17: direct + pr + rollback modes in same history +test_repo=$(setup_simple_repo) +cd "$test_repo" +_deploy_history_append "direct" "aaa11111" "bbb22222" "draft" "main" "2" "direct deploy" "null" "null" "5" >/dev/null 2>&1 +_deploy_history_append "pr" "ccc33333" "aaa11111" "draft" "main" "3" "pr deploy" "42" "null" "15" >/dev/null 2>&1 +_deploy_history_append "rollback" "ddd44444" "ccc33333" "draft" "main" "3" "revert: rollback" "null" "null" "8" >/dev/null 2>&1 +count=$(_deploy_history_count) +if [[ "$count" == "3" ]]; then + _test_pass "mixed mode history (direct + pr + rollback)" +else + _test_fail "mixed mode history" "count=$count" +fi + +# Test 18: verify each mode stored correctly +_deploy_history_get 1 +mode3="$DEPLOY_HIST_MODE" +_deploy_history_get 2 +mode2="$DEPLOY_HIST_MODE" +_deploy_history_get 3 +mode1="$DEPLOY_HIST_MODE" +if [[ "$mode1" == "direct" && "$mode2" == "pr" && "$mode3" == "rollback" ]]; then + _test_pass "each deploy mode stored correctly" +else + _test_fail "each deploy mode stored correctly" "1=$mode1 2=$mode2 3=$mode3" +fi + +# ============================================================================ +# SECTION 6: Flag Parser Edge Cases +# ============================================================================ +echo "" +echo "--- Flag Parser Edge Cases ---" + +# Test 19: --direct-push backward compat (alias for --direct) +test_repo=$(setup_simple_repo) +cd "$test_repo" +git checkout -q -b draft 2>/dev/null +echo "new file" > content.qmd +git add -A >/dev/null 2>&1 && git commit -q -m "add content" >/dev/null 2>&1 +# The enhanced function should accept --direct-push without error +output=$(_teach_deploy_enhanced --direct-push --dry-run 2>&1) +ret=$? +if [[ $ret -eq 0 ]]; then + _test_pass "--direct-push backward compat works" +else + _test_fail "--direct-push backward compat" "ret=$ret" +fi + +# Test 20: -d short flag works +output=$(_teach_deploy_enhanced -d --dry-run 2>&1) +ret=$? +if [[ $ret -eq 0 ]]; then + _test_pass "-d short flag works" +else + _test_fail "-d short flag works" "ret=$ret" +fi + +# Test 21: --direct + --dry-run shows direct mode +output=$(_teach_deploy_enhanced --direct --dry-run 2>&1) +if echo "$output" | grep -qi "direct"; then + _test_pass "--direct + --dry-run shows direct mode preview" +else + _test_fail "--direct + --dry-run shows direct mode preview" "not found" +fi + +# Test 22: -m flag passes custom message to dry-run +output=$(_teach_deploy_enhanced --dry-run -m "Custom Week 5" 2>&1) +if echo "$output" | grep -q "Custom Week 5"; then + _test_pass "-m flag passes custom message to preview" +else + _test_fail "-m flag passes custom message to preview" "not found" +fi + +# ============================================================================ +# SUMMARY +# ============================================================================ +echo "" +echo "═══════════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed, $SKIP skipped" +echo "═══════════════════════════════════════════" +[[ $FAIL -eq 0 ]] diff --git a/tests/test-teach-deploy-v2-unit.zsh b/tests/test-teach-deploy-v2-unit.zsh new file mode 100755 index 000000000..55392b935 --- /dev/null +++ b/tests/test-teach-deploy-v2-unit.zsh @@ -0,0 +1,580 @@ +#!/usr/bin/env zsh +# test-teach-deploy-v2-unit.zsh - Unit tests for teach deploy v2 features +# Tests each function in isolation within a sandboxed git repo + +# Test framework setup +PASS=0 +FAIL=0 +SKIP=0 + +_test_pass() { ((PASS++)); echo " ✅ $1"; } +_test_fail() { ((FAIL++)); echo " ❌ $1: $2"; } +_test_skip() { ((SKIP++)); echo " ⏭️ $1 (skipped)"; } + +# ============================================================================ +# SETUP +# ============================================================================ + +SCRIPT_DIR="${0:A:h}" +PROJECT_ROOT="${SCRIPT_DIR:h}" +TEST_DIR=$(mktemp -d) +ORIGINAL_DIR=$(pwd) + +cleanup() { + cd "$ORIGINAL_DIR" + rm -rf "$TEST_DIR" +} +trap cleanup EXIT + +# Minimal FLOW_COLORS for non-interactive tests +typeset -gA FLOW_COLORS +FLOW_COLORS[info]="" +FLOW_COLORS[success]="" +FLOW_COLORS[error]="" +FLOW_COLORS[warn]="" +FLOW_COLORS[dim]="" +FLOW_COLORS[bold]="" +FLOW_COLORS[reset]="" +FLOW_COLORS[prompt]="" +FLOW_COLORS[muted]="" + +# Source core + helpers (suppress output) +source "$PROJECT_ROOT/lib/core.zsh" 2>/dev/null || true +source "$PROJECT_ROOT/lib/git-helpers.zsh" 2>/dev/null || true +source "$PROJECT_ROOT/lib/deploy-history-helpers.zsh" 2>/dev/null || true +source "$PROJECT_ROOT/lib/deploy-rollback-helpers.zsh" 2>/dev/null || true +source "$PROJECT_ROOT/lib/dispatchers/teach-deploy-enhanced.zsh" 2>/dev/null || true + +# Stub functions that may not be available +if ! typeset -f _teach_error >/dev/null 2>&1; then + _teach_error() { echo "ERROR: $1" >&2; } +fi +if ! typeset -f _git_in_repo >/dev/null 2>&1; then + _git_in_repo() { git rev-parse --git-dir >/dev/null 2>&1; } +fi +if ! typeset -f _git_current_branch >/dev/null 2>&1; then + _git_current_branch() { git branch --show-current 2>/dev/null; } +fi +if ! typeset -f _git_is_clean >/dev/null 2>&1; then + _git_is_clean() { [[ -z "$(git status --porcelain 2>/dev/null)" ]]; } +fi + +# Helper: create a sandboxed git repo with .flow/teach-config.yml +# IMPORTANT: All git output is redirected to /dev/null to keep echo clean +# Uses mktemp for unique directories (avoids subshell counter issues) +setup_git_repo() { + local dir=$(mktemp -d "$TEST_DIR/repo-XXXXXX") + mkdir -p "$dir/.flow" + ( + cd "$dir" + git init -q 2>/dev/null + git config user.email "test@test.com" + git config user.name "Test" + cat > .flow/teach-config.yml <<'YAML' +course: + name: "STAT-101" +git: + draft_branch: draft + production_branch: main + auto_pr: true + require_clean: true +YAML + git add -A >/dev/null 2>&1 + git commit -q -m "init" >/dev/null 2>&1 + ) + echo "$dir" +} + +echo "" +echo "╔════════════════════════════════════════════════════════════╗" +echo "║ teach deploy v2 - Unit Tests ║" +echo "╚════════════════════════════════════════════════════════════╝" +echo "" + +# ============================================================================ +# SECTION 1: Deploy History Helpers +# ============================================================================ +echo "--- Deploy History ---" + +# Test 1: history_append creates file if not exists +test_repo=$(setup_git_repo) +cd "$test_repo" +rm -f .flow/deploy-history.yml +_deploy_history_append "direct" "abc12345" "def67890" "draft" "main" "5" "test deploy" "null" "null" "10" >/dev/null 2>&1 +if [[ -f .flow/deploy-history.yml ]]; then + _test_pass "history_append creates file if not exists" +else + _test_fail "history_append creates file if not exists" "file not created" +fi + +# Test 2: history_append adds entry to existing file +_deploy_history_append "pr" "xyz12345" "abc12345" "draft" "main" "3" "second deploy" "null" "null" "5" >/dev/null 2>&1 +count=$(_deploy_history_count) +if [[ "$count" == "2" ]]; then + _test_pass "history_append adds entry to existing file" +else + _test_fail "history_append adds entry to existing file" "expected 2, got $count" +fi + +# Test 3: history_count returns correct number +test_repo=$(setup_git_repo) +cd "$test_repo" +count=$(_deploy_history_count) +if [[ "$count" == "0" ]]; then + _test_pass "history_count returns 0 for no history" +else + _test_fail "history_count returns 0 for no history" "got $count" +fi + +# Test 4: history_count after appends +_deploy_history_append "direct" "aaa11111" "bbb22222" "draft" "main" "1" "first" "null" "null" "1" >/dev/null 2>&1 +_deploy_history_append "direct" "ccc33333" "aaa11111" "draft" "main" "2" "second" "null" "null" "2" >/dev/null 2>&1 +_deploy_history_append "pr" "ddd44444" "ccc33333" "draft" "main" "3" "third" "null" "null" "3" >/dev/null 2>&1 +count=$(_deploy_history_count) +if [[ "$count" == "3" ]]; then + _test_pass "history_count returns correct count after 3 appends" +else + _test_fail "history_count returns correct count after 3 appends" "expected 3, got $count" +fi + +# Test 5: history_get retrieves correct entry (1 = most recent) +_deploy_history_get 1 +if [[ "$DEPLOY_HIST_MESSAGE" == "third" ]]; then + _test_pass "history_get retrieves most recent entry (idx=1)" +else + _test_fail "history_get retrieves most recent entry (idx=1)" "got: $DEPLOY_HIST_MESSAGE" +fi + +# Test 6: history_get retrieves correct entry (3 = oldest) +_deploy_history_get 3 +if [[ "$DEPLOY_HIST_MESSAGE" == "first" ]]; then + _test_pass "history_get retrieves oldest entry (idx=3)" +else + _test_fail "history_get retrieves oldest entry (idx=3)" "got: $DEPLOY_HIST_MESSAGE" +fi + +# Test 7: history_get fails for out-of-range index +if _deploy_history_get 99 2>/dev/null; then + _test_fail "history_get rejects out-of-range index" "should have returned non-zero" +else + _test_pass "history_get rejects out-of-range index" +fi + +# Test 8: history_list shows formatted table +test_repo=$(setup_git_repo) +cd "$test_repo" +_deploy_history_append "direct" "aaa11111" "bbb22222" "draft" "main" "2" "test message" "null" "null" "5" >/dev/null 2>&1 +output=$(_deploy_history_list 5 2>&1) +if echo "$output" | grep -q "test message"; then + _test_pass "history_list shows deploy messages" +else + _test_fail "history_list shows deploy messages" "message not found in output" +fi + +# Test 9: history_list returns error when no history file +test_repo=$(setup_git_repo) +cd "$test_repo" +rm -f .flow/deploy-history.yml +output=$(_deploy_history_list 5 2>&1) +ret=$? +if [[ $ret -ne 0 ]]; then + _test_pass "history_list returns error when no history" +else + _test_fail "history_list returns error when no history" "expected non-zero exit" +fi + +# Test 10: Single quotes in messages are escaped +test_repo=$(setup_git_repo) +cd "$test_repo" +_deploy_history_append "direct" "aaa11111" "bbb22222" "draft" "main" "1" "week 5 update" "null" "null" "1" >/dev/null 2>&1 +# Verify yq can read the file after append +count=$(_deploy_history_count) +if [[ "$count" == "1" ]]; then + _test_pass "history file is valid YAML after append" +else + _test_fail "history file is valid YAML after append" "yq failed to parse, count=$count" +fi + +# Test 11: history_append stores correct mode +_deploy_history_get 1 +if [[ "$DEPLOY_HIST_MODE" == "direct" ]]; then + _test_pass "history_append stores correct mode field" +else + _test_fail "history_append stores correct mode field" "got: $DEPLOY_HIST_MODE" +fi + +# Test 12: history_append stores commit hash (truncated to 8) +_deploy_history_get 1 +if [[ "$DEPLOY_HIST_COMMIT" == "aaa11111" ]]; then + _test_pass "history_append stores 8-char commit hash" +else + _test_fail "history_append stores 8-char commit hash" "got: $DEPLOY_HIST_COMMIT" +fi + +# ============================================================================ +# SECTION 2: Smart Commit Messages +# ============================================================================ +echo "" +echo "--- Smart Commit Messages ---" + +# Test 13: categorizes lecture files correctly +if typeset -f _generate_smart_commit_message >/dev/null 2>&1; then + test_repo=$(setup_git_repo) + cd "$test_repo" + git checkout -q -b draft 2>/dev/null + mkdir -p lectures + echo "---\ntitle: Week 5\n---\nHello" > lectures/week-05.qmd + git add -A >/dev/null 2>&1 && git commit -q -m "add lecture" >/dev/null 2>&1 + msg=$(_generate_smart_commit_message "draft" "main") + if echo "$msg" | grep -qi "lecture\|content\|week-05"; then + _test_pass "smart commit categorizes lecture files" + else + _test_fail "smart commit categorizes lecture files" "got: $msg" + fi +else + _test_skip "smart commit categorizes lecture files (_generate_smart_commit_message not loaded)" +fi + +# Test 14: categorizes assignment files correctly +if typeset -f _generate_smart_commit_message >/dev/null 2>&1; then + test_repo=$(setup_git_repo) + cd "$test_repo" + git checkout -q -b draft 2>/dev/null + mkdir -p assignments + echo "---\ntitle: HW3\n---" > assignments/hw-03.qmd + git add -A >/dev/null 2>&1 && git commit -q -m "add assignment" >/dev/null 2>&1 + msg=$(_generate_smart_commit_message "draft" "main") + if echo "$msg" | grep -qi "assignment\|content\|hw"; then + _test_pass "smart commit categorizes assignment files" + else + _test_fail "smart commit categorizes assignment files" "got: $msg" + fi +else + _test_skip "smart commit categorizes assignment files" +fi + +# Test 15: categorizes config files correctly +if typeset -f _generate_smart_commit_message >/dev/null 2>&1; then + test_repo=$(setup_git_repo) + cd "$test_repo" + git checkout -q -b draft 2>/dev/null + echo "title: Test" > _quarto.yml + git add -A >/dev/null 2>&1 && git commit -q -m "add config" >/dev/null 2>&1 + msg=$(_generate_smart_commit_message "draft" "main") + if echo "$msg" | grep -qi "config\|quarto\|_quarto"; then + _test_pass "smart commit categorizes config files" + else + _test_fail "smart commit categorizes config files" "got: $msg" + fi +else + _test_skip "smart commit categorizes config files" +fi + +# Test 16: empty diff produces fallback message +if typeset -f _generate_smart_commit_message >/dev/null 2>&1; then + test_repo=$(setup_git_repo) + cd "$test_repo" + msg=$(_generate_smart_commit_message "main" "main") + if [[ -n "$msg" ]]; then + _test_pass "empty diff produces fallback message" + else + _test_fail "empty diff produces fallback message" "got empty string" + fi +else + _test_skip "empty diff produces fallback message" +fi + +# Test 17: message truncates at 72 chars +if typeset -f _generate_smart_commit_message >/dev/null 2>&1; then + test_repo=$(setup_git_repo) + cd "$test_repo" + git checkout -q -b draft 2>/dev/null + mkdir -p lectures labs assignments exams projects + for i in {01..20}; do + echo "content $i" > "lectures/week-${i}.qmd" + done + git add -A >/dev/null 2>&1 && git commit -q -m "add many files" >/dev/null 2>&1 + msg=$(_generate_smart_commit_message "draft" "main") + if [[ ${#msg} -le 72 ]]; then + _test_pass "message truncates at 72 chars (len=${#msg})" + else + _test_fail "message truncates at 72 chars" "length=${#msg}" + fi +else + _test_skip "message truncates at 72 chars" +fi + +# ============================================================================ +# SECTION 3: Preflight Checks +# ============================================================================ +echo "" +echo "--- Preflight Checks ---" + +# Test 18: preflight returns error outside git repo +test_nongit=$(mktemp -d "$TEST_DIR/nongit-XXXXXX") + +cd "$test_nongit" +output=$(_deploy_preflight_checks "true" 2>&1) +ret=$? +if [[ $ret -ne 0 ]]; then + _test_pass "preflight returns error outside git repo" +else + _test_fail "preflight returns error outside git repo" "expected non-zero exit" +fi + +# Test 19: preflight returns error without config file +test_repo_noconfig=$(mktemp -d "$TEST_DIR/noconfig-XXXXXX") + +cd "$test_repo_noconfig" +git init -q 2>/dev/null +git config user.email "test@test.com" +git config user.name "Test" +echo "test" > file.txt +git add -A >/dev/null 2>&1 && git commit -q -m "init" >/dev/null 2>&1 +output=$(_deploy_preflight_checks "true" 2>&1) +ret=$? +if [[ $ret -ne 0 ]]; then + _test_pass "preflight returns error without config file" +else + _test_fail "preflight returns error without config file" "expected non-zero exit" +fi + +# Test 20: preflight sets DEPLOY_* variables correctly +test_repo=$(setup_git_repo) +cd "$test_repo" +_deploy_preflight_checks "true" >/dev/null 2>&1 +if [[ "$DEPLOY_DRAFT_BRANCH" == "draft" && "$DEPLOY_PROD_BRANCH" == "main" ]]; then + _test_pass "preflight sets DEPLOY_* variables correctly" +else + _test_fail "preflight sets DEPLOY_* variables correctly" "draft=$DEPLOY_DRAFT_BRANCH prod=$DEPLOY_PROD_BRANCH" +fi + +# Test 21: preflight sets course name +if [[ "$DEPLOY_COURSE_NAME" == "STAT-101" ]]; then + _test_pass "preflight sets DEPLOY_COURSE_NAME" +else + _test_fail "preflight sets DEPLOY_COURSE_NAME" "got: $DEPLOY_COURSE_NAME" +fi + +# ============================================================================ +# SECTION 4: Dry-run Report +# ============================================================================ +echo "" +echo "--- Dry-run Report ---" + +# Test 22: dry-run shows preview without mutations +test_repo=$(setup_git_repo) +cd "$test_repo" +git checkout -q -b draft 2>/dev/null +echo "new content" > lectures.qmd +git add -A >/dev/null 2>&1 && git commit -q -m "add content" >/dev/null 2>&1 +output=$(_deploy_dry_run_report "draft" "main" "STAT-101" "false" "" 2>&1) +if echo "$output" | grep -qi "DRY RUN"; then + _test_pass "dry-run shows DRY RUN header" +else + _test_fail "dry-run shows DRY RUN header" "not found in output" +fi + +# Test 23: dry-run with direct mode +output=$(_deploy_dry_run_report "draft" "main" "STAT-101" "true" "" 2>&1) +if echo "$output" | grep -qi "direct"; then + _test_pass "dry-run shows direct mode" +else + _test_fail "dry-run shows direct mode" "not found in output" +fi + +# Test 24: dry-run with custom message +output=$(_deploy_dry_run_report "draft" "main" "STAT-101" "false" "custom message" 2>&1) +if echo "$output" | grep -q "custom message"; then + _test_pass "dry-run shows custom message" +else + _test_fail "dry-run shows custom message" "not found in output" +fi + +# Test 25: dry-run shows history log hint +output=$(_deploy_dry_run_report "draft" "main" "STAT-101" "false" "" 2>&1) +if echo "$output" | grep -q "deploy-history"; then + _test_pass "dry-run shows history log hint" +else + _test_fail "dry-run shows history log hint" "not found in output" +fi + +# ============================================================================ +# SECTION 5: .STATUS Update Function +# ============================================================================ +echo "" +echo "--- .STATUS Update ---" + +# Test 26: skips if no .STATUS file +test_repo=$(setup_git_repo) +cd "$test_repo" +rm -f .STATUS +output=$(_deploy_update_status_file 2>&1) +ret=$? +if [[ $ret -eq 0 ]]; then + _test_pass "status update skips if no .STATUS file" +else + _test_fail "status update skips if no .STATUS file" "returned $ret" +fi + +# Test 27: updates last_deploy date (if yq available) +if command -v yq >/dev/null 2>&1; then + test_repo=$(setup_git_repo) + cd "$test_repo" + echo "status: active" > .STATUS + _deploy_update_status_file >/dev/null 2>&1 + last_deploy=$(yq '.last_deploy // ""' .STATUS 2>/dev/null) + today=$(date '+%Y-%m-%d') + if [[ "$last_deploy" == "$today" ]]; then + _test_pass "status update sets last_deploy to today" + else + _test_fail "status update sets last_deploy to today" "got: $last_deploy" + fi +else + _test_skip "status update sets last_deploy (yq not available)" +fi + +# Test 28: updates deploy_count +if command -v yq >/dev/null 2>&1; then + test_repo=$(setup_git_repo) + cd "$test_repo" + echo "status: active" > .STATUS + _deploy_history_append "direct" "aaa11111" "bbb22222" "draft" "main" "1" "test" "null" "null" "1" >/dev/null 2>&1 + _deploy_update_status_file >/dev/null 2>&1 + dc=$(yq '.deploy_count // 0' .STATUS 2>/dev/null) + if [[ "$dc" == "1" ]]; then + _test_pass "status update sets deploy_count" + else + _test_fail "status update sets deploy_count" "got: $dc" + fi +else + _test_skip "status update sets deploy_count (yq not available)" +fi + +# ============================================================================ +# SECTION 6: Rollback (unit-level) +# ============================================================================ +echo "" +echo "--- Rollback ---" + +# Test 29: CI mode requires index +test_repo=$(setup_git_repo) +cd "$test_repo" +_deploy_history_append "direct" "aaa11111" "bbb22222" "draft" "main" "1" "test" "null" "null" "1" >/dev/null 2>&1 +output=$(_deploy_rollback --ci 2>&1) +ret=$? +if [[ $ret -ne 0 ]]; then + _test_pass "rollback CI mode requires explicit index" +else + _test_fail "rollback CI mode requires explicit index" "expected failure" +fi + +# Test 30: rollback validates index against history count +output=$(_deploy_rollback 99 --ci 2>&1) +ret=$? +if [[ $ret -ne 0 ]]; then + _test_pass "rollback rejects out-of-range index" +else + _test_fail "rollback rejects out-of-range index" "expected failure" +fi + +# Test 31: rollback returns error on empty history +test_repo=$(setup_git_repo) +cd "$test_repo" +rm -f .flow/deploy-history.yml +output=$(_deploy_rollback 1 --ci 2>&1) +ret=$? +if [[ $ret -ne 0 ]]; then + _test_pass "rollback returns error on empty history" +else + _test_fail "rollback returns error on empty history" "expected failure" +fi + +# ============================================================================ +# SECTION 7: History Append Edge Cases +# ============================================================================ +echo "" +echo "--- History Edge Cases ---" + +# Test 32: history_append with all null optional fields +test_repo=$(setup_git_repo) +cd "$test_repo" +_deploy_history_append "direct" "abc12345" "def67890" "draft" "main" "0" "empty deploy" "null" "null" "0" >/dev/null 2>&1 +count=$(_deploy_history_count) +if [[ "$count" == "1" ]]; then + _test_pass "history_append handles null optional fields" +else + _test_fail "history_append handles null optional fields" "count=$count" +fi + +# Test 33: history_append with pr_number +_deploy_history_append "pr" "ghi12345" "abc12345" "draft" "main" "5" "pr deploy" "42" "null" "20" >/dev/null 2>&1 +_deploy_history_get 1 +if [[ "$DEPLOY_HIST_PR" == "42" ]]; then + _test_pass "history_append stores pr_number correctly" +else + _test_fail "history_append stores pr_number correctly" "got: $DEPLOY_HIST_PR" +fi + +# Test 34: history file is valid YAML after multiple appends +test_repo=$(setup_git_repo) +cd "$test_repo" +for i in {1..5}; do + _deploy_history_append "direct" "hash${i}000" "prev${i}000" "draft" "main" "$i" "deploy $i" "null" "null" "$i" >/dev/null 2>&1 +done +count=$(_deploy_history_count) +if [[ "$count" == "5" ]]; then + _test_pass "history file valid YAML after 5 appends" +else + _test_fail "history file valid YAML after 5 appends" "count=$count" +fi + +# ============================================================================ +# SECTION 8: Merge Commit Detection (regression for -m 1 rollback fix) +# ============================================================================ +echo "" +echo "--- Merge Commit Detection ---" + +# Test 35: merge commit has 2 parents (validates detection logic) +test_repo=$(setup_git_repo) +cd "$test_repo" +# Create diverging branches +( + cd "$test_repo" + git checkout -q -b feature 2>/dev/null + echo "feature work" > feature.txt + git add -A && git commit -q -m "feat: feature work" 2>/dev/null + git checkout -q main 2>/dev/null + echo "main work" > main.txt + git add -A && git commit -q -m "fix: main work" 2>/dev/null + git merge feature --no-ff -m "merge: feature into main" 2>/dev/null +) >/dev/null 2>&1 +merge_hash=$(git rev-parse HEAD) +parent_count=$(git cat-file -p "$merge_hash" 2>/dev/null | grep -c "^parent ") +if [[ $parent_count -eq 2 ]]; then + _test_pass "merge commit detected with 2 parents" +else + _test_fail "merge commit detected with 2 parents" "got $parent_count" +fi + +# Test 36: regular commit has 1 parent +test_repo=$(setup_git_repo) +cd "$test_repo" +echo "extra" > extra.txt +git add -A && git commit -q -m "add extra" >/dev/null 2>&1 +regular_hash=$(git rev-parse HEAD) +parent_count=$(git cat-file -p "$regular_hash" 2>/dev/null | grep -c "^parent ") +if [[ $parent_count -eq 1 ]]; then + _test_pass "regular commit detected with 1 parent" +else + _test_fail "regular commit detected with 1 parent" "got $parent_count" +fi + +# ============================================================================ +# SUMMARY +# ============================================================================ +echo "" +echo "═══════════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed, $SKIP skipped" +echo "═══════════════════════════════════════════" +[[ $FAIL -eq 0 ]]