Skip to content

Commit 7cb463c

Browse files
committed
Implement bemanmodule.py
1 parent 9a78d83 commit 7cb463c

File tree

4 files changed

+650
-0
lines changed

4 files changed

+650
-0
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
2+
3+
name: beman_module.py tests
4+
5+
on:
6+
push:
7+
pull_request:
8+
workflow_dispatch:
9+
10+
jobs:
11+
beman-module-script-ci:
12+
name: beman_module.py ci
13+
runs-on: ubuntu-latest
14+
steps:
15+
- name: Checkout repository
16+
uses: actions/checkout@v4
17+
18+
- name: Set up Python
19+
uses: actions/setup-python@v5
20+
with:
21+
python-version: 3.13
22+
23+
- name: Install pytest
24+
run: |
25+
python3 -m pip install pytest
26+
27+
- name: Run pytest
28+
run: |
29+
cd beman_module
30+
pytest

beman_module/README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# beman_module.py
2+
3+
<!-- SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception -->
4+
5+
## What is this script?
6+
7+
beman_module.py provides some of the features of `git submodule`, adding child git
8+
repositories to a parent git repository, but unlike with `git submodule`, the entire child
9+
repo is directly checked in, so only maintainers, not users, need to run this script. The
10+
command line interface mimics `git submodule`'s.
11+
12+
## How do I add a beman_module to my repository?
13+
14+
The first `beman_module` you should add is this repository, `infra/`, which you can
15+
bootstrap by running:
16+
17+
<!-- markdownlint-disable MD013 -->
18+
```sh
19+
curl -s https://raw.githubusercontent.com/bemanproject/infra/refs/heads/main/beman_module/beman_module.py | python3 - add https://github.com/bemanproject/infra.git
20+
```
21+
22+
Once that's added, you can run the script from `infra/beman_module/beman_module.py`.
23+
24+
## How do I update a beman_module to the latest trunk?
25+
26+
Simply run `beman_module.py update --remote` to update all beman_modules to latest trunk,
27+
or e.g. `beman_module.py update --remote infra` to update only a specific one.
28+
29+
## How does it work under the hood?
30+
31+
Along with the files from the child repository, it creates a dotfile called
32+
`.beman_module`, which looks like this:
33+
34+
```ini
35+
[beman_module]
36+
remote=https://github.com/bemanproject/infra.git
37+
commit_hash=9b88395a86c4290794e503e94d8213b6c442ae77
38+
```
39+
40+
## How can I make CI ensure that my beman_modules are in a valid state?
41+
42+
Add this job to your CI workflow:
43+
44+
```yaml
45+
beman_modules-test:
46+
runs-on: ubuntu-latest
47+
name: "Check beman_modules"
48+
steps:
49+
- name: Checkout
50+
uses: actions/checkout@v4
51+
- name: Run beman_module check
52+
run: |
53+
(set -o pipefail; ./infra/beman_module/beman_module.py status | ! grep -qF '+')
54+
```
55+
56+
This will fail if the contents of any beman_module don't match what's specified in the
57+
`.beman_module` file.

