forked from bkinnightskytw/code_work_spawner
chore: add hook which check rebased before push
This commit is contained in:
parent
cf908184c7
commit
6931b85682
|
|
@ -7,10 +7,14 @@ on:
|
|||
paths:
|
||||
- "lib/**"
|
||||
- "bin/**"
|
||||
- "test/**"
|
||||
- "pubspec.yaml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "lib/**"
|
||||
- "bin/**"
|
||||
- "test/**"
|
||||
- "pubspec.yaml"
|
||||
|
||||
jobs:
|
||||
dart:
|
||||
|
|
|
|||
|
|
@ -16,3 +16,4 @@ agent-orchestrator.yaml
|
|||
/CWS*.yaml
|
||||
/CWS*.yml
|
||||
dart_test.yaml
|
||||
__pycache__
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
@ -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));
|
||||
}
|
||||
Loading…
Reference in New Issue