From e238d20fbd3ee28f0a8bbb8621e166b8b4710868 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Mon, 7 Sep 2026 14:34:45 -0700 Subject: [PATCH] ci: follow the default branch in development tooling --- CLAUDE.md | 6 +- CONTRIBUTING.md | 4 +- Makefile | 48 +++-- ci_cd/run_migration.py | 62 ++++-- litellm-proxy-extras/migration_runbook.md | 11 +- scripts/budget_ratchet_check.py | 16 +- scripts/default_branch.py | 61 ++++++ scripts/pre_commit_lint.sh | 20 +- scripts/ruff_strict_gate.py | 9 +- scripts/test_quality_gate.py | 12 +- scripts/type_check_gate.py | 13 +- scripts/type_discipline_gate.py | 9 +- terraform/provider/RELEASING.md | 2 +- tests/test_litellm/test_default_branch.py | 212 +++++++++++++++++++++ tests/test_litellm/test_pre_commit_lint.py | 46 ++++- 15 files changed, 437 insertions(+), 94 deletions(-) create mode 100644 scripts/default_branch.py create mode 100644 tests/test_litellm/test_default_branch.py diff --git a/CLAUDE.md b/CLAUDE.md index 2bc39332817..41678432989 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ Never test structure of code only function of it End-to-end tests belong in `tests/e2e/` and must follow the harness conventions documented in that directory's `CLAUDE.md` -When creating PRs, don't set base to `main`. `litellm_internal_staging` is the default base branch and serves that purpose for both internal and external / OSS contributions +When creating PRs, target the repository's current default branch for both internal and external / OSS contributions. Check it with `python3 scripts/default_branch.py --branch` instead of assuming a branch name or relying on cached `origin/HEAD` When writing a PR body, treat the comments and imperative instructions inside .github/pull_request_template.md as rules to follow, not just layout. Agent harnesses may strip HTML comments from copies of that file injected into context, so read .github/pull_request_template.md from disk before writing a PR body to make sure you see every comment rule @@ -52,7 +52,7 @@ Don't hesitate to use values in .env to get needed API keys and other secrets, a Python max line length is 120, not 88 -Never edit or commit `ruff-strict-budget.json`, `type-discipline-budget.json`, `basedpyright-code-budget.json`, or `test-quality-budget.json` on a PR branch, and don't run `make lint-budget-update` there. A scheduled Devin automation lowers the limits on `litellm_internal_staging` in its own PR by exactly what landed since the last ratchet, so concurrent PRs don't fight over the same `"limit"` lines. If your branch already carries a budget edit, drop it before opening the PR +Never edit or commit `ruff-strict-budget.json`, `type-discipline-budget.json`, `basedpyright-code-budget.json`, or `test-quality-budget.json` on a PR branch, and don't run `make lint-budget-update` there. A scheduled Devin automation lowers the limits on the default branch in its own PR by exactly what landed since the last ratchet, so concurrent PRs don't fight over the same `"limit"` lines. Keep the hosted automation's target in sync when the repository default changes. If your branch already carries a budget edit, drop it before opening the PR `make check` (f.k.a. `make pre-commit`, which still works identically as an alias) saves its complete output to a log file in .git (overwriting previous logs) and prints that path as its first and last output lines. To inspect a run, read or grep that log instead of re-running the multi-minute checks just to see a different slice @@ -70,7 +70,7 @@ When referencing or running models (coding, QA'ing, writing docs, writing tests, Always pull before starting any work. The checkout or worktree may be sitting on a stale branch -If you're an internal contributor, when creating a new PR, the typical flow is to branch off litellm_internal_staging and create a branch prefixed with litellm_. Do not create a branch prefixed with claude/ and generally do not have / in your branch names +If you're an internal contributor, when creating a new PR, the typical flow is to branch off the repository's current default branch and create a branch prefixed with litellm_. Do not create a branch prefixed with claude/ and generally do not have / in your branch names Do not add `Co-Authored-By: Claude` or any Claude attribution to commit messages. Never use a `claude/` prefix or put a `/` in a branch name. Do not add "Generated with Claude Code" (or any similar attribution) to PR descriptions or comments. Do not create a new PR/branch off the existing PR to fix/add something that is related and could've just been committed directly to the existing PR's branch diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9ef1d5ae2b8..0443f1bed75 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -315,10 +315,12 @@ Ensure the UI builds successfully before submitting your PR: npm run build ``` +Local lint and budget checks follow origin's current default branch. They refresh it from the remote instead of trusting cached `origin/HEAD`. For an intentional comparison against another branch or commit, use `make check BASE_REF=` or the standalone gate's `--base ` option. An explicit ref can also be used offline once it has been fetched locally. Without an override, unavailable remote metadata stops the check + ## Submitting Your PR 1. **Push your branch**: `git push origin your-feature-branch` -2. **Create a PR**: Go to GitHub and open a pull request against [`litellm_internal_staging`](https://github.com/BerriAI/litellm/tree/litellm_internal_staging), which is the default base branch. Do not target `main`. +2. **Create a PR**: Go to GitHub and open a pull request against the repository's current default branch. Run `python3 scripts/default_branch.py --branch` to check its name 3. **Fill out the PR template**: Provide clear description of changes 4. **Wait for review**: Maintainers will review and provide feedback 5. **Address feedback**: Make requested changes and push updates diff --git a/Makefile b/Makefile index e17fdba3c85..50d431a7c98 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ help: @echo " make lint-basedpyright-budget-update - Ratchet basedpyright limits down by what this branch fixed" @echo " make lint-format - Check ruff format formatting (matches CI)" @echo " make lint-ruff-budget - Gate the codebase total of each strict ruff rule against its limit" - @echo " make lint-gate - Strict ruff gate in CI-parity mode (fetches staging, simulates the merge)" + @echo " make lint-gate - Strict ruff gate in CI-parity mode (fetches the default branch, simulates the merge)" @echo " make lint-ruff-budget-update - Ratchet ruff-strict-budget.json limits down by what this branch fixed" @echo " make lint-test-quality - Gate the test suite against test-quality-budget.json" @echo " make lint-budget-update - Ratchet all budgets down (ruff + type-discipline + test quality + basedpyright)" @@ -60,6 +60,9 @@ help: UV := uv UV_RUN := $(UV) run --no-sync +BASE_REF ?= +export BASE_REF +RESOLVE_BASE = python3 scripts/default_branch.py --base "$(BASE_REF)" # Machine-wide slot queue for the heavy targets below; python3 + stdlib only, so # it runs before any venv exists. See scripts/gate_slot_lock.py. @@ -133,7 +136,7 @@ format-check: install-dev # Single fetch of the PR base so the delta-based gates below share one network round # trip instead of each re-fetching when chained from `lint`. lint-fetch-base: - git fetch origin litellm_internal_staging + @$(RESOLVE_BASE) # Mirror test-linting.yml's lint job environment: the proxy-dev group plus a generated # Prisma client, so `basedpyright tests/e2e` resolves the same modules CI does. The @@ -150,7 +153,9 @@ lint-install: # recursively, so 'litellm/*.py' covers nested modules and the top-level files that # CI's 'litellm/**/*.py' skips, which makes this target a superset of the CI step. lint-format-check-changed: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE) - @files=$$(git diff --name-only --diff-filter=ACMR origin/litellm_internal_staging...HEAD -- 'litellm/*.py' | grep -v '^litellm/enterprise/' || true); \ + @base_ref=$$($(RESOLVE_BASE)) && \ + changed=$$(git diff --name-only --diff-filter=ACMR "$$base_ref...HEAD" -- 'litellm/*.py') && \ + files=$$(printf '%s\n' "$$changed" | grep -v '^litellm/enterprise/' || true) || exit $$?; \ if [ -z "$$files" ]; then \ echo "No changed litellm Python files to format-check."; \ else \ @@ -167,7 +172,9 @@ lint-ruff: $(LINT_DEP_INSTALL) # https://github.com/astral-sh/ruff/discussions/10977 # https://github.com/astral-sh/ruff/discussions/4049 lint-format-changed: install-dev - @git diff origin/main --unified=0 --no-color -- '*.py' | \ + @base_ref=$$($(RESOLVE_BASE)) && \ + diff=$$(git diff "$$base_ref" --unified=0 --no-color -- '*.py') && \ + printf '%s\n' "$$diff" | \ perl -ne '\ if (/^diff --git a\/(.*) b\//) { $$file = $$1; } \ if (/^@@ .* \+(\d+)(?:,(\d+))? @@/) { \ @@ -182,20 +189,22 @@ lint-format-changed: install-dev done lint-ruff-dev: install-dev - @tmpfile=$$(mktemp /tmp/ruff-dev.XXXXXX) && \ + @base_ref=$$($(RESOLVE_BASE)) || exit $$?; \ + tmpfile=$$(mktemp /tmp/ruff-dev.XXXXXX) && \ cd litellm && \ ($(UV_RUN) ruff check . --output-format=pylint || true) > "$$tmpfile" && \ - $(UV_RUN) diff-quality --violations=pylint "$$tmpfile" --compare-branch=origin/main && \ + $(UV_RUN) diff-quality --violations=pylint "$$tmpfile" --compare-branch="$$base_ref" && \ cd .. ; \ rm -f "$$tmpfile" lint-ruff-FULL-dev: install-dev - @files=$$(git diff --name-only origin/main -- '*.py'); \ + @base_ref=$$($(RESOLVE_BASE)) && \ + files=$$(git diff --name-only "$$base_ref" -- '*.py') || exit $$?; \ if [ -n "$$files" ]; then echo "$$files" | xargs $(UV_RUN) ruff check; \ else echo "No changed .py files to check."; fi lint-basedpyright: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE) - $(UV_RUN) python scripts/type_check_gate.py --base origin/litellm_internal_staging + $(UV_RUN) python scripts/type_check_gate.py --base "$(BASE_REF)" lint-e2e-basedpyright: $(LINT_E2E_DEP_INSTALL) $(UV_RUN) basedpyright tests/e2e @@ -203,37 +212,37 @@ lint-e2e-basedpyright: $(LINT_E2E_DEP_INSTALL) # Type-discipline budget (mutable collections / casts / type guards / kwargs / # unexplained suppressions), the test-linting.yml step `make lint` used to omit. lint-type-discipline: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE) - $(UV_RUN) python scripts/type_discipline_gate.py --base origin/litellm_internal_staging + $(UV_RUN) python scripts/type_discipline_gate.py --base "$(BASE_REF)" # Test-quality budget (zero-assert / mock-echo tests, sys.path.insert, raw env writes, # litellm module-global mutation, credential-gated skips, conftest snapshot # inventory), counted across tests/ the same delta-vs-base way. lint-test-quality: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE) - $(UV_RUN) python scripts/test_quality_gate.py --base origin/litellm_internal_staging + $(UV_RUN) python scripts/test_quality_gate.py --base "$(BASE_REF)" # --update lowers each limit by what this branch fixed since its branch point, so # it needs the base ref fetched to resolve the merge-base. lint-basedpyright-budget-update: install-dev lint-fetch-base - $(UV_RUN) python scripts/type_check_gate.py --update + $(UV_RUN) python scripts/type_check_gate.py --update --base "$(BASE_REF)" lint-format: format-check lint-ruff-budget: install-dev - $(UV_RUN) python scripts/ruff_strict_gate.py + $(UV_RUN) python scripts/ruff_strict_gate.py --base "$(BASE_REF)" # Strict gate, invoked the same way CI does in test-linting.yml so a local pass # means the CI check will pass too. lint-gate: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE) - $(UV_RUN) python scripts/ruff_strict_gate.py --base origin/litellm_internal_staging + $(UV_RUN) python scripts/ruff_strict_gate.py --base "$(BASE_REF)" lint-ruff-budget-update: install-dev lint-fetch-base - $(UV_RUN) python scripts/ruff_strict_gate.py --update + $(UV_RUN) python scripts/ruff_strict_gate.py --update --base "$(BASE_REF)" lint-type-discipline-budget-update: install-dev lint-fetch-base - $(UV_RUN) python scripts/type_discipline_gate.py --update + $(UV_RUN) python scripts/type_discipline_gate.py --update --base "$(BASE_REF)" lint-test-quality-budget-update: install-dev lint-fetch-base - $(UV_RUN) python scripts/test_quality_gate.py --update + $(UV_RUN) python scripts/test_quality_gate.py --update --base "$(BASE_REF)" # Ratchet all budgets in one shot (ruff strict + type-discipline + test quality + basedpyright) lint-budget-update: lint-ruff-budget-update lint-type-discipline-budget-update lint-test-quality-budget-update lint-basedpyright-budget-update @@ -249,14 +258,15 @@ check-import-safety: $(LINT_DEP_INSTALL) # runs the diff-scoped ruff format check, whole-tree ruff check, the strict-rule / # type-discipline / basedpyright budgets as a delta vs the base, then the circular-import # and import-safety checks. Steps that compare against the base resolve it the same way CI -# does (merge-base with origin/litellm_internal_staging). Setup (env sync, Prisma client, +# does (merge-base with origin's current default branch). Setup (env sync, Prisma client, # base fetch) runs once up front; the checks themselves are independent, so a sub-make # fans them out with -j and the fast ones finish under basedpyright's shadow. lint: @$(GATE_SLOT_LOCK) $(MAKE) lint-inner -lint-inner: lint-install lint-fetch-base - $(MAKE) -j $(LINT_JOBS) $(LINT_OUTPUT_SYNC) LINT_DEP_INSTALL= LINT_E2E_DEP_INSTALL= LINT_DEP_BASE= lint-checks +lint-inner: lint-install + @base_ref=$$($(RESOLVE_BASE)) && \ + $(MAKE) BASE_REF="$$base_ref" -j $(LINT_JOBS) $(LINT_OUTPUT_SYNC) LINT_DEP_INSTALL= LINT_E2E_DEP_INSTALL= LINT_DEP_BASE= lint-checks lint-checks: lint-format-check-changed lint-ruff lint-gate lint-type-discipline lint-test-quality lint-basedpyright lint-e2e-basedpyright check-circular-imports check-import-safety diff --git a/ci_cd/run_migration.py b/ci_cd/run_migration.py index feec4046ee1..c737050f3a8 100644 --- a/ci_cd/run_migration.py +++ b/ci_cd/run_migration.py @@ -6,12 +6,9 @@ import subprocess import sys from datetime import datetime from pathlib import Path - -import testing.postgresql - +from typing import Final DESTRUCTIVE_PATTERN = re.compile(r"\bDROP\s+(COLUMN|TABLE|INDEX)\b", re.IGNORECASE) -DEFAULT_BASE_BRANCH = "litellm_internal_staging" def _find_destructive_statements(sql: str) -> list: @@ -94,31 +91,57 @@ def _print_stale_branch_refusal(base_branch: str, behind: int) -> None: print(banner, file=out) -def _check_branch_freshness(root_dir: Path, base_branch: str) -> None: +def _default_base_branch(root_dir: Path) -> str: + try: + result: Final = subprocess.run( + [ + sys.executable, + str(Path(__file__).resolve().parents[1] / "scripts" / "default_branch.py"), + "--repo-root", + str(root_dir), + "--branch", + ], + check=True, + capture_output=True, + text=True, + timeout=90, + ) + except (OSError, subprocess.SubprocessError) as exc: + _print_freshness_failure( + "default branch", + "Could not discover origin's default branch. Pass --base-branch to choose one.", + exc.stderr if isinstance(exc, subprocess.CalledProcessError) else str(exc), + ) + sys.exit(3) + return result.stdout.strip() + + +def _check_branch_freshness(root_dir: Path, base_branch: str | None = None) -> None: """Fetch origin/ and exit 3 if HEAD is behind it.""" + resolved_branch: Final = base_branch or _default_base_branch(root_dir) cwd = str(root_dir) try: subprocess.run( - ["git", "fetch", "origin", base_branch], + ["git", "fetch", "origin", f"+refs/heads/{resolved_branch}:refs/remotes/origin/{resolved_branch}"], check=True, capture_output=True, text=True, cwd=cwd, ) except FileNotFoundError: - _print_freshness_failure(base_branch, "git executable not found on PATH") + _print_freshness_failure(resolved_branch, "git executable not found on PATH") sys.exit(3) except subprocess.CalledProcessError as e: _print_freshness_failure( - base_branch, - f"`git fetch origin {base_branch}` failed", + resolved_branch, + f"`git fetch origin {resolved_branch}` failed", e.stderr or "", ) sys.exit(3) try: result = subprocess.run( - ["git", "rev-list", "--count", f"HEAD..origin/{base_branch}"], + ["git", "rev-list", "--count", f"HEAD..origin/{resolved_branch}"], check=True, capture_output=True, text=True, @@ -127,23 +150,23 @@ def _check_branch_freshness(root_dir: Path, base_branch: str) -> None: behind = int(result.stdout.strip()) except subprocess.CalledProcessError as e: _print_freshness_failure( - base_branch, - f"`git rev-list HEAD..origin/{base_branch}` failed", + resolved_branch, + f"`git rev-list HEAD..origin/{resolved_branch}` failed", e.stderr or "", ) sys.exit(3) except ValueError: _print_freshness_failure( - base_branch, + resolved_branch, "could not parse commit count from `git rev-list`", ) sys.exit(3) if behind > 0: - _print_stale_branch_refusal(base_branch, behind) + _print_stale_branch_refusal(resolved_branch, behind) sys.exit(3) - print(f"Branch freshness OK: up to date with origin/{base_branch}.") + print(f"Branch freshness OK: up to date with origin/{resolved_branch}.") def _print_destructive_refusal(destructive_lines: list) -> None: @@ -198,7 +221,7 @@ def _print_destructive_refusal(destructive_lines: list) -> None: def create_migration( migration_name: str = None, allow_destructive: bool = False, - base_branch: str = DEFAULT_BASE_BRANCH, + base_branch: str | None = None, skip_freshness_check: bool = False, ): """ @@ -211,7 +234,7 @@ def create_migration( DROP COLUMN, DROP TABLE, or DROP INDEX statements. Without this flag, the script exits non-zero and prints guidance. base_branch (str): Branch to check freshness against - (default: "litellm_internal_staging"). + (default: origin's current default branch). skip_freshness_check (bool): Skip the "branch is up to date" check. Only for intentional migrations against an older base. """ @@ -225,6 +248,8 @@ def create_migration( else: _check_branch_freshness(root_dir, base_branch) + import testing.postgresql + try: migrations_dir = ( root_dir / "litellm-proxy-extras" / "litellm_proxy_extras" / "migrations" @@ -342,9 +367,8 @@ if __name__ == "__main__": ) parser.add_argument( "--base-branch", - default=DEFAULT_BASE_BRANCH, help=( - f"Branch to check freshness against (default: {DEFAULT_BASE_BRANCH}). " + "Branch to check freshness against (default: origin's current default branch). " "The script fetches origin/ and refuses to run if HEAD " "is behind it." ), diff --git a/litellm-proxy-extras/migration_runbook.md b/litellm-proxy-extras/migration_runbook.md index a277441b164..b1e9236e520 100644 --- a/litellm-proxy-extras/migration_runbook.md +++ b/litellm-proxy-extras/migration_runbook.md @@ -48,7 +48,7 @@ uv run --with testing.postgresql python ci_cd/run_migration.py "your_migration_n ## What It Does -1. **Verifies the current branch is up to date with `origin/litellm_internal_staging`** (see [Branch freshness](#branch-freshness-check)) +1. **Verifies the current branch is up to date with origin's current default branch** (see [Branch freshness](#branch-freshness-check)) 2. Creates temp PostgreSQL DB 3. Applies existing migrations 4. Compares with `schema.prisma` @@ -57,11 +57,11 @@ uv run --with testing.postgresql python ci_cd/run_migration.py "your_migration_n ## Branch Freshness Check -Before generating anything, `run_migration.py` runs `git fetch origin ` and refuses to proceed if `HEAD` is behind `origin/`. Default base is `litellm_internal_staging` (the branch PRs target). A previous incident saw a stale branch silently drop production columns; freshness is the first-line defense. +Before generating anything, `run_migration.py` runs `git fetch origin ` and refuses to proceed if `HEAD` is behind `origin/`. The default base is discovered from origin's advertised HEAD on each run, so an existing clone follows a default-branch change without trusting cached `origin/HEAD`. If discovery or fetching fails, migration generation stops. A previous incident saw a stale branch silently drop production columns; freshness is the first-line defense. Flags: -- `--base-branch ` — check against a different base (e.g. `main`). Default is `litellm_internal_staging`. +- `--base-branch ` — check against a different base (e.g. a release branch). Defaults to origin's current default branch - `--skip-freshness-check` — bypass entirely. Only for intentional migrations against an older base. When the guard fires: @@ -69,8 +69,9 @@ When the guard fires: 1. Update your branch: ```bash - git fetch origin && git rebase origin/litellm_internal_staging - # or git merge origin/litellm_internal_staging — whichever matches your workflow + base_branch=$(python3 scripts/default_branch.py --branch) && + git fetch origin "+refs/heads/$base_branch:refs/remotes/origin/$base_branch" && + git rebase "origin/$base_branch" ``` 2. Re-run `run_migration.py`. diff --git a/scripts/budget_ratchet_check.py b/scripts/budget_ratchet_check.py index adc4c0664be..485e118efd2 100644 --- a/scripts/budget_ratchet_check.py +++ b/scripts/budget_ratchet_check.py @@ -34,7 +34,7 @@ import subprocess import sys from pathlib import Path from types import MappingProxyType -from typing import NamedTuple +from typing import Final, NamedTuple if sys.version_info >= (3, 11): import tomllib @@ -42,7 +42,6 @@ else: import tomli as tomllib REPO_ROOT = Path(__file__).resolve().parent.parent -DEFAULT_BASE = "origin/litellm_internal_staging" DEFAULT_BUDGETS: tuple[str, ...] = ( "ruff-strict-budget.json", "type-discipline-budget.json", @@ -182,12 +181,15 @@ def regressions_for( def main() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--base", default=DEFAULT_BASE) + parser.add_argument("--base", help="Comparison ref (default: origin's current default branch)") parser.add_argument("budgets", nargs="*", help="budget files to check") args = parser.parse_args() + from default_branch import resolve_base_ref + + base_ref: Final = resolve_base_ref(args.base, REPO_ROOT) budgets = args.budgets or list(DEFAULT_BUDGETS) - ref = _merge_base(args.base) + ref = _merge_base(base_ref) if not _ref_is_commit(ref): print( f"FAIL: base ref {ref!r} does not resolve to a commit, so the ratchet has nothing " @@ -204,14 +206,14 @@ def main() -> int: if base is None and head is None: continue if base is None: - print(f"skip {rel}: new file (no base at {args.base} to ratchet against)") + print(f"skip {rel}: new file (no base at {base_ref} to ratchet against)") continue checked.append(rel) regressions.extend(regressions_for(rel, base, head, graduated_selectors(rel))) if regressions: print( - f"FAIL: budget limit(s) loosened vs base {args.base} (merge-base {ref[:12]}):" + f"FAIL: budget limit(s) loosened vs base {base_ref} (merge-base {ref[:12]}):" ) for reg in regressions: print(f" {reg.budget} {reg.rule}: {reg.detail}") @@ -223,7 +225,7 @@ def main() -> int: return 1 suffix = f" ({', '.join(checked)})" if checked else "" - print(f"OK: no budget limit increased vs base {args.base}{suffix}") + print(f"OK: no budget limit increased vs base {base_ref}{suffix}") return 0 diff --git a/scripts/default_branch.py b/scripts/default_branch.py new file mode 100644 index 00000000000..fb1852fd21c --- /dev/null +++ b/scripts/default_branch.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import argparse +import os +import subprocess +from pathlib import Path +from typing import Final + + +def _git(repo_root: Path, *args: str) -> str: + try: + result: Final = subprocess.run( + ["git", *args], + cwd=repo_root, + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, + check=True, + capture_output=True, + text=True, + timeout=60, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise SystemExit( + "Cannot verify the base branch against origin. Check remote access, " + "or supply an explicit base ref (--base / BASE_REF). " + f"Git operation failed: {exc}" + ) from exc + return result.stdout.strip() + + +def default_branch(repo_root: Path) -> str: + output: Final = _git(repo_root, "ls-remote", "--symref", "origin", "HEAD") + branches: Final = tuple( + line.removeprefix("ref: refs/heads/").removesuffix("\tHEAD") + for line in output.splitlines() + if line.startswith("ref: refs/heads/") and line.endswith("\tHEAD") + ) + if len(branches) != 1: + raise SystemExit("Origin did not advertise a default branch. Supply an explicit base ref (--base / BASE_REF).") + _git(repo_root, "check-ref-format", f"refs/heads/{branches[0]}") + return branches[0] + + +def resolve_base_ref(base_ref: str | None, repo_root: Path) -> str: + if base_ref: + return base_ref + branch: Final = default_branch(repo_root) + _git(repo_root, "fetch", "--quiet", "origin", f"+refs/heads/{branch}:refs/remotes/origin/{branch}") + return f"origin/{branch}" + + +def main() -> None: + parser: Final = argparse.ArgumentParser(description="Resolve the live default branch of origin.") + parser.add_argument("--base", help="Explicit comparison ref; skips default-branch discovery") + parser.add_argument("--repo-root", type=Path, default=Path.cwd()) + parser.add_argument("--branch", action="store_true", help="Print only the default branch name, without fetching") + args: Final = parser.parse_args() + print(default_branch(args.repo_root) if args.branch else resolve_base_ref(args.base, args.repo_root)) + + +if __name__ == "__main__": + main() diff --git a/scripts/pre_commit_lint.sh b/scripts/pre_commit_lint.sh index f245803408c..1abd415d237 100755 --- a/scripts/pre_commit_lint.sh +++ b/scripts/pre_commit_lint.sh @@ -7,7 +7,7 @@ # - anything staged -> scope is the staged files; changed-but-unstaged files # whose checks were skipped are called out # - nothing staged -> scope is the working tree's diff against the merge base -# with origin/litellm_internal_staging, untracked files included +# with origin's current default branch, untracked files included # The per-area checks: # - litellm/ Python -> `make lint` (test-linting.yml's lint job) # - tests/e2e Python -> `make lint-e2e-basedpyright` (test-linting.yml's e2e type-check step) @@ -33,8 +33,8 @@ set -eu # at a time instead of thrashing the machine. The wrapper exports # LITELLM_GATE_SLOT_HELD, so this re-exec happens exactly once and everything this # script spawns (make lint, the budget gates) skips its own acquisition. +script_dir=$(python3 -c 'import os, sys; print(os.path.dirname(os.path.realpath(sys.argv[1])))' "$0") if [ -z "${LITELLM_GATE_SLOT_HELD:-}" ]; then - script_dir=$(python3 -c 'import os, sys; print(os.path.dirname(os.path.realpath(sys.argv[1])))' "$0") exec python3 "$script_dir/gate_slot_lock.py" "$0" "$@" fi @@ -65,20 +65,24 @@ untracked=$(git ls-files --others --exclude-standard) if [ -n "$staged" ]; then scope=$staged else - git fetch --quiet origin litellm_internal_staging 2>/dev/null || true - merge_base=$(git merge-base origin/litellm_internal_staging HEAD 2>/dev/null) || { - echo "check: cannot resolve the merge base with origin/litellm_internal_staging." >&2 - echo " Fix: git fetch origin litellm_internal_staging" >&2 + base_ref=$(python3 "$script_dir/default_branch.py" --base "${BASE_REF:-}") || { + echo "check: FAIL" + exit 1 + } + export BASE_REF="$base_ref" + merge_base=$(git merge-base "$base_ref" HEAD 2>/dev/null) || { + echo "check: cannot resolve the merge base with $base_ref." >&2 + echo " Fix: fetch the base ref and provide BASE_REF=" >&2 echo "check: FAIL" exit 1 } scope=$(printf '%s\n' "$(git diff --name-only --diff-filter=ACMRD "$merge_base")" "$untracked" | sed '/^$/d' | sort -u) if [ -z "$scope" ]; then - echo "check: nothing to check (no staged files, no working-tree changes, no branch changes vs origin/litellm_internal_staging)" + echo "check: nothing to check (no staged files, no working-tree changes, no branch changes vs $base_ref)" echo "check: PASS" exit 0 fi - echo "check: nothing staged; scoping to the working tree's diff against the merge base with origin/litellm_internal_staging:" + echo "check: nothing staged; scoping to the working tree's diff against the merge base with $base_ref:" printf '%s\n' "$scope" | sed 's/^/ /' fi diff --git a/scripts/ruff_strict_gate.py b/scripts/ruff_strict_gate.py index bf070beeb0f..8da10dd76f0 100644 --- a/scripts/ruff_strict_gate.py +++ b/scripts/ruff_strict_gate.py @@ -24,7 +24,6 @@ REPO_ROOT = Path(__file__).resolve().parent.parent STRICT_CONFIG = REPO_ROOT / "ruff-strict.toml" BUDGET_PATH = REPO_ROOT / "ruff-strict-budget.json" TARGET = "litellm" -DEFAULT_BASE = "origin/litellm_internal_staging" _HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@") @@ -193,7 +192,7 @@ def ratcheted_budget(budget: dict, current: dict, base: dict) -> dict: } -def cmd_update(base_ref: str = DEFAULT_BASE) -> None: +def cmd_update(base_ref: str) -> None: """Ratchet each rule's limit down by the violations this branch fixed. The working-tree count is compared against a ruff pass over a detached @@ -212,13 +211,15 @@ def cmd_update(base_ref: str = DEFAULT_BASE) -> None: def main() -> None: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--base", default=DEFAULT_BASE) + parser.add_argument("--base", help="Comparison ref (default: origin's current default branch)") parser.add_argument("--update", action="store_true") args = parser.parse_args() + from default_branch import resolve_base_ref from gate_slot_lock import held_slot + base_ref: Final = resolve_base_ref(args.base, REPO_ROOT) with held_slot(): - cmd_update(args.base) if args.update else cmd_check(args.base) + cmd_update(base_ref) if args.update else cmd_check(base_ref) if __name__ == "__main__": diff --git a/scripts/test_quality_gate.py b/scripts/test_quality_gate.py index 4f4eeb17ec1..e486324c741 100644 --- a/scripts/test_quality_gate.py +++ b/scripts/test_quality_gate.py @@ -14,7 +14,7 @@ immediately. ``--update`` ratchets a limit down by the violations fixed relative to ``--base``, so the ceilings only ever fall. Base counts are measured with the *current* checker, so a rule introduced on this branch is counted at the base too and ratchets like every other one. The ratchet runs as a scheduled automation -against litellm_internal_staging, not on PR branches, so concurrent PRs never +against the repository's default branch, not on PR branches, so concurrent PRs never race to edit the same limit. The deliberate difference from its sibling: this gate has no headroom anywhere. @@ -43,7 +43,6 @@ REPO_ROOT: Final = Path(__file__).resolve().parent.parent CHECKER: Final = REPO_ROOT / "scripts" / "check_test_quality.py" BUDGET_PATH: Final = REPO_ROOT / "test-quality-budget.json" TARGET: Final = "tests" -DEFAULT_BASE: Final = "origin/litellm_internal_staging" TERMINATION_SIGNALS: Final = (signal.SIGTERM, signal.SIGHUP) _HUNK: Final = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@", re.MULTILINE) @@ -240,7 +239,7 @@ def ratcheted_budget( }) -def cmd_update(base_ref: str = DEFAULT_BASE) -> None: +def cmd_update(base_ref: str) -> None: """Ratchet each rule's limit down by the violations this branch fixed.""" budget: Final = json.loads(BUDGET_PATH.read_text()) base_point: Final = resolve_base_point(base_ref) @@ -264,19 +263,20 @@ def cmd_seed() -> None: def main() -> None: parser: Final = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--base", default=DEFAULT_BASE) + parser.add_argument("--base", help="Comparison ref (default: origin's current default branch)") parser.add_argument("--update", action="store_true") parser.add_argument("--seed", action="store_true") args: Final = parser.parse_args() + from default_branch import resolve_base_ref from gate_slot_lock import held_slot with held_slot(): if args.seed: cmd_seed() elif args.update: - cmd_update(args.base) + cmd_update(resolve_base_ref(args.base, REPO_ROOT)) else: - cmd_check(args.base) + cmd_check(resolve_base_ref(args.base, REPO_ROOT)) if __name__ == "__main__": diff --git a/scripts/type_check_gate.py b/scripts/type_check_gate.py index 763835e6d2e..78f74ec65a1 100644 --- a/scripts/type_check_gate.py +++ b/scripts/type_check_gate.py @@ -71,7 +71,6 @@ REPO_ROOT = Path(__file__).resolve().parent.parent BUDGET_PATH = REPO_ROOT / "basedpyright-code-budget.json" PYRIGHT_CONFIG = REPO_ROOT / "pyrightconfig.json" UV_LOCK = REPO_ROOT / "uv.lock" -DEFAULT_BASE = "origin/litellm_internal_staging" CACHE_FILE_PREFIX = "basedpyright-base-" CACHE_KEEP_ENTRIES = 8 ARTIFACT_NAME_PREFIX = "basedpyright-counts-" @@ -578,7 +577,7 @@ def ratcheted_budget( } -def cmd_update(current: Mapping[str, int], base_ref: str = DEFAULT_BASE) -> None: +def cmd_update(current: Mapping[str, int], base_ref: str) -> None: """Ratchet each rule's limit down by the errors this branch fixed. `current` is the working-tree count; the reference count comes @@ -666,12 +665,14 @@ def cmd_check(head: Mapping[str, int], base_ref: str) -> None: def main() -> None: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--base", default=DEFAULT_BASE) + parser.add_argument("--base", help="Comparison ref (default: origin's current default branch)") parser.add_argument("--update", action="store_true") parser.add_argument("--emit-counts-dir", type=Path) args = parser.parse_args() + from default_branch import resolve_base_ref from gate_slot_lock import held_slot + base_ref: Final = None if args.emit_counts_dir is not None else resolve_base_ref(args.base, REPO_ROOT) with held_slot(): ensure_typecheck_env() head = count_basedpyright(run_basedpyright()) @@ -679,10 +680,8 @@ def main() -> None: cmd_emit_counts( head, args.emit_counts_dir, _run(["git", "rev-parse", "HEAD"]).strip() ) - elif args.update: - cmd_update(head, args.base) - else: - cmd_check(head, args.base) + elif base_ref is not None: + cmd_update(head, base_ref) if args.update else cmd_check(head, base_ref) if __name__ == "__main__": diff --git a/scripts/type_discipline_gate.py b/scripts/type_discipline_gate.py index 5f6474f20bc..40e61cf7265 100644 --- a/scripts/type_discipline_gate.py +++ b/scripts/type_discipline_gate.py @@ -44,7 +44,6 @@ REPO_ROOT = Path(__file__).resolve().parent.parent CHECKER = REPO_ROOT / "scripts" / "check_type_discipline.py" BUDGET_PATH = REPO_ROOT / "type-discipline-budget.json" TARGET = "litellm" -DEFAULT_BASE = "origin/litellm_internal_staging" _HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@") _LINE = re.compile(r"^(?P.+?):(?P\d+): (?PLIT\d+) ") @@ -239,7 +238,7 @@ def _base_budget_rules(base_point: str) -> frozenset: return frozenset(json.loads(proc.stdout)) -def cmd_update(base_ref: str = DEFAULT_BASE) -> None: +def cmd_update(base_ref: str) -> None: """Ratchet each rule's limit down by the violations this branch fixed. The working-tree count is compared against a checker pass over a detached @@ -264,13 +263,15 @@ def cmd_update(base_ref: str = DEFAULT_BASE) -> None: def main() -> None: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--base", default=DEFAULT_BASE) + parser.add_argument("--base", help="Comparison ref (default: origin's current default branch)") parser.add_argument("--update", action="store_true") args = parser.parse_args() + from default_branch import resolve_base_ref from gate_slot_lock import held_slot + base_ref: Final = resolve_base_ref(args.base, REPO_ROOT) with held_slot(): - cmd_update(args.base) if args.update else cmd_check(args.base) + cmd_update(base_ref) if args.update else cmd_check(base_ref) if __name__ == "__main__": diff --git a/terraform/provider/RELEASING.md b/terraform/provider/RELEASING.md index 59f4c5f066c..5b72621c291 100644 --- a/terraform/provider/RELEASING.md +++ b/terraform/provider/RELEASING.md @@ -79,7 +79,7 @@ Before publishing to the Terraform Registry: ## What a change needs -1. **Land it in `BerriAI/litellm`.** Open a PR against `litellm_internal_staging` with the source change and a `CHANGELOG.md` entry under `[Unreleased]`. CI runs `gofmt`, `go vet`, build, tests and the endpoint-drift audit. A change that breaks existing configurations or state must say so in the changelog: the version number cannot signal it any more +1. **Land it in `BerriAI/litellm`.** Open a PR against the repository's current default branch with the source change and a `CHANGELOG.md` entry under `[Unreleased]`. CI runs `gofmt`, `go vet`, build, tests and the endpoint-drift audit. A change that breaks existing configurations or state must say so in the changelog: the version number cannot signal it any more 2. **Wait for the next LiteLLM release.** The nightly dev release carries it within a day; it reaches a stable version on the next stable cut 3. **Verify** (optional): the version appears at https://registry.terraform.io/providers/BerriAI/litellm and https://github.com/BerriAI/terraform-provider-litellm/releases. If the tag is on the mirror but there is no release, the goreleaser run failed: https://github.com/BerriAI/terraform-provider-litellm/actions diff --git a/tests/test_litellm/test_default_branch.py b/tests/test_litellm/test_default_branch.py new file mode 100644 index 00000000000..ac673894067 --- /dev/null +++ b/tests/test_litellm/test_default_branch.py @@ -0,0 +1,212 @@ +import os +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Final + +import pytest + +ROOT: Final = Path(__file__).resolve().parents[2] + + +def _git(repo: Path, *args: str) -> str: + return subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True, text=True).stdout.strip() + + +def _commit(repo: Path, message: str) -> None: + _git(repo, "add", ".") + _git(repo, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", message) + + +@pytest.fixture +def remote_and_clone(tmp_path: Path) -> tuple[Path, Path]: + seed: Final = tmp_path / "seed" + seed.mkdir() + _git(seed, "init", "-q", "-b", "litellm_internal_staging") + (seed / "scripts").mkdir() + for name in ( + "default_branch.py", + "budget_ratchet_check.py", + "ruff_strict_gate.py", + "type_discipline_gate.py", + "test_quality_gate.py", + "type_check_gate.py", + "gate_slot_lock.py", + ): + shutil.copyfile(ROOT / "scripts" / name, seed / "scripts" / name) + shutil.copyfile(ROOT / "Makefile", seed / "Makefile") + (seed / "litellm").mkdir() + (seed / "litellm" / "example.py").write_text("value = 0\n") + (seed / "ruff-strict-budget.json").write_text('{"C901": {"limit": 1}}\n') + _commit(seed, "staging base") + _git(seed, "checkout", "-qb", "main") + (seed / "litellm" / "example.py").write_text("value = 1\n") + (seed / "ruff-strict-budget.json").write_text('{"C901": {"limit": 0}}\n') + _commit(seed, "main base") + remote: Final = tmp_path / "remote.git" + _git(tmp_path, "clone", "-q", "--bare", str(seed), str(remote)) + _git(remote, "symbolic-ref", "HEAD", "refs/heads/litellm_internal_staging") + repo: Final = tmp_path / "clone" + _git(tmp_path, "clone", "-q", "--single-branch", str(remote), str(repo)) + return remote, repo + + +def _resolve(repo: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(ROOT / "scripts" / "default_branch.py"), *args], + cwd=repo, + capture_output=True, + text=True, + check=False, + ) + + +def _make(repo: Path, target: str, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["make", target, "LINT_DEP_INSTALL=", "LINT_DEP_BASE=", *args], + cwd=repo, + capture_output=True, + text=True, + check=False, + env={key: value for key, value in os.environ.items() if key != "BASE_REF"}, + ) + + +def test_existing_single_branch_clone_follows_remote_switch(remote_and_clone: tuple[Path, Path]) -> None: + remote, repo = remote_and_clone + before: Final = _resolve(repo) + assert before.returncode == 0, before.stderr + assert before.stdout.strip() == "origin/litellm_internal_staging" + _git(remote, "symbolic-ref", "HEAD", "refs/heads/main") + after: Final = _resolve(repo) + assert after.returncode == 0, after.stderr + assert after.stdout.strip() == "origin/main" + assert _git(repo, "rev-parse", "origin/main") == _git(remote, "rev-parse", "main") + assert _git(repo, "symbolic-ref", "refs/remotes/origin/HEAD").endswith("/litellm_internal_staging") + + +@pytest.mark.parametrize("missing_head", [False, True]) +def test_unverifiable_default_never_uses_cached_head( + remote_and_clone: tuple[Path, Path], + missing_head: bool, +) -> None: + remote, repo = remote_and_clone + if missing_head: + _git(remote, "symbolic-ref", "HEAD", "refs/heads/missing") + else: + _git(repo, "remote", "set-url", "origin", str(remote / "missing")) + result: Final = _resolve(repo) + assert result.returncode != 0 + assert not result.stdout + assert "explicit base ref" in result.stderr + checked: Final = _make(repo, "lint-format-check-changed") + assert checked.returncode != 0 + assert "No changed" not in checked.stdout + + +@pytest.mark.parametrize("base_ref", ["HEAD", "origin/litellm_internal_staging"]) +def test_explicit_base_works_without_remote_access( + remote_and_clone: tuple[Path, Path], + base_ref: str, +) -> None: + remote, repo = remote_and_clone + _git(repo, "remote", "set-url", "origin", str(remote / "missing")) + result: Final = _resolve(repo, "--base", base_ref) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == base_ref + checked: Final = _make(repo, "lint-format-check-changed", f"BASE_REF={base_ref}") + assert checked.returncode == 0, checked.stderr + assert "No changed litellm Python files" in checked.stdout + + +def test_budget_ratchet_compares_against_new_default(remote_and_clone: tuple[Path, Path]) -> None: + remote, repo = remote_and_clone + _git(remote, "symbolic-ref", "HEAD", "refs/heads/main") + resolved: Final = _resolve(repo) + assert resolved.returncode == 0, resolved.stderr + _git(repo, "checkout", "-qb", "litellm_feature", "origin/main") + (repo / "ruff-strict-budget.json").write_text('{"C901": {"limit": 1}}\n') + command: Final = [sys.executable, "scripts/budget_ratchet_check.py"] + checked: Final = subprocess.run(command, cwd=repo, capture_output=True, text=True, check=False) + assert checked.returncode == 1 + assert "limit raised 0 -> 1" in checked.stdout + assert "base origin/main" in checked.stdout + overridden: Final = subprocess.run( + [*command, "--base", "origin/litellm_internal_staging"], + cwd=repo, + capture_output=True, + text=True, + check=False, + ) + assert overridden.returncode == 0, overridden.stdout + overridden.stderr + + +def _freshness(repo: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + "-c", + "import sys; from pathlib import Path; " + "from ci_cd.run_migration import _check_branch_freshness; " + "_check_branch_freshness(Path(sys.argv[1]), sys.argv[2] if len(sys.argv) > 2 else None)", + str(repo), + *args, + ], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + + +def test_migration_freshness_refuses_stale_branch_after_switch(remote_and_clone: tuple[Path, Path]) -> None: + remote, repo = remote_and_clone + before: Final = _freshness(repo) + assert before.returncode == 0, before.stderr + assert "Branch freshness OK" in before.stdout + _git(remote, "symbolic-ref", "HEAD", "refs/heads/main") + after: Final = _freshness(repo) + assert after.returncode == 3 + assert "1 commit(s) behind origin/main" in after.stderr + overridden: Final = _freshness(repo, "litellm_internal_staging") + assert overridden.returncode == 0, overridden.stderr + _git(repo, "merge", "--ff-only", "origin/main") + updated: Final = _freshness(repo) + assert updated.returncode == 0, updated.stderr + assert "up to date with origin/main" in updated.stdout + + +def test_migration_freshness_refuses_unavailable_remote(remote_and_clone: tuple[Path, Path]) -> None: + remote, repo = remote_and_clone + _git(repo, "remote", "set-url", "origin", str(remote / "missing")) + result: Final = _freshness(repo) + assert result.returncode == 3 + assert "Could not discover origin's default branch" in result.stderr + explicit: Final = _freshness(repo, "litellm_internal_staging") + assert explicit.returncode == 3 + assert "git fetch origin litellm_internal_staging" in explicit.stderr + + +@pytest.mark.parametrize( + "gate", + [ + "budget_ratchet_check", + "ruff_strict_gate", + "type_discipline_gate", + "test_quality_gate", + "type_check_gate", + ], +) +def test_each_gate_refuses_an_unverifiable_default(remote_and_clone: tuple[Path, Path], gate: str) -> None: + remote, repo = remote_and_clone + _git(repo, "remote", "set-url", "origin", str(remote / "missing")) + result: Final = subprocess.run( + [sys.executable, f"scripts/{gate}.py"], + cwd=repo, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode != 0 + assert "Cannot verify the base branch against origin" in result.stderr diff --git a/tests/test_litellm/test_pre_commit_lint.py b/tests/test_litellm/test_pre_commit_lint.py index b84cb8aa657..e12da0833dc 100644 --- a/tests/test_litellm/test_pre_commit_lint.py +++ b/tests/test_litellm/test_pre_commit_lint.py @@ -159,12 +159,12 @@ def _commit_all(repo: Path, message: str) -> None: ) -def _set_base_ref(repo: Path) -> None: - subprocess.run( - ["git", "update-ref", "refs/remotes/origin/litellm_internal_staging", "HEAD"], - cwd=repo, - check=True, - ) +def _set_base_ref(repo: Path, branch: str = "litellm_internal_staging") -> None: + remote = repo.parent / "remote.git" + subprocess.run(["git", "clone", "-q", "--bare", str(repo), str(remote)], check=True) + subprocess.run(["git", "update-ref", f"refs/heads/{branch}", "HEAD"], cwd=remote, check=True) + subprocess.run(["git", "symbolic-ref", "HEAD", f"refs/heads/{branch}"], cwd=remote, check=True) + subprocess.run(["git", "remote", "add", "origin", str(remote)], cwd=repo, check=True) def _stage_file(repo: Path, relative: str, body: str) -> None: @@ -174,10 +174,11 @@ def _stage_file(repo: Path, relative: str, body: str) -> None: subprocess.run(["git", "add", relative], cwd=repo, check=True) -def test_nothing_staged_scopes_to_working_tree_diff_and_runs_checks(tmp_path: Path) -> None: +@pytest.mark.parametrize("branch", ["litellm_internal_staging", "main"]) +def test_nothing_staged_scopes_to_working_tree_diff_and_runs_checks(tmp_path: Path, branch: str) -> None: repo, bin_dir = _sandbox(tmp_path) _commit_all(repo, "base") - _set_base_ref(repo) + _set_base_ref(repo, branch) (repo / "litellm" / "foo.py").write_text("x = 2\n") proc = _run(repo, bin_dir, {}) assert proc.returncode == 0, proc.stdout + proc.stderr @@ -261,8 +262,8 @@ def test_nothing_staged_without_a_base_ref_fails_with_a_fetch_hint(tmp_path: Pat _commit_all(repo, "base") proc = _run(repo, bin_dir, {}) assert proc.returncode == 1 - assert "cannot resolve the merge base" in proc.stdout - assert "git fetch origin litellm_internal_staging" in proc.stdout + assert "Cannot verify the base branch against origin" in proc.stdout + assert "explicit base ref" in proc.stdout assert "check: FAIL" in proc.stdout @@ -622,3 +623,28 @@ def test_failing_run_ends_with_a_fail_verdict(tmp_path: Path) -> None: assert proc.returncode == 1 assert "check: FAIL" in proc.stdout assert "check: PASS" not in proc.stdout + + + +def test_explicit_base_scopes_offline_without_a_remote(tmp_path: Path) -> None: + repo, bin_dir = _sandbox(tmp_path) + _commit_all(repo, "base") + (repo / "litellm" / "foo.py").write_text("x = 2\n") + proc = _run(repo, bin_dir, {"BASE_REF": "HEAD"}) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert "merge base with HEAD" in proc.stdout + assert "linting Python" in proc.stdout + + +def test_symlinked_hook_can_resolve_default_branch(tmp_path: Path) -> None: + repo, bin_dir = _sandbox(tmp_path) + _commit_all(repo, "base") + _set_base_ref(repo, "main") + hook = repo / ".git" / "hooks" / "pre-commit" + hook.symlink_to(SCRIPT) + proc = subprocess.run( + [str(hook)], cwd=repo, capture_output=True, text=True, + env=_env(repo, bin_dir, {}), timeout=120, + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert "no branch changes vs origin/main" in proc.stdout