diff --git a/.augment-guidelines b/.augment-guidelines new file mode 100644 index 000000000..154156b0f --- /dev/null +++ b/.augment-guidelines @@ -0,0 +1,328 @@ +# Augment Guidelines + +## Development Process + +### Project Stack + +The project uses the following tools and technologies: + +- **uv** - Python package management and virtual environments +- **ruff** - Fast Python linter and formatter +- **py.test** - Testing framework + - **pytest-watcher** - Continuous test runner +- **mypy** - Static type checking +- **doctest** - Testing code examples in documentation + +### Development Workflow + +1. **Start with Formatting** + ``` + uv run ruff format . + ``` + +2. **Run Tests** + ``` + uv run py.test + ``` + + For continuous testing during development, use pytest-watcher: + ``` + # Watch all tests + uv run ptw . + + # Watch and run tests immediately, including doctests + uv run ptw . --now --doctest-modules + + # Watch specific files or directories + uv run ptw . --now --doctest-modules src/libtmux/_internal/ + ``` + +3. **Commit Initial Changes** + Make an atomic commit for your changes using conventional commits. + +4. **Run Linting and Type Checking** + ``` + uv run ruff check . --fix --show-fixes + uv run mypy + ``` + +5. **Verify Tests Again** + ``` + uv run py.test + ``` + +6. **Final Commit** + Make a final commit with any linting/typing fixes. + +### Python Code Standards + +#### Docstring Guidelines + +For `src/**/*.py` files, follow these docstring guidelines: + +1. **Use reStructuredText format** for all docstrings. + ```python + """Short description of the function or class. + + Detailed description using reStructuredText format. + + Parameters + ---------- + param1 : type + Description of param1 + param2 : type + Description of param2 + + Returns + ------- + type + Description of return value + """ + ``` + +2. **Keep the main description on the first line** after the opening `"""`. + +3. **Use NumPy docstyle** for parameter and return value documentation. + +#### Doctest Guidelines + +For doctests in `src/**/*.py` files: + +1. **Use narrative descriptions** for test sections rather than inline comments: + ```python + """Example function. + + Examples + -------- + Create an instance: + + >>> obj = ExampleClass() + + Verify a property: + + >>> obj.property + 'expected value' + """ + ``` + +2. **Move complex examples** to dedicated test files at `tests/examples//test_.py` if they require elaborate setup or multiple steps. + +3. **Utilize pytest fixtures** via `doctest_namespace` for more complex test scenarios: + ```python + """Example with fixture. + + Examples + -------- + >>> # doctest_namespace contains all pytest fixtures from conftest.py + >>> example_fixture = getfixture('example_fixture') + >>> example_fixture.method() + 'expected result' + """ + ``` + +4. **Keep doctests simple and focused** on demonstrating usage rather than comprehensive testing. + +5. **Add blank lines between test sections** for improved readability. + +6. **Test your doctests continuously** using pytest-watcher during development: + ``` + # Watch specific modules for doctest changes + uv run ptw . --now --doctest-modules src/path/to/module.py + ``` + +#### Pytest Testing Guidelines + +1. **Use existing fixtures over mocks**: + - Use fixtures from conftest.py instead of `monkeypatch` and `MagicMock` when available + - For instance, if using libtmux, use provided fixtures: `server`, `session`, `window`, and `pane` + - Document in test docstrings why standard fixtures weren't used for exceptional cases + +2. **Preferred pytest patterns**: + - Use `tmp_path` (pathlib.Path) fixture over Python's `tempfile` + - Use `monkeypatch` fixture over `unittest.mock` + +#### Import Guidelines + +1. **Prefer namespace imports**: + - Import modules and access attributes through the namespace instead of importing specific symbols + - Example: Use `import enum` and access `enum.Enum` instead of `from enum import Enum` + - This applies to standard library modules like `pathlib`, `os`, and similar cases + +2. **Standard aliases**: + - For `typing` module, use `import typing as t` + - Access typing elements via the namespace: `t.NamedTuple`, `t.TypedDict`, etc. + - 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. + +3. **Benefits of namespace imports**: + - Improves code readability by making the source of symbols clear + - Reduces potential naming conflicts + - Makes import statements more maintainable + +## Git Commit Standards + +### Commit Message Format +``` +Component/File(commit-type[Subcomponent/method]): Concise description + +why: Explanation of necessity or impact. +what: +- Specific technical changes made +- Focused on a single topic + +refs: #issue-number, breaking changes, or relevant links +``` + +### Component Patterns +#### General Code Changes +``` +Component/File(feat[method]): Add feature +Component/File(fix[method]): Fix bug +Component/File(refactor[method]): Code restructure +``` + +#### Packages and Dependencies +| Language | Standard Packages | Dev Packages | Extras / Sub-packages | +|------------|------------------------------------|-------------------------------|-----------------------------------------------| +| General | `lang(deps):` | `lang(deps[dev]):` | | +| Python | `py(deps):` | `py(deps[dev]):` | `py(deps[extra]):` | +| JavaScript | `js(deps):` | `js(deps[dev]):` | `js(deps[subpackage]):`, `js(deps[dev{subpackage}]):` | + +##### Examples +- `py(deps[dev]): Update pytest to v8.1` +- `js(deps[ui-components]): Upgrade Button component package` +- `js(deps[dev{linting}]): Add ESLint plugin` + +#### Documentation Changes +Prefix with `docs:` +``` +docs(Component/File[Subcomponent/method]): Update API usage guide +``` + +#### Test Changes +Prefix with `tests:` +``` +tests(Component/File[Subcomponent/method]): Add edge case tests +``` + +### Commit Types Summary +- **feat**: New features or enhancements +- **fix**: Bug fixes +- **refactor**: Code restructuring without functional change +- **docs**: Documentation updates +- **chore**: Maintenance (dependencies, tooling, config) +- **test**: Test-related updates +- **style**: Code style and formatting + +### General Guidelines +- Subject line: Maximum 50 characters +- Body lines: Maximum 72 characters +- Use imperative mood (e.g., "Add", "Fix", not "Added", "Fixed") +- Limit to one topic per commit +- Separate subject from body with a blank line +- Mark breaking changes clearly: `BREAKING:` +- Use `See also:` to provide external references + +### Good Commit Example +``` +Pane(feat[capture_pane]): Add screenshot capture support + +why: Provide visual debugging capability +what: +- Implement capturePane method with image export +- Integrate with existing Pane component logic +- Document usage in Pane README + +refs: #485 +See also: https://example.com/docs/pane-capture +``` + +### Bad Commit Example +``` +fixed stuff and improved some functions +``` + +## Avoiding Debug Loops + +When debugging becomes circular and unproductive, follow these steps: + +### Detection +- You have made multiple unsuccessful attempts to fix the same issue +- You are adding increasingly complex code to address errors +- Each fix creates new errors in a cascading pattern +- You are uncertain about the root cause after 2-3 iterations + +### Action Plan + +1. **Pause and acknowledge the loop** + - Explicitly state that you are in a potential debug loop + - Review what approaches have been tried and failed + +2. **Minimize to MVP** + - Remove all debugging cruft and experimental code + - Revert to the simplest version that demonstrates the issue + - Focus on isolating the core problem without added complexity + +3. **Comprehensive Documentation** + - Provide a clear summary of the issue + - Include minimal but complete code examples that reproduce the problem + - Document exact error messages and unexpected behaviors + - Explain your current understanding of potential causes + +4. **Format for Portability** + - Present the problem in quadruple backticks for easy copying: + +```` +# Problem Summary +[Concise explanation of the issue] + +## Minimal Reproduction Code +```python +# Minimal code example that reproduces the issue +``` + +## Error/Unexpected Output +``` +[Exact error messages or unexpected output] +``` + +## Failed Approaches +[Brief summary of approaches already tried] + +## Suspected Cause +[Your current hypothesis about what might be causing the issue] +```` + +## LLM-Optimized Markdown Content Guidelines + +When creating or editing markdown files within notes directories, adhere to the following guidelines: + +1. **Conciseness and Clarity**: + - **Be Brief**: Present information succinctly, avoiding unnecessary elaboration. + - **Use Clear Language**: Employ straightforward language to convey ideas effectively. + +2. **Structured Formatting**: + - **Headings**: Utilize markdown headings (`#`, `##`, `###`, etc.) to organize content hierarchically. + - **Lists**: Use bullet points (`-`) or numbered lists (`1.`, `2.`, etc.) to enumerate items clearly. + - **Code Blocks**: Enclose code snippets within triple backticks (```) to distinguish them from regular text. + +3. **Semantic Elements**: + - **Emphasis**: Use asterisks (`*`) or underscores (`_`) for italicizing text to denote emphasis. + - **Strong Emphasis**: Use double asterisks (`**`) or double underscores (`__`) for bold text to highlight critical points. + - **Inline Code**: Use single backticks (`) for inline code references. + +4. **Linking and References**: + - **Hyperlinks**: Format links using `[Link Text](mdc:URL)` to provide direct access to external resources. + - **References**: When citing sources, use footnotes or inline citations to maintain readability. + +5. **Avoid Redundancy**: + - **Eliminate Repetition**: Ensure that information is not unnecessarily repeated within the document. + - **Use Summaries**: Provide brief summaries where detailed explanations are not essential. + +6. **Standard Compliance**: + - **llms.txt Conformance**: Structure the document in alignment with the `llms.txt` standard, which includes: + - An H1 heading with the project or site name. + - A blockquote summarizing the project's purpose. + - Additional markdown sections providing detailed information. + - H2-delimited sections containing lists of URLs for further details. + +For more information on the `llms.txt` standard, refer to the official documentation: https://llmstxt.org/ diff --git a/.codex/instructions.md b/.codex/instructions.md new file mode 120000 index 000000000..4ff3f86e7 --- /dev/null +++ b/.codex/instructions.md @@ -0,0 +1 @@ +../.windsurfrules \ No newline at end of file diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 8bfb3542b..000000000 --- a/.coveragerc +++ /dev/null @@ -1,14 +0,0 @@ -[run] -omit = - */_* - pkg/* - */log.py - */conftest.py - -[report] -exclude_lines = - pragma: no cover - def __repr__ - raise NotImplementedError - if __name__ == .__main__.: - def parse_args diff --git a/.cursor/rules/avoid-debug-loops.mdc b/.cursor/rules/avoid-debug-loops.mdc new file mode 100644 index 000000000..8a241ec99 --- /dev/null +++ b/.cursor/rules/avoid-debug-loops.mdc @@ -0,0 +1,57 @@ +--- +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 +globs: *.py +alwaysApply: true +--- +# Avoid Debug Loops + +When debugging becomes circular and unproductive, follow these steps: + +## Detection +- You have made multiple unsuccessful attempts to fix the same issue +- You are adding increasingly complex code to address errors +- Each fix creates new errors in a cascading pattern +- You are uncertain about the root cause after 2-3 iterations + +## Action Plan + +1. **Pause and acknowledge the loop** + - Explicitly state that you are in a potential debug loop + - Review what approaches have been tried and failed + +2. **Minimize to MVP** + - Remove all debugging cruft and experimental code + - Revert to the simplest version that demonstrates the issue + - Focus on isolating the core problem without added complexity + +3. **Comprehensive Documentation** + - Provide a clear summary of the issue + - Include minimal but complete code examples that reproduce the problem + - Document exact error messages and unexpected behaviors + - Explain your current understanding of potential causes + +4. **Format for Portability** + - Present the problem in quadruple backticks for easy copying: + +```` +# Problem Summary +[Concise explanation of the issue] + +## Minimal Reproduction Code +```python +# Minimal code example that reproduces the issue +``` + +## Error/Unexpected Output +``` +[Exact error messages or unexpected output] +``` + +## Failed Approaches +[Brief summary of approaches already tried] + +## Suspected Cause +[Your current hypothesis about what might be causing the issue] +```` + +This format enables the user to easily copy the entire problem statement into a fresh conversation for a clean-slate approach. diff --git a/.cursor/rules/dev-loop.mdc b/.cursor/rules/dev-loop.mdc new file mode 100644 index 000000000..d60a52109 --- /dev/null +++ b/.cursor/rules/dev-loop.mdc @@ -0,0 +1,187 @@ +--- +description: QA every edit +globs: *.py +alwaysApply: true +--- + +# Development Process + +## Project Stack + +The project uses the following tools and technologies: + +- **uv** - Python package management and virtual environments +- **ruff** - Fast Python linter and formatter +- **py.test** - Testing framework + - **pytest-watcher** - Continuous test runner +- **mypy** - Static type checking +- **doctest** - Testing code examples in documentation + +## 1. Start with Formatting + +Format your code first: + +``` +uv run ruff format . +``` + +## 2. Run Tests + +Verify that your changes pass the tests: + +``` +uv run py.test +``` + +For continuous testing during development, use pytest-watcher: + +``` +# Watch all tests +uv run ptw . + +# Watch and run tests immediately, including doctests +uv run ptw . --now --doctest-modules + +# Watch specific files or directories +uv run ptw . --now --doctest-modules src/libtmux/_internal/ +``` + +## 3. Commit Initial Changes + +Make an atomic commit for your changes using conventional commits. +Use `@git-commits.mdc` for assistance with commit message standards. + +## 4. Run Linting and Type Checking + +Check and fix linting issues: + +``` +uv run ruff check . --fix --show-fixes +``` + +Check typings: + +``` +uv run mypy +``` + +## 5. Verify Tests Again + +Ensure tests still pass after linting and type fixes: + +``` +uv run py.test +``` + +## 6. Final Commit + +Make a final commit with any linting/typing fixes. +Use `@git-commits.mdc` for assistance with commit message standards. + +## Development Loop Guidelines + +If there are any failures at any step due to your edits, fix them before proceeding to the next step. + +## Python Code Standards + +### Docstring Guidelines + +For `src/**/*.py` files, follow these docstring guidelines: + +1. **Use reStructuredText format** for all docstrings. + ```python + """Short description of the function or class. + + Detailed description using reStructuredText format. + + Parameters + ---------- + param1 : type + Description of param1 + param2 : type + Description of param2 + + Returns + ------- + type + Description of return value + """ + ``` + +2. **Keep the main description on the first line** after the opening `"""`. + +3. **Use NumPy docstyle** for parameter and return value documentation. + +### Doctest Guidelines + +For doctests in `src/**/*.py` files: + +1. **Use narrative descriptions** for test sections rather than inline comments: + ```python + """Example function. + + Examples + -------- + Create an instance: + + >>> obj = ExampleClass() + + Verify a property: + + >>> obj.property + 'expected value' + """ + ``` + +2. **Move complex examples** to dedicated test files at `tests/examples//test_.py` if they require elaborate setup or multiple steps. + +3. **Utilize pytest fixtures** via `doctest_namespace` for more complex test scenarios: + ```python + """Example with fixture. + + Examples + -------- + >>> # doctest_namespace contains all pytest fixtures from conftest.py + >>> example_fixture = getfixture('example_fixture') + >>> example_fixture.method() + 'expected result' + """ + ``` + +4. **Keep doctests simple and focused** on demonstrating usage rather than comprehensive testing. + +5. **Add blank lines between test sections** for improved readability. + +6. **Test your doctests continuously** using pytest-watcher during development: + ``` + # Watch specific modules for doctest changes + uv run ptw . --now --doctest-modules src/path/to/module.py + ``` + +### Pytest Testing Guidelines + +1. **Use existing fixtures over mocks**: + - Use fixtures from conftest.py instead of `monkeypatch` and `MagicMock` when available + - For instance, if using libtmux, use provided fixtures: `server`, `session`, `window`, and `pane` + - Document in test docstrings why standard fixtures weren't used for exceptional cases + +2. **Preferred pytest patterns**: + - Use `tmp_path` (pathlib.Path) fixture over Python's `tempfile` + - Use `monkeypatch` fixture over `unittest.mock` + +### Import Guidelines + +1. **Prefer namespace imports**: + - Import modules and access attributes through the namespace instead of importing specific symbols + - Example: Use `import enum` and access `enum.Enum` instead of `from enum import Enum` + - This applies to standard library modules like `pathlib`, `os`, and similar cases + +2. **Standard aliases**: + - For `typing` module, use `import typing as t` + - Access typing elements via the namespace: `t.NamedTuple`, `t.TypedDict`, etc. + - 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. + +3. **Benefits of namespace imports**: + - Improves code readability by making the source of symbols clear + - Reduces potential naming conflicts + - Makes import statements more maintainable diff --git a/.cursor/rules/git-commits.mdc b/.cursor/rules/git-commits.mdc new file mode 100644 index 000000000..f9c0980db --- /dev/null +++ b/.cursor/rules/git-commits.mdc @@ -0,0 +1,95 @@ +--- +description: git-commits: Git commit message standards and AI assistance +globs: git-commits: Git commit message standards and AI assistance | *.git/* .gitignore .github/* CHANGELOG.md CHANGES.md +alwaysApply: true +--- +# Optimized Git Commit Standards + +## Commit Message Format +``` +Component/File(commit-type[Subcomponent/method]): Concise description + +why: Explanation of necessity or impact. +what: +- Specific technical changes made +- Focused on a single topic + +refs: #issue-number, breaking changes, or relevant links +``` + +## Component Patterns +### General Code Changes +``` +Component/File(feat[method]): Add feature +Component/File(fix[method]): Fix bug +Component/File(refactor[method]): Code restructure +``` + +### Packages and Dependencies +| Language | Standard Packages | Dev Packages | Extras / Sub-packages | +|------------|------------------------------------|-------------------------------|-----------------------------------------------| +| General | `lang(deps):` | `lang(deps[dev]):` | | +| Python | `py(deps):` | `py(deps[dev]):` | `py(deps[extra]):` | +| JavaScript | `js(deps):` | `js(deps[dev]):` | `js(deps[subpackage]):`, `js(deps[dev{subpackage}]):` | + +#### Examples +- `py(deps[dev]): Update pytest to v8.1` +- `js(deps[ui-components]): Upgrade Button component package` +- `js(deps[dev{linting}]): Add ESLint plugin` + +### Documentation Changes +Prefix with `docs:` +``` +docs(Component/File[Subcomponent/method]): Update API usage guide +``` + +### Test Changes +Prefix with `tests:` +``` +tests(Component/File[Subcomponent/method]): Add edge case tests +``` + +## Commit Types Summary +- **feat**: New features or enhancements +- **fix**: Bug fixes +- **refactor**: Code restructuring without functional change +- **docs**: Documentation updates +- **chore**: Maintenance (dependencies, tooling, config) +- **test**: Test-related updates +- **style**: Code style and formatting + +## General Guidelines +- Subject line: Maximum 50 characters +- Body lines: Maximum 72 characters +- Use imperative mood (e.g., "Add", "Fix", not "Added", "Fixed") +- Limit to one topic per commit +- Separate subject from body with a blank line +- Mark breaking changes clearly: `BREAKING:` +- Use `See also:` to provide external references + +## AI Assistance Workflow in Cursor +- Stage changes with `git add` +- Use `@commit` to generate initial commit message +- Review and refine generated message +- Ensure adherence to these standards + +## Good Commit Example +``` +Pane(feat[capture_pane]): Add screenshot capture support + +why: Provide visual debugging capability +what: +- Implement capturePane method with image export +- Integrate with existing Pane component logic +- Document usage in Pane README + +refs: #485 +See also: https://example.com/docs/pane-capture +``` + +## Bad Commit Example +``` +fixed stuff and improved some functions +``` + +These guidelines ensure clear, consistent commit histories, facilitating easier code review and maintenance. \ No newline at end of file diff --git a/.cursor/rules/notes-llms-txt.mdc b/.cursor/rules/notes-llms-txt.mdc new file mode 100644 index 000000000..ac1709773 --- /dev/null +++ b/.cursor/rules/notes-llms-txt.mdc @@ -0,0 +1,42 @@ +--- +description: LLM-friendly markdown format for notes directories +globs: notes/**/*.md,**/notes/**/*.md +alwaysApply: true +--- + +# Instructions for Generating LLM-Optimized Markdown Content + +When creating or editing markdown files within the specified directories, adhere to the following guidelines to ensure the content is optimized for LLM understanding and efficient token usage: + +1. **Conciseness and Clarity**: + - **Be Brief**: Present information succinctly, avoiding unnecessary elaboration. + - **Use Clear Language**: Employ straightforward language to convey ideas effectively. + +2. **Structured Formatting**: + - **Headings**: Utilize markdown headings (`#`, `##`, `###`, etc.) to organize content hierarchically. + - **Lists**: Use bullet points (`-`) or numbered lists (`1.`, `2.`, etc.) to enumerate items clearly. + - **Code Blocks**: Enclose code snippets within triple backticks (```) to distinguish them from regular text. + +3. **Semantic Elements**: + - **Emphasis**: Use asterisks (`*`) or underscores (`_`) for italicizing text to denote emphasis. + - **Strong Emphasis**: Use double asterisks (`**`) or double underscores (`__`) for bold text to highlight critical points. + - **Inline Code**: Use single backticks (`) for inline code references. + +4. **Linking and References**: + - **Hyperlinks**: Format links using `[Link Text](mdc:URL)` to provide direct access to external resources. + - **References**: When citing sources, use footnotes or inline citations to maintain readability. + +5. **Avoid Redundancy**: + - **Eliminate Repetition**: Ensure that information is not unnecessarily repeated within the document. + - **Use Summaries**: Provide brief summaries where detailed explanations are not essential. + +6. **Standard Compliance**: + - **llms.txt Conformance**: Structure the document in alignment with the `llms.txt` standard, which includes: + - An H1 heading with the project or site name. + - A blockquote summarizing the project's purpose. + - Additional markdown sections providing detailed information. + - H2-delimited sections containing lists of URLs for further details. + +By following these guidelines, the markdown files will be tailored for optimal LLM processing, ensuring that the content is both accessible and efficiently tokenized for AI applications. + +For more information on the `llms.txt` standard, refer to the official documentation: https://llmstxt.org/ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..1246879c4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.dump eol=lf diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..d202a332d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + # Check for updates to GitHub Actions every week + interval: "weekly" diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index 2341f6dad..000000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,70 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [ master ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ master ] - schedule: - - cron: '21 7 * * 4' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'python' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] - # Learn more about CodeQL language support at https://git.io/codeql-language-support - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 - - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 7bfc699e6..12b235ef0 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -5,16 +5,21 @@ on: branches: - master +permissions: + contents: read + id-token: write + jobs: build: runs-on: ubuntu-latest + environment: docs strategy: matrix: - python-version: ["3.10"] + python-version: ['3.14'] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - name: Filter changed file paths to outputs - uses: dorny/paths-filter@v2.7.0 + uses: dorny/paths-filter@v3.0.2 id: changes with: filters: | @@ -27,67 +32,57 @@ jobs: python_files: - 'libvcs/**' - pyproject.toml - - poetry.lock + - uv.lock - name: Should publish if: steps.changes.outputs.docs == 'true' || steps.changes.outputs.root_docs == 'true' || steps.changes.outputs.python_files == 'true' run: echo "PUBLISH=$(echo true)" >> $GITHUB_ENV - - name: Install poetry - run: | - curl -O -sSL https://install.python-poetry.org/install-poetry.py - python install-poetry.py -y --version 1.1.12 - echo "PATH=${HOME}/.poetry/bin:${PATH}" >> $GITHUB_ENV - rm install-poetry.py - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + - name: Install uv + if: env.PUBLISH == 'true' + uses: astral-sh/setup-uv@v7 with: - python-version: ${{ matrix.python-version }} - cache: 'poetry' + enable-cache: true - - name: Add ~/.local/bin to PATH - run: echo "$HOME/.local/bin" >> $GITHUB_PATH + - name: Set up Python ${{ matrix.python-version }} + if: env.PUBLISH == 'true' + run: uv python install ${{ matrix.python-version }} - name: Install dependencies [w/ docs] - run: | - poetry env use ${{ matrix.python-version }} - poetry install --extras "docs lint" + if: env.PUBLISH == 'true' + run: uv sync --all-extras --dev + + - name: Install just + if: env.PUBLISH == 'true' + uses: extractions/setup-just@v3 - name: Build documentation + if: env.PUBLISH == 'true' run: | - pushd docs; make SPHINXBUILD='poetry run sphinx-build' html; popd + cd docs && just html - - name: Push documentation to S3 - uses: jakejarvis/s3-sync-action@v0.5.1 + - name: Configure AWS Credentials + if: env.PUBLISH == 'true' + uses: aws-actions/configure-aws-credentials@v5 with: - args: --acl public-read --follow-symlinks --delete - env: - AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_REGION: "us-west-1" # optional: defaults to us-east-1 - SOURCE_DIR: "docs/_build/html" # optional: defaults to entire repository + role-to-assume: ${{ secrets.LIBVCS_DOCS_ROLE_ARN }} + aws-region: us-east-1 - - name: Generate list of changed files for CloudFront to invalidate + - name: Push documentation to S3 + if: env.PUBLISH == 'true' run: | - FILES=$(find docs/_build/html -exec realpath --relative-to docs/_build/html {} \; | awk '{print "/"$0}' | grep "html\|searchindex.js\|custom.css\|.svg"); - for file in $FILES; do - echo $file - # add bare directory to list of updated paths when we see index.html - [[ "$file" == *"/index.html" ]] && echo $file | sed -e 's/\/index.html$/\//' - done | sort | uniq | tr '\n' ' ' > .updated_files + aws s3 sync docs/_build/html "s3://${{ secrets.LIBVCS_DOCS_BUCKET }}" \ + --delete --follow-symlinks - - name: Invalidate on CloudFront - uses: chetan/invalidate-cloudfront-action@v2.3 - env: - DISTRIBUTION: ${{ secrets.AWS_CLOUDFRONT_DISTRIBUTION }} - AWS_REGION: "us-east-1" - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - PATHS_FROM: .updated_files + - name: Invalidate CloudFront + if: env.PUBLISH == 'true' + run: | + aws cloudfront create-invalidation \ + --distribution-id "${{ secrets.LIBVCS_DOCS_DISTRIBUTION }}" \ + --paths "/index.html" "/objects.inv" "/searchindex.js" - name: Purge cache on Cloudflare + if: env.PUBLISH == 'true' uses: jakejarvis/cloudflare-purge-action@v0.3.0 env: CLOUDFLARE_TOKEN: ${{ secrets.CLOUDFLARE_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c4867d41a..34947bf25 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,55 +1,83 @@ name: tests -on: [push] +on: + push: + branches: + - master + - 'v*.x' + tags: + - '**' + pull_request: + branches: + - '**' jobs: build: runs-on: ubuntu-latest strategy: matrix: - python-version: [ '3.9', '3.10' ] + python-version: ['3.10', '3.14'] steps: - - uses: actions/checkout@v3 - - name: Install poetry - run: | - curl -O -sSL https://install.python-poetry.org/install-poetry.py - python install-poetry.py -y --version 1.1.12 - echo "PATH=${HOME}/.poetry/bin:${PATH}" >> $GITHUB_ENV - rm install-poetry.py + - uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - cache: 'poetry' + run: uv python install ${{ matrix.python-version }} - name: Install dependencies - run: | - # This is required to do as of @actions/checkout@v3 to prevent default python from being used - poetry env use ${{ matrix.python-version }} - poetry install -E "docs test coverage lint format" + run: uv sync --all-extras --dev + + - name: Lint with ruff check + run: uv run ruff check . - - name: Lint with flake8 - run: poetry run flake8 + - name: Format with ruff + run: uv run ruff format . --check + + - name: Lint with mypy + run: uv run mypy . - name: Print python versions run: | python -V - poetry run python -V + uv run python -V - name: Test with pytest - run: poetry run py.test --cov=./ --cov-report=xml - - - uses: codecov/codecov-action@v2 + run: uv run py.test --cov=./ --cov-append --cov-report=xml + env: + COV_CORE_SOURCE: . + COV_CORE_CONFIG: .coveragerc + COV_CORE_DATAFILE: .coverage.eager + - uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} + release: + runs-on: ubuntu-latest + needs: build + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') + + strategy: + matrix: + python-version: ['3.14'] + + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + - name: Build package - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') - run: poetry build + run: uv build - name: Publish package - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@release/v1 with: user: __token__ diff --git a/.gitignore b/.gitignore index 03bf9efe7..29869d033 100644 --- a/.gitignore +++ b/.gitignore @@ -74,9 +74,20 @@ target/ # docs doc/_build/ +# mypy +.mypy_cache/ + *.lprof pip-wheel-metadata/ # Used by publish-docs.yml CI .updated_files + +# Monkeytype +monkeytype.sqlite3 + +# AI +*repopack* + +**/.claude/settings.local.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index db0347ee1..000000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,15 +0,0 @@ -repos: -- repo: https://github.com/psf/black - rev: 22.3.0 - hooks: - - id: black - language_version: python3.10 -- repo: https://github.com/pycqa/isort - rev: 5.10.1 - hooks: - - id: isort - name: isort (python) -- repo: https://github.com/PyCQA/flake8 - rev: 4.0.1 - hooks: - - id: flake8 diff --git a/.python-version b/.python-version index ee33e6007..6324d401a 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.10.4 3.9.9 +3.14 diff --git a/.tmuxp-before-script.sh b/.tmuxp-before-script.sh deleted file mode 100755 index 0721faabd..000000000 --- a/.tmuxp-before-script.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -poetry shell --no-ansi --no-interaction &2> /dev/null -poetry install --no-ansi --no-interaction &2> /dev/null diff --git a/.tmuxp.yaml b/.tmuxp.yaml index e9c122e38..8f7da0cb0 100644 --- a/.tmuxp.yaml +++ b/.tmuxp.yaml @@ -1,25 +1,15 @@ session_name: libvcs start_directory: ./ # load session relative to config location (project root). -before_script: ./.tmuxp-before-script.sh shell_command_before: -- '[ -f .venv/bin/activate ] && source .venv/bin/activate && reset' +- uv virtualenv --quiet > /dev/null 2>&1 && clear windows: - window_name: libvcs focus: True layout: main-horizontal options: - main-pane-height: 35 + main-pane-height: 67% panes: - focus: true + - pane - pane - - make start -- window_name: docs - layout: main-horizontal - options: - main-pane-height: 35 - start_directory: docs/ - panes: - - focus: true - - pane - - pane - - make start + - just start diff --git a/.tool-versions b/.tool-versions index f0fdab453..1c38210ea 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,3 @@ -poetry 1.1.12 -python 3.10.4 3.9.9 +just 1.45.0 +uv 0.9.18 +python 3.14 3.13.11 3.12.12 3.11.14 3.10.19 diff --git a/.vim/coc-settings.json b/.vim/coc-settings.json new file mode 100644 index 000000000..d542a7262 --- /dev/null +++ b/.vim/coc-settings.json @@ -0,0 +1,19 @@ +{ + "[markdown][python]": { + "coc.preferences.formatOnSave": true + }, + "python.analysis.autoSearchPaths": true, + "python.analysis.typeCheckingMode": "basic", + "python.analysis.useLibraryCodeForTypes": true, + "python.formatting.provider": "ruff", + "python.linting.ruffEnabled": true, + "python.linting.mypyEnabled": true, + "python.linting.flake8Enabled": false, + "python.linting.pyflakesEnabled": false, + "python.linting.pycodestyleEnabled": false, + "python.linting.banditEnabled": false, + "python.linting.pylamaEnabled": false, + "python.linting.pylintEnabled": false, + "pyright.organizeimports.provider": "ruff", + "pyright.testing.provider": "pytest", +} diff --git a/.windsurfrules b/.windsurfrules new file mode 100644 index 000000000..b0de6f120 --- /dev/null +++ b/.windsurfrules @@ -0,0 +1,162 @@ +# Python Project Rules + + +- uv - Python package management and virtual environments +- ruff - Fast Python linter and formatter +- py.test - Testing framework + - pytest-watcher - Continuous test runner +- mypy - Static type checking +- doctest - Testing code examples in documentation + + + +- Use a consistent coding style throughout the project +- Format code with ruff before committing +- Run linting and type checking before finalizing changes +- Verify tests pass after each significant change + + + +- Use reStructuredText format for all docstrings in src/**/*.py files +- Keep the main description on the first line after the opening `"""` +- Use NumPy docstyle for parameter and return value documentation +- Format docstrings as follows: + ```python + """Short description of the function or class. + + Detailed description using reStructuredText format. + + Parameters + ---------- + param1 : type + Description of param1 + param2 : type + Description of param2 + + Returns + ------- + type + Description of return value + """ + ``` + + + +- Use narrative descriptions for test sections rather than inline comments +- Format doctests as follows: + ```python + """ + Examples + -------- + Create an instance: + + >>> obj = ExampleClass() + + Verify a property: + + >>> obj.property + 'expected value' + """ + ``` +- Add blank lines between test sections for improved readability +- Keep doctests simple and focused on demonstrating usage +- Move complex examples to dedicated test files at tests/examples//test_.py +- Utilize pytest fixtures via doctest_namespace for complex scenarios + + + +- Run tests with `uv run py.test` before committing changes +- Use pytest-watcher for continuous testing: `uv run ptw . --now --doctest-modules` +- Fix any test failures before proceeding with additional changes + + + +- Make atomic commits with conventional commit messages +- Start with an initial commit of functional changes +- Follow with separate commits for formatting, linting, and type checking fixes + + + +- Use the following commit message format: + ``` + Component/File(commit-type[Subcomponent/method]): Concise description + + why: Explanation of necessity or impact. + what: + - Specific technical changes made + - Focused on a single topic + + refs: #issue-number, breaking changes, or relevant links + ``` + +- Common commit types: + - **feat**: New features or enhancements + - **fix**: Bug fixes + - **refactor**: Code restructuring without functional change + - **docs**: Documentation updates + - **chore**: Maintenance (dependencies, tooling, config) + - **test**: Test-related updates + - **style**: Code style and formatting + +- Prefix Python package changes with: + - `py(deps):` for standard packages + - `py(deps[dev]):` for development packages + - `py(deps[extra]):` for extras/sub-packages + +- General guidelines: + - Subject line: Maximum 50 characters + - Body lines: Maximum 72 characters + - Use imperative mood (e.g., "Add", "Fix", not "Added", "Fixed") + - Limit to one topic per commit + - Separate subject from body with a blank line + - Mark breaking changes clearly: `BREAKING:` + + + +- Use fixtures from conftest.py instead of monkeypatch and MagicMock when available +- For instance, if using libtmux, use provided fixtures: server, session, window, and pane +- Document in test docstrings why standard fixtures weren't used for exceptional cases +- Use tmp_path (pathlib.Path) fixture over Python's tempfile +- Use monkeypatch fixture over unittest.mock + + + +- Prefer namespace imports over importing specific symbols +- Import modules and access attributes through the namespace: + - Use `import enum` and access `enum.Enum` instead of `from enum import Enum` + - This applies to standard library modules like pathlib, os, and similar cases +- For typing, use `import typing as t` and access via the namespace: + - Access typing elements as `t.NamedTuple`, `t.TypedDict`, etc. + - Note primitive types like unions can be done via `|` pipes + - Primitive types like list and dict can be done via `list` and `dict` directly +- Benefits of namespace imports: + - Improves code readability by making the source of symbols clear + - Reduces potential naming conflicts + - Makes import statements more maintainable + + + +- Use our own libvcs pytest fixtures for all repository-related tests in this project: + - Create temporary repositories efficiently with factory fixtures + - Benefit from automatic cleanup when tests finish + - Utilize proper environment variables and configurations + - Test against real VCS operations without mocking + +- Basic repository testing pattern: + ```python + def test_repository_operation(create_git_remote_repo): + # Create a test repository + repo_path = create_git_remote_repo() + + # Test vcspull functionality with the repository + # ... + ``` + +- For more complex scenarios, use the pre-configured repository instances: + ```python + def test_sync_operations(git_repo): + # git_repo is already a GitSync instance + # Test vcspull sync operations + # ... + ``` + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..77f1525dc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,343 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## CRITICAL REQUIREMENTS + +### Test Success +- ALL tests MUST pass for code to be considered complete and working +- Never describe code as "working as expected" if there are ANY failing tests +- Even if specific feature tests pass, failing tests elsewhere indicate broken functionality +- Changes that break existing tests must be fixed before considering implementation complete +- A successful implementation must pass linting, type checking, AND all existing tests + +## Project Overview + +libvcs is a lite, typed Python tool for: +- Detecting and parsing URLs for Git, Mercurial, and Subversion repositories +- Providing command abstractions for git, hg, and svn +- Synchronizing repositories locally +- Creating pytest fixtures for testing with temporary repositories + +The library powers [vcspull](https://www.github.com/vcs-python/vcspull/), a tool for managing and synchronizing multiple git, svn, and mercurial repositories. + +## Development Environment + +This project uses: +- Python 3.9+ +- [uv](https://github.com/astral-sh/uv) for dependency management +- [ruff](https://github.com/astral-sh/ruff) for linting and formatting +- [mypy](https://github.com/python/mypy) for type checking +- [pytest](https://docs.pytest.org/) for testing + +## Common Commands + +### Setting Up Environment + +```bash +# Install dependencies +uv pip install --editable . +uv pip sync + +# Install with development dependencies +uv pip install --editable . -G dev +``` + +### Running Tests + +```bash +# Run all tests +just test +# or directly with pytest +uv run pytest + +# Run a single test file +uv run pytest tests/sync/test_git.py + +# Run a specific test +uv run pytest tests/sync/test_git.py::test_remotes + +# Run tests with test watcher +just start +# or +uv run ptw . +``` + +### Linting and Type Checking + +```bash +# Run ruff for linting +just ruff +# or directly +uv run ruff check . + +# Format code with ruff +just ruff-format +# or directly +uv run ruff format . + +# Run ruff linting with auto-fixes +uv run ruff check . --fix --show-fixes + +# Run mypy for type checking +just mypy +# or directly +uv run mypy src tests + +# Watch mode for linting (using entr) +just watch-ruff +just watch-mypy +``` + +### Development Workflow + +Follow this workflow for code changes: + +1. **Format First**: `uv run ruff format .` +2. **Run Tests**: `uv run pytest` +3. **Run Linting**: `uv run ruff check . --fix --show-fixes` +4. **Check Types**: `uv run mypy` +5. **Verify Tests Again**: `uv run pytest` + +### Documentation + +```bash +# Build documentation +just build-docs + +# Start documentation server with auto-reload +just start-docs + +# Update documentation CSS/JS +just design-docs +``` + +## Code Architecture + +libvcs is organized into three main modules: + +1. **URL Detection and Parsing** (`libvcs.url`) + - Base URL classes in `url/base.py` + - VCS-specific implementations in `url/git.py`, `url/hg.py`, and `url/svn.py` + - URL registry in `url/registry.py` + - Constants in `url/constants.py` + +2. **Command Abstraction** (`libvcs.cmd`) + - Command classes for git, hg, and svn in `cmd/git.py`, `cmd/hg.py`, and `cmd/svn.py` + - Built on top of Python's subprocess module (via `_internal/subprocess.py`) + +3. **Repository Synchronization** (`libvcs.sync`) + - Base sync classes in `sync/base.py` + - VCS-specific sync implementations in `sync/git.py`, `sync/hg.py`, and `sync/svn.py` + +4. **Internal Utilities** (`libvcs._internal`) + - Subprocess wrappers in `_internal/subprocess.py` + - Data structures in `_internal/dataclasses.py` and `_internal/query_list.py` + - Runtime helpers in `_internal/run.py` and `_internal/shortcuts.py` + +5. **pytest Plugin** (`libvcs.pytest_plugin`) + - Provides fixtures for creating temporary repositories for testing + +## Testing Strategy + +libvcs uses pytest for testing with many custom fixtures. The pytest plugin (`pytest_plugin.py`) defines fixtures for creating temporary repositories for testing. These include: + +- `create_git_remote_repo`: Creates a git repository for testing +- `create_hg_remote_repo`: Creates a Mercurial repository for testing +- `create_svn_remote_repo`: Creates a Subversion repository for testing +- `git_repo`, `svn_repo`, `hg_repo`: Pre-made repository instances +- `set_home`, `gitconfig`, `hgconfig`, `git_commit_envvars`: Environment fixtures + +These fixtures handle setup and teardown automatically, creating isolated test environments. + +For running tests with actual VCS commands, tests will be skipped if the corresponding VCS binary is not installed. + +### Testing Guidelines + +1. **Use functional tests only**: Write tests as standalone functions (`test_*`), not classes. Avoid `class TestFoo:` groupings - use descriptive function names and file organization instead. This applies to pytest tests, not doctests. + +### Example Fixture Usage + +```python +def test_repo_sync(git_repo): + # git_repo is already a GitSync instance with a clean repository + # Use it directly in your tests + assert git_repo.get_revision() == "initial" +``` + +### Parameterized Tests + +Use `typing.NamedTuple` for parameterized tests: + +```python +class RepoFixture(t.NamedTuple): + test_id: str # For test naming + repo_args: dict[str, t.Any] + expected_result: str + +@pytest.mark.parametrize( + list(RepoFixture._fields), + REPO_FIXTURES, + ids=[test.test_id for test in REPO_FIXTURES], +) +def test_sync( + # Parameters and fixtures... +): + # Test implementation +``` + +## Coding Standards + +### Imports + +- Use namespace imports: `import enum` instead of `from enum import Enum` +- For typing, use `import typing as t` and access via namespace: `t.NamedTuple`, etc. +- Use `from __future__ import annotations` at the top of all Python files + +### Docstrings + +Follow NumPy docstring style for all functions and methods: + +```python +"""Short description of the function or class. + +Detailed description using reStructuredText format. + +Parameters +---------- +param1 : type + Description of param1 +param2 : type + Description of param2 + +Returns +------- +type + Description of return value +""" +``` + +### Doctests + +**All functions and methods MUST have working doctests.** Doctests serve as both documentation and tests. + +**CRITICAL RULES:** +- Doctests MUST actually execute - never comment out `asyncio.run()` or similar calls +- Doctests MUST NOT be converted to `.. code-block::` as a workaround (code-blocks don't run) +- If you cannot create a working doctest, **STOP and ask for help** + +**Available tools for doctests:** +- `doctest_namespace` fixtures: `tmp_path`, `asyncio`, `create_git_remote_repo`, `create_hg_remote_repo`, `create_svn_remote_repo`, `example_git_repo` +- Ellipsis for variable output: `# doctest: +ELLIPSIS` +- Update `pytest_plugin.py` to add new fixtures to `doctest_namespace` + +**`# doctest: +SKIP` is NOT permitted** - it's just another workaround that doesn't test anything. If a VCS binary might not be installed, pytest already handles skipping via `skip_if_binaries_missing`. Use the fixtures properly. + +**Async doctest pattern:** +```python +>>> async def example(): +... result = await some_async_function() +... return result +>>> asyncio.run(example()) +'expected output' +``` + +**Using fixtures in doctests:** +```python +>>> git = Git(path=tmp_path) # tmp_path from doctest_namespace +>>> git.run(['status']) +'...' +``` + +**When output varies, use ellipsis:** +```python +>>> git.clone(url=f'file://{create_git_remote_repo()}') # doctest: +ELLIPSIS +'Cloning into ...' +``` + +### Git Commit Standards + +Format commit messages as: +``` +Component/File(commit-type[Subcomponent/method]): Concise description + +why: Explanation of necessity or impact. +what: +- Specific technical changes made +- Focused on a single topic +``` + +Common commit types: +- **feat**: New features or enhancements +- **fix**: Bug fixes +- **refactor**: Code restructuring without functional change +- **docs**: Documentation updates +- **chore**: Maintenance (dependencies, tooling, config) +- **test**: Test-related updates +- **style**: Code style and formatting + +Example: +``` +url/git(feat[GitURL]): Add support for custom SSH port syntax + +why: Enable parsing of Git URLs with custom SSH ports +what: +- Add port capture to SCP_REGEX pattern +- Update GitURL.to_url() to include port if specified +- Add tests for the new functionality +``` +For multi-line commits, use heredoc to preserve formatting: +```bash +git commit -m "$(cat <<'EOF' +feat(Component[method]) add feature description + +why: Explanation of the change. +what: +- First change +- Second change +EOF +)" +``` + +## Documentation Standards + +### Code Blocks in Documentation + +When writing documentation (README, CHANGES, docs/), follow these rules for code blocks: + +**One command per code block.** This makes commands individually copyable. + +**Put explanations outside the code block**, not as comments inside. + +Good: + +Run the tests: + +```console +$ uv run pytest +``` + +Run with coverage: + +```console +$ uv run pytest --cov +``` + +Bad: + +```console +# Run the tests +$ uv run pytest + +# Run with coverage +$ uv run pytest --cov +``` + +## Debugging Tips + +When stuck in debugging loops: + +1. **Pause and acknowledge the loop** +2. **Minimize to MVP**: Remove all debugging cruft and experimental code +3. **Document the issue** comprehensively for a fresh approach +4. **Format for portability** (using quadruple backticks) diff --git a/CHANGES b/CHANGES index 1aa03045a..20957a014 100644 --- a/CHANGES +++ b/CHANGES @@ -1,7 +1,6 @@ # Changelog -To install the unreleased libvcs version, see -[developmental releases](https://libvcs.git-pull.com/quickstart.html#developmental-releases). +To install the unreleased libvcs version, see [developmental releases](https://libvcs.git-pull.com/quickstart.html#developmental-releases). [pip](https://pip.pypa.io/en/stable/): @@ -9,21 +8,1057 @@ To install the unreleased libvcs version, see $ pip install --user --upgrade --pre libvcs ``` -## libvcs 0.13.0 (unreleased) +[uv](https://docs.astral.sh/uv/): -- _Add your latest changes from PRs here_ +```console +$ uv add libvcs --prerelease allow +``` + +## libvcs 0.39.x (unreleased) + + + +_Upcoming changes will be written here._ + +### Development + +#### Makefile -> Justfile (#500) + +- Migrate from `Makefile` to `justfile` for running development tasks +- Update documentation to reference `just` commands + +### Documentation + +- Migrate docs deployment to AWS OIDC authentication and AWS CLI + +## libvcs 0.38.1 (2025-12-06) + +### Documentation (#498) + +Fix doctree warnings and broken references + +## libvcs 0.38.0 (2025-11-30) + +### New features + +#### Additional git subcommands (#465) + +New architecture for git subcommands that returns typed objects instead of raw strings: + +- **Traversal** handle collection-level operations (`ls()`, `get()`, `filter()`, `add()`/`create()`) +- **Commands** handle per-entity operations (`show()`, `remove()`, `rename()`) +- All `ls()` methods return `QueryList` for chainable filtering + +New subcommand managers accessible via `Git` instance: + +- {attr}`Git.branches ` -> {class}`~libvcs.cmd.git.GitBranchManager` +- {attr}`Git.remotes ` -> {class}`~libvcs.cmd.git.GitRemoteManager` +- {attr}`Git.stashes ` -> {class}`~libvcs.cmd.git.GitStashManager` +- {attr}`Git.tags ` -> {class}`~libvcs.cmd.git.GitTagManager` +- {attr}`Git.worktrees ` -> {class}`~libvcs.cmd.git.GitWorktreeManager` +- {attr}`Git.notes ` -> {class}`~libvcs.cmd.git.GitNotesManager` +- {attr}`Git.submodules ` -> {class}`~libvcs.cmd.git.GitSubmoduleManager` +- {attr}`Git.reflog ` -> {class}`~libvcs.cmd.git.GitReflogManager` + +Example usage: + +```python +git = Git(path="/path/to/repo") + +# List all branches, filter remote ones +remote_branches = git.branches.ls(remotes=True) + +# Get a specific tag +tag = git.tags.get(tag_name="v1.0.0") +tag.delete() + +# Create a new branch and switch to it +git.branches.create("feature-branch") +``` + +#### cmd: Enhanced Git.init() (#465) + +- Added `ref_format` parameter for `--ref-format` (files/reftable) +- Added `make_parents` parameter to auto-create parent directories +- Improved parameter validation with clear error messages +- Extended `shared` parameter to support octal permissions (e.g., "0660") + +### Documentation + +- Add API documentation for all new Manager/Cmd classes (#465) +- Split git subcommand documentation into separate pages: branch, tag, worktree, notes, reflog + +### Tests + +- Comprehensive test coverage for all new Manager/Cmd classes (#465) + +## libvcs 0.37.0 (2025-11-01) + +### Breaking changes + +- Drop support for Python 3.9; the new minimum is Python 3.10 (#497). + + See also: + - [Python 3.9 EOL timeline](https://devguide.python.org/versions/#:~:text=Release%20manager-,3.9,-PEP%20596) + - [PEP 596](https://peps.python.org/pep-0596/) + +### Development + +- Add Python 3.14 to test matrix (#496) + +## libvcs 0.36.0 (2025-06-22) + +### Improvements + +- Add support for SCP-style Git URLs without requiring `git+ssh://` prefix (#490) + - URLs like `git@github.com:org/repo.git` are now recognized as Git repositories + - `create_project()` can now auto-detect VCS type for these URLs + - Addresses issues reported in [vcspull#49](https://github.com/vcs-python/vcspull/issues/49) and [vcspull#426](https://github.com/vcs-python/vcspull/pull/426) + +## libvcs 0.35.1 (2025-06-21) + +### Bug fixes + +- Fix `run()` output to show streaming progress of commands like `git clone`, + this fixes an issue downstream in `vcspull` (#493) + +### Development + +- Cursor rules for development loop and git commit messages (#488) + +## libvcs 0.35.0 (2025-02-22) + +### Breaking changes + +#### `run()` now uses `text=True` (#485) + +This means that unicode, not bytes, will be used for running `subprocess` +commands in libvcs. If there are any compatibility issues with this, please file +a ticket. + +### Development + +#### chore: Implement PEP 563 deferred annotation resolution (#483) + +- Add `from __future__ import annotations` to defer annotation resolution and reduce unnecessary runtime computations during type checking. +- Enable Ruff checks for PEP-compliant annotations: + - [non-pep585-annotation (UP006)](https://docs.astral.sh/ruff/rules/non-pep585-annotation/) + - [non-pep604-annotation (UP007)](https://docs.astral.sh/ruff/rules/non-pep604-annotation/) + +For more details on PEP 563, see: https://peps.python.org/pep-0563/ + +## libvcs 0.34.0 (2024-11-22) + +_Maintenance only, no bug fixes, or new features_ + +### Development + +#### Project and package management: poetry to uv (#479) + +[uv] is the new package and project manager for the project, replacing Poetry. + +[uv]: https://github.com/astral-sh/uv + +#### Build system: poetry to hatchling (#479) + +[Build system] moved from [poetry] to [hatchling]. + +[Build system]: https://packaging.python.org/en/latest/tutorials/packaging-projects/#choosing-a-build-backend +[poetry]: https://github.com/python-poetry/poetry +[hatchling]: https://hatch.pypa.io/latest/ + +## libvcs 0.33.0 (2024-10-13) + +### New features + +#### Python 3.13 support (#477) + +Added Python 3.13 to package trove classifiers and CI tests. + +#### pytest plugin: Authorship fixtures (#476) + +- New, customizable session-scoped fixtures for default committer on Mercurial and Git: + - Name: {func}`libvcs.pytest_plugin.vcs_name` + - Email: {func}`libvcs.pytest_plugin.vcs_email` + - User (e.g. _`user `_): {func}`libvcs.pytest_plugin.vcs_user` + - For git only: {func}`libvcs.pytest_plugin.git_commit_envvars` + +#### pytest plugins: Default repos use authorship fixtures (#476) + +New repos will automatically apply these session-scoped fixtures. + +## libvcs 0.32.3 (2024-10-13) + +### Bug fixes + +- Pytest fixtures `hg_remote_repo_single_commit_post_init()` and `git_remote_repo_single_commit_post_init()` now support passing `env` for VCS configuration. + + Both functions accept `hgconfig` and `gitconfig` fixtures, now applied in the `hg_repo` and `git_repo` fixtures. + +## libvcs 0.32.2 (2024-10-13) + +### Bug fixes + +- Pytest fixtures: `git_repo` and `hg_repo`: Set configuration for both fixtures. + +## libvcs 0.32.1 (2024-10-12) + +### Revert accidental commit + +Update to commands for `Git` from #465 were pushed to trunk before being prepared (even for experimental use). + +## libvcs 0.32.0 (2024-10-12) + +### Breaking changes + +#### pytest fixtures: Session-scoped `hgconfig` and `gitconfig` (#475) + +These are now set by `set_hgconfig` and `set_gitconfig`, which set `HGRCPATH` and `GIT_CONFIG`, instead of overriding `HOME`. + +### Documentation + +- Updates for pytest plugin documentation. + +## libvcs 0.31.0 (2024-10-12) + +### Breaking changes + +#### pytest plugin: Improve performacne via session-based scoping (#472) + +Improved test execution speed by over 54% by eliminated repetitive repository reinitialization between test runs. +Git, Subversion, and Mercurial repositories are now cached from an initial starter repository + +#### pytest fixtures: `git_local_clone` renamed to `example_git_repo` (#468) + +Renamed `git_local_clone` to `example_git_repo` for better understandability in +documentation / doctests. + +#### cmd: Listing method renamed (#466) + +- `libvcs.cmd.git.GitCmd._list()` -> `libvcs.cmd.git.Git.ls()` +- `libvcs.cmd.svn.Svn._list()` -> `libvcs.cmd.svn.Svn.ls()` + +## libvcs 0.30.1 (2024-06-18) + +### Bug Fixes + +- url(git): Remove unused `weight=0` flags from `AWS_CODE_COMMIT_DEFAULT_RULES` + (#464) +- url(git[GitURL]): Support for detection of AWS CodeCommit URLs (#464) + +### Tests + +- url(registry): Tests for `test_registry.py` detection of AWS CodeCommit URLs + (#464) + +### Documentation + +- README: Overhaul and fixes + +## libvcs 0.30.0 (2024-06-18) + +### New features + +### urls: AWS CodeCommit support (#443) + +- Support for [AWS CodeCommit] URL patterns. Examples: + + - HTTPS: `https://git-codecommit.us-east-1.amazonaws.com/v1/repos/test` + - SSH: `ssh://git-codecommit.us-east-1.amazonaws.com/v1/repos/test` + - HTTPS (GRC): + - `codecommit::us-east-1://test` + - `codecommit://test` + +[AWS CodeCommit]: https://docs.aws.amazon.com/codecommit/ + +### Breaking changes + +#### urls: Variable changes (#463) + +- `RE_PIP_REV` moved from `libvcs.url.git` to `libvcs.url.constants`. +- Regex pattern for user (e.g., `git@`) decoupled to `RE_USER`. +- `RE_PATH` and `SCP_REGEX` (now `RE_SCP`) no longer include user regex pattern + - Existing patterns now use `RE_USER` explicitly. +- `REGEX_SCP` renamed to `RE_SCP` for consistency. + +### Documentation + +- Automatically linkify links that were previously only text. +- Fix docstrings in `query_list` for `MultipleObjectsReturned` and + `ObjectDoesNotExist`. + +### Development + +- poetry: 1.8.1 -> 1.8.2 + + See also: https://github.com/python-poetry/poetry/blob/1.8.2/CHANGELOG.md + +- Code quality: Use f-strings in more places (#460) + + via [ruff 0.4.2](https://github.com/astral-sh/ruff/blob/v0.4.2/CHANGELOG.md). + +## libvcs 0.29.0 (2024-03-24) + +_Maintenance only, no bug fixes, or new features_ + +### Development + +- Aggressive automated lint fixes via `ruff` (#458) + + via ruff v0.3.4, all automated lint fixes, including unsafe and previews were applied: + + ```sh + ruff check --select ALL . --fix --unsafe-fixes --preview --show-fixes; ruff format . + ``` + + Branches were treated with: + + ```sh + git rebase \ + --strategy-option=theirs \ + --exec 'poetry run ruff check --select ALL . --fix --unsafe-fixes --preview --show-fixes; poetry run ruff format .; git add src tests; git commit --amend --no-edit' \ + origin/master + ``` + +- poetry: 1.7.1 -> 1.8.1 + + See also: https://github.com/python-poetry/poetry/blob/1.8.1/CHANGELOG.md + +- ruff 0.2.2 -> 0.3.0 (#457) + + Related formattings. Update CI to use `ruff check .` instead of `ruff .`. + + See also: https://github.com/astral-sh/ruff/blob/v0.3.0/CHANGELOG.md + +## libvcs 0.28.2 (2024-02-17) + +### Fixes + +- `Git.rev_list`: Fix argument expansion (#455) + + Resolves issue with _fatal: '--max-count': not an integer_. + +### Testing + +- CI: Bump actions to Node 20 releases (#456) + +## libvcs 0.28.1 (2024-02-08) + +### Packaging + +- Source distribution: Include `CHANGES`, `MIGRATION`, and `docs/` in tarball + (#454) + +## libvcs 0.28.0 (2024-02-07) + +### Improvement + +- `QueryList` generic support improved (#453) + +## libvcs 0.27.0 (2024-02-06) + +### Development + +- Strengthen linting (#514) + + - Add flake8-commas (COM) + + - https://docs.astral.sh/ruff/rules/#flake8-commas-com + - https://pypi.org/project/flake8-commas/ + + - Add flake8-builtins (A) + + - https://docs.astral.sh/ruff/rules/#flake8-builtins-a + - https://pypi.org/project/flake8-builtins/ + + - Add flake8-errmsg (EM) + + - https://docs.astral.sh/ruff/rules/#flake8-errmsg-em + - https://pypi.org/project/flake8-errmsg/ + +### CI + +- Move CodeQL from advanced configuration file to GitHub's default + +## libvcs 0.26.0 (2023-11-26) + +### Breaking changes + +- Rename pytest plugin protocol typings (#450): + + - `CreateProjectCallbackProtocol` -> `CreateRepoPostInitFn` + - `CreateProjectCallbackFixtureProtocol` -> `CreateRepoPytestFixtureFn` + +### Bug fixes + +- Remove unused command: `Svn.mergelist` (#450) +- Fix `Git.config` docstring (#450) + +### Development + +- ci: Add pydocstyle rule to ruff (#449) +- Add test for direct usage of `HgSync` (#450) +- pytest-watcher, Add configuration (#450): + + - Run initially by default + - Skip post-save files from vim + +### Documentation + +- Add docstrings to functions, methods, classes, and packages (#449) + +## libvcs 0.25.1 (2023-11-23) + +### Packaging + +- Move `gp-libs` to `test` dependencies + +## libvcs 0.25.0 (2023-11-19) + +_Maintenance only, no bug fixes, or new features_ + +### Packaging + +- Poetry: 1.6.1 -> 1.7.0 + + See also: https://github.com/python-poetry/poetry/blob/1.7.0/CHANGELOG.md + +- Move formatting from `black` to [`ruff format`] (#448) + + This retains the same formatting style of `black` while eliminating a + dev dependency by using our existing rust-based `ruff` linter. + + [`ruff format`]: https://docs.astral.sh/ruff/formatter/ + +- Packaging (poetry): Fix development dependencies + + Per [Poetry's docs on managing dependencies] and `poetry check`, we had it wrong: Instead of using extras, we should create these: + + ```toml + [tool.poetry.group.group-name.dependencies] + dev-dependency = "1.0.0" + ``` + + Which we now do. + + [Poetry's docs on managing dependencies]: https://python-poetry.org/docs/master/managing-dependencies/ + +### Development + +- CI: Update action package to fix warnings + + - [dorny/paths-filter]: 2.7.0 -> 2.11.1 + + [dorny/paths-filter]: https://github.com/dorny/paths-filter + +## libvcs 0.24.0 (2023-10-22) + +### Bug fixes + +- Git Remote URLs: Fix bug that would cause git remotes with `@` to be chopped off after the + protocol (#446, fixes #431) + +### Packaging + +- Move pytest configuration to `pyproject.toml` (#441) + +### Development + +- ruff: Remove ERA / `eradicate` plugin + + This rule had too many false positives to trust. Other ruff rules have been beneficial. + +- query_list: Refactor to use access {mod}`typing` via namespace as `t` (#439) + +## libvcs 0.23.0 (2023-08-20) + +_Maintenance only, no bug fixes, or new features_ + +### Development + +- Code quality improvements (#438) + + Additional ruff settings have been enabled. The most recent contribution + includes 100+ automated fixes and 50+ hand-made fixes. + +### Post-release: v0.23.0post0 (2023-08-20) + +- Fixes code comments cleaned up by `ruff`, but missed in QA. In the future, + even when using an automated tool, we will review more thoroughly. + +## libvcs 0.22.2 (2023-08-20) + +_Maintenance only, no bug fixes, or new features_ + +### Development + +- `SubprocessCommand`: Typing fix for `text` param. Found via mypy(1). + +## libvcs 0.22.1 (2023-05-28) + +_Maintenance only, no bug fixes, or new features_ + +### Development + +- Add back `black` for formatting + + This is still necessary to accompany `ruff`, until it replaces black. + +## libvcs 0.22.0 (2023-05-27) + +_Maintenance only, no bug fixes, or new features_ + +### Internal improvements + +- Move formatting, import sorting, and linting to [ruff]. + + This rust-based checker has dramatically improved performance. Linting and + formatting can be done almost instantly. + + This change replaces black, isort, flake8 and flake8 plugins. + +- poetry: 1.4.0 -> 1.5.0 + + See also: https://github.com/python-poetry/poetry/releases/tag/1.5.0 + +## libvcs 0.21.2 (2023-04-07) + +### Development + +- Update mypy to 1.2.0 + +### Fixes + +- SkipDefaultFieldsReprMixin: Fix typing for mypy 1.2.0 + +## libvcs 0.21.1 (2023-03-15) + +### Fixes + +- Remove more `typing_extensions` from runtime (#437 didn't get them all) + +## libvcs 0.21.0 (2023-03-15) + +### New + +- QueryList learned to `.get()` to pick the first result (#435) + + - Raises error if no items found (unless `default=` keyword argument passed) + - Raises error if multiple items found + +### Bug fixes + +- Remove required dependency of typing-extensions (#437) +- Ignore a single line of mypy check in dataclasses for now (#437) + +## libvcs 0.20.0 (2022-10-31) + +### What's new + +#### Python 3.11 support (#433) + +Official support for python 3.11 + +#### URLs: Mapping now class attributes (#433) + +`URL.rule_map` is now a class attribute rather than a dataclass attribute. + +```console + File "/home/user/.python/3.11.0/lib/python3.11/dataclasses.py", line 1211, in wrap + return _process_class(cls, init, repr, eq, order, unsafe_hash, + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/home/user/.python/3.11.0/lib/python3.11/dataclasses.py", line 959, in _process_class + cls_fields.append(_get_field(cls, name, type, kw_only)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/home/user/.python/3.11.0/lib/python3.11/dataclasses.py", line 816, in _get_field + raise ValueError(f'mutable default {type(f.default)} for field ' +ValueError: mutable default for field rule_map is not allowed: use default_factory +``` + +## libvcs 0.19.1 (2022-10-23) + +### Tests + +- Sync, git: Update pytest fixtures, via #432 + +### Documentation + +- CLI, git: Split subcommands into separate pages (remote, stash, submodule), via #432 + +## libvcs 0.19.0 (2022-10-23) + +### New features + +#### Commands + +via #430 + +- Git + + - Support for progress bar + - Add subcommands for: + - stash: {attr}`Git.stash ` -> {class}`libvcs.cmd.git.GitStashCmd` + - remote: {attr}`Git.remote ` -> {class}`libvcs.cmd.git.GitRemoteCmd` + - submodule: {attr}`Git.submodule ` -> + {class}`libvcs.cmd.git.GitSubmoduleCmd` + - Added commands for: + - {meth}`libvcs.cmd.git.Git.rev_parse` + - {meth}`libvcs.cmd.git.Git.rev_list` + - {meth}`libvcs.cmd.git.Git.symbolic_ref` + - {meth}`libvcs.cmd.git.Git.show_ref` + +- SVN + + New and improved: + + - {meth}`libvcs.cmd.svn.Svn.unlock` + - {meth}`libvcs.cmd.svn.Svn.lock` + - {meth}`libvcs.cmd.svn.Svn.propset` + +- Mercurial + + New and improved: + + - {meth}`libvcs.cmd.hg.Hg.pull` + - {meth}`libvcs.cmd.hg.Hg.clone` + - {meth}`libvcs.cmd.hg.Hg.update` + +#### Syncing + +via #430 + +Git, SVN, and Mercurial have moved to `libvcs.cmd` + +## libvcs 0.18.1 (2022-10-23) + +_Maintenance only release, no bug fixes, or new features_ + +- Documentation improvements +- Development package updates +- Add citation file (CITATION.cff) + +## libvcs 0.18.0 (2022-10-09) + +### New features + +#### URLs + +- Added `weight` to matchers (#428) + + - More heavily weighted matcher will have preference over others + - Fixes an issue where `defaults` would be overwritten + + The first, highest weighted will "win", avoiding the recursion causing defau defaults for other + matchers to be applied. + +## libvcs 0.17.0 (2022-09-25) + +### New features + +- URLs: Added `registry`, match find which VCS a URL matches with (#420) +- `create_project`: Learn to guess VCS from URL, if none provided (#420) + +### Breaking changes + +URL renamings (#417): + +- `Matcher` -> `Rule`, `MatcherRegistry` -> `Rules` +- `matches` -> `rule_map` +- `default_patterns` -> `patterns` +- `MATCHERS` -> `RULES` + +### Improvements + +pytest plugin: + +- `create_{git,svn,hg}_remote_repo()` now accepts `init_cmd_args` (`list[str]`, default: + `['--bare']`, #426) + + To not use bare, pass `init_cmd_args=None` + +Sync: + +- `git`: Fix `update_repo` when there are only untracked files (#425, credit: @jfpedroza) + +URL (#423): + +- `hg`: Add `HgBaseURL`, `HgPipURL` +- `svn`: Add `SvnBaseURL`, `SvnPipURL` +- `URLProtocol`: Fix `is_valid` to use `classmethod` +- All: Fix `is_valid` to use default of `None` to avoid implicitly filtering +- Reduce duplicated code in methods by using `super()` + +### Packaging + +- Migrate `.coveragerc` to `pyproject.toml` (#421) +- Remove `.tmuxp.before-script.sh` (was a `before_script` in `.tmuxp.yaml`) that was unused. +- Move `conftest.py` to root level + + - Can be excluded from wheel, included in sdist + - Required to satisfy pytest's `pytest_plugins` only being in top-level confte conftest.py files + since 4.0 (see + [notice](https://docs.pytest.org/en/stable/deprecations.html#pytest-plugins-in-non-top-level-conftest-files)) + - Makes it possible to run `pytest README.md` with doctest plugin + +## libvcs 0.16.5 (2022-09-21) + +### Bug fixes + +- Use pytest's public API when importing (#418) + +## libvcs 0.16.4 (2022-09-18) + +### Infrastructure + +- Bump poetry to 1.1.x to 1.2.x + +## libvcs 0.16.3 (2022-09-18) + +### Bug fixes + +- `QueryList`: Fix lookups of objects (#415) + +### Tests + +- Basic pytest plugin test (#413) +- Add test for object based lookups (#414) + +### Documentation + +- Improve doc examples / tests for `keygetter` and `QueryList` to show deep lookups for objects + (#415) + +### Infrastructure + +- CI speedups (#416) + + - Avoid fetching unused apt package + - Split out release to separate job so the PyPI Upload docker image isn't pulled on normal runs + - Clean up CodeQL + +## libvcs 0.16.2 (2022-09-11) + +### Bug fix + +Remove `Faker` dependency (#412) + +## libvcs 0.16.1 (2022-09-11) + +### Bug fix + +Temporarily add `Faker` as a dependency (due to pytest), track longterm fix on (#411) + +## libvcs 0.16.0 (2022-09-11) + +### New features + +- Added a [pytest plugin](https://libvcs.git-pull.com/pytest-plugin.html). Create fresh, temporarily + repos on your machine locally for git, mercurial, and svn (#409) + +## libvcs 0.15.0 (2022-09-11) + +### Breaking changes + +- Moves (#408): + + - `libvcs.parse` -> `libvcs.url` + - `libvcs.projects` -> `libvcs.sync` + +- Renames (#408): + + - `BaseProject` -> `BaseSync` + - `MercurialProject` -> `HgSync` + - `SubversionProject` -> `SvnSync` + - `GitProject` -> `GitSync` + +- Deprecate custom functions in favor of standard library: + - `which()` in favor of {func}`shutil.which`, via #397 + - `mkdir_p()` in favor of {func}`os.makedirs` and {meth}`pathlib.Path.mkdir` w/ `parents=True`, + via #399 + +### Development + +- Remove `.pre-commit-config.yaml`: This can be done less obtrusively via flake8 and having the user + run the tools themselves. +- Add [flake8-bugbear](https://github.com/PyCQA/flake8-bugbear) (#379) +- Add [flake8-comprehensions](https://github.com/adamchainz/flake8-comprehensions) (#402) + +### Documentation + +- Render changelog in [`linkify_issues`] (#396, #403) +- Fix Table of contents rendering with sphinx autodoc with [`sphinx_toctree_autodoc_fix`] (#403) +- Deprecate `sphinx-autoapi`, per above fixing the table of contents issue (#403) + + This also removes the need to workaround autoapi bugs. + +[`linkify_issues`]: https://gp-libs.git-pull.com/linkify_issues/ +[`sphinx_toctree_autodoc_fix`]: https://gp-libs.git-pull.com/sphinx_toctree_autodoc_fix/ + +## libvcs 0.14.0 (2022-07-31) + +### What's new + +- New and improved logo +- **Improved typings** + + Now [`mypy --strict`] compliant (#390) + + [`mypy --strict`]: https://mypy.readthedocs.io/en/stable/command_line.html#cmdoption-mypy-strict + +- **Parser**: Experimental VCS URL parsing added (#376, #381, #384, #386): + + VCS Parsers return {func}`dataclasses.dataclass` instances. The new tools support validation, + parsing, mutating and exporting into URLs consumable by the VCS. + + ::: {warning} + + APIs are unstable and subject to break until we get it right. + + ::: + + - {mod}`libvcs.url.git` + + - {class}`~libvcs.url.git.GitBaseURL` - Parse git URLs, `git(1)` compatible + + - {meth}`~libvcs.url.git.GitBaseURL.is_valid` + - {meth}`~libvcs.url.git.GitBaseURL.to_url` - export `git clone`-compatible URL + + - {class}`~libvcs.url.git.GitPipURL` - Pip URLs, {meth}`~libvcs.url.git.GitPipURL.is_valid`, + {meth}`~libvcs.url.git.GitPipURL.to_url` + + - {class}`~libvcs.url.git.GitURL` - Compatibility focused, + {meth}`~libvcs.url.git.GitURL.is_valid` {meth}`~libvcs.url.git.GitURL.to_url` + + - {mod}`libvcs.url.hg` + + - {class}`~libvcs.url.hg.HgURL` - Parse Mercurial URLs + - {meth}`~libvcs.url.hg.HgURL.is_valid` + - {meth}`~libvcs.url.hg.HgURL.to_url` - export `hg clone`-compatible URL + + - {mod}`libvcs.url.svn` + + - {class}`~libvcs.url.svn.SvnURL` - Parse Subversion URLs + - {meth}`~libvcs.url.svn.SvnURL.is_valid` + - {meth}`~libvcs.url.svn.SvnURL.to_url` - export `svn checkout`-compatible URL + + Detection can be extended through writing {class}`~libvcs.url.base.Matcher`s and adding them to + the classes' {class}`~libvcs.url.base.MatcherRegistry` + + You can write your own VCS parser by implementing {class}`~libvcs.url.base.URLProtocol`, but it + would be most efficient if you studied the source of the `git(1)` parser to see how it's done. + +### Breaking changes + +- #391 Removed `flat` keyword argument for {class}`libvcs.sync.git.GitSync`. This was unused and the + equivalent can be retrieved via `.to_dict()` on `GitRemote` +- #379 Support for `git+git` URLs removed. Pip removed these in 21.0 due to them being insecure + [^pip-git+git] +- #372 Typings moved from `libvcs.types` -> {mod}`libvcs._internal.types` +- #377 Remove deprecated functions and exceptions + + - Removed `libvcs.shortcuts` + - Removed `libvcs.shortcuts.create_project_from_pip_url()`: This will be replaced in future + versions by #376 / parsing utilities + - Moved `libvcs.shortcuts.create_project()` to {func}`libvcs._internal.shortcuts.create_project` + - Removed {exc}`libvcs.exc.InvalidPipURL` + +[^pip-git+git]: pip removes `git+git@` + +### Fixes + +- Minor spelling fix in Git's `convert_pip_url()` exception +- Fix mercurial cloning in {class}`libvcs.sync.hg.HgSync` + + _Backport from 0.13.1_ + +### Typings + +- Rename `VcsLiteral` -> `VCSLiteral` + + _Backport from 0.13.4_ + +- {func}`~libvcs.shortcuts.create_project`: Add overloads that return the typed project (e.g., + {class}`~libvcs.sync.git.GitSync`) + + _Backport from 0.13.3_ + +### Cleanup + +- #378 #380 Remove duplicate `uses_netloc` scheme for `git+ssh` (this was in cpython since 2.7 / 3.1 + [^git+ssh][^python:bugs:8657]) + +[^git+ssh]: `uses_netloc` added `'git'` and `'git+ssh'` in {mod}`urllib.parse` + + [python/cpython@ead169d] + +[python/cpython@ead169d]: https://github.com/python/cpython/commit/ead169d3114ed0f1041b5b59ca20293449608c50 + +[^python:bugs:8657]: + +## libvcs 0.13.6 (2022-06-18) + +### Development + +- Move `libvcs.shortcuts` to {mod}`libvcs._internal.shortcuts` + +## libvcs 0.13.5 (2022-06-18) + +### Development + +- Note upcoming deprecation of `create_project_from_pip_url` in v0.14 +- Note `create_project` becoming internal API in upcoming release v0.14 +- Fix import in `libvcs.shortcuts` (in v0.14 this module will not exist) + +## libvcs 0.13.4 (2022-06-18) + +### Typing + +- Rename `VcsLiteral` -> `VCSLiteral` + +## libvcs 0.13.3 (2022-06-18) + +### Typings + +- `create_project()`: Add overloads that return the typed project (e.g., + {class}`~libvcs.sync.git.GitSync`) + +## libvcs 0.13.2 (2022-06-12) + +### Typings + +- {func}`libvcs.sync.git.GitSync.remotes`: Add overload + +## libvcs 0.13.1 (2022-06-01) + +### Fixes + +- Fix mercurial cloning in {class}`libvcs.sync.hg.HgSync` + +## libvcs 0.13.0, "Jane" (2022-05-30) + +### Breaking changes + +- #343: `libvcs.cmd.core` moved to `libvcs._internal.run` to make it more clear the API is closed. + + This includes {func}`~libvcs._internal.run.run` + + Before in 0.13: + + ```python + from libvcs.cmd.core import run + ``` + + New module in >=0.13: + + ```python + from libvcs._internal.run import run + ``` + +- #361: {class}`~libvcs._internal.run.run`'s params are now a pass-through to + {class}`subprocess.Popen`. + + - `run(cmd, ...)` is now `run(args, ...)` to match `Popen`'s convention. + +- {class}`libvcs.sync.base.BaseSync`: + + - Removed `parent_dir`: + + Before: `project.parent_dir` + + After: `project.parent.path`. + + - `repo_name` switched from attribute to property + +- Keyword-only arguments via [PEP 3102], [PEP 570] + + - #366: `libvcs.cmd` for hg, git, and svn updated to use + + - #364: Project classes no longer accept positional arguments. + + Deprecated in >=0.13: + + ```python + GitSync('https://github.com/vcs-python/libvcs.git') + ``` + + New style in >=0.13: + + ```python + GitSync(url='https://github.com/vcs-python/libvcs.git') + ``` + +[pep 570]: https://peps.python.org/pep-0570/ +[pep 3102]: https://peps.python.org/pep-3102/#specification + +### What's new + +- **Commands**: Experimental command wrappers added (#346): + + - {class}`libvcs.cmd.git.Git` + + - {meth}`libvcs.cmd.git.Git.help` + - {meth}`libvcs.cmd.git.Git.reset` + - {meth}`libvcs.cmd.git.Git.checkout` + - {meth}`libvcs.cmd.git.Git.status` + - {meth}`libvcs.cmd.git.Git.config` via #360 + +- **Command**: Now support `-C` (which accepts `.git` dirs, see git's manual) in addition to `cwd` + (subprocess-passthrough), #360 + +### Bug fixes + +- Fix argument input for commands, for instance `git config --get color.diff` would not properly + pass-through to subprocess. git: #360, svn and hg: #365 + +### Internals + +- #362 [mypy] support added: + + - Basic mypy tests now pass + - Type annotations added, including improved typings for: + + - {func}`libvcs._internal.run.run` + - {meth}`libvcs._internal.subprocess.SubprocessCommand.Popen` + - {meth}`libvcs._internal.subprocess.SubprocessCommand.check_output` + - {meth}`libvcs._internal.subprocess.SubprocessCommand.run` + + - `make mypy` and `make watch_mypy` + - Automatic checking on CI + +- #345 `libvcs.utils` -> `libvcs._internal` to make it more obvious the APIs are strictly closed. +- `StrOrPath` -> `StrPath` +- #336: {class}`~libvcs._internal.subprocess.SubprocessCommand`: Encapsulated {mod}`subprocess` call + in a {func}`dataclasses.dataclass` for introspecting, modifying, mocking and controlling + execution. +- Dataclass helper: {class}`~libvcs._internal.dataclasses.SkipDefaultFieldsReprMixin` + + Skip default fields in object representations. + + Credit: Pietro Oldrati, 2022-05-08, + [StackOverflow Post](https://stackoverflow.com/a/72161437/1396928) + +### Documentation + +- Document `libvcs.types` +- #362: Improve developer documentation to note [mypy] and have tabbed examples for flake8. + +[mypy]: http://mypy-lang.org/ + +### Packaging + +- Update description and keywords ## libvcs 0.12.4 (2022-05-30) - _Backport from 0.13.x_ Fix argument input for hg and svn commands, would not properly pass-through - to subprocess. {issue}`365` + to subprocess. #365 ## libvcs 0.12.3 (2022-05-28) ### Bug fixes - _Backport from 0.13.x_. Fix argument input for git commands, e.g. `git config --get color.diff` - would not properly pass-through to subprocess. {issue}`360` + would not properly pass-through to subprocess. #360 ## libvcs 0.12.2 (2022-05-10) @@ -42,29 +1077,29 @@ $ pip install --user --upgrade --pre libvcs ### Breaking -- `GitRepo`, `SVNRepo`, `MercurialRepo`, `BaseRepo` have been renamed to `GitProject`, `SVNProject`, - `MercurialProject`, `BaseProject` ({issue}`#327`) -- `GitProject`, `SVNProject`, `MercurialProject`, `BaseProject` have been moved to - `libvcs.projects.{module}.{Module}Project` +- `GitRepo`, `SVNRepo`, `MercurialRepo`, `BaseRepo` have been renamed to `GitSync`, `SVNProject`, + `HgSync`, `BaseSync` (#327) +- `GitSync`, `SVNProject`, `HgSync`, `BaseSync` have been moved to + `libvcs.sync.{module}.{Module}Project` - `repo_dir` param is renamed to `dir`: - Before: `GitProject(url='...', repo_dir='...')` + Before: `GitSync(url='...', repo_path='...')` - After: `GitProject(url='...', dir='...')` + After: `GitSync(url='...', path='...')` - {issue}`#324` + #324 -- `dir` to `pathlib`, `BaseProject.path` -> `BaseProject.dir` -- Logging functions moved to {attr}`libvcs.projects.base.BaseProject.log` ({issue}`#322`) +- `dir` to `pathlib`, `BaseSync.path` -> `BaseSync.path` +- Logging functions moved to {attr}`libvcs.sync.base.BaseSync.log` (#322) - Rename `ProjectLoggingAdapter` to `CmdLoggingAdapter` - `CmdLoggingAdapter`: Rename `repo_name` param to `keyword` - `create_repo` -> `create_project` -- `GitRemote` and `GitStatus`: Move to {func}`dataclasses.dataclass` ({issue}`#329`) -- `extract_status()`: Move to `GitStatus.from_stdout` ({issue}`#329`) +- `GitRemote` and `GitStatus`: Move to {func}`dataclasses.dataclass` (#329) +- `extract_status()`: Move to `GitStatus.from_stdout` (#329) ### What's new -- **Commands**: Experimental command wrappers added ({issue}`#319`): +- **Commands**: Experimental command wrappers added (#319): - {class}`libvcs.cmd.git.Git` @@ -89,12 +1124,12 @@ $ pip install --user --upgrade --pre libvcs - {meth}`libvcs.cmd.hg.Hg.run` - {meth}`libvcs.cmd.hg.Hg.clone` -- {class}`libvcs.projects.git.GitProject` now accepts remotes in `__init__` +- {class}`libvcs.sync.git.GitSync` now accepts remotes in `__init__` ```python - repo = GitProject( + repo = GitSync( url="https://github.com/vcs-python/libvcs", - repo_dir=checkout, + repo_path=checkout, remotes={ 'gitlab': 'https://gitlab.com/vcs-python/libvcs', } @@ -102,9 +1137,9 @@ $ pip install --user --upgrade --pre libvcs ``` ```python - repo = GitProject( + repo = GitSync( url="https://github.com/vcs-python/libvcs", - repo_dir=checkout, + repo_path=checkout, remotes={ 'gitlab': { 'fetch_url': 'https://gitlab.com/vcs-python/libvcs', @@ -114,7 +1149,7 @@ $ pip install --user --upgrade --pre libvcs ) ``` -- {meth}`libvcs.projects.git.GitProject.update_repo` now accepts `set_remotes=True` +- {meth}`libvcs.sync.git.GitSync.update_repo` now accepts `set_remotes=True` ### Compatibility @@ -125,28 +1160,27 @@ $ pip install --user --upgrade --pre libvcs ### Development -- Add codeql analysis ({issue}`#303`) -- git test suite: Lots of parametrization ({issue}`#309`) +- Add codeql analysis (#303) +- git test suite: Lots of parametrization (#309) - CI: Use poetry caching from - [@actions/setup v3.1](https://github.com/actions/setup-python/releases/tag/v3.1.0), - ({issue}`#316`) + [@actions/setup v3.1](https://github.com/actions/setup-python/releases/tag/v3.1.0), (#316) - New constants for `str` -> class mappings - - {data}`libvcs.projects.constants.DEFAULT_VCS_CLASS_MAP` - - {data}`libvcs.projects.constants.DEFAULT_VCS_CLASS_UNION` - - {data}`libvcs.projects.constants.DEFAULT_VCS_LITERAL` + - {data}`libvcs.sync.constants.DEFAULT_VCS_CLASS_MAP` + - {data}`libvcs.sync.constants.DEFAULT_VCS_CLASS_UNION` + - {data}`libvcs.sync.constants.DEFAULT_VCS_LITERAL` - Remove tox and tox-poetry-installer. It turns out installing poetry inside a poetry project doesn't work well. (`poetry update`, `poetry publish`, etc. commands would fail) - Add [doctest](https://docs.python.org/3/library/doctest.html) w/ - [pytest + doctest](https://docs.pytest.org/en/7.1.x/how-to/doctest.html), ({issue}`#321`). + [pytest + doctest](https://docs.pytest.org/en/7.1.x/how-to/doctest.html), (#321). - Publish to PyPI via CI when git tags are set. ### Documentation - API: Split documentation of modules to separate pages -- Fix sphinx-issues ({issue}`#321`) -- Experiment with sphinx-autoapi ({issue}`#328`) for table of contents support +- Fix sphinx-issues (#321) +- Experiment with sphinx-autoapi (#328) for table of contents support ## libvcs 0.11.1 (2022-03-12) @@ -220,7 +1254,7 @@ releases. The API is subject to change significantly in pre-1.0 builds. - Move sphinx api format to Numpy-style - Move from reStructuredText to Markdown (via recommonmark). The master plan is to eliminate - docutils and sphinx as a bottleneck completely in favor of something else (e.g. gatsby with a + docutils and sphinx as a bottleneck completely in favor of something else (e.g., gatsby with a source that inspects our modules and can source intersphinx) - Move from RTD to GitHub Action, full support of poetry extras packages, deploys straight to S3 @@ -417,7 +1451,7 @@ Internal functionality relating to remotes have been reorganized to avoid implic ## libvcs 0.1.2 (2016-06-20) -- change signature on `create_repo_from_pip_url` to accept `pip_url` insetad of `url`. +- change signature on `create_repo_from_pip_url` to accept `pip_url` instead of `url`. - `Base` to accept `repo_dir` instead of `name` and `parent_dir`. ## libvcs 0.1.1 (2016-06-20) diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 000000000..5474ca5e7 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,13 @@ +cff-version: 1.2.0 +message: >- + If you use this software, please cite it as below. + NOTE: Change "x.y" by the version you use. If you are unsure about which version + you are using run: `pip show libvcs`." +authors: +- family-names: "Narlock" + given-names: "Tony" + orcid: "https://orcid.org/0000-0002-2568-415X" +title: "libvcs" +type: software +version: x.y +url: "https://libvcs.git-pull.com" diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/MIGRATION b/MIGRATION new file mode 100644 index 000000000..219c84721 --- /dev/null +++ b/MIGRATION @@ -0,0 +1,94 @@ +# Migration notes + +Migration and deprecation notes for libvcs are here, see {ref}`history` as well. + +```{admonition} Welcome on board! 👋 +1. 📌 For safety, **always** pin the package +2. 📖 Check the migration notes _(You are here)_ +3. đŸ“Ŗ If you feel something got deprecated and it interrupted you - past, present, or future - voice your opinion on the [tracker]. + + We want to make libvcs fun, reliable, and useful for users. + + API changes can be painful. + + 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! + + (Please pin the package. 🙏) + + [tracker]: https://github.com/vcs-python/libvcs/discussions +``` + +## Next release + +_Notes on the upcoming release will be added here_ + + + +### pytest fixtures: `git_local_clone` renamed to `example_git_repo` (#468) + +- pytest: `git_local_clone` renamed to `example_git_repo` + +### Commands: Listing method renamed (#466) + +- `libvcs.cmd.git.GitCmd._list()` -> `libvcs.cmd.git.Git.ls()` +- `libvcs.cmd.svn.Svn._list()` -> `libvcs.cmd.svn.Svn.ls()` + +## libvcs 0.30.0 (2024-06-18) + +### URLs: Variable renamings and moves (#463) + +- `RE_PIP_REV` moved from `libvcs.url.git` to `libvcs.url.constants`. +- `RE_PATH` has changed: + + - The pattern for user matching (e.g., `git@`) has been extracted to `RE_USER`. + - `RE_PATH` and `SCP_REGEX` (now `RE_SCP`) no longer include user regex pattern + - Existing patterns now use `RE_USER` explicitly. + +- `REGEX_SCP` renamed to `RE_SCP` for consistency. + +## libvcs 0.20.0 (2022-10-31) + +### URLs: Mapping now class attributes (#433) + +`URL.rule_map` is now a class attribute rather than a dataclass attribute. + +Before: + +```python +@dataclasses.dataclass(repr=False) +class GitLabURL(GitURL): + rule_map: RuleMap = RuleMap( + _rule_map={'gitlab_prefix': GitLabPrefix} + ) +``` + +In python 3.11, that raises an error: + +```console + File "/home/user/.python/3.11.0/lib/python3.11/dataclasses.py", line 1211, in wrap + return _process_class(cls, init, repr, eq, order, unsafe_hash, + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/home/user/.python/3.11.0/lib/python3.11/dataclasses.py", line 959, in _process_class + cls_fields.append(_get_field(cls, name, type, kw_only)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/home/user/.python/3.11.0/lib/python3.11/dataclasses.py", line 816, in _get_field + raise ValueError(f'mutable default {type(f.default)} for field ' +ValueError: mutable default for field rule_map is not allowed: use default_factory +``` + +After release: + +```python +>>> import dataclasses +>>> from libvcs.url.base import RuleMap +>>> from libvcs.url.git import GitURL, DEFAULT_RULES +>>> @dataclasses.dataclass(repr=False) +... class MyGitURL(GitURL): +... rule_map = RuleMap( +... _rule_map={'gitlab_prefix': DEFAULT_RULES} +... ) +``` + + diff --git a/Makefile b/Makefile deleted file mode 100644 index c52e83926..000000000 --- a/Makefile +++ /dev/null @@ -1,48 +0,0 @@ -PY_FILES= find . -type f -not -path '*/\.*' | grep -i '.*[.]py$$' 2> /dev/null -DOC_FILES= find . -type f -not -path '*/\.*' | grep -i '.*[.]rst\$\|.*[.]md\$\|.*[.]css\$\|.*[.]py\$\|mkdocs\.yml\|CHANGES\|TODO\|.*conf\.py' 2> /dev/null -SHELL := /bin/bash - - -entr_warn: - @echo "----------------------------------------------------------" - @echo " ! File watching functionality non-operational ! " - @echo " " - @echo "Install entr(1) to automatically run tasks on file change." - @echo "See http://entrproject.org/ " - @echo "----------------------------------------------------------" - -isort: - poetry run isort `${PY_FILES}` - -black: - poetry run black `${PY_FILES}` - -test: - poetry run py.test $(test) - -start: - $(MAKE) test; poetry run ptw . - -watch_test: - if command -v entr > /dev/null; then ${PY_FILES} | entr -c $(MAKE) test; else $(MAKE) test entr_warn; fi - -build_docs: - $(MAKE) -C docs html - -watch_docs: - if command -v entr > /dev/null; then ${DOC_FILES} | entr -c $(MAKE) build_docs; else $(MAKE) build_docs entr_warn; fi - -start_docs: - $(MAKE) -C docs start - -design_docs: - $(MAKE) -C docs design - -flake8: - poetry run flake8 - -watch_flake8: - if command -v entr > /dev/null; then ${PY_FILES} | entr -c $(MAKE) flake8; else $(MAKE) flake8 entr_warn; fi - -format_markdown: - prettier --parser=markdown -w *.md docs/*.md docs/**/*.md CHANGES diff --git a/README.md b/README.md index 30e7112fa..42d7d120c 100644 --- a/README.md +++ b/README.md @@ -1,99 +1,170 @@ -# `libvcs` · [![Python Package](https://img.shields.io/pypi/v/libvcs.svg)](https://pypi.org/project/libvcs/) [![License](https://img.shields.io/github/license/vcs-python/libvcs.svg)](https://github.com/vcs-python/libvcs/blob/master/LICENSE) [![Code Coverage](https://codecov.io/gh/vcs-python/libvcs/branch/master/graph/badge.svg)](https://codecov.io/gh/vcs-python/libvcs) +
+ libvcs logo +

