├── .codecov.yml ├── .codex └── instructions.md ├── .cursor └── rules │ ├── avoid-debug-loops.mdc │ ├── dev-loop.mdc │ ├── git-commits.mdc │ └── vcspull-pytest.mdc ├── .github ├── dependabot.yml └── workflows │ ├── docs.yml │ └── tests.yml ├── .gitignore ├── .gitmodules ├── .prettierrc ├── .python-version ├── .tmuxp.yaml ├── .tool-versions ├── .vim └── coc-settings.json ├── .windsurfrules ├── CHANGES ├── LICENSE ├── MIGRATION ├── Makefile ├── README.md ├── conftest.py ├── docs ├── Makefile ├── _static │ ├── css │ │ └── custom.css │ ├── favicon.ico │ ├── img │ │ ├── icons │ │ │ ├── icon-128x128.png │ │ │ ├── icon-144x144.png │ │ │ ├── icon-152x152.png │ │ │ ├── icon-192x192.png │ │ │ ├── icon-384x384.png │ │ │ ├── icon-512x512.png │ │ │ ├── icon-72x72.png │ │ │ └── icon-96x96.png │ │ ├── vcspull-dark.svg │ │ └── vcspull.svg │ ├── vcspull-demo.gif │ └── vcspull-screenshot.png ├── _templates │ ├── layout.html │ └── sidebar │ │ └── projects.html ├── api │ ├── cli │ │ ├── index.md │ │ └── sync.md │ ├── config.md │ ├── exc.md │ ├── index.md │ ├── internals │ │ ├── config_reader.md │ │ └── index.md │ ├── log.md │ ├── types.md │ ├── util.md │ └── validator.md ├── cli │ ├── completion.md │ ├── index.md │ └── sync.md ├── conf.py ├── configuration │ ├── generation.md │ └── index.md ├── developing.md ├── history.md ├── index.md ├── manifest.json ├── migration.md ├── quickstart.md └── redirects.txt ├── examples ├── christmas-tree.yaml ├── code-scholar.yaml └── remotes.yaml ├── pyproject.toml ├── scripts ├── generate_gitlab.py └── generate_gitlab.sh ├── src └── vcspull │ ├── __about__.py │ ├── __init__.py │ ├── _internal │ ├── __init__.py │ └── config_reader.py │ ├── cli │ ├── __init__.py │ └── sync.py │ ├── config.py │ ├── exc.py │ ├── log.py │ ├── types.py │ ├── util.py │ └── validator.py ├── tests ├── __init__.py ├── fixtures │ ├── __init__.py │ └── example.py ├── helpers.py ├── test_cli.py ├── test_config.py ├── test_config_file.py ├── test_repo.py ├── test_sync.py └── test_utils.py └── uv.lock /.codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | notify: 3 | require_ci_to_pass: no 4 | 5 | coverage: 6 | precision: 2 7 | round: down 8 | range: "70...100" 9 | status: 10 | project: 11 | default: 12 | target: auto 13 | threshold: 1% 14 | base: auto 15 | patch: off 16 | -------------------------------------------------------------------------------- /.codex/instructions.md: -------------------------------------------------------------------------------- 1 | ../.windsurfrules -------------------------------------------------------------------------------- /.cursor/rules/avoid-debug-loops.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: When stuck in debugging loops, break the cycle by minimizing to an MVP, removing debugging cruft, and documenting the issue completely for a fresh approach 3 | globs: *.py 4 | alwaysApply: true 5 | --- 6 | # Avoid Debug Loops 7 | 8 | When debugging becomes circular and unproductive, follow these steps: 9 | 10 | ## Detection 11 | - You have made multiple unsuccessful attempts to fix the same issue 12 | - You are adding increasingly complex code to address errors 13 | - Each fix creates new errors in a cascading pattern 14 | - You are uncertain about the root cause after 2-3 iterations 15 | 16 | ## Action Plan 17 | 18 | 1. **Pause and acknowledge the loop** 19 | - Explicitly state that you are in a potential debug loop 20 | - Review what approaches have been tried and failed 21 | 22 | 2. **Minimize to MVP** 23 | - Remove all debugging cruft and experimental code 24 | - Revert to the simplest version that demonstrates the issue 25 | - Focus on isolating the core problem without added complexity 26 | 27 | 3. **Comprehensive Documentation** 28 | - Provide a clear summary of the issue 29 | - Include minimal but complete code examples that reproduce the problem 30 | - Document exact error messages and unexpected behaviors 31 | - Explain your current understanding of potential causes 32 | 33 | 4. **Format for Portability** 34 | - Present the problem in quadruple backticks for easy copying: 35 | 36 | ```` 37 | # Problem Summary 38 | [Concise explanation of the issue] 39 | 40 | ## Minimal Reproduction Code 41 | ```python 42 | # Minimal code example that reproduces the issue 43 | ``` 44 | 45 | ## Error/Unexpected Output 46 | ``` 47 | [Exact error messages or unexpected output] 48 | ``` 49 | 50 | ## Failed Approaches 51 | [Brief summary of approaches already tried] 52 | 53 | ## Suspected Cause 54 | [Your current hypothesis about what might be causing the issue] 55 | ```` 56 | 57 | This format enables the user to easily copy the entire problem statement into a fresh conversation for a clean-slate approach. 58 | -------------------------------------------------------------------------------- /.cursor/rules/dev-loop.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: QA every edit 3 | globs: *.py 4 | alwaysApply: true 5 | --- 6 | 7 | # Development Process 8 | 9 | ## Project Stack 10 | 11 | The project uses the following tools and technologies: 12 | 13 | - **uv** - Python package management and virtual environments 14 | - **ruff** - Fast Python linter and formatter 15 | - **py.test** - Testing framework 16 | - **pytest-watcher** - Continuous test runner 17 | - **mypy** - Static type checking 18 | - **doctest** - Testing code examples in documentation 19 | 20 | ## 1. Start with Formatting 21 | 22 | Format your code first: 23 | 24 | ``` 25 | uv run ruff format . 26 | ``` 27 | 28 | ## 2. Run Tests 29 | 30 | Verify that your changes pass the tests: 31 | 32 | ``` 33 | uv run py.test 34 | ``` 35 | 36 | For continuous testing during development, use pytest-watcher: 37 | 38 | ``` 39 | # Watch all tests 40 | uv run ptw . 41 | 42 | # Watch and run tests immediately, including doctests 43 | uv run ptw . --now --doctest-modules 44 | 45 | # Watch specific files or directories 46 | uv run ptw . --now --doctest-modules src/libtmux/_internal/ 47 | ``` 48 | 49 | ## 3. Commit Initial Changes 50 | 51 | Make an atomic commit for your changes using conventional commits. 52 | Use `@git-commits.mdc` for assistance with commit message standards. 53 | 54 | ## 4. Run Linting and Type Checking 55 | 56 | Check and fix linting issues: 57 | 58 | ``` 59 | uv run ruff check . --fix --show-fixes 60 | ``` 61 | 62 | Check typings: 63 | 64 | ``` 65 | uv run mypy 66 | ``` 67 | 68 | ## 5. Verify Tests Again 69 | 70 | Ensure tests still pass after linting and type fixes: 71 | 72 | ``` 73 | uv run py.test 74 | ``` 75 | 76 | ## 6. Final Commit 77 | 78 | Make a final commit with any linting/typing fixes. 79 | Use `@git-commits.mdc` for assistance with commit message standards. 80 | 81 | ## Development Loop Guidelines 82 | 83 | If there are any failures at any step due to your edits, fix them before proceeding to the next step. 84 | 85 | ## Python Code Standards 86 | 87 | ### Docstring Guidelines 88 | 89 | For `src/**/*.py` files, follow these docstring guidelines: 90 | 91 | 1. **Use reStructuredText format** for all docstrings. 92 | ```python 93 | """Short description of the function or class. 94 | 95 | Detailed description using reStructuredText format. 96 | 97 | Parameters 98 | ---------- 99 | param1 : type 100 | Description of param1 101 | param2 : type 102 | Description of param2 103 | 104 | Returns 105 | ------- 106 | type 107 | Description of return value 108 | """ 109 | ``` 110 | 111 | 2. **Keep the main description on the first line** after the opening `"""`. 112 | 113 | 3. **Use NumPy docstyle** for parameter and return value documentation. 114 | 115 | ### Doctest Guidelines 116 | 117 | For doctests in `src/**/*.py` files: 118 | 119 | 1. **Use narrative descriptions** for test sections rather than inline comments: 120 | ```python 121 | """Example function. 122 | 123 | Examples 124 | -------- 125 | Create an instance: 126 | 127 | >>> obj = ExampleClass() 128 | 129 | Verify a property: 130 | 131 | >>> obj.property 132 | 'expected value' 133 | """ 134 | ``` 135 | 136 | 2. **Move complex examples** to dedicated test files at `tests/examples//test_.py` if they require elaborate setup or multiple steps. 137 | 138 | 3. **Utilize pytest fixtures** via `doctest_namespace` for more complex test scenarios: 139 | ```python 140 | """Example with fixture. 141 | 142 | Examples 143 | -------- 144 | >>> # doctest_namespace contains all pytest fixtures from conftest.py 145 | >>> example_fixture = getfixture('example_fixture') 146 | >>> example_fixture.method() 147 | 'expected result' 148 | """ 149 | ``` 150 | 151 | 4. **Keep doctests simple and focused** on demonstrating usage rather than comprehensive testing. 152 | 153 | 5. **Add blank lines between test sections** for improved readability. 154 | 155 | 6. **Test your doctests continuously** using pytest-watcher during development: 156 | ``` 157 | # Watch specific modules for doctest changes 158 | uv run ptw . --now --doctest-modules src/path/to/module.py 159 | ``` 160 | 161 | ### Pytest Testing Guidelines 162 | 163 | 1. **Use existing fixtures over mocks**: 164 | - Use fixtures from conftest.py instead of `monkeypatch` and `MagicMock` when available 165 | - For instance, if using libtmux, use provided fixtures: `server`, `session`, `window`, and `pane` 166 | - Document in test docstrings why standard fixtures weren't used for exceptional cases 167 | 168 | 2. **Preferred pytest patterns**: 169 | - Use `tmp_path` (pathlib.Path) fixture over Python's `tempfile` 170 | - Use `monkeypatch` fixture over `unittest.mock` 171 | 172 | ### Import Guidelines 173 | 174 | 1. **Prefer namespace imports**: 175 | - Import modules and access attributes through the namespace instead of importing specific symbols 176 | - Example: Use `import enum` and access `enum.Enum` instead of `from enum import Enum` 177 | - This applies to standard library modules like `pathlib`, `os`, and similar cases 178 | 179 | 2. **Standard aliases**: 180 | - For `typing` module, use `import typing as t` 181 | - Access typing elements via the namespace: `t.NamedTuple`, `t.TypedDict`, etc. 182 | - Note primitive types like unions can be done via `|` pipes and primitive types like list and dict can be done via `list` and `dict` directly. 183 | 184 | 3. **Benefits of namespace imports**: 185 | - Improves code readability by making the source of symbols clear 186 | - Reduces potential naming conflicts 187 | - Makes import statements more maintainable 188 | -------------------------------------------------------------------------------- /.cursor/rules/git-commits.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: git-commits: Git commit message standards and AI assistance 3 | globs: git-commits: Git commit message standards and AI assistance | *.git/* .gitignore .github/* CHANGELOG.md CHANGES.md 4 | alwaysApply: true 5 | --- 6 | # Optimized Git Commit Standards 7 | 8 | ## Commit Message Format 9 | ``` 10 | Component/File(commit-type[Subcomponent/method]): Concise description 11 | 12 | why: Explanation of necessity or impact. 13 | what: 14 | - Specific technical changes made 15 | - Focused on a single topic 16 | 17 | refs: #issue-number, breaking changes, or relevant links 18 | ``` 19 | 20 | ## Component Patterns 21 | ### General Code Changes 22 | ``` 23 | Component/File(feat[method]): Add feature 24 | Component/File(fix[method]): Fix bug 25 | Component/File(refactor[method]): Code restructure 26 | ``` 27 | 28 | ### Packages and Dependencies 29 | | Language | Standard Packages | Dev Packages | Extras / Sub-packages | 30 | |------------|------------------------------------|-------------------------------|-----------------------------------------------| 31 | | General | `lang(deps):` | `lang(deps[dev]):` | | 32 | | Python | `py(deps):` | `py(deps[dev]):` | `py(deps[extra]):` | 33 | | JavaScript | `js(deps):` | `js(deps[dev]):` | `js(deps[subpackage]):`, `js(deps[dev{subpackage}]):` | 34 | 35 | #### Examples 36 | - `py(deps[dev]): Update pytest to v8.1` 37 | - `js(deps[ui-components]): Upgrade Button component package` 38 | - `js(deps[dev{linting}]): Add ESLint plugin` 39 | 40 | ### Documentation Changes 41 | Prefix with `docs:` 42 | ``` 43 | docs(Component/File[Subcomponent/method]): Update API usage guide 44 | ``` 45 | 46 | ### Test Changes 47 | Prefix with `tests:` 48 | ``` 49 | tests(Component/File[Subcomponent/method]): Add edge case tests 50 | ``` 51 | 52 | ## Commit Types Summary 53 | - **feat**: New features or enhancements 54 | - **fix**: Bug fixes 55 | - **refactor**: Code restructuring without functional change 56 | - **docs**: Documentation updates 57 | - **chore**: Maintenance (dependencies, tooling, config) 58 | - **test**: Test-related updates 59 | - **style**: Code style and formatting 60 | 61 | ## General Guidelines 62 | - Subject line: Maximum 50 characters 63 | - Body lines: Maximum 72 characters 64 | - Use imperative mood (e.g., "Add", "Fix", not "Added", "Fixed") 65 | - Limit to one topic per commit 66 | - Separate subject from body with a blank line 67 | - Mark breaking changes clearly: `BREAKING:` 68 | - Use `See also:` to provide external references 69 | 70 | ## AI Assistance Workflow in Cursor 71 | - Stage changes with `git add` 72 | - Use `@commit` to generate initial commit message 73 | - Review and refine generated message 74 | - Ensure adherence to these standards 75 | 76 | ## Good Commit Example 77 | ``` 78 | Pane(feat[capture_pane]): Add screenshot capture support 79 | 80 | why: Provide visual debugging capability 81 | what: 82 | - Implement capturePane method with image export 83 | - Integrate with existing Pane component logic 84 | - Document usage in Pane README 85 | 86 | refs: #485 87 | See also: https://example.com/docs/pane-capture 88 | ``` 89 | 90 | ## Bad Commit Example 91 | ``` 92 | fixed stuff and improved some functions 93 | ``` 94 | 95 | These guidelines ensure clear, consistent commit histories, facilitating easier code review and maintenance. -------------------------------------------------------------------------------- /.cursor/rules/vcspull-pytest.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: tests/**/test_*.py 4 | alwaysApply: true 5 | --- 6 | 7 | # VCSPull Pytest Integration with libvcs 8 | 9 | When writing tests for vcspull, leverage libvcs's pytest plugin to efficiently create and manage VCS repositories during testing. 10 | 11 | ## Available Fixtures from libvcs 12 | 13 | libvcs provides a complete set of fixtures that automatically handle the creation and cleanup of VCS repositories: 14 | 15 | ### Core Repository Creation Fixtures 16 | 17 | - `create_git_remote_repo`: Factory fixture that creates a local Git repository 18 | - `create_svn_remote_repo`: Factory fixture that creates a local SVN repository 19 | - `create_hg_remote_repo`: Factory fixture that creates a local Mercurial repository 20 | 21 | ### Pre-configured Repository Fixtures 22 | 23 | - `git_repo`: Pre-made Git repository clone (GitSync instance) 24 | - `svn_repo`: Pre-made SVN repository checkout (SvnSync instance) 25 | - `hg_repo`: Pre-made Mercurial repository clone (HgSync instance) 26 | 27 | ### Environment & Configuration Fixtures 28 | 29 | - `set_home`: Sets a temporary home directory 30 | - `gitconfig`: Git configuration for test repositories 31 | - `hgconfig`: Mercurial configuration for test repositories 32 | - `git_commit_envvars`: Environment variables for Git commits 33 | 34 | ## Usage Examples 35 | 36 | ### Basic Repository Creation 37 | 38 | ```python 39 | def test_vcspull_with_git(create_git_remote_repo): 40 | # Create a test git repository on-the-fly 41 | repo_path = create_git_remote_repo() 42 | 43 | # repo_path is now a pathlib.Path pointing to a clean git repo 44 | # Use this repository in your vcspull tests 45 | ``` 46 | 47 | ### Using Pre-configured Repositories 48 | 49 | ```python 50 | def test_vcspull_sync(git_repo): 51 | # git_repo is already a GitSync instance with a clean repository 52 | # Use it directly in your tests 53 | 54 | # The repository will be automatically cleaned up after the test 55 | ``` 56 | 57 | ### Custom Repository Setup 58 | 59 | ```python 60 | def test_custom_repo_state( 61 | create_git_remote_repo, 62 | git_commit_envvars 63 | ): 64 | # Create a repo with custom initialization 65 | repo_path = create_git_remote_repo() 66 | 67 | # Modify the repository as needed with the correct environment 68 | import subprocess 69 | subprocess.run( 70 | ["git", "commit", "--allow-empty", "-m", "Custom commit"], 71 | cwd=repo_path, 72 | env=git_commit_envvars 73 | ) 74 | ``` 75 | 76 | ## Benefits 77 | 78 | - **Fast tests**: Repositories are created efficiently and cached appropriately 79 | - **Clean environment**: Each test gets fresh repositories without interference 80 | - **Reduced boilerplate**: No need to manually create/clean up repositories 81 | - **Realistic testing**: Test against actual VCS operations 82 | - **Compatible with pytest-xdist**: Works correctly with parallel test execution 83 | 84 | For detailed documentation on all available fixtures, visit: 85 | - https://libvcs.git-pull.com/pytest-plugin.html 86 | - https://github.com/vcs-python/libvcs/blob/master/src/libvcs/pytest_plugin.py 87 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | # Check for updates to GitHub Actions every week 7 | interval: "weekly" 8 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | python-version: ['3.13'] 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Filter changed file paths to outputs 18 | uses: dorny/paths-filter@v3.0.2 19 | id: changes 20 | with: 21 | filters: | 22 | root_docs: 23 | - CHANGES 24 | - README.* 25 | docs: 26 | - 'docs/**' 27 | - 'examples/**' 28 | python_files: 29 | - 'vcspull/**' 30 | - pyproject.toml 31 | - uv.lock 32 | 33 | - name: Should publish 34 | if: steps.changes.outputs.docs == 'true' || steps.changes.outputs.root_docs == 'true' || steps.changes.outputs.python_files == 'true' 35 | run: echo "PUBLISH=$(echo true)" >> $GITHUB_ENV 36 | 37 | - name: Install uv 38 | uses: astral-sh/setup-uv@v5 39 | if: env.PUBLISH == 'true' 40 | with: 41 | enable-cache: true 42 | 43 | - name: Set up Python ${{ matrix.python-version }} 44 | if: env.PUBLISH == 'true' 45 | run: uv python install ${{ matrix.python-version }} 46 | 47 | - name: Install dependencies 48 | if: env.PUBLISH == 'true' 49 | run: uv sync --all-extras --dev 50 | 51 | - name: Print python versions 52 | if: env.PUBLISH == 'true' 53 | run: | 54 | python -V 55 | uv run python -V 56 | 57 | - name: Build documentation 58 | if: env.PUBLISH == 'true' 59 | run: | 60 | pushd docs; make SPHINXBUILD='uv run sphinx-build' html; popd 61 | 62 | - name: Push documentation to S3 63 | if: env.PUBLISH == 'true' 64 | uses: jakejarvis/s3-sync-action@v0.5.1 65 | with: 66 | args: --acl public-read --follow-symlinks --delete 67 | env: 68 | AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }} 69 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 70 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 71 | AWS_REGION: 'us-west-1' # optional: defaults to us-east-1 72 | SOURCE_DIR: 'docs/_build/html' # optional: defaults to entire repository 73 | 74 | - name: Purge cache on Cloudflare 75 | if: env.PUBLISH == 'true' 76 | uses: jakejarvis/cloudflare-purge-action@v0.3.0 77 | env: 78 | CLOUDFLARE_TOKEN: ${{ secrets.CLOUDFLARE_TOKEN }} 79 | CLOUDFLARE_ZONE: ${{ secrets.CLOUDFLARE_ZONE }} 80 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository 8 | 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: ['3.9', '3.13'] 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Install uv 17 | uses: astral-sh/setup-uv@v5 18 | with: 19 | enable-cache: true 20 | 21 | - name: Set up Python ${{ matrix.python-version }} 22 | run: uv python install ${{ matrix.python-version }} 23 | 24 | - name: Install dependencies 25 | run: uv sync --all-extras --dev 26 | 27 | - name: Print python versions 28 | run: | 29 | python -V 30 | uv run python -V 31 | 32 | - name: Lint with ruff check 33 | run: uv run ruff check . 34 | 35 | - name: Format with ruff format 36 | run: uv run ruff format . --check 37 | 38 | - name: Lint with mypy 39 | run: uv run mypy . 40 | 41 | - name: Test with pytest 42 | run: uv run py.test --cov=./ --cov-append --cov-report=xml 43 | env: 44 | COV_CORE_SOURCE: . 45 | COV_CORE_CONFIG: pyproject.toml 46 | COV_CORE_DATAFILE: .coverage.eager 47 | 48 | - uses: codecov/codecov-action@v5 49 | with: 50 | token: ${{ secrets.CODECOV_TOKEN }} 51 | 52 | release: 53 | runs-on: ubuntu-latest 54 | needs: build 55 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 56 | 57 | strategy: 58 | matrix: 59 | python-version: ['3.13'] 60 | 61 | steps: 62 | - uses: actions/checkout@v4 63 | 64 | - name: Install uv 65 | uses: astral-sh/setup-uv@v5 66 | with: 67 | enable-cache: true 68 | 69 | - name: Set up Python ${{ matrix.python-version }} 70 | run: uv python install ${{ matrix.python-version }} 71 | 72 | - name: Install dependencies 73 | run: uv sync --all-extras --dev 74 | 75 | - name: Build package 76 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 77 | run: uv build 78 | 79 | - name: Publish package 80 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 81 | uses: pypa/gh-action-pypi-publish@release/v1 82 | with: 83 | user: __token__ 84 | password: ${{ secrets.PYPI_API_TOKEN }} 85 | skip_existing: true 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS 2 | .DS_Store 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | *env*/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | 59 | # Sphinx documentation 60 | docs/_build/ 61 | 62 | # PyBuilder 63 | target/ 64 | 65 | # ipython Notebook 66 | .ipynb_checkpoints 67 | 68 | # editors 69 | .idea 70 | .ropeproject 71 | *.swp 72 | .vim/ 73 | 74 | # docs 75 | doc/_build/ 76 | 77 | # mypy 78 | .mypy_cache/ 79 | 80 | *.lprof 81 | 82 | pip-wheel-metadata/ 83 | 84 | # Monkeytype 85 | monkeytype.sqlite3 86 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vcs-python/vcspull/107169f95065abaa5e32dcafc82514dd9beb8ac2/.gitmodules -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100 3 | } 4 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13.0 2 | -------------------------------------------------------------------------------- /.tmuxp.yaml: -------------------------------------------------------------------------------- 1 | session_name: vcspull 2 | start_directory: ./ # load session relative to config location (project root). 3 | shell_command_before: 4 | - uv virtualenv --quiet > /dev/null 2>&1 && clear 5 | windows: 6 | - window_name: vcspull 7 | focus: True 8 | layout: main-horizontal 9 | options: 10 | main-pane-height: 67% 11 | panes: 12 | - focus: true 13 | - pane 14 | - make watch_mypy 15 | - make watch_test 16 | - window_name: docs 17 | layout: main-horizontal 18 | options: 19 | main-pane-height: 67% 20 | start_directory: docs/ 21 | panes: 22 | - focus: true 23 | - pane 24 | - pane 25 | - make start 26 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | uv 0.7.9 2 | python 3.13.3 3.12.10 3.11.12 3.10.17 3.9.22 3.8.20 3.7.17 3 | -------------------------------------------------------------------------------- /.vim/coc-settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[markdown][python]": { 3 | "coc.preferences.formatOnSave": true 4 | }, 5 | "python.analysis.autoSearchPaths": true, 6 | "python.analysis.typeCheckingMode": "basic", 7 | "python.analysis.useLibraryCodeForTypes": true, 8 | "python.formatting.provider": "ruff", 9 | "python.linting.ruffEnabled": true, 10 | "python.linting.mypyEnabled": true, 11 | "python.linting.flake8Enabled": false, 12 | "python.linting.pyflakesEnabled": false, 13 | "python.linting.pycodestyleEnabled": false, 14 | "python.linting.banditEnabled": false, 15 | "python.linting.pylamaEnabled": false, 16 | "python.linting.pylintEnabled": false, 17 | "pyright.organizeimports.provider": "ruff", 18 | "pyright.testing.provider": "pytest", 19 | } 20 | -------------------------------------------------------------------------------- /.windsurfrules: -------------------------------------------------------------------------------- 1 | # Python Project Rules 2 | 3 | 4 | - uv - Python package management and virtual environments 5 | - ruff - Fast Python linter and formatter 6 | - py.test - Testing framework 7 | - pytest-watcher - Continuous test runner 8 | - mypy - Static type checking 9 | - doctest - Testing code examples in documentation 10 | 11 | 12 | 13 | - Use a consistent coding style throughout the project 14 | - Format code with ruff before committing 15 | - Run linting and type checking before finalizing changes 16 | - Verify tests pass after each significant change 17 | 18 | 19 | 20 | - Use reStructuredText format for all docstrings in src/**/*.py files 21 | - Keep the main description on the first line after the opening `"""` 22 | - Use NumPy docstyle for parameter and return value documentation 23 | - Format docstrings as follows: 24 | ```python 25 | """Short description of the function or class. 26 | 27 | Detailed description using reStructuredText format. 28 | 29 | Parameters 30 | ---------- 31 | param1 : type 32 | Description of param1 33 | param2 : type 34 | Description of param2 35 | 36 | Returns 37 | ------- 38 | type 39 | Description of return value 40 | """ 41 | ``` 42 | 43 | 44 | 45 | - Use narrative descriptions for test sections rather than inline comments 46 | - Format doctests as follows: 47 | ```python 48 | """ 49 | Examples 50 | -------- 51 | Create an instance: 52 | 53 | >>> obj = ExampleClass() 54 | 55 | Verify a property: 56 | 57 | >>> obj.property 58 | 'expected value' 59 | """ 60 | ``` 61 | - Add blank lines between test sections for improved readability 62 | - Keep doctests simple and focused on demonstrating usage 63 | - Move complex examples to dedicated test files at tests/examples//test_.py 64 | - Utilize pytest fixtures via doctest_namespace for complex scenarios 65 | 66 | 67 | 68 | - Run tests with `uv run py.test` before committing changes 69 | - Use pytest-watcher for continuous testing: `uv run ptw . --now --doctest-modules` 70 | - Fix any test failures before proceeding with additional changes 71 | 72 | 73 | 74 | - Make atomic commits with conventional commit messages 75 | - Start with an initial commit of functional changes 76 | - Follow with separate commits for formatting, linting, and type checking fixes 77 | 78 | 79 | 80 | - Use the following commit message format: 81 | ``` 82 | Component/File(commit-type[Subcomponent/method]): Concise description 83 | 84 | why: Explanation of necessity or impact. 85 | what: 86 | - Specific technical changes made 87 | - Focused on a single topic 88 | 89 | refs: #issue-number, breaking changes, or relevant links 90 | ``` 91 | 92 | - Common commit types: 93 | - **feat**: New features or enhancements 94 | - **fix**: Bug fixes 95 | - **refactor**: Code restructuring without functional change 96 | - **docs**: Documentation updates 97 | - **chore**: Maintenance (dependencies, tooling, config) 98 | - **test**: Test-related updates 99 | - **style**: Code style and formatting 100 | 101 | - Prefix Python package changes with: 102 | - `py(deps):` for standard packages 103 | - `py(deps[dev]):` for development packages 104 | - `py(deps[extra]):` for extras/sub-packages 105 | 106 | - General guidelines: 107 | - Subject line: Maximum 50 characters 108 | - Body lines: Maximum 72 characters 109 | - Use imperative mood (e.g., "Add", "Fix", not "Added", "Fixed") 110 | - Limit to one topic per commit 111 | - Separate subject from body with a blank line 112 | - Mark breaking changes clearly: `BREAKING:` 113 | 114 | 115 | 116 | - Use fixtures from conftest.py instead of monkeypatch and MagicMock when available 117 | - For instance, if using libtmux, use provided fixtures: server, session, window, and pane 118 | - Document in test docstrings why standard fixtures weren't used for exceptional cases 119 | - Use tmp_path (pathlib.Path) fixture over Python's tempfile 120 | - Use monkeypatch fixture over unittest.mock 121 | 122 | 123 | 124 | - Prefer namespace imports over importing specific symbols 125 | - Import modules and access attributes through the namespace: 126 | - Use `import enum` and access `enum.Enum` instead of `from enum import Enum` 127 | - This applies to standard library modules like pathlib, os, and similar cases 128 | - For typing, use `import typing as t` and access via the namespace: 129 | - Access typing elements as `t.NamedTuple`, `t.TypedDict`, etc. 130 | - Note primitive types like unions can be done via `|` pipes 131 | - Primitive types like list and dict can be done via `list` and `dict` directly 132 | - Benefits of namespace imports: 133 | - Improves code readability by making the source of symbols clear 134 | - Reduces potential naming conflicts 135 | - Makes import statements more maintainable 136 | 137 | 138 | 139 | - Use libvcs pytest fixtures for all repository-related tests in vcspull: 140 | - Create temporary repositories efficiently with factory fixtures 141 | - Benefit from automatic cleanup when tests finish 142 | - Utilize proper environment variables and configurations 143 | - Test against real VCS operations without mocking 144 | 145 | - Basic repository testing pattern: 146 | ```python 147 | def test_repository_operation(create_git_remote_repo): 148 | # Create a test repository 149 | repo_path = create_git_remote_repo() 150 | 151 | # Test vcspull functionality with the repository 152 | # ... 153 | ``` 154 | 155 | - For more complex scenarios, use the pre-configured repository instances: 156 | ```python 157 | def test_sync_operations(git_repo): 158 | # git_repo is already a GitSync instance 159 | # Test vcspull sync operations 160 | # ... 161 | ``` 162 | 163 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2014-2018 vcspull contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MIGRATION: -------------------------------------------------------------------------------- 1 | # Migration notes 2 | 3 | Migration and deprecation notes for vcspull are here, see {ref}`changelog` as 4 | well. 5 | 6 | ```{admonition} Welcome on board! 👋 7 | 1. 📌 For safety, **always** pin the package 8 | 2. 📖 Check the migration notes _(You are here)_ 9 | 3. 📣 If you feel something got deprecated and it interrupted you - past, present, or future - voice your opinion on the [tracker]. 10 | 11 | We want to make vcspull fun, reliable, and useful for users. 12 | 13 | API changes can be painful. 14 | 15 | If we can do something to draw the sting, we'll do it. We're taking a balanced approach. That's why these notes are here! 16 | 17 | (Please pin the package. 🙏) 18 | 19 | [tracker]: https://github.com/vcs-python/vcspull/discussions 20 | ``` 21 | 22 | ## Next release 23 | 24 | _Notes on the upcoming release will be added here_ 25 | 26 | 27 | 28 | ## vcspull 1.15.4 (2022-10-16) 29 | 30 | ### Completions for `-c` / `--config` files 31 | 32 | _via #403_ 33 | 34 | After updating, you can re-run [shtab]'s setup (see [completions page]) completion of: 35 | 36 | ```console 37 | $ vcspull sync -c [tab] 38 | ``` 39 | 40 | ```console 41 | $ vcspull sync --config [tab] 42 | ``` 43 | 44 | ## vcspull 1.15.0 (2022-10-09) 45 | 46 | ### Completions have changed 47 | 48 | _via #400_ 49 | 50 | Completions now use a different tool: [shtab]. See the [completions page] for more information. 51 | 52 | If you were using earlier versions of vcspull (earlier than 1.15.0), you may need to uninstall the old completions, first. 53 | 54 | [completions page]: https://vcspull.git-pull.com/cli/completion.html 55 | [shtab]: https://docs.iterative.ai/shtab/ 56 | 57 | ## vcspull v1.13.0 (2022-09-25) 58 | 59 | ### Pulling all repositories 60 | 61 | _via #394_ 62 | 63 | Empty command will now show help output 64 | 65 | ```console 66 | $ vcspull sync 67 | Usage: vcspull sync [OPTIONS] [REPO_TERMS]... 68 | 69 | Options: 70 | -c, --config PATH Specify config 71 | -x, --exit-on-error Exit immediately when encountering an error syncing 72 | multiple repos 73 | -h, --help Show this message and exit. 74 | ``` 75 | 76 | To achieve the equivalent behavior of syncing all repos, pass `'*'`: 77 | 78 | ```console 79 | $ vcspull sync '*' 80 | ``` 81 | 82 | Depending on how shell escaping works in your shell setup with [wild card / asterisk], you may not need to quote `*`. 83 | 84 | [wild card / asterisk]: https://tldp.org/LDP/abs/html/special-chars.html#:~:text=wild%20card%20%5Basterisk%5D. 85 | 86 | ### Terms with no match in config will show a notice 87 | 88 | _via #394_ 89 | 90 | > No repo found in config(s) for "non_existent_repo" 91 | 92 | - Syncing will now skip to the next repos if an error is encountered 93 | 94 | - Learned `--exit-on-error` / `-x` 95 | 96 | Usage: 97 | 98 | ```console 99 | $ vcspull sync --exit-on-error grako django 100 | ``` 101 | 102 | Print traceback for errored repos: 103 | 104 | ```console 105 | $ vcspull --log-level DEBUG sync --exit-on-error grako django 106 | ``` 107 | 108 | ### Untracked files 109 | 110 | _via https://github.com/vcs-python/libvcs/pull/425_ 111 | 112 | Syncing in git repositories with untracked files has been improved (via libvcs 113 | 0.17) 114 | 115 | 118 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PY_FILES= find . -type f -not -path '*/\.*' | grep -i '.*[.]py$$' 2> /dev/null 2 | DOC_FILES= find . -type f -not -path '*/\.*' | grep -i '.*[.]rst\$\|.*[.]md\$\|.*[.]css\$\|.*[.]py\$\|mkdocs\.yml\|CHANGES\|TODO\|.*conf\.py' 2> /dev/null 3 | SHELL := /bin/bash 4 | 5 | 6 | entr_warn: 7 | @echo "----------------------------------------------------------" 8 | @echo " ! File watching functionality non-operational ! " 9 | @echo " " 10 | @echo "Install entr(1) to automatically run tasks on file change." 11 | @echo "See https://eradman.com/entrproject/ " 12 | @echo "----------------------------------------------------------" 13 | 14 | test: 15 | uv run py.test $(test) 16 | 17 | start: 18 | $(MAKE) test; uv run ptw . 19 | 20 | watch_test: 21 | if command -v entr > /dev/null; then ${PY_FILES} | entr -c $(MAKE) test; else $(MAKE) test entr_warn; fi 22 | 23 | build_docs: 24 | $(MAKE) -C docs html 25 | 26 | start_docs: 27 | $(MAKE) -C docs start 28 | 29 | design_docs: 30 | $(MAKE) -C docs design 31 | 32 | ruff_format: 33 | uv run ruff format . 34 | 35 | ruff: 36 | uv run ruff check . 37 | 38 | watch_ruff: 39 | if command -v entr > /dev/null; then ${PY_FILES} | entr -c $(MAKE) ruff; else $(MAKE) ruff entr_warn; fi 40 | 41 | mypy: 42 | uv run mypy `${PY_FILES}` 43 | 44 | watch_mypy: 45 | if command -v entr > /dev/null; then ${PY_FILES} | entr -c $(MAKE) mypy; else $(MAKE) mypy entr_warn; fi 46 | 47 | format_markdown: 48 | prettier --parser=markdown -w *.md docs/*.md docs/**/*.md CHANGES 49 | 50 | monkeytype_create: 51 | uv run monkeytype run `uv run which py.test` 52 | 53 | monkeytype_apply: 54 | uv run monkeytype list-modules | xargs -n1 -I{} sh -c 'uv run monkeytype apply {}' 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # $ vcspull · [![Python Package](https://img.shields.io/pypi/v/vcspull.svg)](https://pypi.org/project/vcspull/) [![License](https://img.shields.io/github/license/vcs-python/vcspull.svg)](https://github.com/vcs-python/vcspull/blob/master/LICENSE) [![Code Coverage](https://codecov.io/gh/vcs-python/vcspull/branch/master/graph/badge.svg)](https://codecov.io/gh/vcs-python/vcspull) 2 | 3 | Manage and sync multiple git, svn, and mercurial repos via JSON or YAML file. Compare to 4 | [myrepos], [mu-repo]. Built on [libvcs]. 5 | 6 | Great if you use the same repos at the same locations across multiple 7 | machines or want to clone / update a pattern of repos without having to 8 | `cd` into each one. 9 | 10 | - clone / update to the latest repos with `$ vcspull` 11 | - use filters to specify a location, repo url or pattern in the 12 | manifest to clone / update 13 | - supports svn, git, hg version control systems 14 | - automatically checkout fresh repositories 15 | - supports [pip](https://pip.pypa.io/)-style URL's 16 | ([RFC3986](https://datatracker.ietf.org/doc/html/rfc3986)-based [url 17 | scheme](https://pip.pypa.io/en/latest/topics/vcs-support/)) 18 | 19 | See the [documentation](https://vcspull.git-pull.com/), [configuration](https://vcspull.git-pull.com/configuration/) examples, and [config generators](https://vcspull.git-pull.com/configuration/generation.html). 20 | 21 | [myrepos]: http://myrepos.branchable.com/ 22 | [mu-repo]: http://fabioz.github.io/mu-repo/ 23 | 24 | # How to 25 | 26 | ## Install 27 | 28 | ```console 29 | $ pip install --user vcspull 30 | ``` 31 | 32 | ### Developmental releases 33 | 34 | You can test the unpublished version of vcspull before its released. 35 | 36 | - [pip](https://pip.pypa.io/en/stable/): 37 | 38 | ```console 39 | $ pip install --user --upgrade --pre vcspull 40 | ``` 41 | 42 | - [pipx](https://pypa.github.io/pipx/docs/): 43 | 44 | ```console 45 | $ pipx install --suffix=@next 'vcspull' --pip-args '\--pre' --force 46 | ``` 47 | 48 | Then use `vcspull@next sync [config]...`. 49 | 50 | ## Configuration 51 | 52 | Add your repos to `~/.vcspull.yaml`. 53 | 54 | _vcspull does not currently scan for repos on your system, but it may in 55 | the future_ 56 | 57 | ```yaml 58 | ~/code/: 59 | flask: "git+https://github.com/mitsuhiko/flask.git" 60 | ~/study/c: 61 | awesome: "git+git://git.naquadah.org/awesome.git" 62 | ~/study/data-structures-algorithms/c: 63 | libds: "git+https://github.com/zhemao/libds.git" 64 | algoxy: 65 | repo: "git+https://github.com/liuxinyu95/AlgoXY.git" 66 | remotes: 67 | tony: "git+ssh://git@github.com/tony/AlgoXY.git" 68 | ``` 69 | 70 | (see the author's 71 | [.vcspull.yaml](https://github.com/tony/.dot-config/blob/master/.vcspull.yaml), 72 | more [configuration](https://vcspull.git-pull.com/configuration.html)) 73 | 74 | `$HOME/.vcspull.yaml` and `$XDG_CONFIG_HOME/vcspull/` (`~/.config/vcspull`) can 75 | be used as a declarative manifest to clone you repos consistently across 76 | machines. Subsequent syncs of nitialized repos will fetch the latest commits. 77 | 78 | ## Sync your repos 79 | 80 | ```console 81 | $ vcspull sync 82 | ``` 83 | 84 | Keep nested VCS repositories updated too, lets say you have a mercurial 85 | or svn project with a git dependency: 86 | 87 | `external_deps.yaml` in your project root (any filename will do): 88 | 89 | ```yaml 90 | ./vendor/: 91 | sdl2pp: "git+https://github.com/libSDL2pp/libSDL2pp.git" 92 | ``` 93 | 94 | Clone / update repos via config file: 95 | 96 | ```console 97 | $ vcspull sync -c external_deps.yaml '*' 98 | ``` 99 | 100 | See the [Quickstart](https://vcspull.git-pull.com/quickstart.html) for 101 | more. 102 | 103 | ## Pulling specific repos 104 | 105 | Have a lot of repos? 106 | 107 | you can choose to update only select repos through 108 | [fnmatch](http://pubs.opengroup.org/onlinepubs/009695399/functions/fnmatch.html) 109 | patterns. remember to add the repos to your `~/.vcspull.{json,yaml}` 110 | first. 111 | 112 | The patterns can be filtered by by directory, repo name or vcs url. 113 | 114 | Any repo starting with "fla": 115 | 116 | ```console 117 | $ vcspull sync "fla*" 118 | ``` 119 | 120 | Any repo with django in the name: 121 | 122 | ```console 123 | $ vcspull sync "*django*" 124 | ``` 125 | 126 | Search by vcs + url, since urls are in this format +://: 127 | 128 | ```console 129 | $ vcspull sync "git+*" 130 | ``` 131 | 132 | Any git repo with python in the vcspull: 133 | 134 | ```console 135 | $ vcspull sync "git+*python* 136 | ``` 137 | 138 | Any git repo with django in the vcs url: 139 | 140 | ```console 141 | $ vcspull sync "git+*django*" 142 | ``` 143 | 144 | All repositories in your ~/code directory: 145 | 146 | ```console 147 | $ vcspull sync "$HOME/code/*" 148 | ``` 149 | 150 | [libvcs]: https://github.com/vcs-python/libvcs 151 | 152 | image 153 | 154 | # Donations 155 | 156 | Your donations fund development of new features, testing and support. 157 | Your money will go directly to maintenance and development of the 158 | project. If you are an individual, feel free to give whatever feels 159 | right for the value you get out of the project. 160 | 161 | See donation options at . 162 | 163 | # More information 164 | 165 | - Python support: >= 3.9, pypy 166 | - VCS supported: git(1), svn(1), hg(1) 167 | - Source: 168 | - Docs: 169 | - Changelog: 170 | - API: 171 | - Issues: 172 | - Test Coverage: 173 | - pypi: 174 | - Open Hub: 175 | - License: [MIT](https://opensource.org/licenses/MIT). 176 | 177 | [![Docs](https://github.com/vcs-python/vcspull/workflows/docs/badge.svg)](https://vcspull.git-pull.com) [![Build Status](https://github.com/vcs-python/vcspull/workflows/tests/badge.svg)](https://github.com/vcs-python/vcspull/actions?query=workflow%3A%22tests%22) 178 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | """Conftest.py (root-level). 2 | 3 | We keep this in root pytest fixtures in pytest's doctest plugin to be available, as well 4 | as avoiding conftest.py from being included in the wheel, in addition to pytest_plugin 5 | for pytester only being available via the root directory. 6 | 7 | See "pytest_plugins in non-top-level conftest files" in 8 | https://docs.pytest.org/en/stable/deprecations.html 9 | """ 10 | 11 | from __future__ import annotations 12 | 13 | import shutil 14 | import typing as t 15 | 16 | import pytest 17 | 18 | if t.TYPE_CHECKING: 19 | import pathlib 20 | 21 | 22 | @pytest.fixture(autouse=True) 23 | def add_doctest_fixtures( 24 | request: pytest.FixtureRequest, 25 | doctest_namespace: dict[str, t.Any], 26 | ) -> None: 27 | """Harness pytest fixtures to doctests namespace.""" 28 | from _pytest.doctest import DoctestItem 29 | 30 | if isinstance(request._pyfuncitem, DoctestItem): 31 | request.getfixturevalue("add_doctest_fixtures") 32 | request.getfixturevalue("set_home") 33 | 34 | 35 | @pytest.fixture(autouse=True) 36 | def setup( 37 | request: pytest.FixtureRequest, 38 | gitconfig: pathlib.Path, 39 | set_home: pathlib.Path, 40 | xdg_config_path: pathlib.Path, 41 | ) -> None: 42 | """Automatically load the pytest fixtures in the parameters.""" 43 | 44 | 45 | @pytest.fixture(autouse=True) 46 | def cwd_default(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None: 47 | """Change the current directory to a temporary directory.""" 48 | monkeypatch.chdir(tmp_path) 49 | 50 | 51 | @pytest.fixture(autouse=True) 52 | def xdg_config_path( 53 | user_path: pathlib.Path, 54 | set_home: pathlib.Path, 55 | ) -> pathlib.Path: 56 | """Create and return path to use for XDG Config Path.""" 57 | p = user_path / ".config" 58 | if not p.exists(): 59 | p.mkdir() 60 | return p 61 | 62 | 63 | @pytest.fixture 64 | def config_path( 65 | xdg_config_path: pathlib.Path, 66 | request: pytest.FixtureRequest, 67 | ) -> pathlib.Path: 68 | """Ensure and return vcspull configuration path.""" 69 | conf_path = xdg_config_path / "vcspull" 70 | conf_path.mkdir(exist_ok=True) 71 | 72 | def clean() -> None: 73 | shutil.rmtree(conf_path) 74 | 75 | request.addfinalizer(clean) 76 | return conf_path 77 | 78 | 79 | @pytest.fixture(autouse=True) 80 | def set_xdg_config_path( 81 | monkeypatch: pytest.MonkeyPatch, 82 | xdg_config_path: pathlib.Path, 83 | ) -> None: 84 | """Set XDG_CONFIG_HOME environment variable.""" 85 | monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg_config_path)) 86 | 87 | 88 | @pytest.fixture 89 | def repos_path(user_path: pathlib.Path, request: pytest.FixtureRequest) -> pathlib.Path: 90 | """Return temporary directory for repository checkout guaranteed unique.""" 91 | path = user_path / "repos" 92 | path.mkdir(exist_ok=True) 93 | 94 | def clean() -> None: 95 | shutil.rmtree(path) 96 | 97 | request.addfinalizer(clean) 98 | return path 99 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | SHELL := /bin/bash 4 | HTTP_PORT = 8022 5 | WATCH_FILES= find .. -type f -not -path '*/\.*' | grep -i '.*[.]\(rst\|md\)\$\|.*[.]py\$\|CHANGES\|TODO\|.*conf\.py' 2> /dev/null 6 | 7 | # You can set these variables from the command line. 8 | SPHINXOPTS = 9 | SPHINXBUILD = sphinx-build 10 | PAPER = 11 | BUILDDIR = _build 12 | 13 | # Internal variables. 14 | PAPEROPT_a4 = -D latex_paper_size=a4 15 | PAPEROPT_letter = -D latex_paper_size=letter 16 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 17 | # the i18n builder cannot share the environment and doctrees with the others 18 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | 20 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 21 | 22 | help: 23 | @echo "Please use \`make ' where is one of" 24 | @echo " html to make standalone HTML files" 25 | @echo " dirhtml to make HTML files named index.html in directories" 26 | @echo " singlehtml to make a single large HTML file" 27 | @echo " pickle to make pickle files" 28 | @echo " json to make JSON files" 29 | @echo " htmlhelp to make HTML files and a HTML help project" 30 | @echo " qthelp to make HTML files and a qthelp project" 31 | @echo " devhelp to make HTML files and a Devhelp project" 32 | @echo " epub to make an epub" 33 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 34 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 35 | @echo " text to make text files" 36 | @echo " man to make manual pages" 37 | @echo " texinfo to make Texinfo files" 38 | @echo " info to make Texinfo files and run them through makeinfo" 39 | @echo " gettext to make PO message catalogs" 40 | @echo " changes to make an overview of all changed/added/deprecated items" 41 | @echo " linkcheck to check all external links for integrity" 42 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 43 | 44 | clean: 45 | -rm -rf $(BUILDDIR)/* 46 | 47 | html: 48 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 49 | @echo 50 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 51 | 52 | dirhtml: 53 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 56 | 57 | singlehtml: 58 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 59 | @echo 60 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 61 | 62 | pickle: 63 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 64 | @echo 65 | @echo "Build finished; now you can process the pickle files." 66 | 67 | json: 68 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 69 | @echo 70 | @echo "Build finished; now you can process the JSON files." 71 | 72 | htmlhelp: 73 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 74 | @echo 75 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 76 | ".hhp project file in $(BUILDDIR)/htmlhelp." 77 | 78 | qthelp: 79 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 80 | @echo 81 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 82 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 83 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/vcspull.qhcp" 84 | @echo "To view the help file:" 85 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/vcspull.qhc" 86 | 87 | devhelp: 88 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 89 | @echo 90 | @echo "Build finished." 91 | @echo "To view the help file:" 92 | @echo "# mkdir -p $$HOME/.local/share/devhelp/vcspull" 93 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/vcspull" 94 | @echo "# devhelp" 95 | 96 | epub: 97 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 98 | @echo 99 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 100 | 101 | latex: 102 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 103 | @echo 104 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 105 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 106 | "(use \`make latexpdf' here to do that automatically)." 107 | 108 | latexpdf: 109 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 110 | @echo "Running LaTeX files through pdflatex..." 111 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 112 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 113 | 114 | text: 115 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 116 | @echo 117 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 118 | 119 | man: 120 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 121 | @echo 122 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 123 | 124 | texinfo: 125 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 126 | @echo 127 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 128 | @echo "Run \`make' in that directory to run these through makeinfo" \ 129 | "(use \`make info' here to do that automatically)." 130 | 131 | info: 132 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 133 | @echo "Running Texinfo files through makeinfo..." 134 | make -C $(BUILDDIR)/texinfo info 135 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 136 | 137 | gettext: 138 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 139 | @echo 140 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 141 | 142 | changes: 143 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 144 | @echo 145 | @echo "The overview file is in $(BUILDDIR)/changes." 146 | 147 | linkcheck: 148 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 149 | @echo 150 | @echo "Link check complete; look for any errors in the above output " \ 151 | "or in $(BUILDDIR)/linkcheck/output.txt." 152 | 153 | doctest: 154 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 155 | @echo "Testing of doctests in the sources finished, look at the " \ 156 | "results in $(BUILDDIR)/doctest/output.txt." 157 | 158 | redirects: 159 | $(SPHINXBUILD) -b rediraffewritediff $(ALLSPHINXOPTS) $(BUILDDIR)/redirect 160 | @echo 161 | @echo "Build finished. The redirects are in rediraffe_redirects." 162 | 163 | checkbuild: 164 | rm -rf $(BUILDDIR) 165 | $(SPHINXBUILD) -n -q ./ $(BUILDDIR) 166 | 167 | watch: 168 | if command -v entr > /dev/null; then ${WATCH_FILES} | entr -c $(MAKE) html; else $(MAKE) html; fi 169 | 170 | serve: 171 | @echo '=================================================' 172 | @echo 173 | @echo 'docs server running at http://localhost:${HTTP_PORT}/' 174 | @echo 175 | @echo '=================================================' 176 | @$(MAKE) serve_py3 177 | 178 | serve_py3: 179 | python -m http.server ${HTTP_PORT} --directory _build/html 180 | 181 | dev: 182 | $(MAKE) -j watch serve 183 | 184 | start: 185 | uv run sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) --port ${HTTP_PORT} $(O) 186 | 187 | design: 188 | # This adds additional watch directories (for _static file changes) and disable incremental builds 189 | uv run sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) --port ${HTTP_PORT} --watch "." -a $(O) 190 | -------------------------------------------------------------------------------- /docs/_static/css/custom.css: -------------------------------------------------------------------------------- 1 | .sidebar-tree p.indented-block { 2 | padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal) 0 3 | var(--sidebar-item-spacing-horizontal); 4 | margin-bottom: 0; 5 | } 6 | 7 | .sidebar-tree p.indented-block span.indent { 8 | margin-left: var(--sidebar-item-spacing-horizontal); 9 | display: block; 10 | } 11 | 12 | .sidebar-tree p.indented-block .project-name { 13 | font-size: var(--sidebar-item-font-size); 14 | font-weight: bold; 15 | margin-right: calc(var(--sidebar-item-spacing-horizontal) / 2.5); 16 | } 17 | 18 | .sidebar-tree .active { 19 | font-weight: bold; 20 | } 21 | -------------------------------------------------------------------------------- /docs/_static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vcs-python/vcspull/107169f95065abaa5e32dcafc82514dd9beb8ac2/docs/_static/favicon.ico -------------------------------------------------------------------------------- /docs/_static/img/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vcs-python/vcspull/107169f95065abaa5e32dcafc82514dd9beb8ac2/docs/_static/img/icons/icon-128x128.png -------------------------------------------------------------------------------- /docs/_static/img/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vcs-python/vcspull/107169f95065abaa5e32dcafc82514dd9beb8ac2/docs/_static/img/icons/icon-144x144.png -------------------------------------------------------------------------------- /docs/_static/img/icons/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vcs-python/vcspull/107169f95065abaa5e32dcafc82514dd9beb8ac2/docs/_static/img/icons/icon-152x152.png -------------------------------------------------------------------------------- /docs/_static/img/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vcs-python/vcspull/107169f95065abaa5e32dcafc82514dd9beb8ac2/docs/_static/img/icons/icon-192x192.png -------------------------------------------------------------------------------- /docs/_static/img/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vcs-python/vcspull/107169f95065abaa5e32dcafc82514dd9beb8ac2/docs/_static/img/icons/icon-384x384.png -------------------------------------------------------------------------------- /docs/_static/img/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vcs-python/vcspull/107169f95065abaa5e32dcafc82514dd9beb8ac2/docs/_static/img/icons/icon-512x512.png -------------------------------------------------------------------------------- /docs/_static/img/icons/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vcs-python/vcspull/107169f95065abaa5e32dcafc82514dd9beb8ac2/docs/_static/img/icons/icon-72x72.png -------------------------------------------------------------------------------- /docs/_static/img/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vcs-python/vcspull/107169f95065abaa5e32dcafc82514dd9beb8ac2/docs/_static/img/icons/icon-96x96.png -------------------------------------------------------------------------------- /docs/_static/img/vcspull-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | vcspull (logo) 21 | 44 | 46 | 55 | 59 | 63 | 64 | 73 | 77 | 81 | 82 | 83 | 88 | 93 | Circle object (shape) 95 | 96 | 101 | Arrow 1 object (Group) 103 | 108 | Arrow 1 Shadow object (Shape) 110 | 111 | 116 | Arrow 1 object (Shape) 118 | 119 | 120 | 125 | Arrow 2 object (Group) 127 | 132 | Arrow 2 Shadow object (Shape) 134 | 135 | 140 | Arrow 2 object (Shape) 142 | 143 | 144 | 145 | 147 | 148 | 150 | vcspull (logo) 151 | 152 | 153 | 154 | 155 | -------------------------------------------------------------------------------- /docs/_static/img/vcspull.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | vcspull 24 | 47 | 49 | 52 | 55 | 56 | 65 | 69 | 73 | 74 | 77 | 80 | 81 | 90 | 94 | 98 | 99 | 102 | 105 | 106 | 115 | 119 | 123 | 124 | 127 | 130 | 131 | 132 | 137 | 142 | Circle object (shape) 144 | 145 | 150 | Arrow 1 object (Group) 152 | 157 | Arrow 1 Shadow object (Shape) 159 | 160 | 165 | Arrow 1 object (Shape) 167 | 168 | 169 | 174 | Arrow 2 object (Group) 176 | 181 | Arrow 2 Shadow object (Shape) 183 | 184 | 189 | Arrow 2 object (Shape) 191 | 192 | 193 | 194 | 196 | 197 | 199 | vcspull 200 | 201 | 202 | 203 | 204 | -------------------------------------------------------------------------------- /docs/_static/vcspull-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vcs-python/vcspull/107169f95065abaa5e32dcafc82514dd9beb8ac2/docs/_static/vcspull-demo.gif -------------------------------------------------------------------------------- /docs/_static/vcspull-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vcs-python/vcspull/107169f95065abaa5e32dcafc82514dd9beb8ac2/docs/_static/vcspull-screenshot.png -------------------------------------------------------------------------------- /docs/_templates/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "!layout.html" %} 2 | {%- block extrahead %} 3 | {{ super() }} 4 | {%- if theme_show_meta_manifest_tag == true %} 5 | 6 | {% endif -%} 7 | {%- if theme_show_meta_og_tags == true %} 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {% endif -%} 24 | {%- if theme_show_meta_app_icon_tags == true %} 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | {% endif -%} 45 | {% endblock %} 46 | -------------------------------------------------------------------------------- /docs/_templates/sidebar/projects.html: -------------------------------------------------------------------------------- 1 | 56 | 70 | -------------------------------------------------------------------------------- /docs/api/cli/index.md: -------------------------------------------------------------------------------- 1 | (api_cli)= 2 | 3 | (api_commands)= 4 | 5 | # CLI 6 | 7 | ```{toctree} 8 | :caption: General commands 9 | :maxdepth: 1 10 | 11 | sync 12 | ``` 13 | 14 | ## vcspull CLI - `vcspull.cli` 15 | 16 | ```{eval-rst} 17 | .. automodule:: vcspull.cli 18 | :members: 19 | :show-inheritance: 20 | :undoc-members: 21 | ``` 22 | -------------------------------------------------------------------------------- /docs/api/cli/sync.md: -------------------------------------------------------------------------------- 1 | # vcspull sync - `vcspull.cli.sync` 2 | 3 | ```{eval-rst} 4 | .. automodule:: vcspull.cli.sync 5 | :members: 6 | :show-inheritance: 7 | :undoc-members: 8 | ``` 9 | -------------------------------------------------------------------------------- /docs/api/config.md: -------------------------------------------------------------------------------- 1 | # Config - `vcspull.config` 2 | 3 | ```{eval-rst} 4 | .. automodule:: vcspull.config 5 | :members: 6 | :show-inheritance: 7 | :undoc-members: 8 | ``` 9 | -------------------------------------------------------------------------------- /docs/api/exc.md: -------------------------------------------------------------------------------- 1 | # Exceptions - `vcspull.exc` 2 | 3 | ```{eval-rst} 4 | .. automodule:: vcspull.exc 5 | :members: 6 | :show-inheritance: 7 | :undoc-members: 8 | ``` 9 | -------------------------------------------------------------------------------- /docs/api/index.md: -------------------------------------------------------------------------------- 1 | (api)= 2 | 3 | # API Reference 4 | 5 | :::{seealso} 6 | For granular control see {ref}`libvcs `'s {ref}`Commands ` and {ref}`Projects `. 7 | ::: 8 | 9 | ## Internals 10 | 11 | :::{warning} 12 | Be careful with these! Internal APIs are **not** covered by version policies. They can break or be removed between minor versions! 13 | 14 | If you need an internal API stabilized please [file an issue](https://github.com/vcs-python/vcspull/issues). 15 | ::: 16 | 17 | ```{toctree} 18 | config 19 | cli/index 20 | exc 21 | log 22 | internals/index 23 | validator 24 | util 25 | types 26 | ``` 27 | -------------------------------------------------------------------------------- /docs/api/internals/config_reader.md: -------------------------------------------------------------------------------- 1 | # Config reader - `vcspull._internal.config_reader` 2 | 3 | :::{warning} 4 | Be careful with these! Internal APIs are **not** covered by version policies. They can break or be removed between minor versions! 5 | 6 | If you need an internal API stabilized please [file an issue](https://github.com/vcs-python/vcspull/issues). 7 | ::: 8 | 9 | ```{eval-rst} 10 | .. automodule:: vcspull._internal.config_reader 11 | :members: 12 | :show-inheritance: 13 | :undoc-members: 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/api/internals/index.md: -------------------------------------------------------------------------------- 1 | (internals)= 2 | 3 | # Internals 4 | 5 | :::{warning} 6 | Be careful with these! Internal APIs are **not** covered by version policies. They can break or be removed between minor versions! 7 | 8 | If you need an internal API stabilized please [file an issue](https://github.com/tmux-python/tmuxp/issues). 9 | ::: 10 | 11 | ```{toctree} 12 | config_reader 13 | ``` 14 | -------------------------------------------------------------------------------- /docs/api/log.md: -------------------------------------------------------------------------------- 1 | # Logging - `vcspull.log` 2 | 3 | ```{eval-rst} 4 | .. automodule:: vcspull.log 5 | :members: 6 | :show-inheritance: 7 | :undoc-members: 8 | ``` 9 | -------------------------------------------------------------------------------- /docs/api/types.md: -------------------------------------------------------------------------------- 1 | # Typings - `vcspull.types` 2 | 3 | ```{eval-rst} 4 | .. automodule:: vcspull.types 5 | :members: 6 | :show-inheritance: 7 | :undoc-members: 8 | ``` 9 | -------------------------------------------------------------------------------- /docs/api/util.md: -------------------------------------------------------------------------------- 1 | # Utilities - `vcspull.util` 2 | 3 | ```{eval-rst} 4 | .. automodule:: vcspull.util 5 | :members: 6 | :show-inheritance: 7 | :undoc-members: 8 | ``` 9 | -------------------------------------------------------------------------------- /docs/api/validator.md: -------------------------------------------------------------------------------- 1 | # Validation - `vcspull.validator` 2 | 3 | ```{eval-rst} 4 | .. automodule:: vcspull.validator 5 | :members: 6 | :show-inheritance: 7 | :undoc-members: 8 | ``` 9 | -------------------------------------------------------------------------------- /docs/cli/completion.md: -------------------------------------------------------------------------------- 1 | (completion)= 2 | 3 | (completions)= 4 | 5 | (cli-completions)= 6 | 7 | # Completions 8 | 9 | ## vcspull 1.15+ (experimental) 10 | 11 | ```{note} 12 | See the [shtab library's documentation on shell completion](https://docs.iterative.ai/shtab/use/#cli-usage) for the most up to date way of connecting completion for vcspull. 13 | ``` 14 | 15 | Provisional support for completions in vcspull 1.15+ are powered by [shtab](https://docs.iterative.ai/shtab/). This must be **installed separately**, as it's **not currently bundled with vcspull**. 16 | 17 | ```console 18 | $ pip install shtab --user 19 | ``` 20 | 21 | :::{tab} bash 22 | 23 | ```bash 24 | shtab --shell=bash -u vcspull.cli.create_parser \ 25 | | sudo tee "$BASH_COMPLETION_COMPAT_DIR"/VCSPULL 26 | ``` 27 | 28 | ::: 29 | 30 | :::{tab} zsh 31 | 32 | ```zsh 33 | shtab --shell=zsh -u vcspull.cli.create_parser \ 34 | | sudo tee /usr/local/share/zsh/site-functions/_VCSPULL 35 | ``` 36 | 37 | ::: 38 | 39 | :::{tab} tcsh 40 | 41 | ```zsh 42 | shtab --shell=tcsh -u vcspull.cli.create_parser \ 43 | | sudo tee /etc/profile.d/VCSPULL.completion.csh 44 | ``` 45 | 46 | ::: 47 | 48 | ## vcspull 0.9 to 1.14 49 | 50 | ```{note} 51 | See the [click library's documentation on shell completion](https://click.palletsprojects.com/en/8.0.x/shell-completion/) for the most up to date way of connecting completion for vcspull. 52 | ``` 53 | 54 | vcspull 0.9 to 1.14 use [click](https://click.palletsprojects.com)'s completion: 55 | 56 | :::{tab} bash 57 | 58 | _~/.bashrc_: 59 | 60 | ```bash 61 | 62 | eval "$(_VCSPULL_COMPLETE=bash_source vcspull)" 63 | 64 | ``` 65 | 66 | ::: 67 | 68 | :::{tab} zsh 69 | 70 | _~/.zshrc_: 71 | 72 | ```zsh 73 | 74 | eval "$(_VCSPULL_COMPLETE=zsh_source vcspull)" 75 | 76 | ``` 77 | 78 | ::: 79 | -------------------------------------------------------------------------------- /docs/cli/index.md: -------------------------------------------------------------------------------- 1 | (cli)= 2 | 3 | # Commands 4 | 5 | ```{toctree} 6 | :caption: General commands 7 | :maxdepth: 1 8 | 9 | sync 10 | ``` 11 | 12 | ```{toctree} 13 | :caption: Completion 14 | :maxdepth: 1 15 | 16 | completion 17 | ``` 18 | 19 | (cli-main)= 20 | 21 | (vcspull-main)= 22 | 23 | ## Command: `vcspull` 24 | 25 | ```{eval-rst} 26 | .. argparse:: 27 | :module: vcspull.cli 28 | :func: create_parser 29 | :prog: vcspull 30 | :nosubcommands: 31 | :nodescription: 32 | 33 | subparser_name : @replace 34 | See :ref:`cli-sync` 35 | ``` 36 | -------------------------------------------------------------------------------- /docs/cli/sync.md: -------------------------------------------------------------------------------- 1 | (cli-sync)= 2 | 3 | (vcspull-sync)= 4 | 5 | # vcspull sync 6 | 7 | ## Command 8 | 9 | ```{eval-rst} 10 | .. argparse:: 11 | :module: vcspull.cli 12 | :func: create_parser 13 | :prog: vcspull 14 | :path: sync 15 | :nodescription: 16 | ``` 17 | 18 | ## Filtering repos 19 | 20 | As of 1.13.x, `$ vcspull sync` with no args passed will show a help dialog: 21 | 22 | ```console 23 | $ vcspull sync 24 | Usage: vcspull sync [OPTIONS] [REPO_TERMS]... 25 | ``` 26 | 27 | ### Sync all repos 28 | 29 | Depending on how your terminal works with shell escapes for expands such as the [wild card / asterisk], you may not need to quote `*`. 30 | 31 | ```console 32 | $ vcspull sync '*' 33 | ``` 34 | 35 | [wild card / asterisk]: https://tldp.org/LDP/abs/html/special-chars.html#:~:text=wild%20card%20%5Basterisk%5D. 36 | 37 | ### Filtering 38 | 39 | Filter all repos start with "django-": 40 | 41 | ```console 42 | $ vcspull sync 'django-*' 43 | ``` 44 | 45 | ### Multiple terms 46 | 47 | Filter all repos start with "django-": 48 | 49 | ```console 50 | $ vcspull sync 'django-anymail' 'django-guardian' 51 | ``` 52 | 53 | ## Error handling 54 | 55 | ### Repos not found in config 56 | 57 | As of 1.13.x, if you enter a repo term (or terms) that aren't found throughout 58 | your configurations, it will show a warning: 59 | 60 | ```console 61 | $ vcspull sync non_existent_repo 62 | No repo found in config(s) for "non_existent_repo" 63 | ``` 64 | 65 | ```console 66 | $ vcspull sync non_existent_repo existing_repo 67 | No repo found in config(s) for "non_existent_repo" 68 | ``` 69 | 70 | ```console 71 | $ vcspull sync non_existent_repo existing_repo another_repo_not_in_config 72 | No repo found in config(s) for "non_existent_repo" 73 | No repo found in config(s) for "another_repo_not_in_config" 74 | ``` 75 | 76 | Since syncing terms are treated as a filter rather than a lookup, the message is 77 | considered a warning, so will not exit even if `--exit-on-error` flag is used. 78 | 79 | ### Syncing 80 | 81 | As of 1.13.x, vcspull will continue to the next repo if an error is encountered when syncing multiple repos. 82 | 83 | To imitate the old behavior, the `--exit-on-error` / `-x` flag: 84 | 85 | ```console 86 | $ vcspull sync --exit-on-error grako django 87 | ``` 88 | 89 | Print traceback for errored repos: 90 | 91 | ```console 92 | $ vcspull --log-level DEBUG sync --exit-on-error grako django 93 | ``` 94 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | """Sphinx configuration for vcspull documentation.""" 2 | 3 | # flake8: noqa: E501 4 | from __future__ import annotations 5 | 6 | import inspect 7 | import pathlib 8 | import sys 9 | import typing as t 10 | from os.path import relpath 11 | 12 | import vcspull 13 | 14 | if t.TYPE_CHECKING: 15 | from sphinx.application import Sphinx 16 | 17 | # Get the project root dir, which is the parent dir of this 18 | cwd = pathlib.Path.cwd() 19 | project_root = cwd.parent 20 | src_root = project_root / "src" 21 | 22 | sys.path.insert(0, str(src_root)) 23 | sys.path.insert(0, str(cwd / "_ext")) 24 | 25 | # package data 26 | about: dict[str, str] = {} 27 | with (src_root / "vcspull" / "__about__.py").open() as fp: 28 | exec(fp.read(), about) 29 | 30 | extensions = [ 31 | "sphinx.ext.autodoc", 32 | "sphinx.ext.intersphinx", 33 | "sphinx_autodoc_typehints", 34 | "sphinx.ext.todo", 35 | "sphinx.ext.napoleon", 36 | "sphinx.ext.linkcode", 37 | "sphinxarg.ext", # sphinx-argparse 38 | "sphinx_inline_tabs", 39 | "sphinx_copybutton", 40 | "sphinxext.opengraph", 41 | "sphinxext.rediraffe", 42 | "myst_parser", 43 | "linkify_issues", 44 | ] 45 | myst_enable_extensions = [ 46 | "colon_fence", 47 | "substitution", 48 | "replacements", 49 | "strikethrough", 50 | "linkify", 51 | ] 52 | 53 | templates_path = ["_templates"] 54 | 55 | source_suffix = {".rst": "restructuredtext", ".md": "markdown"} 56 | 57 | master_doc = "index" 58 | 59 | project = about["__title__"] 60 | project_copyright = about["__copyright__"] 61 | 62 | version = "{}".format(".".join(about["__version__"].split("."))[:2]) 63 | release = "{}".format(about["__version__"]) 64 | 65 | exclude_patterns = ["_build"] 66 | 67 | pygments_style = "monokai" 68 | pygments_dark_style = "monokai" 69 | 70 | html_favicon = "_static/favicon.ico" 71 | html_static_path = ["_static"] 72 | html_css_files = ["css/custom.css"] 73 | html_extra_path = ["manifest.json"] 74 | html_theme = "furo" 75 | html_theme_path: list[str] = [] 76 | html_theme_options = { 77 | "light_logo": "img/vcspull.svg", 78 | "dark_logo": "img/vcspull-dark.svg", 79 | "footer_icons": [ 80 | { 81 | "name": "GitHub", 82 | "url": about["__github__"], 83 | "html": """ 84 | 85 | 86 | 87 | """, 88 | "class": "", 89 | }, 90 | ], 91 | "source_repository": f"{about['__github__']}/", 92 | "source_branch": "master", 93 | "source_directory": "docs/", 94 | } 95 | html_sidebars = { 96 | "**": [ 97 | "sidebar/scroll-start.html", 98 | "sidebar/brand.html", 99 | "sidebar/search.html", 100 | "sidebar/navigation.html", 101 | "sidebar/projects.html", 102 | "sidebar/scroll-end.html", 103 | ], 104 | } 105 | 106 | # linkify_issues 107 | issue_url_tpl = about["__github__"] + "/issues/{issue_id}" 108 | 109 | # sphinx.ext.autodoc 110 | autoclass_content = "both" 111 | autodoc_member_order = "bysource" 112 | toc_object_entries_show_parents = "hide" 113 | autodoc_default_options = { 114 | "undoc-members": True, 115 | "members": True, 116 | "private-members": True, 117 | "show-inheritance": True, 118 | "member-order": "bysource", 119 | } 120 | 121 | # sphinx-autodoc-typehints 122 | autodoc_typehints = "description" # show type hints in doc body instead of signature 123 | simplify_optional_unions = True 124 | 125 | # sphinx.ext.napoleon 126 | napoleon_google_docstring = True 127 | napoleon_include_init_with_doc = True 128 | 129 | # sphinx-copybutton 130 | copybutton_prompt_text = ( 131 | r">>> |\.\.\. |> |\$ |\# | In \[\d*\]: | {2,5}\.\.\.: | {5,8}: " 132 | ) 133 | copybutton_prompt_is_regexp = True 134 | copybutton_remove_prompts = True 135 | 136 | # sphinxext-rediraffe 137 | rediraffe_redirects = "redirects.txt" 138 | rediraffe_branch = "master~1" 139 | 140 | # sphinxext.opengraph 141 | ogp_site_url = about["__docs__"] 142 | ogp_image = "_static/img/icons/icon-192x192.png" 143 | ogp_site_name = about["__title__"] 144 | 145 | intersphinx_mapping = { 146 | "py": ("https://docs.python.org/", None), 147 | "libvcs": ("http://libvcs.git-pull.com/", None), 148 | } 149 | 150 | 151 | def linkcode_resolve(domain: str, info: dict[str, str]) -> None | str: 152 | """ 153 | Determine the URL corresponding to Python object. 154 | 155 | Notes 156 | ----- 157 | From https://github.com/numpy/numpy/blob/v1.15.1/doc/source/conf.py, 7c49cfa 158 | on Jul 31. License BSD-3. https://github.com/numpy/numpy/blob/v1.15.1/LICENSE.txt 159 | """ 160 | if domain != "py": 161 | return None 162 | 163 | modname = info["module"] 164 | fullname = info["fullname"] 165 | 166 | submod = sys.modules.get(modname) 167 | if submod is None: 168 | return None 169 | 170 | obj = submod 171 | for part in fullname.split("."): 172 | try: 173 | obj = getattr(obj, part) 174 | except Exception: # noqa: PERF203 175 | return None 176 | 177 | # strip decorators, which would resolve to the source of the decorator 178 | # possibly an upstream bug in getsourcefile, bpo-1764286 179 | try: 180 | unwrap = inspect.unwrap 181 | except AttributeError: 182 | pass 183 | else: 184 | if callable(obj): 185 | obj = unwrap(obj) 186 | 187 | try: 188 | fn = inspect.getsourcefile(obj) 189 | except Exception: 190 | fn = None 191 | if not fn: 192 | return None 193 | 194 | try: 195 | source, lineno = inspect.getsourcelines(obj) 196 | except Exception: 197 | lineno = None 198 | 199 | linespec = f"#L{lineno}-L{lineno + len(source) - 1}" if lineno else "" 200 | 201 | fn = relpath(fn, start=pathlib.Path(vcspull.__file__).parent) 202 | 203 | if "dev" in about["__version__"]: 204 | return "{}/blob/master/{}/{}/{}{}".format( 205 | about["__github__"], 206 | "src", 207 | about["__package_name__"], 208 | fn, 209 | linespec, 210 | ) 211 | return "{}/blob/v{}/{}/{}/{}{}".format( 212 | about["__github__"], 213 | about["__version__"], 214 | "src", 215 | about["__package_name__"], 216 | fn, 217 | linespec, 218 | ) 219 | 220 | 221 | def remove_tabs_js(app: Sphinx, exc: Exception) -> None: 222 | """Fix for sphinx-inline-tabs#18.""" 223 | if app.builder.format == "html" and not exc: 224 | tabs_js = pathlib.Path(app.builder.outdir) / "_static" / "tabs.js" 225 | tabs_js.unlink(missing_ok=True) 226 | 227 | 228 | def setup(app: Sphinx) -> None: 229 | """Sphinx setup hook.""" 230 | app.connect("build-finished", remove_tabs_js) 231 | -------------------------------------------------------------------------------- /docs/configuration/generation.md: -------------------------------------------------------------------------------- 1 | (config-generation)= 2 | 3 | # Config generation 4 | 5 | As a temporary solution for `vcspull` not being able to generate {ref}`configuration` through scanning directories or fetching them via API (e.g. gitlab, github, etc), you can write scripts to generate configs in the mean time. 6 | 7 | (config-generation-gitlab)= 8 | 9 | ## Collect repos from Gitlab 10 | 11 | Contributed by Andreas Schleifer (a.schleifer@bigpoint.net) 12 | 13 | Limitation on both, no pagination support in either, so only returns the 14 | first page of repos (as of Feb 26th this is 100). 15 | 16 | ````{tab} Shell-script 17 | 18 | _Requires [jq] and [curl]._ 19 | 20 | ```{literalinclude} ../../scripts/generate_gitlab.sh 21 | :language: shell 22 | ``` 23 | 24 | ```console 25 | $ env GITLAB_TOKEN=mySecretToken \ 26 | /path/to/generate_gitlab.sh gitlab.mycompany.com desired_namespace 27 | ``` 28 | 29 | To be executed from the path where the repos should later be stored. It will use 30 | the current working directory as a "prefix" for the path used in the new config file. 31 | 32 | Optional: Set config file output path as additional argument (_will overwrite_) 33 | 34 | ```console 35 | $ env GITLAB_TOKEN=mySecretToken \ 36 | /path/to/generate_gitlab.sh gitlab.mycompany.com desired_namespace /path/to/config.yaml 37 | ``` 38 | 39 | **Demonstration** 40 | 41 | Assume current directory of _/home/user/workspace/_ and script at _/home/user/workspace/scripts/generate_gitlab.sh_: 42 | 43 | ```console 44 | $ ./scripts/generate_gitlab.sh gitlab.com vcs-python 45 | ``` 46 | 47 | New file _vcspull.yaml_: 48 | 49 | ```yaml 50 | /my/workspace/: 51 | g: 52 | url: "git+ssh://git@gitlab.com/vcs-python/g.git" 53 | remotes: 54 | origin: "ssh://git@gitlab.com/vcs-python/g.git" 55 | libvcs: 56 | url: "git+ssh://git@gitlab.com/vcs-python/libvcs.git" 57 | remotes: 58 | origin: "ssh://git@gitlab.com/vcs-python/libvcs.git" 59 | vcspull: 60 | url: "git+ssh://git@gitlab.com/vcs-python/vcspull.git" 61 | remotes: 62 | origin: "ssh://git@gitlab.com/vcs-python/vcspull.git" 63 | ``` 64 | 65 | [jq]: https://stedolan.github.io/jq/ 66 | 67 | [curl]: https://curl.se/ 68 | 69 | ```` 70 | 71 | ````{tab} Python 72 | _Requires [requests] and [pyyaml]._ 73 | 74 | This confirms file overwrite, if already exists. It also requires passing the protocol/schema 75 | of the gitlab mirror, e.g. `https://gitlab.com` instead of `gitlab.com`. 76 | 77 | ```{literalinclude} ../../scripts/generate_gitlab.py 78 | :language: python 79 | ``` 80 | 81 | **Demonstration** 82 | 83 | Assume current directory of _/home/user/workspace/_ and script at _/home/user/workspace/scripts/generate_gitlab.sh_: 84 | 85 | ```console 86 | $ ./scripts/generate_gitlab.py https://gitlab.com vcs-python 87 | ``` 88 | 89 | ```yaml 90 | /my/workspace/vcs-python: 91 | g: 92 | remotes: 93 | origin: ssh://git@gitlab.com/vcs-python/g.git 94 | url: git+ssh://git@gitlab.com/vcs-python/g.git 95 | libvcs: 96 | remotes: 97 | origin: ssh://git@gitlab.com/vcs-python/libvcs.git 98 | url: git+ssh://git@gitlab.com/vcs-python/libvcs.git 99 | vcspull: 100 | remotes: 101 | origin: ssh://git@gitlab.com/vcs-python/vcspull.git 102 | url: git+ssh://git@gitlab.com/vcs-python/vcspull.git 103 | ``` 104 | 105 | [requests]: https://docs.python-requests.org/en/latest/ 106 | [pyyaml]: https://pyyaml.org/ 107 | 108 | ```` 109 | 110 | ### Contribute your own 111 | 112 | Post yours on or create a PR to add 113 | yours to scripts/ and be featured here 114 | -------------------------------------------------------------------------------- /docs/configuration/index.md: -------------------------------------------------------------------------------- 1 | (configuration)= 2 | 3 | # Configuration 4 | 5 | ## URL Format 6 | 7 | Repo type and address is [RFC3986](https://datatracker.ietf.org/doc/html/rfc3986) style URLs. 8 | You may recognize this from `pip`'s [VCS URL] format. 9 | 10 | [vcs url]: https://pip.pypa.io/en/latest/topics/vcs-support/ 11 | 12 | ## Config locations 13 | 14 | You can place the file in one of three places: 15 | 16 | 1. Home: _~/.vcspull.yaml_ 17 | 2. [XDG] home directory: `$XDG_CONFIG_HOME/vcspull/` 18 | 19 | Example: _~/.config/vcspull/myrepos.yaml_ 20 | 21 | `XDG_CONFIG_HOME` is often _~/.config/vcspull/_, but can vary on platform, to check: 22 | 23 | ```console 24 | $ echo $XDG_CONFIG_HOME 25 | ``` 26 | 27 | 3. Anywhere (and trigger via `vcspull sync -c ./path/to/file.yaml sync [repo_name]`) 28 | 29 | [xdg]: https://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html 30 | 31 | ## Schema 32 | 33 | ```{warning} 34 | 35 | This structure is subject to break in upcoming releases. 36 | ``` 37 | 38 | ```yaml 39 | ~/workdir/: 40 | repo_name: 41 | remotes: 42 | origin: git_repo_url 43 | ``` 44 | 45 | ### Examples 46 | 47 | ````{tab} Simple 48 | 49 | ```{literalinclude} ../../examples/remotes.yaml 50 | :language: yaml 51 | 52 | ``` 53 | 54 | To pull _kaptan_: 55 | 56 | ```console 57 | $ vcspull sync kaptan 58 | ``` 59 | 60 | ```` 61 | 62 | ````{tab} Complex 63 | 64 | **Christmas tree** 65 | 66 | config showing off every current feature and inline shortcut available. 67 | 68 | ```{literalinclude} ../../examples/christmas-tree.yaml 69 | :language: yaml 70 | 71 | ``` 72 | 73 | ```` 74 | 75 | ````{tab} Open Source Student 76 | 77 | **Code scholar** 78 | 79 | This file is used to checkout and sync multiple open source 80 | configs. 81 | 82 | YAML: 83 | 84 | ```{literalinclude} ../../examples/code-scholar.yaml 85 | :language: yaml 86 | 87 | ``` 88 | 89 | ```` 90 | 91 | ```{toctree} 92 | :maxdepth: 2 93 | :hidden: 94 | 95 | generation 96 | ``` 97 | 98 | ## Caveats 99 | 100 | (git-remote-ssh-git)= 101 | 102 | ### SSH Git URLs 103 | 104 | For git remotes using SSH authorization such as `git+git@github.com:tony/kaptan.git` use `git+ssh`: 105 | 106 | ```console 107 | git+ssh://git@github.com/tony/kaptan.git 108 | ``` 109 | -------------------------------------------------------------------------------- /docs/developing.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | Developing python projects associated with [git-pull.com] all use the same 4 | structure and workflow. At a later point these will refer to that website for documentation. 5 | 6 | [git-pull.com]: https://git-pull.com 7 | 8 | ## Bootstrap the project 9 | 10 | Install and [git] and [uv] 11 | 12 | Clone: 13 | 14 | ```console 15 | $ git clone https://github.com/vcs-python/vcspull.git 16 | ``` 17 | 18 | ```console 19 | $ cd vcspull 20 | ``` 21 | 22 | Install packages: 23 | 24 | ```console 25 | $ uv sync --all-extras --dev 26 | ``` 27 | 28 | [installation documentation]: https://docs.astral.sh/uv/getting-started/installation/ 29 | [git]: https://git-scm.com/ 30 | 31 | ## Development loop 32 | 33 | ### Tests 34 | 35 | [pytest] is used for tests. 36 | 37 | [pytest]: https://pytest.org/ 38 | 39 | #### Rerun on file change 40 | 41 | via [pytest-watcher] (works out of the box): 42 | 43 | ```console 44 | $ make start 45 | ``` 46 | 47 | via [entr(1)] (requires installation): 48 | 49 | ```console 50 | $ make watch_test 51 | ``` 52 | 53 | [pytest-watcher]: https://github.com/olzhasar/pytest-watcher 54 | 55 | #### Manual (just the command, please) 56 | 57 | ```console 58 | $ uv run py.test 59 | ``` 60 | 61 | or: 62 | 63 | ```console 64 | $ make test 65 | ``` 66 | 67 | #### pytest options 68 | 69 | `PYTEST_ADDOPTS` can be set in the commands below. For more 70 | information read [docs.pytest.com] for the latest documentation. 71 | 72 | [docs.pytest.com]: https://docs.pytest.org/ 73 | 74 | Verbose: 75 | 76 | ```console 77 | $ env PYTEST_ADDOPTS="-verbose" make start 78 | ``` 79 | 80 | Drop into `pdb` on first error: 81 | 82 | ```console 83 | $ env PYTEST_ADDOPTS="-x -s --pdb" make start 84 | ``` 85 | 86 | If you have [ipython] installed: 87 | 88 | ```console 89 | $ env PYTEST_ADDOPTS="--pdbcls=IPython.terminal.debugger:TerminalPdb" \ 90 | make start 91 | ``` 92 | 93 | [ipython]: https://ipython.org/ 94 | 95 | ### Documentation 96 | 97 | [sphinx] is used for documentation generation. In the future this may change to 98 | [docusaurus]. 99 | 100 | Default preview server: http://localhost:8022 101 | 102 | [sphinx]: https://www.sphinx-doc.org/ 103 | [docusaurus]: https://docusaurus.io/ 104 | 105 | #### Rerun on file change 106 | 107 | [sphinx-autobuild] will automatically build the docs, it also handles launching 108 | a server, rebuilding file changes, and updating content in the browser: 109 | 110 | ```console 111 | $ cd docs 112 | ``` 113 | 114 | ```console 115 | $ make start 116 | ``` 117 | 118 | If doing css adjustments: 119 | 120 | ```console 121 | $ make design 122 | ``` 123 | 124 | [sphinx-autobuild]: https://github.com/executablebooks/sphinx-autobuild 125 | 126 | Rebuild docs on file change (requires [entr(1)]): 127 | 128 | ```console 129 | $ cd docs 130 | ``` 131 | 132 | ```console 133 | $ make dev 134 | ``` 135 | 136 | If not GNU Make / no -J support, use two terminals: 137 | 138 | ```console 139 | $ make watch 140 | ``` 141 | 142 | ```console 143 | $ make serve 144 | ``` 145 | 146 | #### Manual (just the command, please) 147 | 148 | ```console 149 | $ cd docs 150 | ``` 151 | 152 | Build: 153 | 154 | ```console 155 | $ make html 156 | ``` 157 | 158 | Launch server: 159 | 160 | ```console 161 | $ make serve 162 | ``` 163 | 164 | ## Linting 165 | 166 | ### ruff 167 | 168 | The project uses [ruff] to handle formatting, sorting imports and linting. 169 | 170 | ````{tab} Command 171 | 172 | uv: 173 | 174 | ```console 175 | $ uv run ruff check . 176 | ``` 177 | 178 | If you setup manually: 179 | 180 | ```console 181 | $ ruff check . 182 | ``` 183 | 184 | ```` 185 | 186 | ````{tab} make 187 | 188 | ```console 189 | $ make ruff 190 | ``` 191 | 192 | ```` 193 | 194 | ````{tab} Watch 195 | 196 | ```console 197 | $ make watch_ruff 198 | ``` 199 | 200 | requires [`entr(1)`]. 201 | 202 | ```` 203 | 204 | ````{tab} Fix files 205 | 206 | uv: 207 | 208 | ```console 209 | $ uv run ruff check . --fix 210 | ``` 211 | 212 | If you setup manually: 213 | 214 | ```console 215 | $ ruff check . --fix 216 | ``` 217 | 218 | ```` 219 | 220 | #### ruff format 221 | 222 | [ruff format] is used for formatting. 223 | 224 | ````{tab} Command 225 | 226 | uv: 227 | 228 | ```console 229 | $ uv run ruff format . 230 | ``` 231 | 232 | If you setup manually: 233 | 234 | ```console 235 | $ ruff format . 236 | ``` 237 | 238 | ```` 239 | 240 | ````{tab} make 241 | 242 | ```console 243 | $ make ruff_format 244 | ``` 245 | 246 | ```` 247 | 248 | ### mypy 249 | 250 | [mypy] is used for static type checking. 251 | 252 | ````{tab} Command 253 | 254 | uv: 255 | 256 | ```console 257 | $ uv run mypy . 258 | ``` 259 | 260 | If you setup manually: 261 | 262 | ```console 263 | $ mypy . 264 | ``` 265 | 266 | ```` 267 | 268 | ````{tab} make 269 | 270 | ```console 271 | $ make mypy 272 | ``` 273 | 274 | ```` 275 | 276 | ````{tab} Watch 277 | 278 | ```console 279 | $ make watch_mypy 280 | ``` 281 | 282 | requires [`entr(1)`]. 283 | ```` 284 | 285 | ````{tab} Configuration 286 | 287 | See `[tool.mypy]` in pyproject.toml. 288 | 289 | ```{literalinclude} ../pyproject.toml 290 | :language: toml 291 | :start-at: "[tool.mypy]" 292 | :end-before: "[tool" 293 | 294 | ``` 295 | 296 | ```` 297 | 298 | ## Publishing to PyPI 299 | 300 | [uv] handles virtualenv creation, package requirements, versioning, 301 | building, and publishing. Therefore there is no setup.py or requirements files. 302 | 303 | Update `__version__` in `__about__.py` and `pyproject.toml`:: 304 | 305 | git commit -m 'build(vcspull): Tag v0.1.1' 306 | git tag v0.1.1 307 | git push 308 | git push --tags 309 | 310 | GitHub Actions will detect the new git tag, and in its own workflow run `uv 311 | build` and push to PyPI. 312 | 313 | [uv]: https://github.com/astral-sh/uv 314 | [entr(1)]: http://eradman.com/entrproject/ 315 | [`entr(1)`]: http://eradman.com/entrproject/ 316 | [ruff format]: https://docs.astral.sh/ruff/formatter/ 317 | [ruff]: https://ruff.rs 318 | [mypy]: http://mypy-lang.org/ 319 | -------------------------------------------------------------------------------- /docs/history.md: -------------------------------------------------------------------------------- 1 | (changes)= 2 | (changelog)= 3 | (history)= 4 | 5 | ```{currentmodule} libtmux 6 | 7 | ``` 8 | 9 | ```{include} ../CHANGES 10 | 11 | ``` 12 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide-toc: true 3 | --- 4 | 5 | (index)= 6 | 7 | ```{include} ../README.md 8 | :end-before: 40 | ``` 41 | -------------------------------------------------------------------------------- /docs/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vcspull", 3 | "short_name": "vcspull", 4 | "description": "synchronize your projects via yaml / json files", 5 | "theme_color": "#2196f3", 6 | "background_color": "#fff", 7 | "display": "browser", 8 | "Scope": "https://vcspull.git-pull.com/", 9 | "start_url": "https://vcspull.git-pull.com/", 10 | "icons": [ 11 | { 12 | "src": "_static/img/icons/icon-72x72.png", 13 | "sizes": "72x72", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "_static/img/icons/icon-96x96.png", 18 | "sizes": "96x96", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "_static/img/icons/icon-128x128.png", 23 | "sizes": "128x128", 24 | "type": "image/png" 25 | }, 26 | { 27 | "src": "_static/img/icons/icon-144x144.png", 28 | "sizes": "144x144", 29 | "type": "image/png" 30 | }, 31 | { 32 | "src": "_static/img/icons/icon-152x152.png", 33 | "sizes": "152x152", 34 | "type": "image/png" 35 | }, 36 | { 37 | "src": "_static/img/icons/icon-192x192.png", 38 | "sizes": "192x192", 39 | "type": "image/png" 40 | }, 41 | { 42 | "src": "_static/img/icons/icon-384x384.png", 43 | "sizes": "384x384", 44 | "type": "image/png" 45 | }, 46 | { 47 | "src": "_static/img/icons/icon-512x512.png", 48 | "sizes": "512x512", 49 | "type": "image/png" 50 | } 51 | ], 52 | "splash_pages": null 53 | } 54 | -------------------------------------------------------------------------------- /docs/migration.md: -------------------------------------------------------------------------------- 1 | (migration)= 2 | 3 | ```{currentmodule} libtmux 4 | 5 | ``` 6 | 7 | ```{include} ../MIGRATION 8 | 9 | ``` 10 | -------------------------------------------------------------------------------- /docs/quickstart.md: -------------------------------------------------------------------------------- 1 | (quickstart)= 2 | 3 | # Quickstart 4 | 5 | ## Installation 6 | 7 | For latest official version: 8 | 9 | ```console 10 | $ pip install --user vcspull 11 | ``` 12 | 13 | Upgrading: 14 | 15 | ```console 16 | $ pip install --user --upgrade vcspull 17 | ``` 18 | 19 | (developmental-releases)= 20 | 21 | ### Developmental releases 22 | 23 | New versions of vcspull are published to PyPI as alpha, beta, or release candidates. 24 | In their versions you will see notification like `a1`, `b1`, and `rc1`, respectively. 25 | `1.10.0b4` would mean the 4th beta release of `1.10.0` before general availability. 26 | 27 | - [pip]\: 28 | 29 | ```console 30 | $ pip install --user --upgrade --pre vcspull 31 | ``` 32 | 33 | - [pipx]\: 34 | 35 | ```console 36 | $ pipx install --suffix=@next 'vcspull' --pip-args '\--pre' --force 37 | ``` 38 | 39 | Then use `vcspull@next sync [config]...`. 40 | 41 | via trunk (can break easily): 42 | 43 | - [pip]\: 44 | 45 | ```console 46 | $ pip install --user -e git+https://github.com/vcs-python/vcspull.git#egg=vcspull 47 | ``` 48 | 49 | - [pipx]\: 50 | 51 | ```console 52 | $ pipx install --suffix=@master 'vcspull @ git+https://github.com/vcs-python/vcspull.git@master' --force 53 | ``` 54 | 55 | [pip]: https://pip.pypa.io/en/stable/ 56 | [pipx]: https://pypa.github.io/pipx/docs/ 57 | 58 | ## Configuration 59 | 60 | ```{seealso} 61 | {ref}`configuration` and {ref}`config-generation`. 62 | ``` 63 | 64 | We will check out the source code of [flask][flask] to `~/code/flask`. 65 | 66 | Prefer JSON? Create a `~/.vcspull.json` file: 67 | 68 | ```json 69 | { 70 | "~/code/": { 71 | "flask": "git+https://github.com/mitsuhiko/flask.git" 72 | } 73 | } 74 | ``` 75 | 76 | YAML? Create a `~/.vcspull.yaml` file: 77 | 78 | ```yaml 79 | ~/code/: 80 | "flask": "git+https://github.com/mitsuhiko/flask.git" 81 | ``` 82 | 83 | The `git+` in front of the repository URL. Mercurial repositories use 84 | `hg+` and Subversion will use `svn+`. Repo type and address is 85 | specified in [pip vcs url][pip vcs url] format. 86 | 87 | Now run the command, to pull all the repositories in your 88 | `.vcspull.yaml` / `.vcspull.json`. 89 | 90 | ```console 91 | $ vcspull sync 92 | ``` 93 | 94 | Also, you can sync arbitrary projects, lets assume you have a mercurial 95 | repo but need a git dependency, in your project add `.deps.yaml` (can 96 | be any name): 97 | 98 | ```yaml 99 | ./vendor/: 100 | sdl2pp: "git+https://github.com/libSDL2pp/libSDL2pp.git" 101 | ``` 102 | 103 | Use `-c` to specify a config. 104 | 105 | ```console 106 | $ vcspull sync -c .deps.yaml 107 | ``` 108 | 109 | You can also use [fnmatch] to pull repositories from your config in 110 | various fashions, e.g.: 111 | 112 | ```console 113 | $ vcspull sync django 114 | ``` 115 | 116 | ```console 117 | $ vcspull sync django\* 118 | ``` 119 | 120 | ```console 121 | $ vcspull sync "django*" 122 | ``` 123 | 124 | Filter by VCS URL: 125 | 126 | Any repo beginning with `http`, `https` or `git` will be look up 127 | repos by the vcs url. 128 | 129 | Pull / update repositories you have with github in the repo url: 130 | 131 | ```console 132 | $ vcspull sync "git+https://github.com/yourusername/*" 133 | ``` 134 | 135 | Pull / update repositories you have with bitbucket in the repo url: 136 | 137 | ```console 138 | $ vcspull sync "git+https://*bitbucket*" 139 | ``` 140 | 141 | Filter by the path of the repo on your local machine: 142 | 143 | Any repo beginning with `/`, `./`, `~` or `$HOME` will scan 144 | for patterns of where the project is on your system: 145 | 146 | Pull all repos inside of _~/study/python_: 147 | 148 | ```console 149 | $ vcspull sync "$HOME/study/python" 150 | ``` 151 | 152 | Pull all the repos you have in directories in my config with "python": 153 | 154 | ```console 155 | $ vcspull sync ~/*python* 156 | ``` 157 | 158 | [pip vcs url]: http://www.pip-installer.org/en/latest/logic.html#vcs-support 159 | [flask]: http://flask.pocoo.org/ 160 | [fnmatch]: http://pubs.opengroup.org/onlinepubs/009695399/functions/fnmatch.html 161 | -------------------------------------------------------------------------------- /docs/redirects.txt: -------------------------------------------------------------------------------- 1 | "cli.md" "cli/index.md" 2 | "api.md" "api/index.md" 3 | -------------------------------------------------------------------------------- /examples/christmas-tree.yaml: -------------------------------------------------------------------------------- 1 | ~/study/: 2 | linux: git+git://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git 3 | freebsd: git+https://github.com/freebsd/freebsd.git 4 | sphinx: hg+https://bitbucket.org/birkenfeld/sphinx 5 | docutils: svn+http://svn.code.sf.net/p/docutils/code/trunk 6 | ~/github_projects/: 7 | kaptan: 8 | url: git+https://github.com/emre/kaptan 9 | remotes: 10 | upstream: git+https://github.com/emre/kaptan 11 | marksteve: git+https://github.com/marksteve/kaptan.git 12 | tony: git+git@github.com:tony/kaptan.git 13 | ~: 14 | .vim: 15 | url: git+git@github.com:tony/vim-config.git 16 | .tmux: 17 | url: git+git@github.com:tony/tmux-config.git 18 | -------------------------------------------------------------------------------- /examples/code-scholar.yaml: -------------------------------------------------------------------------------- 1 | ~/study/c: 2 | awesome: 'git+git://git.naquadah.org/awesome.git' 3 | weechat: 'git+git://git.sv.gnu.org/weechat.git' 4 | retroarch: 'git+https://github.com/Themaister/RetroArch.git' 5 | linux: 'git+https://github.com/torvalds/linux.git' 6 | freebsd: 'git+https://github.com/freebsd/freebsd.git' 7 | ncmpc: 'git+git://git.musicpd.org/master/ncmpc.git' 8 | tmux: 'git+git://git.code.sf.net/p/tmux/tmux-code' 9 | git: 'git+https://github.com/git/git.git' 10 | postgres: 'git+https://github.com/postgres/postgres.git' 11 | pgadmin3: 'git+git://git.postgresql.org/git/pgadmin3.git' 12 | sandy: 'git+http://git.suckless.org/sandy' 13 | understate: 'git+https://github.com/L3V3L9/understate.git' 14 | util-cursor: 'git+git://anongit.freedesktop.org/xcb/util-cursor' 15 | zsh: 'git+git://git.code.sf.net/p/zsh/code' 16 | cpython: 'hg+http://hg.python.org/cpython/' 17 | vim: 'hg+https://vim.googlecode.com/hg/' 18 | nginx: 'hg+http://hg.nginx.org/nginx' 19 | ~/study/c++: 20 | vimpc: 'git+https://github.com/boysetsfrog/vimpc' 21 | mpd: 'git+git://git.musicpd.org/master/mpd.git' 22 | libmpd: 'git+git://git.musicpd.org/master/libmpd.git' 23 | ncmpcpp: 'git+git://git.musicpd.org/mirror/ncmpcpp.git' 24 | libmpdclient: 'git+git://git.musicpd.org/master/libmpdclient.git' 25 | node: 'git+https://github.com/joyent/node.git' 26 | libzmp: 'git+https://github.com/zeromq/libzmq.git' 27 | doubanfm-qt: 'git+https://github.com/zonyitoo/doubanfm-qt.git' 28 | retroarch-phoenix: 'git+https://github.com/Themaister/RetroArch-Phoenix.git' 29 | clementine: 'git+https://code.google.com/p/clementine-player/' 30 | amarok: 'git+git://anongit.kde.org/amarok.git' 31 | ~/study/node: 32 | async: 'git+http://github.com/caolan/async.git' 33 | request: 'git+git://github.com/mikeal/request.git' 34 | express: 'git+https://github.com/visionmedia/express.git' 35 | node-optimist: 'git+http://github.com/substack/node-optimist.git' 36 | commander.js: 'git+https://github.com/visionmedia/commander.js.git' 37 | colors.js: 'git+git://github.com/Marak/colors.js.git' 38 | uglify.js: 'git+git://github.com/mishoo/UglifyJS.git' 39 | connect: 'git+git://github.com/senchalabs/connect.git' 40 | socket.io: 'git+https://github.com/LearnBoost/socket.io.git' 41 | node-mkdirp: 'git+https://github.com/substack/node-mkdirp.git' 42 | jade: 'git+https://github.com/visionmedia/jade.git' 43 | redis: 'git+https://github.com/antirez/redis.git' 44 | node-uuid: 'git+https://github.com/broofa/node-uuid.git' 45 | node-mime: 'git+https://github.com/broofa/node-mime.git' 46 | mime-magic: 'git+https://github.com/SaltwaterC/mime-magic.git' 47 | debug: 'git+https://github.com/visionmedia/debug.git' 48 | winston: 'git+https://github.com/flatiron/winston.git' 49 | less.js: 'git+https://github.com/cloudhead/less.js.git' 50 | less: 'git+https://github.com/cloudhead/less.git' 51 | todo: 'git+https://github.com/cloudhead/toto.git' 52 | http-control: 'git+https://github.com/cloudhead/http-console.git' 53 | cradle: 'git+https://github.com/cloudhead/cradle.git' 54 | journey: 'git+https://github.com/cloudhead/journey.git' 55 | pilgim: 'git+https://github.com/cloudhead/pilgrim.git' 56 | node-glob: 'git+https://github.com/isaacs/node-glob.git' 57 | jsdom: 'git+https://github.com/tmpvar/jsdom.git' 58 | node-mongodb-native: 'git+https://github.com/mongodb/node-mongodb-native.git' 59 | node-pkginfo: 'git+https://github.com/indexzero/node-pkginfo.git' 60 | nodejs-intro: 'git+https://github.com/indexzero/nodejs-intro.git' 61 | wrench-js: 'git+https://github.com/ryanmcgrath/wrench-js.git' 62 | grunt: 'git+https://github.com/gruntjs/grunt.git' 63 | moment: 'git+https://github.com/timrwood/moment.git' 64 | q: 'git+https://github.com/kriskowal/q.git' 65 | mocha: 'git+https://github.com/visionmedia/mocha.git' 66 | node-semvar: 'git+https://github.com/isaacs/node-semver.git' 67 | handlebars.js: 'git+https://github.com/wycats/handlebars.js.git' 68 | underscore.string: 'git+https://github.com/epeli/underscore.string.git' 69 | node-oauth: 'git+https://github.com/ciaranj/node-oauth.git' 70 | vows: 'git+https://github.com/cloudhead/vows.git' 71 | cheerio: 'git+https://github.com/MatthewMueller/cheerio.git' 72 | node-mysql: 'git+https://github.com/felixge/node-mysql.git' 73 | node-querystring: 'git+https://github.com/visionmedia/node-querystring.git' 74 | node-browserify: 'git+https://github.com/substack/node-browserify.git' 75 | node-http-proxy: 'git+https://github.com/nodejitsu/node-http-proxy.git' 76 | through: 'git+https://github.com/dominictarr/through.git' 77 | superagent: 'git+https://github.com/visionmedia/superagent.git' 78 | supertest: 'git+https://github.com/visionmedia/supertest.git' 79 | npm: 'git+https://github.com/isaacs/npm.git' 80 | passport-oauth: 'git+https://github.com/jaredhanson/passport-oauth.git' 81 | watch: 'git+https://github.com/mikeal/watch.git' 82 | hogan.js: 'git+https://github.com/twitter/hogan.js.git' 83 | mustache: 'git+https://github.com/defunkt/mustache.git' 84 | node-temp: 'git+https://github.com/bruce/node-temp.git' 85 | node-sprintf: 'git+https://github.com/maritz/node-sprintf.git' 86 | nodeunit: 'git+https://github.com/caolan/nodeunit.git' 87 | cli-color: 'git+git://github.com/medikoo/cli-color.git' 88 | node-jshint: 'git+https://github.com/jshint/node-jshint.git' 89 | node-static: 'git+https://github.com/cloudhead/node-static.git' 90 | passport: 'git+https://github.com/jaredhanson/passport.git' 91 | shelljs: 'git+https://github.com/arturadib/shelljs.git' 92 | tutorial-nodejs-cli: 'git+https://github.com/oscmejia/tutorial-nodejs-cli.git' 93 | cli-table: 'git+https://github.com/LearnBoost/cli-table.git' 94 | mongoose: 'git+https://github.com/LearnBoost/mongoose.git' 95 | browserbuild: 'git+https://github.com/LearnBoost/browserbuild.git' 96 | engine.io: 'git+https://github.com/LearnBoost/engine.io.git' 97 | engine.io-client: 'git+https://github.com/LearnBoost/engine.io-client.git' 98 | socket.io: 'git+https://github.com/LearnBoost/socket.io.git' 99 | socket.io-client: 'git+https://github.com/LearnBoost/socket.io-client.git' 100 | knox: 'git+https://github.com/LearnBoost/knox.git' 101 | jsonp: 'git+https://github.com/LearnBoost/jsonp.git' 102 | node-tar: 'git+https://github.com/isaacs/node-tar.git' 103 | node-bindings: 'git+https://github.com/TooTallNate/node-bindings.git' 104 | node-fs-extra: 'git+https://github.com/jprichardson/node-fs-extra.git' 105 | chai: 'git+https://github.com/chaijs/chai.git' 106 | grunt-lib-contrib: 'git+https://github.com/gruntjs/grunt-lib-contrib.git' 107 | node-irc: 'git+https://github.com/martynsmith/node-irc.git' 108 | jasmine-node: 'git+https://github.com/mhevery/jasmine-node.git' 109 | node-querystring: 'git+https://github.com/visionmedia/node-querystring.git' 110 | highlight.js: 'git+https://github.com/isagalaev/highlight.js.git' 111 | ~/study/javascript: 112 | backbone.deepmodel: 'git+https://github.com/powmedia/backbone-deep-model.git' 113 | underscore: 'git+https://github.com/documentcloud/underscore.git' 114 | lodash: 'git+https://github.com/bestiejs/lodash.git' 115 | backbone: 'git+https://github.com/documentcloud/backbone.git' 116 | requirejs: 'git+https://github.com/jrburke/requirejs.git' 117 | r.js: 'git+https://github.com/jrburke/r.js.git' 118 | volo: 'git+https://github.com/volojs/volo.git' 119 | create-responsive-template: 'git+https://github.com/volojs/create-responsive-template.git' 120 | yeoman: 'git+https://github.com/yeoman/yeoman.git' 121 | cajon: 'git+https://github.com/requirejs/cajon.git' 122 | jquery: 'git+https://github.com/jrburke/jquery.git' 123 | backbone.marionette: 'git+https://github.com/marionettejs/backbone.marionette.git' 124 | backbone.wreqr: 'git+https://github.com/marionettejs/backbone.wreqr.git' 125 | backbone.babysitter: 'git+https://github.com/marionettejs/backbone.babysitter.git' 126 | flight: 'git+https://github.com/twitter/flight.git' 127 | bower: 'git+https://github.com/twitter/bower.git' 128 | codemirror: 'git+https://github.com/marijnh/CodeMirror.git' 129 | doctorjs: 'git+https://github.com/mozilla/doctorjs.git' 130 | ~/study/python: 131 | jmespath: 'git+https://github.com/boto/jmespath.git' 132 | anyvcs: 'git+https://github.com/ScottDuckworth/python-anyvcs.git' 133 | pip: 'git+git://github.com/pypa/pip.git' 134 | ipdb: 'git+http://github.com/gotcha/ipdb.git' 135 | virtualenv: 'git+https://github.com/pypa/virtualenv.git' 136 | jinja2: 'git+https://github.com/mitsuhiko/jinja2.git' 137 | flask: 'git+https://github.com/mitsuhiko/flask.git' 138 | flask-script: 'git+https://github.com/techniq/flask-script.git' 139 | frozen-flask: 'git+https://github.com/SimonSapin/Frozen-Flask.git' 140 | werkzeug: 'git+https://github.com/mitsuhiko/werkzeug.git' 141 | logbook: 'git+https://github.com/mitsuhiko/logbook.git' 142 | cjklib: 'git+https://github.com/cburgmer/cjklib.git' 143 | pudb: 'git+http://git.tiker.net/trees/pudb.git' 144 | ipython: 'git+https://github.com/ipython/ipython.git' 145 | blessing: 'git+https://github.com/erikrose/blessings.git' 146 | salt: 'git+https://github.com/saltstack/salt.git' 147 | salt-ui: 'git+https://github.com/saltstack/salt-ui.git' 148 | salt-formulae: 'git+https://github.com/saltstack/formulae' 149 | salt-api: 'git+https://github.com/saltstack/salt-api.git' 150 | salt-cloud: 'git+https://github.com/saltstack/salt-cloud.git' 151 | salt-vagrant: 'git+https://github.com/saltstack/salty-vagrant.git' 152 | salt-contrib: 'git+https://github.com/saltstack/salt-contrib.git' 153 | sqlalchemy: 'git+https://github.com/zzzeek/sqlalchemy.git' 154 | wtforms: 'git+https://github.com/Khan/wtforms.git' 155 | botocore: 'git+https://github.com/boto/botocore.git' 156 | libcloud: 'git+https://github.com/apache/libcloud.git' 157 | 158 | argcomplete: 'git+https://github.com/kislyuk/argcomplete.git' 159 | 160 | lxml: 'git+https://github.com/lxml/lxml.git' 161 | 162 | httpbin: 'git+https://github.com/kennethreitz/httpbin.git' 163 | envoy: 'git+https://github.com/kennethreitz/envoy.git' 164 | legit: 'git+https://github.com/kennethreitz/legit.git' 165 | tablib: 'git+https://github.com/kennethreitz/tablib.git' 166 | requests: 'git+https://github.com/kennethreitz/requests.git' 167 | grequests: 'git+https://github.com/kennethreitz/grequests.git' 168 | 169 | kr-sphinx-themes: 'git+https://github.com/kennethreitz/kr-sphinx-themes.git' 170 | 171 | gist-api: 'git+https://github.com/kennethreitz/gistapi.py.git' 172 | 173 | pystache: 'git+https://github.com/defunkt/pystache.git' 174 | pandas: 'git+https://github.com/pydata/pandas' 175 | ncmpy: 'git+https://github.com/cykerway/ncmpy.git' 176 | 177 | gevent: 'git+https://github.com/surfly/gevent.git' 178 | tornado: 'git+https://github.com/facebook/tornado.git' 179 | 180 | soundcloud-cli: 'git+https://github.com/0xPr0xy/soundcloud-cli.git' 181 | pyradio: 'git+https://github.com/coderholic/pyradio.git' 182 | 183 | sh: 'git+https://github.com/amoffat/sh.git' 184 | envoy: 'git+https://github.com/kennethreitz/envoy.git' 185 | 186 | glances: 'git+https://github.com/nicolargo/glances.git' 187 | powerline: 'git+https://github.com/Lokaltog/powerline.git' 188 | jieba: 'git+https://github.com/fxsjy/jieba.git' 189 | storm: 'git+https://github.com/emre/storm.git' 190 | fabric: 'git+https://github.com/fabric/fabric.git' 191 | ansible: 'git+https://github.com/ansible/ansible' 192 | fn.py: 'git+https://github.com/kachayev/fn.py.git' 193 | buildbot: 'git+https://github.com/buildbot/buildbot.git' 194 | beets: 'git+https://github.com/sampsyo/beets.git' 195 | magicmethods: 'git+https://github.com/RafeKettler/magicmethods.git' 196 | 197 | gateone: 'git+https://github.com/liftoff/GateOne.git' 198 | glue: 'git+https://github.com/jorgebastida/glue.git' 199 | objbrowser: 'git+https://github.com/titusjan/objbrowser' 200 | 201 | pythagora: 'git+https://github.com/tarmack/Pythagora.git' 202 | youtube-dl: 'git+https://github.com/rg3/youtube-dl.git' 203 | twisted: 'git+https://github.com/twisted/twisted.git' 204 | 205 | ckan: 'git+https://github.com/okfn/ckan.git' 206 | hackthebox: 'git+https://github.com/moloch--/RootTheBox.git' 207 | mediagoblin: 'git+git://gitorious.org/mediagoblin/mediagoblin.git' 208 | ckan: 'git+https://github.com/okfn/ckan.git' 209 | reddit: 'git+https://github.com/reddit/reddit.git' 210 | 211 | python_koans: 'git+https://github.com/gregmalcolm/python_koans.git' 212 | python-guide: 'git+https://github.com/kennethreitz/python-guide.git' 213 | #introduction_to_sqlalchemy: 'git+https://bitbucket.org/zzzeek/pycon2013_student_package.git' 214 | probabilistic-programming-and-beyesian-methods: 'git+https://github.com/CamDavidsonPilon/Probabilistic-Programming-and-Bayesian-Methods-for-Hackers.git' 215 | python-patterns: 'git+https://github.com/faif/python-patterns.git' 216 | learn-pandas: 'git+https://bitbucket.org/hrojas/learn-pandas.git' 217 | 218 | w3lib: 'git+https://github.com/scrapy/w3lib' 219 | scrapy: 'git+https://github.com/scrapy/scrapy.git' 220 | scrapely: 'git+https://github.com/scrapy/scrapely.git' 221 | 222 | calibre: 'git+git://github.com/kovidgoyal/calibre.git' 223 | 224 | kaptan: 'git+https://github.com/emre/kaptan.git' 225 | aws-cli: 'git+https://github.com/aws/aws-cli.git' 226 | 227 | youcompleteme: 'git+https://github.com/Valloric/YouCompleteMe.git' 228 | 229 | ranger: 'git+git://git.savannah.nongnu.org/ranger.git' 230 | 231 | readthedocs: 'git+https://github.com/rtfd/readthedocs.org.git' 232 | django: 'git+https://github.com/django/django.git' 233 | norman: 'hg+https://bitbucket.org/aquavitae/norman/src' 234 | bpython: 'hg+https://bitbucket.org/bobf/bpython/' 235 | urwid: 'hg+https://excess.org/hg/urwid/' 236 | sphinx: 'hg+https://bitbucket.org/birkenfeld/sphinx' 237 | sphinx-contrib: 'hg+https://bitbucket.org/birkenfeld/sphinx-contrib' 238 | pexpect-u: 'hg+https://bitbucket.org/takluyver/pexpect' 239 | pygments: 'hg+http://bitbucket.org/birkenfeld/pygments-main' 240 | docutils: 'svn+http://svn.code.sf.net/p/docutils/code/trunk' 241 | -------------------------------------------------------------------------------- /examples/remotes.yaml: -------------------------------------------------------------------------------- 1 | ~/workspace/: 2 | kaptan: 3 | url: git+https://github.com/emre/kaptan 4 | remotes: 5 | upstream: git+https://github.com/emre/kaptan 6 | marksteve: git+https://github.com/marksteve/kaptan.git 7 | tony: git+ssh://git@github.com/tony/kaptan.git 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "vcspull" 3 | version = "1.34.0" 4 | description = "Manage and sync multiple git, mercurial, and svn repos" 5 | requires-python = ">=3.9,<4.0" 6 | license = { text = "MIT" } 7 | authors = [ 8 | {name = "Tony Narlock", email = "tony@git-pull.com"} 9 | ] 10 | classifiers = [ 11 | "Development Status :: 4 - Beta", 12 | "License :: OSI Approved :: MIT License", 13 | "Environment :: Web Environment", 14 | "Intended Audience :: Developers", 15 | "Operating System :: POSIX", 16 | "Operating System :: MacOS :: MacOS X", 17 | "Programming Language :: Python", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3.9", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13", 24 | "Topic :: Utilities", 25 | "Topic :: System :: Shells", 26 | ] 27 | packages = [ 28 | { include = "*", from = "src" }, 29 | ] 30 | include = [ 31 | { path = "tests", format = "sdist" }, 32 | { path = ".tmuxp.yaml", format = "sdist" }, 33 | { path = "docs", format = "sdist" }, 34 | { path = "examples", format = "sdist" }, 35 | { path = "conftest.py", format = "sdist" }, 36 | ] 37 | readme = 'README.md' 38 | keywords = [ 39 | "vcspull", 40 | "vcs", 41 | "git", 42 | "svn", 43 | "subversion", 44 | "hg", 45 | "mercurial", 46 | "manage", 47 | "manager", 48 | "sync", 49 | "fetcher", 50 | "updater", 51 | "json", 52 | "yaml", 53 | ] 54 | homepage = "https://vcspull.git-pull.com" 55 | dependencies = [ 56 | "libvcs~=0.35.0", 57 | "colorama>=0.3.9", 58 | "PyYAML>=6.0" 59 | ] 60 | 61 | [project-urls] 62 | "Bug Tracker" = "https://github.com/vcs-python/vcspull/issues" 63 | Documentation = "https://vcspull.git-pull.com" 64 | Repository = "https://github.com/vcs-python/vcspull" 65 | Changes = "https://github.com/vcs-python/vcspull/blob/master/CHANGES" 66 | 67 | [project.scripts] 68 | vcspull = "vcspull:cli.cli" 69 | 70 | [tool.uv] 71 | dev-dependencies = [ 72 | # Docs 73 | "sphinx", 74 | "furo", 75 | "gp-libs", 76 | "sphinx-autobuild", 77 | "sphinx-autodoc-typehints", 78 | "sphinx-inline-tabs", 79 | "sphinxext-opengraph", 80 | "sphinx-copybutton", 81 | "sphinxext-rediraffe", 82 | "sphinx-argparse", 83 | "myst-parser", 84 | "linkify-it-py", 85 | # Testing 86 | "gp-libs", 87 | "pytest", 88 | "pytest-rerunfailures", 89 | "pytest-mock", 90 | "pytest-watcher", 91 | # Coverage 92 | "codecov", 93 | "coverage", 94 | "pytest-cov", 95 | # Lint 96 | "ruff", 97 | "mypy", 98 | # Annotations 99 | "types-requests", 100 | "types-PyYAML", 101 | "types-colorama" 102 | ] 103 | 104 | [dependency-groups] 105 | docs = [ 106 | "sphinx", 107 | "furo", 108 | "gp-libs", 109 | "sphinx-autobuild", 110 | "sphinx-autodoc-typehints", 111 | "sphinx-inline-tabs", 112 | "sphinxext-opengraph", 113 | "sphinx-copybutton", 114 | "sphinxext-rediraffe", 115 | "sphinx-argparse", 116 | "myst-parser", 117 | "linkify-it-py", 118 | ] 119 | testing = [ 120 | "gp-libs", 121 | "pytest", 122 | "pytest-rerunfailures", 123 | "pytest-mock", 124 | "pytest-watcher", 125 | ] 126 | coverage =[ 127 | "codecov", 128 | "coverage", 129 | "pytest-cov", 130 | ] 131 | lint = [ 132 | "ruff", 133 | "mypy", 134 | ] 135 | typings = [ 136 | "types-requests", 137 | "types-PyYAML", 138 | "types-colorama" 139 | ] 140 | 141 | [build-system] 142 | requires = ["hatchling"] 143 | build-backend = "hatchling.build" 144 | 145 | [tool.mypy] 146 | python_version = 3.9 147 | warn_unused_configs = true 148 | files = [ 149 | "src", 150 | "tests", 151 | ] 152 | strict = true 153 | 154 | [[tool.mypy.overrides]] 155 | module = [ 156 | "shtab", 157 | ] 158 | ignore_missing_imports = true 159 | 160 | [tool.coverage.run] 161 | branch = true 162 | parallel = true 163 | omit = [ 164 | "*/_*", 165 | "*/_compat.py", 166 | "docs/conf.py", 167 | "tests/*", 168 | ] 169 | 170 | [tool.coverage.report] 171 | show_missing = true 172 | skip_covered = true 173 | exclude_lines = [ 174 | "pragma: no cover", 175 | "def __repr__", 176 | "raise NotImplementedError", 177 | "return NotImplemented", 178 | "def parse_args", 179 | "if TYPE_CHECKING:", 180 | "if t.TYPE_CHECKING:", 181 | "@overload( |$)", 182 | "from __future__ import annotations", 183 | ] 184 | 185 | [tool.ruff] 186 | target-version = "py39" 187 | 188 | [tool.ruff.lint] 189 | select = [ 190 | "E", # pycodestyle 191 | "F", # pyflakes 192 | "I", # isort 193 | "UP", # pyupgrade 194 | "A", # flake8-builtins 195 | "B", # flake8-bugbear 196 | "C4", # flake8-comprehensions 197 | "COM", # flake8-commas 198 | "EM", # flake8-errmsg 199 | "Q", # flake8-quotes 200 | "PTH", # flake8-use-pathlib 201 | "SIM", # flake8-simplify 202 | "TRY", # Trycertatops 203 | "PERF", # Perflint 204 | "RUF", # Ruff-specific rules 205 | "D", # pydocstyle 206 | "FA100", # future annotations 207 | ] 208 | ignore = [ 209 | "COM812", # missing trailing comma, ruff format conflict 210 | ] 211 | extend-safe-fixes = [ 212 | "UP006", 213 | "UP007", 214 | ] 215 | pyupgrade.keep-runtime-typing = false 216 | 217 | [tool.ruff.lint.flake8-builtins] 218 | builtins-allowed-modules = [ 219 | "dataclasses", 220 | "types", 221 | ] 222 | 223 | [tool.ruff.lint.pydocstyle] 224 | convention = "numpy" 225 | 226 | [tool.ruff.lint.isort] 227 | known-first-party = [ 228 | "vcspull", 229 | ] 230 | combine-as-imports = true 231 | required-imports = [ 232 | "from __future__ import annotations", 233 | ] 234 | 235 | [tool.ruff.lint.per-file-ignores] 236 | "*/__init__.py" = ["F401"] 237 | 238 | [tool.pytest.ini_options] 239 | addopts = "--tb=short --no-header --showlocals" 240 | testpaths = [ 241 | "src/vcspull", 242 | "tests", 243 | "docs", 244 | ] 245 | filterwarnings = [ 246 | "ignore:The frontend.Option(Parser)? class.*:DeprecationWarning::", 247 | ] 248 | -------------------------------------------------------------------------------- /scripts/generate_gitlab.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Example script for export gitlab organization to vcspull config file.""" 3 | 4 | from __future__ import annotations 5 | 6 | import argparse 7 | import logging 8 | import os 9 | import pathlib 10 | import sys 11 | import typing as t 12 | 13 | import requests 14 | import yaml 15 | from libvcs.sync.git import GitRemote 16 | 17 | from vcspull.cli.sync import CouldNotGuessVCSFromURL, guess_vcs 18 | 19 | if t.TYPE_CHECKING: 20 | from vcspull.types import RawConfig 21 | 22 | log = logging.getLogger(__name__) 23 | logging.basicConfig(level=logging.INFO, format="%(message)s") 24 | 25 | try: 26 | gitlab_token = os.environ["GITLAB_TOKEN"] 27 | except KeyError: 28 | log.info("Please provide the environment variable GITLAB_TOKEN") 29 | sys.exit(1) 30 | 31 | parser = argparse.ArgumentParser( 32 | description="Script to generate vcsconfig for all repositories \ 33 | under the given namespace (needs Gitlab >= 10.3)", 34 | ) 35 | parser.add_argument("gitlab_host", type=str, help="url to the gitlab instance") 36 | parser.add_argument( 37 | "gitlab_namespace", 38 | type=str, 39 | help="namespace/group in gitlab to generate vcsconfig for", 40 | ) 41 | parser.add_argument( 42 | "-c", 43 | type=str, 44 | help="path to the target config file (default: ./vcspull.yaml)", 45 | dest="config_file_name", 46 | required=False, 47 | default="./vcspull.yaml", 48 | ) 49 | 50 | args = vars(parser.parse_args()) 51 | gitlab_host = args["gitlab_host"] 52 | gitlab_namespace = args["gitlab_namespace"] 53 | config_filename = pathlib.Path(args["config_file_name"]) 54 | 55 | try: 56 | if config_filename.is_file(): 57 | result = input( 58 | f"The target config file ({config_filename}) already exists, \ 59 | do you want to overwrite it? [y/N] ", 60 | ) 61 | 62 | if result != "y": 63 | log.info( 64 | f"Aborting per user request as existing config file ({config_filename})" 65 | + " should not be overwritten!", 66 | ) 67 | sys.exit(0) 68 | 69 | config_file = config_filename.open(mode="w") 70 | except OSError: 71 | log.info(f"File {config_filename} not accessible") 72 | sys.exit(1) 73 | 74 | response = requests.get( 75 | f"{gitlab_host}/api/v4/groups/{gitlab_namespace}/projects", 76 | params={"include_subgroups": "true", "per_page": "100"}, 77 | headers={"Authorization": f"Bearer {gitlab_token}"}, 78 | ) 79 | 80 | if response.status_code != 200: 81 | log.info(f"Error: {response}") 82 | sys.exit(1) 83 | 84 | path_prefix = pathlib.Path().cwd() 85 | config: RawConfig = {} 86 | 87 | 88 | for group in response.json(): 89 | url_to_repo = group["ssh_url_to_repo"].replace(":", "/") 90 | namespace_path = group["namespace"]["full_path"] 91 | reponame = group["path"] 92 | 93 | path = f"{path_prefix}/{namespace_path}" 94 | 95 | if path not in config: 96 | config[path] = {} 97 | 98 | # simplified config not working - https://github.com/vcs-python/vcspull/issues/332 99 | # config[path][reponame] = 'git+ssh://%s' % (url_to_repo) 100 | 101 | vcs = guess_vcs(url_to_repo) 102 | if vcs is None: 103 | raise CouldNotGuessVCSFromURL(url_to_repo) 104 | 105 | config[path][reponame] = { 106 | "name": reponame, 107 | "path": path / reponame, 108 | "url": f"git+ssh://{url_to_repo}", 109 | "remotes": { 110 | "origin": GitRemote( 111 | name="origin", 112 | fetch_url=f"ssh://{url_to_repo}", 113 | push_url=f"ssh://{url_to_repo}", 114 | ), 115 | }, 116 | "vcs": vcs, 117 | } 118 | 119 | config_yaml = yaml.dump(config) 120 | 121 | log.info(config_yaml) 122 | 123 | config_file.write(config_yaml) 124 | config_file.close() 125 | -------------------------------------------------------------------------------- /scripts/generate_gitlab.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ -z "${GITLAB_TOKEN}" ]; then 4 | echo 'Please provide the environment variable $GITLAB_TOKEN' 5 | exit 1 6 | fi 7 | 8 | if [ $# -lt 2 ]; then 9 | echo "Usage: $0 []" 10 | exit 1 11 | fi 12 | 13 | prefix="$(pwd)" 14 | gitlab_host="${1}" 15 | namespace="${2}" 16 | config_file="${3:-./vcspull.yaml}" 17 | 18 | current_namespace_path="" 19 | 20 | curl --silent --show-error --header "Authorization: Bearer ${GITLAB_TOKEN}" "https://${gitlab_host}/api/v4/groups/${namespace}/projects?include_subgroups=true&per_page=100" \ 21 | | jq -r '.[]|.namespace.full_path + " " + .path' \ 22 | | LC_ALL=C sort \ 23 | | while read namespace_path reponame; do 24 | if [ "${current_namespace_path}" != "${namespace_path}" ]; then 25 | current_namespace_path="${namespace_path}" 26 | 27 | echo "${prefix}/${current_namespace_path}:" 28 | fi 29 | 30 | # simplified config not working - https://github.com/vcs-python/vcspull/issues/332 31 | #echo " ${reponame}: 'git+ssh://git@${gitlab_host}/${current_namespace_path}/${reponame}.git'" 32 | 33 | echo " ${reponame}:" 34 | echo " url: 'git+ssh://git@${gitlab_host}/${current_namespace_path}/${reponame}.git'" 35 | echo " remotes:" 36 | echo " origin: 'ssh://git@${gitlab_host}/${current_namespace_path}/${reponame}.git'" 37 | done \ 38 | | tee "${config_file}" 39 | -------------------------------------------------------------------------------- /src/vcspull/__about__.py: -------------------------------------------------------------------------------- 1 | """Metadata for vcspull.""" 2 | 3 | from __future__ import annotations 4 | 5 | __title__ = "vcspull" 6 | __package_name__ = "vcspull" 7 | __description__ = "Manage and sync multiple git, mercurial, and svn repos" 8 | __version__ = "1.34.0" 9 | __author__ = "Tony Narlock" 10 | __github__ = "https://github.com/vcs-python/vcspull" 11 | __docs__ = "https://vcspull.git-pull.com" 12 | __tracker__ = "https://github.com/vcs-python/vcspull/issues" 13 | __pypi__ = "https://pypi.org/project/vcspull/" 14 | __email__ = "tony@git-pull.com" 15 | __license__ = "MIT" 16 | __copyright__ = "Copyright 2013- Tony Narlock" 17 | -------------------------------------------------------------------------------- /src/vcspull/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Manage multiple git, mercurial, svn repositories from a YAML / JSON file. 3 | 4 | :copyright: Copyright 2013-2018 Tony Narlock. 5 | :license: MIT, see LICENSE for details 6 | """ 7 | 8 | # Set default logging handler to avoid "No handler found" warnings. 9 | from __future__ import annotations 10 | 11 | import logging 12 | from logging import NullHandler 13 | 14 | from . import cli 15 | 16 | logging.getLogger(__name__).addHandler(NullHandler()) 17 | -------------------------------------------------------------------------------- /src/vcspull/_internal/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vcs-python/vcspull/107169f95065abaa5e32dcafc82514dd9beb8ac2/src/vcspull/_internal/__init__.py -------------------------------------------------------------------------------- /src/vcspull/_internal/config_reader.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import pathlib 5 | import typing as t 6 | 7 | import yaml 8 | 9 | if t.TYPE_CHECKING: 10 | from typing_extensions import Literal, TypeAlias 11 | 12 | FormatLiteral = Literal["json", "yaml"] 13 | 14 | RawConfigData: TypeAlias = dict[t.Any, t.Any] 15 | 16 | 17 | class ConfigReader: 18 | r"""Parse string data (YAML and JSON) into a dictionary. 19 | 20 | >>> cfg = ConfigReader({ "session_name": "my session" }) 21 | >>> cfg.dump("yaml") 22 | 'session_name: my session\n' 23 | >>> cfg.dump("json") 24 | '{\n "session_name": "my session"\n}' 25 | """ 26 | 27 | def __init__(self, content: RawConfigData) -> None: 28 | self.content = content 29 | 30 | @staticmethod 31 | def _load(fmt: FormatLiteral, content: str) -> dict[str, t.Any]: 32 | """Load raw config data and directly return it. 33 | 34 | >>> ConfigReader._load("json", '{ "session_name": "my session" }') 35 | {'session_name': 'my session'} 36 | 37 | >>> ConfigReader._load("yaml", 'session_name: my session') 38 | {'session_name': 'my session'} 39 | """ 40 | if fmt == "yaml": 41 | return t.cast( 42 | "dict[str, t.Any]", 43 | yaml.load( 44 | content, 45 | Loader=yaml.SafeLoader, 46 | ), 47 | ) 48 | if fmt == "json": 49 | return t.cast("dict[str, t.Any]", json.loads(content)) 50 | msg = f"{fmt} not supported in configuration" 51 | raise NotImplementedError(msg) 52 | 53 | @classmethod 54 | def load(cls, fmt: FormatLiteral, content: str) -> ConfigReader: 55 | """Load raw config data into a ConfigReader instance (to dump later). 56 | 57 | >>> cfg = ConfigReader.load("json", '{ "session_name": "my session" }') 58 | >>> cfg 59 | 60 | >>> cfg.content 61 | {'session_name': 'my session'} 62 | 63 | >>> cfg = ConfigReader.load("yaml", 'session_name: my session') 64 | >>> cfg 65 | 66 | >>> cfg.content 67 | {'session_name': 'my session'} 68 | """ 69 | return cls( 70 | content=cls._load( 71 | fmt=fmt, 72 | content=content, 73 | ), 74 | ) 75 | 76 | @classmethod 77 | def _from_file(cls, path: pathlib.Path) -> dict[str, t.Any]: 78 | r"""Load data from file path directly to dictionary. 79 | 80 | **YAML file** 81 | 82 | *For demonstration only,* create a YAML file: 83 | 84 | >>> yaml_file = tmp_path / 'my_config.yaml' 85 | >>> yaml_file.write_text('session_name: my session', encoding='utf-8') 86 | 24 87 | 88 | *Read YAML file*: 89 | 90 | >>> ConfigReader._from_file(yaml_file) 91 | {'session_name': 'my session'} 92 | 93 | **JSON file** 94 | 95 | *For demonstration only,* create a JSON file: 96 | 97 | >>> json_file = tmp_path / 'my_config.json' 98 | >>> json_file.write_text('{"session_name": "my session"}', encoding='utf-8') 99 | 30 100 | 101 | *Read JSON file*: 102 | 103 | >>> ConfigReader._from_file(json_file) 104 | {'session_name': 'my session'} 105 | """ 106 | assert isinstance(path, pathlib.Path) 107 | content = path.open().read() 108 | 109 | if path.suffix in {".yaml", ".yml"}: 110 | fmt: FormatLiteral = "yaml" 111 | elif path.suffix == ".json": 112 | fmt = "json" 113 | else: 114 | msg = f"{path.suffix} not supported in {path}" 115 | raise NotImplementedError(msg) 116 | 117 | return cls._load( 118 | fmt=fmt, 119 | content=content, 120 | ) 121 | 122 | @classmethod 123 | def from_file(cls, path: pathlib.Path) -> ConfigReader: 124 | r"""Load data from file path. 125 | 126 | **YAML file** 127 | 128 | *For demonstration only,* create a YAML file: 129 | 130 | >>> yaml_file = tmp_path / 'my_config.yaml' 131 | >>> yaml_file.write_text('session_name: my session', encoding='utf-8') 132 | 24 133 | 134 | *Read YAML file*: 135 | 136 | >>> cfg = ConfigReader.from_file(yaml_file) 137 | >>> cfg 138 | 139 | 140 | >>> cfg.content 141 | {'session_name': 'my session'} 142 | 143 | **JSON file** 144 | 145 | *For demonstration only,* create a JSON file: 146 | 147 | >>> json_file = tmp_path / 'my_config.json' 148 | >>> json_file.write_text('{"session_name": "my session"}', encoding='utf-8') 149 | 30 150 | 151 | *Read JSON file*: 152 | 153 | >>> cfg = ConfigReader.from_file(json_file) 154 | >>> cfg 155 | 156 | 157 | >>> cfg.content 158 | {'session_name': 'my session'} 159 | """ 160 | return cls(content=cls._from_file(path=path)) 161 | 162 | @staticmethod 163 | def _dump( 164 | fmt: FormatLiteral, 165 | content: RawConfigData, 166 | indent: int = 2, 167 | **kwargs: t.Any, 168 | ) -> str: 169 | r"""Dump directly. 170 | 171 | >>> ConfigReader._dump("yaml", { "session_name": "my session" }) 172 | 'session_name: my session\n' 173 | 174 | >>> ConfigReader._dump("json", { "session_name": "my session" }) 175 | '{\n "session_name": "my session"\n}' 176 | """ 177 | if fmt == "yaml": 178 | return yaml.dump( 179 | content, 180 | indent=2, 181 | default_flow_style=False, 182 | Dumper=yaml.SafeDumper, 183 | ) 184 | if fmt == "json": 185 | return json.dumps( 186 | content, 187 | indent=2, 188 | ) 189 | msg = f"{fmt} not supported in config" 190 | raise NotImplementedError(msg) 191 | 192 | def dump(self, fmt: FormatLiteral, indent: int = 2, **kwargs: t.Any) -> str: 193 | r"""Dump via ConfigReader instance. 194 | 195 | >>> cfg = ConfigReader({ "session_name": "my session" }) 196 | >>> cfg.dump("yaml") 197 | 'session_name: my session\n' 198 | >>> cfg.dump("json") 199 | '{\n "session_name": "my session"\n}' 200 | """ 201 | return self._dump( 202 | fmt=fmt, 203 | content=self.content, 204 | indent=indent, 205 | **kwargs, 206 | ) 207 | -------------------------------------------------------------------------------- /src/vcspull/cli/__init__.py: -------------------------------------------------------------------------------- 1 | """CLI utilities for vcspull.""" 2 | 3 | from __future__ import annotations 4 | 5 | import argparse 6 | import logging 7 | import textwrap 8 | import typing as t 9 | from typing import overload 10 | 11 | from libvcs.__about__ import __version__ as libvcs_version 12 | 13 | from vcspull.__about__ import __version__ 14 | from vcspull.log import setup_logger 15 | 16 | from .sync import create_sync_subparser, sync 17 | 18 | log = logging.getLogger(__name__) 19 | 20 | SYNC_DESCRIPTION = textwrap.dedent( 21 | """ 22 | sync vcs repos 23 | 24 | examples: 25 | vcspull sync "*" 26 | vcspull sync "django-*" 27 | vcspull sync "django-*" flask 28 | vcspull sync -c ./myrepos.yaml "*" 29 | vcspull sync -c ./myrepos.yaml myproject 30 | """, 31 | ).strip() 32 | 33 | 34 | @overload 35 | def create_parser( 36 | return_subparsers: t.Literal[True], 37 | ) -> tuple[argparse.ArgumentParser, t.Any]: ... 38 | 39 | 40 | @overload 41 | def create_parser(return_subparsers: t.Literal[False]) -> argparse.ArgumentParser: ... 42 | 43 | 44 | def create_parser( 45 | return_subparsers: bool = False, 46 | ) -> argparse.ArgumentParser | tuple[argparse.ArgumentParser, t.Any]: 47 | """Create CLI argument parser for vcspull.""" 48 | parser = argparse.ArgumentParser( 49 | prog="vcspull", 50 | formatter_class=argparse.RawDescriptionHelpFormatter, 51 | description=SYNC_DESCRIPTION, 52 | ) 53 | parser.add_argument( 54 | "--version", 55 | "-V", 56 | action="version", 57 | version=f"%(prog)s {__version__}, libvcs {libvcs_version}", 58 | ) 59 | parser.add_argument( 60 | "--log-level", 61 | metavar="level", 62 | action="store", 63 | default="INFO", 64 | help="log level (debug, info, warning, error, critical)", 65 | ) 66 | 67 | subparsers = parser.add_subparsers(dest="subparser_name") 68 | sync_parser = subparsers.add_parser( 69 | "sync", 70 | help="synchronize repos", 71 | formatter_class=argparse.RawDescriptionHelpFormatter, 72 | description=SYNC_DESCRIPTION, 73 | ) 74 | create_sync_subparser(sync_parser) 75 | 76 | if return_subparsers: 77 | return parser, sync_parser 78 | return parser 79 | 80 | 81 | def cli(_args: list[str] | None = None) -> None: 82 | """CLI entry point for vcspull.""" 83 | parser, sync_parser = create_parser(return_subparsers=True) 84 | args = parser.parse_args(_args) 85 | 86 | setup_logger(log=log, level=args.log_level.upper()) 87 | 88 | if args.subparser_name is None: 89 | parser.print_help() 90 | return 91 | if args.subparser_name == "sync": 92 | sync( 93 | repo_patterns=args.repo_patterns, 94 | config=args.config, 95 | exit_on_error=args.exit_on_error, 96 | parser=sync_parser, 97 | ) 98 | -------------------------------------------------------------------------------- /src/vcspull/cli/sync.py: -------------------------------------------------------------------------------- 1 | """Synchronization functionality for vcspull.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import sys 7 | import typing as t 8 | from copy import deepcopy 9 | 10 | from libvcs._internal.shortcuts import create_project 11 | from libvcs.url import registry as url_tools 12 | 13 | from vcspull import exc 14 | from vcspull.config import filter_repos, find_config_files, load_configs 15 | 16 | if t.TYPE_CHECKING: 17 | import argparse 18 | import pathlib 19 | from datetime import datetime 20 | 21 | from libvcs._internal.types import VCSLiteral 22 | from libvcs.sync.git import GitSync 23 | 24 | log = logging.getLogger(__name__) 25 | 26 | 27 | def clamp(n: int, _min: int, _max: int) -> int: 28 | """Clamp a number between a min and max value.""" 29 | return max(_min, min(n, _max)) 30 | 31 | 32 | EXIT_ON_ERROR_MSG = "Exiting via error (--exit-on-error passed)" 33 | NO_REPOS_FOR_TERM_MSG = 'No repo found in config(s) for "{name}"' 34 | 35 | 36 | def create_sync_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: 37 | """Create ``vcspull sync`` argument subparser.""" 38 | config_file = parser.add_argument( 39 | "--config", 40 | "-c", 41 | metavar="config-file", 42 | help="optional filepath to specify vcspull config", 43 | ) 44 | parser.add_argument( 45 | "repo_patterns", 46 | metavar="filter", 47 | nargs="*", 48 | help="patterns / terms of repos, accepts globs / fnmatch(3)", 49 | ) 50 | parser.add_argument( 51 | "--exit-on-error", 52 | "-x", 53 | action="store_true", 54 | dest="exit_on_error", 55 | help="exit immediately encountering error (when syncing multiple repos)", 56 | ) 57 | 58 | try: 59 | import shtab 60 | 61 | config_file.complete = shtab.FILE # type: ignore 62 | except ImportError: 63 | pass 64 | return parser 65 | 66 | 67 | def sync( 68 | repo_patterns: list[str], 69 | config: pathlib.Path, 70 | exit_on_error: bool, 71 | parser: argparse.ArgumentParser 72 | | None = None, # optional so sync can be unit tested 73 | ) -> None: 74 | """Entry point for ``vcspull sync``.""" 75 | if isinstance(repo_patterns, list) and len(repo_patterns) == 0: 76 | if parser is not None: 77 | parser.print_help() 78 | sys.exit(2) 79 | 80 | if config: 81 | configs = load_configs([config]) 82 | else: 83 | configs = load_configs(find_config_files(include_home=True)) 84 | found_repos = [] 85 | 86 | for repo_pattern in repo_patterns: 87 | path, vcs_url, name = None, None, None 88 | if any(repo_pattern.startswith(n) for n in ["./", "/", "~", "$HOME"]): 89 | path = repo_pattern 90 | elif any(repo_pattern.startswith(n) for n in ["http", "git", "svn", "hg"]): 91 | vcs_url = repo_pattern 92 | else: 93 | name = repo_pattern 94 | 95 | # collect the repos from the config files 96 | found = filter_repos(configs, path=path, vcs_url=vcs_url, name=name) 97 | if len(found) == 0: 98 | log.info(NO_REPOS_FOR_TERM_MSG.format(name=name)) 99 | found_repos.extend(filter_repos(configs, path=path, vcs_url=vcs_url, name=name)) 100 | 101 | for repo in found_repos: 102 | try: 103 | update_repo(repo) 104 | except Exception as e: # noqa: PERF203 105 | log.info( 106 | f"Failed syncing {repo.get('name')}", 107 | ) 108 | if log.isEnabledFor(logging.DEBUG): 109 | import traceback 110 | 111 | traceback.print_exc() 112 | if exit_on_error: 113 | if parser is not None: 114 | parser.exit(status=1, message=EXIT_ON_ERROR_MSG) 115 | raise SystemExit(EXIT_ON_ERROR_MSG) from e 116 | 117 | 118 | def progress_cb(output: str, timestamp: datetime) -> None: 119 | """CLI Progress callback for command.""" 120 | sys.stdout.write(output) 121 | sys.stdout.flush() 122 | 123 | 124 | def guess_vcs(url: str) -> VCSLiteral | None: 125 | """Guess the VCS from a URL.""" 126 | vcs_matches = url_tools.registry.match(url=url, is_explicit=True) 127 | 128 | if len(vcs_matches) == 0: 129 | log.warning(f"No vcs found for {url}") 130 | return None 131 | if len(vcs_matches) > 1: 132 | log.warning(f"No exact matches for {url}") 133 | return None 134 | 135 | return t.cast("VCSLiteral", vcs_matches[0].vcs) 136 | 137 | 138 | class CouldNotGuessVCSFromURL(exc.VCSPullException): 139 | """Raised when no VCS could be guessed from a URL.""" 140 | 141 | def __init__(self, repo_url: str, *args: object, **kwargs: object) -> None: 142 | return super().__init__(f"Could not automatically determine VCS for {repo_url}") 143 | 144 | 145 | def update_repo( 146 | repo_dict: t.Any, 147 | # repo_dict: Dict[str, Union[str, Dict[str, GitRemote], pathlib.Path]] 148 | ) -> GitSync: 149 | """Synchronize a single repository.""" 150 | repo_dict = deepcopy(repo_dict) 151 | if "pip_url" not in repo_dict: 152 | repo_dict["pip_url"] = repo_dict.pop("url") 153 | if "url" not in repo_dict: 154 | repo_dict["url"] = repo_dict.pop("pip_url") 155 | repo_dict["progress_callback"] = progress_cb 156 | 157 | if repo_dict.get("vcs") is None: 158 | vcs = guess_vcs(url=repo_dict["url"]) 159 | if vcs is None: 160 | raise CouldNotGuessVCSFromURL(repo_url=repo_dict["url"]) 161 | 162 | repo_dict["vcs"] = vcs 163 | 164 | r = create_project(**repo_dict) # Creates the repo object 165 | r.update_repo(set_remotes=True) # Creates repo if not exists and fetches 166 | 167 | # TODO: Fix this 168 | return r # type:ignore 169 | -------------------------------------------------------------------------------- /src/vcspull/config.py: -------------------------------------------------------------------------------- 1 | """Configuration functionality for vcspull.""" 2 | 3 | from __future__ import annotations 4 | 5 | import fnmatch 6 | import logging 7 | import os 8 | import pathlib 9 | import typing as t 10 | 11 | from libvcs.sync.git import GitRemote 12 | 13 | from vcspull.validator import is_valid_config 14 | 15 | from . import exc 16 | from ._internal.config_reader import ConfigReader 17 | from .util import get_config_dir, update_dict 18 | 19 | log = logging.getLogger(__name__) 20 | 21 | if t.TYPE_CHECKING: 22 | from collections.abc import Callable 23 | 24 | from typing_extensions import TypeGuard 25 | 26 | from .types import ConfigDict, RawConfigDict 27 | 28 | 29 | def expand_dir( 30 | dir_: pathlib.Path, 31 | cwd: pathlib.Path | Callable[[], pathlib.Path] = pathlib.Path.cwd, 32 | ) -> pathlib.Path: 33 | """Return path with environmental variables and tilde ~ expanded. 34 | 35 | Parameters 36 | ---------- 37 | _dir : pathlib.Path 38 | cwd : pathlib.Path, optional 39 | current working dir (for deciphering relative _dir paths), defaults to 40 | :py:meth:`os.getcwd()` 41 | 42 | Returns 43 | ------- 44 | pathlib.Path : 45 | Absolute directory path 46 | """ 47 | dir_ = pathlib.Path(os.path.expandvars(str(dir_))).expanduser() 48 | if callable(cwd): 49 | cwd = cwd() 50 | 51 | if not dir_.is_absolute(): 52 | dir_ = pathlib.Path(os.path.normpath(cwd / dir_)) 53 | assert dir_ == pathlib.Path(cwd, dir_).resolve(strict=False) 54 | return dir_ 55 | 56 | 57 | def extract_repos( 58 | config: RawConfigDict, 59 | cwd: pathlib.Path | Callable[[], pathlib.Path] = pathlib.Path.cwd, 60 | ) -> list[ConfigDict]: 61 | """Return expanded configuration. 62 | 63 | end-user configuration permit inline configuration shortcuts, expand to 64 | identical format for parsing. 65 | 66 | Parameters 67 | ---------- 68 | config : dict 69 | the repo config in :py:class:`dict` format. 70 | cwd : pathlib.Path 71 | current working dir (for deciphering relative paths) 72 | 73 | Returns 74 | ------- 75 | list : List of normalized repository information 76 | """ 77 | configs: list[ConfigDict] = [] 78 | if callable(cwd): 79 | cwd = cwd() 80 | 81 | for directory, repos in config.items(): 82 | assert isinstance(repos, dict) 83 | for repo, repo_data in repos.items(): 84 | conf: dict[str, t.Any] = {} 85 | 86 | """ 87 | repo_name: http://myrepo.com/repo.git 88 | 89 | to 90 | 91 | repo_name: { url: 'http://myrepo.com/repo.git' } 92 | 93 | also assures the repo is a :py:class:`dict`. 94 | """ 95 | 96 | if isinstance(repo_data, str): 97 | conf["url"] = repo_data 98 | else: 99 | conf = update_dict(conf, repo_data) 100 | 101 | if "repo" in conf: 102 | if "url" not in conf: 103 | conf["url"] = conf.pop("repo") 104 | else: 105 | conf.pop("repo", None) 106 | 107 | if "name" not in conf: 108 | conf["name"] = repo 109 | 110 | if "path" not in conf: 111 | conf["path"] = expand_dir( 112 | pathlib.Path(expand_dir(pathlib.Path(directory), cwd=cwd)) 113 | / conf["name"], 114 | cwd, 115 | ) 116 | 117 | if "remotes" in conf: 118 | assert isinstance(conf["remotes"], dict) 119 | for remote_name, url in conf["remotes"].items(): 120 | if isinstance(url, GitRemote): 121 | continue 122 | if isinstance(url, str): 123 | conf["remotes"][remote_name] = GitRemote( 124 | name=remote_name, 125 | fetch_url=url, 126 | push_url=url, 127 | ) 128 | elif isinstance(url, dict): 129 | assert "push_url" in url 130 | assert "fetch_url" in url 131 | conf["remotes"][remote_name] = GitRemote( 132 | name=remote_name, 133 | **url, 134 | ) 135 | 136 | def is_valid_config_dict(val: t.Any) -> TypeGuard[ConfigDict]: 137 | assert isinstance(val, dict) 138 | return True 139 | 140 | assert is_valid_config_dict(conf) 141 | 142 | configs.append(conf) 143 | 144 | return configs 145 | 146 | 147 | def find_home_config_files( 148 | filetype: list[str] | None = None, 149 | ) -> list[pathlib.Path]: 150 | """Return configs of ``.vcspull.{yaml,json}`` in user's home directory.""" 151 | if filetype is None: 152 | filetype = ["json", "yaml"] 153 | configs: list[pathlib.Path] = [] 154 | 155 | yaml_config = pathlib.Path("~/.vcspull.yaml").expanduser() 156 | has_yaml_config = yaml_config.exists() 157 | json_config = pathlib.Path("~/.vcspull.json").expanduser() 158 | has_json_config = json_config.exists() 159 | 160 | if not has_yaml_config and not has_json_config: 161 | log.debug( 162 | "No config file found. Create a .vcspull.yaml or .vcspull.json" 163 | " in your $HOME directory. http://vcspull.git-pull.com for a" 164 | " quickstart.", 165 | ) 166 | else: 167 | if sum(filter(None, [has_json_config, has_yaml_config])) > 1: 168 | raise exc.MultipleConfigWarning 169 | if has_yaml_config: 170 | configs.append(yaml_config) 171 | if has_json_config: 172 | configs.append(json_config) 173 | 174 | return configs 175 | 176 | 177 | def find_config_files( 178 | path: list[pathlib.Path] | pathlib.Path | None = None, 179 | match: list[str] | str | None = None, 180 | filetype: t.Literal["json", "yaml", "*"] 181 | | list[t.Literal["json", "yaml", "*"]] 182 | | None = None, 183 | include_home: bool = False, 184 | ) -> list[pathlib.Path]: 185 | """Return repos from a directory and match. Not recursive. 186 | 187 | Parameters 188 | ---------- 189 | path : list 190 | list of paths to search 191 | match : list 192 | list of globs to search against 193 | filetype: list 194 | of filetypes to search against 195 | include_home : bool 196 | Include home configuration files 197 | 198 | Raises 199 | ------ 200 | LoadConfigRepoConflict : 201 | There are two configs that have same path and name with different repo urls. 202 | 203 | Returns 204 | ------- 205 | list : 206 | list of absolute paths to config files. 207 | """ 208 | if filetype is None: 209 | filetype = ["json", "yaml"] 210 | if match is None: 211 | match = ["*"] 212 | config_files = [] 213 | if path is None: 214 | path = get_config_dir() 215 | 216 | if include_home is True: 217 | config_files.extend(find_home_config_files()) 218 | 219 | if isinstance(path, list): 220 | for p in path: 221 | config_files.extend(find_config_files(p, match, filetype)) 222 | return config_files 223 | else: 224 | path = path.expanduser() 225 | if isinstance(match, list): 226 | for m in match: 227 | config_files.extend(find_config_files(path, m, filetype)) 228 | elif isinstance(filetype, list): 229 | for f in filetype: 230 | config_files.extend(find_config_files(path, match, f)) 231 | else: 232 | match = f"{match}.{filetype}" 233 | config_files = list(path.glob(match)) 234 | 235 | return config_files 236 | 237 | 238 | def load_configs( 239 | files: list[pathlib.Path], 240 | cwd: pathlib.Path | Callable[[], pathlib.Path] = pathlib.Path.cwd, 241 | ) -> list[ConfigDict]: 242 | """Return repos from a list of files. 243 | 244 | Parameters 245 | ---------- 246 | files : list 247 | paths to config file 248 | cwd : pathlib.Path 249 | current path (pass down for :func:`extract_repos` 250 | 251 | Returns 252 | ------- 253 | list of dict : 254 | expanded config dict item 255 | 256 | Todo 257 | ---- 258 | Validate scheme, check for duplicate destinations, VCS urls 259 | """ 260 | repos: list[ConfigDict] = [] 261 | if callable(cwd): 262 | cwd = cwd() 263 | 264 | for file in files: 265 | if isinstance(file, str): 266 | file = pathlib.Path(file) 267 | assert isinstance(file, pathlib.Path) 268 | conf = ConfigReader._from_file(file) 269 | assert is_valid_config(conf) 270 | newrepos = extract_repos(conf, cwd=cwd) 271 | 272 | if not repos: 273 | repos.extend(newrepos) 274 | continue 275 | 276 | dupes = detect_duplicate_repos(repos, newrepos) 277 | 278 | if len(dupes) > 0: 279 | msg = ("repos with same path + different VCS detected!", dupes) 280 | raise exc.VCSPullException(msg) 281 | repos.extend(newrepos) 282 | 283 | return repos 284 | 285 | 286 | ConfigDictTuple = tuple["ConfigDict", "ConfigDict"] 287 | 288 | 289 | def detect_duplicate_repos( 290 | config1: list[ConfigDict], 291 | config2: list[ConfigDict], 292 | ) -> list[ConfigDictTuple]: 293 | """Return duplicate repos dict if repo_dir same and vcs different. 294 | 295 | Parameters 296 | ---------- 297 | config1 : list[ConfigDict] 298 | 299 | config2 : list[ConfigDict] 300 | 301 | Returns 302 | ------- 303 | list[ConfigDictTuple] 304 | List of duplicate tuples 305 | """ 306 | if not config1: 307 | return [] 308 | 309 | dupes: list[ConfigDictTuple] = [] 310 | 311 | repo_dirs = { 312 | pathlib.Path(repo["path"]).parent / repo["name"]: repo for repo in config1 313 | } 314 | repo_dirs_2 = { 315 | pathlib.Path(repo["path"]).parent / repo["name"]: repo for repo in config2 316 | } 317 | 318 | for repo_dir, repo in repo_dirs.items(): 319 | if repo_dir in repo_dirs_2: 320 | dupes.append((repo, repo_dirs_2[repo_dir])) 321 | 322 | return dupes 323 | 324 | 325 | def in_dir( 326 | config_dir: pathlib.Path | None = None, 327 | extensions: list[str] | None = None, 328 | ) -> list[str]: 329 | """Return a list of configs in ``config_dir``. 330 | 331 | Parameters 332 | ---------- 333 | config_dir : str 334 | directory to search 335 | extensions : list 336 | filetypes to check (e.g. ``['.yaml', '.json']``). 337 | 338 | Returns 339 | ------- 340 | list 341 | """ 342 | if extensions is None: 343 | extensions = [".yml", ".yaml", ".json"] 344 | if config_dir is None: 345 | config_dir = get_config_dir() 346 | 347 | return [ 348 | path.name 349 | for path in config_dir.iterdir() 350 | if is_config_file(path.name, extensions) and not path.name.startswith(".") 351 | ] 352 | 353 | 354 | def filter_repos( 355 | config: list[ConfigDict], 356 | path: pathlib.Path | t.Literal["*"] | str | None = None, 357 | vcs_url: str | None = None, 358 | name: str | None = None, 359 | ) -> list[ConfigDict]: 360 | """Return a :py:obj:`list` list of repos from (expanded) config file. 361 | 362 | path, vcs_url and name all support fnmatch. 363 | 364 | Parameters 365 | ---------- 366 | config : dict 367 | the expanded repo config in :py:class:`dict` format. 368 | path : str, Optional 369 | directory of checkout location, fnmatch pattern supported 370 | vcs_url : str, Optional 371 | url of vcs remote, fn match pattern supported 372 | name : str, Optional 373 | project name, fnmatch pattern supported 374 | 375 | Returns 376 | ------- 377 | list : 378 | Repos 379 | """ 380 | repo_list: list[ConfigDict] = [] 381 | 382 | if path: 383 | repo_list.extend( 384 | [ 385 | r 386 | for r in config 387 | if fnmatch.fnmatch(str(pathlib.Path(r["path"]).parent), str(path)) 388 | ], 389 | ) 390 | 391 | if vcs_url: 392 | repo_list.extend( 393 | r 394 | for r in config 395 | if fnmatch.fnmatch(str(r.get("url", r.get("repo"))), vcs_url) 396 | ) 397 | 398 | if name: 399 | repo_list.extend( 400 | [r for r in config if fnmatch.fnmatch(str(r.get("name")), name)], 401 | ) 402 | 403 | return repo_list 404 | 405 | 406 | def is_config_file( 407 | filename: str, 408 | extensions: list[str] | str | None = None, 409 | ) -> bool: 410 | """Return True if file has a valid config file type. 411 | 412 | Parameters 413 | ---------- 414 | filename : str 415 | filename to check (e.g. ``mysession.json``). 416 | extensions : list or str 417 | filetypes to check (e.g. ``['.yaml', '.json']``). 418 | 419 | Returns 420 | ------- 421 | bool : True if is a valid config file type 422 | """ 423 | if extensions is None: 424 | extensions = [".yml", ".yaml", ".json"] 425 | extensions = [extensions] if isinstance(extensions, str) else extensions 426 | return any(filename.endswith(e) for e in extensions) 427 | -------------------------------------------------------------------------------- /src/vcspull/exc.py: -------------------------------------------------------------------------------- 1 | """Exceptions for vcspull.""" 2 | 3 | from __future__ import annotations 4 | 5 | 6 | class VCSPullException(Exception): 7 | """Standard exception raised by vcspull.""" 8 | 9 | 10 | class MultipleConfigWarning(VCSPullException): 11 | """Multiple eligible config files found at the same time.""" 12 | 13 | message = "Multiple configs found in home directory use only one. .yaml, .json." 14 | -------------------------------------------------------------------------------- /src/vcspull/log.py: -------------------------------------------------------------------------------- 1 | """Log utilities for formatting CLI output in vcspull. 2 | 3 | This module containers special formatters for processing the additional context 4 | information from :class:`libvcs.base.RepoLoggingAdapter`. 5 | 6 | Colorized formatters for generic logging inside the application is also 7 | provided. 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | import logging 13 | import time 14 | import typing as t 15 | 16 | from colorama import Fore, Style 17 | 18 | LEVEL_COLORS = { 19 | "DEBUG": Fore.BLUE, # Blue 20 | "INFO": Fore.GREEN, # Green 21 | "WARNING": Fore.YELLOW, 22 | "ERROR": Fore.RED, 23 | "CRITICAL": Fore.RED, 24 | } 25 | 26 | 27 | def setup_logger( 28 | log: logging.Logger | None = None, 29 | level: t.Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO", 30 | ) -> None: 31 | """Configure vcspull logger for CLI use. 32 | 33 | Parameters 34 | ---------- 35 | log : :py:class:`logging.Logger` 36 | instance of logger 37 | """ 38 | if not log: 39 | log = logging.getLogger() 40 | if not log.handlers: 41 | channel = logging.StreamHandler() 42 | channel.setFormatter(DebugLogFormatter()) 43 | 44 | log.setLevel(level) 45 | log.addHandler(channel) 46 | 47 | # setup styling for repo loggers 48 | repo_logger = logging.getLogger("libvcs") 49 | channel = logging.StreamHandler() 50 | channel.setFormatter(RepoLogFormatter()) 51 | channel.addFilter(RepoFilter()) 52 | repo_logger.setLevel(level) 53 | repo_logger.addHandler(channel) 54 | 55 | 56 | class LogFormatter(logging.Formatter): 57 | """Log formatting for vcspull.""" 58 | 59 | def template(self, record: logging.LogRecord) -> str: 60 | """Return the prefix for the log message. Template for Formatter. 61 | 62 | Parameters 63 | ---------- 64 | record : :py:class:`logging.LogRecord` 65 | Passed in from inside the :py:meth:`logging.Formatter.format` record. 66 | """ 67 | reset = [Style.RESET_ALL] 68 | levelname = [ 69 | LEVEL_COLORS.get(record.levelname, ""), 70 | Style.BRIGHT, 71 | "(%(levelname)s)", 72 | Style.RESET_ALL, 73 | " ", 74 | ] 75 | asctime = [ 76 | "[", 77 | Fore.BLACK, 78 | Style.DIM, 79 | Style.BRIGHT, 80 | "%(asctime)s", 81 | Fore.RESET, 82 | Style.RESET_ALL, 83 | "]", 84 | ] 85 | name = [ 86 | " ", 87 | Fore.WHITE, 88 | Style.DIM, 89 | Style.BRIGHT, 90 | "%(name)s", 91 | Fore.RESET, 92 | Style.RESET_ALL, 93 | " ", 94 | ] 95 | 96 | return "".join(reset + levelname + asctime + name + reset) 97 | 98 | def __init__(self, color: bool = True, **kwargs: t.Any) -> None: 99 | logging.Formatter.__init__(self, **kwargs) 100 | 101 | def format(self, record: logging.LogRecord) -> str: 102 | """Format log record.""" 103 | try: 104 | record.message = record.getMessage() 105 | except Exception as e: 106 | record.message = f"Bad message ({e!r}): {record.__dict__!r}" 107 | 108 | date_format = "%H:%m:%S" 109 | formatting = self.converter(record.created) 110 | record.asctime = time.strftime(date_format, formatting) 111 | prefix = self.template(record) % record.__dict__ 112 | 113 | formatted = prefix + " " + record.message 114 | return formatted.replace("\n", "\n ") 115 | 116 | 117 | class DebugLogFormatter(LogFormatter): 118 | """Provides greater technical details than standard log Formatter.""" 119 | 120 | def template(self, record: logging.LogRecord) -> str: 121 | """Return the prefix for the log message. Template for Formatter. 122 | 123 | Parameters 124 | ---------- 125 | record : :class:`logging.LogRecord` 126 | Passed from inside the :py:meth:`logging.Formatter.format` record. 127 | """ 128 | reset = [Style.RESET_ALL] 129 | levelname = [ 130 | LEVEL_COLORS.get(record.levelname, ""), 131 | Style.BRIGHT, 132 | "(%(levelname)1.1s)", 133 | Style.RESET_ALL, 134 | " ", 135 | ] 136 | asctime = [ 137 | "[", 138 | Fore.BLACK, 139 | Style.DIM, 140 | Style.BRIGHT, 141 | "%(asctime)s", 142 | Fore.RESET, 143 | Style.RESET_ALL, 144 | "]", 145 | ] 146 | name = [ 147 | " ", 148 | Fore.WHITE, 149 | Style.DIM, 150 | Style.BRIGHT, 151 | "%(name)s", 152 | Fore.RESET, 153 | Style.RESET_ALL, 154 | " ", 155 | ] 156 | module_funcName = [Fore.GREEN, Style.BRIGHT, "%(module)s.%(funcName)s()"] 157 | lineno = [ 158 | Fore.BLACK, 159 | Style.DIM, 160 | Style.BRIGHT, 161 | ":", 162 | Style.RESET_ALL, 163 | Fore.CYAN, 164 | "%(lineno)d", 165 | ] 166 | 167 | return "".join( 168 | reset + levelname + asctime + name + module_funcName + lineno + reset, 169 | ) 170 | 171 | 172 | class RepoLogFormatter(LogFormatter): 173 | """Log message for VCS repository.""" 174 | 175 | def template(self, record: logging.LogRecord) -> str: 176 | """Template for logging vcs bin name, along with a contextual hint.""" 177 | record.message = ( 178 | f"{Fore.MAGENTA}{Style.BRIGHT}{record.message}{Fore.RESET}{Style.RESET_ALL}" 179 | ) 180 | return f"{Fore.GREEN + Style.DIM}|{record.bin_name}| {Fore.YELLOW}({record.keyword}) {Fore.RESET}" # type:ignore # noqa: E501 181 | 182 | 183 | class RepoFilter(logging.Filter): 184 | """Only include repo logs for this type of record.""" 185 | 186 | def filter(self, record: logging.LogRecord) -> bool: 187 | """Only return a record if a keyword object.""" 188 | return "keyword" in record.__dict__ 189 | -------------------------------------------------------------------------------- /src/vcspull/types.py: -------------------------------------------------------------------------------- 1 | """Typings for vcspull.""" 2 | 3 | from __future__ import annotations 4 | 5 | import typing as t 6 | 7 | from typing_extensions import NotRequired, TypedDict 8 | 9 | if t.TYPE_CHECKING: 10 | import pathlib 11 | 12 | from libvcs._internal.types import StrPath, VCSLiteral 13 | from libvcs.sync.git import GitSyncRemoteDict 14 | 15 | 16 | class RawConfigDict(t.TypedDict): 17 | """Configuration dictionary without any type marshalling or variable resolution.""" 18 | 19 | vcs: VCSLiteral 20 | name: str 21 | path: StrPath 22 | url: str 23 | remotes: GitSyncRemoteDict 24 | 25 | 26 | RawConfigDir = dict[str, RawConfigDict] 27 | RawConfig = dict[str, RawConfigDir] 28 | 29 | 30 | class ConfigDict(TypedDict): 31 | """Configuration map for vcspull after shorthands and variables resolved.""" 32 | 33 | vcs: VCSLiteral | None 34 | name: str 35 | path: pathlib.Path 36 | url: str 37 | remotes: NotRequired[GitSyncRemoteDict | None] 38 | shell_command_after: NotRequired[list[str] | None] 39 | 40 | 41 | ConfigDir = dict[str, ConfigDict] 42 | Config = dict[str, ConfigDir] 43 | -------------------------------------------------------------------------------- /src/vcspull/util.py: -------------------------------------------------------------------------------- 1 | """Utility functions for vcspull.""" 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | import pathlib 7 | import typing as t 8 | from collections.abc import Mapping 9 | 10 | LEGACY_CONFIG_DIR = pathlib.Path("~/.vcspull/").expanduser() # remove dupes of this 11 | 12 | 13 | def get_config_dir() -> pathlib.Path: 14 | """ 15 | Return vcspull configuration directory. 16 | 17 | ``VCSPULL_CONFIGDIR`` environmental variable has precedence if set. We also 18 | evaluate XDG default directory from XDG_CONFIG_HOME environmental variable 19 | if set or its default. Then the old default ~/.vcspull is returned for 20 | compatibility. 21 | 22 | Returns 23 | ------- 24 | str : 25 | absolute path to tmuxp config directory 26 | """ 27 | paths: list[pathlib.Path] = [] 28 | if "VCSPULL_CONFIGDIR" in os.environ: 29 | paths.append(pathlib.Path(os.environ["VCSPULL_CONFIGDIR"])) 30 | if "XDG_CONFIG_HOME" in os.environ: 31 | paths.append(pathlib.Path(os.environ["XDG_CONFIG_HOME"]) / "vcspull") 32 | else: 33 | paths.append(pathlib.Path("~/.config/vcspull/")) 34 | paths.append(LEGACY_CONFIG_DIR) 35 | 36 | path = None 37 | for path in paths: 38 | path = path.expanduser() 39 | if path.is_dir(): 40 | return path 41 | 42 | # Return last path as default if none of the previous ones matched 43 | return path 44 | 45 | 46 | T = t.TypeVar("T", bound=dict[str, t.Any]) 47 | 48 | 49 | def update_dict( 50 | d: T, 51 | u: T, 52 | ) -> T: 53 | """Return updated dict. 54 | 55 | Parameters 56 | ---------- 57 | d : dict 58 | u : dict 59 | 60 | Returns 61 | ------- 62 | dict : 63 | Updated dictionary 64 | 65 | Notes 66 | ----- 67 | Thanks: http://stackoverflow.com/a/3233356 68 | """ 69 | for k, v in u.items(): 70 | if isinstance(v, Mapping): 71 | r = update_dict(d.get(k, {}), v) 72 | d[k] = r 73 | else: 74 | d[k] = v 75 | return d 76 | -------------------------------------------------------------------------------- /src/vcspull/validator.py: -------------------------------------------------------------------------------- 1 | """Validation of vcspull configuration file.""" 2 | 3 | from __future__ import annotations 4 | 5 | import pathlib 6 | import typing as t 7 | 8 | if t.TYPE_CHECKING: 9 | from typing_extensions import TypeGuard 10 | 11 | from vcspull.types import RawConfigDict 12 | 13 | 14 | def is_valid_config(config: dict[str, t.Any]) -> TypeGuard[RawConfigDict]: 15 | """Return true and upcast if vcspull configuration file is valid.""" 16 | if not isinstance(config, dict): 17 | return False 18 | 19 | for k, v in config.items(): 20 | if k is None or v is None: 21 | return False 22 | 23 | if not isinstance(k, str) and not isinstance(k, pathlib.Path): 24 | return False 25 | 26 | if not isinstance(v, dict): 27 | return False 28 | 29 | for repo in v.values(): 30 | if not isinstance(repo, (str, dict, pathlib.Path)): 31 | return False 32 | 33 | if isinstance(repo, dict) and "url" not in repo and "repo" not in repo: 34 | return False 35 | 36 | return True 37 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for vcspull package.""" 2 | 3 | from __future__ import annotations 4 | 5 | from . import fixtures 6 | -------------------------------------------------------------------------------- /tests/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | """Test fixture data for vcspull.""" 2 | -------------------------------------------------------------------------------- /tests/fixtures/example.py: -------------------------------------------------------------------------------- 1 | """Example fixture data for vcspull tests.""" 2 | 3 | from __future__ import annotations 4 | 5 | import pathlib 6 | import typing as t 7 | 8 | from libvcs.sync.git import GitRemote 9 | 10 | if t.TYPE_CHECKING: 11 | from vcspull.types import ConfigDict 12 | 13 | config_dict = { 14 | "/home/me/myproject/study/": { 15 | "linux": "git+git://git.kernel.org/linux/torvalds/linux.git", 16 | "freebsd": "git+https://github.com/freebsd/freebsd.git", 17 | "sphinx": "hg+https://bitbucket.org/birkenfeld/sphinx", 18 | "docutils": "svn+http://svn.code.sf.net/p/docutils/code/trunk", 19 | }, 20 | "/home/me/myproject/github_projects/": { 21 | "kaptan": { 22 | "url": "git+git@github.com:tony/kaptan.git", 23 | "remotes": { 24 | "upstream": "git+https://github.com/emre/kaptan", 25 | "ms": "git+https://github.com/ms/kaptan.git", 26 | }, 27 | }, 28 | }, 29 | "/home/me/myproject": { 30 | ".vim": { 31 | "url": "git+git@github.com:tony/vim-config.git", 32 | "shell_command_after": "ln -sf /home/me/.vim/.vimrc /home/me/.vimrc", 33 | }, 34 | ".tmux": { 35 | "url": "git+git@github.com:tony/tmux-config.git", 36 | "shell_command_after": [ 37 | "ln -sf /home/me/.tmux/.tmux.conf /home/me/.tmux.conf", 38 | ], 39 | }, 40 | }, 41 | } 42 | 43 | config_dict_expanded: list[ConfigDict] = [ 44 | { 45 | "vcs": "git", 46 | "name": "linux", 47 | "path": pathlib.Path("/home/me/myproject/study/linux"), 48 | "url": "git+git://git.kernel.org/linux/torvalds/linux.git", 49 | }, 50 | { 51 | "vcs": "git", 52 | "name": "freebsd", 53 | "path": pathlib.Path("/home/me/myproject/study/freebsd"), 54 | "url": "git+https://github.com/freebsd/freebsd.git", 55 | }, 56 | { 57 | "vcs": "git", 58 | "name": "sphinx", 59 | "path": pathlib.Path("/home/me/myproject/study/sphinx"), 60 | "url": "hg+https://bitbucket.org/birkenfeld/sphinx", 61 | }, 62 | { 63 | "vcs": "git", 64 | "name": "docutils", 65 | "path": pathlib.Path("/home/me/myproject/study/docutils"), 66 | "url": "svn+http://svn.code.sf.net/p/docutils/code/trunk", 67 | }, 68 | { 69 | "vcs": "git", 70 | "name": "kaptan", 71 | "url": "git+git@github.com:tony/kaptan.git", 72 | "path": pathlib.Path("/home/me/myproject/github_projects/kaptan"), 73 | "remotes": { 74 | "upstream": GitRemote( 75 | name="upstream", 76 | fetch_url="git+https://github.com/emre/kaptan", 77 | push_url="git+https://github.com/emre/kaptan", 78 | ), 79 | "ms": GitRemote( 80 | name="ms", 81 | fetch_url="git+https://github.com/ms/kaptan.git", 82 | push_url="git+https://github.com/ms/kaptan.git", 83 | ), 84 | }, 85 | }, 86 | { 87 | "vcs": "git", 88 | "name": ".vim", 89 | "path": pathlib.Path("/home/me/myproject/.vim"), 90 | "url": "git+git@github.com:tony/vim-config.git", 91 | "shell_command_after": ["ln -sf /home/me/.vim/.vimrc /home/me/.vimrc"], 92 | }, 93 | { 94 | "vcs": "git", 95 | "name": ".tmux", 96 | "path": pathlib.Path("/home/me/myproject/.tmux"), 97 | "url": "git+git@github.com:tony/tmux-config.git", 98 | "shell_command_after": ["ln -sf /home/me/.tmux/.tmux.conf /home/me/.tmux.conf"], 99 | }, 100 | ] 101 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | """Helpers for vcspull.""" 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | import typing as t 7 | 8 | from typing_extensions import Self 9 | 10 | from vcspull._internal.config_reader import ConfigReader 11 | 12 | if t.TYPE_CHECKING: 13 | import pathlib 14 | 15 | 16 | class EnvironmentVarGuard: 17 | """Class to help protect the environment variable properly. 18 | 19 | May be used as context manager. 20 | Vendorize to fix issue with Anaconda Python 2 not 21 | including test module, see #121. 22 | """ 23 | 24 | def __init__(self) -> None: 25 | self._environ = os.environ 26 | self._unset: set[str] = set() 27 | self._reset: dict[str, str] = {} 28 | 29 | def set(self, envvar: str, value: str) -> None: 30 | """Set environmental variable.""" 31 | if envvar not in self._environ: 32 | self._unset.add(envvar) 33 | else: 34 | self._reset[envvar] = self._environ[envvar] 35 | self._environ[envvar] = value 36 | 37 | def unset(self, envvar: str) -> None: 38 | """Unset environmental variable.""" 39 | if envvar in self._environ: 40 | self._reset[envvar] = self._environ[envvar] 41 | del self._environ[envvar] 42 | 43 | def __enter__(self) -> Self: 44 | """Context manager entry for setting and resetting environmental variable.""" 45 | return self 46 | 47 | def __exit__(self, *ignore_exc: object) -> None: 48 | """Context manager teardown for setting and resetting environmental variable.""" 49 | for envvar, value in self._reset.items(): 50 | self._environ[envvar] = value 51 | for unset in self._unset: 52 | del self._environ[unset] 53 | 54 | 55 | def write_config(config_path: pathlib.Path, content: str) -> pathlib.Path: 56 | """Write configuration file.""" 57 | config_path.write_text(content, encoding="utf-8") 58 | return config_path 59 | 60 | 61 | def load_raw(data: str, fmt: t.Literal["yaml", "json"]) -> dict[str, t.Any]: 62 | """Load configuration data via string value. Accepts yaml or json.""" 63 | return ConfigReader._load(fmt=fmt, content=data) 64 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | """Test CLI entry point for for vcspull.""" 2 | 3 | from __future__ import annotations 4 | 5 | import contextlib 6 | import shutil 7 | import typing as t 8 | 9 | import pytest 10 | import yaml 11 | 12 | from vcspull.__about__ import __version__ 13 | from vcspull.cli import cli 14 | from vcspull.cli.sync import EXIT_ON_ERROR_MSG, NO_REPOS_FOR_TERM_MSG 15 | 16 | if t.TYPE_CHECKING: 17 | import pathlib 18 | 19 | from libvcs.sync.git import GitSync 20 | from typing_extensions import TypeAlias 21 | 22 | ExpectedOutput: TypeAlias = t.Optional[t.Union[str, list[str]]] 23 | 24 | 25 | class SyncCLINonExistentRepo(t.NamedTuple): 26 | """Pytest fixture for vcspull syncing when repo does not exist.""" 27 | 28 | # pytest internal: used for naming test 29 | test_id: str 30 | 31 | # test parameters 32 | sync_args: list[str] 33 | expected_exit_code: int 34 | expected_in_out: ExpectedOutput = None 35 | expected_not_in_out: ExpectedOutput = None 36 | expected_in_err: ExpectedOutput = None 37 | expected_not_in_err: ExpectedOutput = None 38 | 39 | 40 | SYNC_CLI_EXISTENT_REPO_FIXTURES: list[SyncCLINonExistentRepo] = [ 41 | SyncCLINonExistentRepo( 42 | test_id="exists", 43 | sync_args=["my_git_project"], 44 | expected_exit_code=0, 45 | expected_in_out="Already on 'master'", 46 | expected_not_in_out=NO_REPOS_FOR_TERM_MSG.format(name="my_git_repo"), 47 | ), 48 | SyncCLINonExistentRepo( 49 | test_id="non-existent-only", 50 | sync_args=["this_isnt_in_the_config"], 51 | expected_exit_code=0, 52 | expected_in_out=NO_REPOS_FOR_TERM_MSG.format(name="this_isnt_in_the_config"), 53 | ), 54 | SyncCLINonExistentRepo( 55 | test_id="non-existent-mixed", 56 | sync_args=["this_isnt_in_the_config", "my_git_project", "another"], 57 | expected_exit_code=0, 58 | expected_in_out=[ 59 | NO_REPOS_FOR_TERM_MSG.format(name="this_isnt_in_the_config"), 60 | NO_REPOS_FOR_TERM_MSG.format(name="another"), 61 | ], 62 | expected_not_in_out=NO_REPOS_FOR_TERM_MSG.format(name="my_git_repo"), 63 | ), 64 | ] 65 | 66 | 67 | @pytest.mark.parametrize( 68 | list(SyncCLINonExistentRepo._fields), 69 | SYNC_CLI_EXISTENT_REPO_FIXTURES, 70 | ids=[test.test_id for test in SYNC_CLI_EXISTENT_REPO_FIXTURES], 71 | ) 72 | def test_sync_cli_filter_non_existent( 73 | tmp_path: pathlib.Path, 74 | capsys: pytest.CaptureFixture[str], 75 | caplog: pytest.LogCaptureFixture, 76 | monkeypatch: pytest.MonkeyPatch, 77 | user_path: pathlib.Path, 78 | config_path: pathlib.Path, 79 | git_repo: GitSync, 80 | test_id: str, 81 | sync_args: list[str], 82 | expected_exit_code: int, 83 | expected_in_out: ExpectedOutput, 84 | expected_not_in_out: ExpectedOutput, 85 | expected_in_err: ExpectedOutput, 86 | expected_not_in_err: ExpectedOutput, 87 | ) -> None: 88 | """Tests vcspull syncing when repo does not exist.""" 89 | config = { 90 | "~/github_projects/": { 91 | "my_git_project": { 92 | "url": f"git+file://{git_repo.path}", 93 | "remotes": {"test_remote": f"git+file://{git_repo.path}"}, 94 | }, 95 | }, 96 | } 97 | yaml_config = config_path / ".vcspull.yaml" 98 | yaml_config_data = yaml.dump(config, default_flow_style=False) 99 | yaml_config.write_text(yaml_config_data, encoding="utf-8") 100 | 101 | monkeypatch.chdir(tmp_path) 102 | 103 | with contextlib.suppress(SystemExit): 104 | cli(["sync", *sync_args]) 105 | 106 | output = "".join(list(caplog.messages) + list(capsys.readouterr().out)) 107 | 108 | if expected_in_out is not None: 109 | if isinstance(expected_in_out, str): 110 | expected_in_out = [expected_in_out] 111 | for needle in expected_in_out: 112 | assert needle in output 113 | 114 | if expected_not_in_out is not None: 115 | if isinstance(expected_not_in_out, str): 116 | expected_not_in_out = [expected_not_in_out] 117 | for needle in expected_not_in_out: 118 | assert needle not in output 119 | 120 | 121 | class SyncFixture(t.NamedTuple): 122 | """Pytest fixture for vcspull sync.""" 123 | 124 | # pytest internal: used for naming test 125 | test_id: str 126 | 127 | # test params 128 | sync_args: list[str] 129 | expected_exit_code: int 130 | expected_in_out: ExpectedOutput = None 131 | expected_not_in_out: ExpectedOutput = None 132 | expected_in_err: ExpectedOutput = None 133 | expected_not_in_err: ExpectedOutput = None 134 | 135 | 136 | SYNC_REPO_FIXTURES: list[SyncFixture] = [ 137 | # Empty (root command) 138 | SyncFixture( 139 | test_id="empty", 140 | sync_args=[], 141 | expected_exit_code=0, 142 | expected_in_out=["{sync", "positional arguments:"], 143 | ), 144 | # Version 145 | SyncFixture( 146 | test_id="--version", 147 | sync_args=["--version"], 148 | expected_exit_code=0, 149 | expected_in_out=[__version__, ", libvcs"], 150 | ), 151 | SyncFixture( 152 | test_id="-V", 153 | sync_args=["-V"], 154 | expected_exit_code=0, 155 | expected_in_out=[__version__, ", libvcs"], 156 | ), 157 | # Help 158 | SyncFixture( 159 | test_id="--help", 160 | sync_args=["--help"], 161 | expected_exit_code=0, 162 | expected_in_out=["{sync", "positional arguments:"], 163 | ), 164 | SyncFixture( 165 | test_id="-h", 166 | sync_args=["-h"], 167 | expected_exit_code=0, 168 | expected_in_out=["{sync", "positional arguments:"], 169 | ), 170 | # Sync 171 | SyncFixture( 172 | test_id="sync--empty", 173 | sync_args=["sync"], 174 | expected_exit_code=0, 175 | expected_in_out=["positional arguments:"], 176 | ), 177 | # Sync: Help 178 | SyncFixture( 179 | test_id="sync---help", 180 | sync_args=["sync", "--help"], 181 | expected_exit_code=0, 182 | expected_in_out=["filter", "--exit-on-error"], 183 | expected_not_in_out="--version", 184 | ), 185 | SyncFixture( 186 | test_id="sync--h", 187 | sync_args=["sync", "-h"], 188 | expected_exit_code=0, 189 | expected_in_out=["filter", "--exit-on-error"], 190 | expected_not_in_out="--version", 191 | ), 192 | # Sync: Repo terms 193 | SyncFixture( 194 | test_id="sync--one-repo-term", 195 | sync_args=["sync", "my_git_repo"], 196 | expected_exit_code=0, 197 | expected_in_out="my_git_repo", 198 | ), 199 | ] 200 | 201 | 202 | @pytest.mark.parametrize( 203 | list(SyncFixture._fields), 204 | SYNC_REPO_FIXTURES, 205 | ids=[test.test_id for test in SYNC_REPO_FIXTURES], 206 | ) 207 | def test_sync( 208 | tmp_path: pathlib.Path, 209 | capsys: pytest.CaptureFixture[str], 210 | monkeypatch: pytest.MonkeyPatch, 211 | user_path: pathlib.Path, 212 | config_path: pathlib.Path, 213 | git_repo: GitSync, 214 | test_id: str, 215 | sync_args: list[str], 216 | expected_exit_code: int, 217 | expected_in_out: ExpectedOutput, 218 | expected_not_in_out: ExpectedOutput, 219 | expected_in_err: ExpectedOutput, 220 | expected_not_in_err: ExpectedOutput, 221 | ) -> None: 222 | """Tests for vcspull sync.""" 223 | config = { 224 | "~/github_projects/": { 225 | "my_git_repo": { 226 | "url": f"git+file://{git_repo.path}", 227 | "remotes": {"test_remote": f"git+file://{git_repo.path}"}, 228 | }, 229 | "broken_repo": { 230 | "url": f"git+file://{git_repo.path}", 231 | "remotes": {"test_remote": "git+file://non-existent-remote"}, 232 | }, 233 | }, 234 | } 235 | yaml_config = config_path / ".vcspull.yaml" 236 | yaml_config_data = yaml.dump(config, default_flow_style=False) 237 | yaml_config.write_text(yaml_config_data, encoding="utf-8") 238 | 239 | # CLI can sync 240 | with contextlib.suppress(SystemExit): 241 | cli(sync_args) 242 | 243 | result = capsys.readouterr() 244 | output = "".join(list(result.out if expected_exit_code == 0 else result.err)) 245 | 246 | if expected_in_out is not None: 247 | if isinstance(expected_in_out, str): 248 | expected_in_out = [expected_in_out] 249 | for needle in expected_in_out: 250 | assert needle in output 251 | 252 | if expected_not_in_out is not None: 253 | if isinstance(expected_not_in_out, str): 254 | expected_not_in_out = [expected_not_in_out] 255 | for needle in expected_not_in_out: 256 | assert needle not in output 257 | 258 | 259 | class SyncBrokenFixture(t.NamedTuple): 260 | """Tests for vcspull sync when something breaks.""" 261 | 262 | # pytest internal: used for naming test 263 | test_id: str 264 | 265 | # test params 266 | sync_args: list[str] 267 | expected_exit_code: int 268 | expected_in_out: ExpectedOutput = None 269 | expected_not_in_out: ExpectedOutput = None 270 | expected_in_err: ExpectedOutput = None 271 | expected_not_in_err: ExpectedOutput = None 272 | 273 | 274 | SYNC_BROKEN_REPO_FIXTURES: list[SyncBrokenFixture] = [ 275 | SyncBrokenFixture( 276 | test_id="normal-checkout", 277 | sync_args=["my_git_repo"], 278 | expected_exit_code=0, 279 | expected_in_out="Already on 'master'", 280 | ), 281 | SyncBrokenFixture( 282 | test_id="normal-checkout--exit-on-error", 283 | sync_args=["my_git_repo", "--exit-on-error"], 284 | expected_exit_code=0, 285 | expected_in_out="Already on 'master'", 286 | ), 287 | SyncBrokenFixture( 288 | test_id="normal-checkout--x", 289 | sync_args=["my_git_repo", "-x"], 290 | expected_exit_code=0, 291 | expected_in_out="Already on 'master'", 292 | ), 293 | SyncBrokenFixture( 294 | test_id="normal-first-broken", 295 | sync_args=["my_git_repo_not_found", "my_git_repo"], 296 | expected_exit_code=0, 297 | expected_not_in_out=EXIT_ON_ERROR_MSG, 298 | ), 299 | SyncBrokenFixture( 300 | test_id="normal-last-broken", 301 | sync_args=["my_git_repo", "my_git_repo_not_found"], 302 | expected_exit_code=0, 303 | expected_not_in_out=EXIT_ON_ERROR_MSG, 304 | ), 305 | SyncBrokenFixture( 306 | test_id="exit-on-error--exit-on-error-first-broken", 307 | sync_args=["my_git_repo_not_found", "my_git_repo", "--exit-on-error"], 308 | expected_exit_code=1, 309 | expected_in_err=EXIT_ON_ERROR_MSG, 310 | ), 311 | SyncBrokenFixture( 312 | test_id="exit-on-error--x-first-broken", 313 | sync_args=["my_git_repo_not_found", "my_git_repo", "-x"], 314 | expected_exit_code=1, 315 | expected_in_err=EXIT_ON_ERROR_MSG, 316 | expected_not_in_out="master", 317 | ), 318 | # 319 | # Verify ordering 320 | # 321 | SyncBrokenFixture( 322 | test_id="exit-on-error--exit-on-error-last-broken", 323 | sync_args=["my_git_repo", "my_git_repo_not_found", "-x"], 324 | expected_exit_code=1, 325 | expected_in_out="Already on 'master'", 326 | expected_in_err=EXIT_ON_ERROR_MSG, 327 | ), 328 | SyncBrokenFixture( 329 | test_id="exit-on-error--x-last-item", 330 | sync_args=["my_git_repo", "my_git_repo_not_found", "--exit-on-error"], 331 | expected_exit_code=1, 332 | expected_in_out="Already on 'master'", 333 | expected_in_err=EXIT_ON_ERROR_MSG, 334 | ), 335 | ] 336 | 337 | 338 | @pytest.mark.parametrize( 339 | list(SyncBrokenFixture._fields), 340 | SYNC_BROKEN_REPO_FIXTURES, 341 | ids=[test.test_id for test in SYNC_BROKEN_REPO_FIXTURES], 342 | ) 343 | def test_sync_broken( 344 | tmp_path: pathlib.Path, 345 | capsys: pytest.CaptureFixture[str], 346 | monkeypatch: pytest.MonkeyPatch, 347 | user_path: pathlib.Path, 348 | config_path: pathlib.Path, 349 | git_repo: GitSync, 350 | test_id: str, 351 | sync_args: list[str], 352 | expected_exit_code: int, 353 | expected_in_out: ExpectedOutput, 354 | expected_not_in_out: ExpectedOutput, 355 | expected_in_err: ExpectedOutput, 356 | expected_not_in_err: ExpectedOutput, 357 | ) -> None: 358 | """Tests for syncing in vcspull when unexpected error occurs.""" 359 | github_projects = user_path / "github_projects" 360 | my_git_repo = github_projects / "my_git_repo" 361 | if my_git_repo.is_dir(): 362 | shutil.rmtree(my_git_repo) 363 | 364 | config = { 365 | "~/github_projects/": { 366 | "my_git_repo": { 367 | "url": f"git+file://{git_repo.path}", 368 | "remotes": {"test_remote": f"git+file://{git_repo.path}"}, 369 | }, 370 | "my_git_repo_not_found": { 371 | "url": "git+file:///dev/null", 372 | }, 373 | }, 374 | } 375 | yaml_config = config_path / ".vcspull.yaml" 376 | yaml_config_data = yaml.dump(config, default_flow_style=False) 377 | yaml_config.write_text(yaml_config_data, encoding="utf-8") 378 | 379 | # CLI can sync 380 | assert isinstance(sync_args, list) 381 | 382 | with contextlib.suppress(SystemExit): 383 | cli(["sync", *sync_args]) 384 | 385 | result = capsys.readouterr() 386 | out = "".join(list(result.out)) 387 | err = "".join(list(result.err)) 388 | 389 | if expected_in_out is not None: 390 | if isinstance(expected_in_out, str): 391 | expected_in_out = [expected_in_out] 392 | for needle in expected_in_out: 393 | assert needle in out 394 | 395 | if expected_not_in_out is not None: 396 | if isinstance(expected_not_in_out, str): 397 | expected_not_in_out = [expected_not_in_out] 398 | for needle in expected_not_in_out: 399 | assert needle not in out 400 | 401 | if expected_in_err is not None: 402 | if isinstance(expected_in_err, str): 403 | expected_in_err = [expected_in_err] 404 | for needle in expected_in_err: 405 | assert needle in err 406 | 407 | if expected_not_in_err is not None: 408 | if isinstance(expected_not_in_err, str): 409 | expected_not_in_err = [expected_not_in_err] 410 | for needle in expected_not_in_err: 411 | assert needle not in err 412 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | """Tests for vcspull configuration format.""" 2 | 3 | from __future__ import annotations 4 | 5 | import typing as t 6 | 7 | import pytest 8 | 9 | from vcspull import config 10 | 11 | if t.TYPE_CHECKING: 12 | import pathlib 13 | 14 | from vcspull.types import ConfigDict 15 | 16 | 17 | class LoadYAMLFn(t.Protocol): 18 | """Typing for load_yaml pytest fixture.""" 19 | 20 | def __call__( 21 | self, 22 | content: str, 23 | path: str = "randomdir", 24 | filename: str = "randomfilename.yaml", 25 | ) -> tuple[pathlib.Path, list[t.Any | pathlib.Path], list[ConfigDict]]: 26 | """Callable function type signature for load_yaml pytest fixture.""" 27 | ... 28 | 29 | 30 | @pytest.fixture 31 | def load_yaml(tmp_path: pathlib.Path) -> LoadYAMLFn: 32 | """Return a yaml loading function that uses temporary directory path.""" 33 | 34 | def fn( 35 | content: str, 36 | path: str = "randomdir", 37 | filename: str = "randomfilename.yaml", 38 | ) -> tuple[pathlib.Path, list[pathlib.Path], list[ConfigDict]]: 39 | """Return vcspull configurations and write out config to temp directory.""" 40 | dir_ = tmp_path / path 41 | dir_.mkdir() 42 | config_ = dir_ / filename 43 | config_.write_text(content, encoding="utf-8") 44 | 45 | configs = config.find_config_files(path=dir_) 46 | repos = config.load_configs(configs, cwd=dir_) 47 | return dir_, configs, repos 48 | 49 | return fn 50 | 51 | 52 | def test_simple_format(load_yaml: LoadYAMLFn) -> None: 53 | """Test simple configuration YAML file for vcspull.""" 54 | path, _, repos = load_yaml( 55 | """ 56 | vcspull: 57 | libvcs: git+https://github.com/vcs-python/libvcs 58 | """, 59 | ) 60 | 61 | assert len(repos) == 1 62 | repo = repos[0] 63 | 64 | assert path / "vcspull" == repo["path"].parent 65 | assert path / "vcspull" / "libvcs" == repo["path"] 66 | 67 | 68 | def test_relative_dir(load_yaml: LoadYAMLFn) -> None: 69 | """Test configuration files for vcspull support relative directories.""" 70 | path, _, repos = load_yaml( 71 | """ 72 | ./relativedir: 73 | docutils: svn+http://svn.code.sf.net/p/docutils/code/trunk 74 | """, 75 | ) 76 | 77 | config_files = config.find_config_files(path=path) 78 | repos = config.load_configs(config_files, path) 79 | 80 | assert len(repos) == 1 81 | repo = repos[0] 82 | 83 | assert path / "relativedir" == repo["path"].parent 84 | assert path / "relativedir" / "docutils" == repo["path"] 85 | -------------------------------------------------------------------------------- /tests/test_repo.py: -------------------------------------------------------------------------------- 1 | """Tests for placing config dicts into :py:class:`Project` objects.""" 2 | 3 | from __future__ import annotations 4 | 5 | import typing as t 6 | 7 | from libvcs import BaseSync, GitSync, HgSync, SvnSync 8 | from libvcs._internal.shortcuts import create_project 9 | 10 | from vcspull.config import filter_repos 11 | 12 | from .fixtures import example as fixtures 13 | 14 | if t.TYPE_CHECKING: 15 | import pathlib 16 | 17 | 18 | def test_filter_dir() -> None: 19 | """`filter_repos` filter by dir.""" 20 | repo_list = filter_repos(fixtures.config_dict_expanded, path="*github_project*") 21 | 22 | assert len(repo_list) == 1 23 | for r in repo_list: 24 | assert r["name"] == "kaptan" 25 | 26 | 27 | def test_filter_name() -> None: 28 | """`filter_repos` filter by name.""" 29 | repo_list = filter_repos(fixtures.config_dict_expanded, name=".vim") 30 | 31 | assert len(repo_list) == 1 32 | for r in repo_list: 33 | assert r["name"] == ".vim" 34 | 35 | 36 | def test_filter_vcs() -> None: 37 | """`filter_repos` filter by vcs remote url.""" 38 | repo_list = filter_repos(fixtures.config_dict_expanded, vcs_url="*kernel.org*") 39 | 40 | assert len(repo_list) == 1 41 | for r in repo_list: 42 | assert r["name"] == "linux" 43 | 44 | 45 | def test_to_dictlist() -> None: 46 | """`filter_repos` pulls the repos in dict format from the config.""" 47 | repo_list = filter_repos(fixtures.config_dict_expanded) 48 | 49 | for r in repo_list: 50 | assert isinstance(r, dict) 51 | assert "name" in r 52 | assert "parent_dir" in r 53 | assert "url" in r 54 | assert "vcs" in r 55 | 56 | if "remotes" in r: 57 | assert isinstance(r["remotes"], list) 58 | for remote in r["remotes"]: 59 | assert isinstance(remote, dict) 60 | assert remote == "remote_name" 61 | assert remote == "url" 62 | 63 | 64 | def test_vcs_url_scheme_to_object(tmp_path: pathlib.Path) -> None: 65 | """Verify `url` return {Git,Mercurial,Subversion}Project. 66 | 67 | :class:`GitSync`, :class:`HgSync` or :class:`SvnSync` 68 | object based on the pip-style URL scheme. 69 | 70 | """ 71 | git_repo = create_project( 72 | vcs="git", 73 | url="git+git://git.myproject.org/MyProject.git@da39a3ee5e6b4b", 74 | path=str(tmp_path / "myproject1"), 75 | ) 76 | 77 | # TODO cwd and name if duplicated should give an error 78 | 79 | assert isinstance(git_repo, GitSync) 80 | assert isinstance(git_repo, BaseSync) 81 | 82 | hg_repo = create_project( 83 | vcs="hg", 84 | url="hg+https://hg.myproject.org/MyProject#egg=MyProject", 85 | path=str(tmp_path / "myproject2"), 86 | ) 87 | 88 | assert isinstance(hg_repo, HgSync) 89 | assert isinstance(hg_repo, BaseSync) 90 | 91 | svn_repo = create_project( 92 | vcs="svn", 93 | url="svn+svn://svn.myproject.org/svn/MyProject#egg=MyProject", 94 | path=str(tmp_path / "myproject3"), 95 | ) 96 | 97 | assert isinstance(svn_repo, SvnSync) 98 | assert isinstance(svn_repo, BaseSync) 99 | 100 | 101 | def test_to_repo_objects(tmp_path: pathlib.Path) -> None: 102 | """:py:obj:`dict` objects into Project objects.""" 103 | repo_list = filter_repos(fixtures.config_dict_expanded) 104 | for repo_dict in repo_list: 105 | r = create_project(**repo_dict) # type: ignore 106 | 107 | assert isinstance(r, BaseSync) 108 | assert r.repo_name 109 | assert r.repo_name == repo_dict["name"] 110 | assert r.path.parent 111 | assert r.url 112 | assert r.url == repo_dict["url"] 113 | 114 | assert r.path == r.path / r.repo_name 115 | 116 | if hasattr(r, "remotes") and isinstance(r, GitSync): 117 | assert isinstance(r.remotes, dict) 118 | for remote_dict in r.remotes.values(): 119 | assert isinstance(remote_dict, dict) 120 | assert "fetch_url" in remote_dict 121 | assert "push_url" in remote_dict 122 | -------------------------------------------------------------------------------- /tests/test_sync.py: -------------------------------------------------------------------------------- 1 | """Tests for sync functionality of vcspull.""" 2 | 3 | from __future__ import annotations 4 | 5 | import textwrap 6 | import typing as t 7 | 8 | import pytest 9 | from libvcs._internal.shortcuts import create_project 10 | from libvcs.sync.git import GitRemote, GitSync 11 | 12 | from vcspull._internal.config_reader import ConfigReader 13 | from vcspull.cli.sync import update_repo 14 | from vcspull.config import extract_repos, filter_repos, load_configs 15 | from vcspull.validator import is_valid_config 16 | 17 | from .helpers import write_config 18 | 19 | if t.TYPE_CHECKING: 20 | import pathlib 21 | 22 | from libvcs.pytest_plugin import CreateRepoPytestFixtureFn 23 | 24 | from vcspull.types import ConfigDict 25 | 26 | 27 | def test_makes_recursive( 28 | tmp_path: pathlib.Path, 29 | git_remote_repo: pathlib.Path, 30 | ) -> None: 31 | """Ensure that syncing creates directories recursively.""" 32 | conf = ConfigReader._load( 33 | fmt="yaml", 34 | content=textwrap.dedent( 35 | f""" 36 | {tmp_path}/study/myrepo: 37 | my_url: git+file://{git_remote_repo} 38 | """, 39 | ), 40 | ) 41 | if is_valid_config(conf): 42 | repos = extract_repos(config=conf) 43 | assert len(repos) > 0 44 | 45 | filtered_repos = filter_repos(repos, path="*") 46 | assert len(filtered_repos) > 0 47 | 48 | for r in filtered_repos: 49 | assert isinstance(r, dict) 50 | repo = create_project(**r) # type: ignore 51 | repo.obtain() 52 | 53 | assert repo.path.exists() 54 | 55 | 56 | def write_config_remote( 57 | config_path: pathlib.Path, 58 | tmp_path: pathlib.Path, 59 | config_tpl: str, 60 | path: pathlib.Path, 61 | clone_name: str, 62 | ) -> pathlib.Path: 63 | """Write vcspull configuration with git remote.""" 64 | return write_config( 65 | config_path=config_path, 66 | content=config_tpl.format( 67 | tmp_path=str(tmp_path.parent), 68 | path=path, 69 | CLONE_NAME=clone_name, 70 | ), 71 | ) 72 | 73 | 74 | class ConfigVariationTest(t.NamedTuple): 75 | """pytest fixture for testing vcspull configuration.""" 76 | 77 | # pytest (internal), used for naming tests 78 | test_id: str 79 | 80 | # fixture params 81 | config_tpl: str 82 | remote_list: list[str] 83 | 84 | 85 | CONFIG_VARIATION_FIXTURES: list[ConfigVariationTest] = [ 86 | ConfigVariationTest( 87 | test_id="default", 88 | config_tpl=""" 89 | {tmp_path}/study/myrepo: 90 | {CLONE_NAME}: git+file://{path} 91 | """, 92 | remote_list=["origin"], 93 | ), 94 | ConfigVariationTest( 95 | test_id="expanded_repo_style", 96 | config_tpl=""" 97 | {tmp_path}/study/myrepo: 98 | {CLONE_NAME}: 99 | repo: git+file://{path} 100 | """, 101 | remote_list=["repo"], 102 | ), 103 | ConfigVariationTest( 104 | test_id="expanded_repo_style_with_remote", 105 | config_tpl=""" 106 | {tmp_path}/study/myrepo: 107 | {CLONE_NAME}: 108 | repo: git+file://{path} 109 | remotes: 110 | secondremote: git+file://{path} 111 | """, 112 | remote_list=["secondremote"], 113 | ), 114 | ConfigVariationTest( 115 | test_id="expanded_repo_style_with_unprefixed_remote", 116 | config_tpl=""" 117 | {tmp_path}/study/myrepo: 118 | {CLONE_NAME}: 119 | repo: git+file://{path} 120 | remotes: 121 | git_scheme_repo: git@codeberg.org:tmux-python/tmuxp.git 122 | """, 123 | remote_list=["git_scheme_repo"], 124 | ), 125 | ConfigVariationTest( 126 | test_id="expanded_repo_style_with_unprefixed_remote_2", 127 | config_tpl=""" 128 | {tmp_path}/study/myrepo: 129 | {CLONE_NAME}: 130 | repo: git+file://{path} 131 | remotes: 132 | git_scheme_repo: git@github.com:tony/vcspull.git 133 | """, 134 | remote_list=["git_scheme_repo"], 135 | ), 136 | ] 137 | 138 | 139 | @pytest.mark.parametrize( 140 | list(ConfigVariationTest._fields), 141 | CONFIG_VARIATION_FIXTURES, 142 | ids=[test.test_id for test in CONFIG_VARIATION_FIXTURES], 143 | ) 144 | def test_config_variations( 145 | tmp_path: pathlib.Path, 146 | capsys: pytest.CaptureFixture[str], 147 | create_git_remote_repo: CreateRepoPytestFixtureFn, 148 | test_id: str, 149 | config_tpl: str, 150 | remote_list: list[str], 151 | ) -> None: 152 | """Test vcspull sync'ing across a variety of configurations.""" 153 | dummy_repo = create_git_remote_repo() 154 | 155 | config_file = write_config_remote( 156 | config_path=tmp_path / "myrepos.yaml", 157 | tmp_path=tmp_path, 158 | config_tpl=config_tpl, 159 | path=dummy_repo, 160 | clone_name="myclone", 161 | ) 162 | configs = load_configs([config_file]) 163 | 164 | # TODO: Merge repos 165 | repos = filter_repos(configs, path="*") 166 | assert len(repos) == 1 167 | 168 | for repo_dict in repos: 169 | repo: GitSync = update_repo(repo_dict) 170 | remotes = repo.remotes() or {} 171 | remote_names = set(remotes.keys()) 172 | assert set(remote_list).issubset(remote_names) or {"origin"}.issubset( 173 | remote_names, 174 | ) 175 | 176 | for remote_name in remotes: 177 | current_remote = repo.remote(remote_name) 178 | assert current_remote is not None 179 | assert repo_dict is not None 180 | assert isinstance(remote_name, str) 181 | if ( 182 | "remotes" in repo_dict 183 | and isinstance(repo_dict["remotes"], dict) 184 | and remote_name in repo_dict["remotes"] 185 | ): 186 | if repo_dict["remotes"][remote_name].fetch_url.startswith( 187 | "git+file://", 188 | ): 189 | assert current_remote.fetch_url == repo_dict["remotes"][ 190 | remote_name 191 | ].fetch_url.replace( 192 | "git+", 193 | "", 194 | ), "Final git remote should chop git+ prefix" 195 | else: 196 | assert ( 197 | current_remote.fetch_url 198 | == repo_dict["remotes"][remote_name].fetch_url 199 | ) 200 | 201 | 202 | class UpdatingRemoteFixture(t.NamedTuple): 203 | """pytest fixture for vcspull configuration with a git remote.""" 204 | 205 | # pytest (internal), used for naming tests 206 | test_id: str 207 | 208 | # fixture params 209 | config_tpl: str 210 | has_extra_remotes: bool 211 | 212 | 213 | UPDATING_REMOTE_FIXTURES: list[UpdatingRemoteFixture] = [ 214 | UpdatingRemoteFixture( 215 | test_id="no_remotes", 216 | config_tpl=""" 217 | {tmp_path}/study/myrepo: 218 | {CLONE_NAME}: git+file://{path} 219 | """, 220 | has_extra_remotes=False, 221 | ), 222 | UpdatingRemoteFixture( 223 | test_id="no_remotes_expanded_repo_style", 224 | config_tpl=""" 225 | {tmp_path}/study/myrepo: 226 | {CLONE_NAME}: 227 | repo: git+file://{path} 228 | """, 229 | has_extra_remotes=False, 230 | ), 231 | UpdatingRemoteFixture( 232 | test_id="has_remotes_expanded_repo_style", 233 | config_tpl=""" 234 | {tmp_path}/study/myrepo: 235 | {CLONE_NAME}: 236 | repo: git+file://{path} 237 | remotes: 238 | mirror_repo: git+file://{path} 239 | """, 240 | has_extra_remotes=True, 241 | ), 242 | ] 243 | 244 | 245 | @pytest.mark.parametrize( 246 | list(UpdatingRemoteFixture._fields), 247 | UPDATING_REMOTE_FIXTURES, 248 | ids=[test.test_id for test in UPDATING_REMOTE_FIXTURES], 249 | ) 250 | def test_updating_remote( 251 | tmp_path: pathlib.Path, 252 | create_git_remote_repo: CreateRepoPytestFixtureFn, 253 | test_id: str, 254 | config_tpl: str, 255 | has_extra_remotes: bool, 256 | ) -> None: 257 | """Verify yaml configuration state is applied and reflected to local VCS clone.""" 258 | dummy_repo = create_git_remote_repo() 259 | 260 | mirror_name = "mirror_repo" 261 | mirror_repo = create_git_remote_repo() 262 | 263 | repo_parent = tmp_path / "study" / "myrepo" 264 | repo_parent.mkdir(parents=True) 265 | 266 | initial_config: ConfigDict = { 267 | "vcs": "git", 268 | "name": "myclone", 269 | "path": tmp_path / "study/myrepo/myclone", 270 | "url": f"git+file://{dummy_repo}", 271 | "remotes": { 272 | mirror_name: GitRemote( 273 | name=mirror_name, 274 | fetch_url=f"git+file://{dummy_repo}", 275 | push_url=f"git+file://{dummy_repo}", 276 | ), 277 | }, 278 | } 279 | 280 | for repo_dict in filter_repos( 281 | [initial_config], 282 | ): 283 | local_git_remotes = update_repo(repo_dict).remotes() 284 | assert "origin" in local_git_remotes 285 | 286 | expected_remote_url = f"git+file://{mirror_repo}" 287 | 288 | expected_config: ConfigDict = initial_config.copy() 289 | assert isinstance(expected_config["remotes"], dict) 290 | expected_config["remotes"][mirror_name] = GitRemote( 291 | name=mirror_name, 292 | fetch_url=expected_remote_url, 293 | push_url=expected_remote_url, 294 | ) 295 | 296 | repo_dict = filter_repos([expected_config], name="myclone")[0] 297 | assert isinstance(repo_dict, dict) 298 | repo = update_repo(repo_dict) 299 | for remote_name in repo.remotes(): 300 | remote = repo.remote(remote_name) 301 | if remote is not None: 302 | current_remote_url = remote.fetch_url.replace("git+", "") 303 | if remote_name in expected_config["remotes"]: 304 | assert ( 305 | expected_config["remotes"][remote_name].fetch_url.replace( 306 | "git+", 307 | "", 308 | ) 309 | == current_remote_url 310 | ) 311 | 312 | elif remote_name == "origin" and remote_name in expected_config["remotes"]: 313 | assert ( 314 | expected_config["remotes"]["origin"].fetch_url.replace("git+", "") 315 | == current_remote_url 316 | ) 317 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | """Tests for vcspull utilities.""" 2 | 3 | from __future__ import annotations 4 | 5 | import typing as t 6 | 7 | from vcspull.util import get_config_dir 8 | 9 | if t.TYPE_CHECKING: 10 | import pathlib 11 | 12 | import pytest 13 | 14 | 15 | def test_vcspull_configdir_env_var( 16 | tmp_path: pathlib.Path, 17 | monkeypatch: pytest.MonkeyPatch, 18 | ) -> None: 19 | """Test retrieving config directory with VCSPULL_CONFIGDIR set.""" 20 | monkeypatch.setenv("VCSPULL_CONFIGDIR", str(tmp_path)) 21 | 22 | assert get_config_dir() == tmp_path 23 | 24 | 25 | def test_vcspull_configdir_xdg_config_dir( 26 | tmp_path: pathlib.Path, 27 | monkeypatch: pytest.MonkeyPatch, 28 | ) -> None: 29 | """Test retrieving config directory with XDG_CONFIG_HOME set.""" 30 | monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) 31 | vcspull_dir = tmp_path / "vcspull" 32 | vcspull_dir.mkdir() 33 | 34 | assert get_config_dir() == vcspull_dir 35 | 36 | 37 | def test_vcspull_configdir_no_xdg(monkeypatch: pytest.MonkeyPatch) -> None: 38 | """Test retrieving config directory without XDG_CONFIG_HOME set.""" 39 | monkeypatch.delenv("XDG_CONFIG_HOME") 40 | assert get_config_dir() 41 | --------------------------------------------------------------------------------