chore: add hook which check rebased before push

This commit is contained in:
insleker 2026-04-12 01:14:50 +08:00
parent cf908184c7
commit 6931b85682
5 changed files with 511 additions and 0 deletions

View File

@ -7,10 +7,14 @@ on:
paths:
- "lib/**"
- "bin/**"
- "test/**"
- "pubspec.yaml"
pull_request:
paths:
- "lib/**"
- "bin/**"
- "test/**"
- "pubspec.yaml"
jobs:
dart:

1
.gitignore vendored
View File

@ -16,3 +16,4 @@ agent-orchestrator.yaml
/CWS*.yaml
/CWS*.yml
dart_test.yaml
__pycache__

View File

@ -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

View File

@ -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()))

View File

@ -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<void> checkoutFeatureBranch() async {
await runGit(['checkout', '-b', 'feature'], workingDirectory: repoPath);
}
Future<void> 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<void> 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<ProcessResult> runScript([List<String> arguments = const []]) {
return _runScript(workingDirectory: repoPath, arguments: arguments);
}
Future<void> dispose() {
return root.delete(recursive: true);
}
}
Future<ProcessResult> _runScript({
required String workingDirectory,
List<String> 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<String, String> _cleanGitEnvironment() {
return Map<String, String>.from(Platform.environment)
..removeWhere((key, _) => gitContextEnvironmentKeys.contains(key));
}