Skip to content

Commit f3917b3

Browse files
committed
update-checkout: Support specifying --config multiple times
Passing additional --config flags will load each config file in turn and uses the last value for any defined config. This allows having local configs that only define personal schemes and still having access to the default schemes. This also makes it easier to work with machine generated configs without having to cause churn in the main config file.
1 parent 1fd0561 commit f3917b3

File tree

4 files changed

+162
-7
lines changed

4 files changed

+162
-7
lines changed

utils/update_checkout/tests/scheme_mock.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,21 @@
6262
}
6363
}
6464

65+
MOCK_ADDITIONAL_SCHEME = {
66+
'branch-schemes': {
67+
'extra': {
68+
'aliases': ['extra'],
69+
'repos': {
70+
# Spell this differently just to make it distinguishable in
71+
# test output, even though we only have one branch.
72+
# TODO: Support multiple test branches in the repo instead.
73+
'repo1': 'refs/heads/main',
74+
'repo2': 'refs/heads/main',
75+
}
76+
}
77+
}
78+
}
79+
6580

6681
class CallQuietlyException(Exception):
6782
def __init__(self, command, returncode, output):
@@ -97,6 +112,10 @@ def get_config_path(base_dir):
97112
return os.path.join(base_dir, 'test-config.json')
98113

99114

115+
def get_additional_config_path(base_dir):
116+
return os.path.join(base_dir, 'test-additional-config.json')
117+
118+
100119
def setup_mock_remote(base_dir, base_config):
101120
create_dir(base_dir)
102121

@@ -140,6 +159,9 @@ def setup_mock_remote(base_dir, base_config):
140159
with open(get_config_path(base_dir), 'w') as f:
141160
json.dump(base_config, f)
142161

162+
with open(get_additional_config_path(base_dir), 'w') as f:
163+
json.dump(MOCK_ADDITIONAL_SCHEME, f)
164+
143165
return (LOCAL_PATH, REMOTE_PATH)
144166

145167

