Add a Repos module that mirrors git repositories (#783)
Repos keeps local mirrors of git repositories, cloned over https and kept up to date on a schedule.
- A Repo is a Collection (kind='repo') plus a `repository` row. The new `git` downloader clones it, then updates it
(weekly by default, or Never: the clone is kept as it is and only updated on request).
- Repos are saved in `repos/<tag>/<name>` (the Repos Directory, ignored by default) or a directory the user chooses.
An existing clone can be imported where it is. repos.yaml is the source of truth.
- A GitHub-like page shows each repo's README, files, and history, with a ZIP download. READMEs are searchable
under the global search's Other tab.
- git is hardened against hostile repos and configs: https only, pinned addresses with no private addresses (for the
repo and its submodules), sanitized configs, no hooks or links, and only clones WROLPi made or imported are
updated.
- Tagging a Repo moves its clone as one directory. Repo changes wait for repos.yaml to be imported at startup.
- The file mover groups unindexed files with no primary file as one FileGroup, and never replaces a file at the
destination. A failed tag move restores the Collection's tag. A Collection's directory cannot leave the media
directory.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:16:40 -06:00
|
|
|
import json
|
|
|
|
|
import sqlite3
|
|
|
|
|
import time
|
|
|
|
|
from http import HTTPStatus
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from modules.repos import lib
|
|
|
|
|
from modules.repos.models import Repository
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def set_readme(session, repo: Repository, readme_text: str):
|
|
|
|
|
repo.readme_text = readme_text
|
|
|
|
|
session.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_search_repos(test_session, async_client, test_wrolpi_config):
|
|
|
|
|
"""Repos are found by their name, owner, and README; a name match outranks a README mention."""
|
|
|
|
|
kiwix = lib.create_repository(test_session, 'https://github.com/kiwix/kiwix-tools', tag_name='software')
|
|
|
|
|
maps = lib.create_repository(test_session, 'https://github.com/protomaps/basemaps')
|
|
|
|
|
set_readme(test_session, kiwix, 'Kiwix tools serve Zim files offline.')
|
|
|
|
|
set_readme(test_session, maps, 'Vector basemaps. Works with kiwix-tools and offline maps.')
|
|
|
|
|
|
|
|
|
|
def names(search_str=None, tag_names=None):
|
|
|
|
|
repos, total = lib.search_repos(test_session, search_str, tag_names)
|
|
|
|
|
assert total == len(repos)
|
|
|
|
|
return [i['name'] for i in repos]
|
|
|
|
|
|
|
|
|
|
assert names('kiwix') == ['kiwix-tools', 'basemaps']
|
|
|
|
|
assert names('protomaps') == ['basemaps'] # The owner.
|
|
|
|
|
assert names('vector') == ['basemaps']
|
|
|
|
|
assert set(names('offline')) == {'kiwix-tools', 'basemaps'}
|
|
|
|
|
assert names('zim -vector') == ['kiwix-tools']
|
|
|
|
|
assert names('nothing') == []
|
|
|
|
|
|
|
|
|
|
repos, _ = lib.search_repos(test_session, 'vector')
|
|
|
|
|
assert repos[0]['readme_headline'] == '<b>Vector</b> basemaps. Works with kiwix-tools and offline maps.'
|
|
|
|
|
|
|
|
|
|
# Tags.
|
|
|
|
|
assert names('offline', ['software']) == ['kiwix-tools']
|
|
|
|
|
assert names(tag_names=['software']) == ['kiwix-tools']
|
|
|
|
|
assert names('offline', ['software', 'other']) == []
|
|
|
|
|
assert names('offline', ['other']) == []
|
|
|
|
|
|
|
|
|
|
# Nothing to search.
|
|
|
|
|
assert names() == []
|
|
|
|
|
assert names('!!!') == []
|
|
|
|
|
|
|
|
|
|
assert lib.count_repos(test_session, 'offline') == 2
|
|
|
|
|
assert lib.count_repos(test_session, 'offline', ['software']) == 1
|
|
|
|
|
assert lib.count_repos(test_session) == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_search_index_follows_changes(test_session, test_directory, async_client, test_wrolpi_config):
|
|
|
|
|
"""The search index follows README changes, renames from the config, and deletes."""
|
|
|
|
|
repo = lib.create_repository(test_session, 'https://github.com/a/b')
|
|
|
|
|
set_readme(test_session, repo, 'first readme')
|
|
|
|
|
assert lib.count_repos(test_session, 'first') == 1
|
|
|
|
|
|
|
|
|
|
set_readme(test_session, repo, 'second readme')
|
|
|
|
|
assert lib.count_repos(test_session, 'first') == 0
|
|
|
|
|
assert lib.count_repos(test_session, 'second') == 1
|
|
|
|
|
|
repos.yaml imports by diffing (#870)
* repos.yaml imports by diffing
repos.yaml declares one section, its Repos keyed by repo_url_key (another
URL of the same Repo is that Repo), comparing name, description,
directory, Tag, mode, branch, submodules, frequency and clone token (a
missing directory or clone token keeps the database's). An unchanged
import writes nothing (every import rewrote every Repo and its
Download).
An invalid entry is skipped and counted, and its Repo is kept; before,
a Repo whose entry failed validation was deleted. New Repos are created
inside the import's one write: recurring_download (via
get_or_create_download and _set_repository_download) takes commit=False,
and the URL is not looked up again for each new Repo (O(N^2)).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* repos.yaml: a branch URL is keyed by its Repo's URL
file_rows keyed the raw URL, but stores parse_repo_url's URL, which
turns a forge's branch URL (GitHub /tree/<branch>, GitLab
/-/tree/<branch>) into the repo URL and a branch. The entry looked like
a new Repo: its insert hit the existing Repo's name (rolling back every
import of that file), and an invalid such entry did not protect its Repo
from deletion. Rows and invalid keys now use the parsed URL; a URL that
cannot be parsed is skipped by its raw key.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 10:35:57 -06:00
|
|
|
section = lib.ReposSection()
|
|
|
|
|
rows, _ = section.file_rows(dict(repos=[dict(url='https://github.com/a/b', name='renamed')]))
|
|
|
|
|
section.upsert(test_session, rows)
|
Add a Repos module that mirrors git repositories (#783)
Repos keeps local mirrors of git repositories, cloned over https and kept up to date on a schedule.
- A Repo is a Collection (kind='repo') plus a `repository` row. The new `git` downloader clones it, then updates it
(weekly by default, or Never: the clone is kept as it is and only updated on request).
- Repos are saved in `repos/<tag>/<name>` (the Repos Directory, ignored by default) or a directory the user chooses.
An existing clone can be imported where it is. repos.yaml is the source of truth.
- A GitHub-like page shows each repo's README, files, and history, with a ZIP download. READMEs are searchable
under the global search's Other tab.
- git is hardened against hostile repos and configs: https only, pinned addresses with no private addresses (for the
repo and its submodules), sanitized configs, no hooks or links, and only clones WROLPi made or imported are
updated.
- Tagging a Repo moves its clone as one directory. Repo changes wait for repos.yaml to be imported at startup.
- The file mover groups unindexed files with no primary file as one FileGroup, and never replaces a file at the
destination. A failed tag move restores the Collection's tag. A Collection's directory cannot leave the media
directory.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:16:40 -06:00
|
|
|
test_session.commit()
|
|
|
|
|
assert lib.count_repos(test_session, 'renamed') == 1
|
|
|
|
|
|
|
|
|
|
await lib.delete_repository(test_session, repo.id)
|
|
|
|
|
assert lib.count_repos(test_session, 'second') == 0
|
|
|
|
|
assert lib.count_repos(test_session, 'renamed') == 0
|
|
|
|
|
|
|
|
|
|
# The index matches its content.
|
|
|
|
|
with sqlite3.connect(test_directory / 'config/wrolpi.db') as conn:
|
|
|
|
|
conn.execute("INSERT INTO repository_fts(repository_fts, rank) VALUES('integrity-check', 1)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_downloader_indexes_readme(test_session, git_remote_factory, repos_download_manager):
|
|
|
|
|
"""The downloader stores the README as plain text, and keeps it current."""
|
|
|
|
|
readme = '# Example\n\n[](https://ci) An **offline** [library](https://docs).\n'
|
|
|
|
|
remote = git_remote_factory(files={'README.md': readme})
|
|
|
|
|
repo = lib.create_repository(test_session, remote.url)
|
|
|
|
|
await repos_download_manager.wait_for_all_downloads()
|
|
|
|
|
test_session.expire_all()
|
|
|
|
|
assert repo.readme_text == 'Example\nCI An offline library.'
|
|
|
|
|
assert lib.count_repos(test_session, 'library') == 1
|
|
|
|
|
|
|
|
|
|
remote.git('rm', '--quiet', 'README.md')
|
|
|
|
|
remote.commit({'readme.txt': 'Plain *text*'}, 'Plain README')
|
|
|
|
|
download = repo.download
|
|
|
|
|
download.renew()
|
|
|
|
|
test_session.commit()
|
|
|
|
|
await repos_download_manager.wait_for_all_downloads()
|
|
|
|
|
test_session.expire_all()
|
|
|
|
|
assert repo.readme_path == 'readme.txt'
|
|
|
|
|
assert repo.readme_text == 'Plain *text*'
|
|
|
|
|
assert lib.count_repos(test_session, 'library') == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_search_api(test_session, async_client, test_wrolpi_config):
|
|
|
|
|
kiwix = lib.create_repository(test_session, 'https://github.com/kiwix/kiwix-tools', tag_name='software')
|
|
|
|
|
set_readme(test_session, kiwix, 'Serve Zim files offline.')
|
|
|
|
|
lib.create_repository(test_session, 'https://github.com/a/b')
|
|
|
|
|
|
|
|
|
|
body = dict(search_str='zim')
|
|
|
|
|
request, response = await async_client.post('/api/repos/search', content=json.dumps(body))
|
|
|
|
|
assert response.status_code == HTTPStatus.OK
|
|
|
|
|
assert response.json['totals'] == dict(repos=1)
|
|
|
|
|
repo, = response.json['repos']
|
|
|
|
|
assert repo['name'] == 'kiwix-tools'
|
|
|
|
|
assert repo['readme_headline'] == 'Serve <b>Zim</b> files offline.'
|
|
|
|
|
assert 'readme_text' not in repo
|
|
|
|
|
|
|
|
|
|
# The global search's "Other" estimate counts Repos by the search, and by Tag.
|
|
|
|
|
request, response = await async_client.post('/api/search_other_estimates', content=json.dumps(body))
|
2026-10-07 18:22:05 -06:00
|
|
|
assert response.json['others'] == dict(channel_count=0, domain_count=0, playlist_count=0, repo_count=1)
|
Add a Repos module that mirrors git repositories (#783)
Repos keeps local mirrors of git repositories, cloned over https and kept up to date on a schedule.
- A Repo is a Collection (kind='repo') plus a `repository` row. The new `git` downloader clones it, then updates it
(weekly by default, or Never: the clone is kept as it is and only updated on request).
- Repos are saved in `repos/<tag>/<name>` (the Repos Directory, ignored by default) or a directory the user chooses.
An existing clone can be imported where it is. repos.yaml is the source of truth.
- A GitHub-like page shows each repo's README, files, and history, with a ZIP download. READMEs are searchable
under the global search's Other tab.
- git is hardened against hostile repos and configs: https only, pinned addresses with no private addresses (for the
repo and its submodules), sanitized configs, no hooks or links, and only clones WROLPi made or imported are
updated.
- Tagging a Repo moves its clone as one directory. Repo changes wait for repos.yaml to be imported at startup.
- The file mover groups unindexed files with no primary file as one FileGroup, and never replaces a file at the
destination. A failed tag move restores the Collection's tag. A Collection's directory cannot leave the media
directory.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:16:40 -06:00
|
|
|
body = dict(tag_names=['software'])
|
|
|
|
|
request, response = await async_client.post('/api/search_other_estimates', content=json.dumps(body))
|
2026-10-07 18:22:05 -06:00
|
|
|
assert response.json['others'] == dict(channel_count=0, domain_count=0, playlist_count=0, repo_count=1)
|
Add a Repos module that mirrors git repositories (#783)
Repos keeps local mirrors of git repositories, cloned over https and kept up to date on a schedule.
- A Repo is a Collection (kind='repo') plus a `repository` row. The new `git` downloader clones it, then updates it
(weekly by default, or Never: the clone is kept as it is and only updated on request).
- Repos are saved in `repos/<tag>/<name>` (the Repos Directory, ignored by default) or a directory the user chooses.
An existing clone can be imported where it is. repos.yaml is the source of truth.
- A GitHub-like page shows each repo's README, files, and history, with a ZIP download. READMEs are searchable
under the global search's Other tab.
- git is hardened against hostile repos and configs: https only, pinned addresses with no private addresses (for the
repo and its submodules), sanitized configs, no hooks or links, and only clones WROLPi made or imported are
updated.
- Tagging a Repo moves its clone as one directory. Repo changes wait for repos.yaml to be imported at startup.
- The file mover groups unindexed files with no primary file as one FileGroup, and never replaces a file at the
destination. A failed tag move restores the Collection's tag. A Collection's directory cannot leave the media
directory.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:16:40 -06:00
|
|
|
body = dict(tag_names=['software'], search_str='nothing')
|
|
|
|
|
request, response = await async_client.post('/api/search_other_estimates', content=json.dumps(body))
|
2026-10-07 18:22:05 -06:00
|
|
|
assert response.json['others'] == dict(channel_count=0, domain_count=0, playlist_count=0, repo_count=0)
|
Add a Repos module that mirrors git repositories (#783)
Repos keeps local mirrors of git repositories, cloned over https and kept up to date on a schedule.
- A Repo is a Collection (kind='repo') plus a `repository` row. The new `git` downloader clones it, then updates it
(weekly by default, or Never: the clone is kept as it is and only updated on request).
- Repos are saved in `repos/<tag>/<name>` (the Repos Directory, ignored by default) or a directory the user chooses.
An existing clone can be imported where it is. repos.yaml is the source of truth.
- A GitHub-like page shows each repo's README, files, and history, with a ZIP download. READMEs are searchable
under the global search's Other tab.
- git is hardened against hostile repos and configs: https only, pinned addresses with no private addresses (for the
repo and its submodules), sanitized configs, no hooks or links, and only clones WROLPi made or imported are
updated.
- Tagging a Repo moves its clone as one directory. Repo changes wait for repos.yaml to be imported at startup.
- The file mover groups unindexed files with no primary file as one FileGroup, and never replaces a file at the
destination. A failed tag move restores the Collection's tag. A Collection's directory cannot leave the media
directory.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:16:40 -06:00
|
|
|
|
|
|
|
|
# Repos are suggested by name.
|
|
|
|
|
request, response = await async_client.post('/api/search_suggestions', content=json.dumps(dict(search_str='kiw')))
|
|
|
|
|
assert response.json['repos'] == [
|
|
|
|
|
dict(id=kiwix.id, name='kiwix-tools', tag_name='software', location=f'/repos/{kiwix.id}')]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_readme_headline_is_escaped(test_session, async_client, test_wrolpi_config):
|
|
|
|
|
"""A README comes from an untrusted remote; its headline is rendered as HTML, so only the highlight is markup."""
|
|
|
|
|
repo = lib.create_repository(test_session, 'https://github.com/a/b')
|
|
|
|
|
set_readme(test_session, repo, 'zim <img src=x onerror="alert(1)"> & <b>bold</b>')
|
|
|
|
|
|
|
|
|
|
repos, _ = lib.search_repos(test_session, 'zim')
|
|
|
|
|
assert repos[0]['readme_headline'] == \
|
|
|
|
|
'<b>zim</b> <img src=x onerror="alert(1)"> &amp; <b>bold</b>'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_read_readme_text_refuses_symlinks(tmp_path):
|
|
|
|
|
secret = tmp_path / 'secret.txt'
|
|
|
|
|
secret.write_text('secret')
|
|
|
|
|
readme = tmp_path / 'README.md'
|
|
|
|
|
readme.symlink_to(secret)
|
|
|
|
|
assert lib.read_readme_text(readme) is None
|
|
|
|
|
assert lib.find_readme(tmp_path) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize('text', [
|
|
|
|
|
pytest.param('|' + ' ' * 100_000 + 'x', id='table-spaces'),
|
|
|
|
|
pytest.param('-' * 100_000 + 'x', id='dashes'),
|
|
|
|
|
pytest.param('<!--' * 25_000, id='unclosed-comments'),
|
|
|
|
|
pytest.param('![' * 50_000, id='image-brackets'),
|
|
|
|
|
pytest.param('[' * 50_000 + '(' * 50_000, id='link-brackets'),
|
|
|
|
|
pytest.param('<' * 100_000, id='angles'),
|
|
|
|
|
pytest.param('\n' + ' \n' * 50_000 + 'x', id='blank-lines'),
|
|
|
|
|
pytest.param('#' * 100_000, id='hashes'),
|
|
|
|
|
pytest.param('> ' * 50_000, id='quotes'),
|
|
|
|
|
])
|
|
|
|
|
def test_markdown_to_text_is_linear(text):
|
|
|
|
|
"""A README is untrusted; converting the largest README must never take long.
|
|
|
|
|
|
|
|
|
|
Each case takes milliseconds; the patterns this replaced took minutes. The bound is loose so a busy machine
|
|
|
|
|
does not fail it."""
|
|
|
|
|
start = time.monotonic()
|
|
|
|
|
lib.markdown_to_text(text)
|
|
|
|
|
assert time.monotonic() - start < 5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_markdown_to_text():
|
|
|
|
|
readme = '''# Kiwix Tools <!-- hidden
|
|
|
|
|
comment -->
|
|
|
|
|
|
|
|
|
|
[](https://ci) **Kiwix** is an *offline* reader. See [the docs](https://docs).
|
|
|
|
|
|
|
|
|
|
| a | b |
|
|
|
|
|
|---|---|
|
|
|
|
|
| 1 | 2 |
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
kiwix-serve --port 80
|
|
|
|
|
```
|
|
|
|
|
> quoted
|
|
|
|
|
[ref]: https://example.com
|
|
|
|
|
---
|
|
|
|
|
#hashtag
|
|
|
|
|
'''
|
|
|
|
|
assert lib.markdown_to_text(readme) == ('Kiwix Tools\nCI Kiwix is an offline reader. See the docs.'
|
|
|
|
|
'\na b\n1 2\nkiwix-serve --port 80\nquoted\n#hashtag')
|