diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..3c4e9f0 --- /dev/null +++ b/.clang-format @@ -0,0 +1,89 @@ +# From https://github.com/man-group/sparrow with sparrow-specific regexes removed. + +BasedOnStyle: Mozilla + +AccessModifierOffset: '-4' +AlignAfterOpenBracket: BlockIndent +AlignEscapedNewlines: Left +AllowAllArgumentsOnNextLine: false +AllowAllParametersOfDeclarationOnNextLine: false +AllowShortBlocksOnASingleLine: false +AllowShortCaseLabelsOnASingleLine: false +AllowShortFunctionsOnASingleLine: false +AllowShortIfStatementsOnASingleLine: false +# Forbid one line lambdas because clang-format makes a weird split when +# single instructions lambdas are too long. +AllowShortLambdasOnASingleLine: Empty +AllowShortLoopsOnASingleLine: false +AlwaysBreakAfterDefinitionReturnType: None +AlwaysBreakAfterReturnType: None +AlwaysBreakTemplateDeclarations: Yes +BinPackArguments: false +BinPackParameters: false +BreakBeforeBinaryOperators: NonAssignment +BreakBeforeBraces: Allman +BreakBeforeTernaryOperators: true +BreakConstructorInitializers: BeforeComma +BreakInheritanceList: AfterComma +BreakStringLiterals: false +ColumnLimit: '110' +ConstructorInitializerIndentWidth: '4' +ContinuationIndentWidth: '4' +Cpp11BracedListStyle: true +DerivePointerAlignment: false +DisableFormat: false +EmptyLineAfterAccessModifier: Always +EmptyLineBeforeAccessModifier: Always +ExperimentalAutoDetectBinPacking: true +IncludeBlocks: Regroup +IncludeCategories: +- Regex: <[^.]+> + Priority: 1 +- Regex: <.+> + Priority: 2 +- Regex: '".+"' + Priority: 5 +IndentCaseLabels: true +IndentPPDirectives: AfterHash +IndentWidth: '4' +IndentWrappedFunctionNames: false +InsertBraces: true +InsertTrailingCommas: Wrapped +KeepEmptyLinesAtTheStartOfBlocks: false +LambdaBodyIndentation: Signature +Language: Cpp +MaxEmptyLinesToKeep: '2' +NamespaceIndentation: All +ObjCBlockIndentWidth: '4' +ObjCSpaceAfterProperty: false +ObjCSpaceBeforeProtocolList: false +PackConstructorInitializers: Never +PenaltyBreakAssignment: 100000 +PenaltyBreakBeforeFirstCallParameter: 0 +PenaltyBreakComment: 10 +PenaltyBreakOpenParenthesis: 0 +PenaltyBreakTemplateDeclaration: 0 +PenaltyExcessCharacter: 10 +PenaltyIndentedWhitespace: 0 +PenaltyReturnTypeOnItsOwnLine: 10 +PointerAlignment: Left +QualifierAlignment: Custom # Experimental +QualifierOrder: [inline, static, constexpr, const, volatile, type] +ReflowComments: true +SeparateDefinitionBlocks: Always +SortIncludes: CaseInsensitive +SortUsingDeclarations: true +SpaceAfterCStyleCast: true +SpaceAfterTemplateKeyword: true +SpaceBeforeAssignmentOperators: true +SpaceBeforeParens: ControlStatements +SpaceInEmptyParentheses: false +SpacesBeforeTrailingComments: '2' +SpacesInAngles: false +SpacesInCStyleCastParentheses: false +SpacesInContainerLiterals: false +SpacesInParentheses: false +SpacesInSquareBrackets: false +Standard: c++20 +TabWidth: '4' +UseTab: Never diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..af329c2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + labels: + - 'maintenance' + groups: + actions: + patterns: + - '*' diff --git a/.github/workflows/deploy-wasm.yml b/.github/workflows/deploy-wasm.yml new file mode 100644 index 0000000..acff1ac --- /dev/null +++ b/.github/workflows/deploy-wasm.yml @@ -0,0 +1,59 @@ +# Deploy WebAssembly cockle and terminal builds to github pages +name: Deploy wasm to github pages + +on: + push: + branches: + - main + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Python + uses: actions/setup-python@v7 + with: + python-version: '3.14' + + - name: Install mamba + uses: mamba-org/setup-micromamba@v3 + with: + environment-file: wasm/wasm-environment.yml + cache-environment: true + + - name: Build the cockle and JupyterLite terminal deployments + shell: bash -l {0} + working-directory: wasm + run: | + cmake . + make + + - name: Upload artifact + uses: actions/upload-pages-artifact@v5 + with: + path: ./wasm/serve/dist + + deploy: + needs: build + if: github.ref == 'refs/heads/main' + permissions: + pages: write + id-token: write + + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + runs-on: ubuntu-latest + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/test-wasm.yml b/.github/workflows/test-wasm.yml new file mode 100644 index 0000000..59f2230 --- /dev/null +++ b/.github/workflows/test-wasm.yml @@ -0,0 +1,55 @@ +# Test WebAssembly build in cockle deployment +name: Test WebAssembly + +on: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Python + uses: actions/setup-python@v7 + with: + python-version: '3.14' + + - name: Install mamba + uses: mamba-org/setup-micromamba@v3 + with: + environment-file: wasm/wasm-environment.yml + cache-environment: true + + - name: Build + shell: bash -l {0} + working-directory: wasm + run: | + cmake . + make build-recipe + make build-test + + - name: Install playwright chromium + shell: bash -l {0} + working-directory: wasm/test + run: | + npx playwright install chromium + + - name: Upload artifact containing emscripten-forge package + uses: actions/upload-pages-artifact@v5 + with: + path: ./wasm/recipe/em-forge-recipes/output/ + + - name: Run WebAssembly tests + shell: bash -l {0} + working-directory: wasm + env: + GIT2CPP_TEST_PRIVATE_TOKEN: ${{ secrets.GIT2CPP_TEST_PRIVATE_TOKEN }} + run: | + make test diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8fe0ce6..73f41cf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,10 +16,14 @@ defaults: jobs: test: name: 'Build and test' - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] steps: - name: Checkout source - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -44,6 +48,57 @@ jobs: ./git2cpp -v - name: Run tests + env: + GIT2CPP_TEST_PRIVATE_TOKEN: ${{ secrets.GIT2CPP_TEST_PRIVATE_TOKEN }} run: | pytest -v + coverage: + name: 'Test coverage' + runs-on: ubuntu-latest + steps: + - name: Checkout source + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Create micromamba environment + uses: mamba-org/setup-micromamba@main + with: + environment-file: dev-environment.yml + cache-environment: true + create-args: lcov + + - name: Configure CMake + run: | + cmake -Bbuild -DCMAKE_INSTALL_PREFIX=$CONDA_PREFIX -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_FLAGS_DEBUG="-g -O0 --coverage" + + - name: Build with CMake + working-directory: build + run: cmake --build . --parallel 8 + + - name: Run tests + env: + GIT2CPP_TEST_PRIVATE_TOKEN: ${{ secrets.GIT2CPP_TEST_PRIVATE_TOKEN }} + run: | + pytest -v + + - name: Collect C++ coverage + run: | + lcov --version + lcov --output-file coverage.lcov --directory . --capture + lcov --output-file coverage.lcov --extract coverage.lcov '*/git2cpp/src/*' + genhtml coverage.lcov --output-directory outdir + + - name: Upload artifact containing coverage report + uses: actions/upload-artifact@v7 + with: + name: coverage_report + path: outdir + + - name: Upload coverage to codecov + uses: codecov/codecov-action@v7 + with: + files: coverage.lcov + token: ${{ secrets.CODECOV_TOKEN }} + verbose: true diff --git a/.gitignore b/.gitignore index e4f7ae8..8a84302 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ build/ __pycache__/ .cache/ compile_commands.json +serve.log +test/test-results/ + +docs/_build/ +docs/created/ diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..817582e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,31 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-merge-conflict + - id: check-toml + - id: check-yaml + - id: debug-statements + - id: end-of-file-fixer + - id: mixed-line-ending + - id: trailing-whitespace + + - repo: https://github.com/asottile/pyupgrade + rev: v3.21.2 + hooks: + - id: pyupgrade + args: ['--py312-plus'] + + - repo: https://github.com/pre-commit/mirrors-clang-format + rev: v22.1.8 + hooks: + - id: clang-format + args: [--style=file] + exclude_types: [javascript,json] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.2 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..e1768e1 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,21 @@ +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: mambaforge-23.11 + jobs: + post_install: + - cmake -Bbuild -DCMAKE_INSTALL_PREFIX=$CONDA_PREFIX + - cd build && make + - python -m pip install myst-parser sphinx sphinx-book-theme + pre_build: + - cd docs && python create_markdown.py + +conda: + environment: dev-environment.yml + +sphinx: + builder: html + configuration: docs/conf.py + fail_on_warning: true diff --git a/CMakeLists.txt b/CMakeLists.txt index d18cbd9..48e3e5b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,46 +48,113 @@ set(GIT2CPP_SRC ${GIT2CPP_SOURCE_DIR}/subcommand/checkout_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/clone_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/clone_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/diff_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/diff_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/commit_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/commit_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/config_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/config_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/fetch_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/fetch_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/init_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/init_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/log_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/log_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/merge_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/merge_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/mv_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/mv_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/push_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/push_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/rebase_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/rebase_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/remote_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/remote_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/reset_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/reset_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/revlist_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/revlist_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/revparse_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/revparse_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/rm_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/rm_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/showref_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/showref_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/stash_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/stash_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/status_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/status_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/tag_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/tag_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/utils/ansi_code.cpp ${GIT2CPP_SOURCE_DIR}/utils/ansi_code.hpp ${GIT2CPP_SOURCE_DIR}/utils/common.cpp ${GIT2CPP_SOURCE_DIR}/utils/common.hpp + ${GIT2CPP_SOURCE_DIR}/utils/credentials.cpp + ${GIT2CPP_SOURCE_DIR}/utils/credentials.hpp ${GIT2CPP_SOURCE_DIR}/utils/git_exception.cpp ${GIT2CPP_SOURCE_DIR}/utils/git_exception.hpp - ${GIT2CPP_SOURCE_DIR}/utils/output.cpp - ${GIT2CPP_SOURCE_DIR}/utils/output.hpp + ${GIT2CPP_SOURCE_DIR}/utils/input_output.cpp + ${GIT2CPP_SOURCE_DIR}/utils/input_output.hpp + ${GIT2CPP_SOURCE_DIR}/utils/progress.cpp + ${GIT2CPP_SOURCE_DIR}/utils/progress.hpp ${GIT2CPP_SOURCE_DIR}/utils/terminal_pager.cpp ${GIT2CPP_SOURCE_DIR}/utils/terminal_pager.hpp + ${GIT2CPP_SOURCE_DIR}/wasm/constants.hpp + ${GIT2CPP_SOURCE_DIR}/wasm/libgit2_internals.cpp + ${GIT2CPP_SOURCE_DIR}/wasm/libgit2_internals.hpp + ${GIT2CPP_SOURCE_DIR}/wasm/read_buffer.cpp + ${GIT2CPP_SOURCE_DIR}/wasm/read_buffer.hpp + ${GIT2CPP_SOURCE_DIR}/wasm/response.cpp + ${GIT2CPP_SOURCE_DIR}/wasm/response.hpp + ${GIT2CPP_SOURCE_DIR}/wasm/scope.cpp + ${GIT2CPP_SOURCE_DIR}/wasm/scope.hpp + ${GIT2CPP_SOURCE_DIR}/wasm/stream.cpp + ${GIT2CPP_SOURCE_DIR}/wasm/stream.hpp + ${GIT2CPP_SOURCE_DIR}/wasm/subtransport.cpp + ${GIT2CPP_SOURCE_DIR}/wasm/subtransport.hpp + ${GIT2CPP_SOURCE_DIR}/wasm/transport.cpp + ${GIT2CPP_SOURCE_DIR}/wasm/transport.hpp + ${GIT2CPP_SOURCE_DIR}/wasm/utils.cpp + ${GIT2CPP_SOURCE_DIR}/wasm/utils.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/annotated_commit_wrapper.cpp ${GIT2CPP_SOURCE_DIR}/wrapper/annotated_commit_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/branch_wrapper.cpp ${GIT2CPP_SOURCE_DIR}/wrapper/branch_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/commit_wrapper.cpp ${GIT2CPP_SOURCE_DIR}/wrapper/commit_wrapper.hpp + ${GIT2CPP_SOURCE_DIR}/wrapper/config_wrapper.cpp + ${GIT2CPP_SOURCE_DIR}/wrapper/config_wrapper.hpp + ${GIT2CPP_SOURCE_DIR}/wrapper/diff_wrapper.cpp + ${GIT2CPP_SOURCE_DIR}/wrapper/diff_wrapper.hpp + ${GIT2CPP_SOURCE_DIR}/wrapper/diffstats_wrapper.cpp + ${GIT2CPP_SOURCE_DIR}/wrapper/diffstats_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/index_wrapper.cpp ${GIT2CPP_SOURCE_DIR}/wrapper/index_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/object_wrapper.cpp ${GIT2CPP_SOURCE_DIR}/wrapper/object_wrapper.hpp + ${GIT2CPP_SOURCE_DIR}/wrapper/patch_wrapper.cpp + ${GIT2CPP_SOURCE_DIR}/wrapper/patch_wrapper.hpp + ${GIT2CPP_SOURCE_DIR}/wrapper/rebase_wrapper.cpp + ${GIT2CPP_SOURCE_DIR}/wrapper/rebase_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/refs_wrapper.cpp ${GIT2CPP_SOURCE_DIR}/wrapper/refs_wrapper.hpp + ${GIT2CPP_SOURCE_DIR}/wrapper/remote_wrapper.cpp + ${GIT2CPP_SOURCE_DIR}/wrapper/remote_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/repository_wrapper.cpp ${GIT2CPP_SOURCE_DIR}/wrapper/repository_wrapper.hpp + ${GIT2CPP_SOURCE_DIR}/wrapper/revwalk_wrapper.cpp + ${GIT2CPP_SOURCE_DIR}/wrapper/revwalk_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/signature_wrapper.cpp ${GIT2CPP_SOURCE_DIR}/wrapper/signature_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/status_wrapper.cpp ${GIT2CPP_SOURCE_DIR}/wrapper/status_wrapper.hpp + ${GIT2CPP_SOURCE_DIR}/wrapper/strarray_wrapper.cpp + ${GIT2CPP_SOURCE_DIR}/wrapper/strarray_wrapper.hpp + ${GIT2CPP_SOURCE_DIR}/wrapper/tag_wrapper.cpp + ${GIT2CPP_SOURCE_DIR}/wrapper/tag_wrapper.hpp + ${GIT2CPP_SOURCE_DIR}/wrapper/tree_wrapper.cpp + ${GIT2CPP_SOURCE_DIR}/wrapper/tree_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/wrapper_base.hpp ${GIT2CPP_SOURCE_DIR}/main.cpp ${GIT2CPP_SOURCE_DIR}/version.hpp diff --git a/README.md b/README.md index 64c6743..9341b79 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ # git2cpp +[![GithubActions](https://github.com/QuantStack/git2cpp/actions/workflows/test.yml/badge.svg)](https://github.com/QuantStack/git2cpp/actions/workflows/test.yml) +[![Documentation Status](http://readthedocs.org/projects/git2cpp/badge/?version=latest)](https://git2cpp.readthedocs.io/en/latest/?badge=latest) +[![Codecov](https://codecov.io/gh/QuantStack/git2cpp/graph/badge.svg)](https://app.codecov.io/gh/QuantStack/git2cpp) This is a C++ wrapper of [libgit2](https://libgit2.org/) to provide a command-line interface (CLI) to `git` functionality. The intended use is in WebAssembly in-browser terminals (see @@ -8,6 +11,10 @@ used on any POSIX-compliant system. See `overview.md` for further details. +## Development workflow + +### Build + Developer's workflow using `micromamba` to manage the dependencies: ```bash @@ -20,8 +27,58 @@ make -j8 The `git2cpp` executable can then be run, e.g. `./git2cpp -v`. +### Test + The CLI is tested using `python`. From the top-level directory: ```bash pytest -v ``` + +Some tests access the private repository at https://github.com/QuantStack/git2cpp-test-private using +a fine-grained github Personal Access Token (PAT). These tests are skipped by default. To run them +you will need to obtain the PAT from one of the maintainers, and run the tests as follows: + +```bash +GIT2CPP_TEST_PRIVATE_TOKEN= pytest -v +``` + +### pre-commit + +`pre-commit` runs automatically on `git commit`. To run it manually use: + +```bash +pre-commit run --all-files +``` + +## WebAssembly build and deployment + +The `wasm` directory contains everything needed to build the local `git2cpp` source code as an +WebAssembly [Emscripten-forge](https://emscripten-forge.org/) package, create local +[cockle](https://github.com/jupyterlite/cockle) and +[JupyterLite terminal](https://github.com/jupyterlite/terminal) deployments that run in a browser, +and test the WebAssembly build. + +See the `README.md` in the `wasm` directory for further details. + +The latest `cockle` and JupyterLite `terminal` deployments using `git2cpp` are available at +[https://quantstack.net/git2cpp](https://quantstack.net/git2cpp) + +## Documentation + +The project documentation is generated from the `git2cpp` help pages. To build the documentation +locally first build `git2cpp` as usual as described above, then install the documentation +dependencies: + +```bash +micromamba install myst-parser sphinx sphinx-book-theme +``` + +and build the documentation: + +```bash +cd docs +make html +``` + +The top-level documentation page will be `docs/_build/html/index.html` diff --git a/RELEASE.md b/RELEASE.md index 2b01a44..684930f 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -21,6 +21,6 @@ This covers making a new github release in the `git2cpp` repository, and propaga The Emscripten-forge recipe at https://github.com/emscripten-forge/recipes needs to be updated with the new version number and SHA checksum. An Emscripten-forge bot runs once a day and will identify the new github release and create a PR to update the recipe. Wait for this to happen, and if the tests pass and no further changes are required, the PR can be approved and merged. -After the PR is merged to `main`, the recipe will be rebuilt and uploaded to https://prefix.dev/channels/emscripten-forge-dev/packages/git2cpp, which should only take a few minutes. +After the PR is merged to `main`, the recipe will be rebuilt and uploaded to https://prefix.dev/channels/emscripten-forge-4x/packages/git2cpp, which should only take a few minutes. Any subsequent `cockle` or JupyterLite `terminal` deployments that are rebuilt will download and use the latest `git2cpp` WebAssembly package. diff --git a/dev-environment.yml b/dev-environment.yml index c578ec0..288a620 100644 --- a/dev-environment.yml +++ b/dev-environment.yml @@ -6,6 +6,7 @@ dependencies: - libgit2 - cmake - pkg-config + - pre-commit - python - pytest - termcolor-cpp diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..cadcd23 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,18 @@ +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + python create_markdown.py + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_static/custom.css b/docs/_static/custom.css new file mode 100644 index 0000000..ace31ce --- /dev/null +++ b/docs/_static/custom.css @@ -0,0 +1,3 @@ +span.git2cpp-bold { + font-weight: bold; +} diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..34fcb46 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,22 @@ +from datetime import UTC, datetime + +project = "git2cpp" +author = "QuantStack" +copyright = f"2025-{datetime.now(UTC).year}" + +extensions = [ + "myst_parser", +] + +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +html_css_files = ["custom.css"] +html_static_path = ["_static"] +html_theme = "sphinx_book_theme" +html_theme_options = { + "github_url": "https://github.com/QuantStack/git2cpp", + "home_page_in_toc": True, + "show_navbar_depth": 2, +} +html_title = "git2cpp documentation" +myst_enable_extensions = ["deflist"] diff --git a/docs/create_markdown.py b/docs/create_markdown.py new file mode 100644 index 0000000..344f7ae --- /dev/null +++ b/docs/create_markdown.py @@ -0,0 +1,93 @@ +import os +import re +import subprocess +from pathlib import Path + + +def get_filename(args): + directory = Path("created").joinpath(*args[:-1]) + filename = args[-1] + ".md" + return directory / filename + + +def sanitise_line(line): + # Remove trailing whitespace otherwise the markdown parser can insert an extra \n + line = line.rstrip() + + # Replace angular brackets with HTML equivalents. + line = line.replace(r"&", r"&") + line = line.replace(r"<", r"<") + line = line.replace(r">", r">") + + # Replace ansi style codes with elements, only bold is displayed. + # Colour codes are converted to empty elements to match the number of . + line = line.replace("\x1b[1m", r"") + line = line.replace("\x1b[0m", r"") + line = re.sub(r"\x1b\[[^m]+m", r"", line) + + # If there are whitespace characters at the start of the line, replace the first with an   + # so that it is not discarded by the markdown parser used by the parsed-literal directive. + line = re.sub(r"^\s", r" ", line) + + return line + + +# Process a single subcommand, adding new subcommands found to to_process. +def process(args, to_process): + cmd = args + ["--help"] + cmd_string = " ".join(cmd) + filename = get_filename(args) + filename.parent.mkdir(parents=True, exist_ok=True) + + print(f"Writing '{cmd_string}' to file {filename}") + p = subprocess.run(cmd, capture_output=True, text=True, check=True) + + # Write output markdown file, identifying subcommands at the same time to provide + # links to the subcommand markdown files. + subcommands = [] + with open(filename, "w") as f: + f.write(f"({filename})=\n") # Target for links. + f.write(f"# {' '.join(args)}\n") + f.write("\n") + f.write("```{parsed-literal}\n") + + in_subcommand_section = False + for line in p.stdout.splitlines(): + if in_subcommand_section: + match = re.match(r"^( )([\w\-_]+)(\s+.*)$", line) + if match: + subcommand = match.group(2) + subcommand_filename = get_filename(args + [subcommand]) + line = ( + match.group(1) + f"[{subcommand}]({subcommand_filename})" + match.group(3) + ) + subcommands.append(subcommand) + elif line.startswith("SUBCOMMANDS:"): + in_subcommand_section = True + + f.write(sanitise_line(line) + "\n") + f.write("```\n") + + subcommands.sort() + to_process.extend(args + [subcommand] for subcommand in subcommands) + + if len(subcommands) > 0: + # Hidden table of contents for subcommands of this command/subcommand. + f.write("\n") + f.write("```{toctree}\n") + f.write(":hidden:\n") + for subcommand in subcommands: + f.write(f"{args[-1]}/{subcommand}\n") + f.write("```\n") + + +if __name__ == "__main__": + # Modify the PATH so that git2cpp is found by name, as using a full path will cause the help + # pages to write that full path. + git2cpp_dir = Path(__file__).parent.parent / "build" + os.environ["PATH"] = f"{git2cpp_dir}{os.pathsep}{os.environ['PATH']}" + + to_process = [["git2cpp"]] + while len(to_process) > 0: + subcommand = to_process.pop(0) + process(subcommand, to_process) diff --git a/docs/env_vars.md b/docs/env_vars.md new file mode 100644 index 0000000..40bcc76 --- /dev/null +++ b/docs/env_vars.md @@ -0,0 +1,77 @@ +# Environment variables + + +## In all builds + +The following four environment variables should be set for `git2cpp commit` and `git2cpp merge` +subcommands. The use of `git2cpp config` instead is partially supported and will be improved in +time. + +`GIT_AUTHOR_EMAIL` +: The email for the "author" field. + +`GIT_AUTHOR_NAME` +: The human-readable name in the "author" field. + +`GIT_COMMITTER_EMAIL` +: The email for the "committer" field. + +`GIT_COMMITTER_NAME` +: The human-readable name for the "committer" field. + +`GIT_CREDENTIAL_CALLBACK` +: By default, `git2cpp` will prompt the user to enter a username and password if they are required + for remote authentication, using a + [libgit2 credential callback](https://libgit2.org/docs/reference/main/credential/git_credential_acquire_cb.html). + To disable the callback use `export GIT_CREDENTIAL_CALLBACK=0`. + + +## In WebAssembly build only + +(git_cors_proxy)= +`GIT_CORS_PROXY` +: In-browser remote `git2cpp` operations such as `clone`, `fetch` and `push` usually require use of + a [CORS proxy server](cors_proxy). Use this environment variable to specify how the target URL is + encoded into the CORS proxy URL, details of which depend on how the CORS proxy server is + implemented. + + The `GIT_CORS_PROXY` should contain the URL of the CORS proxy itself, followed by a number of + substitutions which are denoted by curly braces. To illustrate the substitutions, assume that the + `git2cpp` command is for the repository at `https://github.com/organisation/repository`. + + Substitutions: + + - `{host}` is replaced by `github.com` + - `{path}` is replaced by `/organisation/repository/` followed by extra information that depends + on details of the `git2cpp` operation being performed + - `{protocol}` is replaced by `https:` + - `{url}` is equivalent to `{protocol}//{host}{path}` + - `{api_key}` is replaced by the value of environment variable `GIT_CORS_PROXY_API_KEY` if it is + set. + + If no substitutions are specified then `{url}` is appended. + + All of the substitutions except `{api_key}` have an `:encode` variant such as `{url:encode}` + that passes the argument through the + [encodeURIComponent](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent) + JavaScript function, which some CORS proxies require. + + You can verify the actual URL used for requests in the Network tab of your browser's Developer + Tools for debugging purposes. + + See [CORS proxy server](cors_proxy) for usage examples. + +`GIT_CORS_PROXY_API_KEY` +: This value is used to replace the `{api_key}` in `GIT_CORS_PROXY` and is intended for use with a + CORS proxy that requires an API key. Alternatively the API key could be put directly in the + `GIT_CORS_PROXY` instead. + +(git_http_timeout)= +`GIT_HTTP_TIMEOUT` +: In the WebAssembly build, all http(s) requests are limited by a timeout which has a default of 10 + seconds. To use a different timeout set the `GIT_HTTP_TIMEOUT` environment variable. For example, + to set a timeout of 20 seconds use: + + ```bash + export GIT_HTTP_TIMEOUT=20 + ``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..c8799bb --- /dev/null +++ b/docs/index.md @@ -0,0 +1,28 @@ +# Overview + +`git2cpp` is a C++ wrapper of [libgit2](https://libgit2.org/) to provide a command-line interface +(CLI) to `git` functionality. The intended use is in WebAssembly in-browser terminals (see the +[cockle](https://github.com/jupyterlite/cockle), +[JupyterLite terminal](https://github.com/jupyterlite/terminal) and +[Notebook.link](https://notebook.link) projects) but it can be compiled and +used on any POSIX-compliant system. + +The Help pages here are generated from the `git2cpp` command and subcommands to show the +functionality that is currently supported. If there are features missing that you would like to use, +please create an issue in the [git2cpp github repository](https://github.com/QuantStack/git2cpp). + +The Appendix contains additional information on [Environment variables](env_vars.md) used in +`git2cpp` and about the behaviour of the [WebAssembly build](wasm_build.md). + +```{toctree} +:caption: Help pages +:hidden: +created/git2cpp +``` + +```{toctree} +:caption: Appendix +:hidden: +env_vars +wasm_build +``` diff --git a/docs/wasm_build.md b/docs/wasm_build.md new file mode 100644 index 0000000..f5f452d --- /dev/null +++ b/docs/wasm_build.md @@ -0,0 +1,89 @@ +# WebAssembly build + +The in-browser WebAssembly build of `git2cpp` is intended to behave as similar as possible to other +builds but there are some differences when remotely accessing remote servers, in particular with +blocking requests and the requirement for a CORS proxy server. + + +## Blocking requests + +Remote `git2cpp` requests in non-WebAssembly builds are progressive and feedback is provided as the +data is streamed back from the remote. But in WebAssembly builds such remote requests are blocking +and no feedback can be provided until the entire response is received back from the remote. +This can be a long time to wait without feedback, and if there is an error such that a response is +not received it could block forever, leaving the in-browser terminal unusable. + +Hence the WebAssembly build limits http(s) requests with a timeout that defaults to 10 seconds. +This timeout can be increased using the [`GIT_HTTP_TIMEOUT` environment variable](git_http_timeout). + +In addition, when a `git2cpp` response is received that is larger than 10 MB, a prompt is presented +to the user to confirm whether to proceed to unpack the response or not. Note that the size of the +response may be smaller or larger than the size of the directory structure it unpacks to. + + +(cors_proxy)= +## CORS proxy server + +The fetching of resources in a browser from one domain to another is often limited by a browser +security feature called +[Cross-Origin Resource Sharing](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS) +(CORS). For this to be allowed the target domain must indicate it is happy to accept cross-origin +requests by adding certain headers to `https` responses. Most git servers such as `github.com` do +not add these headers, so a `git2cpp clone` from `github.com` will fail with a CORS error if run +from within a browser whereas there is no such limitation if run from a terminal of a real computer. + +The solution to this problem is to use a separate CORS proxy server. The `git2cpp` remote request is +sent to this intermediate server which send the request to the target server and as this request is +coming from outside a browser it is not subject to CORS restrictions. The proxy receives the +response from the target server and adds the required CORS headers before returning it to `git2cpp`. + +Various public CORS proxy servers are available for use or you serve your own. It can be useful to +serve your own on `localhost` when experimenting to confirm that everything works as expected +before moving on to a more complex solution. Be aware that the CORS proxy server is able to read the +content of your request so be careful if you are using authentication tokens. + +The [`GIT_CORS_PROXY` environment variable](git_cors_proxy) is used to specify how the target URL is +encoded in the CORS proxy URL. + +### Example running a local CORS proxy server + +If you are running a local [cockle](https://github.com/jupyterlite/cockle) or +[JupyterLite terminal](https://github.com/jupyterlite/terminal) deployment you can also run a local +CORS proxy such as [CORS Anywhere](https://github.com/Rob--W/cors-anywhere) to test it out. +In a separate terminal on your host machine on which you have `nodejs` available, `cd` to a new +clean directory, and download and run the CORS proxy server using: + +```js +npm install cors-anywhere +HOST=localhost PORT=8881 node node_modules/cors-anywhere/server.js +``` + +This will start the CORS proxy server listening on `http://localhost:8881/`. To use this in your +local `cockle` or `JupyterLite terminal` deployment in your browser set the `CORS_PROXY_URL` to be +```bash +export GIT_CORS_PROXY=http://localhost:8881/ +``` +and then try a `git2cpp clone` using something like: +```bash +git2cpp clone https://github.com/some-organisation/some-repository +``` + +### Example using a public CORS proxy server + +There is a public instance of [CORS Anywhere](https://github.com/Rob--W/cors-anywhere) available at +`https://cors-anywhere.herokuapp.com/`. This can be used for demonstration purposes but it requires +an explicit opt-in and your access will be time-limited. To request temporary access go to +`https://cors-anywhere.herokuapp.com/` and follow the instructions there. + +Once you have access, you can try this out in one of the public deployments such as those at +[https://jupyterlite.github.io/cockle](https://jupyterlite.github.io/cockle) or +[https://jupyterlite.github.io/terminal](https://jupyterlite.github.io/terminal). + +Set the `CORS_PROXY_URL` to be +```bash +export GIT_CORS_PROXY=https://cors-anywhere.herokuapp.com/ +``` +and then try a `git2cpp clone` using something like: +```bash +git2cpp clone https://github.com/some-organisation/some-repository +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e68881b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,12 @@ +# This is not a python project but it contains python tests so here are settings for python linting. +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.format] +line-ending = "lf" + +[tool.ruff.lint] +ignore = [ + "PLW1510" +] diff --git a/src/main.cpp b/src/main.cpp index e8479c8..c1ce04d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,20 +1,34 @@ -#include #include -#include // For version number only #include -#include "utils/git_exception.hpp" -#include "version.hpp" +#include +#include // For version number only + #include "subcommand/add_subcommand.hpp" #include "subcommand/branch_subcommand.hpp" #include "subcommand/checkout_subcommand.hpp" #include "subcommand/clone_subcommand.hpp" #include "subcommand/commit_subcommand.hpp" +#include "subcommand/config_subcommand.hpp" +#include "subcommand/diff_subcommand.hpp" +#include "subcommand/fetch_subcommand.hpp" #include "subcommand/init_subcommand.hpp" #include "subcommand/log_subcommand.hpp" #include "subcommand/merge_subcommand.hpp" +#include "subcommand/mv_subcommand.hpp" +#include "subcommand/push_subcommand.hpp" +#include "subcommand/rebase_subcommand.hpp" +#include "subcommand/remote_subcommand.hpp" #include "subcommand/reset_subcommand.hpp" +#include "subcommand/revlist_subcommand.hpp" +#include "subcommand/revparse_subcommand.hpp" +#include "subcommand/rm_subcommand.hpp" +#include "subcommand/showref_subcommand.hpp" +#include "subcommand/stash_subcommand.hpp" #include "subcommand/status_subcommand.hpp" +#include "subcommand/tag_subcommand.hpp" +#include "utils/git_exception.hpp" +#include "version.hpp" int main(int argc, char** argv) { @@ -35,9 +49,22 @@ int main(int argc, char** argv) checkout_subcommand checkout(lg2_obj, app); clone_subcommand clone(lg2_obj, app); commit_subcommand commit(lg2_obj, app); + config_subcommand config(lg2_obj, app); + diff_subcommand diff(lg2_obj, app); + fetch_subcommand fetch(lg2_obj, app); reset_subcommand reset(lg2_obj, app); log_subcommand log(lg2_obj, app); merge_subcommand merge(lg2_obj, app); + mv_subcommand mv(lg2_obj, app); + push_subcommand push(lg2_obj, app); + rebase_subcommand rebase(lg2_obj, app); + remote_subcommand remote(lg2_obj, app); + revlist_subcommand revlist(lg2_obj, app); + revparse_subcommand revparse(lg2_obj, app); + rm_subcommand rm(lg2_obj, app); + stash_subcommand stash(lg2_obj, app); + tag_subcommand tag(lg2_obj, app); + showref_subcommand showref(lg2_obj, app); app.require_subcommand(/* min */ 0, /* max */ 1); @@ -45,7 +72,8 @@ int main(int argc, char** argv) if (version->count()) { - std::cout << "git2cpp version " << GIT2CPP_VERSION_STRING << " (libgit2 " << LIBGIT2_VERSION << ")" << std::endl; + std::cout << "git2cpp version " << GIT2CPP_VERSION_STRING << " (libgit2 " << LIBGIT2_VERSION + << ")" << std::endl; } else if (app.get_subcommands().size() == 0) { @@ -62,7 +90,8 @@ int main(int argc, char** argv) std::cerr << e.what() << std::endl; exit_code = e.error_code(); } - catch (std::exception& e) { + catch (std::exception& e) + { std::cerr << e.what() << std::endl; exit_code = 1; } diff --git a/src/subcommand/add_subcommand.cpp b/src/subcommand/add_subcommand.cpp index 51f57c1..96f947e 100644 --- a/src/subcommand/add_subcommand.cpp +++ b/src/subcommand/add_subcommand.cpp @@ -1,25 +1,33 @@ +#include "add_subcommand.hpp" + #include -#include "add_subcommand.hpp" #include "../wrapper/index_wrapper.hpp" #include "../wrapper/repository_wrapper.hpp" - add_subcommand::add_subcommand(const libgit2_object&, CLI::App& app) { - auto *sub = app.add_subcommand("add", "Add file contents to the index"); + auto* sub = app.add_subcommand("add", "Add file contents to the index"); - sub->add_option("files", m_add_files, "Files to add"); + sub->add_option("", m_add_files, "Files to add content from."); - sub->add_flag("-A,--all,--no-ignore-removal", m_all_flag, ""); + sub->add_flag( + "-A,--all,--no-ignore-removal", + m_all_flag, + "Update the index not only where the working tree has a file matching but also where the index already has an entry. This adds, modifies, and removes index entries to match the working tree.\n\nIf no is given when -A option is used, all files in the entire working tree are updated (old versions of Git used to limit the update to the current directory and its subdirectories)." + ); // sub->add_flag("-n,--dryrun", dryrun_flag, ""); // sub->add_flag("-u,--update", update_flag, ""); // sub->add_flag("-v,--verbose", verbose_flag, ""); - sub->callback([this]() { this->run(); }); + sub->callback( + [this]() + { + this->run(); + } + ); }; - void add_subcommand::run() { auto directory = get_current_git_path(); diff --git a/src/subcommand/add_subcommand.hpp b/src/subcommand/add_subcommand.hpp index 6f31dff..0504799 100644 --- a/src/subcommand/add_subcommand.hpp +++ b/src/subcommand/add_subcommand.hpp @@ -12,6 +12,7 @@ class add_subcommand void run(); private: + bool m_all_flag = false; std::vector m_add_files; }; diff --git a/src/subcommand/branch_subcommand.cpp b/src/subcommand/branch_subcommand.cpp index c321fa8..3d233c4 100644 --- a/src/subcommand/branch_subcommand.cpp +++ b/src/subcommand/branch_subcommand.cpp @@ -1,6 +1,7 @@ +#include "../subcommand/branch_subcommand.hpp" + #include -#include "../subcommand/branch_subcommand.hpp" #include "../wrapper/repository_wrapper.hpp" branch_subcommand::branch_subcommand(const libgit2_object&, CLI::App& app) @@ -14,8 +15,18 @@ branch_subcommand::branch_subcommand(const libgit2_object&, CLI::App& app) sub->add_flag("-r,--remotes", m_remote_flag, "List or delete (if used with -d) the remote-tracking branches"); sub->add_flag("-l,--list", m_list_flag, "List branches"); sub->add_flag("-f,--force", m_force_flag, "Skips confirmation"); + sub->add_flag( + "--show-current", + m_show_current_flag, + "Print the name of the current branch. In detached HEAD state, nothing is printed." + ); - sub->callback([this]() { this->run(); }); + sub->callback( + [this]() + { + this->run(); + } + ); } void branch_subcommand::run() @@ -23,7 +34,12 @@ void branch_subcommand::run() auto directory = get_current_git_path(); auto repo = repository_wrapper::open(directory); - if (m_list_flag || m_branch_name.empty()) + if (m_show_current_flag) + { + // TODO: if another flag, return usage/Generic options/Specific git-branch actions + run_show_current(repo); + } + else if (m_list_flag || m_branch_name.empty()) { run_list(repo); } @@ -39,7 +55,7 @@ void branch_subcommand::run() void branch_subcommand::run_list(const repository_wrapper& repo) { - auto head_name = repo.head().short_name(); + auto head_name = repo.head_short_name(); git_branch_t type = m_all_flag ? GIT_BRANCH_ALL : (m_remote_flag ? GIT_BRANCH_REMOTE : GIT_BRANCH_LOCAL); auto iter = repo.iterate_branches(type); auto br = iter.next(); @@ -64,9 +80,20 @@ void branch_subcommand::run_deletion(repository_wrapper& repo) delete_branch(std::move(branch)); } - void branch_subcommand::run_creation(repository_wrapper& repo) { // TODO: handle specification of starting commit repo.create_branch(m_branch_name, m_force_flag); } + +void branch_subcommand::run_show_current(const repository_wrapper& repo) +{ + auto name = repo.head_short_name(); + + if (name == "HEAD") + { + return; + } + + std::cout << name << std::endl; +} diff --git a/src/subcommand/branch_subcommand.hpp b/src/subcommand/branch_subcommand.hpp index b0d95d6..79d7900 100644 --- a/src/subcommand/branch_subcommand.hpp +++ b/src/subcommand/branch_subcommand.hpp @@ -19,6 +19,7 @@ class branch_subcommand void run_list(const repository_wrapper& repo); void run_deletion(repository_wrapper& repo); void run_creation(repository_wrapper& repo); + void run_show_current(const repository_wrapper& repo); std::string m_branch_name = {}; bool m_deletion_flag = false; @@ -26,4 +27,5 @@ class branch_subcommand bool m_remote_flag = false; bool m_list_flag = false; bool m_force_flag = false; + bool m_show_current_flag = false; }; diff --git a/src/subcommand/checkout_subcommand.cpp b/src/subcommand/checkout_subcommand.cpp index 8ae8ebe..d2b29cd 100644 --- a/src/subcommand/checkout_subcommand.cpp +++ b/src/subcommand/checkout_subcommand.cpp @@ -1,20 +1,111 @@ +#include "../subcommand/checkout_subcommand.hpp" + +#include #include -#include +#include -#include "../subcommand/checkout_subcommand.hpp" +#include + +#include "../subcommand/status_subcommand.hpp" #include "../utils/git_exception.hpp" #include "../wrapper/repository_wrapper.hpp" +#include "../wrapper/status_wrapper.hpp" checkout_subcommand::checkout_subcommand(const libgit2_object&, CLI::App& app) { auto* sub = app.add_subcommand("checkout", "Switch branches or restore working tree files"); - sub->add_option("", m_branch_name, "Branch to checkout"); + sub->add_option( + "", + m_positional_args, + "Tree-ish to checkout, and/or one/many pathspec(s)\ne.g. checkout , checkout , checkout ..., checkout ...\nNote: use without '--'" + ); sub->add_flag("-b", m_create_flag, "Create a new branch before checking it out"); sub->add_flag("-B", m_force_create_flag, "Create a new branch or reset it if it exists before checking it out"); - sub->add_flag("-f, --force", m_force_checkout_flag, "When switching branches, proceed even if the index or the working tree differs from HEAD, and even if there are untracked files in the way"); + sub->add_flag( + "-f, --force", + m_force_checkout_flag, + "When switching branches, proceed even if the index or the working tree differs from HEAD, and even if there are untracked files in the way" + ); + + sub->callback( + [this]() + { + this->run(); + } + ); +} + +namespace +{ + void print_no_switch(status_list_wrapper& sl) + { + std::cout << "Your local changes to the following files would be overwritten by checkout:" << std::endl; + + for (const auto* entry : sl.get_entry_list(GIT_STATUS_WT_MODIFIED)) + { + std::cout << "\t" << entry->index_to_workdir->new_file.path << std::endl; + } + for (const auto* entry : sl.get_entry_list(GIT_STATUS_WT_DELETED)) + { + std::cout << "\t" << entry->index_to_workdir->old_file.path << std::endl; + } - sub->callback([this]() { this->run(); }); + std::cout << "Please commit your changes or stash them before you switch branches.\nAborting" + << std::endl; + return; + } +} + +std::vector convert_paths_to_cstr(const std::vector& pathspecs) +{ + std::vector pathspec_strings; + pathspec_strings.reserve(pathspecs.size()); + for (const auto& f : pathspecs) + { + pathspec_strings.push_back(f.c_str()); + } + return pathspec_strings; +} + +void checkout_subcommand::checkout_head_files( + const repository_wrapper& repo, + const std::vector& pathspecs, + const git_checkout_options& base_options +) +{ + std::vector pathspec_strings = convert_paths_to_cstr(pathspecs); + + git_checkout_options options = base_options; + options.paths.strings = const_cast(pathspec_strings.data()); + options.paths.count = pathspec_strings.size(); + + throw_if_error(git_checkout_head(repo, &options)); +} + +void checkout_subcommand::checkout_ref_files( + const repository_wrapper& repo, + const std::string_view tree_ish, + const std::vector& pathspecs, + const git_checkout_options& base_options +) +{ + auto obj = repo.revparse_single(tree_ish); + if (!obj) + { + throw git_exception( + "error: could not resolve tree-ish '" + std::string(tree_ish) + "'", + git2cpp_error_code::BAD_ARGUMENT + ); + } + + std::vector pathspec_strings = convert_paths_to_cstr(pathspecs); + + git_checkout_options options = base_options; + options.paths.strings = const_cast(pathspec_strings.data()); + options.paths.count = pathspec_strings.size(); + + throw_if_error(git_checkout_tree(repo, *obj, &options)); } void checkout_subcommand::run() @@ -30,45 +121,149 @@ void checkout_subcommand::run() git_checkout_options options; git_checkout_options_init(&options, GIT_CHECKOUT_OPTIONS_VERSION); - if(m_force_checkout_flag) + if (m_force_checkout_flag) { options.checkout_strategy = GIT_CHECKOUT_FORCE; } + else + { + options.checkout_strategy = GIT_CHECKOUT_SAFE; + } + + if (m_positional_args.empty()) + { + throw std::runtime_error("error: no branch or file specified"); + } + + const std::string& target_name = m_positional_args[0]; // can be a branch or a tag + const std::vector pathspecs(m_positional_args.begin() + 1, m_positional_args.end()); if (m_create_flag || m_force_create_flag) { - auto annotated_commit = create_local_branch(repo, m_branch_name, m_force_create_flag); - checkout_tree(repo, annotated_commit, m_branch_name, options); - update_head(repo, annotated_commit, m_branch_name); + if (!pathspecs.empty()) + { + throw git_exception("error: '-b' or '-B' does not accept pathspecs.", git2cpp_error_code::BAD_ARGUMENT); + } + + auto annotated_commit = create_local_branch(repo, target_name, m_force_create_flag); + checkout_tree(repo, annotated_commit, target_name, options); + update_head(repo, annotated_commit, target_name); + + std::cout << "Switched to a new branch '" << target_name << "'" << std::endl; } - else + else if (!pathspecs.empty()) { - auto optional_commit = repo.resolve_local_ref(m_branch_name); - if (!optional_commit) + // Validate all pathspecs before checkout so we can mimic git-like errors + auto lambda_validate_paths = + [](repository_wrapper& repo, const std::vector pathspecs, std::string directory) { - // TODO: handle remote refs - std::ostringstream buffer; - buffer << "error: could not resolve pathspec '" << m_branch_name << "'" << std::endl; - throw std::runtime_error(buffer.str()); + for (const auto& p : pathspecs) + { + if (!std::filesystem::exists(std::filesystem::path(directory) / p) && !repo.does_track(p)) + { + throw git_exception( + "error: pathspec '" + p + "' did not match any file(s) known to git", + git2cpp_error_code::BAD_ARGUMENT + ); + } + } + }; + + // Try tree-ish + pathspec(s) + if (auto obj = repo.revparse_single(target_name)) + { + lambda_validate_paths(repo, pathspecs, directory); + + options.checkout_strategy = GIT_CHECKOUT_FORCE; + checkout_ref_files(repo, target_name, pathspecs, options); + } + // Else treat as files + else + { + lambda_validate_paths(repo, pathspecs, directory); + + std::vector files = m_positional_args; + options.checkout_strategy = GIT_CHECKOUT_FORCE; + checkout_head_files(repo, files, options); } - checkout_tree(repo, *optional_commit, m_branch_name, options); - update_head(repo, *optional_commit, m_branch_name); + return; + } + + auto optional_commit = repo.resolve_local_ref(target_name); + if (!optional_commit) + { + // TODO: handle remote refs + + // Fall back to checking out a unique file + const std::vector file = {target_name}; + + if (!std::filesystem::exists(std::filesystem::path(directory) / target_name)) + { + // Neither a branch/tag nor a file + throw git_exception( + "error: pathspec '" + target_name + "' did not match any file(s) known to git", + git2cpp_error_code::BAD_ARGUMENT + ); + } + + options.checkout_strategy = GIT_CHECKOUT_FORCE; + checkout_head_files(repo, file, options); + return; + } + + auto sl = status_list_wrapper::status_list(repo); + try + { + checkout_tree(repo, *optional_commit, target_name, options); + update_head(repo, *optional_commit, target_name); + } + catch (const git_exception& e) + { + if (sl.has_notstagged_header()) + { + print_no_switch(sl); + } + throw e; + } + + if (sl.has_notstagged_header()) + { + bool is_long = false; + bool is_coloured = false; + std::set tracked_dir_set{}; + print_notstagged(sl, tracked_dir_set, is_long, is_coloured); + } + if (sl.has_tobecommited_header()) + { + bool is_long = false; + bool is_coloured = false; + std::set tracked_dir_set{}; + print_tobecommited(sl, tracked_dir_set, is_long, is_coloured); + } + + std::string_view annotated_ref = optional_commit->reference_name(); + if (!annotated_ref.empty() && repo.find_reference(annotated_ref).is_branch()) + { + std::cout << "Switched to branch '" << target_name << "'" << std::endl; + print_tracking_info(repo, sl, true, false); + } + else + { + std::string sha = optional_commit->commit_oid_tostr().substr(0, 7); + auto commit = repo.find_commit(optional_commit->oid()); + std::string summary = commit.summary(); + std::cout << "HEAD is now at " << sha << " " << summary << std::endl; } } -annotated_commit_wrapper checkout_subcommand::create_local_branch -( - repository_wrapper& repo, - const std::string_view target_name, - bool force -) +annotated_commit_wrapper +checkout_subcommand::create_local_branch(repository_wrapper& repo, const std::string_view target_name, bool force) { auto branch = repo.create_branch(target_name, force); return repo.find_annotated_commit(branch); } -void checkout_subcommand::checkout_tree -( +void checkout_subcommand::checkout_tree( const repository_wrapper& repo, const annotated_commit_wrapper& target_annotated_commit, const std::string_view target_name, @@ -79,29 +274,77 @@ void checkout_subcommand::checkout_tree throw_if_error(git_checkout_tree(repo, target_commit, &options)); } -void checkout_subcommand::update_head -( +void checkout_subcommand::update_head( repository_wrapper& repo, const annotated_commit_wrapper& target_annotated_commit, const std::string_view target_name ) { + // Check if HEAD is already detached or not + const bool head_was_detached = [&]() + { + auto head_ref = repo.head(); + return !head_ref.is_branch(); + }(); + + // Save previous HEAD info (if it was detached) before changing it (for output message) + std::optional previous_head_commit; + std::string previous_head_message; + if (head_was_detached) + { + previous_head_commit = repo.find_commit("HEAD"); + previous_head_message = "Previous HEAD position was " + + std::string(previous_head_commit.value().commit_oid_tostr().substr(0, 7)) + + " " + previous_head_commit.value().summary(); + } + std::string_view annotated_ref = target_annotated_commit.reference_name(); if (!annotated_ref.empty()) { auto ref = repo.find_reference(annotated_ref); - if (ref.is_remote()) + if (ref.is_branch()) { - auto branch = repo.create_branch(target_name, target_annotated_commit); - repo.set_head(branch.reference_name()); + if (head_was_detached) + { + std::cout << previous_head_message << std::endl; + } + repo.set_head(annotated_ref); + return; } - else + } + + repo.set_head_detached(target_annotated_commit); + + if (head_was_detached) + { + // Only print "Previous HEAD position..." if HEAD was already detached before and if there is an + // actual checkout + auto new_head_commit = repo.find_commit("HEAD"); + if (!git_oid_equal(&previous_head_commit.value().oid(), &new_head_commit.oid())) { - repo.set_head(annotated_ref); + std::cout << previous_head_message << std::endl; } } else { - repo.set_head_detached(target_annotated_commit); + // Only print the detached-HEAD advice if HEAD was not already detached. + std::cout << "Note: switching to '" << target_name << "'." << std::endl; + std::cout << std::endl; + std::cout << "You are in 'detached HEAD' state. You can look around, make experimental" << std::endl; + std::cout << "changes and commit them, and you can discard any commits you make in this" << std::endl; + std::cout << "state without impacting any branches by switching back to a branch." << std::endl; + std::cout << std::endl; + + // TODO: add to the following when the switch subcommand is implemented: + // std::cout << "If you want to create a new branch to retain commits you create, you may" << + // std::endl; std::cout << "do so (now or later) by using -c with the switch command. Example:" << + // std::endl; std::cout << " git switch -c " << std::endl; std::cout << std::endl; + // std::cout << "Or undo this operation with:" << std::endl; + // std::cout << std::endl; + // std::cout << " git switch -" << std::endl; + // std::cout << std::endl; + // TODO: add the following later + // std::cout << "Turn off this advice by setting config variable advice.detachedHead to false" + // << std::endl; } } diff --git a/src/subcommand/checkout_subcommand.hpp b/src/subcommand/checkout_subcommand.hpp index e041174..d34f94b 100644 --- a/src/subcommand/checkout_subcommand.hpp +++ b/src/subcommand/checkout_subcommand.hpp @@ -1,7 +1,7 @@ #pragma once -#include #include +#include #include @@ -17,29 +17,36 @@ class checkout_subcommand private: - annotated_commit_wrapper create_local_branch - ( - repository_wrapper& repo, - const std::string_view target_name, - bool force - ); + annotated_commit_wrapper + create_local_branch(repository_wrapper& repo, const std::string_view target_name, bool force); - void checkout_tree - ( + void checkout_tree( const repository_wrapper& repo, const annotated_commit_wrapper& target_annotated_commit, const std::string_view target_name, const git_checkout_options& options ); - void update_head - ( + void update_head( repository_wrapper& repo, const annotated_commit_wrapper& target_annotated_commit, const std::string_view target_name ); - std::string m_branch_name = {}; + void checkout_head_files( + const repository_wrapper& repo, + const std::vector& files, + const git_checkout_options& options + ); + + void checkout_ref_files( + const repository_wrapper& repo, + const std::string_view tree_ish, + const std::vector& pathspecs, + const git_checkout_options& options + ); + + std::vector m_positional_args = {}; bool m_create_flag = false; bool m_force_create_flag = false; bool m_force_checkout_flag = false; diff --git a/src/subcommand/clone_subcommand.cpp b/src/subcommand/clone_subcommand.cpp index 6c9b803..b93af35 100644 --- a/src/subcommand/clone_subcommand.cpp +++ b/src/subcommand/clone_subcommand.cpp @@ -1,7 +1,11 @@ +#include "../subcommand/clone_subcommand.hpp" + #include -#include "../subcommand/clone_subcommand.hpp" -#include "../utils/output.hpp" +#include "../utils/credentials.hpp" +#include "../utils/input_output.hpp" +#include "../utils/progress.hpp" +#include "../wasm/scope.hpp" #include "../wrapper/repository_wrapper.hpp" clone_subcommand::clone_subcommand(const libgit2_object&, CLI::App& app) @@ -10,83 +14,38 @@ clone_subcommand::clone_subcommand(const libgit2_object&, CLI::App& app) sub->add_option("", m_repository, "The (possibly remote) repository to clone from.")->required(); sub->add_option("", m_directory, "The name of a new directory to clone into."); + sub->add_option("--depth", m_depth, "Create a shallow clone of that depth."); + // sub->add_option("--shallow-since", m_shallow_since, "