@@ -162,6 +184,7 @@ def __init__(self, *args, **kwargs):
162184
raise RuntimeError('Misconfigured test suite! Environment '
163185
'variable %s must be set!' % BASEDIR_ENV_VAR)
164186
self.config_path = get_config_path(self.workspace)
187+
self.additional_config_path = get_additional_config_path(self.workspace)
165188
self.update_checkout_path = UPDATE_CHECKOUT_PATH
166189
if not os.access(self.update_checkout_path, os.X_OK):
167190
raise RuntimeError('Error! Could not find executable '

utils/update_checkout/tests/test_clone.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,17 @@ def test_simple_clone(self):
3030
repo_path = os.path.join(self.source_root, repo)
3131
self.assertTrue(os.path.isdir(repo_path))
3232

33+
def test_clone_with_additional_scheme(self):
34+
output = self.call([self.update_checkout_path,
35+
'--config', self.config_path,
36+
'--config', self.additional_config_path,
37+
'--source-root', self.source_root,
38+
'--clone',
39+
'--scheme', 'extra'])
40+
41+
# Test that we're actually checking out the 'extra' scheme based on the output
42+
self.assertIn(b"git checkout refs/heads/main", output)
43+
3344

3445
class SchemeWithMissingRepoTestCase(scheme_mock.SchemeMockTestCase):
3546
def __init__(self, *args, **kwargs):
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# ===--- test_merge_config.py ---------------------------------------------===#
2+
#
3+
# This source file is part of the Swift.org open source project
4+
#
5+
# Copyright (c) 2025 Apple Inc. and the Swift project authors
6+
# Licensed under Apache License v2.0 with Runtime Library Exception
7+
#
8+
# See https:#swift.org/LICENSE.txt for license information
9+
# See https:#swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
#
11+
# ===----------------------------------------------------------------------===#
12+
13+
import unittest
14+
15+
from update_checkout.update_checkout import merge_config, merge_no_duplicates
16+
17+
18+
class MergeTestCase(unittest.TestCase):
19+
def test_no_duplicates(self):
20+
self.assertEqual(merge_no_duplicates({}, {}), {})
21+
self.assertEqual(merge_no_duplicates({"a": 1}, {"b": 2}), {"a": 1, "b": 2})
22+
with self.assertRaises(ValueError):
23+
merge_no_duplicates({"a": 1, "b": 2}, {"b": 3})
24+
25+
def test_merge_config(self):
26+
default_config = {
27+
"ssh-clone-pattern": "git@1",
28+
"https-clone-pattern": "https://1",
29+
"repos": {
30+
"swift": {"remote": {"id": "swiftlang/swift"}},
31+
"llvm-project": {"remote": {"id": "swiftlang/llvm-project"}},
32+
},
33+
"default-branch-scheme": "main",
34+
"branch-schemes": {
35+
"main": {
36+
"aliases": ["swift/main", "main", "stable/20240723"],
37+
},
38+
},
39+
}
40+
41+
self.assertEqual(merge_config(default_config, {
42+
"note": "this is machine generated or something",
43+
"ssh-clone-pattern": "git@2",
44+
"repos": {
45+
"llvm-project": {"remote": {"id": "blah/llvm-project"}},
46+
"swift-syntax": {"remote": {"id": "swiftlang/swift-syntax"}},
47+
},
48+
"default-branch-scheme": "bonus",
49+
"branch-schemes": {
50+
"bonus": {
51+
"aliases": ["bonus", "also-bonus"],
52+
},
53+
},
54+
}), {
55+
"ssh-clone-pattern": "git@2",
56+
"https-clone-pattern": "https://1",
57+
"repos": {
58+
"swift": {"remote": {"id": "swiftlang/swift"}},
59+
"llvm-project": {"remote": {"id": "blah/llvm-project"}},
60+
"swift-syntax": {"remote": {"id": "swiftlang/swift-syntax"}},
61+
},
62+
"default-branch-scheme": "bonus",
63+
"branch-schemes": {
64+
"main": {
65+
"aliases": ["swift/main", "main", "stable/20240723"],
66+
},
67+
"bonus": {
68+
"aliases": ["bonus", "also-bonus"],
69+
},
70+
},
71+
"note": "this is machine generated or something",
72+
})
73+
74+
with self.assertRaises(ValueError):
75+
merge_config(default_config, default_config)

utils/update_checkout/update_checkout/update_checkout.py

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,40 @@ def print_repo_hashes(args, config):
501501
print("{:<35}: {:<35}".format(repo_name, repo_hash))
502502

503503

504+
def merge_no_duplicates(a: dict, b: dict) -> dict:
505+
result = {**a}
506+
for key, value in b.items():
507+
if key in a:
508+
raise ValueError(f"Duplicate scheme {key}")
509+
510+
result[key] = value
511+
return result
512+
513+
514+
def merge_config(config: dict, new_config: dict) -> dict:
515+
"""
516+
Merge two configs, with a 'last-wins' strategy.
517+
518+
The branch-schemes are rejected if they define duplicate schemes.
519+
"""
520+
521+
result = {**config}
522+
for key, value in new_config.items():
523+
if key == "branch-schemes":
524+
# We reject duplicates here since this is the most conservative
525+
# behavior, so it can be relaxed in the future.
526+
# TODO: Another semantics might be nicer, define that as it is needed.
527+
result[key] = merge_no_duplicates(config.get(key, {}), value)
528+
elif key == "repos":
529+
# The "repos" object is last-wins on a key-by-key basis
530+
result[key] = {**config.get(key, {}), **value}
531+
else:
532+
# Anything else is just last-wins
533+
result[key] = value
534+
535+
return result
536+
537+
504538
def validate_config(config):
505539
# Make sure that our branch-names are unique.
506540
scheme_names = config['branch-schemes'].keys()
@@ -634,9 +668,12 @@ def main():
634668
)
635669
parser.add_argument(
636670
"--config",
637-
default=os.path.join(SCRIPT_DIR, os.pardir,
638-
"update-checkout-config.json"),
639-
help="Configuration file to use")
671+
help="""The configuration file to use. Can be specified multiple times,
672+
each config will be merged together with a 'last-wins' strategy.
673+
Overwriting branch-schemes is not allowed.""",
674+
action="append",
675+
default=[],
676+
dest="configs")
640677
parser.add_argument(
641678
"--github-comment",
642679
help="""Check out related pull requests referenced in the given
@@ -694,8 +731,15 @@ def main():
694731
github_comment = args.github_comment
695732
all_repos = args.all_repositories
696733

697-
with open(args.config) as f:
698-
config = json.load(f)
734+
# Set the default config path if none are specified
735+
if not args.configs:
736+
default_path = os.path.join(SCRIPT_DIR, os.pardir,
737+
"update-checkout-config.json")
738+
args.configs.append(default_path)
739+
config = {}
740+
for config_path in args.configs:
741+
with open(config_path) as f:
742+
config = merge_config(config, json.load(f))
699743
validate_config(config)
700744

701745
cross_repos_pr = {}
@@ -742,8 +786,10 @@ def main():
742786
prefix="[swift] ")
743787

744788
# Re-read the config after checkout.
745-
with open(args.config) as f:
746-
config = json.load(f)
789+
config = {}
790+
for config_path in args.configs:
791+
with open(config_path) as f:
792+
config = merge_config(config, json.load(f))
747793
validate_config(config)
748794
scheme_map = get_scheme_map(config, scheme_name)
749795

0 commit comments

Comments
 (0)