beman_module/beman_module.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
#!/usr/bin/env python3
2+
3+
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
4+
5+
import argparse
6+
import configparser
7+
import filecmp
8+
import os
9+
import pathlib
10+
import shutil
11+
import subprocess
12+
import sys
13+
import tempfile
14+
15+
def directory_compare(dir1, dir2, ignore):
16+
compared = filecmp.dircmp(dir1, dir2, ignore=ignore)
17+
if compared.left_only or compared.right_only or compared.diff_files:
18+
return False
19+
for common_dir in compared.common_dirs:
20+
path1 = os.path.join(dir1, common_dir)
21+
path2 = os.path.join(dir2, common_dir)
22+
if not directory_compare(path1, path2, ignore):
23+
return False
24+
return True
25+
26+
class BemanModule:
27+
def __init__(self, dirpath, remote, commit_hash):
28+
self.dirpath = dirpath
29+
self.remote = remote
30+
self.commit_hash = commit_hash
31+
32+
def parse_beman_module_file(path):
33+
config = configparser.ConfigParser()
34+
read_result = config.read(path)
35+
def fail():
36+
raise Exception(f'Failed to parse {path} as a .beman_module file')
37+
if not read_result:
38+
fail()
39+
if not 'beman_module' in config:
40+
fail()
41+
if not 'remote' in config['beman_module']:
42+
fail()
43+
if not 'commit_hash' in config['beman_module']:
44+
fail()
45+
return BemanModule(
46+
str(pathlib.Path(path).resolve().parent),
47+
config['beman_module']['remote'], config['beman_module']['commit_hash'])
48+
49+
def get_beman_module(dir):
50+
beman_module_filepath = os.path.join(dir, '.beman_module')
51+
if os.path.isfile(beman_module_filepath):
52+
return parse_beman_module_file(beman_module_filepath)
53+
else:
54+
return None
55+
56+
def find_beman_modules_in(dir):
57+
assert os.path.isdir(dir)
58+
result = []
59+
for dirpath, _, filenames in os.walk(dir):
60+
if '.beman_module' in filenames:
61+
result.append(parse_beman_module_file(os.path.join(dirpath, '.beman_module')))
62+
return sorted(result, key=lambda module: module.dirpath)
63+
64+
def cwd_git_repository_path():
65+
process = subprocess.run(
66+
['git', 'rev-parse', '--show-toplevel'], capture_output=True, text=True,
67+
check=False)
68+
if process.returncode == 0:
69+
return process.stdout.strip()
70+
elif "fatal: not a git repository" in process.stderr:
71+
return None
72+
else:
73+
raise Exception("git rev-parse --show-toplevel failed")
74+
75+
def clone_beman_module_into_tmpdir(beman_module, remote):
76+
tmpdir = tempfile.TemporaryDirectory()
77+
subprocess.run(
78+
['git', 'clone', beman_module.remote, tmpdir.name], capture_output=True,
79+
check=True)
80+
if not remote:
81+
subprocess.run(
82+
['git', '-C', tmpdir.name, 'reset', '--hard', beman_module.commit_hash],
83+
capture_output=True, check=True)
84+
return tmpdir
85+
86+
def beman_module_status(beman_module):
87+
tmpdir = clone_beman_module_into_tmpdir(beman_module, False)
88+
if directory_compare(tmpdir.name, beman_module.dirpath, ['.beman_module', '.git']):
89+
status_character=' '
90+
else:
91+
status_character='+'
92+
parent_repo_path = cwd_git_repository_path()
93+
if not parent_repo_path:
94+
raise Exception('this is not a git repository')
95+
relpath = pathlib.Path(
96+
beman_module.dirpath).relative_to(pathlib.Path(parent_repo_path))
97+
return status_character + ' ' + beman_module.commit_hash + ' ' + str(relpath)
98+
99+
def beman_module_update(beman_module, remote):
100+
tmpdir = clone_beman_module_into_tmpdir(beman_module, remote)
101+
shutil.rmtree(beman_module.dirpath)
102+
with open(os.path.join(tmpdir.name, '.beman_module'), 'w') as f:
103+
f.write('[beman_module]\n')
104+
f.write(f'remote={beman_module.remote}\n')
105+
f.write(f'commit_hash={beman_module.commit_hash}\n')
106+
shutil.rmtree(os.path.join(tmpdir.name, '.git'))
107+
shutil.copytree(tmpdir.name, beman_module.dirpath)
108+
109+
def update_command(remote, path):
110+
if not path:
111+
parent_repo_path = cwd_git_repository_path()
112+
if not parent_repo_path:
113+
raise Exception('this is not a git repository')
114+
beman_modules = find_beman_modules_in(parent_repo_path)
115+
else:
116+
beman_module = get_beman_module(path)
117+
if not beman_module:
118+
raise Exception(f'{path} is not a beman_module')
119+
beman_modules = [beman_module]
120+
for beman_module in beman_modules:
121+
beman_module_update(beman_module, remote)
122+
123+
def add_command(repository, path):
124+
tmpdir = tempfile.TemporaryDirectory()
125+
subprocess.run(
126+
['git', 'clone', repository], capture_output=True, check=True, cwd=tmpdir.name)
127+
repository_name = os.listdir(tmpdir.name)[0]
128+
if not path:
129+
path = repository_name
130+
if os.path.exists(path):
131+
raise Exception(f'{path} exists')
132+
os.makedirs(path)
133+
tmpdir_repo = os.path.join(tmpdir.name, repository_name)
134+
sha_process = subprocess.run(
135+
['git', 'rev-parse', 'HEAD'], capture_output=True, check=True, text=True,
136+
cwd=tmpdir_repo)
137+
with open(os.path.join(tmpdir_repo, '.beman_module'), 'w') as f:
138+
f.write('[beman_module]\n')
139+
f.write(f'remote={repository}\n')
140+
f.write(f'commit_hash={sha_process.stdout.strip()}\n')
141+
shutil.rmtree(os.path.join(tmpdir_repo, '.git'))
142+
shutil.copytree(tmpdir_repo, path, dirs_exist_ok=True)
143+
144+
def status_command(paths):
145+
if not paths:
146+
parent_repo_path = cwd_git_repository_path()
147+
if not parent_repo_path:
148+
raise Exception('this is not a git repository')
149+
beman_modules = find_beman_modules_in(parent_repo_path)
150+
else:
151+
beman_modules = []
152+
for path in paths:
153+
beman_module = get_beman_module(path)
154+
if not beman_module:
155+
raise Exception(f'{path} is not a beman_module')
156+
beman_modules.append(beman_module)
157+
for beman_module in beman_modules:
158+
print(beman_module_status(beman_module))
159+
160+
def get_parser():
161+
parser = argparse.ArgumentParser(description='Beman pseudo-submodule tool')
162+
subparsers = parser.add_subparsers(dest='command', help='available commands')
163+
parser_update = subparsers.add_parser('update', help='update beman_modules')
164+
parser_update.add_argument(
165+
'--remote', action='store_true',
166+
help='update a beman_module to its latest from upstream')
167+
parser_update.add_argument(
168+
'beman_module_path', nargs='?',
169+
help='relative path to the beman_module to update')
170+
parser_add = subparsers.add_parser('add', help='add a new beman_module')
171+
parser_add.add_argument('repository', help='git repository to add')
172+
parser_add.add_argument(
173+
'path', nargs='?', help='path where the repository will be added')
174+
parser_status = subparsers.add_parser(
175+
'status', help='show the status of beman_modules')
176+
parser_status.add_argument('paths', nargs='*')
177+
return parser
178+
179+
def parse_args(args):
180+
return get_parser().parse_args(args);
181+
182+
def usage():
183+
return get_parser().format_help()
184+
185+
def run_command(args):
186+
if args.command == 'update':
187+
update_command(args.remote, args.beman_module_path)
188+
elif args.command == 'add':
189+
add_command(args.repository, args.path)
190+
elif args.command == 'status':
191+
status_command(args.paths)
192+
else:
193+
raise Exception(usage())
194+
195+
def check_for_git(path):
196+
env = os.environ.copy()
197+
if path is not None:
198+
env["PATH"] = path
199+
return shutil.which("git", path=env.get("PATH")) is not None
200+
201+
def main():
202+
try:
203+
if not check_for_git(None):
204+
raise Exception('git not found in PATH')
205+
args = parse_args(sys.argv[1:])
206+
run_command(args)
207+
except Exception as e:
208+
print("Error:", e, file=sys.stderr)
209+
sys.exit(1)
210+
211+
if __name__ == '__main__':
212+
main()

0 commit comments

Comments
 (0)