libvcs

+

The Swiss Army Knife for Version Control Systems in Python.

+

+ PyPI version + Python versions + Tests status + Coverage + License +

+
-libvcs is a lite, [typed](https://docs.python.org/3/library/typing.html), pythonic wrapper for -`git`, `hg`, and `svn`. Powers [vcspull](https://www.github.com/vcs-python/vcspull/). +**libvcs** provides a unified, [typed](https://docs.python.org/3/library/typing.html), and pythonic interface for managing Git, Mercurial, and Subversion repositories. Whether you're building a deployment tool, a developer utility, or just need to clone a repo in a script, libvcs handles the heavy lifting. -## Setup +It powers [vcspull](https://github.com/vcs-python/vcspull) and simplifies VCS interactions down to a few lines of code. -```console -$ pip install --user libvcs +--- + +## Features at a Glance + +- **🔄 Repository Synchronization**: Clone, update, and manage local repository copies with a high-level API. +- **🛠 Command Abstraction**: Speak fluent `git`, `hg`, and `svn` through fully-typed Python objects. +- **🔗 URL Parsing**: Robustly validate, parse, and manipulate VCS URLs (including SCP-style). +- **đŸ§Ē Pytest Fixtures**: Batteries-included fixtures for spinning up temporary repositories in your test suite. + +## Installation + +```bash +pip install libvcs ``` -Open up python: +With [uv](https://docs.astral.sh/uv/): -```console -$ python +```bash +uv add libvcs ``` -Or for nice autocompletion and highlighting: +Try it interactively: -```console -$ pip install --user ptpython +```bash +uvx --with libvcs ipython ``` -```console -$ ptpython +Tip: libvcs is pre-1.0. Pin a version range in projects to avoid surprises: + +```toml +# pyproject.toml +dependencies = ["libvcs>=0.37,<0.38"] ``` -## Commands (experimental) +## Usage + +### 1. Synchronize Repositories +Clone and update repositories with a consistent API, regardless of the VCS. -Simple [`subprocess`](https://docs.python.org/3/library/subprocess.html) wrappers around `git(1)`, -`hg(1)`, `svn(1)`. Here is [`Git`](https://libvcs.git-pull.com/cmd/git.html#libvcs.cmd.git.Git) w/ -[`Git.clone`](http://libvcs.git-pull.com/cmd/git.html#libvcs.cmd.git.Git.clone): +[**Learn more about Synchronization**](https://libvcs.git-pull.com/sync/) ```python import pathlib -from libvcs.cmd.git import Git +from libvcs.sync.git import GitSync + +# Define your repository +repo = GitSync( + url="https://github.com/vcs-python/libvcs", + path=pathlib.Path.cwd() / "libvcs", + remotes={ + 'gitlab': 'https://gitlab.com/vcs-python/libvcs' + } +) -git = Git(dir=pathlib.Path.cwd() / 'my_git_repo') -git.clone(url='https://github.com/vcs-python/libvcs.git') +# Clone (if not exists) or fetch & update (if exists) +repo.update_repo() + +print(f"Current revision: {repo.get_revision()}") ``` -## Projects +### 2. Command Abstraction +Traverse repository entities intuitively with ORM-like filtering, then run targeted commands against them. -Create a -[`GitProject`](https://libvcs.git-pull.com/projects/git.html#libvcs.projects.git.GitProject) object -of the project to inspect / checkout / update: +[**Learn more about Command Abstraction**](https://libvcs.git-pull.com/cmd/) ```python import pathlib -from libvcs.projects.git import GitProject - -repo = GitProject( - url="https://github.com/vcs-python/libvcs", - dir=pathlib.Path().cwd() / "my_repo", - remotes={ - 'gitlab': 'https://gitlab.com/vcs-python/libvcs' - } -) +from libvcs.cmd.git import Git + +# Initialize the wrapper +git = Git(path=pathlib.Path.cwd() / 'libvcs') + +# Run commands directly +git.clone(url='https://github.com/vcs-python/libvcs.git') +git.checkout(ref='master') + +# Traverse branches with ORM-like filtering +git.branches.create('feature/new-gui') +print(git.branches.ls()) # Returns QueryList for filtering + +# Target specific entities with contextual commands +git.remotes.set_url(name='origin', url='git@github.com:vcs-python/libvcs.git') +git.tags.create(name='v1.0.0', message='Release version 1.0.0') ``` -Update / clone repo: +### 3. URL Parsing +Stop writing regex for Git URLs. Let `libvcs` handle the edge cases. + +[**Learn more about URL Parsing**](https://libvcs.git-pull.com/url/) ```python ->>> r.update_repo() +from libvcs.url.git import GitURL + +# Validate URLs +GitURL.is_valid(url='https://github.com/vcs-python/libvcs.git') # True + +# Parse complex URLs +url = GitURL(url='git@github.com:vcs-python/libvcs.git') + +print(url.user) # 'git' +print(url.hostname) # 'github.com' +print(url.path) # 'vcs-python/libvcs' + +# Transform URLs +url.hostname = 'gitlab.com' +print(url.to_url()) # 'git@gitlab.com:vcs-python/libvcs.git' ``` -Get revision: +### 4. Testing with Pytest +Writing a tool that interacts with VCS? Use our fixtures to keep your tests clean and isolated. + +[**Learn more about Pytest Fixtures**](https://libvcs.git-pull.com/pytest-plugin.html) ```python ->>> r.get_revision() -u'5c227e6ab4aab44bf097da2e088b0ff947370ab8' +import pathlib +from libvcs.pytest_plugin import CreateRepoPytestFixtureFn +from libvcs.sync.git import GitSync + +def test_my_git_tool( + create_git_remote_repo: CreateRepoPytestFixtureFn, + tmp_path: pathlib.Path +): + # Spin up a real, temporary Git server + git_server = create_git_remote_repo() + + # Clone it to a temporary directory + checkout_path = tmp_path / "checkout" + repo = GitSync(path=checkout_path, url=f"file://{git_server}") + repo.obtain() + + assert checkout_path.exists() + assert (checkout_path / ".git").is_dir() ``` -## Donations +## Project Information + +- **Python Support**: 3.10+ +- **VCS Support**: Git (including AWS CodeCommit), Mercurial (hg), Subversion (svn) +- **License**: MIT -Your donations fund development of new features, testing and support. Your money will go directly to -maintenance and development of the project. If you are an individual, feel free to give whatever -feels right for the value you get out of the project. +## Links & Resources -See donation options at . +- **Documentation**: [libvcs.git-pull.com](https://libvcs.git-pull.com) +- **Source Code**: [github.com/vcs-python/libvcs](https://github.com/vcs-python/libvcs) +- **Issue Tracker**: [GitHub Issues](https://github.com/vcs-python/libvcs/issues) +- **Changelog**: [History](https://libvcs.git-pull.com/history.html) +- **PyPI**: [pypi.org/project/libvcs](https://pypi.org/project/libvcs/) -## More information +## Support -- Python support: 3.9+, pypy -- VCS supported: git(1), svn(1), hg(1) -- Source: -- Docs: -- Changelog: -- API: - - [`libvcs.cmd`](https://libvcs.git-pull.com/cmd/): Commands - - [`libvcs.projects`](https://libvcs.git-pull.com/projects/): High-level synchronization commands -- Issues: -- Test Coverage: -- pypi: -- Open Hub: -- License: [MIT](https://opensource.org/licenses/MIT). +Your donations fund development of new features, testing, and support. -[![Docs](https://github.com/vcs-python/libvcs/workflows/docs/badge.svg)](https://libvcs.git-pull.com/) -[![Build Status](https://github.com/vcs-python/libvcs/workflows/tests/badge.svg)](https://github.com/vcs-python/libvcs/actions?query=workflow%3A%22tests%22) +- [Donation Options](https://tony.sh/support.html) \ No newline at end of file diff --git a/conftest.py b/conftest.py new file mode 100644 index 000000000..6a3efb34a --- /dev/null +++ b/conftest.py @@ -0,0 +1,48 @@ +"""Conftest.py (root-level). + +We keep this in root pytest fixtures in pytest's doctest plugin to be available, as well +as avoiding conftest.py from being included in the wheel, in addition to pytest_plugin +for pytester only being available via the root directory. + +See "pytest_plugins in non-top-level conftest files" in +https://docs.pytest.org/en/stable/deprecations.html +""" + +from __future__ import annotations + +import typing as t + +import pytest + +if t.TYPE_CHECKING: + import pathlib + +pytest_plugins = ["pytester"] + + +@pytest.fixture(autouse=True) +def add_doctest_fixtures( + request: pytest.FixtureRequest, + doctest_namespace: dict[str, t.Any], +) -> None: + """Configure doctest fixtures for pytest-doctest.""" + from _pytest.doctest import DoctestItem + + if isinstance(request._pyfuncitem, DoctestItem): + request.getfixturevalue("add_doctest_fixtures") + request.getfixturevalue("set_home") + + +@pytest.fixture(autouse=True) +def cwd_default(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None: + """Configure current directory for pytest tests.""" + monkeypatch.chdir(tmp_path) + + +@pytest.fixture(autouse=True) +def setup( + request: pytest.FixtureRequest, + gitconfig: pathlib.Path, + set_home: pathlib.Path, +) -> None: + """Configure test fixtures for pytest.""" diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index e6dd741ab..000000000 --- a/docs/Makefile +++ /dev/null @@ -1,186 +0,0 @@ -# Makefile for Sphinx documentation -# -SHELL := /bin/bash -HTTP_PORT = 8064 -WATCH_FILES= find .. -type f -not -path '*/\.*' | grep -i '.*[.]\(rst\|md\)\$\|.*[.]py\$\|CHANGES\|TODO\|.*conf\.py' 2> /dev/null - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = poetry run sphinx-build -PAPER = -BUILDDIR = _build - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . - -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext - -help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - -clean: - -rm -rf $(BUILDDIR)/* - -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/libvcs.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/libvcs.qhc" - -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/libvcs" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/libvcs" - @echo "# devhelp" - -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." - -redirects: - $(SPHINXBUILD) -b rediraffewritediff $(ALLSPHINXOPTS) $(BUILDDIR)/redirect - @echo - @echo "Build finished. The redirects are in rediraffe_redirects." - -checkbuild: - rm -rf $(BUILDDIR) - $(SPHINXBUILD) -n -q ./ $(BUILDDIR) - -watch: - if command -v entr > /dev/null; then ${WATCH_FILES} | entr -c $(MAKE) html; else $(MAKE) html; fi - -serve: - @echo '==============================================================' - @echo - @echo 'docs server running at http://localhost:${HTTP_PORT}/' - @echo - @echo '==============================================================' - poetry run python -m http.server ${HTTP_PORT} --directory _build/html - -dev: - $(MAKE) -j watch serve - -start: - poetry run sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) --port ${HTTP_PORT} $(O) - -design: - # This adds additional watch directories (for _static file changes) and disable incremental builds - poetry run sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) --port ${HTTP_PORT} --watch "." -a $(O) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index aefd33b4c..3bf24f5ef 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -1,25 +1,23 @@ h2 { - font-size: 2rem; margin-bottom: 1.25rem; margin-top: 1.25rem; scroll-margin-top: 0.5rem; } h3 { - font-size: 1.5rem; margin-bottom: 1.25rem; margin-top: 1.25rem; scroll-margin-top: 0.5rem; } h4 { - font-size: 1.25rem; margin-bottom: 1.25rem; scroll-margin-top: 0.5rem; } .sidebar-tree p.indented-block { - padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal) 0 var(--sidebar-item-spacing-horizontal); + padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal) 0 + var(--sidebar-item-spacing-horizontal); margin-bottom: 0; } @@ -28,13 +26,12 @@ h4 { display: block; } -.sidebar-tree p.indented-block > :not(.project-name) { - font-size: var(--toc-font-size); -} - .sidebar-tree p.indented-block .project-name { font-size: var(--sidebar-item-font-size); font-weight: bold; margin-right: calc(var(--sidebar-item-spacing-horizontal) / 2.5); } +.sidebar-tree .active { + font-weight: bold; +} diff --git a/docs/_static/img/icons/icon-128x128.png b/docs/_static/img/icons/icon-128x128.png index 159eca80c..1f571dcea 100644 Binary files a/docs/_static/img/icons/icon-128x128.png and b/docs/_static/img/icons/icon-128x128.png differ diff --git a/docs/_static/img/icons/icon-144x144.png b/docs/_static/img/icons/icon-144x144.png index 265b4687a..96c5ac7a9 100644 Binary files a/docs/_static/img/icons/icon-144x144.png and b/docs/_static/img/icons/icon-144x144.png differ diff --git a/docs/_static/img/icons/icon-152x152.png b/docs/_static/img/icons/icon-152x152.png index a6f8432d3..710113528 100644 Binary files a/docs/_static/img/icons/icon-152x152.png and b/docs/_static/img/icons/icon-152x152.png differ diff --git a/docs/_static/img/icons/icon-192x192.png b/docs/_static/img/icons/icon-192x192.png index c14d2b73d..68802e72f 100644 Binary files a/docs/_static/img/icons/icon-192x192.png and b/docs/_static/img/icons/icon-192x192.png differ diff --git a/docs/_static/img/icons/icon-384x384.png b/docs/_static/img/icons/icon-384x384.png index 5cbe4cf53..a1f4ca3a3 100644 Binary files a/docs/_static/img/icons/icon-384x384.png and b/docs/_static/img/icons/icon-384x384.png differ diff --git a/docs/_static/img/icons/icon-512x512.png b/docs/_static/img/icons/icon-512x512.png index 9069f7f2e..3bbccb202 100644 Binary files a/docs/_static/img/icons/icon-512x512.png and b/docs/_static/img/icons/icon-512x512.png differ diff --git a/docs/_static/img/icons/icon-72x72.png b/docs/_static/img/icons/icon-72x72.png index b6f0bd656..17236cc38 100644 Binary files a/docs/_static/img/icons/icon-72x72.png and b/docs/_static/img/icons/icon-72x72.png differ diff --git a/docs/_static/img/icons/icon-96x96.png b/docs/_static/img/icons/icon-96x96.png index 7585b96eb..850eae213 100644 Binary files a/docs/_static/img/icons/icon-96x96.png and b/docs/_static/img/icons/icon-96x96.png differ diff --git a/docs/_static/img/libvcs-dark.svg b/docs/_static/img/libvcs-dark.svg new file mode 100644 index 000000000..5be4cd149 --- /dev/null +++ b/docs/_static/img/libvcs-dark.svg @@ -0,0 +1,201 @@ + + + + + logo (dark) + + + + + + + + + + + + + + + + + + Circle object (shape) + + + Gear object (Group) + + Gear Shadow object (Shape) + + + + Gear object (Shape) + + + + + Arrow 1 object (Group) + + Arrow 1 Shadow object (Shape) + + + Arrow 1 object (Shape) + + + + Arrow 2 object (Group) + + Arrow 2 Shadow object (Shape) + + + Arrow 2 object (Shape) + + + + + + + logo (dark) + + + + diff --git a/docs/_static/img/libvcs.svg b/docs/_static/img/libvcs.svg index 733305ed9..cb071e705 100644 --- a/docs/_static/img/libvcs.svg +++ b/docs/_static/img/libvcs.svg @@ -1 +1,186 @@ -libvcs \ No newline at end of file + + + + + libvcs + + + + + + + + + + + + + + Circle object (shape) + + + Gear object (Group) + + Gear Shadow object (Shape) + + + + Gear object (Shape) + + + + + Arrow 1 object (Group) + + Arrow 1 Shadow object (Shape) + + + Arrow 1 object (Shape) + + + + Arrow 2 object (Group) + + Arrow 2 Shadow object (Shape) + + + Arrow 2 object (Shape) + + + + + + + libvcs + + + + diff --git a/docs/_templates/sidebar/projects.html b/docs/_templates/sidebar/projects.html index 7e9d6780d..97420c1ad 100644 --- a/docs/_templates/sidebar/projects.html +++ b/docs/_templates/sidebar/projects.html @@ -1,7 +1,7 @@ - ``` ```{toctree} @@ -9,8 +11,11 @@ :hidden: quickstart +topics/index +url/index cmd/index -projects/index +sync/index +pytest-plugin ``` ```{toctree} @@ -18,7 +23,9 @@ projects/index :hidden: contributing/index +internals/index history +migration GitHub ``` diff --git a/docs/internals/dataclasses.md b/docs/internals/dataclasses.md new file mode 100644 index 000000000..90290e8e7 --- /dev/null +++ b/docs/internals/dataclasses.md @@ -0,0 +1,8 @@ +# Dataclass helpers - `libvcs._internal.dataclasses` + +```{eval-rst} +.. automodule:: libvcs._internal.dataclasses + :members: + :special-members: + +``` diff --git a/docs/internals/exc.md b/docs/internals/exc.md new file mode 100644 index 000000000..9b49ad639 --- /dev/null +++ b/docs/internals/exc.md @@ -0,0 +1,8 @@ +# Exceptions - `libvcs.exc` + +```{eval-rst} +.. automodule:: libvcs.exc + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/index.md b/docs/internals/index.md new file mode 100644 index 000000000..fb6159812 --- /dev/null +++ b/docs/internals/index.md @@ -0,0 +1,19 @@ +(internals)= + +# Internals + +:::{warning} +Be careful with these! Internal APIs are **not** covered by version policies. They can break or be removed between minor versions! + +If you need an internal API stabilized please [file an issue](https://github.com/vcs-python/libvcs/issues). +::: + +```{toctree} +exc +types +dataclasses +query_list +run +subprocess +shortcuts +``` diff --git a/docs/internals/query_list.md b/docs/internals/query_list.md new file mode 100644 index 000000000..d68bc59a8 --- /dev/null +++ b/docs/internals/query_list.md @@ -0,0 +1,75 @@ +# List querying - `libvcs._internal.query_list` + +`QueryList` is the backbone of the Manager/Cmd pattern. Every `ls()` method in +libvcs returns a `QueryList`, enabling chainable filtering on the results. + +## How It's Used + +All Manager classes return `QueryList` from their `ls()` methods: + +```python +from libvcs.cmd.git import Git + +git = Git(path='/path/to/repo') + +# Each ls() returns a QueryList +branches = git.branches.ls() # QueryList[GitBranchCmd] +tags = git.tags.ls() # QueryList[GitTagCmd] +remotes = git.remotes.ls() # QueryList[GitRemoteCmd] +stashes = git.stashes.ls() # QueryList[GitStashEntryCmd] +worktrees = git.worktrees.ls() # QueryList[GitWorktreeCmd] +``` + +## Filtering + +`QueryList` extends Python's built-in `list` with Django-style lookups: + +```python +# Exact match +branches.filter(name='main') + +# Case-insensitive contains +branches.filter(name__icontains='feature') + +# Nested attribute access +branches.filter(commit__sha__startswith='abc123') +``` + +### Available Lookups + +| Lookup | Description | +|--------|-------------| +| `exact` | Exact match (default) | +| `iexact` | Case-insensitive exact match | +| `contains` | Substring match | +| `icontains` | Case-insensitive substring | +| `startswith` | Prefix match | +| `istartswith` | Case-insensitive prefix | +| `endswith` | Suffix match | +| `iendswith` | Case-insensitive suffix | +| `in` | Value in list | +| `nin` | Value not in list | +| `regex` | Regular expression match | +| `iregex` | Case-insensitive regex | + +### Chaining + +Filters can be chained and combined: + +```python +# Multiple conditions (AND) +branches.filter(name__startswith='feature', is_remote=False) + +# Get single result +branches.get(name='main') + +# Chain filters +branches.filter(is_remote=True).filter(name__contains='release') +``` + +## API Reference + +```{eval-rst} +.. automodule:: libvcs._internal.query_list + :members: +``` diff --git a/docs/internals/run.md b/docs/internals/run.md new file mode 100644 index 000000000..b3931d793 --- /dev/null +++ b/docs/internals/run.md @@ -0,0 +1,8 @@ +# Command helpers - `libvcs._internal.run` + +```{eval-rst} +.. automodule:: libvcs._internal.run + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/shortcuts.md b/docs/internals/shortcuts.md new file mode 100644 index 000000000..7892dbe7d --- /dev/null +++ b/docs/internals/shortcuts.md @@ -0,0 +1,8 @@ +# Shortcuts - `libvcs._internal.shortcuts` + +```{eval-rst} +.. automodule:: libvcs._internal.shortcuts + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/subprocess.md b/docs/internals/subprocess.md new file mode 100644 index 000000000..c5c15d92b --- /dev/null +++ b/docs/internals/subprocess.md @@ -0,0 +1,6 @@ +# SubprocessCommand - `libvcs._internal.subprocess` + +```{eval-rst} +.. automodule:: libvcs._internal.subprocess + :members: +``` diff --git a/docs/internals/types.md b/docs/internals/types.md new file mode 100644 index 000000000..f79421b21 --- /dev/null +++ b/docs/internals/types.md @@ -0,0 +1,8 @@ +# Typing utilities - `libvcs._internal.types` + +```{eval-rst} +.. automodule:: libvcs._internal.types + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/justfile b/docs/justfile new file mode 100644 index 000000000..8866d47da --- /dev/null +++ b/docs/justfile @@ -0,0 +1,206 @@ +# justfile for libvcs documentation +# https://just.systems/ + +set shell := ["bash", "-uc"] + +# Configuration +http_port := "8064" +builddir := "_build" +sphinxopts := "" +sphinxbuild := "uv run sphinx-build" +sourcedir := "." + +# File patterns for watching +watch_files := "find .. -type f -not -path '*/\\.*' | grep -i '.*[.]\\(rst\\|md\\)$\\|.*[.]py$\\|CHANGES\\|TODO\\|.*conf\\.py' 2> /dev/null" + +# Sphinx options +allsphinxopts := "-d " + builddir + "/doctrees " + sphinxopts + " ." + +# List all available commands +default: + @just --list + +# Build HTML documentation +[group: 'build'] +html: + {{ sphinxbuild }} -b html {{ allsphinxopts }} {{ builddir }}/html + @echo "" + @echo "Build finished. The HTML pages are in {{ builddir }}/html." + +# Build directory HTML files +[group: 'build'] +dirhtml: + {{ sphinxbuild }} -b dirhtml {{ allsphinxopts }} {{ builddir }}/dirhtml + @echo "" + @echo "Build finished. The HTML pages are in {{ builddir }}/dirhtml." + +# Build single HTML file +[group: 'build'] +singlehtml: + {{ sphinxbuild }} -b singlehtml {{ allsphinxopts }} {{ builddir }}/singlehtml + @echo "" + @echo "Build finished. The HTML page is in {{ builddir }}/singlehtml." + +# Build EPUB +[group: 'build'] +epub: + {{ sphinxbuild }} -b epub {{ allsphinxopts }} {{ builddir }}/epub + @echo "" + @echo "Build finished. The epub file is in {{ builddir }}/epub." + +# Build LaTeX files +[group: 'build'] +latex: + {{ sphinxbuild }} -b latex {{ allsphinxopts }} {{ builddir }}/latex + @echo "" + @echo "Build finished; the LaTeX files are in {{ builddir }}/latex." + +# Build PDF via LaTeX +[group: 'build'] +latexpdf: + {{ sphinxbuild }} -b latex {{ allsphinxopts }} {{ builddir }}/latex + @echo "Running LaTeX files through pdflatex..." + make -C {{ builddir }}/latex all-pdf + @echo "pdflatex finished; the PDF files are in {{ builddir }}/latex." + +# Build plain text files +[group: 'build'] +text: + {{ sphinxbuild }} -b text {{ allsphinxopts }} {{ builddir }}/text + @echo "" + @echo "Build finished. The text files are in {{ builddir }}/text." + +# Build man pages +[group: 'build'] +man: + {{ sphinxbuild }} -b man {{ allsphinxopts }} {{ builddir }}/man + @echo "" + @echo "Build finished. The manual pages are in {{ builddir }}/man." + +# Build JSON output +[group: 'build'] +json: + {{ sphinxbuild }} -b json {{ allsphinxopts }} {{ builddir }}/json + @echo "" + @echo "Build finished; now you can process the JSON files." + +# Clean build directory +[group: 'misc'] +[confirm] +clean: + rm -rf {{ builddir }}/* + +# Build HTML help files +[group: 'misc'] +htmlhelp: + {{ sphinxbuild }} -b htmlhelp {{ allsphinxopts }} {{ builddir }}/htmlhelp + @echo "" + @echo "Build finished; now you can run HTML Help Workshop with the .hhp project file in {{ builddir }}/htmlhelp." + +# Build Qt help files +[group: 'misc'] +qthelp: + {{ sphinxbuild }} -b qthelp {{ allsphinxopts }} {{ builddir }}/qthelp + @echo "" + @echo "Build finished; now you can run 'qcollectiongenerator' with the .qhcp project file in {{ builddir }}/qthelp." + +# Build Devhelp files +[group: 'misc'] +devhelp: + {{ sphinxbuild }} -b devhelp {{ allsphinxopts }} {{ builddir }}/devhelp + @echo "" + @echo "Build finished." + +# Build Texinfo files +[group: 'misc'] +texinfo: + {{ sphinxbuild }} -b texinfo {{ allsphinxopts }} {{ builddir }}/texinfo + @echo "" + @echo "Build finished. The Texinfo files are in {{ builddir }}/texinfo." + +# Build Info files from Texinfo +[group: 'misc'] +info: + {{ sphinxbuild }} -b texinfo {{ allsphinxopts }} {{ builddir }}/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C {{ builddir }}/texinfo info + @echo "makeinfo finished; the Info files are in {{ builddir }}/texinfo." + +# Build gettext catalogs +[group: 'misc'] +gettext: + {{ sphinxbuild }} -b gettext {{ sphinxopts }} . {{ builddir }}/locale + @echo "" + @echo "Build finished. The message catalogs are in {{ builddir }}/locale." + +# Check all external links +[group: 'validate'] +linkcheck: + {{ sphinxbuild }} -b linkcheck {{ allsphinxopts }} {{ builddir }}/linkcheck + @echo "" + @echo "Link check complete; look for any errors in the above output or in {{ builddir }}/linkcheck/output.txt." + +# Run doctests embedded in documentation +[group: 'validate'] +doctest: + {{ sphinxbuild }} -b doctest {{ allsphinxopts }} {{ builddir }}/doctest + @echo "Testing of doctests in the sources finished, look at the results in {{ builddir }}/doctest/output.txt." + +# Check build from scratch +[group: 'validate'] +checkbuild: + rm -rf {{ builddir }} + {{ sphinxbuild }} -n -q ./ {{ builddir }} + +# Build redirects configuration +[group: 'misc'] +redirects: + {{ sphinxbuild }} -b rediraffewritediff {{ allsphinxopts }} {{ builddir }}/redirects + @echo "" + @echo "Build finished. The redirects are in rediraffe_redirects." + +# Show changes overview +[group: 'misc'] +changes: + {{ sphinxbuild }} -b changes {{ allsphinxopts }} {{ builddir }}/changes + @echo "" + @echo "The overview file is in {{ builddir }}/changes." + +# Watch files and rebuild on change +[group: 'dev'] +watch: + #!/usr/bin/env bash + set -euo pipefail + if command -v entr > /dev/null; then + ${{ watch_files }} | entr -c just html + else + just html + fi + +# Serve documentation via Python http.server +[group: 'dev'] +serve: + @echo '==============================================================' + @echo '' + @echo 'docs server running at http://localhost:{{ http_port }}/' + @echo '' + @echo '==============================================================' + python -m http.server {{ http_port }} --directory {{ builddir }}/html + +# Watch and serve simultaneously +[group: 'dev'] +dev: + #!/usr/bin/env bash + set -euo pipefail + just watch & + just serve + +# Start sphinx-autobuild server +[group: 'dev'] +start: + uv run sphinx-autobuild "{{ sourcedir }}" "{{ builddir }}" {{ sphinxopts }} --port {{ http_port }} + +# Design mode: watch static files and disable incremental builds +[group: 'dev'] +design: + uv run sphinx-autobuild "{{ sourcedir }}" "{{ builddir }}" {{ sphinxopts }} --port {{ http_port }} --watch "." -a diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 000000000..7bd3f4664 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,9 @@ +(migration)= + +```{currentmodule} libtmux + +``` + +```{include} ../MIGRATION + +``` diff --git a/docs/projects/index.md b/docs/projects/index.md deleted file mode 100644 index c086526c9..000000000 --- a/docs/projects/index.md +++ /dev/null @@ -1,50 +0,0 @@ -(projects)= - -# `libvcs.projects` - -Compare to: -[`fabtools.require.git`](https://fabtools.readthedocs.io/en/0.19.0/api/require/git.html), -[`salt.projects.git`](https://docs.saltproject.io/en/latest/ref/projects/all/salt.projects.git.html), -[`ansible.builtin.git`](https://docs.ansible.com/ansible/latest/collections/ansible/builtin/git_module.html) - -:::{warning} - -All APIs are considered experimental and subject to break pre-1.0. They can and will break between -versions. - -::: - -```{toctree} -:caption: API - -git -hg -svn -base -``` - -## Create from VCS url - -Helper methods are available in `libvcs.shortcuts` which can return a repo object from a single -entry-point. - -```{eval-rst} -.. autoapimodule:: libvcs.shortcuts - :members: -``` - -See examples below of git, mercurial, and subversion. - -## Constants - -```{eval-rst} -.. autoapimodule:: libvcs.projects.constants - :members: -``` - -## Utility stuff - -```{eval-rst} -.. autoapimodule:: libvcs.cmd.core - :members: -``` diff --git a/docs/pytest-plugin.md b/docs/pytest-plugin.md new file mode 100644 index 000000000..0a69035ef --- /dev/null +++ b/docs/pytest-plugin.md @@ -0,0 +1,147 @@ +(pytest_plugin)= + +# `pytest` Plugin + +With libvcs's pytest plugin for [pytest], you can easily create Git, SVN, and Mercurial repositories on the fly. + +```{seealso} Are you using libvcs? + +Looking for more flexibility, correctness, or power? Need different defaults? [Connect with us] on GitHub. We'd love to hear about your use case—APIs won't be stabilized until we're confident everything meets expectations. + +[connect with us]: https://github.com/vcs-python/libvcs/discussions +``` + +[pytest]: https://docs.pytest.org/ + +## Usage + +Install `libvcs` using your preferred Python package manager: + +```console +$ pip install libvcs +``` + +Pytest will automatically detect the plugin, and its fixtures will be available. + +## Fixtures + +This pytest plugin works by providing [pytest fixtures](https://docs.pytest.org/en/stable/how-to/fixtures.html). The plugin's fixtures ensure that a fresh Git, Subversion, or Mercurial repository is available for each test. It utilizes [session-scoped fixtures] to cache initial repositories, improving performance across tests. + +[session-scoped fixtures]: https://docs.pytest.org/en/8.3.x/how-to/fixtures.html#fixture-scopes + +(recommended-fixtures)= + +## Recommended Fixtures + +When the plugin is enabled and `pytest` is run, these overridable fixtures are automatically used: + +- Create temporary test directories for: + - `/home/` ({func}`home_path`) + - `/home/${user}` ({func}`user_path`) +- Set the home directory: + - Patch `$HOME` to point to {func}`user_path` using ({func}`set_home`) +- Create configuration files: + - `.gitconfig` via {func}`gitconfig` + - `.hgrc` via {func}`hgconfig` +- Set default VCS configurations: + - Use {func}`hgconfig` for [`HGRCPATH`] via {func}`set_hgconfig` + - Use {func}`gitconfig` for [`GIT_CONFIG`] via {func}`set_gitconfig` +- Set default commit names and emails: + - Name: {func}`vcs_name` + - Email: {func}`vcs_email` + - User (e.g. _`user `_): {func}`vcs_user` + - For git only: {func}`git_commit_envvars` + +These ensure that repositories can be cloned and created without unnecessary warnings. + +[`HGRCPATH`]: https://www.mercurial-scm.org/doc/hg.1.html#:~:text=UNIX%2Dlike%20environments.-,HGRCPATH,-If%20not%20set +[`GIT_CONFIG`]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-GITCONFIG + +## Bootstrapping pytest in `conftest.py` + +To configure the above fixtures with `autouse=True`, add them to your `conftest.py` file or test file, depending on the desired scope. + +_Why aren't these fixtures added automatically by the plugin?_ This design choice promotes explicitness, adhering to best practices for pytest plugins and Python packages. + +### Setting a Temporary Home Directory + +To set a temporary home directory, use the {func}`set_home` fixture with `autouse=True`: + +```python +import pytest + +@pytest.fixture(autouse=True) +def setup(set_home: None): + pass +``` + +### VCS Configuration + +#### Git + +You can override the default author used in {func}`git_remote_repo` and other +fixtures via {func}`vcs_name`, {func}`vcs_email`, and {func}`vcs_user`: + +``` +@pytest.fixture(scope="session") +def vcs_name() -> str: + return "My custom name" +``` + +Use the {func}`set_gitconfig` fixture with `autouse=True`: + +```python +import pytest + +@pytest.fixture(autouse=True) +def setup(set_gitconfig: None): + pass +``` + +Sometimes, `set_getconfig` via `GIT_CONFIG` doesn't apply as expected. For those +cases, you can use {func}`git_commit_envvars`: + +```python +import pytest + +@pytest.fixture +def my_git_repo( + create_git_remote_repo: CreateRepoPytestFixtureFn, + gitconfig: pathlib.Path, + git_commit_envvars: "_ENV", +) -> pathlib.Path: + """Copy the session-scoped Git repository to a temporary directory.""" + repo_path = create_git_remote_repo() + git_remote_repo_single_commit_post_init( + remote_repo_path=repo_path, + env=git_commit_envvars, + ) + return repo_path +``` + +#### Mercurial + +Use the {func}`set_hgconfig` fixture with `autouse=True`: + +```python +import pytest + +@pytest.fixture(autouse=True) +def setup(set_hgconfig: None): + pass +``` + +## Examples + +For usage examples, refer to libvcs's own [tests/](https://github.com/vcs-python/libvcs/tree/master/tests). + +## API Reference + +```{eval-rst} +.. automodule:: libvcs.pytest_plugin + :members: + :inherited-members: + :private-members: + :show-inheritance: + :member-order: bysource +``` diff --git a/docs/quickstart.md b/docs/quickstart.md index 063052809..109beb5df 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -20,9 +20,9 @@ $ pip install --user --upgrade libvcs ### Developmental releases -New versions of libvcs are published to PyPI as alpha, beta, or release candidates. In their -versions you will see notfication like `a1`, `b1`, and `rc1`, respectively. `1.10.0b4` would mean -the 4th beta release of `1.10.0` before general availability. +New versions of libvcs are published to PyPI as alpha, beta, or release candidates. +In their versions you will see notification like `a1`, `b1`, and `rc1`, respectively. +`1.10.0b4` would mean the 4th beta release of `1.10.0` before general availability. - [pip]\: @@ -30,6 +30,12 @@ the 4th beta release of `1.10.0` before general availability. $ pip install --user --upgrade --pre libvcs ``` +- [uv]\: + + ```console + $ uv add libvcs --prerelease allow + ``` + via trunk (can break easily): - [pip]\: @@ -38,4 +44,57 @@ via trunk (can break easily): $ pip install --user -e git+https://github.com/vcs-python/libvcs.git#egg=libvcs ``` +- [uv]\: + + ```console + $ uv add "git+https://github.com/vcs-python/libvcs.git" + ``` + [pip]: https://pip.pypa.io/en/stable/ +[uv]: https://docs.astral.sh/uv/ + +## Basic Usage + +### Commands + +Run git commands directly using {class}`~libvcs.cmd.git.Git`: + +```python +from libvcs.cmd.git import Git + +git = Git(path='/path/to/repo') + +# Initialize a new repository +git.init() + +# Clone a repository +git.clone(url='https://github.com/vcs-python/libvcs.git') + +# Check status +git.status() +``` + +### Subcommand Managers + +Work with branches, tags, remotes, and more using the Manager/Cmd pattern: + +```python +from libvcs.cmd.git import Git + +git = Git(path='/path/to/repo') + +# List and filter branches +branches = git.branches.ls() +remote_branches = git.branches.ls(remotes=True) + +# Create and manage tags +git.tags.create(name='v1.0.0', message='Release 1.0') +tag = git.tags.get(tag_name='v1.0.0') + +# Work with remotes +remotes = git.remotes.ls() +origin = git.remotes.get(remote_name='origin') +origin.prune() +``` + +See {doc}`/cmd/git/index` for the full API reference. diff --git a/docs/redirects.txt b/docs/redirects.txt index 67cd2ae85..f8b60f687 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -4,3 +4,20 @@ "api/hg.md" "projects/hg.md" "api/svn.md" "projects/svn.md" "developing.md" "contributing/index.md" +"contributing/internals.md" "contributing/internals/index.md" +"contributing/internals/exc.md" "internals/exc.md" +"contributing/internals/index.md" "internals/index.md" +"contributing/internals/query_list.md" "internals/query_list.md" +"contributing/internals/subprocess.md" "internals/subprocess.md" +"contributing/internals/types.md" "internals/types.md" +"parse/index.md" "url/index.md" +"parse/base.md" "url/base.md" +"parse/git.md" "url/git.md" +"parse/hg.md" "url/hg.md" +"parse/svn.md" "url/svn.md" +"projects/index.md" "sync/index.md" +"projects/base.md" "sync/base.md" +"projects/git.md" "sync/git.md" +"projects/hg.md" "sync/hg.md" +"projects/svn.md" "sync/svn.md" +"cmd/git.md" "cmd/git/index.md" diff --git a/docs/projects/base.md b/docs/sync/base.md similarity index 64% rename from docs/projects/base.md rename to docs/sync/base.md index adc2e8b49..d74ca5fa3 100644 --- a/docs/projects/base.md +++ b/docs/sync/base.md @@ -1,11 +1,11 @@ -# `libvcs.projects.base` +# `libvcs.sync.base` Base objects / classes for projects. -Adding your own VCS / Extending libvcs can be done through subclassing `BaseProject`. +Adding your own VCS / Extending libvcs can be done through subclassing `BaseSync`. ```{eval-rst} -.. autoapimodule:: libvcs.projects.base +.. automodule:: libvcs.sync.base :members: :show-inheritance: ``` diff --git a/docs/projects/git.md b/docs/sync/git.md similarity index 64% rename from docs/projects/git.md rename to docs/sync/git.md index 7b97816da..6faa4aefb 100644 --- a/docs/projects/git.md +++ b/docs/sync/git.md @@ -1,14 +1,14 @@ -# `libvcs.projects.git` +# `libvcs.sync.git` For `git(1)`. Compare to: [`fabtools.require.git`](https://fabtools.readthedocs.io/en/0.19.0/api/require/git.html), -[`salt.projects.git`](https://docs.saltproject.io/en/latest/ref/projects/all/salt.projects.git.html), +[`salt.states.git`](https://docs.saltproject.io/en/latest/ref/states/all/salt.states.git.html), [`ansible.builtin.git`](https://docs.ansible.com/ansible/latest/collections/ansible/builtin/git_module.html) ```{eval-rst} -.. autoapimodule:: libvcs.projects.git +.. automodule:: libvcs.sync.git :members: :show-inheritance: :undoc-members: diff --git a/docs/projects/hg.md b/docs/sync/hg.md similarity index 62% rename from docs/projects/hg.md rename to docs/sync/hg.md index 3c3464245..b7f441742 100644 --- a/docs/projects/hg.md +++ b/docs/sync/hg.md @@ -1,9 +1,9 @@ -# `libvcs.projects.hg` +# `libvcs.sync.hg` For mercurial, aka `hg(1)`. ```{eval-rst} -.. autoapimodule:: libvcs.projects.hg +.. automodule:: libvcs.sync.hg :members: :show-inheritance: :undoc-members: diff --git a/docs/sync/index.md b/docs/sync/index.md new file mode 100644 index 000000000..3365aada8 --- /dev/null +++ b/docs/sync/index.md @@ -0,0 +1,31 @@ +(projects)= + +# Sync - `libvcs.sync` + +Compare to: +[`fabtools.require.git`](https://fabtools.readthedocs.io/en/0.19.0/api/require/git.html), +[`salt.states.git`](https://docs.saltproject.io/en/latest/ref/states/all/salt.states.git.html), +[`ansible.builtin.git`](https://docs.ansible.com/ansible/latest/collections/ansible/builtin/git_module.html) + +:::{warning} + +All APIs are considered experimental and subject to break pre-1.0. They can and will break between +versions. + +::: + +```{toctree} +:caption: API + +git +hg +svn +base +``` + +## Constants + +```{eval-rst} +.. automodule:: libvcs.sync.constants + :members: +``` diff --git a/docs/projects/svn.md b/docs/sync/svn.md similarity index 62% rename from docs/projects/svn.md rename to docs/sync/svn.md index fd7c242d2..43cfe2871 100644 --- a/docs/projects/svn.md +++ b/docs/sync/svn.md @@ -1,9 +1,9 @@ -# `libvcs.projects.svn` +# `libvcs.sync.svn` For subversion, aka `svn(1)` ```{eval-rst} -.. autoapimodule:: libvcs.projects.svn +.. automodule:: libvcs.sync.svn :members: :show-inheritance: :undoc-members: diff --git a/docs/topics/filtering.md b/docs/topics/filtering.md new file mode 100644 index 000000000..dadac9947 --- /dev/null +++ b/docs/topics/filtering.md @@ -0,0 +1,160 @@ +(querylist-filtering)= + +# QueryList Filtering + +libvcs uses `QueryList` to enable Django-style filtering on git entities. +Every `ls()` method returns a `QueryList`, letting you filter branches, tags, +remotes, and more with a fluent, chainable API. + +## Basic Filtering + +The `filter()` method accepts keyword arguments with optional lookup suffixes: + +```python +>>> from libvcs.cmd.git import Git +>>> git = Git(path=example_git_repo.path) +>>> branches = git.branches.ls() +>>> len(branches) >= 1 # At least master branch +True +``` + +### Exact Match + +The default lookup is `exact`: + +```python +>>> from libvcs.cmd.git import Git +>>> git = Git(path=example_git_repo.path) +>>> # These are equivalent +>>> git.branches.ls().filter(branch_name='master') # doctest: +ELLIPSIS +[] +>>> git.branches.ls().filter(branch_name__exact='master') # doctest: +ELLIPSIS +[] +``` + +### Contains and Startswith + +Use suffixes for partial matching: + +```python +>>> from libvcs.cmd.git import Git +>>> git = Git(path=example_git_repo.path) +>>> # Create branches for this example +>>> git.branches.create(branch='feature-docs') +'' +>>> git.branches.create(branch='feature-tests') +'' +>>> git.branches.create(branch='bugfix-typo') +'' +>>> # Branches containing 'feature' +>>> feature_branches = git.branches.ls().filter(branch_name__contains='feature') +>>> len(feature_branches) >= 2 +True +>>> # Branches starting with 'bug' +>>> bugfix_branches = git.branches.ls().filter(branch_name__startswith='bug') +>>> len(bugfix_branches) >= 1 +True +``` + +## Available Lookups + +| Lookup | Description | +|--------|-------------| +| `exact` | Exact match (default) | +| `iexact` | Case-insensitive exact match | +| `contains` | Substring match | +| `icontains` | Case-insensitive substring | +| `startswith` | Prefix match | +| `istartswith` | Case-insensitive prefix | +| `endswith` | Suffix match | +| `iendswith` | Case-insensitive suffix | +| `in` | Value in list | +| `nin` | Value not in list | +| `regex` | Regular expression match | +| `iregex` | Case-insensitive regex | + +## Getting a Single Item + +Use `get()` to retrieve exactly one matching item: + +```python +>>> from libvcs.cmd.git import Git +>>> git = Git(path=example_git_repo.path) +>>> branch = git.branches.get(branch_name='master') +>>> branch.branch_name +'master' +``` + +If no match or multiple matches are found, `get()` raises an exception. + +## Chaining Filters + +Filters can be chained for complex queries: + +```python +>>> from libvcs.cmd.git import Git +>>> git = Git(path=example_git_repo.path) +>>> # Create branches for this example +>>> git.branches.create(branch='feature-login') +'' +>>> git.branches.create(branch='feature-signup') +'' +>>> # Multiple conditions in one filter (AND) +>>> git.branches.ls().filter( +... branch_name__startswith='feature', +... branch_name__endswith='signup' +... ) # doctest: +ELLIPSIS +[] +>>> # Chained filters (also AND) +>>> git.branches.ls().filter( +... branch_name__contains='feature' +... ).filter( +... branch_name__contains='login' +... ) # doctest: +ELLIPSIS +[] +``` + +## Working with Tags + +The same filtering works on tags: + +```python +>>> from libvcs.cmd.git import Git +>>> git = Git(path=example_git_repo.path) +>>> git.tags.create(name='v1.0.0', message='Release 1.0') +'' +>>> git.tags.create(name='v1.1.0', message='Release 1.1') +'' +>>> git.tags.create(name='v2.0.0-beta', message='Beta release') +'' +>>> # Filter tags by version pattern +>>> v1_tags = git.tags.ls().filter(tag_name__startswith='v1') +>>> len(v1_tags) +2 +>>> # Find beta releases +>>> beta_tags = git.tags.ls().filter(tag_name__contains='beta') +>>> len(beta_tags) +1 +``` + +## Regex Filtering + +For complex patterns, use regex lookups: + +```python +>>> from libvcs.cmd.git import Git +>>> git = Git(path=example_git_repo.path) +>>> # Create tags for regex example +>>> git.tags.create(name='v3.0.0', message='Release 3.0') +'' +>>> git.tags.create(name='v3.1.0', message='Release 3.1') +'' +>>> # Match semantic version tags +>>> results = git.tags.ls().filter(tag_name__regex=r'^v\d+\.\d+\.\d+$') +>>> len(results) >= 2 +True +``` + +## API Reference + +See {class}`~libvcs._internal.query_list.QueryList` for the complete API. diff --git a/docs/topics/index.md b/docs/topics/index.md new file mode 100644 index 000000000..4f281b886 --- /dev/null +++ b/docs/topics/index.md @@ -0,0 +1,15 @@ +--- +orphan: true +--- + +# Topics + +Explore libvcs's core functionalities and design patterns at a high level, +with detailed explanations and runnable examples. + +```{toctree} + +traversing_git +filtering +url_parsing +``` diff --git a/docs/topics/traversing_git.md b/docs/topics/traversing_git.md new file mode 100644 index 000000000..01b755714 --- /dev/null +++ b/docs/topics/traversing_git.md @@ -0,0 +1,281 @@ +(traversing-git-repos)= + +# Traversing Git Repos + +libvcs provides **Managers** and **Commands** for intuitively traversing and +navigating entities in a git repository—branches, tags, remotes, stashes, and +more—with ORM-like convenience via {class}`~libvcs._internal.query_list.QueryList`. + +## Overview + +The pattern consists of two types of classes: + +- **Managers** (`git.branches`, `git.tags`, etc.) let you traverse repository + entities intuitively, listing, filtering, and retrieving them with ORM-like + convenience +- **Commands** are contextual ways to run git commands against a specific target + entity (e.g., delete a branch, rename a tag, set a remote's URL) + +``` +Git instance +├── branches: GitBranchManager +│ ├── ls() -> QueryList[GitBranchCmd] +│ ├── get() -> GitBranchCmd +│ └── create() +├── tags: GitTagManager +├── remotes: GitRemoteManager +├── stashes: GitStashManager +├── worktrees: GitWorktreeManager +├── notes: GitNotesManager +├── submodules: GitSubmoduleManager +└── reflog: GitReflogManager +``` + +## Basic Usage + +```python +>>> from libvcs.cmd.git import Git +>>> git = Git(path=example_git_repo.path) +``` + +### Listing Items + +All Manager classes have an `ls()` method that returns a +{class}`~libvcs._internal.query_list.QueryList`: + +```python +>>> from libvcs.cmd.git import Git +>>> git = Git(path=example_git_repo.path) +>>> branches = git.branches.ls() +>>> isinstance(branches, list) +True +>>> tags = git.tags.ls() +>>> remotes = git.remotes.ls() +``` + +### Getting a Single Item + +Use `get()` with filter criteria to retrieve a single item: + +```python +>>> from libvcs.cmd.git import Git +>>> git = Git(path=example_git_repo.path) +>>> git.tags.create(name='v1.0.0', message='Release 1.0') +'' +>>> tag = git.tags.get(tag_name='v1.0.0') +>>> tag.tag_name +'v1.0.0' +``` + +### Creating Items + +Manager classes provide `create()` or `add()` methods: + +```python +>>> from libvcs.cmd.git import Git +>>> git = Git(path=example_git_repo.path) +>>> git.tags.create(name='v2.0.0', message='Release 2.0') +'' +>>> git.branches.create(branch='feature-branch') +'' +``` + +### Per-Entity Operations + +Cmd objects have methods for mutating or inspecting that entity: + +```python +>>> from libvcs.cmd.git import Git +>>> git = Git(path=example_git_repo.path) +>>> git.tags.create(name='v3.0.0', message='Release 3.0') +'' +>>> tag = git.tags.get(tag_name='v3.0.0') +>>> tag.delete() # doctest: +ELLIPSIS +"Deleted tag 'v3.0.0' ..." +>>> git.branches.create(branch='temp-branch') +'' +>>> branch = git.branches.get(branch_name='temp-branch') +>>> branch.delete() # doctest: +ELLIPSIS +'Deleted branch temp-branch ...' +``` + +## Comparison to Raw Commands + +### Before: Parsing Strings + +Without Managers and Commands, you'd parse raw output: + +```python +>>> from libvcs.cmd.git import Git +>>> git = Git(path=example_git_repo.path) +>>> # Raw output requires parsing +>>> raw_output = git.run(['tag', '-l']) +>>> tag_names = [t for t in raw_output.strip().split('\\n') if t] +``` + +### After: Typed Objects + +With Managers and Commands, you get typed objects: + +```python +>>> from libvcs.cmd.git import Git +>>> git = Git(path=example_git_repo.path) +>>> tags = git.tags.ls() +>>> for tag in tags: # doctest: +SKIP +... print(f"{tag.tag_name}") +``` + +## Working with Remotes + +Add and configure remote repositories: + +```python +>>> from libvcs.cmd.git import Git +>>> git = Git(path=example_git_repo.path) +>>> git.remotes.add(name='upstream', url='https://github.com/vcs-python/libvcs.git') +'' +>>> remotes = git.remotes.ls() +>>> len(remotes) >= 1 +True +``` + +Get a remote and update its URL: + +```python +>>> from libvcs.cmd.git import Git +>>> git = Git(path=example_git_repo.path) +>>> git.remotes.add(name='backup', url='https://example.com/old.git') +'' +>>> remote = git.remotes.get(remote_name='backup') +>>> remote.remote_name +'backup' +>>> remote.set_url(url='https://example.com/new.git') +'' +``` + +## Branch Operations + +Beyond creating and deleting, branches support rename and upstream tracking: + +```python +>>> from libvcs.cmd.git import Git +>>> git = Git(path=example_git_repo.path) +>>> git.branches.create(branch='old-name') +'' +>>> branch = git.branches.get(branch_name='old-name') +>>> branch.rename('new-name') # doctest: +ELLIPSIS +'' +``` + +Copy a branch: + +```python +>>> from libvcs.cmd.git import Git +>>> git = Git(path=example_git_repo.path) +>>> git.branches.create(branch='source-branch') +'' +>>> branch = git.branches.get(branch_name='source-branch') +>>> branch.copy('copied-branch') # doctest: +ELLIPSIS +'' +``` + +## Stash Workflow + +Save work in progress and restore it later: + +```python +>>> from libvcs.cmd.git import Git +>>> git = Git(path=example_git_repo.path) +>>> # Push returns message (or "No local changes to save") +>>> git.stashes.push(message='WIP: feature work') # doctest: +ELLIPSIS +'...' +``` + +List and inspect stashes: + +```python +>>> from libvcs.cmd.git import Git +>>> git = Git(path=example_git_repo.path) +>>> stashes = git.stashes.ls() +>>> isinstance(stashes, list) +True +``` + +## Worktree Management + +Create additional working directories for parallel development: + +```python +>>> from libvcs.cmd.git import Git +>>> git = Git(path=example_git_repo.path) +>>> worktrees = git.worktrees.ls() +>>> len(worktrees) >= 1 # Main worktree always exists +True +``` + +## Notes + +Attach metadata to commits: + +```python +>>> from libvcs.cmd.git import Git +>>> git = Git(path=example_git_repo.path) +>>> git.notes.add(message='Reviewed by Alice') +'' +``` + +## Filtering with ls() Parameters + +Manager `ls()` methods accept parameters to narrow results: + +```python +>>> from libvcs.cmd.git import Git +>>> git = Git(path=example_git_repo.path) +>>> # All local branches +>>> local = git.branches.ls() +>>> isinstance(local, list) +True +>>> # Branches merged into HEAD +>>> merged = git.branches.ls(merged='HEAD') +>>> isinstance(merged, list) +True +``` + +## Error Handling + +When `get()` finds no match, it raises `ObjectDoesNotExist`: + +```python +>>> from libvcs.cmd.git import Git +>>> from libvcs._internal.query_list import ObjectDoesNotExist +>>> git = Git(path=example_git_repo.path) +>>> try: +... git.branches.get(branch_name='nonexistent-branch-xyz') +... except ObjectDoesNotExist: +... print('Branch not found') +Branch not found +``` + +## When to Use + +| Use Case | Approach | +|----------|----------| +| List/filter/get entities | Manager class (`git.branches.ls()`) | +| Mutate a specific entity | Cmd class (`branch.delete()`) | +| Run arbitrary git commands | Direct methods (`git.run([...])`) | +| Complex pipelines | Mix of both | + +## Available Managers + +| Manager | Access | Operations | +|---------|--------|------------| +| {class}`~libvcs.cmd.git.GitBranchManager` | `git.branches` | List, create, checkout branches | +| {class}`~libvcs.cmd.git.GitTagManager` | `git.tags` | List, create tags | +| {class}`~libvcs.cmd.git.GitRemoteManager` | `git.remotes` | List, add, configure remotes | +| {class}`~libvcs.cmd.git.GitStashManager` | `git.stashes` | List, push, clear stashes | +| {class}`~libvcs.cmd.git.GitWorktreeManager` | `git.worktrees` | List, add, prune worktrees | +| {class}`~libvcs.cmd.git.GitNotesManager` | `git.notes` | List, add, prune notes | +| {class}`~libvcs.cmd.git.GitSubmoduleManager` | `git.submodules` | List, add, sync submodules | +| {class}`~libvcs.cmd.git.GitReflogManager` | `git.reflog` | List, expire reflog entries | + +See {doc}`/cmd/git/index` for the complete API reference. diff --git a/docs/topics/url_parsing.md b/docs/topics/url_parsing.md new file mode 100644 index 000000000..7dc459321 --- /dev/null +++ b/docs/topics/url_parsing.md @@ -0,0 +1,125 @@ +(url-parsing)= + +# URL Parsing + +libvcs provides typed URL parsing for git, Mercurial, and Subversion repositories. +Think of it as `urllib.parse` for VCS URLs—detecting URL types, extracting components, +and converting between formats. + +## Detecting URL Types + +Use `is_valid()` to check if a string is a valid VCS URL: + +```python +>>> from libvcs.url.git import GitURL +>>> GitURL.is_valid(url='https://github.com/vcs-python/libvcs.git') +True +>>> GitURL.is_valid(url='git@github.com:vcs-python/libvcs.git') +True +>>> GitURL.is_valid(url='not-a-url') +False +``` + +## Parsing URLs + +Create a URL object to extract components: + +```python +>>> from libvcs.url.git import GitURL +>>> url = GitURL(url='git@github.com:vcs-python/libvcs.git') +>>> url.hostname +'github.com' +>>> url.path +'vcs-python/libvcs' +>>> url.suffix +'.git' +``` + +### HTTPS URLs + +```python +>>> from libvcs.url.git import GitURL +>>> url = GitURL(url='https://github.com/vcs-python/libvcs.git') +>>> url.scheme +'https' +>>> url.hostname +'github.com' +>>> url.path +'vcs-python/libvcs' +``` + +### SCP-style URLs + +Git's SCP-style syntax (`user@host:path`) is also supported: + +```python +>>> from libvcs.url.git import GitURL +>>> url = GitURL(url='git@github.com:vcs-python/libvcs.git') +>>> url.user +'git' +>>> url.hostname +'github.com' +``` + +## Converting URL Formats + +Use `to_url()` to export a URL in a specific format: + +```python +>>> from libvcs.url.git import GitURL +>>> url = GitURL(url='git@github.com:vcs-python/libvcs.git') +>>> url.to_url() +'git@github.com:vcs-python/libvcs.git' +``` + +## Pip-style URLs + +libvcs handles pip-style VCS URLs with branch/tag specifiers: + +```python +>>> from libvcs.url.git import GitURL +>>> url = GitURL(url='git+https://github.com/django/django.git@main') +>>> url.scheme +'git+https' +>>> url.rev +'main' +``` + +## Other VCS Types + +### Mercurial + +```python +>>> from libvcs.url.hg import HgURL +>>> HgURL.is_valid(url='https://hg.mozilla.org/mozilla-central') +True +>>> url = HgURL(url='https://hg.mozilla.org/mozilla-central') +>>> url.hostname +'hg.mozilla.org' +``` + +### Subversion + +```python +>>> from libvcs.url.svn import SvnURL +>>> SvnURL.is_valid(url='svn+ssh://svn.example.org/repo/trunk') +True +>>> url = SvnURL(url='svn+ssh://svn.example.org/repo/trunk') +>>> url.scheme +'svn+ssh' +``` + +## URL Registry + +The registry can auto-detect VCS type from a URL: + +```python +>>> from libvcs.url.registry import registry +>>> matches = registry.match('git@github.com:vcs-python/libvcs.git') +>>> len(matches) >= 1 +True +``` + +## API Reference + +See {doc}`/url/index` for the complete API reference. diff --git a/docs/url/base.md b/docs/url/base.md new file mode 100644 index 000000000..f90d90b72 --- /dev/null +++ b/docs/url/base.md @@ -0,0 +1,9 @@ +(parser-framework)= + +# Framework: Add and extend URL parsers - `libvcs.url.base` + +```{eval-rst} +.. automodule:: libvcs.url.base + :members: + :undoc-members: +``` diff --git a/docs/url/constants.md b/docs/url/constants.md new file mode 100644 index 000000000..4706bc1c0 --- /dev/null +++ b/docs/url/constants.md @@ -0,0 +1,14 @@ +--- +myst: + html_meta: + "property=og:locale": "en_US" +--- +(url-parser-constants)= + +# Constants - `libvcs.url.constants` + +```{eval-rst} +.. automodule:: libvcs.url.constants + :members: + :undoc-members: +``` diff --git a/docs/url/git.md b/docs/url/git.md new file mode 100644 index 000000000..64663376e --- /dev/null +++ b/docs/url/git.md @@ -0,0 +1,19 @@ +--- +myst: + html_meta: + "description lang=en": "Parse git URLs in python" + "keywords": "libvcs, git, parse, urls" + "property=og:locale": "en_US" +--- + +# Git URL Parser - `libvcs.url.git` + +Detect, parse, and change git URLs using libvcs's URL parser for `git(1)`. It builds on top of the +{ref}`VCS-friendly URL parser framework `. + +```{eval-rst} +.. automodule:: libvcs.url.git + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/docs/url/hg.md b/docs/url/hg.md new file mode 100644 index 000000000..8efbf3e66 --- /dev/null +++ b/docs/url/hg.md @@ -0,0 +1,9 @@ +# Mercurial URL Parser - `libvcs.url.hg` + +For hg, aka `hg(1)`. + +```{eval-rst} +.. automodule:: libvcs.url.hg + :members: + :undoc-members: +``` diff --git a/docs/url/index.md b/docs/url/index.md new file mode 100644 index 000000000..e7fdd2910 --- /dev/null +++ b/docs/url/index.md @@ -0,0 +1,270 @@ +(parse)= + +# URL Parser - `libvcs.url` + +We all love {mod}`urllib.parse`, but what about VCS systems? + +Also, things like completions and typings being in demand, what of all these factories? Good python +code, but how to we get editor support and the nice satisfaction of types snapping together? + +If there was a type-friendly structure - like writing our own abstract base class - or a +{mod}`dataclasses` - while also being extensible to patterns and groupings, maybe we could strike a +perfect balance. + +If we could make it ready-to-go out of the box, but also have framework-like extensibility, it could +satisfy the niche. + +## Validate and detect VCS URLs + +````{tab} git + +{meth}`libvcs.url.git.GitURL.is_valid()` + +```python +>>> from libvcs.url.git import GitURL + +>>> GitURL.is_valid(url='https://github.com/vcs-python/libvcs.git') +True +``` + +```python +>>> from libvcs.url.git import GitURL + +>>> GitURL.is_valid(url='git@github.com:vcs-python/libvcs.git') +True +``` + +```` + +````{tab} hg +{meth}`libvcs.url.hg.HgURL.is_valid()` + +```python +>>> from libvcs.url.hg import HgURL + +>>> HgURL.is_valid(url='https://hg.mozilla.org/mozilla-central/mozilla-central') +True +``` + +```python +>>> from libvcs.url.hg import HgURL + +>>> HgURL.is_valid(url='hg@hg.mozilla.org:MyProject/project') +True +``` + +```` + +````{tab} svn + +{meth}`libvcs.url.svn.SvnURL.is_valid()` + + +```python +>>> from libvcs.url.svn import SvnURL + +>>> SvnURL.is_valid( +... url='https://svn.project.org/project-central/project-central') +True +``` + +```python +>>> from libvcs.url.svn import SvnURL + +>>> SvnURL.is_valid(url='svn@svn.project.org:MyProject/project') +True +``` + +```` + +## Parse VCS URLs + +_Compare to {class}`urllib.parse.ParseResult`_ + +````{tab} git + +{class}`libvcs.url.git.GitURL` + +```python +>>> from libvcs.url.git import GitURL + +>>> GitURL(url='git@github.com:vcs-python/libvcs.git') +GitURL(url=git@github.com:vcs-python/libvcs.git, + user=git, + hostname=github.com, + path=vcs-python/libvcs, + suffix=.git, + rule=core-git-scp) +``` + +```` + +````{tab} hg + +{class}`libvcs.url.hg.HgURL` + +```python +>>> from libvcs.url.hg import HgURL + +>>> HgURL( +... url="http://hugin.hg.sourceforge.net:8000/hgroot/hugin/hugin") +HgURL(url=http://hugin.hg.sourceforge.net:8000/hgroot/hugin/hugin, + scheme=http, + hostname=hugin.hg.sourceforge.net, + port=8000, + path=hgroot/hugin/hugin, + rule=core-hg) +``` + +```` + +````{tab} svn + +{class}`libvcs.url.svn.SvnURL` + +```python +>>> from libvcs.url.svn import SvnURL + +>>> SvnURL( +... url='svn+ssh://svn.debian.org/svn/aliothproj/path/in/project/repository') +SvnURL(url=svn+ssh://svn.debian.org/svn/aliothproj/path/in/project/repository, + scheme=svn+ssh, + hostname=svn.debian.org, + path=svn/aliothproj/path/in/project/repository, + rule=pip-url) +``` + +```` + +## Export usable URLs + +- git: {meth}`libvcs.url.git.GitURL.to_url()` +- hg: {meth}`libvcs.url.hg.HgURL.to_url()` +- svn: {meth}`libvcs.url.svn.SvnURL.to_url()` + +`pip` knows what a certain URL string means, but `git clone` won't. + +e.g. `pip install git+https://github.com/django/django.git@3.2` works great with `pip`. + +```console +$ pip install git+https://github.com/django/django.git@3.2 +... +Successfully installed Django-3.2 + +``` + +but `git clone` can't use that: + +```console +$ git clone git+https://github.com/django/django.git@3.2 # Fail +... +Cloning into django.git@3.2''...' +git: 'remote-git+https' is not a git command. See 'git --help'. +``` + +It needs something like this: + +```console +$ git clone https://github.com/django/django.git --branch 3.2 +``` + +But before we get there, we don't know if we want a URL yet. We return a structure, e.g. `GitURL`. + +- Common result primitives across VCS, e.g. `GitURL`. + + Compare to a {class}`urllib.parse.ParseResult` in `urlparse` + + This is where fun can happen, or you can just parse a URL. + +- Allow mutating / replacing parse of a vcs (e.g. just the hostname) +- Support common cases with popular VCS systems +- Support extending parsing for users needing to do so + +## Scope + +### Out of the box + +The ambition for this is to build extendable parsers for package-like URLs, e.g. + +- Vanilla VCS URLs + + - any URL supported by the VCS binary, e.g. `git(1)`, `svn(1)`, `hg(1)`. + +- [pip]-style urls [^pip-url] + - branches + - tags +- [NPM]-style urls[^npm-url] + - branches + - tags + +[pip]: https://pip.pypa.io/en/stable/ + +[^pip-url]: PIP-style URLs + + https://pip.pypa.io/en/stable/topics/vcs-support/ + +[npm]: https://docs.npmjs.com/ + +[^npm-url]: NPM style URLs + + https://docs.npmjs.com/about-packages-and-modules#npm-package-git-url-formats + +## Extendability + +Patterns can be registered. [Similar behavior](https://stackoverflow.com/a/6264214/1396928) exists +in {mod}`urlparse` (undocumented). + +- Any formats not covered by the stock +- Custom urls + + - For orgs on , e.g: + + - `python:mypy` -> `git@github.com:python/mypy.git` + - `inkscape:inkscape` -> `git@gitlab.com:inkscape/inkscape.git` + + - For out of domain trackers, e.g. + + Direct to site: + + - `cb:python-vcs/libtmux` -> `https://codeberg.org/vcs-python/libvcs` + - `kde:plasma/plasma-sdk` -> `git@invent.kde.org:plasma/plasma-sdk.git` + + Aside: Note [KDE's git docs] use of [`url..insteadOf`] and [`url..pushInsteadOf`] + + Direct to site + org / group: + + - `gnome:gedit` -> `git@gitlab.gnome.org:GNOME/gedit.git` + - `openstack:openstack` -> `https://opendev.org/openstack/openstack.git` + - `mozilla:central` -> `https://hg.mozilla.org/mozilla-central/` + +[kde's git docs]: https://community.kde.org/Infrastructure/Git#Pushing +[`url..insteadof`]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-urlltbasegtinsteadOf +[`url..pushinsteadof`]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-urlltbasegtpushInsteadOf + +From there, `GitURL` can be used downstream directly by other projects. + +In our case, `libvcs`s' own {ref}`cmd` and {ref}`projects`, as well as a +[vcspull configuration](https://vcspull.git-pull.com/), will be able to detect and accept various +URL patterns. + +### Matchers: Defaults + +When a match occurs, its `defaults` will fill in non-matched groups. + +### Matchers: First wins + +When registering new matchers, higher `weight`s are checked first. If it's a valid regex grouping, +it will be picked. + +## Explore + +```{toctree} +:caption: API + +git +svn +hg +base +registry +constants +``` diff --git a/docs/url/registry.md b/docs/url/registry.md new file mode 100644 index 000000000..d3657af30 --- /dev/null +++ b/docs/url/registry.md @@ -0,0 +1,139 @@ +# VCS Detection - `libvcs.url.registry` + +Detect VCS from `git`, `hg`, and `svn` URLs. + +**Basic example:** + +```python +>>> from libvcs.url.registry import registry, ParserMatch +>>> from libvcs.url.git import GitURL + +>>> registry.match('git@invent.kde.org:plasma/plasma-sdk.git') +[ParserMatch(vcs='git', match=GitURL(...))] + +>>> registry.match('git@invent.kde.org:plasma/plasma-sdk.git', is_explicit=True) +[ParserMatch(vcs='git', match=GitURL(...))] + +>>> registry.match('git+ssh://git@invent.kde.org:plasma/plasma-sdk.git') +[ParserMatch(vcs='git', match=GitURL(...))] + +>>> registry.match('git+ssh://git@invent.kde.org:plasma/plasma-sdk.git', is_explicit=False) +[] + +>>> registry.match('git+ssh://git@invent.kde.org:plasma/plasma-sdk.git', is_explicit=True) +[ParserMatch(vcs='git', match=GitURL(...))] +``` + +**From the ground up:** + +```python +>>> import dataclasses +>>> from libvcs.url.base import Rule, RuleMap +>>> from libvcs.url.registry import ParserMatch, VCSRegistry +>>> from libvcs.url.git import GitURL + +This will match `github:org/repo`: + +>>> class GitHubPrefix(Rule): +... label = 'gh-prefix' +... description ='Matches prefixes like github:org/repo' +... pattern = r'^github:(?P.*)$' +... defaults = { +... 'hostname': 'github.com', +... 'scheme': 'https' +... } +... is_explicit = True # We know it's git, not any other VCS +... weight = 100 + +Prefix for KDE infrastructure, `kde:group/repository`: + +>>> class KDEPrefix(Rule): # https://community.kde.org/Infrastructure/Git +... label = 'kde-prefix' +... description ='Matches prefixes like kde:org/repo' +... pattern = r'^kde:(?P\w[^:]+)$' +... defaults = { +... 'hostname': 'invent.kde.org', +... 'scheme': 'https' +... } +... is_explicit = True +... weight = 100 + +>>> @dataclasses.dataclass(repr=False) +... class MyGitURLParser(GitURL): +... rule_map = RuleMap( +... _rule_map={ +... **GitURL.rule_map._rule_map, +... 'github_prefix': GitHubPrefix, +... 'kde_prefix': KDEPrefix, +... } +... ) + +>>> my_parsers: "ParserLazyMap" = { +... "git": MyGitURLParser, +... "hg": "libvcs.url.hg.HgURL", +... "svn": "libvcs.url.svn.SvnURL", +... } + +>>> vcs_matcher = VCSRegistry(parsers=my_parsers) + +>>> vcs_matcher.match('git@invent.kde.org:plasma/plasma-sdk.git') +[ParserMatch(vcs='git', match=MyGitURLParser(...)), + ParserMatch(vcs='hg', match=HgURL(...)), + ParserMatch(vcs='svn', match=SvnURL(...))] + +Still works with everything GitURL does: + +>>> vcs_matcher.match('git+ssh://git@invent.kde.org:plasma/plasma-sdk.git', is_explicit=True) +[ParserMatch(vcs='git', match=MyGitURLParser(...))] + +>>> vcs_matcher.match('github:webpack/webpack', is_explicit=True) +[ParserMatch(vcs='git', + match=MyGitURLParser(url=github:webpack/webpack, + scheme=https, + hostname=github.com, + path=webpack/webpack, + rule=gh-prefix))] + +>>> git_match = vcs_matcher.match('github:webpack/webpack', is_explicit=True)[0].match + +>>> git_match.to_url() +'https://github.com/webpack/webpack' + +If an ssh URL is preferred: + +>>> git_match.scheme = None + +>>> git_match.to_url() +'git@github.com:webpack/webpack' + +>>> vcs_matcher.match('kde:frameworks/kirigami', is_explicit=True) +[ParserMatch(vcs='git', + match=MyGitURLParser(url=kde:frameworks/kirigami, + scheme=https, + hostname=invent.kde.org, + path=frameworks/kirigami, + rule=kde-prefix))] + +>>> kde_match = vcs_matcher.match('kde:frameworks/kirigami', is_explicit=True)[0].match + +>>> kde_match +MyGitURLParser(url=kde:frameworks/kirigami, + scheme=https, + hostname=invent.kde.org, + path=frameworks/kirigami, + rule=kde-prefix) + +>>> kde_match.to_url() +'https://invent.kde.org/frameworks/kirigami' + +>>> kde_match.scheme = None + +>>> kde_match.to_url() +'git@invent.kde.org:frameworks/kirigami' +``` + +```{eval-rst} +.. automodule:: libvcs.url.registry + :members: + :undoc-members: +``` diff --git a/docs/url/svn.md b/docs/url/svn.md new file mode 100644 index 000000000..190089a8e --- /dev/null +++ b/docs/url/svn.md @@ -0,0 +1,9 @@ +# SVN URL Parser - `libvcs.url.svn` + +For svn, aka `svn(1)`. + +```{eval-rst} +.. automodule:: libvcs.url.svn + :members: + :undoc-members: +``` diff --git a/justfile b/justfile new file mode 100644 index 000000000..e32d2ff44 --- /dev/null +++ b/justfile @@ -0,0 +1,126 @@ +# justfile for libvcs +# https://just.systems/ + +set shell := ["bash", "-uc"] + +# File patterns +py_files := "find . -type f -not -path '*/\\.*' | grep -i '.*[.]py$' 2> /dev/null" +doc_files := "find . -type f -not -path '*/\\.*' | grep -i '.*[.]rst$\\|.*[.]md$\\|.*[.]css$\\|.*[.]py$\\|mkdocs\\.yml\\|CHANGES\\|TODO\\|.*conf\\.py' 2> /dev/null" +all_files := "find . -type f -not -path '*/\\.*' | grep -i '.*[.]py$\\|.*[.]rst$\\|.*[.]md$\\|.*[.]css$\\|.*[.]py$\\|mkdocs\\.yml\\|CHANGES\\|TODO\\|.*conf\\.py' 2> /dev/null" + +# List all available commands +default: + @just --list + +# Run tests with pytest +[group: 'test'] +test *args: + uv run py.test {{ args }} + +# Run tests then start continuous testing with pytest-watcher +[group: 'test'] +start: + just test + uv run ptw . + +# Watch files and run tests on change (requires entr) +[group: 'test'] +watch-test: + #!/usr/bin/env bash + set -euo pipefail + if command -v entr > /dev/null; then + {{ all_files }} | entr -c just test + else + just test + just _entr-warn + fi + +# Build documentation +[group: 'docs'] +build-docs: + just -f docs/justfile html + +# Watch files and rebuild docs on change +[group: 'docs'] +watch-docs: + #!/usr/bin/env bash + set -euo pipefail + if command -v entr > /dev/null; then + {{ doc_files }} | entr -c just build-docs + else + just build-docs + just _entr-warn + fi + +# Start documentation server with auto-reload +[group: 'docs'] +start-docs: + just -f docs/justfile start + +# Start documentation design mode (watches static files) +[group: 'docs'] +design-docs: + just -f docs/justfile design + +# Format code with ruff +[group: 'lint'] +ruff-format: + uv run ruff format . + +# Run ruff linter +[group: 'lint'] +ruff: + uv run ruff check . + +# Watch files and run ruff on change +[group: 'lint'] +watch-ruff: + #!/usr/bin/env bash + set -euo pipefail + if command -v entr > /dev/null; then + {{ py_files }} | entr -c just ruff + else + just ruff + just _entr-warn + fi + +# Run mypy type checker +[group: 'lint'] +mypy: + uv run mypy $({{ py_files }}) + +# Watch files and run mypy on change +[group: 'lint'] +watch-mypy: + #!/usr/bin/env bash + set -euo pipefail + if command -v entr > /dev/null; then + {{ py_files }} | entr -c just mypy + else + just mypy + just _entr-warn + fi + +# Format markdown files with prettier +[group: 'format'] +format-markdown: + prettier --parser=markdown -w *.md docs/*.md docs/**/*.md CHANGES + +# Run monkeytype to collect runtime types +[group: 'typing'] +monkeytype-create: + uv run monkeytype run $(uv run which py.test) + +# Apply collected monkeytype annotations +[group: 'typing'] +monkeytype-apply: + uv run monkeytype list-modules | xargs -n1 -I{} sh -c 'uv run monkeytype apply {}' + +[private] +_entr-warn: + @echo "----------------------------------------------------------" + @echo " ! File watching functionality non-operational ! " + @echo " " + @echo "Install entr(1) to automatically run tasks on file change." + @echo "See https://eradman.com/entrproject/ " + @echo "----------------------------------------------------------" diff --git a/libvcs/__init__.py b/libvcs/__init__.py deleted file mode 100644 index bbbda7d74..000000000 --- a/libvcs/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Project package for libvcs.""" -import logging - -from .cmd.core import CmdLoggingAdapter -from .projects.base import BaseProject -from .projects.git import GitProject -from .projects.hg import MercurialProject -from .projects.svn import SubversionProject - -__all__ = [ - "GitProject", - "MercurialProject", - "SubversionProject", - "BaseProject", - "CmdLoggingAdapter", -] - -logger = logging.getLogger(__name__) diff --git a/libvcs/cmd/core.py b/libvcs/cmd/core.py deleted file mode 100644 index 8752f4c90..000000000 --- a/libvcs/cmd/core.py +++ /dev/null @@ -1,212 +0,0 @@ -"""Utility functions for libvcs.""" -import datetime -import errno -import logging -import os -import subprocess -import sys -from typing import Optional, Protocol, Union - -from .. import exc -from ..types import StrOrBytesPath - -logger = logging.getLogger(__name__) - -console_encoding = sys.__stdout__.encoding - - -def console_to_str(s): - """From pypa/pip project, pip.backwardwardcompat. License MIT.""" - try: - return s.decode(console_encoding) - except UnicodeDecodeError: - return s.decode("utf_8") - except AttributeError: # for tests, #13 - return s - - -def which( - exe=None, default_paths=["/bin", "/sbin", "/usr/bin", "/usr/sbin", "/usr/local/bin"] -): - """Return path of bin. Python clone of /usr/bin/which. - - from salt.util - https://www.github.com/saltstack/salt - license apache - - Parameters - ---------- - exe : str - Application to search PATHs for. - default_path : list - Application to search PATHs for. - - Returns - ------- - str : - Path to binary - """ - - def _is_executable_file_or_link(exe): - # check for os.X_OK doesn't suffice because directory may executable - return os.access(exe, os.X_OK) and (os.path.isfile(exe) or os.path.islink(exe)) - - if _is_executable_file_or_link(exe): - # executable in cwd or fullpath - return exe - - # Enhance POSIX path for the reliability at some environments, when - # $PATH is changing. This also keeps order, where 'first came, first - # win' for cases to find optional alternatives - search_path = ( - os.environ.get("PATH") and os.environ["PATH"].split(os.pathsep) or list() - ) - for default_path in default_paths: - if default_path not in search_path: - search_path.append(default_path) - os.environ["PATH"] = os.pathsep.join(search_path) - for path in search_path: - full_path = os.path.join(path, exe) - if _is_executable_file_or_link(full_path): - return full_path - logger.info( - "'{}' could not be found in the following search path: " - "'{}'".format(exe, search_path) - ) - - return None - - -def mkdir_p(path): - """Make directories recursively. - - Parameters - ---------- - path : str - path to create - """ - try: - os.makedirs(path) - except OSError as exc: # Python >2.5 - if exc.errno == errno.EEXIST and os.path.isdir(path): - pass - else: - raise Exception("Could not create directory %s" % path) - - -class CmdLoggingAdapter(logging.LoggerAdapter): - """Adapter for additional command-related data to :py:mod:`logging`. - - Extends :py:class:`logging.LoggerAdapter`'s functionality. - - Mixes in additional context via :py:meth:`logging.LoggerAdapter.process()` for - :class:`logging.Formatter` when emitting log entries. - - Parameters - ---------- - bin_name : str - name of the command or vcs tool being wrapped, e.g. 'git' - keyword : str - directory basename, name of repo, hint, etc. e.g. 'django' - """ - - def __init__(self, bin_name: str, keyword: str, *args, **kwargs): - #: bin_name - self.bin_name = bin_name - #: directory basename, name of repository, hint, etc. - self.keyword = keyword - - logging.LoggerAdapter.__init__(self, *args, **kwargs) - - def process(self, msg, kwargs): - """Add additional context information for loggers.""" - prefixed_dict = {} - prefixed_dict["bin_name"] = self.bin_name - prefixed_dict["keyword"] = self.keyword - - kwargs["extra"] = prefixed_dict - - return msg, kwargs - - -class ProgressCallbackProtocol(Protocol): - """Callback to report subprocess communication.""" - - def __call__(self, output: Union[str, bytes], timestamp: datetime.datetime): - """Callback signature for subprocess communication.""" - ... - - -def run( - cmd: Union[str, list[str]], - shell: bool = False, - cwd: Optional[StrOrBytesPath] = None, - log_in_real_time: bool = True, - check_returncode: bool = True, - callback: Optional[ProgressCallbackProtocol] = None, -): - """Run 'cmd' in a shell and return the combined contents of stdout and - stderr (Blocking). Throws an exception if the command exits non-zero. - - Parameters - ---------- - cmd : list or str, or single str, if shell=True - the command to run - - shell : boolean - boolean indicating whether we are using advanced shell - features. Use only when absolutely necessary, since this allows a lot - more freedom which could be exploited by malicious code. See the - warning here: - http://docs.python.org/library/subprocess.html#popen-constructor - - cwd : str - dir command is run from. Defaults to ``path``. - - log_in_real_time : boolean - boolean indicating whether to read stdout from the - subprocess in real time instead of when the process finishes. - - check_returncode : bool - Indicate whether a `libvcs.exc.CommandError` should be raised if return code is - different from 0. - - callback : ProgressCallbackProtocol - callback to return output as a command executes, accepts a function signature - of `(output, timestamp)`. Example usage:: - - def progress_cb(output, timestamp): - sys.stdout.write(output) - sys.stdout.flush() - run(['git', 'pull'], callback=progress_cb) - """ - proc = subprocess.Popen( - cmd, - shell=shell, - stderr=subprocess.PIPE, - stdout=subprocess.PIPE, - cwd=cwd, - ) - - all_output = [] - code = None - line = None - while code is None: - code = proc.poll() - - # output = console_to_str(proc.stdout.readline()) - # all_output.append(output) - if callback and callable(callback): - line = console_to_str(proc.stderr.read(128)) - if line: - callback(output=line, timestamp=datetime.datetime.now()) - if callback and callable(callback): - callback(output="\r", timestamp=datetime.datetime.now()) - - lines = filter(None, (line.strip() for line in proc.stdout.readlines())) - all_output = console_to_str(b"\n".join(lines)) - if code: - stderr_lines = filter(None, (line.strip() for line in proc.stderr.readlines())) - all_output = console_to_str(b"".join(stderr_lines)) - output = "".join(all_output) - if code != 0 and check_returncode: - raise exc.CommandError(output=output, returncode=code, cmd=cmd) - return output diff --git a/libvcs/cmd/git.py b/libvcs/cmd/git.py deleted file mode 100644 index 94c250cff..000000000 --- a/libvcs/cmd/git.py +++ /dev/null @@ -1,1013 +0,0 @@ -import pathlib -import shlex -from typing import Any, Literal, Optional, Sequence, Union - -from ..types import StrOrBytesPath, StrOrPath -from .core import run - -_CMD = Union[StrOrBytesPath, Sequence[StrOrBytesPath]] - - -class Git: - def __init__(self, dir: StrOrPath): - """Lite, typed, pythonic wrapper for git(1). - - Parameters - ---------- - dir : - Operates as PATH in the corresponding git subcommand. - - Examples - -------- - >>> Git(dir=tmp_path) - - """ - #: Directory to check out - self.dir: pathlib.Path - if isinstance(dir, pathlib.Path): - self.dir = dir - else: - self.dir = pathlib.Path(dir) - - def __repr__(self): - return f"" - - def run( - self, - args: _CMD, - # Print-and-exit flags - version: Optional[bool] = None, - help: Optional[bool] = None, - html_path: Optional[bool] = None, - man_path: Optional[bool] = None, - info_path: Optional[bool] = None, - # Normal flags - cwd: Optional[StrOrBytesPath] = None, - git_dir: Optional[StrOrBytesPath] = None, - work_tree: Optional[StrOrBytesPath] = None, - namespace: Optional[StrOrBytesPath] = None, - super_prefix: Optional[StrOrBytesPath] = None, - exec_path: Optional[StrOrBytesPath] = None, - bare: Optional[bool] = None, - no_replace_objects: Optional[bool] = None, - literal_pathspecs: Optional[bool] = None, - global_pathspecs: Optional[bool] = None, - noglob_pathspecs: Optional[bool] = None, - icase_pathspecs: Optional[bool] = None, - no_optional_locks: Optional[bool] = None, - config: Optional[str] = None, - config_env: Optional[str] = None, - **kwargs, - ): - """ - Passing None to a subcommand option, the flag won't be passed unless otherwise - stated. - - `git help` and `git help [cmd]` - - Wraps git's `Options `_. - - Parameters - ---------- - cwd : :attr:`libvcs.cmd.types.StrOrBytesPath`, optional - ``-C ``, Defaults to :attr:`~.cwd` - git_dir : :attr:`libvcs.cmd.types.StrOrBytesPath`, optional - ``--git-dir `` - work_tree : :attr:`libvcs.cmd.types.StrOrBytesPath`, optional - ``--work-tree `` - namespace : :attr:`libvcs.cmd.types.StrOrBytesPath`, optional - ``--namespace `` - super_prefix : :attr:`libvcs.cmd.types.StrOrBytesPath`, optional - ``--super-prefix `` - exec_path : :attr:`libvcs.cmd.types.StrOrBytesPath`, optional - ``--exec-path=`` - bare : bool - ``--bare`` - no_replace_objects : bool - ``--no-replace-objects`` - literal_pathspecs : bool - ``--literal-pathspecs`` - global_pathspecs : bool - ``--glob-pathspecs`` - noglob_pathspecs : bool - ``--noglob-pathspecs`` - icase_pathspecs : bool - ``--icase-pathspecs`` - no_optional_locks : bool - ``--no-optional-locks`` - version : bool - ``--version`` - html_path : bool - ``--html-path`` - man_path : bool - ``--man-path`` - info_path : bool - ``--info-path`` - help : bool - ``-h / --help`` - pager : bool - ``-p --pager`` - no_pager : bool - ``-P / --no-pager`` - config : - ``--config==`` - config_env : - ``--config-env==`` - - Examples - -------- - >>> git = Git(dir=tmp_path) - >>> git.run(['help']) # doctest: +NORMALIZE_WHITESPACE - "usage: git [--version] [--help] [-C ]..." - """ - - if isinstance(args, Sequence): - cli_args = ["git", *args] - else: - cli_args = ["git", args] - - if "cwd" not in kwargs: - kwargs["cwd"] = self.dir - - # - # Print-and-exit - # - if version is True: - cli_args.append("--version") - if help is True: - cli_args.append("--help") - if html_path is True: - cli_args.append("--html-path") - if man_path is True: - cli_args.append("--man-path") - if info_path is True: - cli_args.append("--info-path") - - # - # Flags - # - if cwd is not None: - cli_args.append(f"-C {cwd}") - if git_dir is not None: - cli_args.extend(["--git-dir", str(git_dir)]) - if work_tree is not None: - cli_args.extend(["--work-tree", str(work_tree)]) - if namespace is not None: - cli_args.extend(["--namespace", namespace]) - if super_prefix is not None: - cli_args.extend(["--super-prefix", super_prefix]) - if exec_path is not None: - cli_args.extend(["--exec-path", exec_path]) - if bare is True: - cli_args.append("--bare") - if no_replace_objects is True: - cli_args.append("--no-replace-objects") - if literal_pathspecs is True: - cli_args.append("--literal-pathspecs") - if global_pathspecs is True: - cli_args.append("--global-pathspecs") - if noglob_pathspecs is True: - cli_args.append("--noglob-pathspecs") - if icase_pathspecs is True: - cli_args.append("--icase-pathspecs") - if no_optional_locks is True: - cli_args.append("--no-optional-locks") - - return run(cmd=cli_args, **kwargs) - - def clone( - self, - url: str, - separate_git_dir: Optional[StrOrBytesPath] = None, - template: Optional[str] = None, - depth: Optional[str] = None, - branch: Optional[str] = None, - origin: Optional[str] = None, - upload_pack: Optional[str] = None, - shallow_since: Optional[str] = None, - shallow_exclude: Optional[str] = None, - reference: Optional[str] = None, - reference_if_able: Optional[str] = None, - server_option: Optional[str] = None, - jobs: Optional[str] = None, - force: Optional[bool] = None, - local: Optional[bool] = None, - all: Optional[bool] = None, - no_hardlinks: Optional[bool] = None, - hardlinks: Optional[bool] = None, - shared: Optional[bool] = None, - progress: Optional[bool] = None, - no_checkout: Optional[bool] = None, - no_reject_shallow: Optional[bool] = None, - reject_shallow: Optional[bool] = None, - sparse: Optional[bool] = None, - shallow_submodules: Optional[bool] = None, - no_shallow_submodules: Optional[bool] = None, - remote_submodules: Optional[bool] = None, - no_remote_submodules: Optional[bool] = None, - verbose: Optional[bool] = None, - quiet: Optional[bool] = None, - # Special behavior - make_parents: Optional[bool] = True, - **kwargs, - ): - """Clone a working copy from an git repo. - - Wraps `git clone `_. - - Parameters - ---------- - url : str - directory : str - separate_git_dir : StrOrBytesPath - Separate repository (.git/ ) from working tree - force : bool, optional - force operation to run - make_parents : bool, default: ``True`` - Creates checkout directory (`:attr:`self.dir`) if it doesn't already exist. - - Examples - -------- - >>> git = Git(dir=tmp_path) - >>> git_remote_repo = create_git_remote_repo() - >>> git.clone(url=f'file://{git_remote_repo}') - '' - >>> git.dir.exists() - True - """ - required_flags: list[str] = [url, str(self.dir)] - local_flags: list[str] = [] - - if template is not None: - local_flags.append(f"--template={template}") - if separate_git_dir is not None: - local_flags.append(f"--separate-git-dir={separate_git_dir}") - if (filter := kwargs.pop("filter", None)) is not None: - local_flags.append(f"--filter={filter}") - if depth is not None: - local_flags.extend(["--depth", depth]) - if branch is not None: - local_flags.extend(["--branch", branch]) - if origin is not None: - local_flags.extend(["--origin", origin]) - if upload_pack is not None: - local_flags.extend(["--upload-pack", upload_pack]) - if shallow_since is not None: - local_flags.append(f"--shallow-since={shallow_since}") - if shallow_exclude is not None: - local_flags.append(f"--shallow-exclude={shallow_exclude}") - if reference is not None: - local_flags.extend(["--reference", reference]) - if reference_if_able is not None: - local_flags.extend(["--reference", reference_if_able]) - if server_option is not None: - local_flags.append(f"--server-option={server_option}") - if jobs is not None: - local_flags.extend(["--jobs", jobs]) - if local is True: - local_flags.append("--local") - if hardlinks is True: - local_flags.append("--hardlinks") - if no_hardlinks is True: - local_flags.append("--no-hardlinks") - if shared is True: - local_flags.append("--shared") - if quiet is True: - local_flags.append("--quiet") - if verbose is True: - local_flags.append("--verbose") - if progress is True: - local_flags.append("--progress") - if no_checkout is True: - local_flags.append("--no-checkout") - if no_reject_shallow is True: - local_flags.append("--no-reject-shallow") - if reject_shallow is True: - local_flags.append("--reject-shallow") - if sparse is True: - local_flags.append("--sparse") - if shallow_submodules is True: - local_flags.append("--shallow-submodules") - if no_shallow_submodules is True: - local_flags.append("--no-shallow-submodules") - if remote_submodules is True: - local_flags.append("--remote-submodules") - if no_remote_submodules is True: - local_flags.append("--no-remote-submodules") - - # libvcs special behavior - if make_parents and not self.dir.exists(): - self.dir.mkdir(parents=True) - return self.run( - ["clone", *local_flags, "--", *required_flags], check_returncode=False - ) - - def fetch( - self, - reftag: Optional[Any] = None, - deepen: Optional[str] = None, - depth: Optional[str] = None, - branch: Optional[str] = None, - origin: Optional[str] = None, - upload_pack: Optional[str] = None, - shallow_since: Optional[str] = None, - shallow_exclude: Optional[str] = None, - negotiation_tip: Optional[str] = None, - jobs: Optional[str] = None, - server_option: Optional[str] = None, - recurse_submodules: Optional[ - Union[bool, Literal["yes", "on-demand", "no"]] - ] = None, - recurse_submodules_default: Optional[ - Union[bool, Literal["yes", "on-demand"]] - ] = None, - submodule_prefix: Optional[StrOrBytesPath] = None, - # - all: Optional[bool] = None, - force: Optional[bool] = None, - keep: Optional[bool] = None, - multiple: Optional[bool] = None, - dry_run: Optional[bool] = None, - append: Optional[bool] = None, - atomic: Optional[bool] = None, - ipv4: Optional[bool] = None, - ipv6: Optional[bool] = None, - progress: Optional[bool] = None, - quiet: Optional[bool] = None, - verbose: Optional[bool] = None, - unshallow: Optional[bool] = None, - update_shallow: Optional[bool] = None, - negotiate_tip: Optional[bool] = None, - no_write_fetch_head: Optional[bool] = None, - write_fetch_head: Optional[bool] = None, - no_auto_maintenance: Optional[bool] = None, - auto_maintenance: Optional[bool] = None, - no_write_commit_graph: Optional[bool] = None, - write_commit_graph: Optional[bool] = None, - prefetch: Optional[bool] = None, - prune: Optional[bool] = None, - prune_tags: Optional[bool] = None, - no_tags: Optional[bool] = None, - tags: Optional[bool] = None, - no_recurse_submodules: Optional[bool] = None, - set_upstream: Optional[bool] = None, - update_head_ok: Optional[bool] = None, - show_forced_updates: Optional[bool] = None, - no_show_forced_updates: Optional[bool] = None, - negotiate_only: Optional[bool] = None, - **kwargs, - ): - """Download from repo. Wraps `git fetch `_. - - Examples - -------- - >>> git = Git(dir=git_local_clone.dir) - >>> git_remote_repo = create_git_remote_repo() - >>> git.fetch() - '' - >>> git = Git(dir=git_local_clone.dir) - >>> git_remote_repo = create_git_remote_repo() - >>> git.fetch(reftag=f'file://{git_remote_repo}') - '' - >>> git.dir.exists() - True - """ - required_flags: list[str] = [] - if reftag: - required_flags.insert(0, reftag) - local_flags: list[str] = [] - - if submodule_prefix is not None: - local_flags.append(f"--submodule-prefix={submodule_prefix}") - if (filter := kwargs.pop("filter", None)) is not None: - local_flags.append(f"--filter={filter}") - if depth is not None: - local_flags.extend(["--depth", depth]) - if branch is not None: - local_flags.extend(["--branch", branch]) - if origin is not None: - local_flags.extend(["--origin", origin]) - if upload_pack is not None: - local_flags.extend(["--upload-pack", upload_pack]) - if shallow_since is not None: - local_flags.append(f"--shallow-since={shallow_since}") - if shallow_exclude is not None: - local_flags.append(f"--shallow-exclude={shallow_exclude}") - if server_option is not None: - local_flags.append(f"--server-option={server_option}") - if jobs is not None: - local_flags.extend(["--jobs", jobs]) - if keep: - local_flags.append("--keep") - if force: - local_flags.append("--force") - if multiple: - local_flags.append("--multiple") - if quiet: - local_flags.append("--quiet") - if progress: - local_flags.append("--progress") - if verbose: - local_flags.append("--verbose") - if all: - local_flags.append("--all") - if atomic: - local_flags.append("--atomic") - if unshallow: - local_flags.append("--unshallow") - if append: - local_flags.append("--append") - if update_shallow: - local_flags.append("--update-shallow") - if dry_run: - local_flags.append("--dry-run") - if no_write_fetch_head: - local_flags.append("--no-write-fetch-head") - if write_fetch_head: - local_flags.append("--write-fetch-head") - if auto_maintenance: - local_flags.append("--auto-maintenance") - if no_auto_maintenance: - local_flags.append("--no-auto-maintenance") - if write_commit_graph: - local_flags.append("--write-commit-graph") - if no_write_commit_graph: - local_flags.append("--no-write-commit-graph") - if prefetch: - local_flags.append("--prefetch") - if prune: - local_flags.append("--prune") - if prune_tags: - local_flags.append("--prune-tags") - if tags: - local_flags.append("--tags") - if no_tags: - local_flags.append("--no-tags") - if no_recurse_submodules: - local_flags.append("--no-recurse-submodules") - if set_upstream: - local_flags.append("--set-upstream") - if update_head_ok: - local_flags.append("--update-head-ok") - if show_forced_updates: - local_flags.append("--show-forced-updates") - if no_show_forced_updates: - local_flags.append("--no-show-forced-updates") - if negotiate_only: - local_flags.append("--negotiate-only") - return self.run( - ["fetch", *local_flags, "--", *required_flags], check_returncode=False - ) - - def rebase( - self, - upstream: Optional[str] = None, - onto: Optional[str] = None, - branch: Optional[str] = None, - apply: Optional[bool] = None, - merge: Optional[bool] = None, - quiet: Optional[bool] = None, - verbose: Optional[bool] = None, - stat: Optional[bool] = None, - no_stat: Optional[bool] = None, - verify: Optional[bool] = None, - no_verify: Optional[bool] = None, - fork_point: Optional[bool] = None, - no_fork_point: Optional[bool] = None, - whitespace: Optional[str] = None, - no_whitespace: Optional[bool] = None, - commit_date_is_author_date: Optional[bool] = None, - ignore_date: Optional[bool] = None, - root: Optional[bool] = None, - autostash: Optional[bool] = None, - no_autostash: Optional[bool] = None, - autosquash: Optional[bool] = None, - no_autosquash: Optional[bool] = None, - reschedule_failed_exec: Optional[bool] = None, - no_reschedule_failed_exec: Optional[bool] = None, - context: Optional[int] = None, - rerere_autoupdate: Optional[bool] = None, - no_rerere_autoupdate: Optional[bool] = None, - keep_empty: Optional[bool] = None, - no_keep_empty: Optional[bool] = None, - reapply_cherry_picks: Optional[bool] = None, - no_reapply_cherry_picks: Optional[bool] = None, - allow_empty_message: Optional[bool] = None, - signoff: Optional[bool] = None, - keep_base: Optional[bool] = None, - strategy: Optional[Union[str, bool]] = None, - strategy_option: Optional[str] = None, - exec: Optional[str] = None, - gpg_sign: Optional[Union[str, bool]] = None, - no_gpg_sign: Optional[bool] = None, - empty: Optional[Union[str, Literal["drop", "keep", "ask"]]] = None, - rebase_merges: Optional[ - Union[str, Literal["rebase-cousins", "no-rebase-cousins"]] - ] = None, - # - # Interactive - # - interactive: Optional[bool] = None, - edit_todo: Optional[bool] = None, - skip: Optional[bool] = None, - show_current_patch: Optional[bool] = None, - abort: Optional[bool] = None, - quit: Optional[bool] = None, - **kwargs, - ): - """Reapply commit on top of another tip. - - Wraps `git rebase `_. - - Parameters - ---------- - continue : bool - Accepted via kwargs - - Examples - -------- - >>> git = Git(dir=git_local_clone.dir) - >>> git_remote_repo = create_git_remote_repo() - >>> git.rebase() - 'Current branch master is up to date.' - - Declare upstream: - - >>> git = Git(dir=git_local_clone.dir) - >>> git_remote_repo = create_git_remote_repo() - >>> git.rebase(upstream='origin') - 'Current branch master is up to date.' - >>> git.dir.exists() - True - """ - required_flags: list[str] = [] - local_flags: list[str] = [] - - if upstream: - required_flags.insert(0, upstream) - if branch: - required_flags.insert(0, branch) - if onto: - local_flags.extend(["--onto", onto]) - if context: - local_flags.extend(["--C", context]) - - if exec: - local_flags.extend(["--exec", shlex.quote(exec)]) - if reschedule_failed_exec: - local_flags.append("--reschedule-failed-exec") - if no_reschedule_failed_exec: - local_flags.append("--no-reschedule-failed-exec") - if fork_point: - local_flags.append("--fork-point") - if no_fork_point: - local_flags.append("--no-fork-point") - if root: - local_flags.append("--root") - if keep_base: - local_flags.append("--keep-base") - if autostash: - local_flags.append("--autostash") - if no_autostash: - local_flags.append("--no-autostash") - - if merge: - local_flags.append("--merge") - - if verbose: - local_flags.append("--verbose") - if quiet: - local_flags.append("--quiet") - if stat: - local_flags.append("--stat") - if no_stat: - local_flags.append("--no-stat") - - if whitespace: - local_flags.append("--whitespace") - if no_whitespace: - local_flags.append("--no-whitespace") - - if rerere_autoupdate: - local_flags.append("--rerere-autoupdate") - if no_rerere_autoupdate: - local_flags.append("--no-rerwre-autoupdate") - - if reapply_cherry_picks: - local_flags.append("--reapply-cherry-picks") - if no_reapply_cherry_picks: - local_flags.append("--no-reapply-cherry-picks") - - if keep_empty: - local_flags.append("--keep-empty") - if no_keep_empty: - local_flags.append("--no-keep-empty") - - if verify: - local_flags.append("--verify") - if no_verify: - local_flags.append("--no-verify") - - if ignore_date: - local_flags.append("--ignore-date") - if commit_date_is_author_date: - local_flags.append("--commit-date-is-author-date") - - if empty is not None: - if isinstance(empty, str): - local_flags.append(f"--empty={empty}") - else: - local_flags.append("--empty") - - if rebase_merges is not None: - if isinstance(rebase_merges, str): - local_flags.append(f"--rebase-merges={rebase_merges}") - else: - local_flags.append("--rebase-merges") - - if gpg_sign is not None: - if isinstance(gpg_sign, str): - local_flags.append(f"--gpg-sign={gpg_sign}") - else: - local_flags.append("--gpg-sign") - if no_gpg_sign: - local_flags.append("--no-gpg-sign") - if signoff: - local_flags.append("--signoff") - - # - # Interactive - # - if interactive: - local_flags.append("--interactive") - if kwargs.get("continue"): - local_flags.append("--continue") - if abort: - local_flags.append("--abort") - if edit_todo: - local_flags.append("--edit-todo") - if show_current_patch: - local_flags.append("--show-current-patch") - if quit: - local_flags.append("--quit") - - return self.run( - ["rebase", *local_flags, *required_flags], check_returncode=False - ) - - def pull( - self, - reftag: Optional[Any] = None, - repository: Optional[str] = None, - deepen: Optional[str] = None, - depth: Optional[str] = None, - branch: Optional[str] = None, - origin: Optional[str] = None, - upload_pack: Optional[str] = None, - shallow_since: Optional[str] = None, - shallow_exclude: Optional[str] = None, - negotiation_tip: Optional[str] = None, - jobs: Optional[str] = None, - server_option: Optional[str] = None, - recurse_submodules: Optional[ - Union[bool, Literal["yes", "on-demand", "no"]] - ] = None, - recurse_submodules_default: Optional[ - Union[bool, Literal["yes", "on-demand"]] - ] = None, - submodule_prefix: Optional[StrOrBytesPath] = None, - # - # Pull specific flags - # - # Options related to git pull - # https://git-scm.com/docs/git-pull#_options_related_to_pull - # - cleanup: Optional[str] = None, - rebase: Optional[Union[str, bool]] = None, - no_rebase: Optional[bool] = None, - strategy: Optional[Union[str, bool]] = None, - strategy_option: Optional[str] = None, - gpg_sign: Optional[Union[str, bool]] = None, - no_gpg_sign: Optional[bool] = None, - commit: Optional[bool] = None, - no_commit: Optional[bool] = None, - edit: Optional[bool] = None, - no_edit: Optional[bool] = None, - fast_forward_only: Optional[bool] = None, - fast_forward: Optional[bool] = None, - no_fast_forward: Optional[bool] = None, - sign_off: Optional[bool] = None, - no_sign_off: Optional[bool] = None, - stat: Optional[bool] = None, - no_stat: Optional[bool] = None, - squash: Optional[bool] = None, - no_squash: Optional[bool] = None, - verify: Optional[bool] = None, - no_verify: Optional[bool] = None, - verify_signatures: Optional[bool] = None, - no_verify_signatures: Optional[bool] = None, - summary: Optional[bool] = None, - no_summary: Optional[bool] = None, - autostash: Optional[bool] = None, - no_autostash: Optional[bool] = None, - allow_unrelated_histories: Optional[bool] = None, - # - # Options related to git fetch - # https://git-scm.com/docs/git-pull#_options_related_to_fetching - # - fetch: Optional[bool] = None, - no_fetch: Optional[bool] = None, - all: Optional[bool] = None, - force: Optional[bool] = None, - keep: Optional[bool] = None, - multiple: Optional[bool] = None, - dry_run: Optional[bool] = None, - append: Optional[bool] = None, - atomic: Optional[bool] = None, - ipv4: Optional[bool] = None, - ipv6: Optional[bool] = None, - progress: Optional[bool] = None, - quiet: Optional[bool] = None, - verbose: Optional[bool] = None, - unshallow: Optional[bool] = None, - update_shallow: Optional[bool] = None, - negotiate_tip: Optional[bool] = None, - no_write_fetch_head: Optional[bool] = None, - write_fetch_head: Optional[bool] = None, - no_auto_maintenance: Optional[bool] = None, - auto_maintenance: Optional[bool] = None, - no_write_commit_graph: Optional[bool] = None, - write_commit_graph: Optional[bool] = None, - prefetch: Optional[bool] = None, - prune: Optional[bool] = None, - prune_tags: Optional[bool] = None, - no_tags: Optional[bool] = None, - tags: Optional[bool] = None, - no_recurse_submodules: Optional[bool] = None, - set_upstream: Optional[bool] = None, - update_head_ok: Optional[bool] = None, - show_forced_updates: Optional[bool] = None, - no_show_forced_updates: Optional[bool] = None, - negotiate_only: Optional[bool] = None, - **kwargs, - ): - """Download from repo. Wraps `git pull `_. - - Examples - -------- - >>> git = Git(dir=git_local_clone.dir) - >>> git_remote_repo = create_git_remote_repo() - >>> git.pull() - 'Already up to date.' - - Fetch via ref: - - >>> git = Git(dir=tmp_path) - >>> git.run(['init']) - 'Initialized ...' - >>> git_remote_repo = create_git_remote_repo() - >>> git.pull(reftag=f'file://{git_remote_repo}') - '' - >>> git.dir.exists() - True - """ - required_flags: list[str] = [] - if repository: - required_flags.insert(0, repository) - if reftag: - required_flags.insert(0, reftag) - local_flags: list[str] = [] - - # - # Pull-related arguments - # - if rebase is not None: - if isinstance(rebase, str): - local_flags.append(f"--rebase={rebase}") - else: - local_flags.append("--rebase") - if no_rebase: - local_flags.append("--no-rebase") - if strategy is not None: - if isinstance(strategy, str): - local_flags.append(f"--strategy={strategy}") - else: - local_flags.append("--strategy") - if strategy_option is not None: - local_flags.append(f"--strategy-option={strategy_option}") - if gpg_sign is not None: - if isinstance(gpg_sign, str): - local_flags.append(f"--gpg-sign={gpg_sign}") - else: - local_flags.append("--gpg-sign") - if no_gpg_sign: - local_flags.append("--no-gpg-sign") - if cleanup: - local_flags.append("--cleanup") - if commit: - local_flags.append("--commit") - if no_commit: - local_flags.append("--no-commit") - if fast_forward: - local_flags.append("--fast-forward") - if fast_forward_only: - local_flags.append("--fast-forward-only") - if no_fast_forward: - local_flags.append("--no-fast-forward") - if edit: - local_flags.append("--edit") - if no_edit: - local_flags.append("--no-edit") - if sign_off: - local_flags.append("--sign_off") - if no_sign_off: - local_flags.append("--no-sign_off") - if stat: - local_flags.append("--stat") - if no_stat: - local_flags.append("--no-stat") - if squash: - local_flags.append("--squash") - if no_squash: - local_flags.append("--no-squash") - if verify: - local_flags.append("--verify") - if no_verify: - local_flags.append("--no-verify") - if verify_signatures: - local_flags.append("--verify-signatures") - if no_verify_signatures: - local_flags.append("--no-verify-signatures") - if summary: - local_flags.append("--summary") - if no_summary: - local_flags.append("--no-summary") - if autostash: - local_flags.append("--autostash") - if no_autostash: - local_flags.append("--no-autostash") - if allow_unrelated_histories: - local_flags.append("--allow-unrelated-histories") - # - # Fetch-related arguments - # - if submodule_prefix is not None: - local_flags.append(f"--submodule-prefix={submodule_prefix}") - if (filter := kwargs.pop("filter", None)) is not None: - local_flags.append(f"--filter={filter}") - if depth is not None: - local_flags.extend(["--depth", depth]) - if branch is not None: - local_flags.extend(["--branch", branch]) - if origin is not None: - local_flags.extend(["--origin", origin]) - if upload_pack is not None: - local_flags.extend(["--upload-pack", upload_pack]) - if shallow_since is not None: - local_flags.append(f"--shallow-since={shallow_since}") - if shallow_exclude is not None: - local_flags.append(f"--shallow-exclude={shallow_exclude}") - if server_option is not None: - local_flags.append(f"--server-option={server_option}") - if jobs is not None: - local_flags.extend(["--jobs", jobs]) - if keep: - local_flags.append("--keep") - if force: - local_flags.append("--force") - if multiple: - local_flags.append("--multiple") - if quiet: - local_flags.append("--quiet") - if progress: - local_flags.append("--progress") - if verbose: - local_flags.append("--verbose") - if all: - local_flags.append("--all") - if atomic: - local_flags.append("--atomic") - if unshallow: - local_flags.append("--unshallow") - if append: - local_flags.append("--append") - if update_shallow: - local_flags.append("--update-shallow") - if dry_run: - local_flags.append("--dry-run") - if no_write_fetch_head: - local_flags.append("--no-write-fetch-head") - if write_fetch_head: - local_flags.append("--write-fetch-head") - if auto_maintenance: - local_flags.append("--auto-maintenance") - if no_auto_maintenance: - local_flags.append("--no-auto-maintenance") - if write_commit_graph: - local_flags.append("--write-commit-graph") - if no_write_commit_graph: - local_flags.append("--no-write-commit-graph") - if prefetch: - local_flags.append("--prefetch") - if prune: - local_flags.append("--prune") - if prune_tags: - local_flags.append("--prune-tags") - if tags: - local_flags.append("--tags") - if no_tags: - local_flags.append("--no-tags") - if no_recurse_submodules: - local_flags.append("--no-recurse-submodules") - if set_upstream: - local_flags.append("--set-upstream") - if update_head_ok: - local_flags.append("--update-head-ok") - if show_forced_updates: - local_flags.append("--show-forced-updates") - if no_show_forced_updates: - local_flags.append("--no-show-forced-updates") - if negotiate_only: - local_flags.append("--negotiate-only") - return self.run( - ["pull", *local_flags, "--", *required_flags], check_returncode=False - ) - - def init( - self, - template: Optional[str] = None, - separate_git_dir: Optional[StrOrBytesPath] = None, - object_format: Optional[Literal["sha1", "sha256"]] = None, - branch: Optional[str] = None, - initial_branch: Optional[str] = None, - shared: Optional[bool] = None, - quiet: Optional[bool] = None, - bare: Optional[bool] = None, - **kwargs, - ): - """Create empty repo. Wraps `git init `_. - - Parameters - ---------- - quiet : bool - ``--quiet`` - bare : bool - ``--bare`` - object_format : - Hash algorithm used for objects. SHA-256 is still experimental as of git - 2.36.0. - - Examples - -------- - >>> new_repo = tmp_path / 'example' - >>> new_repo.mkdir() - >>> git = Git(dir=new_repo) - >>> git.init() - 'Initialized empty Git repository in ...' - >>> pathlib.Path(new_repo / 'test').write_text('foo', 'utf-8') - 3 - >>> git.run(['add', '.']) - '' - - Bare: - - >>> new_repo = tmp_path / 'example1' - >>> new_repo.mkdir() - >>> git = Git(dir=new_repo) - >>> git.init(bare=True) - 'Initialized empty Git repository in ...' - >>> pathlib.Path(new_repo / 'HEAD').exists() - True - - Existing repo: - - >>> git = Git(dir=new_repo) - >>> git = Git(dir=git_local_clone.dir) - >>> git_remote_repo = create_git_remote_repo() - >>> git.init() - 'Reinitialized existing Git repository in ...' - - """ - required_flags: list[str] = [str(self.dir)] - local_flags: list[str] = [] - - if template is not None: - local_flags.append(f"--template={template}") - if separate_git_dir is not None: - local_flags.append(f"--separate-git-dir={separate_git_dir}") - if object_format is not None: - local_flags.append(f"--object-format={object_format}") - if branch is not None: - local_flags.extend(["--branch", branch]) - if initial_branch is not None: - local_flags.extend(["--initial-branch", initial_branch]) - if shared is True: - local_flags.append("--shared") - if quiet is True: - local_flags.append("--quiet") - if bare is True: - local_flags.append("--bare") - - return self.run( - ["init", *local_flags, "--", *required_flags], check_returncode=False - ) diff --git a/libvcs/cmd/hg.py b/libvcs/cmd/hg.py deleted file mode 100644 index 90d869efa..000000000 --- a/libvcs/cmd/hg.py +++ /dev/null @@ -1,208 +0,0 @@ -import enum -import pathlib -from typing import Optional, Sequence, Union - -from ..types import StrOrBytesPath, StrOrPath -from .core import run - -_CMD = Union[StrOrBytesPath, Sequence[StrOrBytesPath]] - - -class HgColorType(enum.Enum): - boolean = "boolean" - always = "always" - auto = "auto" - never = "never" - debug = "debug" - - -class HgPagerType(enum.Enum): - boolean = "boolean" - always = "always" - auto = "auto" - never = "never" - - -class Hg: - def __init__(self, dir: StrOrPath): - """Lite, typed, pythonic wrapper for hg(1). - - Parameters - ---------- - dir : - Operates as PATH in the corresponding hg subcommand. - - Examples - -------- - >>> Hg(dir=tmp_path) - - """ - #: Directory to check out - self.dir: pathlib.Path - if isinstance(dir, pathlib.Path): - self.dir = dir - else: - self.dir = pathlib.Path(dir) - - def __repr__(self): - return f"" - - def run( - self, - args: _CMD, - config: Optional[str] = None, - repository: Optional[str] = None, - quiet: Optional[bool] = None, - encoding: Optional[str] = None, - encoding_mode: Optional[str] = None, - verbose: Optional[bool] = None, - traceback: Optional[bool] = None, - debug: Optional[bool] = None, - debugger: Optional[bool] = None, - profile: Optional[bool] = None, - version: Optional[bool] = None, - hidden: Optional[bool] = None, - time: Optional[bool] = None, - pager: Optional[HgPagerType] = None, - color: Optional[HgColorType] = None, - **kwargs, - ): - """ - Passing None to a subcommand option, the flag won't be passed unless otherwise - stated. - - `hg help` and `hg help [cmd]` - - Wraps hg's `Options `_. - - Parameters - ---------- - quiet : bool - -q / --quiet - repository : str - ``--repository REPO`` - cwd : :attr:`libvcs.cmd.types.StrOrBytesPath`, optional - ``--cwd DIR``, Defaults to :attr:`~.cwd` - verbose : bool - ``-v / --verbose`` - non_interactive : bool - ``-y / --noninteractive``, defaults to True - color : HgColorTypeLiteral - ``--color`` - debug : bool - ``--debug`` - debugger : bool - ``--debugger`` - encoding : str - ``--encoding ENCODE`` - encoding_mode : str - ``--encodingmode MODE`` - traceback : bool - ``--traceback`` - time : bool - ``--time`` - profile : bool - ``--profile`` - version : bool - ``--version`` - help : bool - ``-h / --help`` - hidden : bool - ``--hidden`` - pager : HgPagerType - ``--pager TYPE`` - config : - ``--config CONFIG [+]``, ``section.name=value`` - - Examples - -------- - >>> hg = Hg(dir=tmp_path) - >>> hg.run(['help']) # doctest: +NORMALIZE_WHITESPACE - "Mercurial Distributed SCM..." - """ - - if isinstance(args, Sequence): - cli_args = ["hg", *args] - else: - cli_args = ["hg", args] - - if "cwd" not in kwargs: - kwargs["cwd"] = self.dir - - if repository is not None: - cli_args.extend(["--repository", repository]) - if config is not None: - cli_args.extend(["--config", config]) - if pager is not None: - cli_args.append(["--pager", pager]) - if color is not None: - cli_args.append(["--color", color]) - if verbose is True: - cli_args.append("verbose") - if quiet is True: - cli_args.append("--quiet") - if debug is True: - cli_args.append("--debug") - if debugger is True: - cli_args.append("--debugger") - if traceback is True: - cli_args.append("--traceback") - if time is True: - cli_args.append("--time") - if profile is True: - cli_args.append("--profile") - if version is True: - cli_args.append("--version") - if help is True: - cli_args.append("--help") - - return run(cmd=cli_args, **kwargs) - - def clone( - self, - url: str, - no_update: Optional[str] = None, - update_rev: Optional[str] = None, - rev: Optional[str] = None, - branch: Optional[str] = None, - ssh: Optional[str] = None, - remote_cmd: Optional[str] = None, - pull: Optional[bool] = None, - stream: Optional[bool] = None, - insecure: Optional[bool] = None, - ): - """Clone a working copy from a mercurial repo. - - Wraps `hg clone `_. - - Examples - -------- - >>> hg = Hg(dir=tmp_path) - >>> hg_remote_repo = create_hg_remote_repo() - >>> hg.clone(url=f'file://{hg_remote_repo}') - 'updating to branch default...1 files updated, 0 files merged, ...' - >>> hg.dir.exists() - True - """ - required_flags: list[str] = [url, str(self.dir)] - local_flags: list[str] = [] - - if ssh is not None: - local_flags.extend(["--ssh", ssh]) - if remote_cmd is not None: - local_flags.extend(["--remotecmd", remote_cmd]) - if rev is not None: - local_flags.extend(["--rev", rev]) - if branch is not None: - local_flags.extend(["--branch", branch]) - if no_update is True: - local_flags.append("--noupdate") - if pull is True: - local_flags.append("--pull") - if stream is True: - local_flags.append("--stream") - if insecure is True: - local_flags.append("--insecure") - return self.run( - ["clone", *local_flags, "--", *required_flags], check_returncode=False - ) diff --git a/libvcs/cmd/svn.py b/libvcs/cmd/svn.py deleted file mode 100644 index 1b867cd46..000000000 --- a/libvcs/cmd/svn.py +++ /dev/null @@ -1,778 +0,0 @@ -import pathlib -from typing import Literal, Optional, Sequence, Union - -from ..types import StrOrBytesPath, StrOrPath -from .core import run - -_CMD = Union[StrOrBytesPath, Sequence[StrOrBytesPath]] - -DepthLiteral = Union[Literal["infinity", "empty", "files", "immediates"], None] -RevisionLiteral = Union[Literal["HEAD", "BASE", "COMMITTED", "PREV"], None] - - -class Svn: - def __init__(self, dir: StrOrPath): - """Lite, typed, pythonic wrapper for svn(1). - - Parameters - ---------- - dir : - Operates as PATH in the corresponding svn subcommand. - - Examples - -------- - >>> Svn(dir=tmp_path) - - """ - #: Directory to check out - self.dir: pathlib.Path - if isinstance(dir, pathlib.Path): - self.dir = dir - else: - self.dir = pathlib.Path(dir) - - def __repr__(self): - return f"" - - def run( - self, - args: _CMD, - quiet: Optional[bool] = None, - username: Optional[str] = None, - password: Optional[str] = None, - no_auth_cache: Optional[bool] = None, - non_interactive: Optional[bool] = True, - trust_server_cert: Optional[bool] = None, - config_dir: Optional[pathlib.Path] = None, - config_option: Optional[pathlib.Path] = None, - **kwargs, - ): - """ - Passing None to a subcommand option, the flag won't be passed unless otherwise - stated. - - `svn help` and `svn help [cmd]` - - Wraps svn's `Options - `_. - - Parameters - ---------- - quiet : - -q / --quiet - username : - --username - password : - --password - no_auth_cache : - --no-auth-cache - non_interactive : - --non-interactive, defaults to True - trust_server_cert : - --trust-server-cert - config_dir : - --config-dir - config_option : - --config-option, ``FILE:SECTION:OPTION=[VALUE]`` - cwd : :attr:`libvcs.cmd.types.StrOrBytesPath`, optional - Defaults to :attr:`~.cwd` - - Examples - -------- - >>> svn = Svn(dir=tmp_path) - >>> svn.run(['help']) # doctest: +NORMALIZE_WHITESPACE - "usage: svn [options] [args]..." - """ - - if isinstance(args, Sequence): - cli_args = ["svn", *args] - else: - cli_args = ["svn", args] - - if "cwd" not in kwargs: - kwargs["cwd"] = self.dir - - if no_auth_cache is True: - cli_args.append("--no-auth-cache") - if non_interactive is True: - cli_args.append("--non-interactive") - if username is not None: - cli_args.extend(["--username", username]) - if password is not None: - cli_args.extend(["--password", password]) - if trust_server_cert is True: - cli_args.append("--trust-server_cert") - if config_dir is not None: - cli_args.extend(["--config-dir", str(config_dir)]) - if config_option is not None: - cli_args.extend(["--config-option", str(config_option)]) - - return run(cmd=cli_args, **kwargs) - - def checkout( - self, - url: str, - revision: Union[RevisionLiteral, str] = None, - force: Optional[bool] = None, - ignore_externals: Optional[bool] = None, - depth: DepthLiteral = None, - ): - """Check out a working copy from an SVN repo. - - Wraps `svn checkout - `_ (co). - - Parameters - ---------- - url : str - revision : Union[RevisionLiteral, str] - Number, '{ DATE }', 'HEAD', 'BASE', 'COMMITTED', 'PREV' - force : bool, optional - force operation to run - ignore_externals : bool, optional - ignore externals definitions - depth : - Sparse checkout support, Optional - - Examples - -------- - >>> svn = Svn(dir=tmp_path) - >>> svn_remote_repo = create_svn_remote_repo() - >>> svn.checkout(url=f'file://{svn_remote_repo}') - 'Checked out revision ...' - >>> svn.checkout(url=f'file://{svn_remote_repo}', revision=1) - 'svn: E160006: No such revision 1...' - """ - local_flags: list[str] = [url, str(self.dir)] - - if revision is not None: - local_flags.append(f"--revision={revision}") - if depth is not None: - local_flags.append(depth) - if force is True: - local_flags.append("--force") - if ignore_externals is True: - local_flags.append("--ignore-externals") - - return self.run(["checkout", *local_flags], check_returncode=False) - - def add( - self, - path: Union[list[pathlib.Path], pathlib.Path], - targets: Optional[pathlib.Path] = None, - depth: DepthLiteral = None, - force: Optional[bool] = None, - auto_props: Optional[bool] = None, - no_auto_props: Optional[bool] = None, - parents: Optional[bool] = None, - ): - """ - Passing None means the flag won't be passed unless otherwise stated. - - Wraps `svn add - `_. - - Parameters - ---------- - targets : pathlib.path - `--targets ARG`: contents of file ARG as additional args - depth : - `--depth ARG`, Sparse checkout support, Optional - force : - `--force`, Ignore already versioned paths - no_ignore : - `--no-ignore` - auto_props : - `--auto-props` - no_auto_props : - `--no-auto-props` - parents : - `--parents` - - Examples - -------- - >>> svn = Svn(dir=tmp_path) - >>> svn.checkout(url=f'file://{create_svn_remote_repo()}') - '...' - >>> new_file = tmp_path / 'new.txt' - >>> new_file.write_text('example text', encoding="utf-8") - 12 - >>> svn.add(path=new_file) - 'A new.txt' - """ - local_flags: list[str] = [] - - if isinstance(path, list): - local_flags.extend(str(p.absolute()) for p in path) - elif isinstance(path, pathlib.Path): - local_flags.append(str(path.absolute())) - - if force is True: - local_flags.append("--force") - if depth is not None: - local_flags.append(depth) - if auto_props is True: - local_flags.append("--auto-props") - if no_auto_props is True: - local_flags.append("--no-auto-props") - if parents is True: - local_flags.append("--parents") - - return self.run(["add", *local_flags]) - - def auth( - self, - remove: Optional[str] = None, - show_passwords: Optional[bool] = None, - *args, - **kwargs, - ): - """ - Wraps `svn auth - `_. - - Parameters - ---------- - remove : str, optional - Remove matching auth credentials - show_passwords : bool, optional - Show cached passwords - - Examples - -------- - >>> Svn(dir=tmp_path).auth() - "Credentials cache in '...' is empty" - """ - local_flags: list[str] = [*args] - - if remove is not None: - local_flags.extend(["--remove", remove]) - if show_passwords is True: - local_flags.append("--show-passwords") - - return self.run(["auth", *local_flags]) - - def blame( - self, - target: pathlib.Path, - revision: Union[RevisionLiteral, str] = None, - verbose: Optional[bool] = None, - force: Optional[bool] = None, - use_merge_history: Optional[bool] = None, - incremental: Optional[bool] = None, - xml: Optional[bool] = None, - extensions: Optional[str] = None, - *args, - **kwargs, - ): - """ - Wraps `svn blame - `_. - - Parameters - ---------- - target : pathlib.Path - path of file - revision : Union[RevisionLiteral, str] - Number, '{ DATE }', 'HEAD', 'BASE', 'COMMITTED', 'PREV' - verbose : bool - `-v`, `--verbose`, output extra info - use_merge_history : bool - `-g`, `--use-merge-history`, show extra mergeg info - incremental : bool - `--incremental`, give output suitable for concatenation - xml : bool - `--xml`, xml output - extensions : str, optional - Diff or blame tool (pass raw args). - force : bool, optional - force operation to run - - Examples - -------- - >>> svn = Svn(dir=tmp_path) - >>> svn.checkout(url=f'file://{create_svn_remote_repo()}') - 'Checked out revision ...' - >>> new_file = tmp_path / 'new.txt' - >>> new_file.write_text('example text', encoding="utf-8") - 12 - >>> svn.add(path=new_file) - 'A new.txt' - >>> svn.commit(path=new_file, message='My new commit') - '...' - >>> svn.blame('new.txt') - '1 ... example text' - """ - local_flags: list[str] = [target, *args] - - if revision is not None: - local_flags.append(f"--revision={revision}") - if verbose is True: - local_flags.append("--verbose") - if use_merge_history is True: - local_flags.append("--use-merge-history") - if incremental is True: - local_flags.append("--incremental") - if xml is True: - local_flags.append("--xml") - if extensions is not None: - local_flags.extend(["--extensions", extensions]) - if force is True: - local_flags.append("--force") - - return self.run(["blame", *local_flags]) - - def cat(self, *args, **kwargs): - """ - Wraps `svn cat - `_. - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["cat", *local_flags]) - - def changelist(self, *args, **kwargs): - """ - Wraps `svn changelist - `_ (cl). - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["changelist", *local_flags]) - - def cleanup(self, *args, **kwargs): - """ - Wraps `svn cleanup - `_. - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["cleanup", *local_flags]) - - def commit( - self, - path: Union[list[pathlib.Path], pathlib.Path], - targets: Optional[pathlib.Path] = None, - message: Optional[str] = None, - no_unlock: Optional[bool] = None, - file: Optional[pathlib.Path] = None, - depth: DepthLiteral = None, - encoding: Optional[str] = None, - force_log: Optional[bool] = None, - keep_changelists: Optional[bool] = None, - include_externals: Optional[bool] = None, - *args, - **kwargs, - ): - """ - Wraps `svn commit - `_ (ci). - - Parameters - ---------- - targets : pathlib.Path - `--targets ARG`: contents of file ARG as additional args - depth : - `--depth ARG`, Sparse checkout support, Optional - encoding : - `--encoding`, treat value as charset encoding passed - keep_changelists : - `--keep_changelists`, don't delete changelists after commit - force_log : - `--force-log`, Ignore already versioned paths - - Examples - -------- - >>> svn = Svn(dir=tmp_path) - >>> svn.checkout(url=f'file://{create_svn_remote_repo()}') - '...' - >>> new_file = tmp_path / 'new.txt' - >>> new_file.write_text('example text', encoding="utf-8") - 12 - >>> svn.add(path=new_file) - 'A new.txt' - >>> svn.commit(path=new_file, message='My new commit') - 'Adding new.txt...Transmitting file data...Committed revision 1.' - """ - local_flags: list[str] = [] - - if isinstance(path, list): - local_flags.extend(str(p.absolute()) for p in path) - elif isinstance(path, pathlib.Path): - local_flags.append(str(path.absolute())) - - if depth is not None: - local_flags.append(depth) - if message is not None: - local_flags.append(f'--message="{message}"') - if no_unlock is True: - local_flags.append("--no-unlock") - if file is not None: - local_flags.extend(["--file", str(file)]) - if force_log is True: - local_flags.append("--force") - if include_externals is True: - local_flags.append("--include-externals") - - return self.run(["commit", *local_flags]) - - def copy(self, *args, **kwargs): - """ - Wraps `svn copy - `_ (cp). - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["copy", *local_flags]) - - def delete(self, *args, **kwargs): - """ - Wraps `svn delete - `_ (del, remove, - rm). - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["delete", *local_flags]) - - def diff(self, *args, **kwargs): - """ - Wraps `svn diff - `_. - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["diff", *local_flags]) - - def export(self, *args, **kwargs): - """ - Wraps `svn export - `_. - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["export", *local_flags]) - - def help(self, *args, **kwargs): - """ - Wraps `svn help - `_ (?, h). - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["help", *local_flags]) - - def import_(self, *args, **kwargs): - """ - Wraps `svn import - `_. - - Due to python limitation, .import isn't possible. - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["import", *local_flags]) - - def info(self, *args, **kwargs): - """ - Wraps `svn info - `_. - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["info", *local_flags]) - - def list(self, *args, **kwargs): - """ - Wraps `svn list - `_ (ls). - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["list", *local_flags]) - - def lock(self, *args, **kwargs): - """ - Wraps `svn lock - `_. - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["lock", *local_flags]) - - def log(self, *args, **kwargs): - """ - Wraps `svn log - `_. - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["log", *local_flags]) - - def merge(self, *args, **kwargs): - """ - Wraps `svn merge - `_. - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["merge", *local_flags]) - - def mergelist(self, *args, **kwargs): - """ - Wraps `svn mergelist - `_. - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["mergelist", *local_flags]) - - def mkdir(self, *args, **kwargs): - """ - Wraps `svn mkdir - `_. - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["mkdir", *local_flags]) - - def move(self, *args, **kwargs): - """ - Wraps `svn move - `_ (mv, rename, - ren). - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["move", *local_flags]) - - def patch(self, *args, **kwargs): - """ - Wraps `svn patch - `_. - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["patch", *local_flags]) - - def propdel(self, *args, **kwargs): - """ - Wraps `svn propdel - `_ (pdel, pd). - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["propdel", *local_flags]) - - def propedit(self, *args, **kwargs): - """ - Wraps `svn propedit - `_ (pedit, pe). - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["propedit", *local_flags]) - - def propget(self, *args, **kwargs): - """ - Wraps `svn propget - `_ (pget, pg). - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["propget", *local_flags]) - - def proplist(self, *args, **kwargs): - """ - Wraps `svn proplist - `_ (plist, pl). - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["proplist", *local_flags]) - - def propset(self, *args, **kwargs): - """ - Wraps `svn propset - `_ (pset, ps). - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["propset", *local_flags]) - - def relocate(self, *args, **kwargs): - """ - Wraps `svn relocate - `_. - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["relocate", *local_flags]) - - def resolve(self, *args, **kwargs): - """ - Wraps `svn resolve - `_. - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["resolve", *local_flags]) - - def resolved(self, *args, **kwargs): - """ - Wraps `svn resolved - `_. - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["resolved", *local_flags]) - - def revert(self, *args, **kwargs): - """ - Wraps `svn revert - `_. - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["revert", *local_flags]) - - def status(self, *args, **kwargs): - """ - Wraps `svn status - `_ (stat, st). - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["status", *local_flags]) - - def switch(self, *args, **kwargs): - """ - Wraps `svn switch - `_ (sw). - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["switch", *local_flags]) - - def unlock(self, *args, **kwargs): - """ - Wraps `svn unlock - `_. - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["unlock", *local_flags]) - - def update(self, *args, **kwargs): - """ - Wraps `svn update - `_ (up). - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["update", *local_flags]) - - def upgrade(self, *args, **kwargs): - """ - Wraps `svn upgrade - `_. - - Parameters - ---------- - """ - local_flags: list[str] = [*args] - - self.run(["upgrade", *local_flags]) diff --git a/libvcs/conftest.py b/libvcs/conftest.py deleted file mode 100644 index 2aac2615b..000000000 --- a/libvcs/conftest.py +++ /dev/null @@ -1,362 +0,0 @@ -"""pytest fixtures. Live inside libvcs for doctest.""" -import functools -import getpass -import pathlib -import shutil -import textwrap -from typing import Any, Optional, Protocol - -import pytest - -from faker import Faker - -from libvcs.cmd.core import run, which -from libvcs.projects.git import GitProject, GitRemoteDict - -skip_if_git_missing = pytest.mark.skipif( - not which("git"), reason="git is not available" -) -skip_if_svn_missing = pytest.mark.skipif( - not which("svn"), reason="svn is not available" -) -skip_if_hg_missing = pytest.mark.skipif(not which("hg"), reason="hg is not available") - - -def pytest_ignore_collect(path, config: pytest.Config): - if not which("svn") and any(needle in path for needle in ["svn", "subversion"]): - return True - if not which("git") and "git" in path: - return True - if not which("hg") and any(needle in path for needle in ["hg", "mercurial"]): - return True - - return False - - -@pytest.fixture(autouse=True) -def home_default(monkeypatch: pytest.MonkeyPatch, user_path: pathlib.Path): - monkeypatch.setenv("HOME", str(user_path)) - - -@pytest.fixture(autouse=True, scope="session") -def home_path(tmp_path_factory: pytest.TempPathFactory): - return tmp_path_factory.mktemp("home") - - -@pytest.fixture(autouse=True, scope="session") -def user_path(home_path: pathlib.Path): - p = home_path / getpass.getuser() - p.mkdir() - return p - - -@pytest.fixture(autouse=True) -@pytest.mark.usefixtures("home_default") -@skip_if_git_missing -def gitconfig(user_path: pathlib.Path): - gitconfig = user_path / ".gitconfig" - user_email = "libvcs@git-pull.com" - gitconfig.write_text( - textwrap.dedent( - f""" - [user] - email = {user_email} - name = {getpass.getuser()} - """ - ), - encoding="utf-8", - ) - - output = run(["git", "config", "--get", "user.email"]) - assert user_email in output, "Should use our fixture config and home directory" - - return gitconfig - - -@pytest.fixture(autouse=True, scope="session") -@pytest.mark.usefixtures("home_default") -@skip_if_hg_missing -def hgconfig(user_path: pathlib.Path): - hgrc = user_path / ".hgrc" - hgrc.write_text( - textwrap.dedent( - f""" - [ui] - username = libvcs tests - merge = internal:merge - - [trusted] - users = {getpass.getuser()} - """ - ), - encoding="utf-8", - ) - return hgrc - - -@pytest.fixture(scope="function") -def projects_path(user_path: pathlib.Path, request: pytest.FixtureRequest): - """User's local checkouts and clones. Emphemeral directory.""" - dir = user_path / "projects" - dir.mkdir(exist_ok=True) - - def clean(): - shutil.rmtree(dir) - - request.addfinalizer(clean) - return dir - - -@pytest.fixture(scope="function") -def remote_repos_path(user_path: pathlib.Path, request: pytest.FixtureRequest): - """System's remote (file-based) repos to clone andpush to. Emphemeral directory.""" - dir = user_path / "remote_repos" - dir.mkdir(exist_ok=True) - - def clean(): - shutil.rmtree(dir) - - request.addfinalizer(clean) - return dir - - -def unique_repo_name( - faker: Faker, remote_repos_path: pathlib.Path, max_retries: int = 15 -) -> str: - attempts = 1 - while True: - if attempts > max_retries: - raise Exception( - f"Could not find unused repo destination (attempts: {attempts})" - ) - remote_repo_name = faker.slug() - suggestion = remote_repos_path / remote_repo_name - if suggestion.exists(): - attempts += 1 - continue - return remote_repo_name - - -class CreateProjectCallbackProtocol(Protocol): - def __call__(self, remote_repo_path: pathlib.Path): - ... - - -class CreateProjectCallbackFixtureProtocol(Protocol): - def __call__( - self, - remote_repos_path: Optional[pathlib.Path] = None, - remote_repo_name: Optional[str] = None, - remote_repo_post_init: Optional[CreateProjectCallbackProtocol] = None, - ): - ... - - -def _create_git_remote_repo( - remote_repos_path: pathlib.Path, - remote_repo_name: str, - remote_repo_post_init: Optional[CreateProjectCallbackProtocol] = None, -) -> pathlib.Path: - remote_repo_path = remote_repos_path / remote_repo_name - run(["git", "init", remote_repo_name], cwd=remote_repos_path) - - if remote_repo_post_init is not None and callable(remote_repo_post_init): - remote_repo_post_init(remote_repo_path=remote_repo_path) - - return remote_repo_path - - -@pytest.fixture -@skip_if_git_missing -def create_git_remote_repo(remote_repos_path: pathlib.Path, faker: Faker): - """Factory. Create git remote repo to for clone / push purposes""" - - def fn( - remote_repos_path: pathlib.Path = remote_repos_path, - remote_repo_name: Optional[str] = None, - remote_repo_post_init: Optional[CreateProjectCallbackProtocol] = None, - ): - return _create_git_remote_repo( - remote_repos_path=remote_repos_path, - remote_repo_name=remote_repo_name - if remote_repo_name is not None - else unique_repo_name(faker=faker, remote_repos_path=remote_repos_path), - remote_repo_post_init=remote_repo_post_init, - ) - - return fn - - -def git_remote_repo_single_commit_post_init(remote_repo_path: pathlib.Path): - testfile_filename = "testfile.test" - run(["touch", testfile_filename], cwd=remote_repo_path) - run(["git", "add", testfile_filename], cwd=remote_repo_path) - run(["git", "commit", "-m", "test file for dummyrepo"], cwd=remote_repo_path) - - -@pytest.fixture -@pytest.mark.usefixtures("gitconfig", "home_default") -@skip_if_git_missing -def git_remote_repo(remote_repos_path: pathlib.Path): - """Pre-made git repo w/ 1 commit, used as a file:// remote to clone and push to.""" - return _create_git_remote_repo( - remote_repos_path=remote_repos_path, - remote_repo_name="dummyrepo", - remote_repo_post_init=git_remote_repo_single_commit_post_init, - ) - - -def _create_svn_remote_repo( - remote_repos_path: pathlib.Path, - remote_repo_name: str, - remote_repo_post_init: Optional[CreateProjectCallbackProtocol] = None, -) -> pathlib.Path: - """Create a test SVN repo to for checkout / commit purposes""" - - remote_repo_path = remote_repos_path / remote_repo_name - run(["svnadmin", "create", remote_repo_path]) - - if remote_repo_post_init is not None and callable(remote_repo_post_init): - remote_repo_post_init(remote_repo_path=remote_repo_path) - - return remote_repo_path - - -@pytest.fixture -@skip_if_svn_missing -def create_svn_remote_repo(remote_repos_path: pathlib.Path, faker: Faker): - """Pre-made svn repo, bare, used as a file:// remote to checkout and commit to.""" - - def fn( - remote_repos_path: pathlib.Path = remote_repos_path, - remote_repo_name: Optional[str] = None, - remote_repo_post_init: Optional[CreateProjectCallbackProtocol] = None, - ): - return _create_svn_remote_repo( - remote_repos_path=remote_repos_path, - remote_repo_name=remote_repo_name - if remote_repo_name is not None - else unique_repo_name(faker=faker, remote_repos_path=remote_repos_path), - remote_repo_post_init=remote_repo_post_init, - ) - - return fn - - -@pytest.fixture -@skip_if_svn_missing -def svn_remote_repo(remote_repos_path: pathlib.Path) -> pathlib.Path: - """Pre-made. Local file:// based SVN server.""" - svn_repo_name = "svn_server_dir" - remote_repo_path = _create_svn_remote_repo( - remote_repos_path=remote_repos_path, - remote_repo_name=svn_repo_name, - remote_repo_post_init=None, - ) - - return remote_repo_path - - -def _create_hg_remote_repo( - remote_repos_path: pathlib.Path, - remote_repo_name: str, - remote_repo_post_init: Optional[CreateProjectCallbackProtocol] = None, -) -> pathlib.Path: - """Create a test hg repo to for checkout / commit purposes""" - remote_repo_path = remote_repos_path / remote_repo_name - run(["hg", "init", remote_repo_name], cwd=remote_repos_path) - - if remote_repo_post_init is not None and callable(remote_repo_post_init): - remote_repo_post_init(remote_repo_path=remote_repo_path) - - return remote_repo_path - - -def hg_remote_repo_single_commit_post_init(remote_repo_path: pathlib.Path): - testfile_filename = "testfile.test" - run(["touch", testfile_filename], cwd=remote_repo_path) - run(["hg", "add", testfile_filename], cwd=remote_repo_path) - run(["hg", "commit", "-m", "test file for hg repo"], cwd=remote_repo_path) - - -@pytest.fixture -@pytest.mark.usefixtures("hgconfig") -@skip_if_hg_missing -def create_hg_remote_repo(remote_repos_path: pathlib.Path, faker: Faker): - """Pre-made hg repo, bare, used as a file:// remote to checkout and commit to.""" - - def fn( - remote_repos_path: pathlib.Path = remote_repos_path, - remote_repo_name: Optional[str] = None, - remote_repo_post_init: Optional[CreateProjectCallbackProtocol] = None, - ): - return _create_hg_remote_repo( - remote_repos_path=remote_repos_path, - remote_repo_name=remote_repo_name - if remote_repo_name is not None - else unique_repo_name(faker=faker, remote_repos_path=remote_repos_path), - remote_repo_post_init=remote_repo_post_init, - ) - - return fn - - -@pytest.fixture -@pytest.mark.usefixtures("hgconfig") -@skip_if_hg_missing -def hg_remote_repo(remote_repos_path: pathlib.Path): - """Pre-made, file-based repo for push and pull.""" - return _create_hg_remote_repo( - remote_repos_path=remote_repos_path, - remote_repo_name="dummyrepo", - remote_repo_post_init=hg_remote_repo_single_commit_post_init, - ) - - -@pytest.fixture -def git_repo(projects_path: pathlib.Path, git_remote_repo: pathlib.Path): - """Pre-made git clone of remote repo checked out to user's projects dir.""" - git_repo = GitProject( - url=f"file://{git_remote_repo}", - dir=str(projects_path / "git_repo"), - remotes={ - "origin": GitRemoteDict( - **{ - "push_url": f"file://{git_remote_repo}", - "fetch_url": f"file://{git_remote_repo}", - } - ) - }, - ) - git_repo.obtain() - return git_repo - - -@pytest.fixture(autouse=True) -def add_doctest_fixtures( - doctest_namespace: dict[str, Any], - tmp_path: pathlib.Path, - home_default: pathlib.Path, - gitconfig: pathlib.Path, - create_git_remote_repo: CreateProjectCallbackFixtureProtocol, - create_svn_remote_repo: CreateProjectCallbackFixtureProtocol, - create_hg_remote_repo: CreateProjectCallbackFixtureProtocol, - git_repo: pathlib.Path, -): - doctest_namespace["tmp_path"] = tmp_path - if which("git"): - doctest_namespace["gitconfig"] = gitconfig - doctest_namespace["create_git_remote_repo"] = functools.partial( - create_git_remote_repo, - remote_repo_post_init=git_remote_repo_single_commit_post_init, - ) - doctest_namespace["create_git_remote_repo_bare"] = create_git_remote_repo - doctest_namespace["git_local_clone"] = git_repo - if which("svn"): - doctest_namespace["create_svn_remote_repo"] = create_svn_remote_repo - if which("hg"): - doctest_namespace["create_hg_remote_repo_bare"] = create_hg_remote_repo - doctest_namespace["create_hg_remote_repo"] = functools.partial( - create_hg_remote_repo, - remote_repo_post_init=hg_remote_repo_single_commit_post_init, - ) diff --git a/libvcs/projects/constants.py b/libvcs/projects/constants.py deleted file mode 100644 index 2bede0688..000000000 --- a/libvcs/projects/constants.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import Dict, Literal, Type, Union - -from libvcs import GitProject, MercurialProject, SubversionProject - -#: Default VCS systems by string (in :data:`DEFAULT_VCS_CLASS_MAP`) -DEFAULT_VCS_LITERAL = Literal["git", "hg", "svn"] -#: Union of VCS Classes -DEFAULT_VCS_CLASS_UNION = Type[Union[GitProject, MercurialProject, SubversionProject]] -#: ``str`` -> ``class`` Map. ``DEFAULT_VCS_CLASS_MAP['git']`` -> -#: :class:`~libvcs.projects.git.GitProject` -DEFAULT_VCS_CLASS_MAP: Dict[DEFAULT_VCS_LITERAL, DEFAULT_VCS_CLASS_UNION] = { - "git": GitProject, - "svn": SubversionProject, - "hg": MercurialProject, -} diff --git a/libvcs/projects/hg.py b/libvcs/projects/hg.py deleted file mode 100644 index 367a97caf..000000000 --- a/libvcs/projects/hg.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Mercurial Repo object for libvcs. - -.. todo:: - - The following is from pypa/pip (MIT license): - - - [`MercurialProject.convert_pip_url`](libvcs.hg.convert_pip_url) - - [`MercurialProject.get_url`](libvcs.hg.MercurialProject.get_url) - - [`MercurialProject.get_revision`](libvcs.hg.MercurialProject.get_revision) -""" # NOQA E5 -import logging -import pathlib - -from .base import BaseProject - -logger = logging.getLogger(__name__) - - -class MercurialProject(BaseProject): - bin_name = "hg" - schemes = ("hg", "hg+http", "hg+https", "hg+file") - - def obtain(self, *args, **kwargs): - self.ensure_dir() - - # Double hyphens between [OPTION]... -- SOURCE [DEST] prevent command injections - # via aliases - self.run(["clone", "--noupdate", "-q", "--", self.url, self.dir]) - self.run(["update", "-q"]) - - def get_revision(self): - return self.run(["parents", "--template={rev}"]) - - def update_repo(self, *args, **kwargs): - self.ensure_dir() - if not pathlib.Path(self.dir / ".hg").exists(): - self.obtain() - self.update_repo() - else: - self.run(["update"]) - self.run(["pull", "-u"]) diff --git a/libvcs/projects/svn.py b/libvcs/projects/svn.py deleted file mode 100644 index a094f9e08..000000000 --- a/libvcs/projects/svn.py +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env python -"""Subversion object for libvcs. - -.. todo:: - - The follow are from saltstack/salt (Apache license): - - - [`SubversionProject.get_revision_file`](libvcs.svn.SubversionProject.get_revision_file) - - The following are pypa/pip (MIT license): - - - [`SubversionProject.convert_pip_url`](libvcs.svn.SubversionProject.convert_pip_url) - - [`SubversionProject.get_url`](libvcs.svn.SubversionProject.get_url) - - [`SubversionProject.get_revision`](libvcs.svn.SubversionProject.get_revision) - - [`get_rev_options`](libvcs.svn.get_rev_options) -""" # NOQA: E5 -import logging -import os -import pathlib -import re -from urllib import parse as urlparse - -from .base import BaseProject, VCSLocation, convert_pip_url as base_convert_pip_url - -logger = logging.getLogger(__name__) - - -def convert_pip_url(pip_url: str) -> VCSLocation: - # hotfix the URL scheme after removing svn+ from svn+ssh:// re-add it - url, rev = base_convert_pip_url(pip_url) - if url.startswith("ssh://"): - url = "svn+" + url - return VCSLocation(url=url, rev=rev) - - -class SubversionProject(BaseProject): - bin_name = "svn" - schemes = ("svn", "svn+ssh", "svn+http", "svn+https", "svn+svn") - - def __init__(self, url, dir, *args, **kwargs): - """A svn repository. - - Parameters - ---------- - url : str - URL in subversion repository - - svn_username : str, optional - username to use for checkout and update - - svn_password : str, optional - password to use for checkout and update - - svn_trust_cert : bool - trust the Subversion server site certificate, default False - """ - if "svn_trust_cert" not in kwargs: - self.svn_trust_cert = False - - self.rev = kwargs.get("rev") - super().__init__(url, dir, *args, **kwargs) - - def _user_pw_args(self): - args = [] - for param_name in ["svn_username", "svn_password"]: - if hasattr(self, param_name): - args.extend(["--" + param_name[4:], getattr(self, param_name)]) - return args - - def obtain(self, quiet=None): - self.ensure_dir() - - url, rev = self.url, self.rev - - cmd = ["checkout", "-q", url, "--non-interactive"] - if self.svn_trust_cert: - cmd.append("--trust-server-cert") - cmd.extend(self._user_pw_args()) - cmd.extend(get_rev_options(url, rev)) - cmd.append(self.dir) - - self.run(cmd) - - def get_revision_file(self, location): - """Return revision for a file.""" - - current_rev = self.run(["info", location]) - - _INI_RE = re.compile(r"^([^:]+):\s+(\S.*)$", re.M) - - info_list = _INI_RE.findall(current_rev) - return int(dict(info_list)["Revision"]) - - def get_revision(self, location=None): - """Return maximum revision for all files under a given location""" - - if not location: - location = self.url - - if os.path.exists(location) and not os.path.isdir(location): - return self.get_revision_file(location) - - # Note: taken from setuptools.command.egg_info - revision = 0 - - for base, dirs, files in os.walk(location): - if ".svn" not in dirs: - dirs[:] = [] - continue # no sense walking uncontrolled subdirs - dirs.remove(".svn") - entries_fn = os.path.join(base, ".svn", "entries") - if not os.path.exists(entries_fn): - # FIXME: should we warn? - continue - - dirurl, localrev = self._get_svn_url_rev(base) - - if base == location: - base_url = dirurl + "/" # save the root url - elif not dirurl or not dirurl.startswith(base_url): - dirs[:] = [] - continue # not part of the same svn tree, skip it - revision = max(revision, localrev) - return revision - - def update_repo(self, dest=None, *args, **kwargs): - self.ensure_dir() - if pathlib.Path(self.dir / ".svn").exists(): - dest = self.dir if not dest else dest - - url, rev = self.url, self.rev - - cmd = ["update"] - cmd.extend(self._user_pw_args()) - cmd.extend(get_rev_options(url, rev)) - - self.run(cmd) - else: - self.obtain() - self.update_repo() - - -def get_rev_options(url, rev): - """Return revision options. From pip pip.vcs.subversion.""" - if rev: - rev_options = ["-r", rev] - else: - rev_options = [] - - r = urlparse.urlsplit(url) - if hasattr(r, "username"): - # >= Python-2.5 - username, password = r.username, r.password - else: - netloc = r[1] - if "@" in netloc: - auth = netloc.split("@")[0] - if ":" in auth: - username, password = auth.split(":", 1) - else: - username, password = auth, None - else: - username, password = None, None - - if username: - rev_options += ["--username", username] - if password: - rev_options += ["--password", password] - return rev_options diff --git a/libvcs/shortcuts.py b/libvcs/shortcuts.py deleted file mode 100644 index a639a1725..000000000 --- a/libvcs/shortcuts.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Shortcuts""" -from typing import Union - -from libvcs import GitProject, MercurialProject, SubversionProject -from libvcs.exc import InvalidPipURL, InvalidVCS - - -def create_project( - url, vcs, progress_callback=None, *args, **kwargs -) -> Union[GitProject, MercurialProject, SubversionProject]: - r"""Return an object representation of a VCS repository. - - Examples - -------- - >>> from libvcs.shortcuts import create_project - >>> r = create_project( - ... url=f'file://{create_git_remote_repo()}', - ... vcs='git', - ... dir=tmp_path - ... ) - - >>> isinstance(r, GitProject) - True - """ - if vcs == "git": - return GitProject(url, progress_callback=progress_callback, *args, **kwargs) - elif vcs == "hg": - return MercurialProject( - url, progress_callback=progress_callback, *args, **kwargs - ) - elif vcs == "svn": - return SubversionProject( - url, progress_callback=progress_callback, *args, **kwargs - ) - else: - raise InvalidVCS("VCS %s is not a valid VCS" % vcs) - - -def create_project_from_pip_url( - pip_url, **kwargs -) -> Union[GitProject, MercurialProject, SubversionProject]: - r"""Return an object representation of a VCS repository via pip-style url. - - Examples - -------- - - >>> from libvcs.shortcuts import create_project_from_pip_url - >>> r = create_project_from_pip_url( - ... pip_url=f'git+{create_git_remote_repo()}', - ... dir=tmp_path - ... ) - >>> isinstance(r, GitProject) - True - """ - if pip_url.startswith("git+"): - return GitProject.from_pip_url(pip_url, **kwargs) - elif pip_url.startswith("hg+"): - return MercurialProject.from_pip_url(pip_url, **kwargs) - elif pip_url.startswith("svn+"): - return SubversionProject.from_pip_url(pip_url, **kwargs) - else: - raise InvalidPipURL(pip_url) diff --git a/libvcs/types.py b/libvcs/types.py deleted file mode 100644 index 02b327fd4..000000000 --- a/libvcs/types.py +++ /dev/null @@ -1,12 +0,0 @@ -from os import PathLike -from typing import Union - -# See also, if this type is baked in in typing in the future -# - https://stackoverflow.com/q/53418046/1396928 -# - https://github.com/python/typeshed/issues/5912 -# PathLike = TypeVar("PathLike", str, pathlib.Path) -# OptionalPathLike = TypeVar("OptionalPathLike", str, pathlib.Path, None) - - -StrOrBytesPath = Union[str, bytes, PathLike[str], PathLike[bytes]] # stable -StrOrPath = Union[str, PathLike[str]] # stable diff --git a/libvcs/utils/__init__.py b/libvcs/utils/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/libvcs/utils/query_list.py b/libvcs/utils/query_list.py deleted file mode 100644 index 6c6456df3..000000000 --- a/libvcs/utils/query_list.py +++ /dev/null @@ -1,219 +0,0 @@ -import re -import traceback -from typing import Any, Callable, Optional, Protocol, Sequence, TypeVar, Union - -T = TypeVar("T", Any, Any) - - -def keygetter(obj, path): - """obj, "foods__breakfast", obj['foods']['breakfast'] - - >>> keygetter({ "foods": { "breakfast": "cereal" } }, "foods__breakfast") - 'cereal' - >>> keygetter({ "foods": { "breakfast": "cereal" } }, "foods") - {'breakfast': 'cereal'} - - """ - try: - sub_fields = path.split("__") - dct = obj - for sub_field in sub_fields: - dct = dct[sub_field] - return dct - except Exception as e: - traceback.print_exception(e) - return None - - -def parse_lookup(obj, path, lookup): - """Check if field lookup key, e.g. "my__path__contains" has comparator, return val. - - If comparator not used or value not found, return None. - - mykey__endswith("mykey") -> "mykey" else None - - >>> parse_lookup({ "food": "red apple" }, "food__istartswith", "__istartswith") - 'red apple' - """ - try: - if path.endswith(lookup): - if field_name := path.rsplit(lookup)[0]: - return keygetter(obj, field_name) - except Exception as e: - traceback.print_exception(e) - return None - - -class LookupProtocol(Protocol): - """Protocol for :class:`QueryList` filtering operators.""" - - def __call__(self, data: Union[list[str], str], rhs: Union[list[str], str]): - """Callback for :class:`QueryList` filtering operators.""" - - -def lookup_exact(data, rhs): - return rhs == data - - -def lookup_iexact(data, rhs): - return rhs.lower() == data.lower() - - -def lookup_contains(data, rhs): - return rhs in data - - -def lookup_icontains(data, rhs): - return rhs.lower() in data.lower() - - -def lookup_startswith(data, rhs): - return data.startswith(rhs) - - -def lookup_istartswith(data, rhs): - return data.lower().startswith(rhs.lower()) - - -def lookup_endswith(data, rhs): - return data.endswith(rhs) - - -def lookup_iendswith(data, rhs): - return data.lower().endswith(rhs.lower()) - - -def lookup_in(data, rhs): - if isinstance(rhs, list): - return data in rhs - return rhs in data - - -def lookup_nin(data, rhs): - if isinstance(rhs, list): - return data not in rhs - return rhs not in data - - -def lookup_regex(data, rhs): - return re.search(rhs, data) - - -def lookup_iregex(data, rhs): - return re.search(rhs, data, re.IGNORECASE) - - -LOOKUP_NAME_MAP: dict[str, LookupProtocol] = { - "eq": lookup_exact, - "exact": lookup_exact, - "iexact": lookup_iexact, - "contains": lookup_contains, - "icontains": lookup_icontains, - "startswith": lookup_startswith, - "istartswith": lookup_istartswith, - "endswith": lookup_endswith, - "iendswith": lookup_iendswith, - "in": lookup_in, - "nin": lookup_nin, - "regex": lookup_regex, - "iregex": lookup_iregex, -} - - -class QueryList(list[T]): - """Filter list of object/dicts. For small, local datasets. *Experimental, unstable*. - - >>> query = QueryList( - ... [ - ... { - ... "place": "Largo", - ... "city": "Tampa", - ... "state": "Florida", - ... "foods": {"fruit": ["banana", "orange"], "breakfast": "cereal"}, - ... }, - ... { - ... "place": "Chicago suburbs", - ... "city": "Elmhurst", - ... "state": "Illinois", - ... "foods": {"fruit": ["apple", "cantelope"], "breakfast": "waffles"}, - ... }, - ... ] - ... ) - >>> query.filter(place="Chicago suburbs")[0]['city'] - 'Elmhurst' - >>> query.filter(place__icontains="chicago")[0]['city'] - 'Elmhurst' - >>> query.filter(foods__breakfast="waffles")[0]['city'] - 'Elmhurst' - >>> query.filter(foods__fruit__in="cantelope")[0]['city'] - 'Elmhurst' - >>> query.filter(foods__fruit__in="orange")[0]['city'] - 'Tampa' - """ - - data: Sequence[T] - - def items(self): - data: Sequence[T] - - if self.pk_key is None: - raise Exception("items() require a pk_key exists") - return [(getattr(item, self.pk_key), item) for item in self] - - def __eq__(self, other): - data = other - - if not isinstance(self, list) or not isinstance(data, list): - return False - - if len(self) == len(data): - for (a, b) in zip(self, data): - if isinstance(a, dict): - a_keys = a.keys() - if a.keys == b.keys(): - for key in a_keys: - if abs(a[key] - b[key]) > 1: - return False - else: - if a != b: - return False - - return True - return False - - def filter(self, matcher: Optional[Union[Callable[[T], bool], T]] = None, **kwargs): - def filter_lookup(obj) -> bool: - for path, v in kwargs.items(): - try: - lhs, op = path.rsplit("__", 1) - - if op not in LOOKUP_NAME_MAP: - raise ValueError(f"{op} not in LOOKUP_NAME_MAP") - except ValueError: - lhs = path - op = "exact" - - assert op in LOOKUP_NAME_MAP - path = lhs - data = keygetter(obj, path) - - if not LOOKUP_NAME_MAP[op](data, v): - return False - - return True - - if callable(matcher): - _filter = matcher - elif matcher is not None: - - def val_match(obj): - if isinstance(matcher, list): - return obj in matcher - else: - return obj == matcher - - _filter = val_match - else: - _filter = filter_lookup - - return self.__class__(k for k in self if _filter(k)) diff --git a/notes/2025-11-26-command-support.md b/notes/2025-11-26-command-support.md new file mode 100644 index 000000000..32bc4be6b --- /dev/null +++ b/notes/2025-11-26-command-support.md @@ -0,0 +1,612 @@ +# Git Command Support Audit - 2025-11-26 + +This document provides a comprehensive audit of git command support in libvcs, documenting existing implementations and planned additions following the Manager/Cmd pattern. + +## Pattern Overview + +``` +Manager (collection-level) Cmd (per-entity) +├── ls() -> QueryList[Cmd] ├── run() +├── get(**kwargs) -> Cmd | None ├── show() +├── filter(**kwargs) -> list[Cmd] ├── remove()/delete() +├── add() / create() ├── rename() +└── run() └── entity-specific operations +``` + +--- + +## 1. GitBranchManager / GitBranchCmd + +**Pattern Status**: Implemented + +### GitBranchManager (Collection-level) + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, cmd)` | Implemented | Constructor | +| `__repr__()` | Implemented | String representation | +| `run(command, local_flags, quiet, cached)` | Implemented | Run git branch command | +| `checkout(branch)` | Implemented | Checkout a branch | +| `create(branch)` | Implemented | Create new branch via `checkout -b` | +| `_ls()` | Implemented | Internal raw listing | +| `ls()` | Implemented | Returns `QueryList[GitBranchCmd]` | +| `get(**kwargs)` | Implemented | Get single branch by filter | +| `filter(**kwargs)` | Implemented | Filter branches | + +#### CLI Flag → Python Parameter Mapping: `ls()` Enhancements + +| Git CLI Flag | Python Parameter | Description | +|--------------|------------------|-------------| +| `-a, --all` | `_all: bool` | List all branches (local + remote) | +| `-r, --remotes` | `remotes: bool` | List remote branches only | +| `--merged ` | `merged: str \| None` | Filter merged branches | +| `--no-merged ` | `no_merged: str \| None` | Filter unmerged branches | +| `-v, --verbose` | `verbose: bool` | Show tracking info | +| `--contains ` | `contains: str \| None` | Branches containing commit | +| `--sort=` | `sort: str \| None` | Sort key | + +### GitBranchCmd (Per-entity) + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, branch_name, cmd)` | Implemented | Constructor with `branch_name` | +| `__repr__()` | Implemented | String representation | +| `run(command, local_flags, quiet, cached)` | Implemented | Run git branch command | +| `checkout()` | Implemented | Checkout this branch | +| `create()` | Implemented | Create this branch | +| `delete(force)` | Implemented | `-d` / `-D` | +| `rename(new_name, force)` | Implemented | `-m` / `-M` | +| `copy(new_name, force)` | Implemented | `-c` / `-C` | +| `set_upstream(upstream)` | Implemented | `--set-upstream-to` | +| `unset_upstream()` | Implemented | `--unset-upstream` | +| `track(remote_branch)` | Implemented | `-t` / `--track` | + +#### CLI Flag → Python Parameter Mapping: GitBranchCmd Methods + +| Method | Git CLI | Parameters → Flags | +|--------|---------|-------------------| +| `delete()` | `git branch -d/-D` | `force=True` → `-D`, else `-d` | +| `rename(new_name)` | `git branch -m/-M` | `force=True` → `-M`, else `-m` | +| `copy(new_name)` | `git branch -c/-C` | `force=True` → `-C`, else `-c` | +| `set_upstream(upstream)` | `git branch --set-upstream-to=` | `upstream` → `--set-upstream-to={upstream}` | +| `unset_upstream()` | `git branch --unset-upstream` | None | +| `track(remote_branch)` | `git branch -t` | `remote_branch` → `-t {remote_branch}` | + +### GitBranchManager Enhancements + +| Feature | Status | Description | +|---------|--------|-------------| +| `--all` support | Implemented | List all branches (local + remote) | +| `--remotes` support | Implemented | List remote branches only | +| `--merged` filter | Implemented | Filter merged branches | +| `--no-merged` filter | Implemented | Filter unmerged branches | +| `--verbose` support | Implemented | Show tracking info | + +--- + +## 2. GitRemoteManager / GitRemoteCmd + +**Pattern Status**: Implemented + +### GitRemoteManager (Collection-level) + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, cmd)` | Implemented | Constructor | +| `__repr__()` | Implemented | String representation | +| `run(command, local_flags)` | Implemented | Run git remote command | +| `add(name, url, fetch, track, master, mirror)` | Implemented | Add new remote | +| `show(name, verbose, no_query_remotes)` | Implemented | Show remotes | +| `_ls()` | Implemented | Internal raw listing | +| `ls()` | Implemented | Returns `QueryList[GitRemoteCmd]` | +| `get(**kwargs)` | Implemented | Get single remote by filter | +| `filter(**kwargs)` | Implemented | Filter remotes | + +### GitRemoteCmd (Per-entity) + +Properties: `remote_name`, `fetch_url`, `push_url` + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, remote_name, fetch_url, push_url, cmd)` | Implemented | Constructor | +| `__repr__()` | Implemented | String representation | +| `run(command, local_flags, verbose)` | Implemented | Run git remote command | +| `rename(old, new, progress)` | Implemented | Rename remote | +| `remove()` | Implemented | Delete remote | +| `show(verbose, no_query_remotes)` | Implemented | Show remote details | +| `prune(dry_run)` | Implemented | Prune stale branches | +| `get_url(push, _all)` | Implemented | Get remote URL | +| `set_url(url, old_url, push, add, delete)` | Implemented | Set remote URL | +| `set_branches(*branches, add)` | Implemented | `set-branches` | +| `set_head(branch, auto, delete)` | Implemented | `set-head` | +| `update(prune)` | Implemented | `update` | + +#### CLI Flag → Python Parameter Mapping: Existing Methods + +| Method | Parameters → Flags | +|--------|-------------------| +| `rename()` | `progress=True` → `--progress`, `progress=False` → `--no-progress` | +| `show()` | `verbose=True` → `--verbose`, `no_query_remotes=True` → `-n` | +| `prune()` | `dry_run=True` → `--dry-run` | +| `get_url()` | `push=True` → `--push`, `_all=True` → `--all` | +| `set_url()` | `push=True` → `--push`, `add=True` → `--add`, `delete=True` → `--delete` | + +#### CLI Flag → Python Parameter Mapping: Additional Methods + +| Method | Git CLI | Parameters → Flags | +|--------|---------|-------------------| +| `set_branches(*branches)` | `git remote set-branches` | `add=True` → `--add`, `branches` → positional | +| `set_head(branch)` | `git remote set-head` | `auto=True` → `-a`, `delete=True` → `-d`, `branch` → positional | +| `update()` | `git remote update` | `prune=True` → `-p` | + +--- + +## 3. GitStashManager / GitStashEntryCmd + +**Pattern Status**: Implemented + +### Current GitStashCmd + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, cmd)` | Implemented | Constructor | +| `__repr__()` | Implemented | String representation | +| `run(command, local_flags, quiet, cached)` | Implemented | Run git stash command | +| `ls()` | Implemented | List stashes (returns string) | +| `push(path, patch, staged)` | Implemented | Push to stash | +| `pop(stash, index, quiet)` | Implemented | Pop from stash | +| `save(message, staged, keep_index, patch, include_untracked, _all, quiet)` | Implemented | Save to stash | + +### Planned GitStashManager (Collection-level) + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, cmd)` | Implemented | Constructor | +| `run(command, local_flags)` | Implemented | Run git stash command | +| `ls()` | Implemented | Returns `QueryList[GitStashEntryCmd]` | +| `get(**kwargs)` | Implemented | Get single stash by filter | +| `filter(**kwargs)` | Implemented | Filter stashes | +| `push(message, path, patch, staged, keep_index, include_untracked)` | Implemented | Push to stash | +| `clear()` | Implemented | Clear all stashes | + +#### CLI Flag → Python Parameter Mapping: `push()` + +| Git CLI Flag | Python Parameter | Description | +|--------------|------------------|-------------| +| `-p, --patch` | `patch: bool` | Interactive patch selection | +| `-S, --staged` | `staged: bool` | Stash only staged changes | +| `-k, --keep-index` | `keep_index: bool` | Keep index intact | +| `-u, --include-untracked` | `include_untracked: bool` | Include untracked files | +| `-a, --all` | `_all: bool` | Include ignored files | +| `-q, --quiet` | `quiet: bool` | Suppress output | +| `-m, --message ` | `message: str \| None` | Stash message | +| `-- ` | `path: list[str] \| None` | Limit to paths | + +### Planned GitStashEntryCmd (Per-entity) + +Properties: `index: int`, `branch: str`, `message: str` + +Parse from: `stash@{0}: On master: my message` + +**Parsing pattern**: +```python +stash_pattern = r"stash@\{(?P\d+)\}: On (?P[^:]+): (?P.+)" +``` + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, index, branch, message, cmd)` | Implemented | Constructor | +| `show(stat, patch)` | Implemented | Show stash diff | +| `apply(index)` | Implemented | Apply without removing | +| `pop(index)` | Implemented | Apply and remove | +| `drop()` | Implemented | Delete this stash | +| `branch(branch_name)` | Implemented | Create branch from stash | + +#### CLI Flag → Python Parameter Mapping: GitStashEntryCmd Methods + +| Method | Git CLI | Parameters → Flags | +|--------|---------|-------------------| +| `show()` | `git stash show` | `stat=True` → `--stat`, `patch=True` → `-p`, `include_untracked=True` → `-u` | +| `apply()` | `git stash apply` | `index=True` → `--index`, `quiet=True` → `-q` | +| `pop()` | `git stash pop` | `index=True` → `--index`, `quiet=True` → `-q` | +| `drop()` | `git stash drop` | `quiet=True` → `-q` | +| `branch(name)` | `git stash branch` | `name` → positional | + +--- + +## 4. GitSubmoduleCmd (Current) → GitSubmoduleManager / GitSubmoduleCmd (Planned) + +**Pattern Status**: Implemented + +### Current GitSubmoduleCmd + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, cmd)` | Implemented | Constructor | +| `__repr__()` | Implemented | String representation | +| `run(command, local_flags, quiet, cached)` | Implemented | Run git submodule command | +| `init(path)` | Implemented | Initialize submodules | +| `update(path, init, force, checkout, rebase, merge, recursive, _filter)` | Implemented | Update submodules | + +### Planned GitSubmoduleManager (Collection-level) + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, cmd)` | Implemented | Constructor | +| `run(command, local_flags)` | Implemented | Run git submodule command | +| `ls()` | Implemented | Returns `QueryList[GitSubmoduleCmd]` | +| `get(**kwargs)` | Implemented | Get single submodule by filter | +| `filter(**kwargs)` | Implemented | Filter submodules | +| `add(url, path, branch, name, force)` | Implemented | Add submodule | +| `foreach(command, recursive)` | Implemented | Execute in each submodule | +| `sync(recursive)` | Implemented | Sync submodule URLs | +| `summary(commit, files, cached)` | Implemented | Summarize changes | + +#### CLI Flag → Python Parameter Mapping: GitSubmoduleManager Methods + +| Method | Git CLI | Parameters → Flags | +|--------|---------|-------------------| +| `add()` | `git submodule add` | `branch` → `-b`, `force=True` → `-f`, `name` → `--name`, `depth` → `--depth` | +| `foreach()` | `git submodule foreach` | `recursive=True` → `--recursive` | +| `sync()` | `git submodule sync` | `recursive=True` → `--recursive` | +| `summary()` | `git submodule summary` | `cached=True` → `--cached`, `files=True` → `--files`, `summary_limit` → `-n` | + +### Planned GitSubmoduleCmd (Per-entity) + +Properties: `name`, `path`, `url`, `branch`, `sha` + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, name, submodule_path, url, branch, cmd)` | Implemented | Constructor | +| `init()` | Implemented | Initialize this submodule | +| `update(init, force, checkout, rebase, merge, recursive)` | Implemented | Update this submodule | +| `deinit(force)` | Implemented | Unregister submodule | +| `set_branch(branch)` | Implemented | Set branch | +| `set_url(url)` | Implemented | Set URL | +| `status()` | Implemented | Show status | +| `absorbgitdirs()` | Implemented | Absorb gitdir | + +#### CLI Flag → Python Parameter Mapping: GitSubmoduleCmd Methods + +| Method | Git CLI | Parameters → Flags | +|--------|---------|-------------------| +| `init()` | `git submodule init` | None | +| `update()` | `git submodule update` | `init=True` → `--init`, `force=True` → `-f`, `recursive=True` → `--recursive`, `checkout/rebase/merge` → mode flags | +| `deinit()` | `git submodule deinit` | `force=True` → `-f`, `_all=True` → `--all` | +| `set_branch(branch)` | `git submodule set-branch` | `branch` → `-b`, `default=True` → `-d` | +| `set_url(url)` | `git submodule set-url` | `url` → positional | +| `status()` | `git submodule status` | `recursive=True` → `--recursive` | +| `absorbgitdirs()` | `git submodule absorbgitdirs` | None | + +--- + +## 5. GitTagManager / GitTagCmd (New) + +**Pattern Status**: Implemented + +### Planned GitTagManager (Collection-level) + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, cmd)` | Implemented | Constructor | +| `run(command, local_flags)` | Implemented | Run git tag command | +| `ls(pattern, sort, contains, no_contains, merged, no_merged)` | Implemented | Returns `QueryList[GitTagCmd]` | +| `get(**kwargs)` | Implemented | Get single tag by filter | +| `filter(**kwargs)` | Implemented | Filter tags | +| `create(name, ref, message, annotate, sign, force)` | Implemented | Create tag | + +#### CLI Flag → Python Parameter Mapping: `create()` + +| Git CLI Flag | Python Parameter | Description | +|--------------|------------------|-------------| +| `-a, --annotate` | `annotate: bool` | Create annotated tag | +| `-s, --sign` | `sign: bool` | Create GPG-signed tag | +| `-u ` | `local_user: str \| None` | Use specific GPG key | +| `-f, --force` | `force: bool` | Replace existing tag | +| `-m ` | `message: str \| None` | Tag message | +| `-F ` | `file: str \| None` | Read message from file | + +#### CLI Flag → Python Parameter Mapping: `ls()` + +| Git CLI Flag | Python Parameter | Description | +|--------------|------------------|-------------| +| `-l ` | `pattern: str \| None` | List tags matching pattern | +| `--sort=` | `sort: str \| None` | Sort by key | +| `--contains ` | `contains: str \| None` | Tags containing commit | +| `--no-contains ` | `no_contains: str \| None` | Tags not containing commit | +| `--merged ` | `merged: str \| None` | Tags merged into commit | +| `--no-merged ` | `no_merged: str \| None` | Tags not merged | +| `-n` | `lines: int \| None` | Print annotation lines | + +### Planned GitTagCmd (Per-entity) + +Properties: `tag_name`, `ref`, `message` (for annotated) + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, tag_name, ref, message, cmd)` | Implemented | Constructor | +| `show()` | Implemented | Show tag details | +| `delete()` | Implemented | Delete tag (`-d`) | +| `verify()` | Implemented | Verify signed tag (`-v`) | + +#### CLI Flag → Python Parameter Mapping: GitTagCmd Methods + +| Method | Git CLI | Parameters → Flags | +|--------|---------|-------------------| +| `delete()` | `git tag -d` | None | +| `verify()` | `git tag -v` | None | +| `show()` | `git show` | (uses git show, not git tag) | + +--- + +## 6. GitWorktreeManager / GitWorktreeCmd (New) + +**Pattern Status**: Implemented + +### Planned GitWorktreeManager (Collection-level) + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, cmd)` | Implemented | Constructor | +| `run(command, local_flags)` | Implemented | Run git worktree command | +| `ls()` | Implemented | Returns `QueryList[GitWorktreeCmd]` | +| `get(**kwargs)` | Implemented | Get single worktree by filter | +| `filter(**kwargs)` | Implemented | Filter worktrees | +| `add(path, branch, detach, checkout, lock, force)` | Implemented | Add worktree | +| `prune(dry_run, verbose, expire)` | Implemented | Prune worktrees | + +#### CLI Flag → Python Parameter Mapping: `add()` + +| Git CLI Flag | Python Parameter | Description | +|--------------|------------------|-------------| +| `-f, --force` | `force: bool` | Force creation | +| `--detach` | `detach: bool` | Detach HEAD | +| `--checkout` | `checkout: bool` | Checkout after add | +| `--lock` | `lock: bool` | Lock worktree | +| `--reason ` | `reason: str \| None` | Lock reason | +| `-b ` | `new_branch: str \| None` | Create new branch | +| `-B ` | `new_branch_force: str \| None` | Force create branch | +| `--orphan` | `orphan: bool` | Create orphan branch | +| `--track` | `track: bool` | Track remote | + +#### CLI Flag → Python Parameter Mapping: `prune()` + +| Git CLI Flag | Python Parameter | Description | +|--------------|------------------|-------------| +| `-n, --dry-run` | `dry_run: bool` | Dry run | +| `-v, --verbose` | `verbose: bool` | Verbose output | +| `--expire