diff --git a/.github/workflows/unittest.yaml b/.github/workflows/unittest.yaml index f7396e2..5d32883 100644 --- a/.github/workflows/unittest.yaml +++ b/.github/workflows/unittest.yaml @@ -7,10 +7,14 @@ on: paths: - "lib/**" - "bin/**" + - "test/**" + - "pubspec.yaml" pull_request: paths: - "lib/**" - "bin/**" + - "test/**" + - "pubspec.yaml" jobs: dart: diff --git a/.gitignore b/.gitignore index 9b1e487..05ab6cb 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ agent-orchestrator.yaml /CWS*.yaml /CWS*.yml dart_test.yaml +__pycache__ \ No newline at end of file diff --git a/lefthook.yml b/lefthook.yml index 923993e..f4a8586 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,3 +1,5 @@ +skip_lfs: true + pre-commit: commands: format: @@ -6,3 +8,8 @@ pre-commit: run: dart analyze # test: # run: dart test + +pre-push: + commands: + require-latest-main: + run: uv run scripts/check_branch_up_to_date.py diff --git a/scripts/check_branch_up_to_date.py b/scripts/check_branch_up_to_date.py new file mode 100644 index 0000000..989b561 --- /dev/null +++ b/scripts/check_branch_up_to_date.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 + +import argparse +import asyncio +import select +import sys + + +async def run_git( + args: list[str], + check: bool = True, + timeout: int | None = None, +) -> tuple[int, str, str]: + process = await asyncio.create_subprocess_exec( + "git", + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout, stderr = await asyncio.wait_for( + process.communicate(), + timeout=timeout, + ) + except asyncio.CancelledError: + process.kill() + await process.wait() + raise + except asyncio.TimeoutError: + process.kill() + await process.wait() + raise + + result = ( + process.returncode or 0, + stdout.decode(), + stderr.decode(), + ) + if check and result[0] != 0: + raise RuntimeError(result[2].strip() or f"git {' '.join(args)} failed") + return result + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Verify current branch includes the latest main branch commit before push." + ) + ) + parser.add_argument( + "--remote", + default="auto", + help="Remote to compare against (default: auto -> upstream, else origin).", + ) + parser.add_argument( + "--base-branch", + default="main", + help="Base branch to validate against (default: main).", + ) + parser.add_argument( + "--fetch-timeout-seconds", + type=int, + default=60, + help="Timeout for git fetch in seconds (default: 60).", + ) + return parser.parse_args() + + +def pushed_local_refs_from_stdin() -> list[str]: + if sys.stdin is None or sys.stdin.closed or sys.stdin.isatty(): + return [] + + try: + ready, _, _ = select.select([sys.stdin], [], [], 0.05) + except (OSError, ValueError): + return [] + + if not ready: + return [] + + payload = sys.stdin.read() + refs: list[str] = [] + for raw_line in payload.splitlines(): + line = raw_line.strip() + if not line: + continue + parts = line.split(maxsplit=3) + if len(parts) < 4: + continue + refs.append(parts[0]) + return refs + + +def is_tag_only_push(local_refs: list[str]) -> bool: + if not local_refs: + return False + return all(ref.startswith("refs/tags/") for ref in local_refs) + + +async def get_current_branch() -> str | None: + returncode, stdout, _ = await run_git( + ["symbolic-ref", "--quiet", "--short", "HEAD"], + check=False, + ) + if returncode != 0: + return None + return stdout.strip() + + +async def choose_remote(remote_arg: str) -> str: + if remote_arg != "auto": + return remote_arg + + _, stdout, _ = await run_git(["remote"]) + remotes = stdout.splitlines() + if "upstream" in remotes: + return "upstream" + return "origin" + + +async def fetch_base(remote: str, base_branch: str, timeout_seconds: int) -> None: + try: + returncode, _, stderr = await run_git( + [ + "fetch", + "--quiet", + "--no-tags", + "--recurse-submodules=no", + remote, + f"+refs/heads/{base_branch}:refs/remotes/{remote}/{base_branch}", + ], + check=False, + timeout=timeout_seconds, + ) + except asyncio.TimeoutError as exc: + raise RuntimeError(f"git fetch timed out after {timeout_seconds}s") from exc + if returncode != 0: + message = stderr.strip() or f"git fetch {remote} {base_branch} failed" + raise RuntimeError(message) + + +async def remote_ref_exists(remote: str, base_branch: str) -> bool: + remote_ref = f"refs/remotes/{remote}/{base_branch}" + returncode, _, _ = await run_git( + ["show-ref", "--verify", "--quiet", remote_ref], + check=False, + ) + return returncode == 0 + + +async def is_base_ancestor_of_head(base_ref: str) -> bool: + returncode, _, _ = await run_git( + ["merge-base", "--is-ancestor", base_ref, "HEAD"], + check=False, + ) + return returncode == 0 + + +async def ahead_behind(base_ref: str) -> tuple[int, int]: + _, stdout, _ = await run_git( + ["rev-list", "--left-right", "--count", f"HEAD...{base_ref}"] + ) + parts = stdout.strip().split() + if len(parts) != 2: + return 0, 0 + ahead = int(parts[0]) + behind = int(parts[1]) + return ahead, behind + + +async def cancel_task(task: asyncio.Task[object]) -> None: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + +async def main() -> int: + args = parse_args() + local_refs = pushed_local_refs_from_stdin() + if is_tag_only_push(local_refs): + return 0 + + branch_task = asyncio.create_task(get_current_branch()) + remote_task = asyncio.create_task(choose_remote(args.remote)) + + branch = await branch_task + if branch is None: + await cancel_task(remote_task) + print( + "[pre-push] Detached HEAD detected; skipping base branch freshness check." + ) + return 0 + + if branch == args.base_branch: + await cancel_task(remote_task) + return 0 + + remote = await remote_task + try: + await fetch_base(remote, args.base_branch, args.fetch_timeout_seconds) + except RuntimeError as exc: + print( + f"[pre-push] Failed to fetch {remote}/{args.base_branch}: {exc}", + file=sys.stderr, + ) + return 1 + + if not await remote_ref_exists(remote, args.base_branch): + print( + f"[pre-push] Missing remote tracking ref {remote}/{args.base_branch}.", + file=sys.stderr, + ) + return 1 + + base_ref = f"{remote}/{args.base_branch}" + if await is_base_ancestor_of_head(base_ref): + return 0 + + ahead, behind = await ahead_behind(base_ref) + print( + f"[pre-push] Branch '{branch}' is not up to date with {base_ref} " + f"(ahead={ahead}, behind={behind}).", + file=sys.stderr, + ) + print( + "[pre-push] Rebase before pushing:", + file=sys.stderr, + ) + print( + f" git fetch {remote} {args.base_branch}", + file=sys.stderr, + ) + print( + f" git rebase {base_ref}", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/test/scripts/check_branch_up_to_date_test.dart b/test/scripts/check_branch_up_to_date_test.dart new file mode 100644 index 0000000..8507b95 --- /dev/null +++ b/test/scripts/check_branch_up_to_date_test.dart @@ -0,0 +1,259 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +import '../support/git_utils.dart'; + +/// ```gherkin +/// Feature: Pre-push branch freshness check +/// +/// As a contributor to Code Work Spawner +/// I want the pre-push hook to validate feature branches against the latest main +/// So that stale branches are blocked without slowing down exempt push paths +/// ``` +void main() { + /// ```gherkin + /// Scenario: Accept a feature branch that includes the latest main + /// Given a feature branch based on the remote main branch + /// When running the pre-push freshness check + /// Then the script exits successfully + /// ``` + test('Accept a feature branch that includes the latest main', () async { + // Given a feature branch based on the remote main branch. + final fixture = await _GitFixture.create(); + addTearDown(fixture.dispose); + await fixture.checkoutFeatureBranch(); + await fixture.commitFile('feature.txt', 'feature'); + + // When running the pre-push freshness check. + final result = await fixture.runScript(); + + // Then the script exits successfully. + expect(result.exitCode, 0); + expect(result.stderr, isEmpty); + }); + + /// ```gherkin + /// Scenario: Reject a feature branch that is behind latest main + /// Given a feature branch that does not include a newer remote main commit + /// When running the pre-push freshness check + /// Then the script exits with rebase guidance + /// ``` + test('Reject a feature branch that is behind latest main', () async { + // Given a feature branch that does not include a newer remote main commit. + final fixture = await _GitFixture.create(); + addTearDown(fixture.dispose); + await fixture.checkoutFeatureBranch(); + await fixture.commitFile('feature.txt', 'feature'); + await fixture.advanceRemoteMain(); + + // When running the pre-push freshness check. + final result = await fixture.runScript(); + + // Then the script exits with rebase guidance. + expect(result.exitCode, 1); + expect( + result.stderr, + contains( + "[pre-push] Branch 'feature' is not up to date with origin/main", + ), + ); + expect(result.stderr, contains('git rebase origin/main')); + }); + + /// ```gherkin + /// Scenario: Skip the check when pushing main + /// Given the current branch is main and the configured remote is invalid + /// When running the pre-push freshness check + /// Then the script exits successfully without fetching + /// ``` + test('Skip the check when pushing main', () async { + // Given the current branch is main and the configured remote is invalid. + final fixture = await _GitFixture.create(); + addTearDown(fixture.dispose); + + // When running the pre-push freshness check. + final result = await fixture.runScript(['--remote', 'missing']); + + // Then the script exits successfully without fetching. + expect(result.exitCode, 0); + expect(result.stderr, isEmpty); + }); + + /// ```gherkin + /// Scenario: Skip the check for tag-only pushes + /// Given pre-push input only contains tag refs + /// When running the pre-push freshness check outside a Git repository + /// Then the script exits successfully before inspecting Git state + /// ``` + test('Skip the check for tag-only pushes', () async { + // Given pre-push input only contains tag refs. + final directory = await Directory.systemTemp.createTemp( + 'check_branch_up_to_date_tag_', + ); + addTearDown(() => directory.delete(recursive: true)); + const stdin = 'refs/tags/v1 abc refs/tags/v1 def\n'; + + // When running the pre-push freshness check outside a Git repository. + final result = await _runScript( + workingDirectory: directory.path, + stdin: stdin, + ); + + // Then the script exits successfully before inspecting Git state. + expect(result.exitCode, 0); + expect(result.stderr, isEmpty); + }); + + /// ```gherkin + /// Scenario: Skip the check in detached HEAD state + /// Given the repository is in detached HEAD state and the configured remote is invalid + /// When running the pre-push freshness check + /// Then the script exits successfully with a detached HEAD message + /// ``` + test('Skip the check in detached HEAD state', () async { + // Given the repository is in detached HEAD state and the configured remote is invalid. + final fixture = await _GitFixture.create(); + addTearDown(fixture.dispose); + await runGit([ + 'checkout', + '--detach', + 'HEAD', + ], workingDirectory: fixture.repoPath); + + // When running the pre-push freshness check. + final result = await fixture.runScript(['--remote', 'missing']); + + // Then the script exits successfully with a detached HEAD message. + expect(result.exitCode, 0); + expect( + result.stdout, + contains( + '[pre-push] Detached HEAD detected; skipping base branch freshness check.', + ), + ); + expect(result.stderr, isEmpty); + }); + + /// ```gherkin + /// Scenario: Fail closed when fetch fails + /// Given a feature branch with an invalid configured remote + /// When running the pre-push freshness check + /// Then the script exits with a fetch failure + /// ``` + test('Fail closed when fetch fails', () async { + // Given a feature branch with an invalid configured remote. + final fixture = await _GitFixture.create(); + addTearDown(fixture.dispose); + await fixture.checkoutFeatureBranch(); + + // When running the pre-push freshness check. + final result = await fixture.runScript(['--remote', 'missing']); + + // Then the script exits with a fetch failure. + expect(result.exitCode, 1); + expect(result.stderr, contains('[pre-push] Failed to fetch missing/main:')); + }); +} + +class _GitFixture { + _GitFixture({required this.root, required this.repoPath}); + + final Directory root; + final String repoPath; + + static Future<_GitFixture> create() async { + final root = await Directory.systemTemp.createTemp( + 'check_branch_up_to_date_', + ); + final remotePath = p.join(root.path, 'origin.git'); + final repoPath = p.join(root.path, 'repo'); + + await Directory(repoPath).create(); + await runGit(['init', '--initial-branch=main'], workingDirectory: repoPath); + await runGit([ + 'config', + 'user.email', + 'test@example.com', + ], workingDirectory: repoPath); + await runGit([ + 'config', + 'user.name', + 'Test User', + ], workingDirectory: repoPath); + await File(p.join(repoPath, 'README.md')).writeAsString('repo'); + await runGit(['add', '.'], workingDirectory: repoPath); + await runGit(['commit', '-m', 'init'], workingDirectory: repoPath); + + await runGit([ + 'init', + '--bare', + '--initial-branch=main', + remotePath, + ], workingDirectory: root.path); + await runGit([ + 'remote', + 'add', + 'origin', + remotePath, + ], workingDirectory: repoPath); + await runGit(['push', '-u', 'origin', 'main'], workingDirectory: repoPath); + + return _GitFixture(root: root, repoPath: repoPath); + } + + Future checkoutFeatureBranch() async { + await runGit(['checkout', '-b', 'feature'], workingDirectory: repoPath); + } + + Future commitFile(String relativePath, String contents) async { + await File(p.join(repoPath, relativePath)).writeAsString(contents); + await runGit(['add', relativePath], workingDirectory: repoPath); + await runGit(['commit', '-m', 'test change'], workingDirectory: repoPath); + } + + Future advanceRemoteMain() async { + await runGit(['checkout', 'main'], workingDirectory: repoPath); + await commitFile('main.txt', 'main'); + await runGit(['push', 'origin', 'main'], workingDirectory: repoPath); + await runGit(['checkout', 'feature'], workingDirectory: repoPath); + } + + Future runScript([List arguments = const []]) { + return _runScript(workingDirectory: repoPath, arguments: arguments); + } + + Future dispose() { + return root.delete(recursive: true); + } +} + +Future _runScript({ + required String workingDirectory, + List arguments = const [], + String? stdin, +}) async { + final process = await Process.start( + 'python3', + [ + p.join(Directory.current.path, 'scripts', 'check_branch_up_to_date.py'), + ...arguments, + ], + workingDirectory: workingDirectory, + environment: _cleanGitEnvironment(), + ); + if (stdin != null) { + process.stdin.write(stdin); + } + await process.stdin.close(); + final stdout = await process.stdout.transform(systemEncoding.decoder).join(); + final stderr = await process.stderr.transform(systemEncoding.decoder).join(); + final exitCode = await process.exitCode; + return ProcessResult(process.pid, exitCode, stdout, stderr); +} + +Map _cleanGitEnvironment() { + return Map.from(Platform.environment) + ..removeWhere((key, _) => gitContextEnvironmentKeys.contains(key)); +}