diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c3c424..a57aa08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,7 @@ jobs: fmt-clippy: name: Rust — fmt / clippy runs-on: ubuntu-latest + if: ${{ !startsWith(github.head_ref, 'fixture/') }} timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -34,6 +35,7 @@ jobs: test: name: Rust — test (${{ matrix.os }}) runs-on: ${{ matrix.os }} + if: ${{ !startsWith(github.head_ref, 'fixture/') }} timeout-minutes: 15 strategy: fail-fast: false diff --git a/.github/workflows/commit-lint.yml b/.github/workflows/commit-lint.yml index 6509869..6441ef0 100644 --- a/.github/workflows/commit-lint.yml +++ b/.github/workflows/commit-lint.yml @@ -11,7 +11,7 @@ permissions: jobs: lint: runs-on: ubuntu-latest - if: ${{ !startsWith(github.head_ref, 'release-please--branches--') }} + if: ${{ !startsWith(github.head_ref, 'release-please--branches--') && !startsWith(github.head_ref, 'fixture/') }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/live-gh.yml b/.github/workflows/live-gh.yml new file mode 100644 index 0000000..593a5a3 --- /dev/null +++ b/.github/workflows/live-gh.yml @@ -0,0 +1,117 @@ +name: Live gh + +# Detects drift in gh and the gh-stack extension. Runs the live suite (see +# docs/architecture/07-live-validation.md) against the fixture PRs with a +# known-good pinned toolchain and with the latest releases. A failure in +# "latest" while "pinned" passes means an upstream change broke stack-stat. +# +# Not run on pull requests: it mutates shared remote state and needs a token with +# write access. +on: + schedule: + - cron: "17 6 * * *" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + issues: write + +# Tests reset shared fixture branches, so only one run may execute at a time and +# a cancelled run must never be replaced mid-reset. +concurrency: + group: live-gh + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + # Needs push access to the fixture repository; the default workflow token is + # the fallback. + GH_TOKEN: ${{ secrets.GH_ACCESS_TOKEN || github.token }} + STACK_STAT_LIVE: "1" + +jobs: + live: + name: Live gh (${{ matrix.name }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + # Both jobs reset the same fixture branches. + max-parallel: 1 + matrix: + include: + # Versions the suite was validated against; update deliberately. + - name: pinned + gh: "2.101.0" + extension: "v0.1.1" + # Whatever upstream shipped most recently. + - name: latest + gh: latest + extension: latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - name: Install gh ${{ matrix.gh }} + run: | + version="${{ matrix.gh }}" + if [ "$version" = latest ]; then + version="$(gh api repos/cli/cli/releases/latest --jq .tag_name)" + version="${version#v}" + fi + curl -fsSL "https://github.com/cli/cli/releases/download/v${version}/gh_${version}_linux_amd64.tar.gz" \ + | tar -xz -C "$RUNNER_TEMP" + sudo install "$RUNNER_TEMP/gh_${version}_linux_amd64/bin/gh" /usr/local/bin/gh + hash -r + - name: Install gh-stack ${{ matrix.extension }} + run: | + if [ "${{ matrix.extension }}" = latest ]; then + gh extension install github/gh-stack + else + gh extension install github/gh-stack --pin "${{ matrix.extension }}" + fi + - name: Configure git for gh + run: | + gh auth setup-git + git config --global user.name "stack-stat live tests" + git config --global user.email "live-tests@users.noreply.github.com" + - name: Record tool versions + run: | + { + echo "### Tool versions (${{ matrix.name }})" + echo '```' + gh --version + gh extension list + echo '```' + } | tee -a "$GITHUB_STEP_SUMMARY" + - name: Check fixture + id: fixture + run: bash scripts/live-fixture.sh check + - name: Run live tests + run: cargo test -p stack-stat-core --test live_gh --locked -- --test-threads=4 + - name: Restore fixture + if: ${{ always() }} + run: bash scripts/live-fixture.sh reset + - name: Report failure + # Scheduled runs only: manual and temporary branch runs report in the UI. + if: ${{ failure() && github.event_name == 'schedule' }} + env: + FIXTURE_FAILED: ${{ steps.fixture.outcome == 'failure' }} + run: | + if [ "$FIXTURE_FAILED" = true ]; then + title="Live gh fixture broken" + hint="The fixture check failed, so this is not gh drift. Run scripts/live-fixture.sh check and see docs/architecture/07-live-validation.md." + else + title="Live gh drift detected" + hint="If only the latest job failed, gh or gh-stack changed in a way that breaks stack-stat. See docs/architecture/07-live-validation.md." + fi + body="Live gh (${{ matrix.name }}: gh ${{ matrix.gh }}, gh-stack ${{ matrix.extension }}) failed: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + existing="$(gh issue list --state open --search "\"$title\" in:title" --json number --jq '.[0].number // empty')" + if [ -n "$existing" ]; then + gh issue comment "$existing" --body "$body" + else + gh issue create --title "$title" --label bug --body "$body + + $hint" + fi diff --git a/AGENTS.md b/AGENTS.md index 4316e78..d828fee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,5 +88,6 @@ Do not change production invariants or expose broad internals solely for tests. | App state machine | `crates/stack-stat-tui/tests/` | Fake providers, state transitions, action effects | | Rendering | `crates/stack-stat-tui/tests/` | `ratatui::TestBackend` frame contracts | | CLI | `crates/stack-stat-tui/tests/` | Report and update behavior against isolated temp repos | +| Live gh | `crates/stack-stat-core/tests/live_gh.rs` | Real `gh`/`gh stack` against fixture PRs; opt-in with `make live-test` ([guide](docs/architecture/07-live-validation.md)) | Do not add PTY-based integration tests or visual snapshots: direct App behavior and `TestBackend` buffer checks are more precise. diff --git a/Makefile b/Makefile index 85ffd3c..58bc9b7 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Developer convenience targets for stack-stat. .DEFAULT_GOAL := help -.PHONY: help dev-setup setup-hooks format lint tests preflight rustdoc docs-serve demo demo-gif clean +.PHONY: help dev-setup setup-hooks format lint tests live-test live-fixture-check live-fixture-reset preflight rustdoc docs-serve demo demo-gif clean help: ## List available targets @grep -hE '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "} {printf " \033[36m%-24s\033[0m %s\n", $$1, $$2}' @@ -30,6 +30,15 @@ tests: ## Run the full suite preflight: lint tests ## Run the CI-equivalent checks +live-test: ## Run end-to-end tests against real gh and gh stack (needs auth and push access to the fixture repo) + STACK_STAT_LIVE=1 cargo test -p stack-stat-core --test live_gh --locked -- --test-threads=4 + +live-fixture-check: ## Verify the live fixture's tags, branches, PR states and labels + bash scripts/live-fixture.sh check + +live-fixture-reset: ## Restore the live fixture branches to their start tags + bash scripts/live-fixture.sh reset + rustdoc: ## Build rustdoc and open it cargo doc --workspace --no-deps --locked @echo "Rustdoc built in target/doc; open stack_stat_core/index.html with your platform's browser." diff --git a/crates/stack-stat-core/src/providers/github_cli.rs b/crates/stack-stat-core/src/providers/github_cli.rs index 3b07d28..e9b682e 100644 --- a/crates/stack-stat-core/src/providers/github_cli.rs +++ b/crates/stack-stat-core/src/providers/github_cli.rs @@ -68,7 +68,13 @@ impl CliGitHubProvider { args: &[&str], deadline: Duration, ) -> Result<(String, String), GitHubError> { - let output = self.run_process(args, deadline).await?; + let mut output = self.run_process(args, deadline).await?; + if args.first() == Some(&"stack") && output.status.success() && installed_extension(&output) + { + // On first use gh installs the extension and exits 0 without running the + // subcommand, so the requested action has not happened yet. + output = self.run_process(args, deadline).await?; + } if !output.status.success() { return Err(classify_gh_failure( args, @@ -107,6 +113,13 @@ impl CliGitHubProvider { } } +/// Whether `gh` auto-installed the stack extension instead of running the command. +fn installed_extension(output: &std::process::Output) -> bool { + const MARKER: &str = "Successfully installed github/gh-stack"; + String::from_utf8_lossy(&output.stdout).contains(MARKER) + || String::from_utf8_lossy(&output.stderr).contains(MARKER) +} + #[async_trait] impl GitHubProvider for CliGitHubProvider { async fn stack_view(&self, current_branch: &str) -> Result { diff --git a/crates/stack-stat-core/src/providers/graphql.rs b/crates/stack-stat-core/src/providers/graphql.rs index cc869e0..28d1340 100644 --- a/crates/stack-stat-core/src/providers/graphql.rs +++ b/crates/stack-stat-core/src/providers/graphql.rs @@ -86,7 +86,10 @@ pub fn classify_gh_failure(args: &[&str], code: i32, stderr: &str) -> GitHubErro if lower.contains("unknown command \"stack\" for \"gh\"") { return GitHubError::MissingStackExtension; } - if code == 2 && matches!(args.get(1), Some(&"push" | &"sync")) { + if code == 2 + && matches!(args.get(1), Some(&"push" | &"sync")) + && lower.contains("is not part of a stack") + { return GitHubError::LocalTrackingMissing; } if matches!(code, 3 | 7) { @@ -103,7 +106,8 @@ pub fn classify_gh_failure(args: &[&str], code: i32, stderr: &str) -> GitHubErro .map(str::to_owned); return GitHubError::RateLimited { reset_hint: hint }; } - if lower.contains("auth") + if code == 4 + || lower.contains("auth") || lower.contains("gh auth login") || lower.contains("bad credentials") { diff --git a/crates/stack-stat-core/tests/github_cli.rs b/crates/stack-stat-core/tests/github_cli.rs index 18a7940..6f6856e 100644 --- a/crates/stack-stat-core/tests/github_cli.rs +++ b/crates/stack-stat-core/tests/github_cli.rs @@ -142,9 +142,22 @@ fn parser_rejects_responses_without_data_and_preserves_bot_and_context() { fn classifier_distinguishes_local_tracking_from_remote_discovery() { for action in ["push", "sync"] { assert_eq!( - classify_gh_failure(&["stack", action], 2, "not in a stack"), + classify_gh_failure( + &["stack", action], + 2, + "✗ current branch \"feature\" is not part of a stack" + ), GitHubError::LocalTrackingMissing ); + // Observed on gh-stack v0.1.1: exit 2 is also used outside a repository. + assert!(matches!( + classify_gh_failure( + &["stack", action], + 2, + "✗ not a git repository: failed to run git: fatal: not a git repository" + ), + GitHubError::Command { code: 2, .. } + )); } assert!(matches!( classify_gh_failure(&["api", "graphql"], 2, "query failed"), @@ -166,6 +179,15 @@ fn classifier_distinguishes_local_tracking_from_remote_discovery() { classify_gh_failure(&["pr", "list"], 1, "gh auth login first"), GitHubError::Auth ); + // Observed on gh 2.101.0 with no token: exit 4 and no "auth login" hint. + assert_eq!( + classify_gh_failure( + &["api", "user"], + 4, + "gh: To use GitHub CLI in automation, set the GH_TOKEN environment variable." + ), + GitHubError::Auth + ); assert!(matches!( classify_gh_failure(&["api"], 1, "connection refused"), GitHubError::Network(_) diff --git a/crates/stack-stat-core/tests/live_gh.rs b/crates/stack-stat-core/tests/live_gh.rs new file mode 100644 index 0000000..359df79 --- /dev/null +++ b/crates/stack-stat-core/tests/live_gh.rs @@ -0,0 +1,419 @@ +//! End-to-end checks against the real `gh` and `gh stack` binaries and the live +//! fixture described in `docs/architecture/07-live-validation.md`. +//! +//! Skipped unless `STACK_STAT_LIVE=1`. Run with `make live-test`. + +use stack_stat_core::{ + model::StackMode, + providers::{ + CliGitHubProvider, GitHubError, GitHubProvider, StackBranch, StackViewResponse, + graphql::classify_gh_failure, + }, +}; +use std::{path::Path, process::Command, sync::LazyLock}; +use tempfile::TempDir; +use tokio::sync::{RwLock, RwLockWriteGuard}; + +/// Repository holding the fixture stacks. +fn fixture_repo() -> String { + std::env::var("STACK_STAT_FIXTURE_REPO").unwrap_or_else(|_| "cam1se/stack-stat".into()) +} + +/// Fork of the fixture repository that holds the fork PR. +fn fork_repo() -> String { + std::env::var("STACK_STAT_FIXTURE_FORK").unwrap_or_else(|_| "tcamise-gpsw/stack-stat".into()) +} + +/// Every test depends on the remote fixture. Read-only tests hold this lock +/// shared; mutating tests hold it exclusively, so nothing reads a half-reset +/// fixture. +static FIXTURE: RwLock<()> = RwLock::const_new(()); + +/// Result of `live-fixture.sh check`, run once per test process. +static FIXTURE_OK: LazyLock = LazyLock::new(|| run_script("check")); + +fn require_fixture() { + assert!( + *FIXTURE_OK, + "fixture check failed; see FIXTURE: lines above and docs/architecture/07-live-validation.md" + ); +} + +/// Take the fixture lock shared for the rest of the test. +macro_rules! shared { + () => { + let _shared = FIXTURE.read().await; + require_fixture(); + }; +} + +macro_rules! live { + () => { + if std::env::var("STACK_STAT_LIVE").as_deref() != Ok("1") { + eprintln!("skipped: set STACK_STAT_LIVE=1 to run live gh tests"); + return; + } + }; +} + +fn run_script(arg: &str) -> bool { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + Command::new("bash") + .arg(root.join("scripts/live-fixture.sh")) + .arg(arg) + .status() + .is_ok_and(|status| status.success()) +} + +fn script(arg: &str) { + assert!(run_script(arg), "live-fixture.sh {arg} failed"); +} + +/// Exclusive access to the fixture: resets it before the test and again when +/// dropped, including when the test panics. +struct Exclusive { + _guard: RwLockWriteGuard<'static, ()>, +} + +impl Exclusive { + async fn begin() -> Self { + let guard = FIXTURE.write().await; + require_fixture(); + script("reset"); + Self { _guard: guard } + } +} + +impl Drop for Exclusive { + fn drop(&mut self) { + // Never panic while possibly unwinding; the next run resets again. + if !run_script("reset") { + eprintln!("warning: fixture reset after test failed"); + } + } +} + +/// Clone `slug` with `gh` and check out `branch`. +fn clone(slug: &str, branch: &str) -> TempDir { + let dir = tempfile::tempdir().unwrap(); + let output = Command::new("gh") + .args(["repo", "clone", slug]) + .arg(dir.path()) + .args(["--", "--branch", branch]) + .output() + .unwrap(); + assert!( + output.status.success(), + "clone {slug}@{branch}: {}", + String::from_utf8_lossy(&output.stderr) + ); + dir +} + +fn provider(dir: &Path) -> CliGitHubProvider { + let (owner, repo) = fixture_repo() + .split_once('/') + .map(|(o, r)| (o.to_owned(), r.to_owned())) + .unwrap(); + CliGitHubProvider::new(dir.to_path_buf(), owner, repo) +} + +/// Run `gh` in an empty, isolated config and data directory with a dummy token. +fn isolated_gh(args: &[&str], token: &str) -> (i32, String, String) { + let home = tempfile::tempdir().unwrap(); + let output = Command::new("gh") + .args(args) + .current_dir(home.path()) + .env("GH_TOKEN", token) + .env("GH_CONFIG_DIR", home.path().join("config")) + .env("XDG_DATA_HOME", home.path().join("data")) + .env("XDG_CONFIG_HOME", home.path().join("xdg")) + .env("GH_PROMPT_DISABLED", "1") + .env("GH_NO_UPDATE_NOTIFIER", "1") + .env("NO_COLOR", "1") + .output() + .unwrap(); + ( + output.status.code().unwrap_or(-1), + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + ) +} + +#[tokio::test] +async fn resolves_owner_and_name_with_gh_repo_view() { + live!(); + shared!(); + let dir = clone(&fixture_repo(), "fixture/s1-b"); + let resolved = CliGitHubProvider::resolve_repo(dir.path()).await.unwrap(); + let expected = fixture_repo(); + assert_eq!(format!("{}/{}", resolved.0, resolved.1), expected); +} + +#[tokio::test] +async fn discovers_a_three_pr_stack_and_loads_details() { + live!(); + shared!(); + let dir = clone(&fixture_repo(), "fixture/s1-b"); + let github = provider(dir.path()); + let stack = github.stack_view("fixture/s1-b").await.unwrap(); + assert_eq!(stack.mode, StackMode::Stacked); + assert_eq!(stack.trunk, "main"); + let names: Vec<_> = stack.branches.iter().map(|b| b.name.as_str()).collect(); + assert_eq!(names, ["fixture/s1-a", "fixture/s1-b", "fixture/s1-c"]); + let numbers: Vec<_> = stack + .branches + .iter() + .map(|b| b.pr.as_ref().unwrap().number) + .collect(); + assert_eq!(numbers, [27, 28, 29]); + assert!(stack.branches.iter().all(|b| !b.is_merged)); + let details = github.pr_details(&stack).await.unwrap(); + assert_eq!(details.prs.len(), 3); + assert!(details.prs.iter().all(|pr| pr.is_draft)); + assert!(details.remote_rebase_branches.is_empty()); +} + +/// Assert a one-row single-mode view and return its row. +fn single_row(stack: &StackViewResponse) -> &StackBranch { + assert_eq!(stack.mode, StackMode::Single); + assert_eq!(stack.branches.len(), 1); + &stack.branches[0] +} + +#[tokio::test] +async fn branch_without_a_pull_request_is_a_single_row_on_the_default_branch() { + live!(); + shared!(); + let dir = clone(&fixture_repo(), "fixture/s6-nopr"); + let github = provider(dir.path()); + let stack = github.stack_view("fixture/s6-nopr").await.unwrap(); + assert_eq!(stack.trunk, "main"); + let row = single_row(&stack); + assert_eq!(row.name, "fixture/s6-nopr"); + assert!(row.pr.is_none()); + assert!(github.pr_details(&stack).await.unwrap().prs.is_empty()); + let unknown = github.stack_view("fixture/no-such-branch").await.unwrap(); + assert!(single_row(&unknown).pr.is_none()); +} + +#[tokio::test] +async fn unstacked_pull_request_is_a_single_row_based_on_its_base_ref() { + live!(); + shared!(); + let dir = clone(&fixture_repo(), "fixture/s4-same"); + let stack = provider(dir.path()) + .stack_view("fixture/s4-same") + .await + .unwrap(); + assert_eq!(stack.trunk, "main"); + let pr = single_row(&stack).pr.as_ref().unwrap(); + assert_eq!((pr.number, pr.state.as_str()), (37, "OPEN")); +} + +#[tokio::test] +async fn single_pull_request_uses_a_non_default_base_as_trunk() { + live!(); + shared!(); + let dir = clone(&fixture_repo(), "fixture/s5-single"); + let github = provider(dir.path()); + let stack = github.stack_view("fixture/s5-single").await.unwrap(); + assert_eq!(stack.trunk, "fixture/single-base"); + let pr = single_row(&stack).pr.as_ref().unwrap(); + assert_eq!((pr.number, pr.state.as_str()), (43, "OPEN")); + let details = github.pr_details(&stack).await.unwrap(); + assert_eq!(details.prs.len(), 1); + assert!(details.prs[0].is_draft); +} + +#[tokio::test] +async fn closed_and_merged_single_pull_requests_keep_their_lifecycle() { + live!(); + shared!(); + let dir = clone(&fixture_repo(), "main"); + let github = provider(dir.path()); + let closed = github.stack_view("fixture/s7-closed").await.unwrap(); + let row = single_row(&closed); + assert!(!row.is_merged); + assert_eq!(row.pr.as_ref().map(|pr| pr.state.as_str()), Some("CLOSED")); + let merged = github.stack_view("fixture/s8-merged").await.unwrap(); + let row = single_row(&merged); + assert!(row.is_merged); + assert_eq!(row.pr.as_ref().map(|pr| pr.state.as_str()), Some("MERGED")); + assert_eq!(merged.trunk, "fixture/single-base"); +} + +#[tokio::test] +async fn merged_stack_keeps_deleted_branches() { + live!(); + shared!(); + let dir = clone(&fixture_repo(), "main"); + let github = provider(dir.path()); + let stack = github.stack_view("fixture/s3-b").await.unwrap(); + assert_eq!(stack.mode, StackMode::Stacked); + assert_eq!(stack.trunk, "fixture/trunk"); + let names: Vec<_> = stack.branches.iter().map(|b| b.name.as_str()).collect(); + assert_eq!(names, ["fixture/s3-a", "fixture/s3-b"]); + assert!(stack.branches.iter().all(|b| b.is_merged)); + let numbers: Vec<_> = stack + .branches + .iter() + .map(|b| b.pr.as_ref().unwrap().number) + .collect(); + assert_eq!(numbers, [34, 35]); +} + +/// Two PRs use head branch `fixture/s1-c`: #29, the top of stack s1, and fork +/// PR #40, which is not stacked. The push remote decides which one is ours. +#[tokio::test] +async fn same_branch_name_resolves_by_head_repository_owner() { + live!(); + shared!(); + let upstream = clone(&fixture_repo(), "fixture/s1-c"); + let stack = provider(upstream.path()) + .stack_view("fixture/s1-c") + .await + .unwrap(); + assert_eq!(stack.mode, StackMode::Stacked); + assert_eq!(stack.branches.len(), 3); + let fork = clone(&fork_repo(), "fixture/s1-c"); + let from_fork = provider(fork.path()) + .stack_view("fixture/s1-c") + .await + .unwrap(); + let pr = single_row(&from_fork).pr.as_ref().unwrap(); + assert_eq!(pr.number, 40); +} + +#[tokio::test] +async fn push_without_local_tracking_is_classified() { + live!(); + shared!(); + let dir = clone(&fixture_repo(), "fixture/s1-b"); + let error = provider(dir.path()).push_stack().await.unwrap_err(); + assert_eq!(error, GitHubError::LocalTrackingMissing); +} + +#[tokio::test] +async fn push_outside_a_repository_is_a_plain_command_error() { + live!(); + let dir = tempfile::tempdir().unwrap(); + let error = provider(dir.path()).push_stack().await.unwrap_err(); + assert!( + matches!(&error, GitHubError::Command { code: 2, stderr } if stderr.contains("not a git repository")), + "{error:?}" + ); +} + +#[test] +fn missing_auth_is_classified() { + live!(); + let (code, _, stderr) = isolated_gh(&["api", "user"], ""); + assert_eq!(code, 4, "{stderr}"); + assert_eq!( + classify_gh_failure(&["api", "user"], code, &stderr), + GitHubError::Auth + ); +} + +/// gh 2.101.0 installs a missing extension on first use and exits 0 without +/// running the subcommand; the provider retries once after seeing this marker. +#[test] +fn gh_installs_missing_extension_on_first_use() { + live!(); + let token = Command::new("gh").args(["auth", "token"]).output().unwrap(); + let token = String::from_utf8(token.stdout).unwrap(); + let (code, stdout, stderr) = isolated_gh(&["stack", "view"], token.trim()); + assert_eq!(code, 0, "{stderr}"); + assert!( + format!("{stdout}{stderr}").contains("Successfully installed github/gh-stack"), + "{stdout}{stderr}" + ); +} + +#[tokio::test] +async fn link_push_sync_and_draft_toggle_round_trip() { + live!(); + let _fixture = Exclusive::begin().await; + let dir = clone(&fixture_repo(), "fixture/s1-b"); + let github = provider(dir.path()); + github.checkout_branch("fixture/s1-b").await.unwrap(); + github.push_stack().await.unwrap(); + github.sync_stack().await.unwrap(); + github.toggle_draft(27, true).await.unwrap(); + let details = github + .pr_details(&github.stack_view("fixture/s1-b").await.unwrap()) + .await + .unwrap(); + assert!( + !details + .prs + .iter() + .find(|p| p.number == 27) + .unwrap() + .is_draft + ); + github.toggle_draft(27, false).await.unwrap(); +} + +#[tokio::test] +async fn sync_against_conflicting_trunk_reports_rebase_conflict() { + live!(); + let _fixture = Exclusive::begin().await; + let dir = clone(&fixture_repo(), "fixture/s2-b"); + let github = provider(dir.path()); + github.checkout_branch("fixture/s2-b").await.unwrap(); + script("conflict"); + let result = github.sync_stack().await; + assert_eq!(result.unwrap_err(), GitHubError::RebaseConflict); +} + +#[tokio::test] +async fn single_pull_request_draft_toggle_round_trip() { + live!(); + let _fixture = Exclusive::begin().await; + let dir = clone(&fixture_repo(), "fixture/s5-single"); + let github = provider(dir.path()); + github.toggle_draft(43, true).await.unwrap(); + let stack = github.stack_view("fixture/s5-single").await.unwrap(); + assert!(!github.pr_details(&stack).await.unwrap().prs[0].is_draft); + github.toggle_draft(43, false).await.unwrap(); +} + +fn commit_here(dir: &Path, message: &str) { + let status = Command::new("git") + .args([ + "-c", + "user.name=stack-stat live tests", + "-c", + "user.email=live-tests@users.noreply.github.com", + "commit", + "--allow-empty", + "-m", + message, + ]) + .current_dir(dir) + .status() + .unwrap(); + assert!(status.success()); +} + +/// Single mode pushes with plain `git push`, not `gh stack push`: it succeeds +/// without any local stack tracking and reports a rejection as a Git error. +#[tokio::test] +async fn single_mode_push_uses_plain_git_and_reports_rejection() { + live!(); + let _fixture = Exclusive::begin().await; + let dir = clone(&fixture_repo(), "fixture/s6-nopr"); + let other = clone(&fixture_repo(), "fixture/s6-nopr"); + let github = provider(dir.path()); + commit_here(dir.path(), "fixture: live push"); + github.push_branch().await.unwrap(); + // The second clone predates that push, so its commit cannot fast-forward. + commit_here(other.path(), "fixture: diverging"); + let error = provider(other.path()).push_branch().await.unwrap_err(); + let GitHubError::Git(message) = error else { + panic!("expected a Git error, got {error:?}"); + }; + assert!(message.contains("rejected"), "{message}"); +} diff --git a/docs/README.md b/docs/README.md index fe47161..0abb091 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,6 +35,7 @@ Read in this order: | [04 Domain and config](architecture/04-domain-and-config.md) | Model vocabulary and configuration precedence | | [05 Providers and store](architecture/05-providers-and-store.md) | CLI adapters and local check retention | | [06 Terminal application](architecture/06-tui.md) | App modes, polling, effects, and rendering | +| [07 Live validation](architecture/07-live-validation.md) | End-to-end suite, fixture, pinned gh behavior, and gaps | The first two pages follow the [C4 model](https://c4model.com); later numbered pages cover subsystems. Put extending recipes beside their subsystem. Do not hand-model generated schemas or public Rust signatures. Link to rustdoc instead. diff --git a/docs/adr/0004-live-fixture-validation.md b/docs/adr/0004-live-fixture-validation.md new file mode 100644 index 0000000..b5bc941 --- /dev/null +++ b/docs/adr/0004-live-fixture-validation.md @@ -0,0 +1,39 @@ +# ADR-0004: Validate gh and gh stack against a live fixture + +- **Status:** Proposed +- **Date:** 2026-10-13 +- **Deciders:** tcamise + +## Context + +Every test ran against fakes or fixtures, so assumptions about `gh` and `gh stack` output and exit codes went unchecked. Probing the real binaries while building this showed three of them wrong or imprecise: + +- `gh` 2.101.0 installs a missing `stack` extension on first use and exits 0 without running the command, so the "unknown command" classifier never matched and the requested action silently did nothing. +- `gh stack` exits 2 both for a branch that is not in a stack and for a directory that is not a Git repository, so exit 2 alone cannot mean "local tracking missing". +- `gh` exits 4 with no "auth login" hint when no token is set, so the text-only auth classifier missed it. + +## Decision + +Add a live suite that runs the real binaries against long-lived fixture pull requests in this repository, described in [live validation](../architecture/07-live-validation.md). + +- **Fixture over ephemeral stacks.** Fixture PRs, `fixture-start/*` tags, and a reset script give deterministic starting states without per-run PR creation. The suite creates nothing and needs no merge rights. +- **Tags reset state.** Branch drift is undone by force-pushing a branch to its tag. A merged stack cannot be reset, so it is read-only. +- **Rulesets protect the fixture.** Branch deletion and tag updates are blocked for everyone but admins. CI skips `fixture/*` pull requests. +- **Opt-in and off the PR path.** Tests run only with `STACK_STAT_LIVE=1`. CI runs them on a schedule and by manual dispatch with a serializing `concurrency` group, because they mutate shared remote state, depend on rate limits, and need a write token that fork PRs must not reach. +- **Pinned and latest toolchains.** The nightly run tests a pinned known-good `gh` and `gh-stack` pair and the latest releases of both. A latest-only failure is upstream drift; the pinned pair moves only after the suite passes against it. A failed scheduled run opens or updates a tracking issue. +- **Classify by text and code together.** Exit 2 maps to `LocalTrackingMissing` only with `is not part of a stack`; exit 4 maps to `Auth`; a first-use auto-install is retried once. +- **No PTY harness.** The `AGENTS.md` rule against PTY tests and visual snapshots stands. Dashboard behavior stays covered by `TestBackend`; the live suite targets the adapter boundary where the real binaries matter. +- **Existing fake tests stay.** They run offline on every pull request and cover logic the live suite does not. None is deleted; the classifier tests were rewritten to the observed output. + +## Consequences + +- A change in `gh` or `gh stack` output, exit codes, or GraphQL shape that breaks the adapter fails the scheduled run within a day, and a maintainer can reproduce it with `make live-test`. +- Fixture PRs appear in this repository's pull request list permanently. +- Runs take minutes and can fail from GitHub outages, rate limits, or someone editing a fixture PR. +- Coverage gaps (pagination over 100 entries, fork push, version matrix, real TUI) are listed in the living page and are not claimed as covered. + +## Alternatives considered + +- **Ephemeral stacks per run.** Isolated and self-cleaning, but slower, requiring a write token with PR creation, and leaves orphans when a run is cancelled. +- **Recorded responses behind a fake `gh`.** Fast and offline, but cannot detect that GitHub or `gh` changed; kept as a possible base layer. +- **PTY or VHS driver for the dashboard.** Rejected for now; it would reverse the `AGENTS.md` rule and adds flakiness for little extra coverage beyond the adapter checks. diff --git a/docs/adr/README.md b/docs/adr/README.md index 36ca77a..071449e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -11,6 +11,7 @@ Number ADRs chronologically with zero-padded permanent IDs (`0001-short-slug.md` |[ADR-0001](0001-remote-rebase-status.md)|Derive rebase status from remote state|Proposed| |[ADR-0002](0002-read-stack-from-github.md)|Read stack structure from GitHub; use gh stack only for actions|Accepted; single-mode missing-data and action rules superseded by [ADR-0003](0003-single-pr-and-no-pr-mode.md)| |[ADR-0003](0003-single-pr-and-no-pr-mode.md)|Show a single PR or current branch without a GitHub stack|Accepted| +|[ADR-0004](0004-live-fixture-validation.md)|Validate gh and gh stack against a live fixture|Proposed| ## Template diff --git a/docs/architecture/05-providers-and-store.md b/docs/architecture/05-providers-and-store.md index 49e15bf..0d95e23 100644 --- a/docs/architecture/05-providers-and-store.md +++ b/docs/architecture/05-providers-and-store.md @@ -35,6 +35,8 @@ Stacked checkout, push, and sync use `gh stack`; only these actions check that t Single-mode push runs plain `git push` and reports ordinary Git errors, including a missing upstream, without trying to link a stack or set an upstream. Because Git prints the remote and hook output before a rejection, the error message leads with the first `[rejected]`, `fatal:`, or `error:` line, followed by the full stderr; the status bar shows that first line. Single-mode checkout and sync are disabled by the App with a status message, not sent to `gh stack`. PR browser and draft/ready actions need no extension and remain available whenever the selected entry has a PR; a closed PR cannot toggle draft state. +Exit codes are ambiguous, so classification pairs them with stderr text: exit 2 on push or sync means missing local tracking only when stderr says the branch `is not part of a stack` (exit 2 also reports "not a git repository"), and exit 4 means authentication. `gh` installs a missing extension on first use and exits 0 without running the subcommand, so the adapter retries a `gh stack` command once when it sees that install message. These behaviors are pinned against the real binaries by the [live suite](07-live-validation.md). + ## Local check results The file store lives at `/stack-stat/status//.json`. Branch names are encoded as one platform-safe directory segment; `feat/api` does not create nested directories. The file contains a map of named results with outcome, optional message, and UTC timestamp. Cooperative readers take a shared lock and reporters/cleanup take an exclusive lock on a separate `status.lock` file. Writing merges a named result with its existing file and atomically replaces the file from a temporary sibling. This prevents a Windows reader's open handle from blocking replacement, while preserving complete JSON for readers across processes and worktrees. If no store exists, reads return empty without creating a lock file. diff --git a/docs/architecture/07-live-validation.md b/docs/architecture/07-live-validation.md new file mode 100644 index 0000000..030fdae --- /dev/null +++ b/docs/architecture/07-live-validation.md @@ -0,0 +1,72 @@ +# 07 Live validation + +The fake-based suites prove stack-stat's logic; they cannot prove that `gh` and `gh stack` still print and exit the way the adapter assumes. The live suite in [`live_gh.rs`](../../crates/stack-stat-core/tests/live_gh.rs) runs the real binaries against fixture pull requests on GitHub. The choice and its trade-offs are recorded in [ADR-0004](../adr/0004-live-fixture-validation.md). + +## Run it + +```sh +make live-test # needs gh auth with push access to the fixture repository +make live-fixture-check # verify tags, branches, PR states, and labels +make live-fixture-reset # restore fixture branches to their start tags +``` + +Tests skip silently unless `STACK_STAT_LIVE=1`; `make live-test` sets it. `STACK_STAT_FIXTURE_REPO` and `STACK_STAT_FIXTURE_FORK` override the fixture repository (default `cam1se/stack-stat`) and the fork that holds the fork PRs (default `tcamise-gpsw/stack-stat`). The suite needs `git`, `gh`, and network access; the `gh stack` extension is installed by `gh` itself on first use. + +## Fixture + +The fixture lives in this repository under the `fixture/` branch prefix. Branches are protected from deletion and the `fixture-start/*` and `fixture-conflict/*` tags from update or deletion by repository rulesets; admins bypass them. CI skips pull requests whose head branch starts with `fixture/`. + +The fixture PRs stay open permanently. Closing and reopening them, like the draft toggles and branch resets, sends notification emails, and the local agent-led TUI tests ([#42](https://github.com/cam1se/stack-stat/issues/42)) need them open. Every fixture PR carries the `fixture` and `do not merge` labels. Four PRs are permanent end states that tests only read: #34 and #35 (merged stack), #45 (merged single PR), and #44 (closed single PR). + +Muting the threads cuts the email noise, but GitHub requires the `notifications` token scope for that, which the CI and agent tokens do not have; mute them from each account's notification settings instead. + +`scripts/live-fixture.sh check` verifies the tags, branches, PR states, and labels. The tests run it once per process and the workflow runs it before the tests, so a broken fixture fails with `FIXTURE:` lines and is reported as "Live gh fixture broken", not as drift. `scripts/live-fixture.sh up` reopens any fixture PR that was closed by accident. `scripts/live-fixture.sh rebuild` recreates missing branches from their tags and missing PRs for stacks `s1` and `s2` and PR `s5`, and relabels every fixture PR. Merged PRs, the closed PR, and fork PRs cannot be rebuilt by the script; recreate them by hand, as the fixture was first built. + +|Stack|Pull requests|Trunk|Role| +|---|---|---|---| +|`s1`|three stacked drafts on `fixture/s1-a`, `-b`, `-c`|`main`|Reads, link, push, sync, draft toggle| +|`s2`|two stacked drafts on `fixture/s2-a`, `-b`|`fixture/trunk`|Rebase conflict: `scripts/live-fixture.sh conflict` moves trunk to a commit editing the same file| +|`s3`|two merged PRs, branches deleted|`fixture/trunk`|Merged stack reads; read-only, cannot be reset| +|`s4`|one unstacked upstream PR on `fixture/s4-same`|`main`|Single PR on the default branch| +|`s5`|one unstacked draft on `fixture/s5-single`|`fixture/single-base`|Single PR whose base is not the default branch; draft toggle| +|`s6`|branch `fixture/s6-nopr`, pushed, no PR|none|No-PR branch; trunk is the default branch| +|`s7`|one closed, unmerged PR|`fixture/single-base`|Single closed PR; read-only| +|`s8`|one merged PR|`fixture/single-base`|Single merged PR; read-only, cannot be reset| +|fork|fork PR #40 from `fixture/s1-c` (open); #38 from `fixture/s4-same` (closed, unused)|`main`|Same head branch name as an upstream PR, different head owner| + +[`scripts/live-fixture.sh`](../../scripts/live-fixture.sh) implements `status`, `reset`, and `conflict`. `reset` force-pushes each branch to its `fixture-start/` tag and restores draft state. Read-only tests hold a shared fixture lock; a mutating test holds it exclusively, resets the fixture before it starts, and resets again when it ends, including when it panics, so no test reads a half-reset fixture. The workflow adds a final reset step and a `concurrency` group so two runs never touch the fixture at once. + +Tag a new baseline with `git tag -f fixture-start/ ` only after temporarily lifting the tag ruleset. + +## Pinned behavior + +Observed with `gh` 2.101.0 and `gh-stack` v0.1.1: + +|Situation|Observed|Adapter result| +|---|---|---| +|`gh stack` with the extension absent|`gh` installs it, prints `Successfully installed github/gh-stack`, exits 0 without running the subcommand|Retry the command once| +|Push or sync in a repository where the branch is untracked|exit 2, `current branch "…" is not part of a stack`|`LocalTrackingMissing`| +|Any `gh stack` command outside a Git repository|exit 2, `not a git repository`|`Command { code: 2, .. }`| +|`gh` with no token|exit 4, `To use GitHub CLI in automation, set the GH_TOKEN`|`Auth`| +|Sync against a conflicting trunk|stack-stat treats exit codes 3 and 7 as a conflict; the live test confirms the result, not which code|`RebaseConflict`| + +## Coverage and gaps + +Covered live: owner/name resolution; single-mode push with plain `git push` and its rejection; stacked, merged-stack and single-mode discovery (unstacked, non-default base, no PR, closed, merged) with PR details; same-name fork PR; link (`gh stack checkout`), push, sync, and draft toggle for stacked and single PRs; sync conflict; missing tracking, repository, extension, and authentication. + +Not covered: + +- **Discovery over 100 stack entries.** Needs a 100-PR stack; pagination remains fixture-tested. +- **Fork clone pushing to its own fork.** The fork PR is read with a fork checkout, but push and sync from a fork are not exercised. +- **Exact conflict exit code.** The test asserts the classified result, not whether `gh stack` returned 3 or 7. +- **Real dashboard rendering.** There is no headless stack printer; the TUI is still covered through `TestBackend` and fake providers. Revisit if a non-interactive resolved-stack command is added. +- **Older `gh` versions.** The workflow tests one pinned known-good pair and the latest releases; versions in between are not tested. +- **`s3` and fork PR drift.** If someone closes a fixture PR or deletes a fork branch, tests fail until the fixture is rebuilt by hand. + +## Credentials + +The workflow uses the `GH_ACCESS_TOKEN` repository secret, falling back to the workflow token. The token must force-push `fixture/*` branches, toggle draft state on the `s1`/`s2` PRs, and open issues; it never needs to merge. The workflow runs on a schedule and on manual dispatch only, never on pull requests, so a fork PR cannot reach the token. + +## Detecting version drift + +The workflow runs a two-job matrix nightly (06:17 UTC) and on dispatch. `pinned` uses the versions the suite was validated against (`gh` 2.101.0, `gh-stack` v0.1.1); `latest` installs the newest `gh` release and the newest extension. A failure in `latest` with `pinned` passing means an upstream change breaks stack-stat; a failure in both points at the fixture or the adapter. Raise the pinned versions only after the suite passes against them. A failed scheduled run opens or updates the issue titled "Live gh drift detected". Each job records the exact versions in its step summary. diff --git a/scripts/live-fixture.sh b/scripts/live-fixture.sh new file mode 100755 index 0000000..e817e2e --- /dev/null +++ b/scripts/live-fixture.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# Manage the live GitHub fixture used by the end-to-end suite (issue #23). +# +# Fixture PRs stay open permanently so the suite and local agent-led TUI tests +# can use them at any time; closing and reopening them only generates +# notification emails. +# +# Usage: scripts/live-fixture.sh +# up Reset branches and reopen any closed fixture PRs (recovery only). +# reset Force-push every fixture branch to its fixture-start/* tag and +# restore draft state. Requires the PRs to be open. +# conflict Move fixture/trunk to fixture-conflict/trunk so `gh stack sync` +# on stack s2 hits a rebase conflict. Undo with `reset`. +# check Verify the fixture: tags, branches, PR states and labels. +# status Print each fixture branch and whether it matches its start tag. +# rebuild Recreate missing branches from their tags and missing PRs for +# stacks s1/s2, s4 and s5. Merged and fork PRs cannot be rebuilt +# here; see docs/architecture/07-live-validation.md. +# +# Environment: STACK_STAT_FIXTURE_REPO (default cam1se/stack-stat). +set -euo pipefail + +repo="${STACK_STAT_FIXTURE_REPO:-cam1se/stack-stat}" +url="https://github.com/${repo}.git" +labels=("fixture" "do not merge") + +# Branches restored to fixture-start/ by `reset`. +branches=(s1-a s1-b s1-c s2-a s2-b s5-single s6-nopr trunk) +# PRs that stay open; `reset` restores the drafts among them. +open_prs=(27 28 29 31 32 37 40 43) +# Of those, PRs that must be drafts while open. +draft_prs=(27 28 29 31 32 43) +# PRs that are permanently merged or closed; tests only read them. +merged_prs=(34 35 45) +closed_prs=(38 44) + +remote_sha() { git ls-remote "$url" "$1" | cut -f1; } + +pr_state() { gh pr view "$1" --repo "$repo" --json state --jq .state; } + +reset_branches() { + for b in "${branches[@]}"; do + tag="$(remote_sha "refs/tags/fixture-start/$b")" + [ -n "$tag" ] || { echo "missing tag fixture-start/$b" >&2; exit 1; } + tmp="$(mktemp -d)" + git -C "$tmp" init -q + git -C "$tmp" fetch -q --depth 1 "$url" "refs/tags/fixture-start/$b:refs/tags/t" + git -C "$tmp" push -q --force "$url" "refs/tags/t:refs/heads/fixture/$b" + rm -rf "$tmp" + done +} + +reset_drafts() { + for pr in "${draft_prs[@]}"; do + is_draft="$(gh pr view "$pr" --repo "$repo" --json isDraft --jq .isDraft)" + [ "$is_draft" = true ] || gh pr ready "$pr" --repo "$repo" --undo >/dev/null + done +} + +reopen_prs() { + for pr in "${open_prs[@]}"; do + [ "$(pr_state "$pr")" = OPEN ] || gh pr reopen "$pr" --repo "$repo" >/dev/null + done +} + +check() { + failures=0 + fail() { echo "FIXTURE: $*" >&2; failures=$((failures + 1)); } + for b in "${branches[@]}"; do + [ -n "$(remote_sha "refs/tags/fixture-start/$b")" ] || fail "missing tag fixture-start/$b" + [ -n "$(remote_sha "refs/heads/fixture/$b")" ] || fail "missing branch fixture/$b" + done + for pr in "${open_prs[@]}"; do + want=OPEN + got="$(pr_state "$pr")" || { fail "PR #$pr not found"; continue; } + [ "$got" = "$want" ] || fail "PR #$pr is $got, expected $want" + done + for pr in "${merged_prs[@]}"; do + [ "$(pr_state "$pr")" = MERGED ] || fail "PR #$pr is not MERGED (it cannot be recreated by script)" + done + for pr in "${closed_prs[@]}"; do + [ "$(pr_state "$pr")" = CLOSED ] || fail "PR #$pr is not CLOSED" + done + for pr in "${open_prs[@]}" "${merged_prs[@]}" "${closed_prs[@]}"; do + have="$(gh pr view "$pr" --repo "$repo" --json labels --jq '.labels[].name')" + for label in "${labels[@]}"; do + printf '%s\n' "$have" | grep -qx "$label" || fail "PR #$pr lacks the '$label' label" + done + done + if [ "$failures" -ne 0 ]; then + echo "fixture check failed with $failures problem(s); see docs/architecture/07-live-validation.md" >&2 + exit 1 + fi + echo "fixture ok" +} + +rebuild() { + tmp="$(mktemp -d)" + gh repo clone "$repo" "$tmp/w" -- -q + cd "$tmp/w" + for b in "${branches[@]}"; do + if [ -z "$(remote_sha "refs/heads/fixture/$b")" ]; then + echo "recreating branch fixture/$b from its tag" + git fetch -q origin "refs/tags/fixture-start/$b:refs/tags/t-$b" + git push -q origin "refs/tags/t-$b:refs/heads/fixture/$b" + fi + done + git fetch -q origin + has_pr() { [ -n "$(gh pr list --repo "$repo" --head "$1" --state all --json number --jq '.[0].number // empty')" ]; } + ensure_stack() { + if has_pr "fixture/$2"; then return; fi + echo "creating stack $1" + base="${3:-main}" + git checkout -q -f "fixture/$2" + shift 3 + gh stack init --base "$base" "${@/#/fixture/}" + gh stack submit --auto + } + ensure_stack s1 s1-a main s1-a s1-b s1-c + ensure_stack s2 s2-a fixture/trunk s2-a s2-b + if ! has_pr fixture/s5-single; then + echo "creating PR for fixture/s5-single" + gh pr create --repo "$repo" --draft --base fixture/single-base --head fixture/s5-single \ + --title "fixture: s5-single (single PR, non-default base)" --body "Live fixture for stack-stat e2e tests (#23). Do not merge." >/dev/null + fi + gh label create "fixture" --repo "$repo" --color BFD4F2 \ + --description "Live e2e test fixture" 2>/dev/null || true + gh label create "do not merge" --repo "$repo" --color B60205 \ + --description "Never merge this PR" 2>/dev/null || true + for pr in "${open_prs[@]}" "${merged_prs[@]}" "${closed_prs[@]}"; do + gh pr edit "$pr" --repo "$repo" --add-label "fixture" --add-label "do not merge" >/dev/null \ + || echo "warning: could not label PR #$pr" >&2 + done + cd / + rm -rf "$tmp" + echo "rebuild done; run '$0 check' to verify" +} + +cmd="${1:-status}" +case "$cmd" in + status) + for b in "${branches[@]}"; do + head="$(remote_sha "refs/heads/fixture/$b")" + tag="$(remote_sha "refs/tags/fixture-start/$b")" + if [ "$head" = "$tag" ]; then state=clean; else state=DRIFTED; fi + printf '%-18s %s\n' "fixture/$b" "$state" + done + ;; + reset) + reset_branches + reset_drafts + echo "fixture reset" + ;; + up) + reopen_prs + reset_branches + reset_drafts + echo "fixture up" + ;; + conflict) + tmp="$(mktemp -d)" + git -C "$tmp" init -q + git -C "$tmp" fetch -q --depth 1 "$url" "refs/tags/fixture-conflict/trunk:refs/tags/t" + git -C "$tmp" push -q --force "$url" "refs/tags/t:refs/heads/fixture/trunk" + rm -rf "$tmp" + echo "fixture/trunk now conflicts with stack s2" + ;; + check) + check + ;; + rebuild) + rebuild + ;; + *) + echo "usage: $0 " >&2 + exit 2 + ;; +esac