chore: add hook which check rebased before push
This commit is contained in:
parent
cf908184c7
commit
6931b85682
|
|
@ -7,10 +7,14 @@ on:
|
||||||
paths:
|
paths:
|
||||||
- "lib/**"
|
- "lib/**"
|
||||||
- "bin/**"
|
- "bin/**"
|
||||||
|
- "test/**"
|
||||||
|
- "pubspec.yaml"
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- "lib/**"
|
- "lib/**"
|
||||||
- "bin/**"
|
- "bin/**"
|
||||||
|
- "test/**"
|
||||||
|
- "pubspec.yaml"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
dart:
|
dart:
|
||||||
|
|
|
||||||
|
|
@ -16,3 +16,4 @@ agent-orchestrator.yaml
|
||||||
/CWS*.yaml
|
/CWS*.yaml
|
||||||
/CWS*.yml
|
/CWS*.yml
|
||||||
dart_test.yaml
|
dart_test.yaml
|
||||||
|
__pycache__
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
skip_lfs: true
|
||||||
|
|
||||||
pre-commit:
|
pre-commit:
|
||||||
commands:
|
commands:
|
||||||
format:
|
format:
|
||||||
|
|
@ -6,3 +8,8 @@ pre-commit:
|
||||||
run: dart analyze
|
run: dart analyze
|
||||||
# test:
|
# test:
|
||||||
# run: dart 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