From 76f210762b186031fb6a062e0f3a4aef2724d7f5 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Mon, 2 Feb 2026 09:00:38 +0000 Subject: [PATCH 01/35] Support git init and clone in wasm tests (#86) --- .gitignore | 1 + RELEASE.md | 2 +- test/conftest_wasm.py | 101 ++++++++++++++++++++++++++++-- test/test_clone.py | 22 ++++--- test/test_fixtures.py | 39 ++++++++++++ test/test_git.py | 25 ++++++++ wasm/.gitignore | 2 + wasm/CMakeLists.txt | 17 +++++ wasm/README.md | 51 ++++++++++++--- wasm/cockle-deploy/CMakeLists.txt | 2 +- wasm/cockle-deploy/package.json | 2 +- wasm/lite-deploy/CMakeLists.txt | 7 ++- wasm/recipe/CMakeLists.txt | 10 ++- wasm/recipe/modify-recipe.py | 18 +++--- wasm/test/CMakeLists.txt | 6 +- wasm/test/assets/index.html | 10 ++- wasm/test/cockle-config-in.json | 6 +- wasm/test/package.json | 8 ++- wasm/test/src/index.ts | 36 +++++++---- 19 files changed, 307 insertions(+), 58 deletions(-) create mode 100644 test/test_fixtures.py diff --git a/.gitignore b/.gitignore index 9e99726..1061b39 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__/ .cache/ compile_commands.json serve.log +test/test-results/ 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/test/conftest_wasm.py b/test/conftest_wasm.py index 89d5061..1c4fae0 100644 --- a/test/conftest_wasm.py +++ b/test/conftest_wasm.py @@ -1,15 +1,29 @@ -# Extra fixtures used for wasm testing. +# Extra fixtures used for wasm testing, including some that override the default pytest fixtures. from functools import partial -from pathlib import Path +import os +import pathlib from playwright.sync_api import Page import pytest +import re import subprocess import time + +# Only include particular test files when testing wasm. +# This can be removed when all tests support wasm. +def pytest_ignore_collect(collection_path: pathlib.Path) -> bool: + return collection_path.name not in [ + "test_clone.py", + "test_fixtures.py", + "test_git.py", + "test_init.py", + ] + + @pytest.fixture(scope="session", autouse=True) def run_web_server(): with open('serve.log', 'w') as f: - cwd = Path(__file__).parent.parent / 'wasm/test' + cwd = pathlib.Path(__file__).parent.parent / 'wasm/test' proc = subprocess.Popen( ['npm', 'run', 'serve'], stdout=f, stderr=f, cwd=cwd ) @@ -18,24 +32,76 @@ def run_web_server(): yield proc.terminate() + @pytest.fixture(scope="function", autouse=True) def load_page(page: Page): # Load web page at start of every test. page.goto("http://localhost:8000") page.locator("#loaded").wait_for() + +def os_chdir(dir: str): + subprocess.run(["cd", str(dir)], capture_output=True, check=True, text=True) + + +def os_getcwd(): + return subprocess.run(["pwd"], capture_output=True, check=True, text=True).stdout.strip() + + +class MockPath(pathlib.Path): + def __init__(self, path: str = ""): + super().__init__(path) + + def exists(self) -> bool: + p = subprocess.run(['stat', str(self)]) + return p.returncode == 0 + + def is_dir(self) -> bool: + p = subprocess.run(['stat', '-c', '%F', str(self)], capture_output=True, text=True) + return p.returncode == 0 and p.stdout.strip() == 'directory' + + def is_file(self) -> bool: + p = subprocess.run(['stat', '-c', '%F', str(self)], capture_output=True, text=True) + return p.returncode == 0 and p.stdout.strip() == 'regular file' + + def iterdir(self): + p = subprocess.run(["ls", str(self), '-a', '-1'], capture_output=True, text=True, check=True) + for f in filter(lambda f: f not in ['', '.', '..'], re.split(r"\r?\n", p.stdout)): + yield MockPath(self / f) + + def __truediv__(self, other): + if isinstance(other, str): + return MockPath(f"{self}/{other}") + raise RuntimeError("MockPath.__truediv__ only supports strings") + + def subprocess_run( page: Page, cmd: list[str], *, capture_output: bool = False, - cwd: str | None = None, + check: bool = False, + cwd: str | MockPath | None = None, text: bool | None = None ) -> subprocess.CompletedProcess: + shell_run = "async cmd => await window.cockle.shellRun(cmd)" + + # Set cwd. if cwd is not None: - raise RuntimeError('cwd is not yet supported') + proc = page.evaluate(shell_run, "pwd") + if proc['returncode'] != 0: + raise RuntimeError("Error getting pwd") + old_cwd = proc['stdout'].strip() + if old_cwd == str(cwd): + # cwd is already correct. + cwd = None + else: + proc = page.evaluate(shell_run, f"cd {cwd}") + if proc['returncode'] != 0: + raise RuntimeError(f"Error setting cwd to {cwd}") + + proc = page.evaluate(shell_run, " ".join(cmd)) - proc = page.evaluate("async cmd => window.cockle.shellRun(cmd)", cmd) # TypeScript object is auto converted to Python dict. # Want to return subprocess.CompletedProcess, consider namedtuple if this fails in future. stdout = proc['stdout'] if capture_output else '' @@ -43,6 +109,16 @@ def subprocess_run( if not text: stdout = stdout.encode("utf-8") stderr = stderr.encode("utf-8") + + # Reset cwd. + if cwd is not None: + proc = page.evaluate(shell_run, "cd " + old_cwd) + if proc['returncode'] != 0: + raise RuntimeError(f"Error setting cwd to {old_cwd}") + + if check and proc['returncode'] != 0: + raise subprocess.CalledProcessError(proc['returncode'], cmd, stdout, stderr) + return subprocess.CompletedProcess( args=cmd, returncode=proc['returncode'], @@ -50,6 +126,19 @@ def subprocess_run( stderr=stderr ) + +@pytest.fixture(scope="function") +def tmp_path() -> MockPath: + # Assumes only one tmp_path needed per test. + path = MockPath('/drive/tmp0') + subprocess.run(['mkdir', str(path)], check=True) + assert path.exists() + assert path.is_dir() + return path + + @pytest.fixture(scope="function", autouse=True) def mock_subprocess_run(page: Page, monkeypatch): monkeypatch.setattr(subprocess, "run", partial(subprocess_run, page)) + monkeypatch.setattr(os, "chdir", os_chdir) + monkeypatch.setattr(os, "getcwd", os_getcwd) diff --git a/test/test_clone.py b/test/test_clone.py index 0461d43..57144b4 100644 --- a/test/test_clone.py +++ b/test/test_clone.py @@ -1,7 +1,5 @@ -import os import subprocess - -import pytest +from .conftest import GIT2CPP_TEST_WASM url = "https://github.com/xtensor-stack/xtl.git" @@ -11,8 +9,8 @@ def test_clone(git2cpp_path, tmp_path, run_in_tmp_path): p_clone = subprocess.run(clone_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_clone.returncode == 0 - assert os.path.exists(os.path.join(tmp_path, "xtl")) - assert os.path.exists(os.path.join(tmp_path, "xtl/include")) + assert (tmp_path / "xtl").exists() + assert (tmp_path / "xtl/include").exists() def test_clone_is_bare(git2cpp_path, tmp_path, run_in_tmp_path): @@ -20,9 +18,19 @@ def test_clone_is_bare(git2cpp_path, tmp_path, run_in_tmp_path): p_clone = subprocess.run(clone_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_clone.returncode == 0 + assert (tmp_path / "xtl").is_dir() + status_cmd = [git2cpp_path, "status"] - p_status = subprocess.run(status_cmd, capture_output=True, cwd=tmp_path, text=True) - assert p_status.returncode != 0 + p_status = subprocess.run(status_cmd, capture_output=True, cwd=tmp_path / "xtl", text=True) + if not GIT2CPP_TEST_WASM: + # TODO: fix this in wasm build + assert p_status.returncode != 0 + assert "This operation is not allowed against bare repositories" in p_status.stderr + + branch_cmd = [git2cpp_path, "branch"] + p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=tmp_path / "xtl", text=True) + assert p_branch.returncode == 0 + assert p_branch.stdout.strip() == "* master" def test_clone_shallow(git2cpp_path, tmp_path, run_in_tmp_path): diff --git a/test/test_fixtures.py b/test/test_fixtures.py new file mode 100644 index 0000000..c8c0c04 --- /dev/null +++ b/test/test_fixtures.py @@ -0,0 +1,39 @@ +# Test fixtures to confirm that wasm monkeypatching works correctly. + +import re +import subprocess +from .conftest import GIT2CPP_TEST_WASM + + +def test_run_in_tmp_path(tmp_path, run_in_tmp_path): + p = subprocess.run(['pwd'], capture_output=True, text=True, check=True) + assert p.stdout.strip() == str(tmp_path) + + +def test_tmp_path(tmp_path): + p = subprocess.run(['pwd'], capture_output=True, text=True, check=True, cwd=str(tmp_path)) + assert p.stdout.strip() == str(tmp_path) + + assert tmp_path.exists() + assert tmp_path.is_dir() + assert not tmp_path.is_file() + + assert sorted(tmp_path.iterdir()) == [] + subprocess.run(['mkdir', f"{tmp_path}/def"], capture_output=True, text=True, check=True) + assert sorted(tmp_path.iterdir()) == [tmp_path / 'def'] + subprocess.run(['mkdir', f"{tmp_path}/abc"], capture_output=True, text=True, check=True) + assert sorted(tmp_path.iterdir()) == [tmp_path / 'abc', tmp_path / 'def'] + + p = subprocess.run(['pwd'], capture_output=True, text=True, check=True, cwd=tmp_path.parent) + assert p.stdout.strip() == str(tmp_path.parent) + assert tmp_path in list(tmp_path.parent.iterdir()) + + +def test_env_vars(): + # By default there should be not GIT_* env vars set. + p = subprocess.run(['env'], capture_output=True, text=True, check=True) + git_lines = sorted(filter(lambda f: f.startswith("GIT_"), re.split(r"\r?\n", p.stdout))) + if GIT2CPP_TEST_WASM: + assert git_lines == ["GIT_CORS_PROXY=http://localhost:8881/"] + else: + assert git_lines == [] diff --git a/test/test_git.py b/test/test_git.py index 8bf5e4f..50a954e 100644 --- a/test/test_git.py +++ b/test/test_git.py @@ -1,5 +1,7 @@ import pytest +import re import subprocess +from .conftest import GIT2CPP_TEST_WASM @pytest.mark.parametrize("arg", ['-v', '--version']) @@ -18,3 +20,26 @@ def test_error_on_unknown_option(git2cpp_path): assert p.returncode == 109 assert p.stdout == b'' assert p.stderr.startswith(b"The following argument was not expected: --unknown") + + +@pytest.mark.skipif(not GIT2CPP_TEST_WASM, reason="Only test in WebAssembly") +def test_cockle_config(git2cpp_path): + # Check cockle-config shows git2cpp is available. + cmd = ["cockle-config", "module", "git2cpp"] + p = subprocess.run(cmd, capture_output=True, text=True) + assert p.returncode == 0 + lines = [line for line in re.split(r"\r?\n", p.stdout) if len(line) > 0] + assert len(lines) == 5 + assert lines[1] == "│ module │ package │ cached │" + assert lines[3] == "│ git2cpp │ git2cpp │ │" + + p = subprocess.run([git2cpp_path, "-v"], capture_output=True, text=True) + assert p.returncode == 0 + + # Check git2cpp module has been cached. + p = subprocess.run(cmd, capture_output=True, text=True) + assert p.returncode == 0 + lines = [line for line in re.split(r"\r?\n", p.stdout) if len(line) > 0] + assert len(lines) == 5 + assert lines[1] == "│ module │ package │ cached │" + assert lines[3] == "│ git2cpp │ git2cpp │ yes │" diff --git a/wasm/.gitignore b/wasm/.gitignore index a4c0e06..8f8b10f 100644 --- a/wasm/.gitignore +++ b/wasm/.gitignore @@ -8,6 +8,8 @@ node_modules/ package-lock.json .jupyterlite.doit.db +cockle/ +lite-deploy/package.json recipe/em-forge-recipes/ serve/*/ test/assets/*/ diff --git a/wasm/CMakeLists.txt b/wasm/CMakeLists.txt index f8a6670..a6d0df5 100644 --- a/wasm/CMakeLists.txt +++ b/wasm/CMakeLists.txt @@ -1,6 +1,9 @@ cmake_minimum_required(VERSION 3.28) project(git2cpp-wasm) +option(USE_RECIPE_PATCHES "Use patches from emscripten-forge recipe or not" ON) +option(USE_COCKLE_RELEASE "Use latest cockle release rather than repo main branch" OFF) + add_subdirectory(recipe) add_subdirectory(cockle-deploy) add_subdirectory(lite-deploy) @@ -14,3 +17,17 @@ add_custom_target(rebuild DEPENDS rebuild-recipe rebuild-cockle rebuild-lite reb # Serve both cockle and JupyterLite deployments. add_custom_target(serve COMMAND npx static-handler --cors --coop --coep --corp serve) + +if (USE_COCKLE_RELEASE) + execute_process(COMMAND npm view @jupyterlite/cockle version OUTPUT_VARIABLE COCKLE_BRANCH) + set(COCKLE_BRANCH "v${COCKLE_BRANCH}") +else() + set(COCKLE_BRANCH "main") +endif() + +add_custom_target(cockle + COMMENT "Using cockle from github repository ${COCKLE_BRANCH} branch" + # Don't re-clone if directory already exists - could do better here. + COMMAND test -d cockle || git clone https://github.com/jupyterlite/cockle --depth 1 --branch ${COCKLE_BRANCH} + COMMAND cd cockle && npm install && npm run build +) diff --git a/wasm/README.md b/wasm/README.md index 899c0d2..75b828c 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -33,6 +33,18 @@ cmake . make ``` +The available `cmake` options are: + +- `USE_RECIPE_PATCHES`: Use patches from emscripten-forge recipe or not, default is `ON` +- `USE_COCKLE_RELEASE`: Use latest cockle release rather than repo main branch, default is `OFF` + +For example, to run `cmake` but without using emscripten-forge recipe patches use: + +```bash +cmake . -DUSE_RECIPE_PATCHES=OFF +make +``` + The built emscripten-forge package will be file named something like `git2cpp-0.0.5-h7223423_1.tar.bz2` in the directory `recipe/em-force-recipes/output/emscripten-wasm32`. @@ -53,28 +65,51 @@ Note that the `source` for the `git2cpp` package is the local filesystem rather version number of the current Emscripten-forge recipe rather than the version of the local `git2cpp` source code which can be checked using `git2cpp -v` at the `cockle`/`terminal` command line. +## Rebuild + +After making changes to the local `git2cpp` source code you can rebuild the WebAssembly package, +both deployments and test code using from the `wasm` directory: + +```bash +make rebuild +``` + ## Test -To test the WebAssembly build use: +To test the WebAssembly build use from the `wasm` directory: ```bash make test ``` This runs (some of) the tests in the top-level `test` directory with various monkey patching so that -`git2cpp` commands are executed in the browser. If there are problems running the tests then ensure -you have the latest `playwright` browser installed: - +`git2cpp` commands are executed in the browser. +The tests that are run are defined in the function `pytest_ignore_collect` in `conftest_wasm.py`. +If there are problems running the tests then ensure you have the latest `playwright` browser installed: ```bash playwright install chromium ``` -## Rebuild +You can run a specific test from the top-level `test` directory (not the `wasm/test` directory) +using: -After making changes to the local `git2cpp` source code you can rebuild the WebAssembly package, -both deployments and test code using: +```bash +GIT2CPP_TEST_WASM=1 pytest -v test_git.py::test_version +``` + +### Manually running the test servers + +If wasm tests are failing it can be helpful to run the test servers and manually run `cockle` +commands to help understand the problem. To do this use: ```bash -make rebuild +cd wasm/test +npm run serve ``` + +This will start both the test server on port 8000 and the CORS server on port 8881. Open a browser +at http://localhost:8000/ and to run a command such as `ls -l` open the dev console and enter the +following at the prompt: `await window.cockle.shellRun('ls -al')`. The generated output will appear +in a new `
` in the web page in a format similar to that returned by Python's +`subprocess.run()`. diff --git a/wasm/cockle-deploy/CMakeLists.txt b/wasm/cockle-deploy/CMakeLists.txt index 3cbc66e..6a11bf9 100644 --- a/wasm/cockle-deploy/CMakeLists.txt +++ b/wasm/cockle-deploy/CMakeLists.txt @@ -6,7 +6,7 @@ include("../common.cmake") set(SERVE_DIR "../serve/cockle") add_custom_target(build-cockle - DEPENDS build-recipe + DEPENDS build-recipe cockle COMMAND npm install COMMAND COCKLE_WASM_EXTRA_CHANNEL=${BUILT_PACKAGE_DIR} npm run build BYPRODUCTS cockle_wasm_env node_modules ${SERVE_DIR} diff --git a/wasm/cockle-deploy/package.json b/wasm/cockle-deploy/package.json index 4830e3d..2700383 100644 --- a/wasm/cockle-deploy/package.json +++ b/wasm/cockle-deploy/package.json @@ -20,7 +20,7 @@ "typescript": "^5.4.5" }, "dependencies": { - "@jupyterlite/cockle": "^1.3.0", + "@jupyterlite/cockle": "file:../cockle", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "deepmerge-ts": "^7.1.4" diff --git a/wasm/lite-deploy/CMakeLists.txt b/wasm/lite-deploy/CMakeLists.txt index 1ba6b5b..eb43121 100644 --- a/wasm/lite-deploy/CMakeLists.txt +++ b/wasm/lite-deploy/CMakeLists.txt @@ -6,7 +6,12 @@ include("../common.cmake") set(SERVE_DIR "../serve/lite") add_custom_target(build-lite - DEPENDS build-recipe + DEPENDS build-recipe cockle + + # Ensure cockle is installed in this directory so that we used the correct prepare_wasm script. + COMMAND npm install ../cockle + COMMAND npm install + COMMAND jupyter lite --version COMMAND COCKLE_WASM_EXTRA_CHANNEL=${BUILT_PACKAGE_DIR} jupyter lite build --output-dir ${SERVE_DIR} BYPRODUCTS cockle-config.json .cockle_temp/ .jupyterlite.doit.db cockle_wasm_env/ ${SERVE_DIR} diff --git a/wasm/recipe/CMakeLists.txt b/wasm/recipe/CMakeLists.txt index 48560e9..43445b9 100644 --- a/wasm/recipe/CMakeLists.txt +++ b/wasm/recipe/CMakeLists.txt @@ -5,7 +5,8 @@ include("../common.cmake") set(EM_FORGE_RECIPES_REPO "https://github.com/emscripten-forge/recipes") set(GIT2CPP_RECIPE_DIR "recipes/recipes_emscripten/git2cpp") -set(RATTLER_ARGS "--package-format tar-bz2 --target-platform emscripten-wasm32 -c https://repo.prefix.dev/emscripten-forge-dev -c microsoft -c conda-forge") +set(PREFIX_CHANNEL "https://repo.prefix.dev/emscripten-forge-4x") +set(RATTLER_ARGS "--package-format tar-bz2 --target-platform emscripten-wasm32 -c ${PREFIX_CHANNEL} -c microsoft -c conda-forge") # Only want the git2cpp recipe from emscripten-forge/recipes repo, not the whole repo. # Note removing the .git directory otherwise `git clean -fxd` will not remove the directory. @@ -18,9 +19,14 @@ add_custom_command( COMMAND ${CMAKE_COMMAND} -E remove_directory ${EM_FORGE_RECIPES_DIR}/.git ) +set(MODIFY_RECIPE_ARGS "") +if (NOT USE_RECIPE_PATCHES) + set(MODIFY_RECIPE_ARGS "--no-patches") +endif() + add_custom_target(modify-recipe DEPENDS ${EM_FORGE_RECIPES_DIR} - COMMAND python ${CMAKE_CURRENT_SOURCE_DIR}/modify-recipe.py ${GIT2CPP_RECIPE_DIR} + COMMAND python ${CMAKE_CURRENT_SOURCE_DIR}/modify-recipe.py ${GIT2CPP_RECIPE_DIR} ${MODIFY_RECIPE_ARGS} WORKING_DIRECTORY ${EM_FORGE_RECIPES_DIR} ) diff --git a/wasm/recipe/modify-recipe.py b/wasm/recipe/modify-recipe.py index 70a3d83..5cd105d 100644 --- a/wasm/recipe/modify-recipe.py +++ b/wasm/recipe/modify-recipe.py @@ -1,20 +1,18 @@ # Modify the git2cpp emscripten-forge recipe to build from the local repo. # This can be called repeatedly and will produce the same output. +import argparse from pathlib import Path import shutil -import sys import yaml -def quit(msg): - print(msg) - exit(1) +parser = argparse.ArgumentParser() +parser.add_argument('input_directory', type=Path) +parser.add_argument('--no-patches', action='store_true') +args = parser.parse_args() -if len(sys.argv) < 2: - quit(f'Usage: {sys.argv[0]} ') - -input_dir = Path(sys.argv[1]) +input_dir = args.input_directory if not input_dir.is_dir(): quit(f'{input_dir} should exist and be a directory') @@ -44,6 +42,10 @@ def quit(msg): print(' Changing source to point to local git2cpp repo') source['path'] = '../../../../../../' +if args.no_patches: + print(' Deleting patches') + del source['patches'] + # Overwrite recipe file. with open(input_filename, 'w') as f: yaml.dump(recipe, f) diff --git a/wasm/test/CMakeLists.txt b/wasm/test/CMakeLists.txt index 29db8ad..5700d94 100644 --- a/wasm/test/CMakeLists.txt +++ b/wasm/test/CMakeLists.txt @@ -4,7 +4,7 @@ project(git2cpp-wasm-test) include("../common.cmake") add_custom_target(build-test - DEPENDS build-recipe + DEPENDS build-recipe cockle COMMAND npm install COMMAND COCKLE_WASM_EXTRA_CHANNEL=${BUILT_PACKAGE_DIR} npm run build BYPRODUCTS cockle_wasm_env lib node_modules @@ -15,8 +15,8 @@ add_custom_target(rebuild-test ) add_custom_target(test - # A pytest fixture ensures that `npm run serve` runs for the duration of the tests. - COMMAND GIT2CPP_TEST_WASM=1 pytest -v -rP test_git.py + # A pytest fixture ensures that `npm run serve` runs for the duration of the tests. + COMMAND GIT2CPP_TEST_WASM=1 pytest -v -rP WORKING_DIRECTORY ../../test ) diff --git a/wasm/test/assets/index.html b/wasm/test/assets/index.html index 57ba584..d6506ab 100644 --- a/wasm/test/assets/index.html +++ b/wasm/test/assets/index.html @@ -1,8 +1,14 @@ - Testing git2cpp in cockle - + Testing git2cpp in cockle + + diff --git a/wasm/test/cockle-config-in.json b/wasm/test/cockle-config-in.json index bbb59a1..cb777b5 100644 --- a/wasm/test/cockle-config-in.json +++ b/wasm/test/cockle-config-in.json @@ -10,10 +10,6 @@ "vi": "vim" }, "environment": { - "GIT_CORS_PROXY": "https://corsproxy.io/?url=", - "GIT_AUTHOR_NAME": "Jane Doe", - "GIT_AUTHOR_EMAIL": "jane.doe@blabla.com", - "GIT_COMMITTER_NAME": "Jane Doe", - "GIT_COMMITTER_EMAIL": "jane.doe@blabla.com" + "GIT_CORS_PROXY": "http://localhost:8881/" } } diff --git a/wasm/test/package.json b/wasm/test/package.json index e4259f6..908dcce 100644 --- a/wasm/test/package.json +++ b/wasm/test/package.json @@ -10,12 +10,16 @@ "scripts": { "build": "rspack build", "postbuild": "node node_modules/@jupyterlite/cockle/lib/tools/prepare_wasm.js --copy assets", - "serve": "rspack serve" + "serve": "concurrently 'npm run serve:cockle' 'npm run serve:cors'", + "serve:cockle": "rspack serve", + "serve:cors": "HOST=localhost PORT=8881 node node_modules/cors-anywhere/server.js " }, "devDependencies": { - "@jupyterlite/cockle": "^1.3.0", + "@jupyterlite/cockle": "file:../cockle", "@rspack/cli": "^0.7.5", "@rspack/core": "^0.7.5", + "concurrently": "^9.2.1", + "cors-anywhere": "^0.4.4", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "typescript": "^5.5.4" diff --git a/wasm/test/src/index.ts b/wasm/test/src/index.ts index 37d4ab7..4ccb906 100644 --- a/wasm/test/src/index.ts +++ b/wasm/test/src/index.ts @@ -21,7 +21,7 @@ async function setup() { const cockle = { shell, - shellRun: (cmd: string[]) => shellRun(shell, output, cmd) + shellRun: (cmd: string) => shellRun(shell, output, cmd) }; (window as any).cockle = cockle; @@ -29,34 +29,48 @@ async function setup() { // Add div to indicate setup complete which can be awaited at start of tests. const div = document.createElement("div"); div.id = 'loaded'; - div.innerHTML = 'loaded'; + div.innerHTML = 'Loaded'; document.body.appendChild(div); } async function shellRun( shell: Shell, output: MockTerminalOutput, - cmd: string[] + cmd: string, ): Promise { - // Keep stdout and stderr separate by outputting stdout to temporary file and stderr to terminal, - // then read the temporary file to get stdout to return. + // Keep stdout and stderr separate by outputting stdout to terminal and stderr to temporary file, + // then read the temporary file to get stderr to return. + // There are issues here with use of \n and \r\n at ends of lines. output.clear(); - let cmdLine = cmd.join(' ') + '> .outtmp' + '\r'; + let cmdLine = cmd + ' 2> /drive/.errtmp' + '\r'; await shell.input(cmdLine); - const stderr = stripOutput(output.textAndClear(), cmdLine); + const stdout = stripOutput(output.textAndClear(), cmdLine); const returncode = await shell.exitCode(); - // Read stdout from .outtmp file. - cmdLine = 'cat .outtmp\r'; + // Read stderr from .errtmp file. + cmdLine = 'cat /drive/.errtmp\r'; await shell.input(cmdLine); - const stdout = stripOutput(output.textAndClear(), cmdLine); + const stderr = stripOutput(output.textAndClear(), cmdLine); // Delete temporary file. - cmdLine = 'rm .outtmp\r'; + cmdLine = 'rm /drive/.errtmp\r'; await shell.input(cmdLine); output.clear(); + // Display in browser in new div. + const div = document.createElement("div"); + let content = `> ${cmd}
returncode: ${returncode}` + if (stdout.length > 0) { + content += `
stdout: ${stdout.trim().replace(/\n/g, "
")}`; + } + if (stderr.length > 0) { + content += `
stderr: ${stderr.trim().replace(/\n/g, "
")}`; + } + div.innerHTML = content; + document.body.appendChild(div); + div.scrollIntoView(); + return { returncode, stdout, stderr }; } From efcbd3e3decb84201a79d05a8b83f054ff40f366 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Fri, 6 Feb 2026 09:25:40 +0000 Subject: [PATCH 02/35] Add CI run on macos (#92) --- .github/workflows/test.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6bd1a6c..1224b15 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,11 @@ 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@v6 From e00fe2eadc740800d950c9a770b7789314bdef46 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Fri, 6 Feb 2026 09:29:30 +0000 Subject: [PATCH 03/35] Support running all tests in WebAssembly (#89) * Support running all tests in WebAssembly * Add test-wasm CI run, on demand only --- .github/workflows/test-wasm.yml | 46 +++++++++++++++++++++++++ test/conftest.py | 15 ++++++--- test/conftest_wasm.py | 60 ++++++++++++++++++++++++++++++--- test/test_add.py | 8 +++-- test/test_branch.py | 7 +++- test/test_config.py | 5 ++- test/test_log.py | 6 +++- test/test_merge.py | 12 +++---- test/test_rebase.py | 30 +++++++++++------ test/test_reset.py | 6 +++- test/test_status.py | 6 +++- wasm/test/src/index.ts | 20 +++++++++-- 12 files changed, 185 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/test-wasm.yml diff --git a/.github/workflows/test-wasm.yml b/.github/workflows/test-wasm.yml new file mode 100644 index 0000000..fcd424a --- /dev/null +++ b/.github/workflows/test-wasm.yml @@ -0,0 +1,46 @@ +# 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 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.14' + + - name: Install mamba + uses: mamba-org/setup-micromamba@v2 + 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 built-test + + - name: Upload artifact containing emscripten-forge package + uses: actions/upload-pages-artifact@v4 + with: + path: ./wasm/recipe/em-forge-recipes/output/ + + - name: Run WebAssembly tests + shell: bash -l {0} + working-directory: wasm + run: | + make test diff --git a/test/conftest.py b/test/conftest.py index abb2efd..ea11f67 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -35,7 +35,14 @@ def xtl_clone(git2cpp_path, tmp_path, run_in_tmp_path): @pytest.fixture def commit_env_config(monkeypatch): - monkeypatch.setenv("GIT_AUTHOR_NAME", "Jane Doe") - monkeypatch.setenv("GIT_AUTHOR_EMAIL", "jane.doe@blabla.com") - monkeypatch.setenv("GIT_COMMITTER_NAME", "Jane Doe") - monkeypatch.setenv("GIT_COMMITTER_EMAIL", "jane.doe@blabla.com") + config = { + "GIT_AUTHOR_NAME": "Jane Doe", + "GIT_AUTHOR_EMAIL": "jane.doe@blabla.com", + "GIT_COMMITTER_NAME": "Jane Doe", + "GIT_COMMITTER_EMAIL": "jane.doe@blabla.com" + } + for key, value in config.items(): + if GIT2CPP_TEST_WASM: + subprocess.run(["export", f"{key}='{value}'"], check=True) + else: + monkeypatch.setenv(key, value) diff --git a/test/conftest_wasm.py b/test/conftest_wasm.py index 1c4fae0..23df174 100644 --- a/test/conftest_wasm.py +++ b/test/conftest_wasm.py @@ -13,10 +13,24 @@ # This can be removed when all tests support wasm. def pytest_ignore_collect(collection_path: pathlib.Path) -> bool: return collection_path.name not in [ + "test_add.py", + "test_branch.py", + "test_checkout.py" "test_clone.py", + "test_commit.py", + "test_config.py", "test_fixtures.py", "test_git.py", "test_init.py", + "test_log.py", + "test_merge.py", + "test_rebase.py", + "test_remote.py", + "test_reset.py", + "test_revlist.py", + "test_revparse.py", + "test_stash.py", + "test_status.py", ] @@ -48,6 +62,10 @@ def os_getcwd(): return subprocess.run(["pwd"], capture_output=True, check=True, text=True).stdout.strip() +def os_remove(file: str): + return subprocess.run(["rm", str(file)], capture_output=True, check=True, text=True) + + class MockPath(pathlib.Path): def __init__(self, path: str = ""): super().__init__(path) @@ -69,6 +87,23 @@ def iterdir(self): for f in filter(lambda f: f not in ['', '.', '..'], re.split(r"\r?\n", p.stdout)): yield MockPath(self / f) + def mkdir(self): + subprocess.run(["mkdir", str(self)], capture_output=True, text=True, check=True) + + def read_text(self) -> str: + p = subprocess.run(["cat", str(self)], capture_output=True, text=True, check=True) + text = p.stdout + if text.endswith("\n"): + text = text[:-1] + return text + + def write_text(self, data: str): + # Note that in general it is not valid to direct output of a subprocess.run call to a file, + # but we get away with it here as the command arguments are passed straight through to + # cockle without being checked. + p = subprocess.run(["echo", data, ">", str(self)], capture_output=True, text=True) + assert p.returncode == 0 + def __truediv__(self, other): if isinstance(other, str): return MockPath(f"{self}/{other}") @@ -82,13 +117,14 @@ def subprocess_run( capture_output: bool = False, check: bool = False, cwd: str | MockPath | None = None, + input: str | None = None, text: bool | None = None ) -> subprocess.CompletedProcess: - shell_run = "async cmd => await window.cockle.shellRun(cmd)" + shell_run = "async obj => await window.cockle.shellRun(obj.cmd, obj.input)" # Set cwd. if cwd is not None: - proc = page.evaluate(shell_run, "pwd") + proc = page.evaluate(shell_run, { "cmd": "pwd" } ) if proc['returncode'] != 0: raise RuntimeError("Error getting pwd") old_cwd = proc['stdout'].strip() @@ -96,11 +132,24 @@ def subprocess_run( # cwd is already correct. cwd = None else: - proc = page.evaluate(shell_run, f"cd {cwd}") + proc = page.evaluate(shell_run, { "cmd": f"cd {cwd}" } ) if proc['returncode'] != 0: raise RuntimeError(f"Error setting cwd to {cwd}") - proc = page.evaluate(shell_run, " ".join(cmd)) + def maybe_wrap_arg(s: str | MockPath) -> str: + # An argument containing spaces needs to be wrapped in quotes if it is not already, due + # to how the command is passed to cockle as a single string. + # Could do better here. + s = str(s) + if ' ' in s and not s.endswith("'"): + return "'" + s + "'" + return s + + shell_run_args = { + "cmd": " ".join([maybe_wrap_arg(s) for s in cmd]), + "input": input + } + proc = page.evaluate(shell_run, shell_run_args) # TypeScript object is auto converted to Python dict. # Want to return subprocess.CompletedProcess, consider namedtuple if this fails in future. @@ -112,7 +161,7 @@ def subprocess_run( # Reset cwd. if cwd is not None: - proc = page.evaluate(shell_run, "cd " + old_cwd) + proc = page.evaluate(shell_run, { "cmd": "cd " + old_cwd } ) if proc['returncode'] != 0: raise RuntimeError(f"Error setting cwd to {old_cwd}") @@ -142,3 +191,4 @@ def mock_subprocess_run(page: Page, monkeypatch): monkeypatch.setattr(subprocess, "run", partial(subprocess_run, page)) monkeypatch.setattr(os, "chdir", os_chdir) monkeypatch.setattr(os, "getcwd", os_getcwd) + monkeypatch.setattr(os, "remove", os_remove) diff --git a/test/test_add.py b/test/test_add.py index a772779..d324b8b 100644 --- a/test/test_add.py +++ b/test/test_add.py @@ -1,6 +1,7 @@ import subprocess import pytest +from .conftest import GIT2CPP_TEST_WASM @pytest.mark.parametrize("all_flag", ["", "-A", "--all", "--no-ignore-removal"]) @@ -38,5 +39,8 @@ def test_add_nogit(git2cpp_path, tmp_path): p.write_text('') cmd_add = [git2cpp_path, 'add', 'mook_file.txt'] - p_add = subprocess.run(cmd_add, cwd=tmp_path, text=True) - assert p_add.returncode != 0 + p_add = subprocess.run(cmd_add, cwd=tmp_path, text=True, capture_output=True) + if not GIT2CPP_TEST_WASM: + # TODO: fix this in wasm build + assert p_add.returncode != 0 + assert "error: could not find repository at" in p_add.stderr diff --git a/test/test_branch.py b/test/test_branch.py index 20c1149..3a21136 100644 --- a/test/test_branch.py +++ b/test/test_branch.py @@ -1,6 +1,7 @@ import subprocess import pytest +from .conftest import GIT2CPP_TEST_WASM def test_branch_list(xtl_clone, git2cpp_path, tmp_path): @@ -37,7 +38,11 @@ def test_branch_create_delete(xtl_clone, git2cpp_path, tmp_path): def test_branch_nogit(git2cpp_path, tmp_path): cmd = [git2cpp_path, 'branch'] p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) - assert p.returncode != 0 + if not GIT2CPP_TEST_WASM: + # TODO: fix this in wasm build + assert p.returncode != 0 + assert "error: could not find repository at" in p.stderr + def test_branch_new_repo(git2cpp_path, tmp_path, run_in_tmp_path): # tmp_path exists and is empty. diff --git a/test/test_config.py b/test/test_config.py index cecb720..dcf4712 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -1,6 +1,7 @@ import subprocess import pytest +from .conftest import GIT2CPP_TEST_WASM def test_config_list(commit_env_config, git2cpp_path, tmp_path): @@ -52,5 +53,7 @@ def test_config_unset(git2cpp_path, tmp_path): cmd_get = [git2cpp_path, "config", "get", "core.bare"] p_get = subprocess.run(cmd_get, capture_output=True, cwd=tmp_path, text=True) - assert p_get.returncode != 0 + if not GIT2CPP_TEST_WASM: + # TODO: fix this in wasm build + assert p_get.returncode != 0 assert p_get.stderr == "error: config value 'core.bare' was not found\n" diff --git a/test/test_log.py b/test/test_log.py index 639cacf..9d60d6f 100644 --- a/test/test_log.py +++ b/test/test_log.py @@ -1,6 +1,7 @@ import subprocess import pytest +from .conftest import GIT2CPP_TEST_WASM @pytest.mark.parametrize("format_flag", ["", "--format=full", "--format=fuller"]) @@ -40,7 +41,10 @@ def test_log(xtl_clone, commit_env_config, git2cpp_path, tmp_path, format_flag): def test_log_nogit(commit_env_config, git2cpp_path, tmp_path): cmd_log = [git2cpp_path, "log"] p_log = subprocess.run(cmd_log, capture_output=True, cwd=tmp_path, text=True) - assert p_log.returncode != 0 + if not GIT2CPP_TEST_WASM: + # TODO: fix this in wasm build + assert p_log.returncode != 0 + assert "error: could not find repository at" in p_log.stderr @pytest.mark.parametrize("max_count_flag", ["", "-n", "--max-count"]) diff --git a/test/test_merge.py b/test/test_merge.py index 411ec07..a094444 100644 --- a/test/test_merge.py +++ b/test/test_merge.py @@ -169,12 +169,12 @@ def test_merge_conflict(xtl_clone, commit_env_config, git2cpp_path, tmp_path, fl ) assert p_abort.returncode == 0 assert (xtl_path / "mook_file.txt").exists() - with open(xtl_path / "mook_file.txt") as f: - if answer == "y": - assert "BLA" in f.read() - assert "bla" not in f.read() - else: - assert "Abort." in p_abort.stdout + text = (xtl_path / "mook_file.txt").read_text() + if answer == "y": + assert "BLA" in text + assert "bla" not in text + else: + assert "Abort." in p_abort.stdout elif flag == "--quit": pass diff --git a/test/test_rebase.py b/test/test_rebase.py index cb4a214..de5efe8 100644 --- a/test/test_rebase.py +++ b/test/test_rebase.py @@ -1,6 +1,7 @@ import subprocess import pytest +from .conftest import GIT2CPP_TEST_WASM def test_rebase_basic(xtl_clone, commit_env_config, git2cpp_path, tmp_path): @@ -195,9 +196,7 @@ def test_rebase_abort(xtl_clone, commit_env_config, git2cpp_path, tmp_path): assert "Rebase aborted" in p_abort.stdout # Verify we're back to original state - with open(conflict_file) as f: - content = f.read() - assert content == "feature content" + assert conflict_file.read_text() == "feature content" def test_rebase_continue(xtl_clone, commit_env_config, git2cpp_path, tmp_path): @@ -237,9 +236,7 @@ def test_rebase_continue(xtl_clone, commit_env_config, git2cpp_path, tmp_path): assert "Successfully rebased" in p_continue.stdout # Verify resolution - with open(conflict_file) as f: - content = f.read() - assert content == "resolved content" + assert conflict_file.read_text() == "resolved content" def test_rebase_skip(xtl_clone, commit_env_config, git2cpp_path, tmp_path): @@ -349,7 +346,10 @@ def test_rebase_no_upstream_error(xtl_clone, commit_env_config, git2cpp_path, tm rebase_cmd = [git2cpp_path, "rebase"] p_rebase = subprocess.run(rebase_cmd, capture_output=True, cwd=xtl_path, text=True) - assert p_rebase.returncode != 0 + if not GIT2CPP_TEST_WASM: + # TODO: fix this in wasm build + assert p_rebase.returncode != 0 + assert "upstream is required for rebase" in p_rebase.stderr def test_rebase_invalid_upstream_error(xtl_clone, commit_env_config, git2cpp_path, tmp_path): @@ -359,7 +359,9 @@ def test_rebase_invalid_upstream_error(xtl_clone, commit_env_config, git2cpp_pat rebase_cmd = [git2cpp_path, "rebase", "nonexistent-branch"] p_rebase = subprocess.run(rebase_cmd, capture_output=True, cwd=xtl_path, text=True) - assert p_rebase.returncode != 0 + if not GIT2CPP_TEST_WASM: + # TODO: fix this in wasm build + assert p_rebase.returncode != 0 assert "could not resolve upstream" in p_rebase.stderr or "could not resolve upstream" in p_rebase.stdout @@ -388,7 +390,9 @@ def test_rebase_already_in_progress_error(xtl_clone, commit_env_config, git2cpp_ # Try to start another rebase rebase_cmd = [git2cpp_path, "rebase", "master"] p_rebase = subprocess.run(rebase_cmd, capture_output=True, cwd=xtl_path, text=True) - assert p_rebase.returncode != 0 + if not GIT2CPP_TEST_WASM: + # TODO: fix this in wasm build + assert p_rebase.returncode != 0 assert "rebase is already in progress" in p_rebase.stderr or "rebase is already in progress" in p_rebase.stdout @@ -399,7 +403,9 @@ def test_rebase_continue_without_rebase_error(xtl_clone, commit_env_config, git2 continue_cmd = [git2cpp_path, "rebase", "--continue"] p_continue = subprocess.run(continue_cmd, capture_output=True, cwd=xtl_path, text=True) - assert p_continue.returncode != 0 + if not GIT2CPP_TEST_WASM: + # TODO: fix this in wasm build + assert p_continue.returncode != 0 assert "No rebase in progress" in p_continue.stderr or "No rebase in progress" in p_continue.stdout @@ -427,5 +433,7 @@ def test_rebase_continue_with_unresolved_conflicts(xtl_clone, commit_env_config, # Try to continue without resolving continue_cmd = [git2cpp_path, "rebase", "--continue"] p_continue = subprocess.run(continue_cmd, capture_output=True, cwd=xtl_path, text=True) - assert p_continue.returncode != 0 + if not GIT2CPP_TEST_WASM: + # TODO: fix this in wasm build + assert p_continue.returncode != 0 assert "resolve conflicts" in p_continue.stderr or "resolve conflicts" in p_continue.stdout diff --git a/test/test_reset.py b/test/test_reset.py index e1242c1..dd87829 100644 --- a/test/test_reset.py +++ b/test/test_reset.py @@ -1,6 +1,7 @@ import subprocess import pytest +from .conftest import GIT2CPP_TEST_WASM def test_reset(xtl_clone, commit_env_config, git2cpp_path, tmp_path): @@ -36,4 +37,7 @@ def test_reset(xtl_clone, commit_env_config, git2cpp_path, tmp_path): def test_reset_nogit(git2cpp_path, tmp_path): cmd_reset = [git2cpp_path, "reset", "--hard", "HEAD~1"] p_reset = subprocess.run(cmd_reset, capture_output=True, cwd=tmp_path, text=True) - assert p_reset.returncode != 0 + if not GIT2CPP_TEST_WASM: + # TODO: fix this in wasm build + assert p_reset.returncode != 0 + assert "error: could not find repository at" in p_reset.stderr diff --git a/test/test_status.py b/test/test_status.py index 93a632c..7d4bb91 100644 --- a/test/test_status.py +++ b/test/test_status.py @@ -3,6 +3,7 @@ import subprocess import pytest +from .conftest import GIT2CPP_TEST_WASM @pytest.mark.parametrize("short_flag", ["", "-s", "--short"]) @@ -42,7 +43,10 @@ def test_status_new_file(xtl_clone, git2cpp_path, tmp_path, short_flag, long_fla def test_status_nogit(git2cpp_path, tmp_path): cmd = [git2cpp_path, "status"] p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) - assert p.returncode != 0 + if not GIT2CPP_TEST_WASM: + # TODO: fix this in wasm build + assert p.returncode != 0 + assert "error: could not find repository at" in p.stderr @pytest.mark.parametrize("short_flag", ["", "-s", "--short"]) diff --git a/wasm/test/src/index.ts b/wasm/test/src/index.ts index 4ccb906..8f38b22 100644 --- a/wasm/test/src/index.ts +++ b/wasm/test/src/index.ts @@ -1,4 +1,4 @@ -import { Shell } from '@jupyterlite/cockle'; +import { delay, Shell } from '@jupyterlite/cockle'; import { MockTerminalOutput } from './utils'; interface IReturn { @@ -21,7 +21,7 @@ async function setup() { const cockle = { shell, - shellRun: (cmd: string) => shellRun(shell, output, cmd) + shellRun: (cmd: string, input: string | undefined | null) => shellRun(shell, output, cmd, input) }; (window as any).cockle = cockle; @@ -37,13 +37,27 @@ async function shellRun( shell: Shell, output: MockTerminalOutput, cmd: string, + input: string | undefined | null ): Promise { // Keep stdout and stderr separate by outputting stdout to terminal and stderr to temporary file, // then read the temporary file to get stderr to return. // There are issues here with use of \n and \r\n at ends of lines. output.clear(); let cmdLine = cmd + ' 2> /drive/.errtmp' + '\r'; - await shell.input(cmdLine); + + if (input !== undefined && input !== null) { + async function delayThenStdin(): Promise { + const chars = input! + '\x04'; // EOT + await delay(100); + for (const char of chars) { + await shell.input(char); + await delay(10); + } + } + await Promise.all([shell.input(cmdLine), delayThenStdin()]); + } else { + await shell.input(cmdLine); + } const stdout = stripOutput(output.textAndClear(), cmdLine); const returncode = await shell.exitCode(); From 42d1072e02294b7f1a14239fcfe3836dde01304d Mon Sep 17 00:00:00 2001 From: Johan Mabille Date: Fri, 6 Feb 2026 16:02:56 +0100 Subject: [PATCH 04/35] Implemented mv subcommand (#93) * Implemented mv subcommand * Applied review comments --- CMakeLists.txt | 2 + src/main.cpp | 2 + src/subcommand/mv_subcommand.cpp | 45 ++++++ src/subcommand/mv_subcommand.hpp | 21 +++ src/wrapper/index_wrapper.cpp | 10 ++ src/wrapper/index_wrapper.hpp | 3 + test/conftest_wasm.py | 1 + test/test_mv.py | 250 +++++++++++++++++++++++++++++++ 8 files changed, 334 insertions(+) create mode 100644 src/subcommand/mv_subcommand.cpp create mode 100644 src/subcommand/mv_subcommand.hpp create mode 100644 test/test_mv.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 3212e5a..ad29fd2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -60,6 +60,8 @@ set(GIT2CPP_SRC ${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 diff --git a/src/main.cpp b/src/main.cpp index 6aaec34..529002d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -15,6 +15,7 @@ #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" @@ -48,6 +49,7 @@ int main(int argc, char** argv) 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); diff --git a/src/subcommand/mv_subcommand.cpp b/src/subcommand/mv_subcommand.cpp new file mode 100644 index 0000000..adff792 --- /dev/null +++ b/src/subcommand/mv_subcommand.cpp @@ -0,0 +1,45 @@ +#include +#include +#include "mv_subcommand.hpp" + +#include "../utils/git_exception.hpp" +#include "../wrapper/index_wrapper.hpp" +#include "../wrapper/repository_wrapper.hpp" + +namespace fs = std::filesystem; + +mv_subcommand::mv_subcommand(const libgit2_object&, CLI::App& app) +{ + auto* sub = app.add_subcommand("mv" , "Move or rename a file, a directory, or a symlink"); + sub->add_option("", m_source_path, "The path of the source to move")->required()->check(CLI::ExistingFile); + sub->add_option("", m_destination_path, "The path of the destination")->required(); + sub->add_flag("-f,--force", m_force, "Force renaming or moving of a file even if the exists."); + + sub->callback([this]() { this->run(); }); +} + +void mv_subcommand::run() +{ + auto directory = get_current_git_path(); + auto repo = repository_wrapper::open(directory); + + bool exists = fs::exists(m_destination_path) && !fs::is_directory(m_destination_path); + if (exists && !m_force) + { + // TODO: replace magic number with enum when diff command is merged + throw git_exception("destination already exists", 128); + } + + std::error_code ec; + fs::rename(m_source_path, m_destination_path, ec); + + if(ec) + { + throw git_exception("Could not move file", ec.value()); + } + + auto index = repo.make_index(); + index.remove_entry(m_source_path); + index.add_entry(m_destination_path); + index.write(); +} diff --git a/src/subcommand/mv_subcommand.hpp b/src/subcommand/mv_subcommand.hpp new file mode 100644 index 0000000..dc74032 --- /dev/null +++ b/src/subcommand/mv_subcommand.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +#include "../utils/common.hpp" + +class mv_subcommand +{ +public: + + explicit mv_subcommand(const libgit2_object&, CLI::App& app); + void run(); + +private: + + std::string m_source_path; + std::string m_destination_path; + bool m_force = false; +}; + diff --git a/src/wrapper/index_wrapper.cpp b/src/wrapper/index_wrapper.cpp index 7ff0ce2..4ba0b15 100644 --- a/src/wrapper/index_wrapper.cpp +++ b/src/wrapper/index_wrapper.cpp @@ -21,6 +21,11 @@ index_wrapper index_wrapper::init(repository_wrapper& rw) return index; } +void index_wrapper::add_entry(const std::string& path) +{ + throw_if_error(git_index_add_bypath(*this, path.c_str())); +} + void index_wrapper::add_entries(std::vector patterns) { add_impl(std::move(patterns)); @@ -37,6 +42,11 @@ void index_wrapper::add_impl(std::vector patterns) throw_if_error(git_index_add_all(*this, array, 0, NULL, NULL)); } +void index_wrapper::remove_entry(const std::string& path) +{ + throw_if_error(git_index_remove_bypath(*this, path.c_str())); +} + void index_wrapper::write() { throw_if_error(git_index_write(*this)); diff --git a/src/wrapper/index_wrapper.hpp b/src/wrapper/index_wrapper.hpp index 0fa8b55..9091242 100644 --- a/src/wrapper/index_wrapper.hpp +++ b/src/wrapper/index_wrapper.hpp @@ -23,9 +23,12 @@ class index_wrapper : public wrapper_base void write(); git_oid write_tree(); + void add_entry(const std::string& path); void add_entries(std::vector patterns); void add_all(); + void remove_entry(const std::string& path); + bool has_conflict() const; void output_conflicts(); void conflict_cleanup(); diff --git a/test/conftest_wasm.py b/test/conftest_wasm.py index 23df174..64e33ae 100644 --- a/test/conftest_wasm.py +++ b/test/conftest_wasm.py @@ -24,6 +24,7 @@ def pytest_ignore_collect(collection_path: pathlib.Path) -> bool: "test_init.py", "test_log.py", "test_merge.py", + "test_mv.py", "test_rebase.py", "test_remote.py", "test_reset.py", diff --git a/test/test_mv.py b/test/test_mv.py new file mode 100644 index 0000000..72b71bb --- /dev/null +++ b/test/test_mv.py @@ -0,0 +1,250 @@ +import subprocess + +import pytest + + +def test_mv_basic(xtl_clone, git2cpp_path, tmp_path): + """Test basic mv operation to rename a file""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a test file + test_file = xtl_path / "test_file.txt" + test_file.write_text("test content") + + # Add the file to git + add_cmd = [git2cpp_path, "add", "test_file.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + # Move/rename the file + mv_cmd = [git2cpp_path, "mv", "test_file.txt", "renamed_file.txt"] + p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_mv.returncode == 0 + + # Verify the file was moved + assert not test_file.exists() + assert (xtl_path / "renamed_file.txt").exists() + + # Check git status + status_cmd = [git2cpp_path, "status", "--long"] + p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_status.returncode == 0 + # TODO: uncomment this when the status command is fixed. + #assert "renamed:" in p_status.stdout and "renamed_file.txt" in p_status.stdout + + +def test_mv_to_subdirectory(xtl_clone, git2cpp_path, tmp_path): + """Test moving a file to a subdirectory""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a test file + test_file = xtl_path / "move_me.txt" + test_file.write_text("content to move") + + # Add the file to git + add_cmd = [git2cpp_path, "add", "move_me.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + # Move the file to existing subdirectory + mv_cmd = [git2cpp_path, "mv", "move_me.txt", "include/move_me.txt"] + p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_mv.returncode == 0 + + # Verify the file was moved + assert not test_file.exists() + assert (xtl_path / "include" / "move_me.txt").exists() + + # Check git status + status_cmd = [git2cpp_path, "status", "--long"] + p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_status.returncode == 0 + # TODO: uncomment this when the status command is fixed. + #assert "renamed:" in p_status.stdout and "move_me.txt" in p_status.stdout + + +def test_mv_destination_exists_without_force(xtl_clone, git2cpp_path, tmp_path): + """Test that mv fails when destination exists without --force flag""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create source file + source_file = xtl_path / "source.txt" + source_file.write_text("source content") + + # Create destination file + dest_file = xtl_path / "destination.txt" + dest_file.write_text("destination content") + + # Add both files to git + add_cmd = [git2cpp_path, "add", "source.txt", "destination.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + # Try to move without force - should fail + mv_cmd = [git2cpp_path, "mv", "source.txt", "destination.txt"] + p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_mv.returncode != 0 + assert "destination already exists" in p_mv.stderr + + # Verify source file still exists + assert source_file.exists() + assert dest_file.exists() + + +@pytest.mark.parametrize("force_flag", ["-f", "--force"]) +def test_mv_destination_exists_with_force(xtl_clone, git2cpp_path, tmp_path, force_flag): + """Test that mv succeeds when destination exists with --force flag""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create source file + source_file = xtl_path / "source.txt" + source_file.write_text("source content") + + # Create destination file + dest_file = xtl_path / "destination.txt" + dest_file.write_text("destination content") + + # Add both files to git + add_cmd = [git2cpp_path, "add", "source.txt", "destination.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + # Move with force - should succeed + mv_cmd = [git2cpp_path, "mv", force_flag, "source.txt", "destination.txt"] + p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_mv.returncode == 0 + + # Verify source file was moved + assert not source_file.exists() + assert dest_file.exists() + assert dest_file.read_text() == "source content" + + +def test_mv_nonexistent_source(xtl_clone, git2cpp_path, tmp_path): + """Test that mv fails when source file doesn't exist""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Try to move a file that doesn't exist + mv_cmd = [git2cpp_path, "mv", "nonexistent.txt", "destination.txt"] + p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_mv.returncode != 0 + + +def test_mv_multiple_files(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test moving multiple files sequentially""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create test files + file1 = xtl_path / "file1.txt" + file1.write_text("content 1") + file2 = xtl_path / "file2.txt" + file2.write_text("content 2") + + # Add files to git + add_cmd = [git2cpp_path, "add", "file1.txt", "file2.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + # Commit the files + commit_cmd = [git2cpp_path, "commit", "-m", "Add test files"] + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_commit.returncode == 0 + + # Move first file + mv_cmd1 = [git2cpp_path, "mv", "file1.txt", "renamed1.txt"] + p_mv1 = subprocess.run(mv_cmd1, capture_output=True, cwd=xtl_path, text=True) + assert p_mv1.returncode == 0 + + # Move second file + mv_cmd2 = [git2cpp_path, "mv", "file2.txt", "renamed2.txt"] + p_mv2 = subprocess.run(mv_cmd2, capture_output=True, cwd=xtl_path, text=True) + assert p_mv2.returncode == 0 + + # Verify both files were moved + assert not file1.exists() + assert not file2.exists() + assert (xtl_path / "renamed1.txt").exists() + assert (xtl_path / "renamed2.txt").exists() + + +def test_mv_and_commit(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test moving a file and committing the change""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a test file + test_file = xtl_path / "original.txt" + test_file.write_text("original content") + + # Add and commit the file + add_cmd = [git2cpp_path, "add", "original.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + commit_cmd = [git2cpp_path, "commit", "-m", "Add original file"] + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_commit.returncode == 0 + + # Move the file + mv_cmd = [git2cpp_path, "mv", "original.txt", "moved.txt"] + p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_mv.returncode == 0 + + # Check status before commit + status_cmd = [git2cpp_path, "status", "--long"] + p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_status.returncode == 0 + assert "Changes to be committed" in p_status.stdout + + # Commit the move + commit_cmd2 = [git2cpp_path, "commit", "-m", "Move file"] + p_commit2 = subprocess.run(commit_cmd2, capture_output=True, cwd=xtl_path, text=True) + assert p_commit2.returncode == 0 + + # Verify the file is in the new location + assert not (xtl_path / "original.txt").exists() + assert (xtl_path / "moved.txt").exists() + + +def test_mv_nogit(git2cpp_path, tmp_path): + """Test that mv fails when not in a git repository""" + # Create a test file outside a git repo + test_file = tmp_path / "test.txt" + test_file.write_text("test content") + + # Try to mv without being in a git repo + mv_cmd = [git2cpp_path, "mv", "test.txt", "moved.txt"] + p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_mv.returncode != 0 + + +def test_mv_preserve_content(xtl_clone, git2cpp_path, tmp_path): + """Test that file content is preserved after mv""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a test file with specific content + test_content = "This is important content that should be preserved" + test_file = xtl_path / "important.txt" + test_file.write_text(test_content) + + # Add the file to git + add_cmd = [git2cpp_path, "add", "important.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + # Move the file + mv_cmd = [git2cpp_path, "mv", "important.txt", "preserved.txt"] + p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_mv.returncode == 0 + + # Verify content is preserved + moved_file = xtl_path / "preserved.txt" + assert moved_file.exists() + assert moved_file.read_text() == test_content From e5586eb03d937cd2b964988dde3934f18e488081 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Fri, 6 Feb 2026 15:32:53 +0000 Subject: [PATCH 05/35] Fix wasm-test github action (#94) * Try to fix test-wasm github action * Install playwright chromium --- .github/workflows/test-wasm.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-wasm.yml b/.github/workflows/test-wasm.yml index fcd424a..6065ccd 100644 --- a/.github/workflows/test-wasm.yml +++ b/.github/workflows/test-wasm.yml @@ -32,7 +32,13 @@ jobs: run: | cmake . make build-recipe - make built-test + 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@v4 From a61ab2687407dbe0f30fd560f61cf9faf313d8b2 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Fri, 6 Feb 2026 16:33:25 +0100 Subject: [PATCH 06/35] Add diff subcommand (#87) * Add diff subcommand * edit error codes * address review comments * Update src/subcommand/diff_subcommand.cpp Co-authored-by: Johan Mabille --------- Co-authored-by: Johan Mabille --- CMakeLists.txt | 10 + src/main.cpp | 2 + src/subcommand/checkout_subcommand.cpp | 2 +- src/subcommand/diff_subcommand.cpp | 345 +++++++++++++ src/subcommand/diff_subcommand.hpp | 55 ++ src/utils/common.cpp | 17 +- src/utils/common.hpp | 2 + src/utils/git_exception.cpp | 5 +- src/utils/git_exception.hpp | 7 + src/wrapper/diff_wrapper.cpp | 37 ++ src/wrapper/diff_wrapper.hpp | 31 ++ src/wrapper/diffstats_wrapper.cpp | 20 + src/wrapper/diffstats_wrapper.hpp | 25 + src/wrapper/patch_wrapper.cpp | 27 + src/wrapper/patch_wrapper.hpp | 25 + src/wrapper/repository_wrapper.cpp | 70 ++- src/wrapper/repository_wrapper.hpp | 11 + src/wrapper/tree_wrapper.cpp | 12 + src/wrapper/tree_wrapper.hpp | 23 + test/test_diff.py | 676 +++++++++++++++++++++++++ 20 files changed, 1394 insertions(+), 8 deletions(-) create mode 100644 src/subcommand/diff_subcommand.cpp create mode 100644 src/subcommand/diff_subcommand.hpp create mode 100644 src/wrapper/diff_wrapper.cpp create mode 100644 src/wrapper/diff_wrapper.hpp create mode 100644 src/wrapper/diffstats_wrapper.cpp create mode 100644 src/wrapper/diffstats_wrapper.hpp create mode 100644 src/wrapper/patch_wrapper.cpp create mode 100644 src/wrapper/patch_wrapper.hpp create mode 100644 src/wrapper/tree_wrapper.cpp create mode 100644 src/wrapper/tree_wrapper.hpp create mode 100644 test/test_diff.py diff --git a/CMakeLists.txt b/CMakeLists.txt index ad29fd2..8bf8207 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,6 +48,8 @@ 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 @@ -98,10 +100,16 @@ set(GIT2CPP_SRC ${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 @@ -116,6 +124,8 @@ set(GIT2CPP_SRC ${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/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/src/main.cpp b/src/main.cpp index 529002d..029e694 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -11,6 +11,7 @@ #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" @@ -45,6 +46,7 @@ int main(int argc, char** argv) 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); diff --git a/src/subcommand/checkout_subcommand.cpp b/src/subcommand/checkout_subcommand.cpp index 8ae8ebe..aba90fb 100644 --- a/src/subcommand/checkout_subcommand.cpp +++ b/src/subcommand/checkout_subcommand.cpp @@ -24,7 +24,7 @@ void checkout_subcommand::run() if (repo.state() != GIT_REPOSITORY_STATE_NONE) { - throw std::runtime_error("Cannot checkout, repository is in unexpected state"); + throw std::runtime_error("Cannot checkout, repository is in unexpected state"); } git_checkout_options options; diff --git a/src/subcommand/diff_subcommand.cpp b/src/subcommand/diff_subcommand.cpp new file mode 100644 index 0000000..0e352e0 --- /dev/null +++ b/src/subcommand/diff_subcommand.cpp @@ -0,0 +1,345 @@ +#include +#include +#include + +#include "../utils/common.hpp" +#include "../utils/git_exception.hpp" +#include "../subcommand/diff_subcommand.hpp" +#include "../wrapper/patch_wrapper.hpp" +#include "../wrapper/repository_wrapper.hpp" + +diff_subcommand::diff_subcommand(const libgit2_object&, CLI::App& app) +{ + auto* sub = app.add_subcommand("diff", "Show changes between commits, commit and working tree, etc"); + + sub->add_option("", m_files, "tree-ish objects to compare"); + + sub->add_flag("--stat", m_stat_flag, "Generate a diffstat"); + sub->add_flag("--shortstat", m_shortstat_flag, "Output only the last line of --stat"); + sub->add_flag("--numstat", m_numstat_flag, "Machine-friendly --stat"); + sub->add_flag("--summary", m_summary_flag, "Output a condensed summary"); + sub->add_flag("--name-only", m_name_only_flag, "Show only names of changed files"); + sub->add_flag("--name-status", m_name_status_flag, "Show names and status of changed files"); + sub->add_flag("--raw", m_raw_flag, "Generate the diff in raw format"); + + sub->add_flag("--cached,--staged", m_cached_flag, "Compare staged changes to HEAD"); + sub->add_flag("--no-index", m_no_index_flag, "Compare two files on filesystem"); + + sub->add_flag("-R", m_reverse_flag, "Swap two inputs"); + sub->add_flag("-a,--text", m_text_flag, "Treat all files as text"); + sub->add_flag("--ignore-space-at-eol", m_ignore_space_at_eol_flag, "Ignore changes in whitespace at EOL"); + sub->add_flag("-b,--ignore-space-change", m_ignore_space_change_flag, "Ignore changes in amount of whitespace"); + sub->add_flag("-w,--ignore-all-space", m_ignore_all_space_flag, "Ignore whitespace when comparing lines"); + sub->add_flag("--patience", m_patience_flag, "Generate diff using patience algorithm"); + sub->add_flag("--minimal", m_minimal_flag, "Spend extra time to find smallest diff"); + + // TODO: add the following flags after the "move" subcommand has been implemented (needed for the tests) + // sub->add_option("-M,--find-renames", m_rename_threshold, "Detect renames") + // ->expected(0,1) + // ->each([this](const std::string&) { m_find_renames_flag = true; }); + // sub->add_option("-C,--find-copies", m_copy_threshold, "Detect copies") + // ->expected(0,1) + // ->each([this](const std::string&) { m_find_copies_flag = true; }); + // sub->add_flag("--find-copies-harder", m_find_copies_harder_flag, "Detect copies from unmodified files"); + // sub->add_flag("-B,--break-rewrites", m_break_rewrites_flag, "Detect file rewrites"); + + sub->add_option("-U,--unified", m_context_lines, "Lines of context"); + sub->add_option("--inter-hunk-context", m_interhunk_lines, "Context between hunks"); + sub->add_option("--abbrev", m_abbrev, "Abbreviation length for object names") + ->expected(0,1); + + sub->add_flag("--color", m_colour_flag, "Show colored diff"); + sub->add_flag("--no-color", m_no_colour_flag, "Turn off colored diff"); + + sub->callback([this]() { this->run(); }); +} + +void diff_subcommand::print_stats(const diff_wrapper& diff, bool use_colour) +{ + git_diff_stats_format_t format; + if (m_stat_flag) + { + if (m_shortstat_flag || m_numstat_flag || m_summary_flag) + { + throw git_exception("Only one of --stat, --shortstat, --numstat and --summary should be provided.", git2cpp_error_code::BAD_ARGUMENT); + } + else + { + format = GIT_DIFF_STATS_FULL; + } + } + else if (m_shortstat_flag) + { + if (m_numstat_flag || m_summary_flag) + { + throw git_exception("Only one of --stat, --shortstat, --numstat and --summary should be provided.", git2cpp_error_code::BAD_ARGUMENT); + } + else + { + format = GIT_DIFF_STATS_SHORT; + } + } + else if (m_numstat_flag) + { + if (m_summary_flag) + { + throw git_exception("Only one of --stat, --shortstat, --numstat and --summary should be provided.", git2cpp_error_code::BAD_ARGUMENT); + } + else + { + format = GIT_DIFF_STATS_NUMBER; + } + } + else if (m_summary_flag) + { + format = GIT_DIFF_STATS_INCLUDE_SUMMARY; + } + + auto stats = diff.get_stats(); + auto buf = stats.to_buf(format, 80); + + if (use_colour && m_stat_flag) + { + // Add colors to + and - characters + std::string output(buf.ptr); + bool in_parentheses = false; + for (char c : output) + { + if (c == '(') + { + in_parentheses = true; + std::cout << c; + } + else if (c == ')') + { + in_parentheses = false; + std::cout << c; + } + else if (c == '+' && !in_parentheses) + { + std::cout << termcolor::green << '+' << termcolor::reset; + } + else if (c == '-' && !in_parentheses) + { + std::cout << termcolor::red << '-' << termcolor::reset; + } + else + { + std::cout << c; + } + } + } + else + { + std::cout << buf.ptr; + } + + git_buf_dispose(&buf); +} + +static int colour_printer([[maybe_unused]] const git_diff_delta* delta, [[maybe_unused]] const git_diff_hunk* hunk, const git_diff_line* line, void* payload) +{ + bool use_colour = *reinterpret_cast(payload); + + // Only print origin for context/addition/deletion lines + // For other line types, content already includes everything + bool print_origin = (line->origin == GIT_DIFF_LINE_CONTEXT || + line->origin == GIT_DIFF_LINE_ADDITION || + line->origin == GIT_DIFF_LINE_DELETION); + + if (use_colour) + { + switch (line->origin) { + case GIT_DIFF_LINE_ADDITION: std::cout << termcolor::green; break; + case GIT_DIFF_LINE_DELETION: std::cout << termcolor::red; break; + case GIT_DIFF_LINE_ADD_EOFNL: std::cout << termcolor::green; break; + case GIT_DIFF_LINE_DEL_EOFNL: std::cout << termcolor::red; break; + case GIT_DIFF_LINE_FILE_HDR: std::cout << termcolor::bold; break; + case GIT_DIFF_LINE_HUNK_HDR: std::cout << termcolor::cyan; break; + default: break; + } + } + + if (print_origin) + { + std::cout << line->origin; + } + + std::cout << std::string_view(line->content, line->content_len); + + if (use_colour) + { + std::cout << termcolor::reset; + } + + return 0; +} + +void diff_subcommand::print_diff(diff_wrapper& diff, bool use_colour) +{ + if (m_stat_flag || m_shortstat_flag || m_numstat_flag || m_summary_flag) + { + print_stats(diff, use_colour); + return; + } + + // TODO: add the following flags after the "move" subcommand has been implemented (needed for the tests) + // if (m_find_renames_flag || m_find_copies_flag || m_find_copies_harder_flag || m_break_rewrites_flag) + // { + // git_diff_find_options find_opts; + // git_diff_find_options_init(&find_opts, GIT_DIFF_FIND_OPTIONS_VERSION); + + // if (m_find_renames_flag) + // { + // find_opts.flags |= GIT_DIFF_FIND_RENAMES; + // find_opts.rename_threshold = m_rename_threshold; + // } + // if (m_find_copies_flag) + // { + // find_opts.flags |= GIT_DIFF_FIND_COPIES; + // find_opts.copy_threshold = m_copy_threshold; + // } + // if (m_find_copies_harder_flag) + // { + // find_opts.flags |= GIT_DIFF_FIND_COPIES_FROM_UNMODIFIED; + // } + // if (m_break_rewrites_flag) + // { + // find_opts.flags |= GIT_DIFF_FIND_REWRITES; + // } + + // diff.find_similar(&find_opts); + // } + + git_diff_format_t format = GIT_DIFF_FORMAT_PATCH; + if (m_name_only_flag) + { + format = GIT_DIFF_FORMAT_NAME_ONLY; + } + else if (m_name_status_flag) + { + format = GIT_DIFF_FORMAT_NAME_STATUS; + } + else if (m_raw_flag) + { + format = GIT_DIFF_FORMAT_RAW; + } + + diff.print(format, colour_printer, &use_colour); +} + +diff_wrapper compute_diff_no_index(std::vector files, git_diff_options& diffopts) //std::pair +{ + if (files.size() != 2) + { + throw git_exception("usage: git diff --no-index [] [...]", git2cpp_error_code::BAD_ARGUMENT); + } + + git_diff_options_init(&diffopts, GIT_DIFF_OPTIONS_VERSION); + + std::string file1_str = read_file(files[0]); + std::string file2_str = read_file(files[1]); + + if (file1_str.empty()) + { + throw git_exception("Cannot read file: " + files[0], git2cpp_error_code::GENERIC_ERROR); //TODO: check error code with git + } + if (file2_str.empty()) + { + throw git_exception("Cannot read file: " + files[1], git2cpp_error_code::GENERIC_ERROR); //TODO: check error code with git + } + + auto patch = patch_wrapper::patch_from_files(files[0], file1_str, files[1], file2_str, &diffopts); + auto buf = patch.to_buf(); + auto diff = diff_wrapper::diff_from_buffer(buf); + + git_buf_dispose(&buf); + + return diff; +} + +void diff_subcommand::run() +{ + git_diff_options diffopts; + git_diff_options_init(&diffopts, GIT_DIFF_OPTIONS_VERSION); + + bool use_colour = false; + if (m_no_colour_flag) + { + if (m_colour_flag) + { + throw git_exception("Only one of --color and --no-color should be provided.", git2cpp_error_code::BAD_ARGUMENT); + } + else + { + use_colour = false; + } + } + else if (m_colour_flag) + { + use_colour = true; + } + + if (m_no_index_flag) + { + auto diff = compute_diff_no_index(m_files, diffopts); + diff_subcommand::print_diff(diff, use_colour); + } + else + { + auto directory = get_current_git_path(); + auto repo = repository_wrapper::open(directory); + + diffopts.context_lines = m_context_lines; + diffopts.interhunk_lines = m_interhunk_lines; + diffopts.id_abbrev = m_abbrev; + + if (m_reverse_flag) { diffopts.flags |= GIT_DIFF_REVERSE; } + if (m_text_flag) { diffopts.flags |= GIT_DIFF_FORCE_TEXT; } + if (m_ignore_space_at_eol_flag) { diffopts.flags |= GIT_DIFF_IGNORE_WHITESPACE_EOL; } + if (m_ignore_space_change_flag) { diffopts.flags |= GIT_DIFF_IGNORE_WHITESPACE_CHANGE; } + if (m_ignore_all_space_flag) { diffopts.flags |= GIT_DIFF_IGNORE_WHITESPACE; } + if (m_untracked_flag) { diffopts.flags |= GIT_DIFF_INCLUDE_UNTRACKED; } + if (m_patience_flag) { diffopts.flags |= GIT_DIFF_PATIENCE; } + if (m_minimal_flag) { diffopts.flags |= GIT_DIFF_MINIMAL; } + + std::optional tree1; + std::optional tree2; + + // TODO: throw error if m_files.size() > 2 + if (m_files.size() >= 1) + { + tree1 = repo.treeish_to_tree(m_files[0]); + } + if (m_files.size() ==2) + { + tree2 = repo.treeish_to_tree(m_files[1]); + } + + auto diff = [&repo, &tree1, &tree2, &diffopts, this]() + { + if (tree1.has_value() && tree2.has_value()) + { + return repo.diff_tree_to_tree(std::move(tree1.value()), std::move(tree2.value()), &diffopts); + } + else if (m_cached_flag) + { + if (m_cached_flag || !tree1) + { + tree1 = repo.treeish_to_tree("HEAD"); + } + return repo.diff_tree_to_index(std::move(tree1.value()), std::nullopt, &diffopts); + } + else if (tree1) + { + return repo.diff_tree_to_workdir_with_index(std::move(tree1.value()), &diffopts); + } + else + { + return repo.diff_index_to_workdir(std::nullopt, &diffopts); + } + }(); + + diff_subcommand::print_diff(diff, use_colour); + } +} diff --git a/src/subcommand/diff_subcommand.hpp b/src/subcommand/diff_subcommand.hpp new file mode 100644 index 0000000..966e77c --- /dev/null +++ b/src/subcommand/diff_subcommand.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include +#include + +#include "../utils/common.hpp" +#include "../wrapper/diff_wrapper.hpp" + +class diff_subcommand +{ +public: + + explicit diff_subcommand(const libgit2_object&, CLI::App& app); + void print_stats(const diff_wrapper& diff, bool use_colour); + void print_diff(diff_wrapper& diff, bool use_colour); + void run(); + +private: + + std::vector m_files; + + bool m_stat_flag = false; + bool m_shortstat_flag = false; + bool m_numstat_flag = false; + bool m_summary_flag = false; + bool m_name_only_flag = false; + bool m_name_status_flag = false; + bool m_raw_flag = false; + + bool m_cached_flag = false; + bool m_no_index_flag = false; + + bool m_reverse_flag = false; + bool m_text_flag = false; + bool m_ignore_space_at_eol_flag = false; + bool m_ignore_space_change_flag = false; + bool m_ignore_all_space_flag = false; + bool m_untracked_flag = false; + bool m_patience_flag = false; + bool m_minimal_flag = false; + + // int m_rename_threshold = 50; + // bool m_find_renames_flag = false; + // int m_copy_threshold = 50; + // bool m_find_copies_flag = false; + // bool m_find_copies_harder_flag = false; + // bool m_break_rewrites_flag = false; + + int m_context_lines = 3; + int m_interhunk_lines = 0; + int m_abbrev = 7; + + bool m_colour_flag = true; + bool m_no_colour_flag = false; +}; diff --git a/src/utils/common.cpp b/src/utils/common.cpp index a9b84d4..1df06e6 100644 --- a/src/utils/common.cpp +++ b/src/utils/common.cpp @@ -1,11 +1,12 @@ #include #include +#include +#include #include #include -#include - #include "common.hpp" +#include "git_exception.hpp" libgit2_object::libgit2_object() { @@ -101,3 +102,15 @@ void git_strarray_wrapper::init_str_array() m_array.strings[i] = const_cast(m_patterns[i].c_str()); } } + +std::string read_file(const std::string& path) +{ + std::ifstream file(path, std::ios::binary); + if (!file) + { + throw git_exception("error: Could not access " + path, git2cpp_error_code::GENERIC_ERROR); + } + std::stringstream buffer; + buffer << file.rdbuf(); + return buffer.str(); +} diff --git a/src/utils/common.hpp b/src/utils/common.hpp index 6751b46..be9f360 100644 --- a/src/utils/common.hpp +++ b/src/utils/common.hpp @@ -64,3 +64,5 @@ class git_strarray_wrapper void reset_str_array(); void init_str_array(); }; + +std::string read_file(const std::string& path); diff --git a/src/utils/git_exception.cpp b/src/utils/git_exception.cpp index 18bca2c..8426d45 100644 --- a/src/utils/git_exception.cpp +++ b/src/utils/git_exception.cpp @@ -10,11 +10,14 @@ void throw_if_error(int exit_code) } } - git_exception::git_exception(const std::string_view message, int error_code) : m_message(message), m_error_code(error_code) {} +git_exception::git_exception(const std::string_view message, git2cpp_error_code error_code) + : git_exception(message, static_cast(error_code)) +{} + int git_exception::error_code() const { return m_error_code; diff --git a/src/utils/git_exception.hpp b/src/utils/git_exception.hpp index e9d67ec..1efb76a 100644 --- a/src/utils/git_exception.hpp +++ b/src/utils/git_exception.hpp @@ -3,6 +3,12 @@ #include #include +enum class git2cpp_error_code +{ + GENERIC_ERROR = -1, + BAD_ARGUMENT = 129, +}; + void throw_if_error(int exit_code); class git_exception : public std::exception @@ -10,6 +16,7 @@ class git_exception : public std::exception public: git_exception(const std::string_view message, int error_code); + git_exception(const std::string_view message, git2cpp_error_code error_code); int error_code() const; diff --git a/src/wrapper/diff_wrapper.cpp b/src/wrapper/diff_wrapper.cpp new file mode 100644 index 0000000..37e4cd7 --- /dev/null +++ b/src/wrapper/diff_wrapper.cpp @@ -0,0 +1,37 @@ +#include "../utils/git_exception.hpp" +#include "../wrapper/diff_wrapper.hpp" + +diff_wrapper::diff_wrapper(git_diff* diff) + : base_type(diff) +{ +} + +diff_wrapper::~diff_wrapper() +{ + git_diff_free(p_resource); + p_resource = nullptr; +} + +void diff_wrapper::find_similar(git_diff_find_options* find_opts) +{ + throw_if_error(git_diff_find_similar(p_resource, find_opts)); +} + +void diff_wrapper::print(git_diff_format_t format, git_diff_line_cb print_cb, void* payload) +{ + throw_if_error(git_diff_print(p_resource, format, print_cb, payload)); +} + +diffstats_wrapper diff_wrapper::get_stats() const +{ + git_diff_stats* stats; + throw_if_error(git_diff_get_stats(&stats, *this)); + return diffstats_wrapper(stats); +} + +diff_wrapper diff_wrapper::diff_from_buffer(git_buf buf) +{ + git_diff* diff; + throw_if_error(git_diff_from_buffer(&diff, buf.ptr, buf.size)); + return diff_wrapper(diff); +} diff --git a/src/wrapper/diff_wrapper.hpp b/src/wrapper/diff_wrapper.hpp new file mode 100644 index 0000000..b82b497 --- /dev/null +++ b/src/wrapper/diff_wrapper.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include "../wrapper/wrapper_base.hpp" +#include "../wrapper/diffstats_wrapper.hpp" + +class diff_wrapper : public wrapper_base +{ +public: + + using base_type = wrapper_base; + + ~diff_wrapper(); + + diff_wrapper(diff_wrapper&&) noexcept = default; + diff_wrapper& operator=(diff_wrapper&&) noexcept = default; + + void find_similar(git_diff_find_options* find_opts); + void print(git_diff_format_t format, git_diff_line_cb print_cb, void* payload); + diffstats_wrapper get_stats() const; + static diff_wrapper diff_from_buffer(git_buf buf); + + +private: + + diff_wrapper(git_diff* diff); + + friend class buf_wrapper; + friend class repository_wrapper; +}; diff --git a/src/wrapper/diffstats_wrapper.cpp b/src/wrapper/diffstats_wrapper.cpp new file mode 100644 index 0000000..6dea273 --- /dev/null +++ b/src/wrapper/diffstats_wrapper.cpp @@ -0,0 +1,20 @@ +#include "../utils/git_exception.hpp" +#include "../wrapper/diffstats_wrapper.hpp" + +diffstats_wrapper::diffstats_wrapper(git_diff_stats* stats) + : base_type(stats) +{ +} + +diffstats_wrapper::~diffstats_wrapper() +{ + git_diff_stats_free(p_resource); + p_resource = nullptr; +} + +git_buf diffstats_wrapper::to_buf(git_diff_stats_format_t format, size_t width) +{ + git_buf buf = GIT_BUF_INIT; + throw_if_error(git_diff_stats_to_buf(&buf, *this, format, 80)); + return buf; +} diff --git a/src/wrapper/diffstats_wrapper.hpp b/src/wrapper/diffstats_wrapper.hpp new file mode 100644 index 0000000..e4152e8 --- /dev/null +++ b/src/wrapper/diffstats_wrapper.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include + +#include "../wrapper/wrapper_base.hpp" + +class diffstats_wrapper : public wrapper_base +{ +public: + + using base_type = wrapper_base; + + ~diffstats_wrapper(); + + diffstats_wrapper(diffstats_wrapper&&) noexcept = default; + diffstats_wrapper& operator=(diffstats_wrapper&&) noexcept = default; + + git_buf to_buf(git_diff_stats_format_t format, size_t width); + +private: + + diffstats_wrapper(git_diff_stats* stats); + + friend class diff_wrapper; +}; diff --git a/src/wrapper/patch_wrapper.cpp b/src/wrapper/patch_wrapper.cpp new file mode 100644 index 0000000..d75a450 --- /dev/null +++ b/src/wrapper/patch_wrapper.cpp @@ -0,0 +1,27 @@ +#include "../utils/git_exception.hpp" +#include "../wrapper/patch_wrapper.hpp" + +patch_wrapper::patch_wrapper(git_patch* patch) + : base_type(patch) +{ +} + +patch_wrapper::~patch_wrapper() +{ + git_patch_free(p_resource); + p_resource = nullptr; +} + +git_buf patch_wrapper::to_buf() +{ + git_buf buf = GIT_BUF_INIT; + throw_if_error(git_patch_to_buf(&buf, *this)); + return buf; +} + +patch_wrapper patch_wrapper::patch_from_files(const std::string& path1, const std::string& file1_str, const std::string& path2, const std::string& file2_str, git_diff_options* diffopts) +{ + git_patch* patch; + throw_if_error(git_patch_from_buffers(&patch, file1_str.c_str(), file1_str.length(), path1.c_str(), file2_str.c_str(), file2_str.length(), path2.c_str(), diffopts)); + return patch_wrapper(patch); +} diff --git a/src/wrapper/patch_wrapper.hpp b/src/wrapper/patch_wrapper.hpp new file mode 100644 index 0000000..4f15dc4 --- /dev/null +++ b/src/wrapper/patch_wrapper.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +#include "../wrapper/wrapper_base.hpp" + +class patch_wrapper : public wrapper_base +{ +public: + + using base_type = wrapper_base; + + ~patch_wrapper(); + + patch_wrapper(patch_wrapper&&) noexcept = default; + patch_wrapper& operator=(patch_wrapper&&) noexcept = default; + + git_buf to_buf(); + static patch_wrapper patch_from_files(const std::string& path1, const std::string& file1_str, const std::string& path2, const std::string& file2_str, git_diff_options* diffopts); + +private: + + patch_wrapper(git_patch* patch); +}; diff --git a/src/wrapper/repository_wrapper.cpp b/src/wrapper/repository_wrapper.cpp index 418920e..31b7177 100644 --- a/src/wrapper/repository_wrapper.cpp +++ b/src/wrapper/repository_wrapper.cpp @@ -9,6 +9,7 @@ #include "../wrapper/remote_wrapper.hpp" #include "../wrapper/repository_wrapper.hpp" #include "config_wrapper.hpp" +#include "diff_wrapper.hpp" repository_wrapper::~repository_wrapper() { @@ -208,17 +209,14 @@ void repository_wrapper::create_commit(const signature_wrapper::author_committer } }(); - git_tree* tree; index_wrapper index = this->make_index(); git_oid tree_id = index.write_tree(); index.write(); - throw_if_error(git_tree_lookup(&tree, *this, &tree_id)); + auto tree = this->tree_lookup(&tree_id); throw_if_error(git_commit_create(&commit_id, *this, update_ref.c_str(), author_committer_signatures.first, author_committer_signatures.second, message_encoding, message.data(), tree, parents_count, parents)); - - git_tree_free(tree); } std::optional repository_wrapper::resolve_local_ref @@ -364,6 +362,21 @@ void repository_wrapper::checkout_tree(const object_wrapper& target, const git_c throw_if_error(git_checkout_tree(*this, target, &opts)); } +tree_wrapper repository_wrapper::tree_lookup(const git_oid* tree_id) +{ + git_tree* tree; + throw_if_error(git_tree_lookup(&tree, *this, tree_id)); + return tree_wrapper(tree); +} + +tree_wrapper repository_wrapper::treeish_to_tree(const std::string& treeish) +{ + auto obj = this->revparse_single(treeish.c_str()); + git_tree* tree = nullptr; + throw_if_error(git_object_peel(reinterpret_cast(&tree), obj.value(), GIT_OBJECT_TREE)); + return tree_wrapper(tree); +} + // Remotes remote_wrapper repository_wrapper::find_remote(std::string_view name) const @@ -430,9 +443,58 @@ std::vector repository_wrapper::list_remotes() const // Config + config_wrapper repository_wrapper::get_config() { git_config* cfg; throw_if_error(git_repository_config(&cfg, *this)); return config_wrapper(cfg); } + + +// Diff + +diff_wrapper repository_wrapper::diff_tree_to_index(tree_wrapper old_tree, std::optional index, git_diff_options* diffopts) +{ + git_diff* diff; + git_index* idx = nullptr; + if (index) + { + idx = *index; + } + throw_if_error(git_diff_tree_to_index(&diff, *this, old_tree, idx, diffopts)); + return diff_wrapper(diff); +} + +diff_wrapper repository_wrapper::diff_tree_to_tree(tree_wrapper old_tree, tree_wrapper new_tree, git_diff_options* diffopts) +{ + git_diff* diff; + throw_if_error(git_diff_tree_to_tree(&diff, *this, old_tree, new_tree, diffopts)); + return diff_wrapper(diff); +} + +diff_wrapper repository_wrapper::diff_tree_to_workdir(tree_wrapper old_tree, git_diff_options* diffopts) +{ + git_diff* diff; + throw_if_error(git_diff_tree_to_workdir(&diff, *this, old_tree, diffopts)); + return diff_wrapper(diff); +} + +diff_wrapper repository_wrapper::diff_tree_to_workdir_with_index(tree_wrapper old_tree, git_diff_options* diffopts) +{ + git_diff* diff; + throw_if_error(git_diff_tree_to_workdir_with_index(&diff, *this, old_tree, diffopts)); + return diff_wrapper(diff); +} + +diff_wrapper repository_wrapper::diff_index_to_workdir(std::optional index, git_diff_options* diffopts) +{ + git_diff* diff; + git_index* idx = nullptr; + if (index) + { + idx = *index; + } + throw_if_error(git_diff_index_to_workdir(&diff, *this, idx, diffopts)); + return diff_wrapper(diff); +} diff --git a/src/wrapper/repository_wrapper.hpp b/src/wrapper/repository_wrapper.hpp index a3be98d..b3e8dbc 100644 --- a/src/wrapper/repository_wrapper.hpp +++ b/src/wrapper/repository_wrapper.hpp @@ -11,12 +11,14 @@ #include "../wrapper/branch_wrapper.hpp" #include "../wrapper/commit_wrapper.hpp" #include "../wrapper/config_wrapper.hpp" +#include "../wrapper/diff_wrapper.hpp" #include "../wrapper/index_wrapper.hpp" #include "../wrapper/object_wrapper.hpp" #include "../wrapper/refs_wrapper.hpp" #include "../wrapper/remote_wrapper.hpp" #include "../wrapper/revwalk_wrapper.hpp" #include "../wrapper/signature_wrapper.hpp" +#include "../wrapper/tree_wrapper.hpp" #include "../wrapper/wrapper_base.hpp" class repository_wrapper : public wrapper_base @@ -88,6 +90,8 @@ class repository_wrapper : public wrapper_base // Trees void checkout_tree(const object_wrapper& target, const git_checkout_options opts); + tree_wrapper tree_lookup(const git_oid* tree_id); + tree_wrapper treeish_to_tree(const std::string& treeish); // Remotes remote_wrapper find_remote(std::string_view name) const; @@ -100,6 +104,13 @@ class repository_wrapper : public wrapper_base // Config config_wrapper get_config(); + // Diff + diff_wrapper diff_tree_to_index(tree_wrapper old_tree, std::optional index, git_diff_options* diffopts); + diff_wrapper diff_tree_to_tree(tree_wrapper old_tree, tree_wrapper new_tree, git_diff_options* diffopts); + diff_wrapper diff_tree_to_workdir(tree_wrapper old_tree, git_diff_options* diffopts); + diff_wrapper diff_tree_to_workdir_with_index(tree_wrapper old_tree, git_diff_options* diffopts); + diff_wrapper diff_index_to_workdir(std::optional index, git_diff_options* diffopts); + private: repository_wrapper() = default; diff --git a/src/wrapper/tree_wrapper.cpp b/src/wrapper/tree_wrapper.cpp new file mode 100644 index 0000000..3f696ff --- /dev/null +++ b/src/wrapper/tree_wrapper.cpp @@ -0,0 +1,12 @@ +#include "../wrapper/tree_wrapper.hpp" + +tree_wrapper::tree_wrapper(git_tree* tree) + : base_type(tree) +{ +} + +tree_wrapper::~tree_wrapper() +{ + git_tree_free(p_resource); + p_resource = nullptr; +} diff --git a/src/wrapper/tree_wrapper.hpp b/src/wrapper/tree_wrapper.hpp new file mode 100644 index 0000000..06d11c4 --- /dev/null +++ b/src/wrapper/tree_wrapper.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include "../wrapper/wrapper_base.hpp" + +class tree_wrapper : public wrapper_base +{ +public: + + using base_type = wrapper_base; + + ~tree_wrapper(); + + tree_wrapper(tree_wrapper&&) noexcept = default; + tree_wrapper& operator=(tree_wrapper&&) noexcept = default; + +private: + + tree_wrapper(git_tree* tree); + + friend class repository_wrapper; +}; diff --git a/test/test_diff.py b/test/test_diff.py new file mode 100644 index 0000000..6dd5f1a --- /dev/null +++ b/test/test_diff.py @@ -0,0 +1,676 @@ +import re +import subprocess + +import pytest + + +def test_diff_nogit(git2cpp_path, tmp_path): + cmd = [git2cpp_path, "diff"] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode != 0 + assert "repository" in p.stderr.lower() or "not a git" in p.stderr.lower() + + +def test_diff_working_directory(xtl_clone, git2cpp_path, tmp_path): + xtl_path = tmp_path / "xtl" + + readme = xtl_path / "README.md" + original_content = readme.read_text() + readme.write_text(original_content + "\nNew line added") + + cmd = [git2cpp_path, "diff"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "README.md" in p.stdout + assert "New line added" in p.stdout # should be "+New line added" + + +@pytest.mark.parametrize("cached_flag", ["--cached", "--staged"]) +def test_diff_cached(xtl_clone, git2cpp_path, tmp_path, cached_flag): + xtl_path = tmp_path / "xtl" + + new_file = xtl_path / "new_file.txt" + new_file.write_text("Hello, world!") + + cmd_add = [git2cpp_path, "add", "new_file.txt"] + subprocess.run(cmd_add, cwd=xtl_path, check=True) + + cmd_diff = [git2cpp_path, "diff", cached_flag] + p_diff = subprocess.run(cmd_diff, capture_output=True, cwd=xtl_path, text=True) + assert p_diff.returncode == 0 + assert "new_file.txt" in p_diff.stdout + assert "+Hello, world!" in p_diff.stdout + + +def test_diff_two_commits(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + xtl_path = tmp_path / "xtl" + + new_file = xtl_path / "new_file.txt" + new_file.write_text("Hello, world!") + + cmd_add = [git2cpp_path, "add", "new_file.txt"] + subprocess.run(cmd_add, cwd=xtl_path, check=True) + + cmd_commit = [git2cpp_path, "commit", "-m", "new commit"] + subprocess.run(cmd_commit, cwd=xtl_path, check=True) + + cmd_diff = [git2cpp_path, "diff", "HEAD~1", "HEAD"] + p_diff = subprocess.run(cmd_diff, capture_output=True, cwd=xtl_path, text=True) + assert p_diff.returncode == 0 + assert "new_file.txt" in p_diff.stdout + assert "+Hello, world!" in p_diff.stdout + + +def test_diff_no_index(git2cpp_path, tmp_path): + file1 = tmp_path / "file1.txt" + file2 = tmp_path / "file2.txt" + + file1.write_text("Hello\nWorld\n") + file2.write_text("Hello\nPython\n") + + cmd = [git2cpp_path, "diff", "--no-index", str(file1), str(file2)] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode == 0 + assert "-World" in p.stdout + assert "+Python" in p.stdout + + +def test_diff_stat(xtl_clone, git2cpp_path, tmp_path): + xtl_path = tmp_path / "xtl" + + readme = xtl_path / "README.md" + readme.write_text("Modified content\n") + + cmd = [git2cpp_path, "diff", "--stat"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "README.md" in p.stdout + assert "1 file changed, 1 insertion(+)" in p.stdout + assert "Modified content" not in p.stdout + + +def test_diff_shortstat(xtl_clone, git2cpp_path, tmp_path): + """Test diff with --shortstat (last line of --stat only)""" + xtl_path = tmp_path / "xtl" + + readme = xtl_path / "README.md" + readme.write_text("Modified content\n") + + cmd = [git2cpp_path, "diff", "--shortstat"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "README.md" not in p.stdout + assert "1 file changed, 1 insertion(+)" in p.stdout + assert "Modified content" not in p.stdout + + +def test_diff_numstat(xtl_clone, git2cpp_path, tmp_path): + """Test diff with --numstat (machine-friendly stat)""" + xtl_path = tmp_path / "xtl" + + readme = xtl_path / "README.md" + readme.write_text("Modified content\n") + + cmd = [git2cpp_path, "diff", "--numstat"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "README.md" in p.stdout + assert bool(re.search("1 [0-9]*", p.stdout)) + assert "Modified content" not in p.stdout + + +def test_diff_summary(xtl_clone, git2cpp_path, tmp_path): + """Test diff with --summary""" + xtl_path = tmp_path / "xtl" + + new_file = xtl_path / "newfile.txt" + new_file.write_text("New content") + + cmd_add = [git2cpp_path, "add", "newfile.txt"] + subprocess.run(cmd_add, cwd=xtl_path, check=True) + + cmd = [git2cpp_path, "diff", "--cached", "--summary"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "newfile.txt" in p.stdout + assert "+" not in p.stdout + + +def test_diff_name_only(xtl_clone, git2cpp_path, tmp_path): + xtl_path = tmp_path / "xtl" + + (xtl_path / "README.md").write_text("Modified") + + cmd = [git2cpp_path, "diff", "--name-only"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + + assert p.returncode == 0 + assert p.stdout == "README.md\n" + assert "+" not in p.stdout + + +def test_diff_name_status(xtl_clone, git2cpp_path, tmp_path): + xtl_path = tmp_path / "xtl" + + (xtl_path / "README.md").write_text("Modified") + + cmd = [git2cpp_path, "diff", "--name-status"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert p.stdout == "M\tREADME.md\n" + + +def test_diff_raw(xtl_clone, git2cpp_path, tmp_path): + """Test diff with --raw format""" + xtl_path = tmp_path / "xtl" + + readme = xtl_path / "README.md" + readme.write_text("Modified") + + cmd = [git2cpp_path, "diff", "--raw"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "M\tREADME.md" in p.stdout + assert bool(re.search(":[0-9]*", p.stdout)) + + +def test_diff_reverse(xtl_clone, git2cpp_path, tmp_path): + xtl_path = tmp_path / "xtl" + + readme = xtl_path / "README.md" + original = readme.read_text() + readme.write_text(original + "\nAdded line") + + cmd_normal = [git2cpp_path, "diff"] + p_normal = subprocess.run(cmd_normal, capture_output=True, cwd=xtl_path, text=True) + assert p_normal.returncode == 0 + assert "+Added line" in p_normal.stdout + + cmd_reverse = [git2cpp_path, "diff", "-R"] + p_reverse = subprocess.run( + cmd_reverse, capture_output=True, cwd=xtl_path, text=True + ) + assert p_reverse.returncode == 0 + assert "-Added line" in p_reverse.stdout + + +@pytest.mark.parametrize("text_flag", ["-a", "--text"]) +def test_diff_text(xtl_clone, commit_env_config, git2cpp_path, tmp_path, text_flag): + """Test diff with -a/--text (treat all files as text)""" + xtl_path = tmp_path / "xtl" + + binary_file = xtl_path / "binary.bin" + binary_file.write_bytes(b"\x00\x01\x02\x03") + + cmd_add = [git2cpp_path, "add", "binary.bin"] + subprocess.run(cmd_add, cwd=xtl_path, check=True) + + cmd_commit = [git2cpp_path, "commit", "-m", "add binary"] + subprocess.run(cmd_commit, cwd=xtl_path, check=True) + + binary_file.write_bytes(b"\x00\x01\x02\x04") + + cmd_text = [git2cpp_path, "diff", text_flag] + p = subprocess.run(cmd_text, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "binary.bin" in p.stdout + assert "@@" in p.stdout + + +def test_diff_ignore_space_at_eol(xtl_clone, git2cpp_path, tmp_path): + """Test diff with --ignore-space-at-eol""" + xtl_path = tmp_path / "xtl" + + readme = xtl_path / "README.md" + original = readme.read_text() + # Add trailing spaces at end of line + readme.write_text(original.rstrip() + " \n") + + cmd = [git2cpp_path, "diff", "--ignore-space-at-eol"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert p.stdout == "" + + +@pytest.mark.parametrize("space_change_flag", ["-b", "--ignore-space-change"]) +def test_diff_ignore_space_change( + xtl_clone, commit_env_config, git2cpp_path, tmp_path, space_change_flag +): + """Test diff with -b/--ignore-space-change""" + xtl_path = tmp_path / "xtl" + + test_file = xtl_path / "test.txt" + test_file.write_text("Hello world\n") + + cmd_add = [git2cpp_path, "add", "test.txt"] + subprocess.run(cmd_add, cwd=xtl_path, check=True) + + cmd_commit = [git2cpp_path, "commit", "-m", "test"] + subprocess.run(cmd_commit, cwd=xtl_path, check=True) + + # Change spacing + test_file.write_text("Hello world\n") + + cmd_diff = [git2cpp_path, "diff", space_change_flag] + p = subprocess.run(cmd_diff, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert p.stdout == "" + + +@pytest.mark.parametrize("ignore_space_flag", ["-w", "--ignore-all-space"]) +def test_diff_ignore_all_space( + xtl_clone, commit_env_config, git2cpp_path, tmp_path, ignore_space_flag +): + """Test diff with -w/--ignore-all-space""" + xtl_path = tmp_path / "xtl" + + test_file = xtl_path / "test.txt" + test_file.write_text("Hello world\n") + + cmd_add = [git2cpp_path, "add", "test.txt"] + subprocess.run(cmd_add, cwd=xtl_path, check=True) + + cmd_commit = [git2cpp_path, "commit", "-m", "test"] + subprocess.run(cmd_commit, cwd=xtl_path, check=True) + + test_file.write_text("Helloworld") + + cmd_diff = [git2cpp_path, "diff", ignore_space_flag] + p = subprocess.run(cmd_diff, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert p.stdout == "" + + +@pytest.mark.parametrize( + "unified_context_flag,context_lines", + [("-U0", 0), ("-U1", 1), ("-U5", 5), ("--unified=3", 3)], +) +def test_diff_unified_context( + xtl_clone, + commit_env_config, + git2cpp_path, + tmp_path, + unified_context_flag, + context_lines, +): + """Test diff with -U/--unified for context lines""" + xtl_path = tmp_path / "xtl" + + test_file = xtl_path / "test.txt" + # Create a file with enough lines to see context differences + test_file.write_text( + "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10\n" + ) + + cmd_add = [git2cpp_path, "add", "test.txt"] + subprocess.run(cmd_add, cwd=xtl_path, check=True) + + cmd_commit = [git2cpp_path, "commit", "-m", "test"] + subprocess.run(cmd_commit, cwd=xtl_path, check=True) + + # Modify line 5 (middle of the file) + test_file.write_text( + "Line 1\nLine 2\nLine 3\nLine 4\nMODIFIED LINE 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10\n" + ) + + # Run diff with the parameterized flag + cmd = [git2cpp_path, "diff", unified_context_flag] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "test.txt" in p.stdout + assert "MODIFIED LINE 5" in p.stdout + assert "@@" in p.stdout # Hunk header should always be present + + # Verify context lines based on context_lines parameter + + if context_lines >= 1: + # Should show immediate neighbors + assert "Line 4" in p.stdout + assert "Line 6" in p.stdout + + if context_lines >= 3: + # Should show 3 lines before and after + assert "Line 2" in p.stdout + assert "Line 3" in p.stdout + assert "Line 7" in p.stdout + assert "Line 8" in p.stdout + + if context_lines >= 5: + # Should show 5 lines before and after (reaching file boundaries) + assert "Line 1" in p.stdout + assert "Line 9" in p.stdout + assert "Line 10" in p.stdout + + if context_lines == 0: + # With U0, context lines should not appear (except in headers) + # Filter out header lines + output_lines = [ + line + for line in p.stdout.split("\n") + if not line.startswith("@@") + and not line.startswith("---") + and not line.startswith("+++") + and not line.startswith("diff ") + and not line.startswith("index ") + ] + output_content = "\n".join(output_lines) + + # Should have the deletion and addition, but minimal other content + assert "-Line 5" in output_content + assert "+MODIFIED LINE 5" in output_content + + # Verify that lines too far from the change don't appear with small context + if context_lines == 1: + assert "Line 2" not in p.stdout or p.stdout.count("Line 2") == 0 + assert "Line 8" not in p.stdout or p.stdout.count("Line 8") == 0 + + +def test_diff_inter_hunk_context(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test diff with --inter-hunk-context""" + xtl_path = tmp_path / "xtl" + + test_file = xtl_path / "test.txt" + lines = [f"Line {i}\n" for i in range(1, 31)] + test_file.write_text("".join(lines)) + + cmd_add = [git2cpp_path, "add", "test.txt"] + subprocess.run(cmd_add, cwd=xtl_path, check=True) + + cmd_commit = [git2cpp_path, "commit", "-m", "test"] + subprocess.run(cmd_commit, cwd=xtl_path, check=True) + + # Modify two separate sections + lines[4] = "Modified Line 5\n" + lines[19] = "Modified Line 20\n" + test_file.write_text("".join(lines)) + + # Test with small inter-hunk-context (should keep hunks separate) + cmd_small = [git2cpp_path, "diff", "--inter-hunk-context=1"] + p_small = subprocess.run(cmd_small, capture_output=True, cwd=xtl_path, text=True) + assert p_small.returncode == 0 + assert "Modified Line 5" in p_small.stdout + assert "Modified Line 20" in p_small.stdout + assert "@@" in p_small.stdout + + # Count hunks in small context output + hunk_count_small = len( + [ + l + for l in p_small.stdout.split("\n") + if l.startswith("@@") and l.endswith("@@") + ] + ) + + # Test with large inter-hunk-context (should merge hunks into one) + cmd_large = [git2cpp_path, "diff", "--inter-hunk-context=15"] + p_large = subprocess.run(cmd_large, capture_output=True, cwd=xtl_path, text=True) + assert p_large.returncode == 0 + assert "Modified Line 5" in p_large.stdout + assert "Modified Line 20" in p_large.stdout + assert "@@" in p_large.stdout + + # Count hunks in large context output + hunk_count_large = len( + [ + l + for l in p_large.stdout.split("\n") + if l.startswith("@@") and l.endswith("@@") + ] + ) + + # Verify both modifications appear in both outputs + assert "Modified Line 5" in p_small.stdout and "Modified Line 5" in p_large.stdout + assert "Modified Line 20" in p_small.stdout and "Modified Line 20" in p_large.stdout + + # Large inter-hunk-context should produce fewer or equal hunks (merging effect) + assert hunk_count_large <= hunk_count_small, ( + f"Expected large context ({hunk_count_large} hunks) to have <= hunks than small context ({hunk_count_small} hunks)" + ) + + +def test_diff_abbrev(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test diff with --abbrev for object name abbreviation""" + xtl_path = tmp_path / "xtl" + + test_file = xtl_path / "test.txt" + test_file.write_text("Original content\n") + + cmd_add = [git2cpp_path, "add", "test.txt"] + subprocess.run(cmd_add, cwd=xtl_path, check=True) + + cmd_commit = [git2cpp_path, "commit", "-m", "initial commit"] + subprocess.run(cmd_commit, cwd=xtl_path, check=True) + + # Modify the file + test_file.write_text("Modified content\n") + + # Test default --abbrev + cmd_default = [git2cpp_path, "diff", "--abbrev"] + p_default = subprocess.run( + cmd_default, capture_output=True, cwd=xtl_path, text=True + ) + assert p_default.returncode == 0 + assert "test.txt" in p_default.stdout + + # Test --abbrev=7 (short hash) + cmd_7 = [git2cpp_path, "diff", "--abbrev=7"] + p_7 = subprocess.run(cmd_7, capture_output=True, cwd=xtl_path, text=True) + assert p_7.returncode == 0 + assert "test.txt" in p_7.stdout + + # Test --abbrev=12 (longer hash) + cmd_12 = [git2cpp_path, "diff", "--abbrev=12"] + p_12 = subprocess.run(cmd_12, capture_output=True, cwd=xtl_path, text=True) + assert p_12.returncode == 0 + assert "test.txt" in p_12.stdout + + # Extract hash lengths from index lines to verify abbrev is working + hash_pattern = r"index ([0-9a-f]+)\.\.([0-9a-f]+)" + match_7 = re.search(hash_pattern, p_7.stdout) + match_12 = re.search(hash_pattern, p_12.stdout) + + if match_7 and match_12: + hash_len_7 = len(match_7.group(1)) + hash_len_12 = len(match_12.group(1)) + + # Verify that abbrev=12 produces longer or equal hash than abbrev=7 + assert hash_len_12 >= hash_len_7, ( + f"Expected abbrev=12 ({hash_len_12}) to be >= abbrev=7 ({hash_len_7})" + ) + + +# Note: only checking if the output is a diff +def test_diff_patience(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test diff with --patience algorithm""" + xtl_path = tmp_path / "xtl" + + test_file = xtl_path / "test.txt" + test_file.write_text("Line 1\nLine 2\nLine 3\n") + + cmd_add = [git2cpp_path, "add", "test.txt"] + subprocess.run(cmd_add, cwd=xtl_path, check=True) + + cmd_commit = [git2cpp_path, "commit", "-m", "test"] + subprocess.run(cmd_commit, cwd=xtl_path, check=True) + + test_file.write_text("Line 1\nNew Line\nLine 2\nLine 3\n") + + cmd = [git2cpp_path, "diff"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "test.txt" in p.stdout + assert "+New Line" in p.stdout + + +# Note: only checking if the output is a diff +def test_diff_minimal(xtl_clone, git2cpp_path, tmp_path): + """Test diff with --minimal (spend extra time to find smallest diff)""" + xtl_path = tmp_path / "xtl" + + readme = xtl_path / "README.md" + original = readme.read_text() + readme.write_text(original + "\nExtra line\n") + + cmd = [git2cpp_path, "diff", "--minimal"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "README.md" in p.stdout + assert "+Extra line" in p.stdout + + +# TODO: Find a way to check the colour +# @pytest.mark.parametrize("colour_flag", ["--color", "--no-color"]) +# def test_diff_colour(xtl_clone, git2cpp_path, tmp_path, colour_flag): +# xtl_path = tmp_path / "xtl" + +# (xtl_path / "README.md").write_text("Modified") + +# cmd = [git2cpp_path, "diff", colour_flag] +# p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True, shell=True) +# assert p.returncode == 0 +# # how to check if colour ? + +# ansi_escape = re.compile(r"\x1b\[[0-9;]*m") +# if colour_flag == "--no-color": +# assert not bool(re.search(ansi_escape, p.stdout)) +# else: +# assert bool(re.search(ansi_escape, p.stdout)) + + +# TODO: add the following flags after the "move" subcommand has been implemented (needed for the tests) +# @pytest.mark.parametrize("renames_flag", ["-M", "--find-renames"]) +# def test_diff_find_renames(xtl_clone, git2cpp_path, tmp_path, renames_flag): +# """Test diff with -M/--find-renames""" +# xtl_path = tmp_path / "xtl" + +# old_file = xtl_path / "old_name.txt" +# old_file.write_text("Hello\n") + +# cmd_add = [git2cpp_path, "add", "old_name.txt"] +# subprocess.run(cmd_add, cwd=xtl_path, check=True) + +# cmd_commit = [git2cpp_path, "commit", "-m", "Add file"] +# subprocess.run(cmd_commit, cwd=xtl_path, check=True) + +# new_file = xtl_path / "new_name.txt" +# old_file.rename(new_file) +# old_file.write_text("Goodbye\n") + +# cmd_add_all = [git2cpp_path, "add", "-A"] +# subprocess.run(cmd_add_all, cwd=xtl_path, check=True) + +# cmd = [git2cpp_path, "diff", "--cached", renames_flag] +# p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) +# assert p.returncode == 0 +# # assert "similarity index" in p.stdout +# # assert "rename from" in p.stdout +# assert "+++ b/new_name.txt" in p.stdout +# assert "--- a/old_name.txt" in p.stdout +# print("===\n", p.stdout, "===\n") + + +# def test_diff_find_renames_with_threshold(xtl_clone, git2cpp_path, tmp_path): +# """Test diff with -M with threshold value""" +# xtl_path = tmp_path / "xtl" + +# old_file = xtl_path / "old.txt" +# old_file.write_text("Content\n") + +# cmd_add = [git2cpp_path, "add", "old.txt"] +# subprocess.run(cmd_add, cwd=xtl_path, check=True) + +# cmd_commit = [git2cpp_path, "commit", "-m", "Add file"] +# subprocess.run(cmd_commit, cwd=xtl_path, check=True) + +# new_file = xtl_path / "new.txt" +# old_file.rename(new_file) + +# cmd_add_all = [git2cpp_path, "add", "-A"] +# subprocess.run(cmd_add_all, cwd=xtl_path, check=True) + +# cmd = [git2cpp_path, "diff", "--cached", "-M50"] +# p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) +# assert p.returncode == 0 +# print(p.stdout) # Doesn't do the same as the previous one. Why ??? + + +# @pytest.mark.parametrize("copies_flag", ["-C", "--find-copies"]) +# def test_diff_find_copies(xtl_clone, git2cpp_path, tmp_path, copies_flag): +# """Test diff with -C/--find-copies""" +# xtl_path = tmp_path / "xtl" + +# original_file = xtl_path / "original.txt" +# original_file.write_text("Content to be copied\n") + +# cmd_add = [git2cpp_path, "add", "original.txt"] +# subprocess.run(cmd_add, cwd=xtl_path, check=True) + +# copied_file = xtl_path / "copied.txt" +# copied_file.write_text("Content to be copied\n") + +# cmd_add_copy = [git2cpp_path, "add", "copied.txt"] +# subprocess.run(cmd_add_copy, cwd=xtl_path, check=True) + +# cmd = [git2cpp_path, "diff", "--cached", copies_flag] +# p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) +# assert p.returncode == 0 +# print(p.stdout) + + +# def test_diff_find_copies_with_threshold(xtl_clone, git2cpp_path, tmp_path): +# """Test diff with -C with threshold value""" +# xtl_path = tmp_path / "xtl" + +# original_file = xtl_path / "original.txt" +# original_file.write_text("Content\n") + +# cmd_add = [git2cpp_path, "add", "original.txt"] +# subprocess.run(cmd_add, cwd=xtl_path, check=True) + +# copied_file = xtl_path / "copied.txt" +# copied_file.write_text("Content to be copied\n") + +# cmd_add_copy = [git2cpp_path, "add", "copied.txt"] +# subprocess.run(cmd_add_copy, cwd=xtl_path, check=True) + +# cmd = [git2cpp_path, "diff", "--cached", "-C50"] +# p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) +# assert p.returncode == 0 + + +# def test_diff_find_copies_harder(xtl_clone, git2cpp_path, tmp_path): +# """Test diff with --find-copies-harder""" +# xtl_path = tmp_path / "xtl" + +# test_file = xtl_path / "test.txt" +# test_file.write_text("Content\n") + +# cmd_add = [git2cpp_path, "add", "test.txt"] +# subprocess.run(cmd_add, cwd=xtl_path, check=True) + +# cmd = [git2cpp_path, "diff", "--cached", "--find-copies-harder"] +# p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) +# assert p.returncode == 0 + + +# @pytest.mark.parametrize("break_rewrites_flag", ["-B", "--break-rewrites"]) +# def test_diff_break_rewrites(xtl_clone, git2cpp_path, tmp_path, break_rewrites_flag): +# """Test diff with -B/--break-rewrites""" +# xtl_path = tmp_path / "xtl" + +# test_file = xtl_path / "test.txt" +# test_file.write_text("Original content\n") + +# cmd_add = [git2cpp_path, "add", "test.txt"] +# subprocess.run(cmd_add, cwd=xtl_path, check=True) + +# cmd_commit = [git2cpp_path, "commit", "-m", "test"] +# subprocess.run(cmd_commit, cwd=xtl_path, check=True) + +# # Completely rewrite the file +# test_file.write_text("Completely different content\n") + +# cmd = [git2cpp_path, "diff", break_rewrites_flag] +# p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) +# assert p.returncode == 0 +# print(p.stdout) From 4bd9b8eaf27c251362ebcf4ab073ebd336a9754b Mon Sep 17 00:00:00 2001 From: Johan Mabille Date: Tue, 10 Feb 2026 09:24:56 +0100 Subject: [PATCH 07/35] Implemented git rm (#95) * Implemented git rm * Applied review comments --- CMakeLists.txt | 2 + src/main.cpp | 4 +- src/subcommand/mv_subcommand.cpp | 2 +- src/subcommand/rm_subcommand.cpp | 64 +++++ src/subcommand/rm_subcommand.hpp | 21 ++ src/utils/git_exception.hpp | 1 + src/wrapper/index_wrapper.cpp | 15 ++ src/wrapper/index_wrapper.hpp | 2 + src/wrapper/repository_wrapper.cpp | 7 + src/wrapper/repository_wrapper.hpp | 2 + src/wrapper/status_wrapper.cpp | 13 -- test/conftest_wasm.py | 1 + test/test_rm.py | 361 +++++++++++++++++++++++++++++ 13 files changed, 480 insertions(+), 15 deletions(-) create mode 100644 src/subcommand/rm_subcommand.cpp create mode 100644 src/subcommand/rm_subcommand.hpp create mode 100644 test/test_rm.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 8bf8207..cdcecda 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -76,6 +76,8 @@ set(GIT2CPP_SRC ${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/stash_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/stash_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/status_subcommand.cpp diff --git a/src/main.cpp b/src/main.cpp index 029e694..efb385c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -25,6 +25,7 @@ #include "subcommand/status_subcommand.hpp" #include "subcommand/revparse_subcommand.hpp" #include "subcommand/revlist_subcommand.hpp" +#include "subcommand/rm_subcommand.hpp" int main(int argc, char** argv) { @@ -55,8 +56,9 @@ int main(int argc, char** argv) push_subcommand push(lg2_obj, app); rebase_subcommand rebase(lg2_obj, app); remote_subcommand remote(lg2_obj, app); - revparse_subcommand revparse(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); app.require_subcommand(/* min */ 0, /* max */ 1); diff --git a/src/subcommand/mv_subcommand.cpp b/src/subcommand/mv_subcommand.cpp index adff792..176c874 100644 --- a/src/subcommand/mv_subcommand.cpp +++ b/src/subcommand/mv_subcommand.cpp @@ -27,7 +27,7 @@ void mv_subcommand::run() if (exists && !m_force) { // TODO: replace magic number with enum when diff command is merged - throw git_exception("destination already exists", 128); + throw git_exception("destination already exists", git2cpp_error_code::FILESYSTEM_ERROR); } std::error_code ec; diff --git a/src/subcommand/rm_subcommand.cpp b/src/subcommand/rm_subcommand.cpp new file mode 100644 index 0000000..b9bf1c6 --- /dev/null +++ b/src/subcommand/rm_subcommand.cpp @@ -0,0 +1,64 @@ +#include +#include +#include "rm_subcommand.hpp" +#include "../utils/common.hpp" +#include "../utils/git_exception.hpp" +#include "../wrapper/index_wrapper.hpp" +#include "../wrapper/repository_wrapper.hpp" + +namespace fs = std::filesystem; + +rm_subcommand::rm_subcommand(const libgit2_object&, CLI::App& app) +{ + auto* rm = app.add_subcommand("rm", "Remove files from the working tree and from the index"); + rm->add_option("", m_pathspec, "Files to remove"); + rm->add_flag("-r", m_recursive, "Allow recursive removal when a leading directory name is given"); + + rm->callback([this]() { this->run(); }); +} + +void rm_subcommand::run() +{ + auto directory = get_current_git_path(); + auto repo = repository_wrapper::open(directory); + + index_wrapper index = repo.make_index(); + + std::vector files; + std::vector directories; + + std::ranges::for_each(m_pathspec, [&](const std::string& path) + { + if (!fs::exists(path)) + { + std::string msg = "fatal: pathspec '" + path + "' did not math any file"; + throw git_exception(msg, git2cpp_error_code::FILESYSTEM_ERROR); + } + if (fs::is_directory(path)) + { + directories.push_back(path); + } + else + { + if (!repo.does_track(path)) + { + std::string msg = "fatal: pathsspec '" + path + "'is not tracked"; + throw git_exception(msg, git2cpp_error_code::FILESYSTEM_ERROR); + } + files.push_back(path); + } + }); + + if (!directories.empty() && !m_recursive) + { + std::string msg = "fatal: not removing '" + directories.front() + "' recursively without -r"; + throw git_exception(msg, git2cpp_error_code::FILESYSTEM_ERROR); + } + + index.remove_entries(files); + index.remove_directories(directories); + index.write(); + + std::ranges::for_each(files, [](const std::string& path) { fs::remove(path); }); + std::ranges::for_each(directories, [](const std::string& path) { fs::remove_all(path); }); +} diff --git a/src/subcommand/rm_subcommand.hpp b/src/subcommand/rm_subcommand.hpp new file mode 100644 index 0000000..1f575fc --- /dev/null +++ b/src/subcommand/rm_subcommand.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include +#include +#include + +#include "../utils/common.hpp" + +class rm_subcommand +{ +public: + + explicit rm_subcommand(const libgit2_object&, CLI::App& app); + void run(); + +private: + + std::vector m_pathspec; + bool m_recursive = false; +}; + diff --git a/src/utils/git_exception.hpp b/src/utils/git_exception.hpp index 1efb76a..49b1ca2 100644 --- a/src/utils/git_exception.hpp +++ b/src/utils/git_exception.hpp @@ -6,6 +6,7 @@ enum class git2cpp_error_code { GENERIC_ERROR = -1, + FILESYSTEM_ERROR = 128, BAD_ARGUMENT = 129, }; diff --git a/src/wrapper/index_wrapper.cpp b/src/wrapper/index_wrapper.cpp index 4ba0b15..701aab1 100644 --- a/src/wrapper/index_wrapper.cpp +++ b/src/wrapper/index_wrapper.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -47,6 +48,20 @@ void index_wrapper::remove_entry(const std::string& path) throw_if_error(git_index_remove_bypath(*this, path.c_str())); } +void index_wrapper::remove_entries(std::vector paths) +{ + git_strarray_wrapper array{paths}; + throw_if_error(git_index_remove_all(*this, array, NULL, NULL)); +} + +void index_wrapper::remove_directories(std::vector entries) +{ + std::for_each(entries.cbegin(), entries.cend(), [this](const std::string& path) + { + throw_if_error(git_index_remove_directory(*this, path.c_str(), 0)); + }); +} + void index_wrapper::write() { throw_if_error(git_index_write(*this)); diff --git a/src/wrapper/index_wrapper.hpp b/src/wrapper/index_wrapper.hpp index 9091242..9a973cc 100644 --- a/src/wrapper/index_wrapper.hpp +++ b/src/wrapper/index_wrapper.hpp @@ -28,6 +28,8 @@ class index_wrapper : public wrapper_base void add_all(); void remove_entry(const std::string& path); + void remove_entries(std::vector paths); + void remove_directories(std::vector paths); bool has_conflict() const; void output_conflicts(); diff --git a/src/wrapper/repository_wrapper.cpp b/src/wrapper/repository_wrapper.cpp index 31b7177..a7fcf2b 100644 --- a/src/wrapper/repository_wrapper.cpp +++ b/src/wrapper/repository_wrapper.cpp @@ -70,6 +70,13 @@ revwalk_wrapper repository_wrapper::new_walker() return revwalk_wrapper(walker); } +bool repository_wrapper::does_track(std::string_view path) const +{ + unsigned int flags; + throw_if_error(git_status_file(&flags, *this, path.data())); + return !(flags & GIT_STATUS_WT_NEW) && !(flags & GIT_STATUS_IGNORED); +} + // Head bool repository_wrapper::is_head_unborn() const diff --git a/src/wrapper/repository_wrapper.hpp b/src/wrapper/repository_wrapper.hpp index b3e8dbc..991bdc5 100644 --- a/src/wrapper/repository_wrapper.hpp +++ b/src/wrapper/repository_wrapper.hpp @@ -43,6 +43,8 @@ class repository_wrapper : public wrapper_base revwalk_wrapper new_walker(); + bool does_track(std::string_view path) const; + // Head bool is_head_unborn() const; reference_wrapper head() const; diff --git a/src/wrapper/status_wrapper.cpp b/src/wrapper/status_wrapper.cpp index a6fe876..b45395e 100644 --- a/src/wrapper/status_wrapper.cpp +++ b/src/wrapper/status_wrapper.cpp @@ -84,16 +84,3 @@ auto status_list_wrapper::get_entry_list(git_status_t status) const -> const sta } } - - -// std::ostream& operator<<(std::ostream& out, const status_list_wrapper& slw) -// { -// std::size_t status_list_size = git_status_list_entrycount(slw); -// for (std::size_t i = 0; i < status_list_size; ++i) -// { -// std::cout << i << " "; -// auto entry = git_status_byindex(slw, i); - -// } -// return out; -// }; diff --git a/test/conftest_wasm.py b/test/conftest_wasm.py index 64e33ae..d6f2b1e 100644 --- a/test/conftest_wasm.py +++ b/test/conftest_wasm.py @@ -30,6 +30,7 @@ def pytest_ignore_collect(collection_path: pathlib.Path) -> bool: "test_reset.py", "test_revlist.py", "test_revparse.py", + "test_rm.py", "test_stash.py", "test_status.py", ] diff --git a/test/test_rm.py b/test/test_rm.py new file mode 100644 index 0000000..dcb927c --- /dev/null +++ b/test/test_rm.py @@ -0,0 +1,361 @@ +import subprocess + +import pytest + + +def test_rm_basic_file(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test basic rm operation to remove a file""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a test file + test_file = xtl_path / "test_file.txt" + test_file.write_text("test content") + + # Add and commit the file + add_cmd = [git2cpp_path, "add", "test_file.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + commit_cmd = [git2cpp_path, "commit", "-m", "Add test file"] + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_commit.returncode == 0 + + # Remove the file + rm_cmd = [git2cpp_path, "rm", "test_file.txt"] + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_rm.returncode == 0 + + # Verify the file was removed from working tree + assert not test_file.exists() + + # Check git status + status_cmd = [git2cpp_path, "status", "--long"] + p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_status.returncode == 0 + assert "Changes to be committed" in p_status.stdout + assert "deleted" in p_status.stdout + + +def test_rm_multiple_files(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test removing multiple files at once""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create test files + file1 = xtl_path / "file1.txt" + file1.write_text("content 1") + file2 = xtl_path / "file2.txt" + file2.write_text("content 2") + + # Add and commit files + add_cmd = [git2cpp_path, "add", "file1.txt", "file2.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + commit_cmd = [git2cpp_path, "commit", "-m", "Add test files"] + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_commit.returncode == 0 + + # Remove both files + rm_cmd = [git2cpp_path, "rm", "file1.txt", "file2.txt"] + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_rm.returncode == 0 + + # Verify both files were removed + assert not file1.exists() + assert not file2.exists() + + # Check git status + status_cmd = [git2cpp_path, "status", "--long"] + p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_status.returncode == 0 + assert "Changes to be committed" in p_status.stdout + assert "deleted" in p_status.stdout + + +def test_rm_directory_without_recursive_flag(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test that rm fails when trying to remove a directory without -r flag""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a directory with a file + test_dir = xtl_path / "test_dir" + test_dir.mkdir() + test_file = test_dir / "file.txt" + test_file.write_text("content") + + # Add and commit the file + add_cmd = [git2cpp_path, "add", "test_dir/file.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + commit_cmd = [git2cpp_path, "commit", "-m", "Add test directory"] + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_commit.returncode == 0 + + # Try to remove directory without -r flag - should fail + rm_cmd = [git2cpp_path, "rm", "test_dir"] + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_rm.returncode != 0 + assert "not removing" in p_rm.stderr and "recursively without -r" in p_rm.stderr + + # Verify directory still exists + assert test_dir.exists() + assert test_file.exists() + + +def test_rm_directory_with_recursive_flag(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test removing a directory with -r flag""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a directory with files + test_dir = xtl_path / "test_dir" + test_dir.mkdir() + file1 = test_dir / "file1.txt" + file1.write_text("content 1") + file2 = test_dir / "file2.txt" + file2.write_text("content 2") + + # Add and commit the files + add_cmd = [git2cpp_path, "add", "test_dir/file1.txt", "test_dir/file2.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + commit_cmd = [git2cpp_path, "commit", "-m", "Add test directory"] + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_commit.returncode == 0 + + # Remove directory with -r flag - should succeed + rm_cmd = [git2cpp_path, "rm", "-r", "test_dir"] + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_rm.returncode == 0 + + # Check git status + status_cmd = [git2cpp_path, "status", "--long"] + p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_status.returncode == 0 + assert "Changes to be committed" in p_status.stdout + assert "deleted" in p_status.stdout + + +def test_rm_and_commit(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test removing a file and committing the change""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a test file + test_file = xtl_path / "to_remove.txt" + test_file.write_text("content to remove") + + # Add and commit the file + add_cmd = [git2cpp_path, "add", "to_remove.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + commit_cmd = [git2cpp_path, "commit", "-m", "Add file to remove"] + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_commit.returncode == 0 + + # Remove the file + rm_cmd = [git2cpp_path, "rm", "to_remove.txt"] + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_rm.returncode == 0 + + # Check status before commit + status_cmd = [git2cpp_path, "status", "--long"] + p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_status.returncode == 0 + assert "Changes to be committed" in p_status.stdout + assert "deleted" in p_status.stdout + + # Commit the removal + commit_cmd2 = [git2cpp_path, "commit", "-m", "Remove file"] + p_commit2 = subprocess.run(commit_cmd2, capture_output=True, cwd=xtl_path, text=True) + assert p_commit2.returncode == 0 + + # Verify the file is gone + assert not test_file.exists() + + # Check status after commit + status_cmd2 = [git2cpp_path, "status", "--long"] + p_status2 = subprocess.run(status_cmd2, capture_output=True, cwd=xtl_path, text=True) + assert p_status2.returncode == 0 + assert "to_remove.txt" not in p_status2.stdout + + +def test_rm_nonexistent_file(xtl_clone, git2cpp_path, tmp_path): + """Test that rm fails when file doesn't exist""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Try to remove a file that doesn't exist + rm_cmd = [git2cpp_path, "rm", "nonexistent.txt"] + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_rm.returncode != 0 + + +def test_rm_untracked_file(xtl_clone, git2cpp_path, tmp_path): + """Test that rm fails when trying to remove an untracked file""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create an untracked file + untracked_file = xtl_path / "untracked.txt" + untracked_file.write_text("untracked content") + + # Try to remove untracked file - should fail + rm_cmd = [git2cpp_path, "rm", "untracked.txt"] + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_rm.returncode != 0 + + +def test_rm_staged_file(xtl_clone, git2cpp_path, tmp_path): + """Test removing a file that was added but not yet committed""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a test file + test_file = xtl_path / "staged.txt" + test_file.write_text("staged content") + + # Add the file (but don't commit) + add_cmd = [git2cpp_path, "add", "staged.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + # Remove the file + rm_cmd = [git2cpp_path, "rm", "staged.txt"] + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_rm.returncode == 0 + + # Verify the file was removed + assert not test_file.exists() + + # Check git status - should show nothing staged + status_cmd = [git2cpp_path, "status", "--long"] + p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_status.returncode == 0 + assert "Changes to be committed" not in p_status.stdout + assert "staged.txt" not in p_status.stdout + + +def test_rm_file_in_subdirectory(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test removing a file in a subdirectory""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Use existing subdirectory + test_file = xtl_path / "include" / "test.txt" + test_file.write_text("test content") + + # Add and commit the file + add_cmd = [git2cpp_path, "add", "include/test.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + commit_cmd = [git2cpp_path, "commit", "-m", "Add file in subdirectory"] + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_commit.returncode == 0 + + # Remove the file + rm_cmd = [git2cpp_path, "rm", "include/test.txt"] + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_rm.returncode == 0 + + # Verify the file was removed + assert not test_file.exists() + + # Check git status + status_cmd = [git2cpp_path, "status", "--long"] + p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_status.returncode == 0 + assert "Changes to be committed" in p_status.stdout + assert "deleted" in p_status.stdout + + +def test_rm_nogit(git2cpp_path, tmp_path): + """Test that rm fails when not in a git repository""" + # Create a test file outside a git repo + test_file = tmp_path / "test.txt" + test_file.write_text("test content") + + # Try to rm without being in a git repo + rm_cmd = [git2cpp_path, "rm", "test.txt"] + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_rm.returncode != 0 + + +def test_rm_nested_directory_recursive(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test removing a nested directory structure with -r flag""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create nested directory structure + nested_dir = xtl_path / "level1" / "level2" + nested_dir.mkdir(parents=True) + file1 = xtl_path / "level1" / "file1.txt" + file1.write_text("content 1") + file2 = nested_dir / "file2.txt" + file2.write_text("content 2") + + # Add and commit the files + add_cmd = [git2cpp_path, "add", "level1/file1.txt", "level1/level2/file2.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + commit_cmd = [git2cpp_path, "commit", "-m", "Add nested structure"] + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_commit.returncode == 0 + + # Remove the directory tree with -r flag + rm_cmd = [git2cpp_path, "rm", "-r", "level1"] + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_rm.returncode == 0 + + # Check git status + status_cmd = [git2cpp_path, "status", "--long"] + p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_status.returncode == 0 + assert "Changes to be committed" in p_status.stdout + assert "deleted" in p_status.stdout + + +def test_rm_mixed_files_and_directory(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test removing both individual files and directories in one command""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a file and a directory with contents + single_file = xtl_path / "single.txt" + single_file.write_text("single file") + + test_dir = xtl_path / "remove_dir" + test_dir.mkdir() + dir_file = test_dir / "file.txt" + dir_file.write_text("file in dir") + + # Add and commit everything + add_cmd = [git2cpp_path, "add", "single.txt", "remove_dir/file.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + commit_cmd = [git2cpp_path, "commit", "-m", "Add mixed content"] + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_commit.returncode == 0 + + # Remove both file and directory + rm_cmd = [git2cpp_path, "rm", "-r", "single.txt", "remove_dir"] + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_rm.returncode == 0 + + # Verify everything was removed + assert not single_file.exists() + + # Check git status + status_cmd = [git2cpp_path, "status", "--long"] + p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_status.returncode == 0 + assert "Changes to be committed" in p_status.stdout + assert "deleted" in p_status.stdout From 7fad05efde372ae88983c01702b99543c10caeba Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Thu, 12 Feb 2026 09:46:24 +0100 Subject: [PATCH 08/35] update using git2cpp_error_code in config_subcommand (#97) --- src/subcommand/config_subcommand.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/subcommand/config_subcommand.cpp b/src/subcommand/config_subcommand.cpp index 3472eac..5cde788 100644 --- a/src/subcommand/config_subcommand.cpp +++ b/src/subcommand/config_subcommand.cpp @@ -56,7 +56,7 @@ void config_subcommand::run_get() { if (m_name.empty()) { - throw git_exception("error: wrong number of arguments, should be 1", 129); + throw git_exception("error: wrong number of arguments, should be 1", git2cpp_error_code::BAD_ARGUMENT); } auto directory = get_current_git_path(); @@ -73,7 +73,7 @@ void config_subcommand::run_set() { if (m_name.empty() | m_value.empty()) { - throw git_exception("error: wrong number of arguments, should be 2", 129); + throw git_exception("error: wrong number of arguments, should be 2", git2cpp_error_code::BAD_ARGUMENT); } auto directory = get_current_git_path(); @@ -87,7 +87,7 @@ void config_subcommand::run_unset() { if (m_name.empty()) { - throw git_exception("error: wrong number of arguments, should be 1", 129); + throw git_exception("error: wrong number of arguments, should be 1", git2cpp_error_code::BAD_ARGUMENT); } auto directory = get_current_git_path(); From 289f0695a3c1a2bad3b978604507984a96580d4e Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Fri, 13 Feb 2026 23:48:01 +0100 Subject: [PATCH 09/35] Add ```tag``` subcommand (#96) * Add tag subcommand * address review comments --- CMakeLists.txt | 4 + src/main.cpp | 2 + src/subcommand/log_subcommand.cpp | 2 +- src/subcommand/tag_subcommand.cpp | 237 +++++++++++++++++++++++ src/subcommand/tag_subcommand.hpp | 32 ++++ src/utils/common.cpp | 17 ++ src/utils/common.hpp | 4 + src/utils/terminal_pager.cpp | 12 +- src/utils/terminal_pager.hpp | 2 - src/wrapper/commit_wrapper.cpp | 5 + src/wrapper/commit_wrapper.hpp | 1 + src/wrapper/object_wrapper.cpp | 5 + src/wrapper/object_wrapper.hpp | 1 + src/wrapper/repository_wrapper.cpp | 13 ++ src/wrapper/repository_wrapper.hpp | 5 + src/wrapper/tag_wrapper.cpp | 23 +++ src/wrapper/tag_wrapper.hpp | 25 +++ test/test_tag.py | 298 +++++++++++++++++++++++++++++ 18 files changed, 675 insertions(+), 13 deletions(-) create mode 100644 src/subcommand/tag_subcommand.cpp create mode 100644 src/subcommand/tag_subcommand.hpp create mode 100644 src/wrapper/tag_wrapper.cpp create mode 100644 src/wrapper/tag_wrapper.hpp create mode 100644 test/test_tag.py diff --git a/CMakeLists.txt b/CMakeLists.txt index cdcecda..30c36bc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -82,6 +82,8 @@ set(GIT2CPP_SRC ${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 @@ -126,6 +128,8 @@ set(GIT2CPP_SRC ${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/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 diff --git a/src/main.cpp b/src/main.cpp index efb385c..5d0223a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -23,6 +23,7 @@ #include "subcommand/reset_subcommand.hpp" #include "subcommand/stash_subcommand.hpp" #include "subcommand/status_subcommand.hpp" +#include "subcommand/tag_subcommand.hpp" #include "subcommand/revparse_subcommand.hpp" #include "subcommand/revlist_subcommand.hpp" #include "subcommand/rm_subcommand.hpp" @@ -60,6 +61,7 @@ int main(int argc, char** argv) revparse_subcommand revparse(lg2_obj, app); rm_subcommand rm(lg2_obj, app); stash_subcommand stash(lg2_obj, app); + tag_subcommand tag(lg2_obj, app); app.require_subcommand(/* min */ 0, /* max */ 1); diff --git a/src/subcommand/log_subcommand.cpp b/src/subcommand/log_subcommand.cpp index 856ba7d..f5bc56b 100644 --- a/src/subcommand/log_subcommand.cpp +++ b/src/subcommand/log_subcommand.cpp @@ -78,7 +78,7 @@ void print_commit(const commit_wrapper& commit, std::string m_format_flag) print_time(author.when(), "Date:\t"); } } - std::cout << "\n " << git_commit_message(commit) << "\n" << std::endl; + std::cout << "\n " << commit.message() << "\n" << std::endl; } void log_subcommand::run() diff --git a/src/subcommand/tag_subcommand.cpp b/src/subcommand/tag_subcommand.cpp new file mode 100644 index 0000000..fd25be1 --- /dev/null +++ b/src/subcommand/tag_subcommand.cpp @@ -0,0 +1,237 @@ +#include + +#include "../subcommand/tag_subcommand.hpp" +#include "../wrapper/commit_wrapper.hpp" +#include "../wrapper/tag_wrapper.hpp" + +tag_subcommand::tag_subcommand(const libgit2_object&, CLI::App& app) +{ + auto* sub = app.add_subcommand("tag", "Create, list, delete or verify tags"); + + sub->add_flag("-l,--list", m_list_flag, "List tags. With optional ."); + sub->add_flag("-f,--force", m_force_flag, "Replace an existing tag with the given name (instead of failing)"); + sub->add_option("-d,--delete", m_delete, "Delete existing tags with the given names."); + sub->add_option("-n", m_num_lines, " specifies how many lines from the annotation, if any, are printed when using -l. Implies --list."); + sub->add_option("-m,--message", m_message, "Tag message for annotated tags"); + sub->add_option("", m_tag_name, "Tag name"); + sub->add_option("", m_target, "Target commit (defaults to HEAD)"); + + sub->callback([this]() { this->run(); }); +} + +// Tag listing: Print individual message lines +void print_list_lines(const std::string& message, int num_lines) +{ + if (message.empty()) + { + return; + } + + auto lines = split_input_at_newlines(message); + + // header + std::cout << lines[0]; + + // other lines + if (num_lines <= 1 || lines.size() <= 2) + { + std::cout << std::endl; + } + else + { + for (size_t i = 1; i < lines.size() ; i++) + { + if (i < num_lines) + { + std::cout << "\n\t\t" << lines[i]; + } + } + } +} + +// Tag listing: Print an actual tag object +void print_tag(git_tag* tag, int num_lines) +{ + std::cout << std::left << std::setw(16) << git_tag_name(tag); + + if (num_lines) + { + std::string msg = git_tag_message(tag); + if (!msg.empty()) + { + print_list_lines(msg, num_lines); + } + else + { + std::cout << std::endl; + } + } + else + { + std::cout << std::endl; + } +} + +// Tag listing: Print a commit (target of a lightweight tag) +void print_commit(git_commit* commit, std::string name, int num_lines) +{ + std::cout << std::left << std::setw(16) << name; + + if (num_lines) + { + std::string msg = git_commit_message(commit); + if (!msg.empty()) + { + print_list_lines(msg, num_lines); + } + else + { + std::cout < tag_subcommand::get_target_obj(repository_wrapper& repo) +{ + if (m_tag_name.empty()) + { + throw git_exception("Tag name required", git2cpp_error_code::GENERIC_ERROR); + } + + std::string target = m_target.empty() ? "HEAD" : m_target; + + auto target_obj = repo.revparse_single(target); + if (!target_obj.has_value()) + { + throw git_exception("Unable to resolve target: " + target, git2cpp_error_code::GENERIC_ERROR); + } + + return target_obj; +} + +void tag_subcommand::handle_error(int error) +{ + if (error < 0) + { + if (error == GIT_EEXISTS) + { + throw git_exception("tag '" + m_tag_name + "' already exists", git2cpp_error_code::FILESYSTEM_ERROR); + } + throw git_exception("Unable to create annotated tag", error); + } +} + +void tag_subcommand::create_lightweight_tag(repository_wrapper& repo) +{ + auto target_obj = tag_subcommand::get_target_obj(repo); + + git_oid oid; + size_t force = m_force_flag ? 1 : 0; + int error = git_tag_create_lightweight(&oid, repo, m_tag_name.c_str(), target_obj.value(), force); + + handle_error(error); +} + +void tag_subcommand::create_tag(repository_wrapper& repo) +{ + auto target_obj = tag_subcommand::get_target_obj(repo); + + auto tagger = signature_wrapper::get_default_signature_from_env(repo); + + git_oid oid; + size_t force = m_force_flag ? 1 : 0; + int error = git_tag_create(&oid, repo, m_tag_name.c_str(), target_obj.value(), tagger.first, m_message.c_str(), force); + + handle_error(error); +} + +void tag_subcommand::run() +{ + auto directory = get_current_git_path(); + auto repo = repository_wrapper::open(directory); + + if (!m_delete.empty()) + { + delete_tag(repo); + } + else if (m_list_flag || (m_tag_name.empty() && m_message.empty())) + { + list_tags(repo); + } + else if (!m_message.empty()) + { + create_tag(repo); + } + else if (!m_tag_name.empty()) + { + create_lightweight_tag(repo); + } + else + { + list_tags(repo); + } + +} diff --git a/src/subcommand/tag_subcommand.hpp b/src/subcommand/tag_subcommand.hpp new file mode 100644 index 0000000..512ea18 --- /dev/null +++ b/src/subcommand/tag_subcommand.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include + +#include "../utils/common.hpp" +#include "../wrapper/repository_wrapper.hpp" + +class tag_subcommand +{ +public: + + explicit tag_subcommand(const libgit2_object&, CLI::App& app); + + void run(); + +private: + + void list_tags(repository_wrapper& repo); + void delete_tag(repository_wrapper& repo); + void create_lightweight_tag(repository_wrapper& repo); + void create_tag(repository_wrapper& repo); + std::optional get_target_obj(repository_wrapper& repo); + void handle_error(int error); + + std::string m_delete; + std::string m_message; + std::string m_tag_name; + std::string m_target; + bool m_list_flag = false; + bool m_force_flag = false; + int m_num_lines = 0; +}; diff --git a/src/utils/common.cpp b/src/utils/common.cpp index 1df06e6..0d918f2 100644 --- a/src/utils/common.cpp +++ b/src/utils/common.cpp @@ -4,6 +4,9 @@ #include #include #include +#include + +#include #include "common.hpp" #include "git_exception.hpp" @@ -103,6 +106,11 @@ void git_strarray_wrapper::init_str_array() } } +size_t git_strarray_wrapper::size() +{ + return m_patterns.size(); +} + std::string read_file(const std::string& path) { std::ifstream file(path, std::ios::binary); @@ -114,3 +122,12 @@ std::string read_file(const std::string& path) buffer << file.rdbuf(); return buffer.str(); } + +std::vector split_input_at_newlines(std::string_view str) +{ + auto split = str | std::ranges::views::split('\n') + | std::ranges::views::transform([](auto&& range) { + return std::string(range.begin(), range.end()); + }); + return std::vector{split.begin(), split.end()}; +} diff --git a/src/utils/common.hpp b/src/utils/common.hpp index be9f360..bd77845 100644 --- a/src/utils/common.hpp +++ b/src/utils/common.hpp @@ -57,6 +57,8 @@ class git_strarray_wrapper operator git_strarray*(); + size_t size(); + private: std::vector m_patterns; git_strarray m_array; @@ -66,3 +68,5 @@ class git_strarray_wrapper }; std::string read_file(const std::string& path); + +std::vector split_input_at_newlines(std::string_view str); diff --git a/src/utils/terminal_pager.cpp b/src/utils/terminal_pager.cpp index 1b79996..3bec415 100644 --- a/src/utils/terminal_pager.cpp +++ b/src/utils/terminal_pager.cpp @@ -12,6 +12,7 @@ #include "ansi_code.hpp" #include "output.hpp" #include "terminal_pager.hpp" +#include "common.hpp" terminal_pager::terminal_pager() : m_rows(0), m_columns(0), m_start_row_index(0) @@ -167,7 +168,7 @@ void terminal_pager::show() { release_cout(); - split_input_at_newlines(m_stringbuf.view()); + m_lines = split_input_at_newlines(m_stringbuf.view()); update_terminal_size(); if (m_rows == 0 || m_lines.size() <= m_rows - 1) @@ -196,15 +197,6 @@ void terminal_pager::show() m_start_row_index = 0; } -void terminal_pager::split_input_at_newlines(std::string_view str) -{ - auto split = str | std::ranges::views::split('\n') - | std::ranges::views::transform([](auto&& range) { - return std::string(range.begin(), range.end()); - }); - m_lines = std::vector{split.begin(), split.end()}; -} - void terminal_pager::update_terminal_size() { struct winsize size; diff --git a/src/utils/terminal_pager.hpp b/src/utils/terminal_pager.hpp index 8c710a1..ea02865 100644 --- a/src/utils/terminal_pager.hpp +++ b/src/utils/terminal_pager.hpp @@ -49,8 +49,6 @@ class terminal_pager void scroll(bool up, bool page); - void split_input_at_newlines(std::string_view str); - void update_terminal_size(); diff --git a/src/wrapper/commit_wrapper.cpp b/src/wrapper/commit_wrapper.cpp index 33efa9f..fc214cc 100644 --- a/src/wrapper/commit_wrapper.cpp +++ b/src/wrapper/commit_wrapper.cpp @@ -28,6 +28,11 @@ std::string commit_wrapper::commit_oid_tostr() const return git_oid_tostr(buf, sizeof(buf), &this->oid()); } +std::string commit_wrapper::message() const +{ + return git_commit_message(*this); +} + std::string commit_wrapper::summary() const { return git_commit_summary(*this); diff --git a/src/wrapper/commit_wrapper.hpp b/src/wrapper/commit_wrapper.hpp index 4fe280f..0db1066 100644 --- a/src/wrapper/commit_wrapper.hpp +++ b/src/wrapper/commit_wrapper.hpp @@ -24,6 +24,7 @@ class commit_wrapper : public wrapper_base const git_oid& oid() const; std::string commit_oid_tostr() const; + std::string message() const; std::string summary() const; commit_list_wrapper get_parents_list() const; diff --git a/src/wrapper/object_wrapper.cpp b/src/wrapper/object_wrapper.cpp index bf21361..7649540 100644 --- a/src/wrapper/object_wrapper.cpp +++ b/src/wrapper/object_wrapper.cpp @@ -20,3 +20,8 @@ object_wrapper::operator git_commit*() const noexcept { return reinterpret_cast(p_resource); } + +object_wrapper::operator git_tag*() const noexcept +{ + return reinterpret_cast(p_resource); +} diff --git a/src/wrapper/object_wrapper.hpp b/src/wrapper/object_wrapper.hpp index d839ade..8faf1e1 100644 --- a/src/wrapper/object_wrapper.hpp +++ b/src/wrapper/object_wrapper.hpp @@ -18,6 +18,7 @@ class object_wrapper : public wrapper_base const git_oid& oid() const; operator git_commit*() const noexcept; + operator git_tag*() const noexcept; private: diff --git a/src/wrapper/repository_wrapper.cpp b/src/wrapper/repository_wrapper.cpp index a7fcf2b..2050ffe 100644 --- a/src/wrapper/repository_wrapper.cpp +++ b/src/wrapper/repository_wrapper.cpp @@ -505,3 +505,16 @@ diff_wrapper repository_wrapper::diff_index_to_workdir(std::optional repository_wrapper::tag_list_match(std::string pattern) +{ + git_strarray tag_names; + throw_if_error(git_tag_list_match(&tag_names, pattern.c_str(), *this)); + + std::vector result(tag_names.strings, tag_names.strings + tag_names.count); + + git_strarray_dispose(&tag_names); + return result; +} diff --git a/src/wrapper/repository_wrapper.hpp b/src/wrapper/repository_wrapper.hpp index 991bdc5..428f64f 100644 --- a/src/wrapper/repository_wrapper.hpp +++ b/src/wrapper/repository_wrapper.hpp @@ -6,6 +6,7 @@ #include +#include "../utils/common.hpp" #include "../utils/git_exception.hpp" #include "../wrapper/annotated_commit_wrapper.hpp" #include "../wrapper/branch_wrapper.hpp" @@ -113,6 +114,10 @@ class repository_wrapper : public wrapper_base diff_wrapper diff_tree_to_workdir_with_index(tree_wrapper old_tree, git_diff_options* diffopts); diff_wrapper diff_index_to_workdir(std::optional index, git_diff_options* diffopts); + //Tags + // git_strarray_wrapper tag_list_match(std::string pattern); + std::vector tag_list_match(std::string pattern); + private: repository_wrapper() = default; diff --git a/src/wrapper/tag_wrapper.cpp b/src/wrapper/tag_wrapper.cpp new file mode 100644 index 0000000..e385dd4 --- /dev/null +++ b/src/wrapper/tag_wrapper.cpp @@ -0,0 +1,23 @@ +#include "../wrapper/tag_wrapper.hpp" +#include + +tag_wrapper::tag_wrapper(git_tag* tag) + : base_type(tag) +{ +} + +tag_wrapper::~tag_wrapper() +{ + git_tag_free(p_resource); + p_resource = nullptr; +} + +std::string tag_wrapper::name() +{ + return git_tag_name(*this); +} + +std::string tag_wrapper::message() +{ + return git_tag_message(*this); +} diff --git a/src/wrapper/tag_wrapper.hpp b/src/wrapper/tag_wrapper.hpp new file mode 100644 index 0000000..fb78eee --- /dev/null +++ b/src/wrapper/tag_wrapper.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +#include "../wrapper/wrapper_base.hpp" + +class tag_wrapper : public wrapper_base +{ +public: + + using base_type = wrapper_base; + + ~tag_wrapper(); + + tag_wrapper(tag_wrapper&&) noexcept = default; + tag_wrapper& operator=(tag_wrapper&&) noexcept = default; + + std::string name(); + std::string message(); + +private: + + tag_wrapper(git_tag* tag); +}; diff --git a/test/test_tag.py b/test/test_tag.py new file mode 100644 index 0000000..d698463 --- /dev/null +++ b/test/test_tag.py @@ -0,0 +1,298 @@ +import subprocess + +import pytest + +def test_tag_list_empty(xtl_clone, git2cpp_path, tmp_path): + """Test listing tags when there are no tags.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + cmd = [git2cpp_path, 'tag'] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "0.2.0" in p.stdout + + +def test_tag_create_lightweight(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test creating a lightweight tag.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a lightweight tag + create_cmd = [git2cpp_path, 'tag', 'v1.0.0'] + subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, text=True, check=True) + + # List tags to verify it was created + list_cmd = [git2cpp_path, 'tag'] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_list.returncode == 0 + assert 'v1.0.0' in p_list.stdout + + +def test_tag_create_annotated(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test creating an annotated tag.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create an annotated tag + create_cmd = [git2cpp_path, 'tag', '-m', 'Release version 1.0', 'v1.0.0'] + subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, text=True, check=True) + + # List tags to verify it was created + list_cmd = [git2cpp_path, 'tag', "-n", "1"] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_list.returncode == 0 + assert 'v1.0.0' in p_list.stdout + assert 'Release version 1.0' in p_list.stdout + + +def test_tag_create_on_specific_commit(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test creating a tag on a specific commit.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Get the commit SHA before creating new commit + old_head_cmd = ['git', 'rev-parse', 'HEAD'] + p_old_head = subprocess.run(old_head_cmd, capture_output=True, cwd=xtl_path, text=True) + old_head_sha = p_old_head.stdout.strip() + + # Create a commit first + file_path = xtl_path / "test_file.txt" + file_path.write_text("test content") + + add_cmd = [git2cpp_path, 'add', 'test_file.txt'] + subprocess.run(add_cmd, cwd=xtl_path, check=True) + + commit_cmd = [git2cpp_path, 'commit', '-m', 'test commit'] + subprocess.run(commit_cmd, cwd=xtl_path, check=True) + + # Get new HEAD commit SHA + new_head_cmd = ['git', 'rev-parse', 'HEAD'] + p_new_head = subprocess.run(new_head_cmd, capture_output=True, cwd=xtl_path, text=True) + new_head_sha = p_new_head.stdout.strip() + + # Verify we actually created a new commit + assert old_head_sha != new_head_sha + + # Create tag on HEAD + tag_cmd = [git2cpp_path, 'tag', 'v1.0.0', 'HEAD'] + subprocess.run(tag_cmd, capture_output=True, cwd=xtl_path, check=True) + + # Verify tag exists + list_cmd = [git2cpp_path, 'tag'] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_list.returncode == 0 + assert 'v1.0.0' in p_list.stdout + + # Get commit SHA that the tag points to + tag_sha_cmd = ['git', 'rev-parse', 'v1.0.0^{commit}'] + p_tag_sha = subprocess.run(tag_sha_cmd, capture_output=True, cwd=xtl_path, text=True) + tag_sha = p_tag_sha.stdout.strip() + + # Verify tag points to new HEAD, not old HEAD + assert tag_sha == new_head_sha + assert tag_sha != old_head_sha + + +def test_tag_delete(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test deleting a tag.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a tag + create_cmd = [git2cpp_path, 'tag', 'v1.0.0'] + subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, text=True, check=True) + + # Delete the tag + delete_cmd = [git2cpp_path, 'tag', '-d', 'v1.0.0'] + p_delete = subprocess.run(delete_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_delete.returncode == 0 + assert "Deleted tag 'v1.0.0'" in p_delete.stdout + + # Verify tag is gone + list_cmd = [git2cpp_path, 'tag'] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_list.returncode == 0 + assert 'v1.0.0' not in p_list.stdout + + +def test_tag_delete_nonexistent(xtl_clone, git2cpp_path, tmp_path): + """Test deleting a tag that doesn't exist.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Try to delete non-existent tag + delete_cmd = [git2cpp_path, 'tag', '-d', 'nonexistent'] + p_delete = subprocess.run(delete_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_delete.returncode != 0 + assert "not found" in p_delete.stderr + + +@pytest.mark.parametrize("list_flag", ["-l", "--list"]) +def test_tag_list_with_flag(xtl_clone, commit_env_config, git2cpp_path, tmp_path, list_flag): + """Test listing tags with -l or --list flag.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a tag + tag_cmd = [git2cpp_path, 'tag', 'v1.0.0'] + subprocess.run(tag_cmd, capture_output=True, cwd=xtl_path, text=True) + + # List tags + list_cmd = [git2cpp_path, 'tag', list_flag] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_list.returncode == 0 + assert 'v1.0.0' in p_list.stdout + + +def test_tag_list_with_pattern(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test listing tags with a pattern.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create tags with different prefixes + tag_cmd_1 = [git2cpp_path, 'tag', 'v1.0.0'] + subprocess.run(tag_cmd_1, capture_output=True, cwd=xtl_path, text=True) + + tag_cmd_2 = [git2cpp_path, 'tag', 'v1.0.1'] + subprocess.run(tag_cmd_2, capture_output=True, cwd=xtl_path, text=True) + + tag_cmd_3 = [git2cpp_path, 'tag', 'release-1.0'] + subprocess.run(tag_cmd_3, capture_output=True, cwd=xtl_path, text=True) + + # List only tags matching pattern + list_cmd = [git2cpp_path, 'tag', '-l', 'v1.0*'] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_list.returncode == 0 + assert 'v1.0.0' in p_list.stdout + assert 'v1.0.1' in p_list.stdout + assert 'release-1.0' not in p_list.stdout + + +def test_tag_list_with_message_lines(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test listing tags with message lines (-n flag).""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create an annotated tag with a message + create_cmd = [git2cpp_path, 'tag', '-m', 'First line\nSecond line\nThird line\nForth line', 'v1.0.0'] + p_create = subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_create.returncode == 0 + + # List tags with message lines + list_cmd = [git2cpp_path, 'tag', '-n', '3', '-l'] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_list.returncode == 0 + assert 'v1.0.0' in p_list.stdout + assert 'First line' in p_list.stdout + assert 'Second line' in p_list.stdout + assert 'Third line' in p_list.stdout + assert 'Forth line' not in p_list.stdout + + +@pytest.mark.parametrize("force_flag", ["-f", "--force"]) +def test_tag_force_replace(xtl_clone, commit_env_config, git2cpp_path, tmp_path, force_flag): + """Test replacing an existing tag with -f or --force flag.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create initial tag + create_cmd_1 = [git2cpp_path, 'tag', 'v1.0.0'] + subprocess.run(create_cmd_1, capture_output=True, cwd=xtl_path, text=True, check=True) + + # Try to create same tag without force (should fail) + create_cmd_2 = [git2cpp_path, 'tag', 'v1.0.0'] + p_create_2 = subprocess.run(create_cmd_2, capture_output=True, cwd=xtl_path) + assert p_create_2.returncode != 0 + + # Create same tag with force (should succeed) + create_cmd_3 = [git2cpp_path, 'tag', force_flag, 'v1.0.0'] + p_create_3 = subprocess.run(create_cmd_3, capture_output=True, cwd=xtl_path, text=True) + assert p_create_3.returncode == 0 + + +def test_tag_nogit(git2cpp_path, tmp_path): + """Test tag command outside a git repository.""" + cmd = [git2cpp_path, 'tag'] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode != 0 + + +def test_tag_annotated_no_message(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test creating an annotated tag without a message should fail.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a commit with a known message + file_path = xtl_path / "test_file.txt" + file_path.write_text("test content") + + add_cmd = [git2cpp_path, 'add', 'test_file.txt'] + subprocess.run(add_cmd, cwd=xtl_path, check=True) + + commit_cmd = [git2cpp_path, 'commit', '-m', 'my specific commit message'] + subprocess.run(commit_cmd, cwd=xtl_path, check=True) + + # Create tag with empty message (should create lightweight tag) + create_cmd = [git2cpp_path, 'tag', '-m', '', 'v1.0.0'] + subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, check=True) + + # List tag with messages - lightweight tag shows commit message + list_cmd = [git2cpp_path, 'tag', '-n', '1'] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_list.returncode == 0 + assert 'v1.0.0' in p_list.stdout + # Lightweight tag shows the commit message, not a tag message + assert 'my specific commit message' in p_list.stdout + + +def test_tag_multiple_create_and_list(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test creating multiple tags and listing them.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create multiple tags + tags = ['v1.0.0', 'v1.0.1', 'v1.1.0', 'v2.0.0'] + for tag in tags: + create_cmd = [git2cpp_path, 'tag', tag] + subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, check=True) + + # List all tags + list_cmd = [git2cpp_path, 'tag'] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_list.returncode == 0 + + # Verify all tags are in the list + for tag in tags: + assert tag in p_list.stdout + + +def test_tag_on_new_commit(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test creating tags on new commits.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Tag the current commit + tag_cmd_1 = [git2cpp_path, 'tag', 'before-change'] + subprocess.run(tag_cmd_1, cwd=xtl_path, check=True) + + # Make a new commit + file_path = xtl_path / "new_file.txt" + file_path.write_text("new content") + + add_cmd = [git2cpp_path, 'add', 'new_file.txt'] + subprocess.run(add_cmd, cwd=xtl_path, check=True) + + commit_cmd = [git2cpp_path, 'commit', '-m', 'new commit'] + subprocess.run(commit_cmd, cwd=xtl_path, check=True) + + # Tag the new commit + tag_cmd_2 = [git2cpp_path, 'tag', 'after-change'] + subprocess.run(tag_cmd_2, cwd=xtl_path, check=True) + + # List tags + list_cmd = [git2cpp_path, 'tag'] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_list.returncode == 0 + assert 'before-change' in p_list.stdout + assert 'after-change' in p_list.stdout From 266f4cfeda380094d44f78dff31f83e075eda2d1 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Wed, 18 Feb 2026 16:10:56 +0000 Subject: [PATCH 10/35] Fix wasm test returncode (#100) * Fix return code in wasm tests * Implement Path.mkdir(parents=True) in wasm tests --- test/conftest_wasm.py | 14 +++++++++----- test/test_add.py | 5 +---- test/test_branch.py | 5 +---- test/test_clone.py | 5 +---- test/test_config.py | 5 +---- test/test_log.py | 5 +---- test/test_rebase.py | 21 +++++---------------- test/test_reset.py | 5 +---- test/test_status.py | 5 +---- 9 files changed, 21 insertions(+), 49 deletions(-) diff --git a/test/conftest_wasm.py b/test/conftest_wasm.py index d6f2b1e..2292918 100644 --- a/test/conftest_wasm.py +++ b/test/conftest_wasm.py @@ -89,8 +89,11 @@ def iterdir(self): for f in filter(lambda f: f not in ['', '.', '..'], re.split(r"\r?\n", p.stdout)): yield MockPath(self / f) - def mkdir(self): - subprocess.run(["mkdir", str(self)], capture_output=True, text=True, check=True) + def mkdir(self, *, parents=False): + args = [str(self)] + if parents: + args.append("-p") + subprocess.run(["mkdir"] + args, capture_output=True, text=True, check=True) def read_text(self) -> str: p = subprocess.run(["cat", str(self)], capture_output=True, text=True, check=True) @@ -155,6 +158,7 @@ def maybe_wrap_arg(s: str | MockPath) -> str: # TypeScript object is auto converted to Python dict. # Want to return subprocess.CompletedProcess, consider namedtuple if this fails in future. + returncode = proc['returncode'] stdout = proc['stdout'] if capture_output else '' stderr = proc['stderr'] if capture_output else '' if not text: @@ -167,12 +171,12 @@ def maybe_wrap_arg(s: str | MockPath) -> str: if proc['returncode'] != 0: raise RuntimeError(f"Error setting cwd to {old_cwd}") - if check and proc['returncode'] != 0: - raise subprocess.CalledProcessError(proc['returncode'], cmd, stdout, stderr) + if check and returncode != 0: + raise subprocess.CalledProcessError(returncode, cmd, stdout, stderr) return subprocess.CompletedProcess( args=cmd, - returncode=proc['returncode'], + returncode=returncode, stdout=stdout, stderr=stderr ) diff --git a/test/test_add.py b/test/test_add.py index d324b8b..8c19f63 100644 --- a/test/test_add.py +++ b/test/test_add.py @@ -1,7 +1,6 @@ import subprocess import pytest -from .conftest import GIT2CPP_TEST_WASM @pytest.mark.parametrize("all_flag", ["", "-A", "--all", "--no-ignore-removal"]) @@ -40,7 +39,5 @@ def test_add_nogit(git2cpp_path, tmp_path): cmd_add = [git2cpp_path, 'add', 'mook_file.txt'] p_add = subprocess.run(cmd_add, cwd=tmp_path, text=True, capture_output=True) - if not GIT2CPP_TEST_WASM: - # TODO: fix this in wasm build - assert p_add.returncode != 0 + assert p_add.returncode != 0 assert "error: could not find repository at" in p_add.stderr diff --git a/test/test_branch.py b/test/test_branch.py index 3a21136..b9dd30c 100644 --- a/test/test_branch.py +++ b/test/test_branch.py @@ -1,7 +1,6 @@ import subprocess import pytest -from .conftest import GIT2CPP_TEST_WASM def test_branch_list(xtl_clone, git2cpp_path, tmp_path): @@ -38,9 +37,7 @@ def test_branch_create_delete(xtl_clone, git2cpp_path, tmp_path): def test_branch_nogit(git2cpp_path, tmp_path): cmd = [git2cpp_path, 'branch'] p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) - if not GIT2CPP_TEST_WASM: - # TODO: fix this in wasm build - assert p.returncode != 0 + assert p.returncode != 0 assert "error: could not find repository at" in p.stderr diff --git a/test/test_clone.py b/test/test_clone.py index 57144b4..ff9ccec 100644 --- a/test/test_clone.py +++ b/test/test_clone.py @@ -1,5 +1,4 @@ import subprocess -from .conftest import GIT2CPP_TEST_WASM url = "https://github.com/xtensor-stack/xtl.git" @@ -22,9 +21,7 @@ def test_clone_is_bare(git2cpp_path, tmp_path, run_in_tmp_path): status_cmd = [git2cpp_path, "status"] p_status = subprocess.run(status_cmd, capture_output=True, cwd=tmp_path / "xtl", text=True) - if not GIT2CPP_TEST_WASM: - # TODO: fix this in wasm build - assert p_status.returncode != 0 + assert p_status.returncode != 0 assert "This operation is not allowed against bare repositories" in p_status.stderr branch_cmd = [git2cpp_path, "branch"] diff --git a/test/test_config.py b/test/test_config.py index dcf4712..cecb720 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -1,7 +1,6 @@ import subprocess import pytest -from .conftest import GIT2CPP_TEST_WASM def test_config_list(commit_env_config, git2cpp_path, tmp_path): @@ -53,7 +52,5 @@ def test_config_unset(git2cpp_path, tmp_path): cmd_get = [git2cpp_path, "config", "get", "core.bare"] p_get = subprocess.run(cmd_get, capture_output=True, cwd=tmp_path, text=True) - if not GIT2CPP_TEST_WASM: - # TODO: fix this in wasm build - assert p_get.returncode != 0 + assert p_get.returncode != 0 assert p_get.stderr == "error: config value 'core.bare' was not found\n" diff --git a/test/test_log.py b/test/test_log.py index 9d60d6f..18b91ee 100644 --- a/test/test_log.py +++ b/test/test_log.py @@ -1,7 +1,6 @@ import subprocess import pytest -from .conftest import GIT2CPP_TEST_WASM @pytest.mark.parametrize("format_flag", ["", "--format=full", "--format=fuller"]) @@ -41,9 +40,7 @@ def test_log(xtl_clone, commit_env_config, git2cpp_path, tmp_path, format_flag): def test_log_nogit(commit_env_config, git2cpp_path, tmp_path): cmd_log = [git2cpp_path, "log"] p_log = subprocess.run(cmd_log, capture_output=True, cwd=tmp_path, text=True) - if not GIT2CPP_TEST_WASM: - # TODO: fix this in wasm build - assert p_log.returncode != 0 + assert p_log.returncode != 0 assert "error: could not find repository at" in p_log.stderr diff --git a/test/test_rebase.py b/test/test_rebase.py index de5efe8..b6806b9 100644 --- a/test/test_rebase.py +++ b/test/test_rebase.py @@ -1,7 +1,6 @@ import subprocess import pytest -from .conftest import GIT2CPP_TEST_WASM def test_rebase_basic(xtl_clone, commit_env_config, git2cpp_path, tmp_path): @@ -346,9 +345,7 @@ def test_rebase_no_upstream_error(xtl_clone, commit_env_config, git2cpp_path, tm rebase_cmd = [git2cpp_path, "rebase"] p_rebase = subprocess.run(rebase_cmd, capture_output=True, cwd=xtl_path, text=True) - if not GIT2CPP_TEST_WASM: - # TODO: fix this in wasm build - assert p_rebase.returncode != 0 + assert p_rebase.returncode != 0 assert "upstream is required for rebase" in p_rebase.stderr @@ -359,9 +356,7 @@ def test_rebase_invalid_upstream_error(xtl_clone, commit_env_config, git2cpp_pat rebase_cmd = [git2cpp_path, "rebase", "nonexistent-branch"] p_rebase = subprocess.run(rebase_cmd, capture_output=True, cwd=xtl_path, text=True) - if not GIT2CPP_TEST_WASM: - # TODO: fix this in wasm build - assert p_rebase.returncode != 0 + assert p_rebase.returncode != 0 assert "could not resolve upstream" in p_rebase.stderr or "could not resolve upstream" in p_rebase.stdout @@ -390,9 +385,7 @@ def test_rebase_already_in_progress_error(xtl_clone, commit_env_config, git2cpp_ # Try to start another rebase rebase_cmd = [git2cpp_path, "rebase", "master"] p_rebase = subprocess.run(rebase_cmd, capture_output=True, cwd=xtl_path, text=True) - if not GIT2CPP_TEST_WASM: - # TODO: fix this in wasm build - assert p_rebase.returncode != 0 + assert p_rebase.returncode != 0 assert "rebase is already in progress" in p_rebase.stderr or "rebase is already in progress" in p_rebase.stdout @@ -403,9 +396,7 @@ def test_rebase_continue_without_rebase_error(xtl_clone, commit_env_config, git2 continue_cmd = [git2cpp_path, "rebase", "--continue"] p_continue = subprocess.run(continue_cmd, capture_output=True, cwd=xtl_path, text=True) - if not GIT2CPP_TEST_WASM: - # TODO: fix this in wasm build - assert p_continue.returncode != 0 + assert p_continue.returncode != 0 assert "No rebase in progress" in p_continue.stderr or "No rebase in progress" in p_continue.stdout @@ -433,7 +424,5 @@ def test_rebase_continue_with_unresolved_conflicts(xtl_clone, commit_env_config, # Try to continue without resolving continue_cmd = [git2cpp_path, "rebase", "--continue"] p_continue = subprocess.run(continue_cmd, capture_output=True, cwd=xtl_path, text=True) - if not GIT2CPP_TEST_WASM: - # TODO: fix this in wasm build - assert p_continue.returncode != 0 + assert p_continue.returncode != 0 assert "resolve conflicts" in p_continue.stderr or "resolve conflicts" in p_continue.stdout diff --git a/test/test_reset.py b/test/test_reset.py index dd87829..d816afb 100644 --- a/test/test_reset.py +++ b/test/test_reset.py @@ -1,7 +1,6 @@ import subprocess import pytest -from .conftest import GIT2CPP_TEST_WASM def test_reset(xtl_clone, commit_env_config, git2cpp_path, tmp_path): @@ -37,7 +36,5 @@ def test_reset(xtl_clone, commit_env_config, git2cpp_path, tmp_path): def test_reset_nogit(git2cpp_path, tmp_path): cmd_reset = [git2cpp_path, "reset", "--hard", "HEAD~1"] p_reset = subprocess.run(cmd_reset, capture_output=True, cwd=tmp_path, text=True) - if not GIT2CPP_TEST_WASM: - # TODO: fix this in wasm build - assert p_reset.returncode != 0 + assert p_reset.returncode != 0 assert "error: could not find repository at" in p_reset.stderr diff --git a/test/test_status.py b/test/test_status.py index 7d4bb91..55b66df 100644 --- a/test/test_status.py +++ b/test/test_status.py @@ -3,7 +3,6 @@ import subprocess import pytest -from .conftest import GIT2CPP_TEST_WASM @pytest.mark.parametrize("short_flag", ["", "-s", "--short"]) @@ -43,9 +42,7 @@ def test_status_new_file(xtl_clone, git2cpp_path, tmp_path, short_flag, long_fla def test_status_nogit(git2cpp_path, tmp_path): cmd = [git2cpp_path, "status"] p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) - if not GIT2CPP_TEST_WASM: - # TODO: fix this in wasm build - assert p.returncode != 0 + assert p.returncode != 0 assert "error: could not find repository at" in p.stderr From e1814ee3bd29149075dd348e13e6578f9fc9eeb5 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Wed, 18 Feb 2026 18:00:59 +0100 Subject: [PATCH 11/35] update messages in status (#99) * update messages in status * address review comments * split run into smaller functions * address review comment --- src/subcommand/status_subcommand.cpp | 240 ++++++++++++++++-------- src/wrapper/repository_wrapper.cpp | 48 ++++- src/wrapper/repository_wrapper.hpp | 12 +- src/wrapper/status_wrapper.cpp | 11 +- test/test_status.py | 264 ++++++++++++++++++++++++++- 5 files changed, 490 insertions(+), 85 deletions(-) diff --git a/src/subcommand/status_subcommand.cpp b/src/subcommand/status_subcommand.cpp index 9e976eb..8bca1e5 100644 --- a/src/subcommand/status_subcommand.cpp +++ b/src/subcommand/status_subcommand.cpp @@ -28,10 +28,10 @@ status_subcommand::status_subcommand(const libgit2_object&, CLI::App& app) const std::string untracked_header = "Untracked files:\n (use \"git add ...\" to include in what will be committed)\n"; const std::string tobecommited_header = "Changes to be committed:\n (use \"git reset HEAD ...\" to unstage)\n"; const std::string ignored_header = "Ignored files:\n (use \"git add -f ...\" to include in what will be committed)\n"; -const std::string notstagged_header = "Changes not staged for commit:\n"; -// "Changes not staged for commit:\n (use \"git add%s ...\" to update what will be committed)\n (use \"git checkout -- ...\" to discard changes in working directory)\n" +const std::string notstagged_header = "Changes not staged for commit:\n (use \"git add ...\" to update what will be committed)\n"; +// TODO: add the following ot notstagged_header after "checkout " is implemented: (use \"git checkout -- ...\" to discard changes in working directory)\n"; const std::string unmerged_header = "Unmerged paths:\n (use \"git add ...\" to mark resolution)\n"; -// const std::string nothingtocommit_message = "No changes added to commit (use \"git add\" and/or \"git commit -a\")"; +const std::string nothingtocommit_message = "no changes added to commit (use \"git add\" and/or \"git commit -a\")"; const std::string treeclean_message = "Nothing to commit, working tree clean"; enum class output_format @@ -161,6 +161,154 @@ void print_not_tracked(const std::vector& entries_to_print, const s print_entries(not_tracked_entries_to_print, is_long, colour); } +void print_tracking_info(repository_wrapper& repo, status_list_wrapper& sl, status_subcommand_options options, bool is_long) +{ + auto branch_name = repo.head_short_name(); + auto tracking_info = repo.get_tracking_info(); + + if (is_long) + { + std::cout << "On branch " << branch_name << std::endl; + + if (tracking_info.has_upstream) + { + if(tracking_info.ahead > 0 && tracking_info.behind == 0) + { + std::cout << "Your branch is ahead of '" << tracking_info.upstream_name << "' by " + << tracking_info.ahead << " commit" + << (tracking_info.ahead > 1 ? "s" : "") << "." << std::endl; + std::cout << " (use \"git push\" to publish your local commits)" << std::endl; + } + else if (tracking_info.ahead == 0 && tracking_info.behind > 0) + { + std::cout << "Your branch is behind '" << tracking_info.upstream_name << "' by " + << tracking_info.behind << " commit" + << (tracking_info.behind > 1 ? "s" : "") << "." << std::endl; + std::cout << " (use \"git pull\" to update your local branch)" << std::endl; + } + else if (tracking_info.ahead > 0 && tracking_info.behind > 0) + { + std::cout << "Your branch and '" << tracking_info.upstream_name + << "' have diverged," << std::endl; + std::cout << "and have " << tracking_info.ahead << " and " + << tracking_info.behind << " different commit" + << ((tracking_info.ahead + tracking_info.behind) > 2 ? "s" : "") + << " each, respectively." << std::endl; + std::cout << " (use \"git pull\" to merge the remote branch into yours)" << std::endl; + } + else // ahead == 0 && behind == 0 + { + std::cout << "Your branch is up to date with '" << tracking_info.upstream_name << "'." << std::endl; + } + std::cout << std::endl; + } + + if (repo.is_head_unborn()) + { + std::cout << "No commit yet\n" << std::endl; + } + + if (sl.has_unmerged_header()) + { + std::cout << "You have unmerged paths.\n (fix conflicts and run \"git commit\")\n (use \"git merge --abort\" to abort the merge)\n" << std::endl; + } + } + else + { + if (options.m_branch_flag) + { + std::cout << "## " << branch_name << std::endl; + } + + if (tracking_info.has_upstream) + { + std::cout << "..." << tracking_info.upstream_name; + + if (tracking_info.ahead > 0 || tracking_info.behind > 0) + { + std::cout << " ["; + if (tracking_info.ahead > 0) + { + std::cout << "ahead " << tracking_info.ahead; + } + if (tracking_info.behind > 0) + { + if (tracking_info.ahead > 0) + { + std::cout << ", "; + } + std::cout << "behind " << tracking_info.behind; + } + std::cout << "]"; + } + std::cout << std::endl; + } + } +} + +void print_tobecommited(status_list_wrapper& sl, output_format of, std::set tracked_dir_set, bool is_long) +{ + stream_colour_fn colour = termcolor::green; + if (is_long) + { + std::cout << tobecommited_header; + } + print_entries(get_entries_to_print(GIT_STATUS_INDEX_NEW, sl, true, of, &tracked_dir_set), is_long, colour); + print_entries(get_entries_to_print(GIT_STATUS_INDEX_MODIFIED, sl, true, of, &tracked_dir_set), is_long, colour); + print_entries(get_entries_to_print(GIT_STATUS_INDEX_DELETED, sl, true, of, &tracked_dir_set), is_long, colour); + print_entries(get_entries_to_print(GIT_STATUS_INDEX_RENAMED, sl, true, of, &tracked_dir_set), is_long, colour); + print_entries(get_entries_to_print(GIT_STATUS_INDEX_TYPECHANGE, sl, true, of, &tracked_dir_set), is_long, colour); + if (is_long) + { + std::cout << std::endl; + } +} + +void print_notstagged(status_list_wrapper& sl, output_format of, std::set tracked_dir_set, bool is_long) +{ + stream_colour_fn colour = termcolor::red; + if (is_long) + { + std::cout << notstagged_header; + } + print_entries(get_entries_to_print(GIT_STATUS_WT_MODIFIED, sl, false, of, &tracked_dir_set), is_long, colour); + print_entries(get_entries_to_print(GIT_STATUS_WT_DELETED, sl, false, of, &tracked_dir_set), is_long, colour); + print_entries(get_entries_to_print(GIT_STATUS_WT_TYPECHANGE, sl, false, of, &tracked_dir_set), is_long, colour); + print_entries(get_entries_to_print(GIT_STATUS_WT_RENAMED, sl, false, of, &tracked_dir_set), is_long, colour); + if (is_long) + { + std::cout << std::endl; + } +} + +void print_unmerged(status_list_wrapper& sl, output_format of, std::set tracked_dir_set, std::set untracked_dir_set, bool is_long) +{ + stream_colour_fn colour = termcolor::red; + if (is_long) + { + std::cout << unmerged_header; + } + print_not_tracked(get_entries_to_print(GIT_STATUS_CONFLICTED, sl, false, of), tracked_dir_set, untracked_dir_set, is_long, colour); + if (is_long) + { + std::cout << std::endl; + } +} + +void print_untracked(status_list_wrapper& sl, output_format of, std::set tracked_dir_set, std::set untracked_dir_set, bool is_long) +{ + stream_colour_fn colour = termcolor::red; + if (is_long) + { + std::cout << untracked_header; + } + print_not_tracked(get_entries_to_print(GIT_STATUS_WT_NEW, sl, false, of), tracked_dir_set, untracked_dir_set, is_long, colour); + if (is_long) + { + std::cout << std::endl; + } +} + void status_subcommand::run() { status_run(m_options); @@ -171,7 +319,6 @@ void status_run(status_subcommand_options options) auto directory = get_current_git_path(); auto repo = repository_wrapper::open(directory); auto sl = status_list_wrapper::status_list(repo); - auto branch_name = repo.head_short_name(); std::set tracked_dir_set{}; std::set untracked_dir_set{}; @@ -194,98 +341,37 @@ void status_run(status_subcommand_options options) bool is_long; is_long = ((of == output_format::DEFAULT) || (of == output_format::LONG)); - if (is_long) - { - std::cout << "On branch " << branch_name << "\n" << std::endl; - - if (repo.is_head_unborn()) - { - std::cout << "No commits yet\n" << std::endl; - } - - if (sl.has_unmerged_header()) - { - std::cout << "You have unmerged paths.\n (fix conflicts and run \"git commit\")\n (use \"git merge --abort\" to abort the merge)\n" << std::endl; - } - } - else - { - if (options.m_branch_flag) - { - std::cout << "## " << branch_name << std::endl; - } - } + print_tracking_info(repo, sl, options, is_long); if (sl.has_tobecommited_header()) { - stream_colour_fn colour = termcolor::green; - if (is_long) - { - std::cout << tobecommited_header; - } - print_entries(get_entries_to_print(GIT_STATUS_INDEX_NEW, sl, true, of, &tracked_dir_set), is_long, colour); - print_entries(get_entries_to_print(GIT_STATUS_INDEX_MODIFIED, sl, true, of, &tracked_dir_set), is_long, colour); - print_entries(get_entries_to_print(GIT_STATUS_INDEX_DELETED, sl, true, of, &tracked_dir_set), is_long, colour); - print_entries(get_entries_to_print(GIT_STATUS_INDEX_RENAMED, sl, true, of, &tracked_dir_set), is_long, colour); - print_entries(get_entries_to_print(GIT_STATUS_INDEX_TYPECHANGE, sl, true, of, &tracked_dir_set), is_long, colour); - if (is_long) - { - std::cout << std::endl; - } + print_tobecommited(sl, of, tracked_dir_set,is_long); } if (sl.has_notstagged_header()) { - stream_colour_fn colour = termcolor::red; - if (is_long) - { - std::cout << notstagged_header; - } - print_entries(get_entries_to_print(GIT_STATUS_WT_MODIFIED, sl, false, of, &tracked_dir_set), is_long, colour); - print_entries(get_entries_to_print(GIT_STATUS_WT_DELETED, sl, false, of, &tracked_dir_set), is_long, colour); - print_entries(get_entries_to_print(GIT_STATUS_WT_TYPECHANGE, sl, false, of, &tracked_dir_set), is_long, colour); - print_entries(get_entries_to_print(GIT_STATUS_WT_RENAMED, sl, false, of, &tracked_dir_set), is_long, colour); - if (is_long) - { - std::cout << std::endl; - } + print_notstagged(sl, of, tracked_dir_set, is_long); } // TODO: check if should be printed before "not stagged" files if (sl.has_unmerged_header()) { - stream_colour_fn colour = termcolor::red; - if (is_long) - { - std::cout << unmerged_header; - } - print_not_tracked(get_entries_to_print(GIT_STATUS_CONFLICTED, sl, false, of), tracked_dir_set, untracked_dir_set, is_long, colour); - if (is_long) - { - std::cout << std::endl; - } + print_unmerged(sl, of, tracked_dir_set, untracked_dir_set, is_long); } if (sl.has_untracked_header()) { - stream_colour_fn colour = termcolor::red; - if (is_long) - { - std::cout << untracked_header; - } - print_not_tracked(get_entries_to_print(GIT_STATUS_WT_NEW, sl, false, of), tracked_dir_set, untracked_dir_set, is_long, colour); - if (is_long) - { - std::cout << std::endl; - } + print_untracked(sl, of, tracked_dir_set, untracked_dir_set, is_long); } // TODO: check if this message should be displayed even if there are untracked files - if (!(sl.has_tobecommited_header() | sl.has_notstagged_header() | sl.has_unmerged_header() | sl.has_untracked_header())) + if (is_long && !(sl.has_tobecommited_header() || sl.has_notstagged_header() || sl.has_unmerged_header() || sl.has_untracked_header())) { - if (is_long) - { - std::cout << treeclean_message << std::endl; - } + std::cout << treeclean_message << std::endl; + } + + if (is_long & !sl.has_tobecommited_header() && (sl.has_notstagged_header() || sl.has_untracked_header())) + { + std::cout << nothingtocommit_message << std::endl; } } diff --git a/src/wrapper/repository_wrapper.cpp b/src/wrapper/repository_wrapper.cpp index 2050ffe..0e9c05b 100644 --- a/src/wrapper/repository_wrapper.cpp +++ b/src/wrapper/repository_wrapper.cpp @@ -168,6 +168,53 @@ branch_iterator repository_wrapper::iterate_branches(git_branch_t type) const return branch_iterator(iter); } +std::optional repository_wrapper::upstream() const +{ + git_reference* ref; + reference_wrapper head = this->head(); + int error = git_branch_upstream(&ref, head); + if (error == 0) + { + return reference_wrapper(ref); + } + else + { + return std::nullopt; + } +} + +branch_tracking_info repository_wrapper::get_tracking_info() const +{ + branch_tracking_info info; + info.has_upstream = false; + info.ahead = 0; + info.behind = 0; + info.upstream_name = ""; + + if (this->is_head_unborn()) + { + return info; + } + + reference_wrapper head = this->head(); + std::optional upstream = this->upstream(); + + if (upstream) + { + info.has_upstream = true; + info.upstream_name = upstream.value().short_name(); + + auto local_oid = head.target(); + auto upstream_oid = upstream.value().target(); + + if (local_oid && upstream_oid) + { + git_graph_ahead_behind(&info.ahead, &info.behind, *this, local_oid, upstream_oid); + } + } + return info; +} + // Commits commit_wrapper repository_wrapper::find_commit(std::string_view ref_name) const @@ -458,7 +505,6 @@ config_wrapper repository_wrapper::get_config() return config_wrapper(cfg); } - // Diff diff_wrapper repository_wrapper::diff_tree_to_index(tree_wrapper old_tree, std::optional index, git_diff_options* diffopts) diff --git a/src/wrapper/repository_wrapper.hpp b/src/wrapper/repository_wrapper.hpp index 428f64f..25e922b 100644 --- a/src/wrapper/repository_wrapper.hpp +++ b/src/wrapper/repository_wrapper.hpp @@ -22,6 +22,14 @@ #include "../wrapper/tree_wrapper.hpp" #include "../wrapper/wrapper_base.hpp" +struct branch_tracking_info +{ + bool has_upstream; + std::string upstream_name; + size_t ahead; + size_t behind; +}; + class repository_wrapper : public wrapper_base { public: @@ -62,10 +70,10 @@ class repository_wrapper : public wrapper_base branch_wrapper create_branch(std::string_view name, bool force); branch_wrapper create_branch(std::string_view name, const commit_wrapper& commit, bool force); branch_wrapper create_branch(std::string_view name, const annotated_commit_wrapper& commit, bool force); - branch_wrapper find_branch(std::string_view name) const; - branch_iterator iterate_branches(git_branch_t type) const; + std::optional upstream() const; + branch_tracking_info get_tracking_info() const; // Commits commit_wrapper find_commit(std::string_view ref_name = "HEAD") const; diff --git a/src/wrapper/status_wrapper.cpp b/src/wrapper/status_wrapper.cpp index b45395e..8f68e1c 100644 --- a/src/wrapper/status_wrapper.cpp +++ b/src/wrapper/status_wrapper.cpp @@ -9,8 +9,16 @@ status_list_wrapper::~status_list_wrapper() status_list_wrapper status_list_wrapper::status_list(const repository_wrapper& rw) { + git_status_options opts = GIT_STATUS_OPTIONS_INIT; + opts.show = GIT_STATUS_SHOW_INDEX_AND_WORKDIR; + opts.flags = GIT_STATUS_OPT_INCLUDE_UNTRACKED | + GIT_STATUS_OPT_RENAMES_HEAD_TO_INDEX | + GIT_STATUS_OPT_RENAMES_INDEX_TO_WORKDIR | + GIT_STATUS_OPT_SORT_CASE_SENSITIVELY; + opts.rename_threshold = 50; + status_list_wrapper res; - throw_if_error(git_status_list_new(&(res.p_resource), rw, nullptr)); + throw_if_error(git_status_list_new(&(res.p_resource), rw, &opts)); std::size_t status_list_size = git_status_list_entrycount(res.p_resource); for (std::size_t i = 0; i < status_list_size; ++i) @@ -83,4 +91,3 @@ auto status_list_wrapper::get_entry_list(git_status_t status) const -> const sta return m_empty; } } - diff --git a/test/test_status.py b/test/test_status.py index 55b66df..b6fb075 100644 --- a/test/test_status.py +++ b/test/test_status.py @@ -24,7 +24,7 @@ def test_status_new_file(xtl_clone, git2cpp_path, tmp_path, short_flag, long_fla cmd.append(short_flag) if long_flag != "": cmd.append(long_flag) - p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True, check=True) if (long_flag == "--long") or ((long_flag == "") & (short_flag == "")): assert "On branch master" in p.stdout @@ -89,6 +89,264 @@ def test_status_new_repo(git2cpp_path, tmp_path, run_in_tmp_path): p = subprocess.run(cmd, cwd=tmp_path) assert p.returncode == 0 - status_cmd = [git2cpp_path, "status"] - p_status = subprocess.run(status_cmd, cwd=tmp_path) + cmd_status = [git2cpp_path, "status"] + p_status = subprocess.run(cmd_status, capture_output=True, cwd=tmp_path, text=True) assert p_status.returncode == 0 + assert "On branch ma" in p_status.stdout # "main" locally, but "master" in the CI + assert "No commit yet" in p_status.stdout + assert "Nothing to commit, working tree clean" in p_status.stdout + + +def test_status_clean_tree(xtl_clone, git2cpp_path, tmp_path): + """Test 'Nothing to commit, working tree clean' message""" + xtl_path = tmp_path / "xtl" + + cmd = [git2cpp_path, "status"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + + assert p.returncode == 0 + assert "On branch master" in p.stdout + assert "Nothing to commit, working tree clean" in p.stdout + + +@pytest.mark.parametrize("short_flag", ["", "-s"]) +def test_status_rename_detection(xtl_clone, git2cpp_path, tmp_path, short_flag): + """Test that renamed files are detected correctly""" + xtl_path = tmp_path / "xtl" + + # Rename a file using git mv or by moving and staging + old_readme = xtl_path / "README.md" + new_readme = xtl_path / "README_renamed.md" + + # Move the README file + os.rename(old_readme, new_readme) + + # Move/rename the LICENCE file using mv + cmd_mv = [git2cpp_path, "mv", "LICENSE", "LICENSE_renamed"] + subprocess.run(cmd_mv, capture_output=True, cwd=xtl_path, check=True) + + # Stage both the deletion and addition + cmd_add = [git2cpp_path, "add", "--all"] + subprocess.run(cmd_add, capture_output=True, cwd=xtl_path, check=True) + + # Check status + cmd_status = [git2cpp_path, "status"] + if short_flag == "-s": + cmd_status.append(short_flag) + p = subprocess.run(cmd_status, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + + # Should show as renamed, not as deleted + new file + assert "README.md -> README_renamed.md" in p.stdout + assert "LICENSE -> LICENSE_renamed" in p.stdout + if short_flag == "-s": + assert "R " in p.stdout + else: + assert "renamed:" in p.stdout + + +@pytest.mark.parametrize("short_flag", ["", "-s"]) +def test_status_mixed_changes(xtl_clone, git2cpp_path, tmp_path, short_flag): + """Test status with both staged and unstaged changes""" + xtl_path = tmp_path / "xtl" + + # Create a new file and stage it + staged_file = xtl_path / "staged.txt" + staged_file.write_text("staged content") + + # Deleted a file staged + del_file = xtl_path / "README.md" + os.remove(del_file) + + # Stage the two previous files + subprocess.run([git2cpp_path, "add", "staged.txt", "README.md"], cwd=xtl_path, check=True) + + # Modify an existing file without staging + unstaged_file = xtl_path / "CMakeLists.txt" + unstaged_file.write_text("unstaged changes") + + # Create an untracked file + untracked_file = xtl_path / "untracked.txt" + untracked_file.write_text("untracked") + + cmd_status = [git2cpp_path, "status"] + if short_flag == "-s": + cmd_status.append(short_flag) + p = subprocess.run(cmd_status, capture_output=True, cwd=xtl_path, text=True) + + assert p.returncode == 0 + if short_flag == "-s": + assert "A staged.txt" in p.stdout + assert "D README.md" in p.stdout + assert " M CMakeLists.txt" in p.stdout + assert "?? untracked.txt" in p.stdout + else: + assert "Changes to be committed" in p.stdout + assert "new file: staged.txt" in p.stdout + assert "deleted: README.md" in p.stdout + assert "Changes not staged for commit" in p.stdout + assert "modified: CMakeLists.txt" in p.stdout + assert "Untracked files" in p.stdout + assert "untracked.txt" in p.stdout + + +@pytest.mark.parametrize("short_flag", ["", "-s"]) +def test_status_typechange(xtl_clone, git2cpp_path, tmp_path, short_flag): + """Test status shows typechange (file to symlink or vice versa)""" + xtl_path = tmp_path / "xtl" + + # Remove a file and replace with a symlink + test_file = xtl_path / "README.md" + os.remove(test_file) + os.symlink("CMakeLists.txt", test_file) + + cmd_status = [git2cpp_path, "status"] + if short_flag == "-s": + cmd_status.append(short_flag) + p = subprocess.run(cmd_status, capture_output=True, cwd=xtl_path, text=True) + + assert p.returncode == 0 + # Should show typechange in unstaged changes + if short_flag == "-s": + assert " T " in p.stdout + else: + assert "Changes not staged for commit" in p.stdout + + +@pytest.mark.parametrize("short_flag", ["", "-s"]) +def test_status_untracked_directory(xtl_clone, git2cpp_path, tmp_path, short_flag): + """Test that untracked directories are shown with trailing slash""" + xtl_path = tmp_path / "xtl" + + # Create a directory with files + new_dir = xtl_path / "new_directory" + new_dir.mkdir() + (new_dir / "file1.txt").write_text("content1") + (new_dir / "file2.txt").write_text("content2") + + cmd_status = [git2cpp_path, "status"] + if short_flag == "-s": + cmd_status.append(short_flag) + p = subprocess.run(cmd_status, capture_output=True, cwd=xtl_path, text=True) + + assert p.returncode == 0 + if short_flag == "-s": + assert "?? " in p.stdout + else: + assert "Untracked files" in p.stdout + # Directory should be shown with trailing slash, not individual files + assert "new_directory/" in p.stdout + assert "file1.txt" not in p.stdout + assert "file2.txt" not in p.stdout + + +@pytest.mark.parametrize("short_flag", ["", "-s"]) +def test_status_ahead_of_upstream(commit_env_config, git2cpp_path, tmp_path, short_flag): + """Test status when local branch is ahead of upstream""" + # Create a repository with remote tracking + repo_path = tmp_path / "repo" + repo_path.mkdir() + + # Initialize repo + subprocess.run([git2cpp_path, "init"], cwd=repo_path, check=True) + + # Create initial commit + test_file = repo_path / "file.txt" + test_file.write_text("initial") + subprocess.run([git2cpp_path, "add", "file.txt"], cwd=repo_path, check=True) + subprocess.run([git2cpp_path, "commit", "-m", "initial"], cwd=repo_path, check=True) + + # Clone it to create remote tracking + clone_path = tmp_path / "clone" + subprocess.run(["git", "clone", str(repo_path), str(clone_path)], check=True) + + # Make a commit in clone + clone_file = clone_path / "file2.txt" + clone_file.write_text("new file") + subprocess.run([git2cpp_path, "add", "file2.txt"], cwd=clone_path, check=True) + subprocess.run([git2cpp_path, "commit", "-m", "second commit"], cwd=clone_path, check=True) + + # Check status + cmd_status = [git2cpp_path, "status"] + if (short_flag == "-s"): + cmd_status.append(short_flag) + p = subprocess.run(cmd_status, capture_output=True, cwd=clone_path, text=True) + + assert p.returncode == 0 + if short_flag == "-s": + assert "...origin/ma" in p.stdout # "main" locally, but "master" in the CI + assert "[ahead 1]" in p.stdout + else: + assert "Your branch is ahead of" in p.stdout + assert "by 1 commit" in p.stdout + assert 'use "git push"' in p.stdout + + +@pytest.mark.parametrize("short_flag", ["", "-s"]) +@pytest.mark.parametrize("branch_flag", ["-b", "--branch"]) +def test_status_with_branch_and_tracking(commit_env_config, git2cpp_path, tmp_path, short_flag, branch_flag): + """Test short format with branch flag shows tracking info""" + # Create a repository with remote tracking + repo_path = tmp_path / "repo" + repo_path.mkdir() + + subprocess.run([git2cpp_path, "init"], cwd=repo_path) + test_file = repo_path / "file.txt" + test_file.write_text("initial") + subprocess.run([git2cpp_path, "add", "file.txt"], cwd=repo_path, check=True) + subprocess.run([git2cpp_path, "commit", "-m", "initial"], cwd=repo_path, check=True) + + # Clone it + clone_path = tmp_path / "clone" + subprocess.run(["git", "clone", str(repo_path), str(clone_path)], check=True) + + # Make a commit + clone_file = clone_path / "file2.txt" + clone_file.write_text("new") + subprocess.run([git2cpp_path, "add", "file2.txt"], cwd=clone_path, check=True) + subprocess.run([git2cpp_path, "commit", "-m", "second"], cwd=clone_path, check=True) + + # Check short status with branch flag + cmd_status = [git2cpp_path, "status", branch_flag] + if short_flag == "-s": + cmd_status.append(short_flag) + p = subprocess.run(cmd_status, capture_output=True, cwd=clone_path, text=True) + + assert p.returncode == 0 + if short_flag == "-s": + assert "## ma" in p.stdout # "main" locally, but "master" in the CI + assert "[ahead 1]" in p.stdout + else: + assert "On branch ma" in p.stdout # "main" locally, but "master" in the CI + assert "Your branch is ahead of 'origin/ma" in p.stdout # "main" locally, but "master" in the CI + assert "1 commit." in p.stdout + + +def test_status_all_headers_shown(xtl_clone, git2cpp_path, tmp_path): + """Test that all status headers can be shown together""" + xtl_path = tmp_path / "xtl" + + # Changes to be committed + staged = xtl_path / "staged.txt" + staged.write_text("staged") + subprocess.run([git2cpp_path, "add", "staged.txt"], cwd=xtl_path, check=True) + + # Changes not staged + modified = xtl_path / "CMakeLists.txt" + modified.write_text("modified") + + # Untracked + untracked = xtl_path / "untracked.txt" + untracked.write_text("untracked") + + cmd_status = [git2cpp_path, "status"] + p = subprocess.run(cmd_status, capture_output=True, cwd=xtl_path, text=True) + + assert p.returncode == 0 + assert "On branch master" in p.stdout + assert "Changes to be committed:" in p.stdout + assert 'use "git reset HEAD ..." to unstage' in p.stdout + assert "Changes not staged for commit:" in p.stdout + assert 'use "git add ..." to update what will be committed' in p.stdout + assert "Untracked files:" in p.stdout + assert 'use "git add ..." to include in what will be committed' in p.stdout From 8c48282e706f89558eb56cf66de302543ac3ccc8 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Thu, 19 Feb 2026 14:00:46 +0000 Subject: [PATCH 12/35] Add test for commit message via stdin (#101) --- test/test_commit.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/test_commit.py b/test/test_commit.py index f7b17a6..c628d78 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -32,3 +32,37 @@ def test_commit(xtl_clone, commit_env_config, git2cpp_path, tmp_path, all_flag): ) assert p_status_2.returncode == 0 assert "mook_file" not in p_status_2.stdout + + +@pytest.mark.parametrize("commit_msg", ["Added file", ""]) +def test_commit_message_via_stdin(commit_env_config, git2cpp_path, tmp_path, run_in_tmp_path, commit_msg): + cmd = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd) + assert p_init.returncode == 0 + + (tmp_path / "file.txt").write_text("Some text") + + cmd_add = [git2cpp_path, "add", "file.txt"] + p_add = subprocess.run(cmd_add) + assert p_add.returncode == 0 + + cmd_commit = [git2cpp_path, "commit"] + p_commit = subprocess.run(cmd_commit, text=True, capture_output=True, input=commit_msg) + + if commit_msg == "": + # No commit message + assert p_commit.returncode != 0 + assert "Aborting, no commit message specified" in p_commit.stderr + else: + # Valid commit message + assert p_commit.returncode == 0 + + cmd_log = [git2cpp_path, "log"] + p_log = subprocess.run(cmd_log, text=True, capture_output=True) + assert p_log.returncode == 0 + lines = p_log.stdout.splitlines() + + assert "commit" in lines[0] + assert "Author:" in lines[1] + assert "Date" in lines[2] + assert commit_msg in lines[4] From e49d94ea083eaee701818368877b7cbd66c8a49f Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Thu, 19 Feb 2026 16:09:45 +0000 Subject: [PATCH 13/35] Add CI run to gather code coverage (#73) * Add CI run to gather code coverage * Upload to codecov * Use CODECOV_TOKEN --- .github/workflows/test.yml | 47 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1224b15..c760934 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,3 +51,50 @@ jobs: run: | pytest -v + coverage: + name: 'Test coverage' + runs-on: ubuntu-latest + steps: + - name: Checkout source + uses: actions/checkout@v4 + 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 + 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@v6 + with: + name: coverage_report + path: outdir + + - name: Upload coverage to codecov + uses: codecov/codecov-action@v5 + with: + files: coverage.lcov + token: ${{ secrets.CODECOV_TOKEN }} + verbose: true From c460fcebca2ce68f4ee53f99b0fa02e42bf4a3e1 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Fri, 20 Feb 2026 08:24:31 +0000 Subject: [PATCH 14/35] Support use of local CORS proxy when serving the wasm deployments (#102) --- wasm/.gitignore | 2 +- wasm/CMakeLists.txt | 6 ++---- wasm/README.md | 9 +++++++++ wasm/cockle-deploy/CMakeLists.txt | 2 +- wasm/cockle-deploy/package.json | 8 ++++---- wasm/cockle-deploy/rspack.config.js | 2 +- wasm/cockle-deploy/src/defs.ts | 1 + wasm/cockle-deploy/src/deployment.ts | 11 ++++++++--- wasm/cockle-deploy/src/index.ts | 19 +++++++++++++++++-- wasm/cockle-deploy/tsconfig.json | 2 +- wasm/lite-deploy/CMakeLists.txt | 2 +- wasm/serve/CMakeLists.txt | 8 ++++++++ wasm/serve/{ => dist}/index.html | 0 wasm/serve/package.json | 17 +++++++++++++++++ 14 files changed, 71 insertions(+), 18 deletions(-) create mode 100644 wasm/serve/CMakeLists.txt rename wasm/serve/{ => dist}/index.html (100%) create mode 100644 wasm/serve/package.json diff --git a/wasm/.gitignore b/wasm/.gitignore index 8f8b10f..838f04a 100644 --- a/wasm/.gitignore +++ b/wasm/.gitignore @@ -11,6 +11,6 @@ package-lock.json cockle/ lite-deploy/package.json recipe/em-forge-recipes/ -serve/*/ +serve/dist/*/ test/assets/*/ test/lib/ diff --git a/wasm/CMakeLists.txt b/wasm/CMakeLists.txt index a6d0df5..63d2b54 100644 --- a/wasm/CMakeLists.txt +++ b/wasm/CMakeLists.txt @@ -7,17 +7,15 @@ option(USE_COCKLE_RELEASE "Use latest cockle release rather than repo main branc add_subdirectory(recipe) add_subdirectory(cockle-deploy) add_subdirectory(lite-deploy) +add_subdirectory(serve) add_subdirectory(test) # Build everything (package, cockle and lite deployments, tests). -add_custom_target(build ALL DEPENDS build-recipe build-cockle build-lite build-test) +add_custom_target(build ALL DEPENDS build-recipe build-cockle build-lite build-serve build-test) # Rebuild after change in C++ code. add_custom_target(rebuild DEPENDS rebuild-recipe rebuild-cockle rebuild-lite rebuild-test) -# Serve both cockle and JupyterLite deployments. -add_custom_target(serve COMMAND npx static-handler --cors --coop --coep --corp serve) - if (USE_COCKLE_RELEASE) execute_process(COMMAND npm view @jupyterlite/cockle version OUTPUT_VARIABLE COCKLE_BRANCH) set(COCKLE_BRANCH "v${COCKLE_BRANCH}") diff --git a/wasm/README.md b/wasm/README.md index 75b828c..b8f94a8 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -65,6 +65,15 @@ Note that the `source` for the `git2cpp` package is the local filesystem rather version number of the current Emscripten-forge recipe rather than the version of the local `git2cpp` source code which can be checked using `git2cpp -v` at the `cockle`/`terminal` command line. +If you want to remotely access git servers using `git2cpp` subcommands such as `clone`, `fetch` +and `push`, then serve with a local CORS proxy using: + +```bash +make serve-with-cors +``` + +which will use the CORS proxy on `http://localhost:8881/`. + ## Rebuild After making changes to the local `git2cpp` source code you can rebuild the WebAssembly package, diff --git a/wasm/cockle-deploy/CMakeLists.txt b/wasm/cockle-deploy/CMakeLists.txt index 6a11bf9..ff5d26d 100644 --- a/wasm/cockle-deploy/CMakeLists.txt +++ b/wasm/cockle-deploy/CMakeLists.txt @@ -3,7 +3,7 @@ project(git2cpp-wasm-cockle-deploy) include("../common.cmake") -set(SERVE_DIR "../serve/cockle") +set(SERVE_DIR "../serve/dist/cockle") add_custom_target(build-cockle DEPENDS build-recipe cockle diff --git a/wasm/cockle-deploy/package.json b/wasm/cockle-deploy/package.json index 2700383..0c93ce2 100644 --- a/wasm/cockle-deploy/package.json +++ b/wasm/cockle-deploy/package.json @@ -3,11 +3,11 @@ "scripts": { "build": "rspack build", "postbuild": "npm run postbuild:wasm && npm run postbuild:index", - "postbuild:wasm": "node node_modules/@jupyterlite/cockle/lib/tools/prepare_wasm.js --copy ../serve/cockle/", - "postbuild:index": "cp assets/index.html ../serve/cockle/" + "postbuild:wasm": "node node_modules/@jupyterlite/cockle/lib/tools/prepare_wasm.js --copy ../serve/dist/cockle/", + "postbuild:index": "cp assets/index.html ../serve/dist/cockle/" }, - "main": "../serve/cockle/index.js", - "types": "../serve/cockle/index.d.ts", + "main": "../serve/dist/cockle/index.js", + "types": "../serve/dist/cockle/index.d.ts", "keywords": [], "author": "", "license": "ISC", diff --git a/wasm/cockle-deploy/rspack.config.js b/wasm/cockle-deploy/rspack.config.js index d6e29ff..7db8166 100644 --- a/wasm/cockle-deploy/rspack.config.js +++ b/wasm/cockle-deploy/rspack.config.js @@ -24,6 +24,6 @@ module.exports = { }, output: { filename: 'bundle.js', - path: path.resolve(__dirname, '../serve/cockle'), + path: path.resolve(__dirname, '../serve/dist/cockle'), } }; diff --git a/wasm/cockle-deploy/src/defs.ts b/wasm/cockle-deploy/src/defs.ts index a44acd7..4675963 100644 --- a/wasm/cockle-deploy/src/defs.ts +++ b/wasm/cockle-deploy/src/defs.ts @@ -6,5 +6,6 @@ export namespace IDeployment { browsingContextId: string; shellManager: IShellManager; targetDiv: HTMLElement; + useLocalCors?: string; } } diff --git a/wasm/cockle-deploy/src/deployment.ts b/wasm/cockle-deploy/src/deployment.ts index e10b1ca..b98a27f 100644 --- a/wasm/cockle-deploy/src/deployment.ts +++ b/wasm/cockle-deploy/src/deployment.ts @@ -1,4 +1,4 @@ -import { Shell } from '@jupyterlite/cockle' +import { IShell, Shell } from '@jupyterlite/cockle' import { FitAddon } from '@xterm/addon-fit' import { Terminal } from '@xterm/xterm' import { IDeployment } from './defs' @@ -22,13 +22,18 @@ export class Deployment { const { baseUrl, browsingContextId, shellManager } = options; - this._shell = new Shell({ + const shellOptions: IShell.IOptions = { browsingContextId, baseUrl, wasmBaseUrl: baseUrl, shellManager, outputCallback: this.outputCallback.bind(this), - }) + }; + if (options.useLocalCors) { + shellOptions.environment = { GIT_CORS_PROXY: options.useLocalCors }; + } + + this._shell = new Shell(shellOptions); } async start(): Promise { diff --git a/wasm/cockle-deploy/src/index.ts b/wasm/cockle-deploy/src/index.ts index ac63e63..95b6ab7 100644 --- a/wasm/cockle-deploy/src/index.ts +++ b/wasm/cockle-deploy/src/index.ts @@ -1,13 +1,28 @@ import { ShellManager } from '@jupyterlite/cockle'; import "./style/deployment.css" +import { IDeployment } from './defs'; import { Deployment } from "./deployment"; document.addEventListener("DOMContentLoaded", async () => { const baseUrl = window.location.href; const shellManager = new ShellManager(); const browsingContextId = await shellManager.installServiceWorker(baseUrl); - const targetDiv: HTMLElement = document.getElementById('targetdiv')!; - const playground = new Deployment({ baseUrl, browsingContextId, shellManager, targetDiv }); + + const options: IDeployment.IOptions = { + baseUrl, browsingContextId, shellManager, targetDiv + } + + // See if a local CORS proxy is available where we are expecting it by checking if it is there. + // This isn't good practice but is OK for local manual testing. + try { + const corsProxy = "http://localhost:8881/" + const response = await fetch(corsProxy); + if (response.ok && response.type == "cors") { + options.useLocalCors = corsProxy; + } + } catch (error) {} + + const playground = new Deployment(options); await playground.start(); }) diff --git a/wasm/cockle-deploy/tsconfig.json b/wasm/cockle-deploy/tsconfig.json index 89b3e89..4ab9763 100644 --- a/wasm/cockle-deploy/tsconfig.json +++ b/wasm/cockle-deploy/tsconfig.json @@ -13,7 +13,7 @@ "noUnusedLocals": true, "preserveWatchOutput": true, "resolveJsonModule": true, - "outDir": "../serve/cockle", + "outDir": "../serve/dist/cockle", "rootDir": "src", "strict": true, "strictNullChecks": true, diff --git a/wasm/lite-deploy/CMakeLists.txt b/wasm/lite-deploy/CMakeLists.txt index eb43121..f07c7f7 100644 --- a/wasm/lite-deploy/CMakeLists.txt +++ b/wasm/lite-deploy/CMakeLists.txt @@ -3,7 +3,7 @@ project(git2cpp-wasm-lite-deploy) include("../common.cmake") -set(SERVE_DIR "../serve/lite") +set(SERVE_DIR "../serve/dist/lite") add_custom_target(build-lite DEPENDS build-recipe cockle diff --git a/wasm/serve/CMakeLists.txt b/wasm/serve/CMakeLists.txt new file mode 100644 index 0000000..fab98ac --- /dev/null +++ b/wasm/serve/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.28) +project(git2cpp-wasm) + +add_custom_target(build-serve COMMAND npm install) + +# Serve both cockle and JupyterLite deployments. +add_custom_target(serve COMMAND npm run serve) +add_custom_target(serve-with-cors COMMAND npm run serve-with-cors) diff --git a/wasm/serve/index.html b/wasm/serve/dist/index.html similarity index 100% rename from wasm/serve/index.html rename to wasm/serve/dist/index.html diff --git a/wasm/serve/package.json b/wasm/serve/package.json new file mode 100644 index 0000000..93c2a33 --- /dev/null +++ b/wasm/serve/package.json @@ -0,0 +1,17 @@ +{ + "name": "serve", + "version": "1.0.0", + "license": "BSD-3-Clause", + "private": true, + "scripts": { + "serve": "npm run serve:basic", + "serve-with-cors": "COCKLE_LOCAL_CORS=1 concurrently 'npm run serve:basic' 'npm run serve:cors'", + "serve:basic": "npx static-handler --cors --coop --coep --corp dist", + "serve:cors": "HOST=localhost PORT=8881 node node_modules/cors-anywhere/server.js " + }, + "devDependencies": { + "concurrently": "^9.2.1", + "cors-anywhere": "^0.4.4", + "static-handler": "^0.5.3" + } +} From 90a295eb58cf24314ab45c27846352a663e3cc6d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 07:51:02 +0000 Subject: [PATCH 15/35] Bump actions/checkout from 4 to 6 in the actions group (#105) Bumps the actions group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 4 to 6 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c760934..78072f4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,7 +56,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout source - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 From aae4150cbb2573988cff334c3f38ea24c3602558 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Wed, 25 Feb 2026 16:52:31 +0100 Subject: [PATCH 16/35] Add checkout message (#103) * Add message when checking out * Add checkout messages * address review comments --- src/subcommand/checkout_subcommand.cpp | 62 ++++++++- src/subcommand/status_subcommand.cpp | 117 ++++++++++------ src/subcommand/status_subcommand.hpp | 4 + test/test_checkout.py | 177 ++++++++++++++++++++++--- 4 files changed, 302 insertions(+), 58 deletions(-) diff --git a/src/subcommand/checkout_subcommand.cpp b/src/subcommand/checkout_subcommand.cpp index aba90fb..d3477a3 100644 --- a/src/subcommand/checkout_subcommand.cpp +++ b/src/subcommand/checkout_subcommand.cpp @@ -1,9 +1,12 @@ #include #include +#include #include "../subcommand/checkout_subcommand.hpp" +#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) { @@ -17,6 +20,23 @@ checkout_subcommand::checkout_subcommand(const libgit2_object&, CLI::App& app) sub->callback([this]() { this->run(); }); } +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; + } + + std::cout << "Please commit your changes or stash them before you switch branches.\nAborting" << std::endl; + return; +} + void checkout_subcommand::run() { auto directory = get_current_git_path(); @@ -30,16 +50,22 @@ 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_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); + + std::cout << "Switched to a new branch '" << m_branch_name << "'" << std::endl; } else { @@ -51,8 +77,38 @@ void checkout_subcommand::run() buffer << "error: could not resolve pathspec '" << m_branch_name << "'" << std::endl; throw std::runtime_error(buffer.str()); } - checkout_tree(repo, *optional_commit, m_branch_name, options); - update_head(repo, *optional_commit, m_branch_name); + + auto sl = status_list_wrapper::status_list(repo); + try + { + checkout_tree(repo, *optional_commit, m_branch_name, options); + update_head(repo, *optional_commit, m_branch_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::cout << "Switched to branch '" << m_branch_name << "'" << std::endl; + print_tracking_info(repo, sl, true); } } diff --git a/src/subcommand/status_subcommand.cpp b/src/subcommand/status_subcommand.cpp index 8bca1e5..8a95d20 100644 --- a/src/subcommand/status_subcommand.cpp +++ b/src/subcommand/status_subcommand.cpp @@ -7,7 +7,6 @@ #include #include "status_subcommand.hpp" -#include "../wrapper/status_wrapper.hpp" status_subcommand::status_subcommand(const libgit2_object&, CLI::App& app) @@ -47,14 +46,14 @@ struct print_entry std::string item; }; -std::string get_print_status(git_status_t status, output_format of) +std::string get_print_status(git_status_t status, bool is_long) { std::string entry_status; - if ((of == output_format::DEFAULT) || (of == output_format::LONG)) + if (is_long) { entry_status = get_status_msg(status).long_mod; } - else if (of == output_format::SHORT) + else { entry_status = get_status_msg(status).short_mod; } @@ -89,7 +88,7 @@ std::string get_print_item(const char* old_path, const char* new_path) } std::vector get_entries_to_print(git_status_t status, status_list_wrapper& sl, - bool head_selector, output_format of, std::set* tracked_dir_set = nullptr) + bool head_selector, bool is_long, std::set* tracked_dir_set = nullptr) { std::vector entries_to_print{}; const auto& entry_list = sl.get_entry_list(status); @@ -106,7 +105,7 @@ std::vector get_entries_to_print(git_status_t status, status_list_w update_tracked_dir_set(old_path, tracked_dir_set); - print_entry e = { get_print_status(status, of), get_print_item(old_path, new_path)}; + print_entry e = { get_print_status(status, is_long), get_print_item(old_path, new_path)}; entries_to_print.push_back(std::move(e)); } @@ -161,15 +160,12 @@ void print_not_tracked(const std::vector& entries_to_print, const s print_entries(not_tracked_entries_to_print, is_long, colour); } -void print_tracking_info(repository_wrapper& repo, status_list_wrapper& sl, status_subcommand_options options, bool is_long) +void print_tracking_info(repository_wrapper& repo, status_list_wrapper& sl, bool is_long) { - auto branch_name = repo.head_short_name(); auto tracking_info = repo.get_tracking_info(); if (is_long) { - std::cout << "On branch " << branch_name << std::endl; - if (tracking_info.has_upstream) { if(tracking_info.ahead > 0 && tracking_info.behind == 0) @@ -215,11 +211,6 @@ void print_tracking_info(repository_wrapper& repo, status_list_wrapper& sl, stat } else { - if (options.m_branch_flag) - { - std::cout << "## " << branch_name << std::endl; - } - if (tracking_info.has_upstream) { std::cout << "..." << tracking_info.upstream_name; @@ -246,63 +237,100 @@ void print_tracking_info(repository_wrapper& repo, status_list_wrapper& sl, stat } } -void print_tobecommited(status_list_wrapper& sl, output_format of, std::set tracked_dir_set, bool is_long) +void print_tobecommited(status_list_wrapper& sl, std::set tracked_dir_set, bool is_long, bool is_coloured) { - stream_colour_fn colour = termcolor::green; + + stream_colour_fn colour; + if (is_coloured) + { + colour = termcolor::green; + } + else + { + colour = termcolor::bright_white; + } + if (is_long) { std::cout << tobecommited_header; } - print_entries(get_entries_to_print(GIT_STATUS_INDEX_NEW, sl, true, of, &tracked_dir_set), is_long, colour); - print_entries(get_entries_to_print(GIT_STATUS_INDEX_MODIFIED, sl, true, of, &tracked_dir_set), is_long, colour); - print_entries(get_entries_to_print(GIT_STATUS_INDEX_DELETED, sl, true, of, &tracked_dir_set), is_long, colour); - print_entries(get_entries_to_print(GIT_STATUS_INDEX_RENAMED, sl, true, of, &tracked_dir_set), is_long, colour); - print_entries(get_entries_to_print(GIT_STATUS_INDEX_TYPECHANGE, sl, true, of, &tracked_dir_set), is_long, colour); + print_entries(get_entries_to_print(GIT_STATUS_INDEX_NEW, sl, true, is_long, &tracked_dir_set), is_long, colour); + print_entries(get_entries_to_print(GIT_STATUS_INDEX_MODIFIED, sl, true, is_long, &tracked_dir_set), is_long, colour); + print_entries(get_entries_to_print(GIT_STATUS_INDEX_DELETED, sl, true, is_long, &tracked_dir_set), is_long, colour); + print_entries(get_entries_to_print(GIT_STATUS_INDEX_RENAMED, sl, true, is_long, &tracked_dir_set), is_long, colour); + print_entries(get_entries_to_print(GIT_STATUS_INDEX_TYPECHANGE, sl, true, is_long, &tracked_dir_set), is_long, colour); if (is_long) { std::cout << std::endl; } } -void print_notstagged(status_list_wrapper& sl, output_format of, std::set tracked_dir_set, bool is_long) +void print_notstagged(status_list_wrapper& sl, std::set tracked_dir_set, bool is_long, bool is_coloured) { - stream_colour_fn colour = termcolor::red; + stream_colour_fn colour; + if (is_coloured) + { + colour = termcolor::red; + } + else + { + colour = termcolor::bright_white; + } + if (is_long) { std::cout << notstagged_header; } - print_entries(get_entries_to_print(GIT_STATUS_WT_MODIFIED, sl, false, of, &tracked_dir_set), is_long, colour); - print_entries(get_entries_to_print(GIT_STATUS_WT_DELETED, sl, false, of, &tracked_dir_set), is_long, colour); - print_entries(get_entries_to_print(GIT_STATUS_WT_TYPECHANGE, sl, false, of, &tracked_dir_set), is_long, colour); - print_entries(get_entries_to_print(GIT_STATUS_WT_RENAMED, sl, false, of, &tracked_dir_set), is_long, colour); + print_entries(get_entries_to_print(GIT_STATUS_WT_MODIFIED, sl, false, is_long, &tracked_dir_set), is_long, colour); + print_entries(get_entries_to_print(GIT_STATUS_WT_DELETED, sl, false, is_long, &tracked_dir_set), is_long, colour); + print_entries(get_entries_to_print(GIT_STATUS_WT_TYPECHANGE, sl, false, is_long, &tracked_dir_set), is_long, colour); + print_entries(get_entries_to_print(GIT_STATUS_WT_RENAMED, sl, false, is_long, &tracked_dir_set), is_long, colour); if (is_long) { std::cout << std::endl; } } -void print_unmerged(status_list_wrapper& sl, output_format of, std::set tracked_dir_set, std::set untracked_dir_set, bool is_long) +void print_unmerged(status_list_wrapper& sl, std::set tracked_dir_set, std::set untracked_dir_set, bool is_long, bool is_coloured) { - stream_colour_fn colour = termcolor::red; + stream_colour_fn colour; + if (is_coloured) + { + colour = termcolor::red; + } + else + { + colour = termcolor::bright_white; + } + if (is_long) { std::cout << unmerged_header; } - print_not_tracked(get_entries_to_print(GIT_STATUS_CONFLICTED, sl, false, of), tracked_dir_set, untracked_dir_set, is_long, colour); + print_not_tracked(get_entries_to_print(GIT_STATUS_CONFLICTED, sl, false, is_long), tracked_dir_set, untracked_dir_set, is_long, colour); if (is_long) { std::cout << std::endl; } } -void print_untracked(status_list_wrapper& sl, output_format of, std::set tracked_dir_set, std::set untracked_dir_set, bool is_long) +void print_untracked(status_list_wrapper& sl, std::set tracked_dir_set, std::set untracked_dir_set, bool is_long, bool is_coloured) { - stream_colour_fn colour = termcolor::red; + stream_colour_fn colour; + if (is_coloured) + { + colour = termcolor::red; + } + else + { + colour = termcolor::bright_white; + } + if (is_long) { std::cout << untracked_header; } - print_not_tracked(get_entries_to_print(GIT_STATUS_WT_NEW, sl, false, of), tracked_dir_set, untracked_dir_set, is_long, colour); + print_not_tracked(get_entries_to_print(GIT_STATUS_WT_NEW, sl, false, is_long), tracked_dir_set, untracked_dir_set, is_long, colour); if (is_long) { std::cout << std::endl; @@ -341,27 +369,38 @@ void status_run(status_subcommand_options options) bool is_long; is_long = ((of == output_format::DEFAULT) || (of == output_format::LONG)); - print_tracking_info(repo, sl, options, is_long); + + auto branch_name = repo.head_short_name(); + if (is_long) + { + std::cout << "On branch " << branch_name << std::endl; + } + else if (options.m_branch_flag) + { + std::cout << "## " << branch_name << std::endl; + } + bool is_coloured = true; + print_tracking_info(repo, sl, is_long); if (sl.has_tobecommited_header()) { - print_tobecommited(sl, of, tracked_dir_set,is_long); + print_tobecommited(sl, tracked_dir_set, is_long, is_coloured); } if (sl.has_notstagged_header()) { - print_notstagged(sl, of, tracked_dir_set, is_long); + print_notstagged(sl, tracked_dir_set, is_long, is_coloured); } // TODO: check if should be printed before "not stagged" files if (sl.has_unmerged_header()) { - print_unmerged(sl, of, tracked_dir_set, untracked_dir_set, is_long); + print_unmerged(sl, tracked_dir_set, untracked_dir_set, is_long, is_coloured); } if (sl.has_untracked_header()) { - print_untracked(sl, of, tracked_dir_set, untracked_dir_set, is_long); + print_untracked(sl, tracked_dir_set, untracked_dir_set, is_long, is_coloured); } // TODO: check if this message should be displayed even if there are untracked files diff --git a/src/subcommand/status_subcommand.hpp b/src/subcommand/status_subcommand.hpp index fcd1a37..064a09f 100644 --- a/src/subcommand/status_subcommand.hpp +++ b/src/subcommand/status_subcommand.hpp @@ -3,6 +3,7 @@ #include #include "../utils/common.hpp" +#include "../wrapper/status_wrapper.hpp" struct status_subcommand_options { @@ -22,4 +23,7 @@ class status_subcommand status_subcommand_options m_options; }; +void print_tobecommited(status_list_wrapper& sl, std::set tracked_dir_set, bool is_long, bool is_coloured); +void print_notstagged(status_list_wrapper& sl, std::set tracked_dir_set, bool is_long, bool is_coloured); +void print_tracking_info(repository_wrapper& repo, status_list_wrapper& sl, bool is_long); void status_run(status_subcommand_options fl = {}); diff --git a/test/test_checkout.py b/test/test_checkout.py index 3243318..8a32501 100644 --- a/test/test_checkout.py +++ b/test/test_checkout.py @@ -7,44 +7,189 @@ def test_checkout(xtl_clone, git2cpp_path, tmp_path): assert (tmp_path / "xtl").exists() xtl_path = tmp_path / "xtl" - create_cmd = [git2cpp_path, 'branch', 'foregone'] + create_cmd = [git2cpp_path, "branch", "foregone"] p_create = subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, text=True) assert p_create.returncode == 0 - checkout_cmd = [git2cpp_path, 'checkout', 'foregone'] - p_checkout = subprocess.run(checkout_cmd, capture_output=True, cwd=xtl_path, text=True) + checkout_cmd = [git2cpp_path, "checkout", "foregone"] + p_checkout = subprocess.run( + checkout_cmd, capture_output=True, cwd=xtl_path, text=True + ) assert p_checkout.returncode == 0 - assert(p_checkout.stdout == ''); + assert "Switched to branch 'foregone'" in p_checkout.stdout - branch_cmd = [git2cpp_path, 'branch'] + branch_cmd = [git2cpp_path, "branch"] p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=xtl_path, text=True) assert p_branch.returncode == 0 - assert(p_branch.stdout == '* foregone\n master\n') + assert p_branch.stdout == "* foregone\n master\n" - checkout_cmd[2] = 'master' - p_checkout2 = subprocess.run(checkout_cmd, capture_output=True, cwd=xtl_path, text=True) + checkout_cmd[2] = "master" + p_checkout2 = subprocess.run( + checkout_cmd, capture_output=True, cwd=xtl_path, text=True + ) assert p_checkout2.returncode == 0 + assert "Switched to branch 'master'" in p_checkout2.stdout def test_checkout_b(xtl_clone, git2cpp_path, tmp_path): assert (tmp_path / "xtl").exists() xtl_path = tmp_path / "xtl" - checkout_cmd = [git2cpp_path, 'checkout', '-b', 'foregone'] - p_checkout = subprocess.run(checkout_cmd, capture_output=True, cwd=xtl_path, text=True) + checkout_cmd = [git2cpp_path, "checkout", "-b", "foregone"] + p_checkout = subprocess.run( + checkout_cmd, capture_output=True, cwd=xtl_path, text=True + ) assert p_checkout.returncode == 0 - assert(p_checkout.stdout == ''); + assert "Switched to a new branch 'foregone'" in p_checkout.stdout - branch_cmd = [git2cpp_path, 'branch'] + branch_cmd = [git2cpp_path, "branch"] p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=xtl_path, text=True) assert p_branch.returncode == 0 - assert(p_branch.stdout == '* foregone\n master\n') + assert p_branch.stdout == "* foregone\n master\n" - checkout_cmd.remove('-b') - checkout_cmd[2] = 'master' + checkout_cmd.remove("-b") + checkout_cmd[2] = "master" p_checkout2 = subprocess.run(checkout_cmd, cwd=xtl_path, text=True) assert p_checkout2.returncode == 0 p_branch2 = subprocess.run(branch_cmd, capture_output=True, cwd=xtl_path, text=True) assert p_branch2.returncode == 0 - assert(p_branch2.stdout == ' foregone\n* master\n') + assert p_branch2.stdout == " foregone\n* master\n" + + +def test_checkout_B_force_create(xtl_clone, git2cpp_path, tmp_path): + """Test checkout -B to force create or reset a branch""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a branch first + create_cmd = [git2cpp_path, "branch", "resetme"] + p_create = subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_create.returncode == 0 + + # Use -B to reset it (should not fail even if branch exists) + checkout_cmd = [git2cpp_path, "checkout", "-B", "resetme"] + p_checkout = subprocess.run( + checkout_cmd, capture_output=True, cwd=xtl_path, text=True + ) + assert p_checkout.returncode == 0 + assert "Switched to a new branch 'resetme'" in p_checkout.stdout + + # Verify we're on the branch + branch_cmd = [git2cpp_path, "branch"] + p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_branch.returncode == 0 + assert "* resetme" in p_branch.stdout + + +def test_checkout_invalid_branch(xtl_clone, git2cpp_path, tmp_path): + """Test that checkout fails gracefully with invalid branch name""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Try to checkout non-existent branch + checkout_cmd = [git2cpp_path, "checkout", "nonexistent"] + p_checkout = subprocess.run( + checkout_cmd, capture_output=True, cwd=xtl_path, text=True + ) + + # Should fail with error message + assert p_checkout.returncode != 0 + assert "error: could not resolve pathspec 'nonexistent'" in p_checkout.stderr + + +def test_checkout_with_unstaged_changes(xtl_clone, git2cpp_path, tmp_path): + """Test that checkout shows unstaged changes when switching branches""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a new branch + create_cmd = [git2cpp_path, "branch", "newbranch"] + p_create = subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_create.returncode == 0 + + # Modify a file (unstaged change) + readme_path = xtl_path / "README.md" + readme_path.write_text("Modified content") + + # Checkout - should succeed and show the modified file status + checkout_cmd = [git2cpp_path, "checkout", "newbranch"] + p_checkout = subprocess.run( + checkout_cmd, capture_output=True, cwd=xtl_path, text=True + ) + + # Should succeed and show status + assert p_checkout.returncode == 0 + assert " M README.md" in p_checkout.stdout + assert "Switched to branch 'newbranch'" in p_checkout.stdout + + +@pytest.mark.parametrize("force_flag", ["", "-f", "--force"]) +def test_checkout_refuses_overwrite( + xtl_clone, commit_env_config, git2cpp_path, tmp_path, force_flag +): + """Test that checkout refuses to switch when local changes would be overwritten, and switches when using --force""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a new branch and switch to it + create_cmd = [git2cpp_path, "checkout", "-b", "newbranch"] + p_create = subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_create.returncode == 0 + + # Modify README.md and commit it on newbranch + readme_path = xtl_path / "README.md" + readme_path.write_text("Content on newbranch") + + add_cmd = [git2cpp_path, "add", "README.md"] + subprocess.run(add_cmd, cwd=xtl_path, text=True) + + commit_cmd = [git2cpp_path, "commit", "-m", "Change on newbranch"] + subprocess.run(commit_cmd, cwd=xtl_path, text=True) + + # Switch back to master + checkout_master_cmd = [git2cpp_path, "checkout", "master"] + p_master = subprocess.run( + checkout_master_cmd, capture_output=True, cwd=xtl_path, text=True + ) + assert p_master.returncode == 0 + + # Now modify README.md locally (unstaged) on master + readme_path.write_text("Local modification on master") + + # Try to checkout newbranch + checkout_cmd = [git2cpp_path, "checkout"] + if force_flag != "": + checkout_cmd.append(force_flag) + checkout_cmd.append("newbranch") + p_checkout = subprocess.run( + checkout_cmd, capture_output=True, cwd=xtl_path, text=True + ) + + if force_flag == "": + assert p_checkout.returncode != 0 + assert ( + "Your local changes to the following files would be overwritten by checkout:" + in p_checkout.stdout + ) + assert "README.md" in p_checkout.stdout + assert ( + "Please commit your changes or stash them before you switch branches" + in p_checkout.stdout + ) + + # Verify we're still on master (didn't switch) + branch_cmd = [git2cpp_path, "branch"] + p_branch = subprocess.run( + branch_cmd, capture_output=True, cwd=xtl_path, text=True + ) + assert "* master" in p_branch.stdout + else: + assert "Switched to branch 'newbranch'" in p_checkout.stdout + + # Verify we switched to newbranch + branch_cmd = [git2cpp_path, "branch"] + p_branch = subprocess.run( + branch_cmd, capture_output=True, cwd=xtl_path, text=True + ) + assert "* newbranch" in p_branch.stdout From 1a6fb5ba342d5df110934a4ee09c2b979c659b8c Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Fri, 27 Feb 2026 15:11:36 +0100 Subject: [PATCH 17/35] Implement stash show (#107) --- src/subcommand/diff_subcommand.cpp | 26 +++++++-------- src/subcommand/diff_subcommand.hpp | 3 +- src/subcommand/stash_subcommand.cpp | 52 ++++++++++++++++++++++++++--- src/subcommand/stash_subcommand.hpp | 10 ++++++ src/wrapper/commit_wrapper.cpp | 19 ++++++++++- src/wrapper/commit_wrapper.hpp | 4 +++ src/wrapper/repository_wrapper.cpp | 8 ++--- src/wrapper/repository_wrapper.hpp | 8 ++--- src/wrapper/tree_wrapper.hpp | 1 + test/test_stash.py | 25 ++++++++++++++ 10 files changed, 128 insertions(+), 28 deletions(-) diff --git a/src/subcommand/diff_subcommand.cpp b/src/subcommand/diff_subcommand.cpp index 0e352e0..1ed8fb1 100644 --- a/src/subcommand/diff_subcommand.cpp +++ b/src/subcommand/diff_subcommand.cpp @@ -54,12 +54,12 @@ diff_subcommand::diff_subcommand(const libgit2_object&, CLI::App& app) sub->callback([this]() { this->run(); }); } -void diff_subcommand::print_stats(const diff_wrapper& diff, bool use_colour) +void print_stats(const diff_wrapper& diff, bool use_colour, bool stat_flag, bool shortstat_flag, bool numstat_flag, bool summary_flag) { git_diff_stats_format_t format; - if (m_stat_flag) + if (stat_flag) { - if (m_shortstat_flag || m_numstat_flag || m_summary_flag) + if (shortstat_flag || numstat_flag || summary_flag) { throw git_exception("Only one of --stat, --shortstat, --numstat and --summary should be provided.", git2cpp_error_code::BAD_ARGUMENT); } @@ -68,9 +68,9 @@ void diff_subcommand::print_stats(const diff_wrapper& diff, bool use_colour) format = GIT_DIFF_STATS_FULL; } } - else if (m_shortstat_flag) + else if (shortstat_flag) { - if (m_numstat_flag || m_summary_flag) + if (numstat_flag || summary_flag) { throw git_exception("Only one of --stat, --shortstat, --numstat and --summary should be provided.", git2cpp_error_code::BAD_ARGUMENT); } @@ -79,9 +79,9 @@ void diff_subcommand::print_stats(const diff_wrapper& diff, bool use_colour) format = GIT_DIFF_STATS_SHORT; } } - else if (m_numstat_flag) + else if (numstat_flag) { - if (m_summary_flag) + if (summary_flag) { throw git_exception("Only one of --stat, --shortstat, --numstat and --summary should be provided.", git2cpp_error_code::BAD_ARGUMENT); } @@ -90,7 +90,7 @@ void diff_subcommand::print_stats(const diff_wrapper& diff, bool use_colour) format = GIT_DIFF_STATS_NUMBER; } } - else if (m_summary_flag) + else if (summary_flag) { format = GIT_DIFF_STATS_INCLUDE_SUMMARY; } @@ -98,7 +98,7 @@ void diff_subcommand::print_stats(const diff_wrapper& diff, bool use_colour) auto stats = diff.get_stats(); auto buf = stats.to_buf(format, 80); - if (use_colour && m_stat_flag) + if (use_colour && stat_flag) { // Add colors to + and - characters std::string output(buf.ptr); @@ -179,7 +179,7 @@ void diff_subcommand::print_diff(diff_wrapper& diff, bool use_colour) { if (m_stat_flag || m_shortstat_flag || m_numstat_flag || m_summary_flag) { - print_stats(diff, use_colour); + print_stats(diff, use_colour, m_stat_flag, m_shortstat_flag, m_numstat_flag, m_summary_flag); return; } @@ -320,7 +320,7 @@ void diff_subcommand::run() { if (tree1.has_value() && tree2.has_value()) { - return repo.diff_tree_to_tree(std::move(tree1.value()), std::move(tree2.value()), &diffopts); + return repo.diff_tree_to_tree(tree1.value(), tree2.value(), &diffopts); } else if (m_cached_flag) { @@ -328,11 +328,11 @@ void diff_subcommand::run() { tree1 = repo.treeish_to_tree("HEAD"); } - return repo.diff_tree_to_index(std::move(tree1.value()), std::nullopt, &diffopts); + return repo.diff_tree_to_index(tree1.value(), std::nullopt, &diffopts); } else if (tree1) { - return repo.diff_tree_to_workdir_with_index(std::move(tree1.value()), &diffopts); + return repo.diff_tree_to_workdir_with_index(tree1.value(), &diffopts); } else { diff --git a/src/subcommand/diff_subcommand.hpp b/src/subcommand/diff_subcommand.hpp index 966e77c..5c2c23f 100644 --- a/src/subcommand/diff_subcommand.hpp +++ b/src/subcommand/diff_subcommand.hpp @@ -11,7 +11,6 @@ class diff_subcommand public: explicit diff_subcommand(const libgit2_object&, CLI::App& app); - void print_stats(const diff_wrapper& diff, bool use_colour); void print_diff(diff_wrapper& diff, bool use_colour); void run(); @@ -53,3 +52,5 @@ class diff_subcommand bool m_colour_flag = true; bool m_no_colour_flag = false; }; + +void print_stats(const diff_wrapper& diff, bool use_colour, bool stat_flag, bool shortstat_flag, bool numstat_flag, bool summary_flag); diff --git a/src/subcommand/stash_subcommand.cpp b/src/subcommand/stash_subcommand.cpp index 752ef5f..04bce61 100644 --- a/src/subcommand/stash_subcommand.cpp +++ b/src/subcommand/stash_subcommand.cpp @@ -5,13 +5,13 @@ #include +#include "../subcommand/diff_subcommand.hpp" #include "../subcommand/stash_subcommand.hpp" #include "../subcommand/status_subcommand.hpp" -#include "../wrapper/repository_wrapper.hpp" bool has_subcommand(CLI::App* cmd) { - std::vector subs = { "push", "pop", "list", "apply" }; + std::vector subs = { "push", "pop", "list", "apply", "show" }; return std::any_of(subs.begin(), subs.end(), [cmd](const std::string& s) { return cmd->got_subcommand(s); }); } @@ -22,10 +22,15 @@ stash_subcommand::stash_subcommand(const libgit2_object&, CLI::App& app) auto* list = stash->add_subcommand("list", ""); auto* pop = stash->add_subcommand("pop", ""); auto* apply = stash->add_subcommand("apply", ""); + auto* show = stash->add_subcommand("show", "Show the changes recorded in the stash as a diff"); push->add_option("-m,--message", m_message, ""); pop->add_option("--index", m_index, ""); apply->add_option("--index", m_index, ""); + show->add_flag("--stat", m_stat_flag, "Generate a diffstat"); + show->add_flag("--shortstat", m_shortstat_flag, "Output only the last line of --stat"); + show->add_flag("--numstat", m_numstat_flag, "Machine-friendly --stat"); + show->add_flag("--summary", m_summary_flag, "Output a condensed summary"); stash->callback([this,stash]() { @@ -38,6 +43,7 @@ stash_subcommand::stash_subcommand(const libgit2_object&, CLI::App& app) list->callback([this]() { this->run_list(); }); pop->callback([this]() { this->run_pop(); }); apply->callback([this]() { this->run_apply(); }); + show->callback([this]() { this->run_show(); }); } void stash_subcommand::run_push() @@ -66,14 +72,20 @@ void stash_subcommand::run_list() throw_if_error(git_stash_foreach(repo, list_stash_cb, NULL)); } +git_oid stash_subcommand::resolve_stash_commit(repository_wrapper& repo) +{ + std::string stash_spec = "stash@{" + std::to_string(m_index) + "}"; + auto stash_obj = repo.revparse_single(stash_spec); + git_oid stash_id = stash_obj->oid(); + return stash_id; +} + void stash_subcommand::run_pop() { auto directory = get_current_git_path(); auto repo = repository_wrapper::open(directory); - std::string stash_spec = "stash@{" + std::to_string(m_index) + "}"; - auto stash_obj = repo.revparse_single(stash_spec); - git_oid stash_id = stash_obj->oid(); + git_oid stash_id = resolve_stash_commit(repo); char id_string[GIT_OID_HEXSZ + 1]; git_oid_tostr(id_string, sizeof(id_string), &stash_id); @@ -90,3 +102,33 @@ void stash_subcommand::run_apply() throw_if_error(git_stash_apply(repo, m_index, NULL)); status_run(); } + +void stash_subcommand::run_show() +{ + auto directory = get_current_git_path(); + auto repo = repository_wrapper::open(directory); + + git_oid stash_id = resolve_stash_commit(repo); + commit_wrapper stash_commit = repo.find_commit(stash_id); + + if (git_commit_parentcount(stash_commit) < 1) + { + throw std::runtime_error("stash show: stash commit has no parents"); + } + + commit_wrapper parent_commit = stash_commit.get_parent(0); + + tree_wrapper stash_tree = stash_commit.tree(); + tree_wrapper parent_tree = parent_commit.tree(); + + git_diff_options diff_opts = GIT_DIFF_OPTIONS_INIT; + + diff_wrapper diff = repo.diff_tree_to_tree(parent_tree, stash_tree, &diff_opts); + + bool use_colour = true; + if (!m_shortstat_flag && !m_numstat_flag && !m_summary_flag) + { + m_stat_flag = true; + } + print_stats(diff, use_colour, m_stat_flag, m_shortstat_flag, m_numstat_flag, m_summary_flag); +} diff --git a/src/subcommand/stash_subcommand.hpp b/src/subcommand/stash_subcommand.hpp index c6a13ce..3d07980 100644 --- a/src/subcommand/stash_subcommand.hpp +++ b/src/subcommand/stash_subcommand.hpp @@ -3,18 +3,28 @@ #include #include "../utils/common.hpp" +#include "../wrapper/repository_wrapper.hpp" class stash_subcommand { public: explicit stash_subcommand(const libgit2_object&, CLI::App& app); + git_oid resolve_stash_commit(repository_wrapper& repo); void run_push(); void run_list(); void run_pop(); void run_apply(); + void run_show(); + +private: std::vector m_options; std::string m_message = ""; size_t m_index = 0; + + bool m_stat_flag = false; + bool m_shortstat_flag = false; + bool m_numstat_flag = false; + bool m_summary_flag = false; }; diff --git a/src/wrapper/commit_wrapper.cpp b/src/wrapper/commit_wrapper.cpp index fc214cc..aa4cae3 100644 --- a/src/wrapper/commit_wrapper.cpp +++ b/src/wrapper/commit_wrapper.cpp @@ -1,6 +1,9 @@ -#include "../wrapper/commit_wrapper.hpp" #include +#include "../utils/git_exception.hpp" +#include "tree_wrapper.hpp" +#include "../wrapper/commit_wrapper.hpp" + commit_wrapper::commit_wrapper(git_commit* commit) : base_type(commit) { @@ -38,6 +41,13 @@ std::string commit_wrapper::summary() const return git_commit_summary(*this); } +commit_wrapper commit_wrapper::get_parent(size_t i) const +{ + git_commit* parent; + throw_if_error(git_commit_parent(&parent, *this, i)); + return commit_wrapper(parent); +} + commit_list_wrapper commit_wrapper::get_parents_list() const { size_t parent_count = git_commit_parentcount(*this); @@ -52,3 +62,10 @@ commit_list_wrapper commit_wrapper::get_parents_list() const } return commit_list_wrapper(std::move(parents_list)); } + +tree_wrapper commit_wrapper::tree() const +{ + git_tree* tree; + throw_if_error(git_commit_tree(&tree, *this)); + return tree_wrapper(tree); +} diff --git a/src/wrapper/commit_wrapper.hpp b/src/wrapper/commit_wrapper.hpp index 0db1066..c50b389 100644 --- a/src/wrapper/commit_wrapper.hpp +++ b/src/wrapper/commit_wrapper.hpp @@ -4,6 +4,7 @@ #include #include "../wrapper/wrapper_base.hpp" +#include "../wrapper/tree_wrapper.hpp" class commit_wrapper; using commit_list_wrapper = list_wrapper; @@ -27,8 +28,11 @@ class commit_wrapper : public wrapper_base std::string message() const; std::string summary() const; + commit_wrapper get_parent(size_t i) const; commit_list_wrapper get_parents_list() const; + tree_wrapper tree() const; + private: commit_wrapper(git_commit* commit); diff --git a/src/wrapper/repository_wrapper.cpp b/src/wrapper/repository_wrapper.cpp index 0e9c05b..e14863d 100644 --- a/src/wrapper/repository_wrapper.cpp +++ b/src/wrapper/repository_wrapper.cpp @@ -507,7 +507,7 @@ config_wrapper repository_wrapper::get_config() // Diff -diff_wrapper repository_wrapper::diff_tree_to_index(tree_wrapper old_tree, std::optional index, git_diff_options* diffopts) +diff_wrapper repository_wrapper::diff_tree_to_index(const tree_wrapper& old_tree, std::optional index, git_diff_options* diffopts) { git_diff* diff; git_index* idx = nullptr; @@ -519,21 +519,21 @@ diff_wrapper repository_wrapper::diff_tree_to_index(tree_wrapper old_tree, std:: return diff_wrapper(diff); } -diff_wrapper repository_wrapper::diff_tree_to_tree(tree_wrapper old_tree, tree_wrapper new_tree, git_diff_options* diffopts) +diff_wrapper repository_wrapper::diff_tree_to_tree(const tree_wrapper& old_tree, const tree_wrapper& new_tree, git_diff_options* diffopts) { git_diff* diff; throw_if_error(git_diff_tree_to_tree(&diff, *this, old_tree, new_tree, diffopts)); return diff_wrapper(diff); } -diff_wrapper repository_wrapper::diff_tree_to_workdir(tree_wrapper old_tree, git_diff_options* diffopts) +diff_wrapper repository_wrapper::diff_tree_to_workdir(const tree_wrapper& old_tree, git_diff_options* diffopts) { git_diff* diff; throw_if_error(git_diff_tree_to_workdir(&diff, *this, old_tree, diffopts)); return diff_wrapper(diff); } -diff_wrapper repository_wrapper::diff_tree_to_workdir_with_index(tree_wrapper old_tree, git_diff_options* diffopts) +diff_wrapper repository_wrapper::diff_tree_to_workdir_with_index(const tree_wrapper& old_tree, git_diff_options* diffopts) { git_diff* diff; throw_if_error(git_diff_tree_to_workdir_with_index(&diff, *this, old_tree, diffopts)); diff --git a/src/wrapper/repository_wrapper.hpp b/src/wrapper/repository_wrapper.hpp index 25e922b..18a25e7 100644 --- a/src/wrapper/repository_wrapper.hpp +++ b/src/wrapper/repository_wrapper.hpp @@ -116,10 +116,10 @@ class repository_wrapper : public wrapper_base config_wrapper get_config(); // Diff - diff_wrapper diff_tree_to_index(tree_wrapper old_tree, std::optional index, git_diff_options* diffopts); - diff_wrapper diff_tree_to_tree(tree_wrapper old_tree, tree_wrapper new_tree, git_diff_options* diffopts); - diff_wrapper diff_tree_to_workdir(tree_wrapper old_tree, git_diff_options* diffopts); - diff_wrapper diff_tree_to_workdir_with_index(tree_wrapper old_tree, git_diff_options* diffopts); + diff_wrapper diff_tree_to_index(const tree_wrapper& old_tree, std::optional index, git_diff_options* diffopts); + diff_wrapper diff_tree_to_tree(const tree_wrapper& old_tree, const tree_wrapper& new_tree, git_diff_options* diffopts); + diff_wrapper diff_tree_to_workdir(const tree_wrapper& old_tree, git_diff_options* diffopts); + diff_wrapper diff_tree_to_workdir_with_index(const tree_wrapper& old_tree, git_diff_options* diffopts); diff_wrapper diff_index_to_workdir(std::optional index, git_diff_options* diffopts); //Tags diff --git a/src/wrapper/tree_wrapper.hpp b/src/wrapper/tree_wrapper.hpp index 06d11c4..0b22638 100644 --- a/src/wrapper/tree_wrapper.hpp +++ b/src/wrapper/tree_wrapper.hpp @@ -19,5 +19,6 @@ class tree_wrapper : public wrapper_base tree_wrapper(git_tree* tree); + friend class commit_wrapper; friend class repository_wrapper; }; diff --git a/test/test_stash.py b/test/test_stash.py index 286324a..dadf045 100644 --- a/test/test_stash.py +++ b/test/test_stash.py @@ -137,3 +137,28 @@ def test_stash_apply(xtl_clone, commit_env_config, git2cpp_path, tmp_path, index assert "stash@{0}" in p_list.stdout if index_flag != "": assert "stash@{1}" in p_list.stdout + + +def test_stash_show(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + filename = "mook_show.txt" + p = xtl_path / filename + p.write_text("Hello") + + cmd_add = [git2cpp_path, "add", filename] + p_add = subprocess.run(cmd_add, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + cmd_stash = [git2cpp_path, "stash"] + p_stash = subprocess.run(cmd_stash, capture_output=True, cwd=xtl_path, text=True) + assert p_stash.returncode == 0 + + cmd_show = [git2cpp_path, "stash", "show", "--stat"] + p_show = subprocess.run(cmd_show, capture_output=True, cwd=xtl_path, text=True) + assert p_show.returncode == 0 + + # A diffstat should mention the file and summary "file changed" + assert filename in p_show.stdout + assert "1 file changed" in p_show.stdout From 1ba9701b71e62ee8bc17d7b4aa9231f1dd461fd2 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Fri, 27 Feb 2026 16:08:11 +0100 Subject: [PATCH 18/35] Add tracking info for log subcommand (#106) * Add tracking info * address review comments * address review comments --- src/subcommand/log_subcommand.cpp | 194 +++++++++++++++++++++++++++++- test/test_log.py | 194 ++++++++++++++++++++++++++++++ 2 files changed, 383 insertions(+), 5 deletions(-) diff --git a/src/subcommand/log_subcommand.cpp b/src/subcommand/log_subcommand.cpp index f5bc56b..1c7fff0 100644 --- a/src/subcommand/log_subcommand.cpp +++ b/src/subcommand/log_subcommand.cpp @@ -1,8 +1,11 @@ #include #include -#include +#include +#include #include +#include #include +#include #include @@ -50,7 +53,166 @@ void print_time(git_time intime, std::string prefix) std::cout << prefix << out << " " << sign << std::format("{:02d}", hours) << std::format("{:02d}", minutes) < get_tags_for_commit(repository_wrapper& repo, const git_oid& commit_oid) +{ + std::vector tags; + git_strarray tag_names = {0}; + + if (git_tag_list(&tag_names, repo) != 0) + { + return tags; + } + + for (size_t i = 0; i < tag_names.count; i++) + { + std::string tag_name = tag_names.strings[i]; + std::string ref_name = "refs/tags/" + std::string(tag_name); + + reference_wrapper tag_ref = repo.find_reference(ref_name); + object_wrapper peeled = tag_ref.peel(); + + if (git_oid_equal(&peeled.oid(), &commit_oid)) + { + tags.push_back(std::string(tag_name)); + } + } + + git_strarray_dispose(&tag_names); // TODO: refactor git_strarray_wrapper to use it here + return tags; +} + +std::vector get_branches_for_commit(repository_wrapper& repo, git_branch_t type, const git_oid& commit_oid, const std::string exclude_branch) +{ + std::vector branches; + + auto branch_iter = repo.iterate_branches(type); + while (auto branch = branch_iter.next()) + { + const git_oid* branch_target = nullptr; + git_reference* ref = branch.value(); + + if (git_reference_type(ref) == GIT_REFERENCE_DIRECT) + { + branch_target = git_reference_target(ref); + } + else if (git_reference_type(ref) == GIT_REFERENCE_SYMBOLIC) + { + git_reference* resolved = nullptr; + if (git_reference_resolve(&resolved, ref) == 0) + { + branch_target = git_reference_target(resolved); + git_reference_free(resolved); + } + } + + if (branch_target && git_oid_equal(branch_target, &commit_oid)) + { + std::string branch_name(branch->name()); + if (type == GIT_BRANCH_LOCAL) + { + if (branch_name != exclude_branch) + { + branches.push_back(branch_name); + } + } + else + { + branches.push_back(branch_name); + } + } + } + + return branches; +} + +struct commit_refs +{ + std::string head_branch; + std::vector tags; + std::vector local_branches; + std::vector remote_branches; + + bool has_refs() const { + return !head_branch.empty() || !tags.empty() || + !local_branches.empty() || !remote_branches.empty(); + } +}; + +commit_refs get_refs_for_commit(repository_wrapper& repo, const git_oid& commit_oid) +{ + commit_refs refs; + + if (!repo.is_head_unborn()) + { + auto head = repo.head(); + auto head_taget = head.target(); + if (git_oid_equal(head_taget, &commit_oid)) + { + refs.head_branch = head.short_name(); + } + } + + refs.tags = get_tags_for_commit(repo, commit_oid); + refs.local_branches = get_branches_for_commit(repo, GIT_BRANCH_LOCAL, commit_oid, refs.head_branch); + refs.remote_branches = get_branches_for_commit(repo, GIT_BRANCH_REMOTE, commit_oid, ""); + + return refs; +} + +void print_refs(const commit_refs& refs) +{ + if (!refs.has_refs()) + { + return; + } + + std::cout << " ("; + + bool first = true; + + if (!refs.head_branch.empty()) + { + std::cout << termcolor::bold << termcolor::cyan << "HEAD" << termcolor::reset + << termcolor::yellow << " -> " << termcolor::reset + << termcolor::bold << termcolor::green << refs.head_branch << termcolor::reset + << termcolor::yellow; + first = false; + } + + for (const auto& tag :refs.tags) + { + if (!first) + { + std::cout << ", "; + } + std::cout << termcolor::bold << "tag: " << tag << termcolor::reset << termcolor::yellow; + first = false; + } + + for (const auto& remote : refs.remote_branches) + { + if (!first) + { + std::cout << ", "; + } + std::cout << termcolor::bold << termcolor::red << remote << termcolor::reset << termcolor::yellow; + first = false; + } + + for (const auto& local : refs.local_branches) + { + if (!first) + { + std::cout << ", "; + } + std::cout << termcolor::bold << termcolor::green << local << termcolor::reset << termcolor::yellow; + first = false; + } + + std::cout << ")" << termcolor::reset; +} + +void print_commit(repository_wrapper& repo, const commit_wrapper& commit, std::string m_format_flag) { std::string buf = commit.commit_oid_tostr(); @@ -58,7 +220,13 @@ void print_commit(const commit_wrapper& commit, std::string m_format_flag) signature_wrapper committer = signature_wrapper::get_commit_committer(commit); stream_colour_fn colour = termcolor::yellow; - std::cout << colour << "commit " << buf << termcolor::reset << std::endl; + std::cout << colour << "commit " << buf; + + commit_refs refs = get_refs_for_commit(repo, commit.oid()); + print_refs(refs); + + std::cout << termcolor::reset << std::endl; + if (m_format_flag=="fuller") { std::cout << "Author:\t " << author.name() << " " << author.email() << std::endl; @@ -78,7 +246,19 @@ void print_commit(const commit_wrapper& commit, std::string m_format_flag) print_time(author.when(), "Date:\t"); } } - std::cout << "\n " << commit.message() << "\n" << std::endl; + + std::string message = commit.message(); + while (!message.empty() && message.back() == '\n') + { + message.pop_back(); + } + std::istringstream message_stream(message); + std::string line; + while (std::getline(message_stream, line)) + { + std::cout << "\n " << line; + } + std::cout << std::endl; } void log_subcommand::run() @@ -102,8 +282,12 @@ void log_subcommand::run() git_oid commit_oid; while (!walker.next(commit_oid) && i 2 else: assert p_log.stdout.count("Author") == 2 + + +def test_log_with_head_reference(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test that HEAD reference is shown on the latest commit.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a new commit + p = xtl_path / "test_file.txt" + p.write_text("test content") + + subprocess.run([git2cpp_path, "add", "test_file.txt"], cwd=xtl_path, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", "test commit"], cwd=xtl_path, check=True + ) + + # Run log with max count 1 to get only the latest commit + p_log = subprocess.run( + [git2cpp_path, "log", "-n", "1"], capture_output=True, cwd=xtl_path, text=True + ) + assert p_log.returncode == 0 + + # Check that HEAD reference is shown + assert "HEAD ->" in p_log.stdout + assert "master" in p_log.stdout or "main" in p_log.stdout + + +def test_log_with_tag(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test that tags are shown in log output.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a commit and tag it + p = xtl_path / "tagged_file.txt" + p.write_text("tagged content") + + subprocess.run([git2cpp_path, "add", "tagged_file.txt"], cwd=xtl_path, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", "tagged commit"], cwd=xtl_path, check=True + ) + + # Create a tag (using git command since git2cpp might not have tag creation yet) + subprocess.run(["git", "tag", "v1.0.0"], cwd=xtl_path, check=True) + + # Run log + p_log = subprocess.run( + [git2cpp_path, "log", "-n", "1"], capture_output=True, cwd=xtl_path, text=True + ) + assert p_log.returncode == 0 + + # Check that tag is shown + assert "tag: v1.0.0" in p_log.stdout + + +def test_log_with_multiple_tags(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test that multiple tags on the same commit are all shown.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a commit + p = xtl_path / "multi_tag_file.txt" + p.write_text("content") + + subprocess.run( + [git2cpp_path, "add", "multi_tag_file.txt"], cwd=xtl_path, check=True + ) + subprocess.run( + [git2cpp_path, "commit", "-m", "multi tag commit"], cwd=xtl_path, check=True + ) + + # Create multiple tags + subprocess.run(["git", "tag", "v1.0.0"], cwd=xtl_path, check=True) + subprocess.run(["git", "tag", "stable"], cwd=xtl_path, check=True) + subprocess.run(["git", "tag", "latest"], cwd=xtl_path, check=True) + + # Run log + p_log = subprocess.run( + [git2cpp_path, "log", "-n", "1"], capture_output=True, cwd=xtl_path, text=True + ) + assert p_log.returncode == 0 + + # Check that all tags are shown + assert "tag: v1.0.0" in p_log.stdout + assert "tag: stable" in p_log.stdout + assert "tag: latest" in p_log.stdout + + +def test_log_with_annotated_tag(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test that annotated tags are shown in log output.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a commit + p = xtl_path / "annotated_tag_file.txt" + p.write_text("content") + + subprocess.run( + [git2cpp_path, "add", "annotated_tag_file.txt"], cwd=xtl_path, check=True + ) + subprocess.run( + [git2cpp_path, "commit", "-m", "annotated tag commit"], cwd=xtl_path, check=True + ) + + # Create an annotated tag + subprocess.run( + ["git", "tag", "-a", "v2.0.0", "-m", "Version 2.0.0"], cwd=xtl_path, check=True + ) + + # Run log + p_log = subprocess.run( + [git2cpp_path, "log", "-n", "1"], capture_output=True, cwd=xtl_path, text=True + ) + assert p_log.returncode == 0 + + # Check that annotated tag is shown + assert "tag: v2.0.0" in p_log.stdout + + +def test_log_with_branch(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test that branches are shown in log output.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a commit + p = xtl_path / "branch_file.txt" + p.write_text("content") + + subprocess.run([git2cpp_path, "add", "branch_file.txt"], cwd=xtl_path, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", "branch commit"], cwd=xtl_path, check=True + ) + + # Create a new branch pointing to HEAD + subprocess.run(["git", "branch", "feature-branch"], cwd=xtl_path, check=True) + + # Run log + p_log = subprocess.run( + [git2cpp_path, "log", "-n", "1"], capture_output=True, cwd=xtl_path, text=True + ) + assert p_log.returncode == 0 + + # Check that both branches are shown (HEAD -> master/main and feature-branch) + assert "feature-branch" in p_log.stdout + + +def test_log_with_remote_branches(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test that remote branches are shown in log output.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # The xtl_clone fixture already has remote branches (origin/master, etc.) + # Run log to check they appear + p_log = subprocess.run( + [git2cpp_path, "log", "-n", "1"], capture_output=True, cwd=xtl_path, text=True + ) + assert p_log.returncode == 0 + + # Check that origin remote branches are shown + assert "origin/master" in p_log.stdout + + +def test_log_commit_without_references( + xtl_clone, commit_env_config, git2cpp_path, tmp_path +): + """Test that commits without any references don't show empty parentheses.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create two commits - the second one will have refs, the first won't + for i in range(2): + p = xtl_path / f"file_{i}.txt" + p.write_text(f"content {i}") + subprocess.run([git2cpp_path, "add", f"file_{i}.txt"], cwd=xtl_path, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", f"commit {i}"], cwd=xtl_path, check=True + ) + + # Run log with 2 commits + p_log = subprocess.run( + [git2cpp_path, "log", "-n", "2"], capture_output=True, cwd=xtl_path, text=True + ) + assert p_log.returncode == 0 + + # First commit line should have references + lines = p_log.stdout.split("\n") + first_commit_line = [l for l in lines if l.startswith("commit")][0] + assert "(" in first_commit_line # Has references + + # Second commit (older one) should not have empty parentheses + second_commit_line = [l for l in lines if l.startswith("commit")][1] + # Should either have no parentheses or have actual references + if "(" in second_commit_line: + # If it has parentheses, they shouldn't be empty + assert "()" not in second_commit_line From 32b605736515ea8d507d7aba13906b155b9ed763 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 07:29:41 +0000 Subject: [PATCH 19/35] Bump actions/upload-artifact from 6 to 7 in the actions group (#112) Bumps the actions group with 1 update: [actions/upload-artifact](https://github.com/actions/upload-artifact). Updates `actions/upload-artifact` from 6 to 7 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 78072f4..3cdc82e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -87,7 +87,7 @@ jobs: genhtml coverage.lcov --output-directory outdir - name: Upload artifact containing coverage report - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: coverage_report path: outdir From 054814f0afde2c4928e0f14ae2f0bf4a598bb985 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Wed, 4 Mar 2026 09:07:49 +0000 Subject: [PATCH 20/35] Generate sphinx docs from help pages (#110) * Generate sphinx docs from help pages * Fix prebuild steps in .readthedocs.yaml --- .github/workflows/test.yml | 38 +++++++++++++++++ .gitignore | 3 ++ .readthedocs.yaml | 21 ++++++++++ README.md | 19 +++++++++ docs/Makefile | 18 ++++++++ docs/conf.py | 20 +++++++++ docs/create_markdown.py | 85 ++++++++++++++++++++++++++++++++++++++ docs/index.md | 17 ++++++++ 8 files changed, 221 insertions(+) create mode 100644 .readthedocs.yaml create mode 100644 docs/Makefile create mode 100644 docs/conf.py create mode 100644 docs/create_markdown.py create mode 100644 docs/index.md diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3cdc82e..98240a4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -98,3 +98,41 @@ jobs: files: coverage.lcov token: ${{ secrets.CODECOV_TOKEN }} verbose: true + + docs: + name: 'Build docs' + runs-on: ubuntu-latest + steps: + - name: Checkout source + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Create micromamba environment + uses: mamba-org/setup-micromamba@main + with: + environment-file: dev-environment.yml + cache-environment: true + + - name: Configure CMake + run: | + cmake -Bbuild -DCMAKE_INSTALL_PREFIX=$CONDA_PREFIX + + - name: Build with CMake + working-directory: build + run: cmake --build . --parallel 8 + + - name: Install docs dependencies + run: | + python -m pip install myst-parser sphinx sphinx-book-theme + + - name: Build docs + working-directory: docs + run: | + make html + + - name: Upload built docs + uses: actions/upload-artifact@v6 + with: + name: git2cpp-docs + path: docs/_build/html diff --git a/.gitignore b/.gitignore index 1061b39..8a84302 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ __pycache__/ compile_commands.json serve.log test/test-results/ + +docs/_build/ +docs/created/ 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/README.md b/README.md index b9cc19f..2f8d445 100644 --- a/README.md +++ b/README.md @@ -38,3 +38,22 @@ 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/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/conf.py b/docs/conf.py new file mode 100644 index 0000000..3bf0cde --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,20 @@ +from datetime import date + +project = "git2cpp" +author = "QuantStack" +copyright = f"2025-{date.today().year}" + +extensions = [ + "myst_parser", +] + +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +html_static_path = [] +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" diff --git a/docs/create_markdown.py b/docs/create_markdown.py new file mode 100644 index 0000000..7cb59fe --- /dev/null +++ b/docs/create_markdown.py @@ -0,0 +1,85 @@ +import os +from pathlib import Path +import re +import subprocess + + +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">") + + # 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/index.md b/docs/index.md new file mode 100644 index 0000000..462e7d4 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,17 @@ +# 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 (the +[cockle](https://github.com/jupyterlite/cockle) and +[JupyterLite terminal](https://github.com/jupyterlite/terminal) 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). + +```{toctree} +:caption: Help pages +:hidden: +created/git2cpp +``` From fdfd07392d5b53138d532a98537b298d43f42e87 Mon Sep 17 00:00:00 2001 From: Johan Mabille Date: Wed, 4 Mar 2026 14:07:30 +0100 Subject: [PATCH 21/35] Badges (#114) * Removed docs workflow * Added badges to README --- .github/workflows/test.yml | 38 -------------------------------------- README.md | 2 ++ 2 files changed, 2 insertions(+), 38 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 98240a4..3cdc82e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -98,41 +98,3 @@ jobs: files: coverage.lcov token: ${{ secrets.CODECOV_TOKEN }} verbose: true - - docs: - name: 'Build docs' - runs-on: ubuntu-latest - steps: - - name: Checkout source - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Create micromamba environment - uses: mamba-org/setup-micromamba@main - with: - environment-file: dev-environment.yml - cache-environment: true - - - name: Configure CMake - run: | - cmake -Bbuild -DCMAKE_INSTALL_PREFIX=$CONDA_PREFIX - - - name: Build with CMake - working-directory: build - run: cmake --build . --parallel 8 - - - name: Install docs dependencies - run: | - python -m pip install myst-parser sphinx sphinx-book-theme - - - name: Build docs - working-directory: docs - run: | - make html - - - name: Upload built docs - uses: actions/upload-artifact@v6 - with: - name: git2cpp-docs - path: docs/_build/html diff --git a/README.md b/README.md index 2f8d445..f498f03 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ # 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) 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 From e0f7ab945d39b7da8aebf5c3d1bdecb658739237 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Wed, 4 Mar 2026 17:30:16 +0100 Subject: [PATCH 22/35] Reduce use of xtl_clone in tests (#113) --- test/conftest.py | 28 ++- test/test_add.py | 28 +-- test/test_branch.py | 47 +++-- test/test_checkout.py | 140 +++++++------ test/test_commit.py | 25 ++- test/test_config.py | 10 + test/test_diff.py | 254 ++++++++++++------------ test/test_log.py | 150 ++++++++------ test/test_merge.py | 131 ++++++++----- test/test_mv.py | 148 +++++++------- test/test_rebase.py | 445 ++++++++++++++++++++++++++---------------- test/test_reset.py | 23 ++- test/test_revlist.py | 31 +-- test/test_rm.py | 192 +++++++++--------- test/test_stash.py | 79 ++++---- test/test_status.py | 253 ++++++++++++++++-------- test/test_tag.py | 304 ++++++++++++++++------------- 17 files changed, 1328 insertions(+), 960 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index ea11f67..7ed4a75 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -4,11 +4,12 @@ import pytest -GIT2CPP_TEST_WASM = os.getenv('GIT2CPP_TEST_WASM') == "1" +GIT2CPP_TEST_WASM = os.getenv("GIT2CPP_TEST_WASM") == "1" if GIT2CPP_TEST_WASM: from .conftest_wasm import * + # Fixture to run test in current tmp_path @pytest.fixture def run_in_tmp_path(tmp_path): @@ -21,9 +22,10 @@ def run_in_tmp_path(tmp_path): @pytest.fixture(scope="session") def git2cpp_path(): if GIT2CPP_TEST_WASM: - return 'git2cpp' + return "git2cpp" else: - return Path(__file__).parent.parent / 'build' / 'git2cpp' + return Path(__file__).parent.parent / "build" / "git2cpp" + @pytest.fixture def xtl_clone(git2cpp_path, tmp_path, run_in_tmp_path): @@ -39,10 +41,28 @@ def commit_env_config(monkeypatch): "GIT_AUTHOR_NAME": "Jane Doe", "GIT_AUTHOR_EMAIL": "jane.doe@blabla.com", "GIT_COMMITTER_NAME": "Jane Doe", - "GIT_COMMITTER_EMAIL": "jane.doe@blabla.com" + "GIT_COMMITTER_EMAIL": "jane.doe@blabla.com", } for key, value in config.items(): if GIT2CPP_TEST_WASM: subprocess.run(["export", f"{key}='{value}'"], check=True) else: monkeypatch.setenv(key, value) + + +@pytest.fixture +def repo_init_with_commit(commit_env_config, git2cpp_path, tmp_path, run_in_tmp_path): + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path, text=True) + assert p_init.returncode == 0 + + p = tmp_path / "initial.txt" + p.write_text("initial") + + cmd_add = [git2cpp_path, "add", "initial.txt"] + p_add = subprocess.run(cmd_add, capture_output=True, cwd=tmp_path, text=True) + assert p_add.returncode == 0 + + cmd_commit = [git2cpp_path, "commit", "-m", "Initial commit"] + p_commit = subprocess.run(cmd_commit, capture_output=True, cwd=tmp_path, text=True) + assert p_commit.returncode == 0 diff --git a/test/test_add.py b/test/test_add.py index 8c19f63..0e537f5 100644 --- a/test/test_add.py +++ b/test/test_add.py @@ -4,26 +4,27 @@ @pytest.mark.parametrize("all_flag", ["", "-A", "--all", "--no-ignore-removal"]) -def test_add(xtl_clone, git2cpp_path, tmp_path, all_flag): - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" +def test_add(git2cpp_path, tmp_path, all_flag): + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 - p = xtl_path / "mook_file.txt" - p.write_text('') + p = tmp_path / "mook_file.txt" + p.write_text("") - p2 = xtl_path / "mook_file_2.txt" - p2.write_text('') + p2 = tmp_path / "mook_file_2.txt" + p2.write_text("") - cmd_add = [git2cpp_path, 'add'] + cmd_add = [git2cpp_path, "add"] if all_flag != "": cmd_add.append(all_flag) else: cmd_add.append("mook_file.txt") - p_add = subprocess.run(cmd_add, cwd=xtl_path, text=True) + p_add = subprocess.run(cmd_add, cwd=tmp_path, text=True) assert p_add.returncode == 0 - cmd_status = [git2cpp_path, 'status', "--long"] - p_status = subprocess.run(cmd_status, cwd=xtl_path, capture_output=True, text=True) + cmd_status = [git2cpp_path, "status", "--long"] + p_status = subprocess.run(cmd_status, cwd=tmp_path, capture_output=True, text=True) assert p_status.returncode == 0 assert "Changes to be committed" in p_status.stdout @@ -33,11 +34,12 @@ def test_add(xtl_clone, git2cpp_path, tmp_path, all_flag): else: assert "Untracked files" in p_status.stdout + def test_add_nogit(git2cpp_path, tmp_path): p = tmp_path / "mook_file.txt" - p.write_text('') + p.write_text("") - cmd_add = [git2cpp_path, 'add', 'mook_file.txt'] + cmd_add = [git2cpp_path, "add", "mook_file.txt"] p_add = subprocess.run(cmd_add, cwd=tmp_path, text=True, capture_output=True) assert p_add.returncode != 0 assert "error: could not find repository at" in p_add.stderr diff --git a/test/test_branch.py b/test/test_branch.py index b9dd30c..ffaf29b 100644 --- a/test/test_branch.py +++ b/test/test_branch.py @@ -3,52 +3,51 @@ import pytest -def test_branch_list(xtl_clone, git2cpp_path, tmp_path): - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" +def test_branch_list(repo_init_with_commit, git2cpp_path, tmp_path): + assert (tmp_path / "initial.txt").exists() - cmd = [git2cpp_path, 'branch'] - p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + cmd = [git2cpp_path, "branch"] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 - assert(p.stdout == '* master\n') + assert "* ma" in p.stdout -def test_branch_create_delete(xtl_clone, git2cpp_path, tmp_path): - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" +def test_branch_create_delete(repo_init_with_commit, git2cpp_path, tmp_path): + assert (tmp_path / "initial.txt").exists() - create_cmd = [git2cpp_path, 'branch', 'foregone'] - p_create = subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, text=True) + create_cmd = [git2cpp_path, "branch", "foregone"] + p_create = subprocess.run(create_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_create.returncode == 0 - list_cmd = [git2cpp_path, 'branch'] - p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + list_cmd = [git2cpp_path, "branch"] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_list.returncode == 0 - assert(p_list.stdout == ' foregone\n* master\n') + assert " foregone\n* ma" in p_list.stdout - del_cmd = [git2cpp_path, 'branch', '-d', 'foregone'] - p_del = subprocess.run(del_cmd, capture_output=True, cwd=xtl_path, text=True) + del_cmd = [git2cpp_path, "branch", "-d", "foregone"] + p_del = subprocess.run(del_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_del.returncode == 0 - p_list2 = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + p_list2 = subprocess.run(list_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_list2.returncode == 0 - assert(p_list2.stdout == '* master\n') + assert "* ma" in p_list2.stdout + def test_branch_nogit(git2cpp_path, tmp_path): - cmd = [git2cpp_path, 'branch'] + cmd = [git2cpp_path, "branch"] p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) assert p.returncode != 0 assert "error: could not find repository at" in p.stderr def test_branch_new_repo(git2cpp_path, tmp_path, run_in_tmp_path): - # tmp_path exists and is empty. + # tmp_path exists and is empty. assert list(tmp_path.iterdir()) == [] - cmd = [git2cpp_path, 'init'] - p = subprocess.run(cmd, cwd = tmp_path) + cmd = [git2cpp_path, "init"] + subprocess.run(cmd, cwd=tmp_path, check=True) - branch_cmd = [git2cpp_path, 'branch'] - p_branch = subprocess.run(branch_cmd, cwd = tmp_path) + branch_cmd = [git2cpp_path, "branch"] + p_branch = subprocess.run(branch_cmd, cwd=tmp_path) assert p_branch.returncode == 0 diff --git a/test/test_checkout.py b/test/test_checkout.py index 8a32501..b9ac96a 100644 --- a/test/test_checkout.py +++ b/test/test_checkout.py @@ -3,94 +3,106 @@ import pytest -def test_checkout(xtl_clone, git2cpp_path, tmp_path): - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" +def test_checkout(repo_init_with_commit, git2cpp_path, tmp_path): + assert (tmp_path / "initial.txt").exists() + + default_branch = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + cwd=tmp_path, + text=True, + check=True, + ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented create_cmd = [git2cpp_path, "branch", "foregone"] - p_create = subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, text=True) + p_create = subprocess.run(create_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_create.returncode == 0 checkout_cmd = [git2cpp_path, "checkout", "foregone"] p_checkout = subprocess.run( - checkout_cmd, capture_output=True, cwd=xtl_path, text=True + checkout_cmd, capture_output=True, cwd=tmp_path, text=True ) assert p_checkout.returncode == 0 assert "Switched to branch 'foregone'" in p_checkout.stdout branch_cmd = [git2cpp_path, "branch"] - p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=xtl_path, text=True) + p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_branch.returncode == 0 - assert p_branch.stdout == "* foregone\n master\n" + assert p_branch.stdout == f"* foregone\n {default_branch}\n" - checkout_cmd[2] = "master" + checkout_cmd[2] = default_branch p_checkout2 = subprocess.run( - checkout_cmd, capture_output=True, cwd=xtl_path, text=True + checkout_cmd, capture_output=True, cwd=tmp_path, text=True ) assert p_checkout2.returncode == 0 - assert "Switched to branch 'master'" in p_checkout2.stdout + assert f"Switched to branch '{default_branch}'" in p_checkout2.stdout + +def test_checkout_b(repo_init_with_commit, git2cpp_path, tmp_path): + assert (tmp_path / "initial.txt").exists() -def test_checkout_b(xtl_clone, git2cpp_path, tmp_path): - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + default_branch = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + cwd=tmp_path, + text=True, + check=True, + ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented checkout_cmd = [git2cpp_path, "checkout", "-b", "foregone"] p_checkout = subprocess.run( - checkout_cmd, capture_output=True, cwd=xtl_path, text=True + checkout_cmd, capture_output=True, cwd=tmp_path, text=True ) assert p_checkout.returncode == 0 assert "Switched to a new branch 'foregone'" in p_checkout.stdout branch_cmd = [git2cpp_path, "branch"] - p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=xtl_path, text=True) + p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_branch.returncode == 0 - assert p_branch.stdout == "* foregone\n master\n" + assert p_branch.stdout == f"* foregone\n {default_branch}\n" checkout_cmd.remove("-b") - checkout_cmd[2] = "master" - p_checkout2 = subprocess.run(checkout_cmd, cwd=xtl_path, text=True) + checkout_cmd[2] = default_branch + p_checkout2 = subprocess.run(checkout_cmd, cwd=tmp_path, text=True) assert p_checkout2.returncode == 0 - p_branch2 = subprocess.run(branch_cmd, capture_output=True, cwd=xtl_path, text=True) + p_branch2 = subprocess.run(branch_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_branch2.returncode == 0 - assert p_branch2.stdout == " foregone\n* master\n" + assert p_branch2.stdout == f" foregone\n* {default_branch}\n" -def test_checkout_B_force_create(xtl_clone, git2cpp_path, tmp_path): +def test_checkout_B_force_create(repo_init_with_commit, git2cpp_path, tmp_path): """Test checkout -B to force create or reset a branch""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() # Create a branch first create_cmd = [git2cpp_path, "branch", "resetme"] - p_create = subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, text=True) + p_create = subprocess.run(create_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_create.returncode == 0 # Use -B to reset it (should not fail even if branch exists) checkout_cmd = [git2cpp_path, "checkout", "-B", "resetme"] p_checkout = subprocess.run( - checkout_cmd, capture_output=True, cwd=xtl_path, text=True + checkout_cmd, capture_output=True, cwd=tmp_path, text=True ) assert p_checkout.returncode == 0 assert "Switched to a new branch 'resetme'" in p_checkout.stdout # Verify we're on the branch branch_cmd = [git2cpp_path, "branch"] - p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=xtl_path, text=True) + p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_branch.returncode == 0 assert "* resetme" in p_branch.stdout -def test_checkout_invalid_branch(xtl_clone, git2cpp_path, tmp_path): +def test_checkout_invalid_branch(repo_init_with_commit, git2cpp_path, tmp_path): """Test that checkout fails gracefully with invalid branch name""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() # Try to checkout non-existent branch checkout_cmd = [git2cpp_path, "checkout", "nonexistent"] p_checkout = subprocess.run( - checkout_cmd, capture_output=True, cwd=xtl_path, text=True + checkout_cmd, capture_output=True, cwd=tmp_path, text=True ) # Should fail with error message @@ -98,64 +110,70 @@ def test_checkout_invalid_branch(xtl_clone, git2cpp_path, tmp_path): assert "error: could not resolve pathspec 'nonexistent'" in p_checkout.stderr -def test_checkout_with_unstaged_changes(xtl_clone, git2cpp_path, tmp_path): +def test_checkout_with_unstaged_changes(repo_init_with_commit, git2cpp_path, tmp_path): """Test that checkout shows unstaged changes when switching branches""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + initial_file = tmp_path / "initial.txt" + assert (initial_file).exists() # Create a new branch create_cmd = [git2cpp_path, "branch", "newbranch"] - p_create = subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, text=True) + p_create = subprocess.run(create_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_create.returncode == 0 # Modify a file (unstaged change) - readme_path = xtl_path / "README.md" - readme_path.write_text("Modified content") + initial_file.write_text("Modified content") # Checkout - should succeed and show the modified file status checkout_cmd = [git2cpp_path, "checkout", "newbranch"] p_checkout = subprocess.run( - checkout_cmd, capture_output=True, cwd=xtl_path, text=True + checkout_cmd, capture_output=True, cwd=tmp_path, text=True ) # Should succeed and show status assert p_checkout.returncode == 0 - assert " M README.md" in p_checkout.stdout + assert " M initial.txt" in p_checkout.stdout assert "Switched to branch 'newbranch'" in p_checkout.stdout @pytest.mark.parametrize("force_flag", ["", "-f", "--force"]) def test_checkout_refuses_overwrite( - xtl_clone, commit_env_config, git2cpp_path, tmp_path, force_flag + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path, force_flag ): """Test that checkout refuses to switch when local changes would be overwritten, and switches when using --force""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + initial_file = tmp_path / "initial.txt" + assert (initial_file).exists() + + default_branch = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + cwd=tmp_path, + text=True, + check=True, + ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented # Create a new branch and switch to it create_cmd = [git2cpp_path, "checkout", "-b", "newbranch"] - p_create = subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, text=True) + p_create = subprocess.run(create_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_create.returncode == 0 - # Modify README.md and commit it on newbranch - readme_path = xtl_path / "README.md" - readme_path.write_text("Content on newbranch") + # Modify initial.txt and commit it on newbranch + initial_file.write_text("Content on newbranch") - add_cmd = [git2cpp_path, "add", "README.md"] - subprocess.run(add_cmd, cwd=xtl_path, text=True) + add_cmd = [git2cpp_path, "add", "initial.txt"] + subprocess.run(add_cmd, cwd=tmp_path, text=True) commit_cmd = [git2cpp_path, "commit", "-m", "Change on newbranch"] - subprocess.run(commit_cmd, cwd=xtl_path, text=True) + subprocess.run(commit_cmd, cwd=tmp_path, text=True) - # Switch back to master - checkout_master_cmd = [git2cpp_path, "checkout", "master"] - p_master = subprocess.run( - checkout_master_cmd, capture_output=True, cwd=xtl_path, text=True + # Switch back to default branch + checkout_default_cmd = [git2cpp_path, "checkout", default_branch] + p_default = subprocess.run( + checkout_default_cmd, capture_output=True, cwd=tmp_path, text=True ) - assert p_master.returncode == 0 + assert p_default.returncode == 0 - # Now modify README.md locally (unstaged) on master - readme_path.write_text("Local modification on master") + # Now modify initial.txt locally (unstaged) on default branch + initial_file.write_text(f"Local modification on {default_branch}") # Try to checkout newbranch checkout_cmd = [git2cpp_path, "checkout"] @@ -163,7 +181,7 @@ def test_checkout_refuses_overwrite( checkout_cmd.append(force_flag) checkout_cmd.append("newbranch") p_checkout = subprocess.run( - checkout_cmd, capture_output=True, cwd=xtl_path, text=True + checkout_cmd, capture_output=True, cwd=tmp_path, text=True ) if force_flag == "": @@ -172,24 +190,24 @@ def test_checkout_refuses_overwrite( "Your local changes to the following files would be overwritten by checkout:" in p_checkout.stdout ) - assert "README.md" in p_checkout.stdout + assert "initial.txt" in p_checkout.stdout assert ( "Please commit your changes or stash them before you switch branches" in p_checkout.stdout ) - # Verify we're still on master (didn't switch) + # Verify we're still on default branch (didn't switch) branch_cmd = [git2cpp_path, "branch"] p_branch = subprocess.run( - branch_cmd, capture_output=True, cwd=xtl_path, text=True + branch_cmd, capture_output=True, cwd=tmp_path, text=True ) - assert "* master" in p_branch.stdout + assert f"* {default_branch}" in p_branch.stdout else: assert "Switched to branch 'newbranch'" in p_checkout.stdout # Verify we switched to newbranch branch_cmd = [git2cpp_path, "branch"] p_branch = subprocess.run( - branch_cmd, capture_output=True, cwd=xtl_path, text=True + branch_cmd, capture_output=True, cwd=tmp_path, text=True ) assert "* newbranch" in p_branch.stdout diff --git a/test/test_commit.py b/test/test_commit.py index c628d78..1e1c4a0 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -4,38 +4,41 @@ @pytest.mark.parametrize("all_flag", ["", "-A", "--all", "--no-ignore-removal"]) -def test_commit(xtl_clone, commit_env_config, git2cpp_path, tmp_path, all_flag): - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" +def test_commit(commit_env_config, git2cpp_path, tmp_path, all_flag): + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 - p = xtl_path / "mook_file.txt" + p = tmp_path / "mook_file.txt" p.write_text("") cmd_add = [git2cpp_path, "add", "mook_file.txt"] - p_add = subprocess.run(cmd_add, cwd=xtl_path, text=True) + p_add = subprocess.run(cmd_add, cwd=tmp_path, text=True) assert p_add.returncode == 0 cmd_status = [git2cpp_path, "status", "--long"] - p_status = subprocess.run(cmd_status, capture_output=True, cwd=xtl_path, text=True) + p_status = subprocess.run(cmd_status, capture_output=True, cwd=tmp_path, text=True) assert p_status.returncode == 0 assert "Changes to be committed" in p_status.stdout assert "new file" in p_status.stdout cmd_commit = [git2cpp_path, "commit", "-m", "test commit"] - p_commit = subprocess.run(cmd_commit, cwd=xtl_path, text=True) + p_commit = subprocess.run(cmd_commit, cwd=tmp_path, text=True) assert p_commit.returncode == 0 cmd_status_2 = [git2cpp_path, "status", "--long"] p_status_2 = subprocess.run( - cmd_status_2, capture_output=True, cwd=xtl_path, text=True + cmd_status_2, capture_output=True, cwd=tmp_path, text=True ) assert p_status_2.returncode == 0 assert "mook_file" not in p_status_2.stdout @pytest.mark.parametrize("commit_msg", ["Added file", ""]) -def test_commit_message_via_stdin(commit_env_config, git2cpp_path, tmp_path, run_in_tmp_path, commit_msg): +def test_commit_message_via_stdin( + commit_env_config, git2cpp_path, tmp_path, run_in_tmp_path, commit_msg +): cmd = [git2cpp_path, "init", "."] p_init = subprocess.run(cmd) assert p_init.returncode == 0 @@ -47,7 +50,9 @@ def test_commit_message_via_stdin(commit_env_config, git2cpp_path, tmp_path, run assert p_add.returncode == 0 cmd_commit = [git2cpp_path, "commit"] - p_commit = subprocess.run(cmd_commit, text=True, capture_output=True, input=commit_msg) + p_commit = subprocess.run( + cmd_commit, text=True, capture_output=True, input=commit_msg + ) if commit_msg == "": # No commit message diff --git a/test/test_config.py b/test/test_config.py index cecb720..f734042 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -54,3 +54,13 @@ def test_config_unset(git2cpp_path, tmp_path): p_get = subprocess.run(cmd_get, capture_output=True, cwd=tmp_path, text=True) assert p_get.returncode != 0 assert p_get.stderr == "error: config value 'core.bare' was not found\n" + + +def test_config_get_missing_name_exit_code(git2cpp_path, tmp_path): + p = subprocess.run( + [git2cpp_path, "config", "get"], + capture_output=True, + text=True, + cwd=tmp_path, + ) + assert p.returncode == 129 diff --git a/test/test_diff.py b/test/test_diff.py index 6dd5f1a..ee1fdb5 100644 --- a/test/test_diff.py +++ b/test/test_diff.py @@ -11,51 +11,53 @@ def test_diff_nogit(git2cpp_path, tmp_path): assert "repository" in p.stderr.lower() or "not a git" in p.stderr.lower() -def test_diff_working_directory(xtl_clone, git2cpp_path, tmp_path): - xtl_path = tmp_path / "xtl" +def test_diff_working_directory(repo_init_with_commit, git2cpp_path, tmp_path): + initial_file = tmp_path / "initial.txt" + assert (initial_file).exists() - readme = xtl_path / "README.md" - original_content = readme.read_text() - readme.write_text(original_content + "\nNew line added") + original_content = initial_file.read_text() + initial_file.write_text(original_content + "\nNew line added") cmd = [git2cpp_path, "diff"] - p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 - assert "README.md" in p.stdout - assert "New line added" in p.stdout # should be "+New line added" + assert "initial.txt" in p.stdout + assert "+New line added" in p.stdout @pytest.mark.parametrize("cached_flag", ["--cached", "--staged"]) -def test_diff_cached(xtl_clone, git2cpp_path, tmp_path, cached_flag): - xtl_path = tmp_path / "xtl" +def test_diff_cached(repo_init_with_commit, git2cpp_path, tmp_path, cached_flag): + assert (tmp_path / "initial.txt").exists() - new_file = xtl_path / "new_file.txt" + new_file = tmp_path / "new_file.txt" new_file.write_text("Hello, world!") cmd_add = [git2cpp_path, "add", "new_file.txt"] - subprocess.run(cmd_add, cwd=xtl_path, check=True) + subprocess.run(cmd_add, cwd=tmp_path, check=True) cmd_diff = [git2cpp_path, "diff", cached_flag] - p_diff = subprocess.run(cmd_diff, capture_output=True, cwd=xtl_path, text=True) + p_diff = subprocess.run(cmd_diff, capture_output=True, cwd=tmp_path, text=True) assert p_diff.returncode == 0 assert "new_file.txt" in p_diff.stdout assert "+Hello, world!" in p_diff.stdout -def test_diff_two_commits(xtl_clone, commit_env_config, git2cpp_path, tmp_path): - xtl_path = tmp_path / "xtl" +def test_diff_two_commits( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path +): + assert (tmp_path / "initial.txt").exists() - new_file = xtl_path / "new_file.txt" + new_file = tmp_path / "new_file.txt" new_file.write_text("Hello, world!") cmd_add = [git2cpp_path, "add", "new_file.txt"] - subprocess.run(cmd_add, cwd=xtl_path, check=True) + subprocess.run(cmd_add, cwd=tmp_path, check=True) cmd_commit = [git2cpp_path, "commit", "-m", "new commit"] - subprocess.run(cmd_commit, cwd=xtl_path, check=True) + subprocess.run(cmd_commit, cwd=tmp_path, check=True) cmd_diff = [git2cpp_path, "diff", "HEAD~1", "HEAD"] - p_diff = subprocess.run(cmd_diff, capture_output=True, cwd=xtl_path, text=True) + p_diff = subprocess.run(cmd_diff, capture_output=True, cwd=tmp_path, text=True) assert p_diff.returncode == 0 assert "new_file.txt" in p_diff.stdout assert "+Hello, world!" in p_diff.stdout @@ -75,208 +77,212 @@ def test_diff_no_index(git2cpp_path, tmp_path): assert "+Python" in p.stdout -def test_diff_stat(xtl_clone, git2cpp_path, tmp_path): - xtl_path = tmp_path / "xtl" +def test_diff_stat(repo_init_with_commit, git2cpp_path, tmp_path): + initial_file = tmp_path / "initial.txt" + assert (initial_file).exists() - readme = xtl_path / "README.md" - readme.write_text("Modified content\n") + initial_file.write_text("Modified content\n") cmd = [git2cpp_path, "diff", "--stat"] - p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 - assert "README.md" in p.stdout + assert "initial.txt" in p.stdout assert "1 file changed, 1 insertion(+)" in p.stdout assert "Modified content" not in p.stdout -def test_diff_shortstat(xtl_clone, git2cpp_path, tmp_path): +def test_diff_shortstat(repo_init_with_commit, git2cpp_path, tmp_path): """Test diff with --shortstat (last line of --stat only)""" - xtl_path = tmp_path / "xtl" + initial_file = tmp_path / "initial.txt" + assert (initial_file).exists() - readme = xtl_path / "README.md" - readme.write_text("Modified content\n") + initial_file.write_text("Modified content\n") cmd = [git2cpp_path, "diff", "--shortstat"] - p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 assert "README.md" not in p.stdout assert "1 file changed, 1 insertion(+)" in p.stdout assert "Modified content" not in p.stdout -def test_diff_numstat(xtl_clone, git2cpp_path, tmp_path): +def test_diff_numstat(repo_init_with_commit, git2cpp_path, tmp_path): """Test diff with --numstat (machine-friendly stat)""" - xtl_path = tmp_path / "xtl" + initial_file = tmp_path / "initial.txt" + assert (initial_file).exists() - readme = xtl_path / "README.md" - readme.write_text("Modified content\n") + initial_file.write_text("Modified content\n") cmd = [git2cpp_path, "diff", "--numstat"] - p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 - assert "README.md" in p.stdout + assert "initial.txt" in p.stdout assert bool(re.search("1 [0-9]*", p.stdout)) assert "Modified content" not in p.stdout -def test_diff_summary(xtl_clone, git2cpp_path, tmp_path): +def test_diff_summary(repo_init_with_commit, git2cpp_path, tmp_path): """Test diff with --summary""" - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() - new_file = xtl_path / "newfile.txt" + new_file = tmp_path / "newfile.txt" new_file.write_text("New content") cmd_add = [git2cpp_path, "add", "newfile.txt"] - subprocess.run(cmd_add, cwd=xtl_path, check=True) + subprocess.run(cmd_add, cwd=tmp_path, check=True) cmd = [git2cpp_path, "diff", "--cached", "--summary"] - p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 assert "newfile.txt" in p.stdout assert "+" not in p.stdout -def test_diff_name_only(xtl_clone, git2cpp_path, tmp_path): - xtl_path = tmp_path / "xtl" +def test_diff_name_only(repo_init_with_commit, git2cpp_path, tmp_path): + initial_file = tmp_path / "initial.txt" + assert (initial_file).exists() - (xtl_path / "README.md").write_text("Modified") + initial_file.write_text("Modified") cmd = [git2cpp_path, "diff", "--name-only"] - p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 - assert p.stdout == "README.md\n" + assert p.stdout == "initial.txt\n" assert "+" not in p.stdout -def test_diff_name_status(xtl_clone, git2cpp_path, tmp_path): - xtl_path = tmp_path / "xtl" +def test_diff_name_status(repo_init_with_commit, git2cpp_path, tmp_path): + initial_file = tmp_path / "initial.txt" + assert (initial_file).exists() - (xtl_path / "README.md").write_text("Modified") + initial_file.write_text("Modified") cmd = [git2cpp_path, "diff", "--name-status"] - p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 - assert p.stdout == "M\tREADME.md\n" + assert p.stdout == "M\tinitial.txt\n" -def test_diff_raw(xtl_clone, git2cpp_path, tmp_path): +def test_diff_raw(repo_init_with_commit, git2cpp_path, tmp_path): """Test diff with --raw format""" - xtl_path = tmp_path / "xtl" + initial_file = tmp_path / "initial.txt" + assert (initial_file).exists() - readme = xtl_path / "README.md" - readme.write_text("Modified") + initial_file.write_text("Modified") cmd = [git2cpp_path, "diff", "--raw"] - p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 - assert "M\tREADME.md" in p.stdout + assert "M\tinitial.txt" in p.stdout assert bool(re.search(":[0-9]*", p.stdout)) -def test_diff_reverse(xtl_clone, git2cpp_path, tmp_path): - xtl_path = tmp_path / "xtl" +def test_diff_reverse(repo_init_with_commit, git2cpp_path, tmp_path): + initial_file = tmp_path / "initial.txt" + assert (initial_file).exists() - readme = xtl_path / "README.md" - original = readme.read_text() - readme.write_text(original + "\nAdded line") + original = initial_file.read_text() + initial_file.write_text(original + "\nAdded line") cmd_normal = [git2cpp_path, "diff"] - p_normal = subprocess.run(cmd_normal, capture_output=True, cwd=xtl_path, text=True) + p_normal = subprocess.run(cmd_normal, capture_output=True, cwd=tmp_path, text=True) assert p_normal.returncode == 0 assert "+Added line" in p_normal.stdout cmd_reverse = [git2cpp_path, "diff", "-R"] p_reverse = subprocess.run( - cmd_reverse, capture_output=True, cwd=xtl_path, text=True + cmd_reverse, capture_output=True, cwd=tmp_path, text=True ) assert p_reverse.returncode == 0 assert "-Added line" in p_reverse.stdout @pytest.mark.parametrize("text_flag", ["-a", "--text"]) -def test_diff_text(xtl_clone, commit_env_config, git2cpp_path, tmp_path, text_flag): +def test_diff_text( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path, text_flag +): """Test diff with -a/--text (treat all files as text)""" - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() - binary_file = xtl_path / "binary.bin" + binary_file = tmp_path / "binary.bin" binary_file.write_bytes(b"\x00\x01\x02\x03") cmd_add = [git2cpp_path, "add", "binary.bin"] - subprocess.run(cmd_add, cwd=xtl_path, check=True) + subprocess.run(cmd_add, cwd=tmp_path, check=True) cmd_commit = [git2cpp_path, "commit", "-m", "add binary"] - subprocess.run(cmd_commit, cwd=xtl_path, check=True) + subprocess.run(cmd_commit, cwd=tmp_path, check=True) binary_file.write_bytes(b"\x00\x01\x02\x04") cmd_text = [git2cpp_path, "diff", text_flag] - p = subprocess.run(cmd_text, capture_output=True, cwd=xtl_path, text=True) + p = subprocess.run(cmd_text, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 assert "binary.bin" in p.stdout assert "@@" in p.stdout -def test_diff_ignore_space_at_eol(xtl_clone, git2cpp_path, tmp_path): +def test_diff_ignore_space_at_eol(repo_init_with_commit, git2cpp_path, tmp_path): """Test diff with --ignore-space-at-eol""" - xtl_path = tmp_path / "xtl" + initial_file = tmp_path / "initial.txt" + assert (initial_file).exists() - readme = xtl_path / "README.md" - original = readme.read_text() + original = initial_file.read_text() # Add trailing spaces at end of line - readme.write_text(original.rstrip() + " \n") + initial_file.write_text(original.rstrip() + " \n") cmd = [git2cpp_path, "diff", "--ignore-space-at-eol"] - p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 assert p.stdout == "" @pytest.mark.parametrize("space_change_flag", ["-b", "--ignore-space-change"]) def test_diff_ignore_space_change( - xtl_clone, commit_env_config, git2cpp_path, tmp_path, space_change_flag + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path, space_change_flag ): """Test diff with -b/--ignore-space-change""" - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() - test_file = xtl_path / "test.txt" + test_file = tmp_path / "test.txt" test_file.write_text("Hello world\n") cmd_add = [git2cpp_path, "add", "test.txt"] - subprocess.run(cmd_add, cwd=xtl_path, check=True) + subprocess.run(cmd_add, cwd=tmp_path, check=True) cmd_commit = [git2cpp_path, "commit", "-m", "test"] - subprocess.run(cmd_commit, cwd=xtl_path, check=True) + subprocess.run(cmd_commit, cwd=tmp_path, check=True) # Change spacing test_file.write_text("Hello world\n") cmd_diff = [git2cpp_path, "diff", space_change_flag] - p = subprocess.run(cmd_diff, capture_output=True, cwd=xtl_path, text=True) + p = subprocess.run(cmd_diff, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 assert p.stdout == "" @pytest.mark.parametrize("ignore_space_flag", ["-w", "--ignore-all-space"]) def test_diff_ignore_all_space( - xtl_clone, commit_env_config, git2cpp_path, tmp_path, ignore_space_flag + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path, ignore_space_flag ): """Test diff with -w/--ignore-all-space""" - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() - test_file = xtl_path / "test.txt" + test_file = tmp_path / "test.txt" test_file.write_text("Hello world\n") cmd_add = [git2cpp_path, "add", "test.txt"] - subprocess.run(cmd_add, cwd=xtl_path, check=True) + subprocess.run(cmd_add, cwd=tmp_path, check=True) cmd_commit = [git2cpp_path, "commit", "-m", "test"] - subprocess.run(cmd_commit, cwd=xtl_path, check=True) + subprocess.run(cmd_commit, cwd=tmp_path, check=True) test_file.write_text("Helloworld") cmd_diff = [git2cpp_path, "diff", ignore_space_flag] - p = subprocess.run(cmd_diff, capture_output=True, cwd=xtl_path, text=True) + p = subprocess.run(cmd_diff, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 assert p.stdout == "" @@ -286,7 +292,7 @@ def test_diff_ignore_all_space( [("-U0", 0), ("-U1", 1), ("-U5", 5), ("--unified=3", 3)], ) def test_diff_unified_context( - xtl_clone, + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path, @@ -294,19 +300,19 @@ def test_diff_unified_context( context_lines, ): """Test diff with -U/--unified for context lines""" - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() - test_file = xtl_path / "test.txt" + test_file = tmp_path / "test.txt" # Create a file with enough lines to see context differences test_file.write_text( "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10\n" ) cmd_add = [git2cpp_path, "add", "test.txt"] - subprocess.run(cmd_add, cwd=xtl_path, check=True) + subprocess.run(cmd_add, cwd=tmp_path, check=True) cmd_commit = [git2cpp_path, "commit", "-m", "test"] - subprocess.run(cmd_commit, cwd=xtl_path, check=True) + subprocess.run(cmd_commit, cwd=tmp_path, check=True) # Modify line 5 (middle of the file) test_file.write_text( @@ -315,7 +321,7 @@ def test_diff_unified_context( # Run diff with the parameterized flag cmd = [git2cpp_path, "diff", unified_context_flag] - p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 assert "test.txt" in p.stdout assert "MODIFIED LINE 5" in p.stdout @@ -365,19 +371,21 @@ def test_diff_unified_context( assert "Line 8" not in p.stdout or p.stdout.count("Line 8") == 0 -def test_diff_inter_hunk_context(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_diff_inter_hunk_context( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path +): """Test diff with --inter-hunk-context""" - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() - test_file = xtl_path / "test.txt" + test_file = tmp_path / "test.txt" lines = [f"Line {i}\n" for i in range(1, 31)] test_file.write_text("".join(lines)) cmd_add = [git2cpp_path, "add", "test.txt"] - subprocess.run(cmd_add, cwd=xtl_path, check=True) + subprocess.run(cmd_add, cwd=tmp_path, check=True) cmd_commit = [git2cpp_path, "commit", "-m", "test"] - subprocess.run(cmd_commit, cwd=xtl_path, check=True) + subprocess.run(cmd_commit, cwd=tmp_path, check=True) # Modify two separate sections lines[4] = "Modified Line 5\n" @@ -386,7 +394,7 @@ def test_diff_inter_hunk_context(xtl_clone, commit_env_config, git2cpp_path, tmp # Test with small inter-hunk-context (should keep hunks separate) cmd_small = [git2cpp_path, "diff", "--inter-hunk-context=1"] - p_small = subprocess.run(cmd_small, capture_output=True, cwd=xtl_path, text=True) + p_small = subprocess.run(cmd_small, capture_output=True, cwd=tmp_path, text=True) assert p_small.returncode == 0 assert "Modified Line 5" in p_small.stdout assert "Modified Line 20" in p_small.stdout @@ -403,7 +411,7 @@ def test_diff_inter_hunk_context(xtl_clone, commit_env_config, git2cpp_path, tmp # Test with large inter-hunk-context (should merge hunks into one) cmd_large = [git2cpp_path, "diff", "--inter-hunk-context=15"] - p_large = subprocess.run(cmd_large, capture_output=True, cwd=xtl_path, text=True) + p_large = subprocess.run(cmd_large, capture_output=True, cwd=tmp_path, text=True) assert p_large.returncode == 0 assert "Modified Line 5" in p_large.stdout assert "Modified Line 20" in p_large.stdout @@ -428,18 +436,18 @@ def test_diff_inter_hunk_context(xtl_clone, commit_env_config, git2cpp_path, tmp ) -def test_diff_abbrev(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_diff_abbrev(repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path): """Test diff with --abbrev for object name abbreviation""" - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() - test_file = xtl_path / "test.txt" + test_file = tmp_path / "test.txt" test_file.write_text("Original content\n") cmd_add = [git2cpp_path, "add", "test.txt"] - subprocess.run(cmd_add, cwd=xtl_path, check=True) + subprocess.run(cmd_add, cwd=tmp_path, check=True) cmd_commit = [git2cpp_path, "commit", "-m", "initial commit"] - subprocess.run(cmd_commit, cwd=xtl_path, check=True) + subprocess.run(cmd_commit, cwd=tmp_path, check=True) # Modify the file test_file.write_text("Modified content\n") @@ -447,20 +455,20 @@ def test_diff_abbrev(xtl_clone, commit_env_config, git2cpp_path, tmp_path): # Test default --abbrev cmd_default = [git2cpp_path, "diff", "--abbrev"] p_default = subprocess.run( - cmd_default, capture_output=True, cwd=xtl_path, text=True + cmd_default, capture_output=True, cwd=tmp_path, text=True ) assert p_default.returncode == 0 assert "test.txt" in p_default.stdout # Test --abbrev=7 (short hash) cmd_7 = [git2cpp_path, "diff", "--abbrev=7"] - p_7 = subprocess.run(cmd_7, capture_output=True, cwd=xtl_path, text=True) + p_7 = subprocess.run(cmd_7, capture_output=True, cwd=tmp_path, text=True) assert p_7.returncode == 0 assert "test.txt" in p_7.stdout # Test --abbrev=12 (longer hash) cmd_12 = [git2cpp_path, "diff", "--abbrev=12"] - p_12 = subprocess.run(cmd_12, capture_output=True, cwd=xtl_path, text=True) + p_12 = subprocess.run(cmd_12, capture_output=True, cwd=tmp_path, text=True) assert p_12.returncode == 0 assert "test.txt" in p_12.stdout @@ -480,41 +488,43 @@ def test_diff_abbrev(xtl_clone, commit_env_config, git2cpp_path, tmp_path): # Note: only checking if the output is a diff -def test_diff_patience(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_diff_patience( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path +): """Test diff with --patience algorithm""" - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() - test_file = xtl_path / "test.txt" + test_file = tmp_path / "test.txt" test_file.write_text("Line 1\nLine 2\nLine 3\n") cmd_add = [git2cpp_path, "add", "test.txt"] - subprocess.run(cmd_add, cwd=xtl_path, check=True) + subprocess.run(cmd_add, cwd=tmp_path, check=True) cmd_commit = [git2cpp_path, "commit", "-m", "test"] - subprocess.run(cmd_commit, cwd=xtl_path, check=True) + subprocess.run(cmd_commit, cwd=tmp_path, check=True) test_file.write_text("Line 1\nNew Line\nLine 2\nLine 3\n") cmd = [git2cpp_path, "diff"] - p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 assert "test.txt" in p.stdout assert "+New Line" in p.stdout # Note: only checking if the output is a diff -def test_diff_minimal(xtl_clone, git2cpp_path, tmp_path): +def test_diff_minimal(repo_init_with_commit, git2cpp_path, tmp_path): """Test diff with --minimal (spend extra time to find smallest diff)""" - xtl_path = tmp_path / "xtl" + initial_file = tmp_path / "initial.txt" + assert (initial_file).exists() - readme = xtl_path / "README.md" - original = readme.read_text() - readme.write_text(original + "\nExtra line\n") + original = initial_file.read_text() + initial_file.write_text(original + "\nExtra line\n") cmd = [git2cpp_path, "diff", "--minimal"] - p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 - assert "README.md" in p.stdout + assert "initial.txt" in p.stdout assert "+Extra line" in p.stdout diff --git a/test/test_log.py b/test/test_log.py index ac22fb2..f4d3e40 100644 --- a/test/test_log.py +++ b/test/test_log.py @@ -4,25 +4,26 @@ @pytest.mark.parametrize("format_flag", ["", "--format=full", "--format=fuller"]) -def test_log(xtl_clone, commit_env_config, git2cpp_path, tmp_path, format_flag): - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" +def test_log(commit_env_config, git2cpp_path, tmp_path, format_flag): + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 - p = xtl_path / "mook_file.txt" + p = tmp_path / "mook_file.txt" p.write_text("") cmd_add = [git2cpp_path, "add", "mook_file.txt"] - p_add = subprocess.run(cmd_add, cwd=xtl_path, text=True) + p_add = subprocess.run(cmd_add, cwd=tmp_path, text=True) assert p_add.returncode == 0 cmd_commit = [git2cpp_path, "commit", "-m", "test commit"] - p_commit = subprocess.run(cmd_commit, cwd=xtl_path, text=True) + p_commit = subprocess.run(cmd_commit, cwd=tmp_path, text=True) assert p_commit.returncode == 0 cmd_log = [git2cpp_path, "log"] if format_flag != "": cmd_log.append(format_flag) - p_log = subprocess.run(cmd_log, capture_output=True, cwd=xtl_path, text=True) + p_log = subprocess.run(cmd_log, capture_output=True, cwd=tmp_path, text=True) assert p_log.returncode == 0 assert "Jane Doe" in p_log.stdout assert "test commit" in p_log.stdout @@ -46,16 +47,37 @@ def test_log_nogit(commit_env_config, git2cpp_path, tmp_path): @pytest.mark.parametrize("max_count_flag", ["", "-n", "--max-count"]) def test_max_count( - xtl_clone, commit_env_config, git2cpp_path, tmp_path, max_count_flag + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path, max_count_flag ): - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() + + p2 = tmp_path / "second.txt" + p2.write_text("second file") + + cmd_add2 = [git2cpp_path, "add", "second.txt"] + subprocess.run(cmd_add2, capture_output=True, cwd=tmp_path, text=True, check=True) + + cmd_commit2 = [git2cpp_path, "commit", "-m", "Second commit"] + subprocess.run( + cmd_commit2, capture_output=True, cwd=tmp_path, text=True, check=True + ) + + p3 = tmp_path / "third.txt" + p3.write_text("third file") + + cmd_add3 = [git2cpp_path, "add", "third.txt"] + subprocess.run(cmd_add3, capture_output=True, cwd=tmp_path, text=True, check=True) + + cmd_commit3 = [git2cpp_path, "commit", "-m", "Third commit"] + subprocess.run( + cmd_commit3, capture_output=True, cwd=tmp_path, text=True, check=True + ) cmd_log = [git2cpp_path, "log"] if max_count_flag != "": cmd_log.append(max_count_flag) cmd_log.append("2") - p_log = subprocess.run(cmd_log, capture_output=True, cwd=xtl_path, text=True) + p_log = subprocess.run(cmd_log, capture_output=True, cwd=tmp_path, text=True) assert p_log.returncode == 0 if max_count_flag == "": @@ -64,23 +86,24 @@ def test_max_count( assert p_log.stdout.count("Author") == 2 -def test_log_with_head_reference(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_log_with_head_reference( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path +): """Test that HEAD reference is shown on the latest commit.""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() # Create a new commit - p = xtl_path / "test_file.txt" + p = tmp_path / "test_file.txt" p.write_text("test content") - subprocess.run([git2cpp_path, "add", "test_file.txt"], cwd=xtl_path, check=True) + subprocess.run([git2cpp_path, "add", "test_file.txt"], cwd=tmp_path, check=True) subprocess.run( - [git2cpp_path, "commit", "-m", "test commit"], cwd=xtl_path, check=True + [git2cpp_path, "commit", "-m", "test commit"], cwd=tmp_path, check=True ) # Run log with max count 1 to get only the latest commit p_log = subprocess.run( - [git2cpp_path, "log", "-n", "1"], capture_output=True, cwd=xtl_path, text=True + [git2cpp_path, "log", "-n", "1"], capture_output=True, cwd=tmp_path, text=True ) assert p_log.returncode == 0 @@ -89,26 +112,27 @@ def test_log_with_head_reference(xtl_clone, commit_env_config, git2cpp_path, tmp assert "master" in p_log.stdout or "main" in p_log.stdout -def test_log_with_tag(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_log_with_tag(commit_env_config, git2cpp_path, tmp_path): """Test that tags are shown in log output.""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 # Create a commit and tag it - p = xtl_path / "tagged_file.txt" + p = tmp_path / "tagged_file.txt" p.write_text("tagged content") - subprocess.run([git2cpp_path, "add", "tagged_file.txt"], cwd=xtl_path, check=True) + subprocess.run([git2cpp_path, "add", "tagged_file.txt"], cwd=tmp_path, check=True) subprocess.run( - [git2cpp_path, "commit", "-m", "tagged commit"], cwd=xtl_path, check=True + [git2cpp_path, "commit", "-m", "tagged commit"], cwd=tmp_path, check=True ) # Create a tag (using git command since git2cpp might not have tag creation yet) - subprocess.run(["git", "tag", "v1.0.0"], cwd=xtl_path, check=True) + subprocess.run([git2cpp_path, "tag", "v1.0.0"], cwd=tmp_path, check=True) # Run log p_log = subprocess.run( - [git2cpp_path, "log", "-n", "1"], capture_output=True, cwd=xtl_path, text=True + [git2cpp_path, "log", "-n", "1"], capture_output=True, cwd=tmp_path, text=True ) assert p_log.returncode == 0 @@ -116,30 +140,31 @@ def test_log_with_tag(xtl_clone, commit_env_config, git2cpp_path, tmp_path): assert "tag: v1.0.0" in p_log.stdout -def test_log_with_multiple_tags(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_log_with_multiple_tags(commit_env_config, git2cpp_path, tmp_path): """Test that multiple tags on the same commit are all shown.""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 # Create a commit - p = xtl_path / "multi_tag_file.txt" + p = tmp_path / "multi_tag_file.txt" p.write_text("content") subprocess.run( - [git2cpp_path, "add", "multi_tag_file.txt"], cwd=xtl_path, check=True + [git2cpp_path, "add", "multi_tag_file.txt"], cwd=tmp_path, check=True ) subprocess.run( - [git2cpp_path, "commit", "-m", "multi tag commit"], cwd=xtl_path, check=True + [git2cpp_path, "commit", "-m", "multi tag commit"], cwd=tmp_path, check=True ) # Create multiple tags - subprocess.run(["git", "tag", "v1.0.0"], cwd=xtl_path, check=True) - subprocess.run(["git", "tag", "stable"], cwd=xtl_path, check=True) - subprocess.run(["git", "tag", "latest"], cwd=xtl_path, check=True) + subprocess.run([git2cpp_path, "tag", "v1.0.0"], cwd=tmp_path, check=True) + subprocess.run([git2cpp_path, "tag", "stable"], cwd=tmp_path, check=True) + subprocess.run([git2cpp_path, "tag", "latest"], cwd=tmp_path, check=True) # Run log p_log = subprocess.run( - [git2cpp_path, "log", "-n", "1"], capture_output=True, cwd=xtl_path, text=True + [git2cpp_path, "log", "-n", "1"], capture_output=True, cwd=tmp_path, text=True ) assert p_log.returncode == 0 @@ -149,30 +174,33 @@ def test_log_with_multiple_tags(xtl_clone, commit_env_config, git2cpp_path, tmp_ assert "tag: latest" in p_log.stdout -def test_log_with_annotated_tag(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_log_with_annotated_tag(commit_env_config, git2cpp_path, tmp_path): """Test that annotated tags are shown in log output.""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 # Create a commit - p = xtl_path / "annotated_tag_file.txt" + p = tmp_path / "annotated_tag_file.txt" p.write_text("content") subprocess.run( - [git2cpp_path, "add", "annotated_tag_file.txt"], cwd=xtl_path, check=True + [git2cpp_path, "add", "annotated_tag_file.txt"], cwd=tmp_path, check=True ) subprocess.run( - [git2cpp_path, "commit", "-m", "annotated tag commit"], cwd=xtl_path, check=True + [git2cpp_path, "commit", "-m", "annotated tag commit"], cwd=tmp_path, check=True ) # Create an annotated tag subprocess.run( - ["git", "tag", "-a", "v2.0.0", "-m", "Version 2.0.0"], cwd=xtl_path, check=True + ["git", "tag", "-a", "v2.0.0", "-m", "Version 2.0.0"], + cwd=tmp_path, + check=True, ) # Run log p_log = subprocess.run( - [git2cpp_path, "log", "-n", "1"], capture_output=True, cwd=xtl_path, text=True + [git2cpp_path, "log", "-n", "1"], capture_output=True, cwd=tmp_path, text=True ) assert p_log.returncode == 0 @@ -180,26 +208,27 @@ def test_log_with_annotated_tag(xtl_clone, commit_env_config, git2cpp_path, tmp_ assert "tag: v2.0.0" in p_log.stdout -def test_log_with_branch(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_log_with_branch(commit_env_config, git2cpp_path, tmp_path): """Test that branches are shown in log output.""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 # Create a commit - p = xtl_path / "branch_file.txt" + p = tmp_path / "branch_file.txt" p.write_text("content") - subprocess.run([git2cpp_path, "add", "branch_file.txt"], cwd=xtl_path, check=True) + subprocess.run([git2cpp_path, "add", "branch_file.txt"], cwd=tmp_path, check=True) subprocess.run( - [git2cpp_path, "commit", "-m", "branch commit"], cwd=xtl_path, check=True + [git2cpp_path, "commit", "-m", "branch commit"], cwd=tmp_path, check=True ) # Create a new branch pointing to HEAD - subprocess.run(["git", "branch", "feature-branch"], cwd=xtl_path, check=True) + subprocess.run([git2cpp_path, "branch", "feature-branch"], cwd=tmp_path, check=True) # Run log p_log = subprocess.run( - [git2cpp_path, "log", "-n", "1"], capture_output=True, cwd=xtl_path, text=True + [git2cpp_path, "log", "-n", "1"], capture_output=True, cwd=tmp_path, text=True ) assert p_log.returncode == 0 @@ -223,25 +252,24 @@ def test_log_with_remote_branches(xtl_clone, commit_env_config, git2cpp_path, tm assert "origin/master" in p_log.stdout -def test_log_commit_without_references( - xtl_clone, commit_env_config, git2cpp_path, tmp_path -): +def test_log_commit_without_references(commit_env_config, git2cpp_path, tmp_path): """Test that commits without any references don't show empty parentheses.""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 # Create two commits - the second one will have refs, the first won't for i in range(2): - p = xtl_path / f"file_{i}.txt" + p = tmp_path / f"file_{i}.txt" p.write_text(f"content {i}") - subprocess.run([git2cpp_path, "add", f"file_{i}.txt"], cwd=xtl_path, check=True) + subprocess.run([git2cpp_path, "add", f"file_{i}.txt"], cwd=tmp_path, check=True) subprocess.run( - [git2cpp_path, "commit", "-m", f"commit {i}"], cwd=xtl_path, check=True + [git2cpp_path, "commit", "-m", f"commit {i}"], cwd=tmp_path, check=True ) # Run log with 2 commits p_log = subprocess.run( - [git2cpp_path, "log", "-n", "2"], capture_output=True, cwd=xtl_path, text=True + [git2cpp_path, "log", "-n", "2"], capture_output=True, cwd=tmp_path, text=True ) assert p_log.returncode == 0 diff --git a/test/test_merge.py b/test/test_merge.py index a094444..a805ff3 100644 --- a/test/test_merge.py +++ b/test/test_merge.py @@ -5,159 +5,184 @@ # TODO: Have a different "person" for the commit and for the merge # TODO: Test "unborn" case, but how ? -def test_merge_fast_forward(xtl_clone, commit_env_config, git2cpp_path, tmp_path): - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" +def test_merge_fast_forward( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path +): + assert (tmp_path / "initial.txt").exists() + + default_branch = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + cwd=tmp_path, + text=True, + check=True, + ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented checkout_cmd = [git2cpp_path, "checkout", "-b", "foregone"] p_checkout = subprocess.run( - checkout_cmd, capture_output=True, cwd=xtl_path, text=True + checkout_cmd, capture_output=True, cwd=tmp_path, text=True ) assert p_checkout.returncode == 0 - file_path = xtl_path / "mook_file.txt" + file_path = tmp_path / "mook_file.txt" file_path.write_text("blablabla") add_cmd = [git2cpp_path, "add", "mook_file.txt"] - p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + p_add = subprocess.run(add_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_add.returncode == 0 commit_cmd = [git2cpp_path, "commit", "-m", "test commit"] - p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_commit.returncode == 0 - checkout_cmd_2 = [git2cpp_path, "checkout", "master"] + checkout_cmd_2 = [git2cpp_path, "checkout", default_branch] p_checkout_2 = subprocess.run( - checkout_cmd_2, capture_output=True, cwd=xtl_path, text=True + checkout_cmd_2, capture_output=True, cwd=tmp_path, text=True ) assert p_checkout_2.returncode == 0 merge_cmd = [git2cpp_path, "merge", "foregone"] - p_merge = subprocess.run(merge_cmd, capture_output=True, cwd=xtl_path, text=True) + p_merge = subprocess.run(merge_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_merge.returncode == 0 assert "Fast-forward" in p_merge.stdout log_cmd = [git2cpp_path, "log", "--format=full", "--max-count", "1"] - p_log = subprocess.run(log_cmd, capture_output=True, cwd=xtl_path, text=True) + p_log = subprocess.run(log_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_log.returncode == 0 assert "Author: Jane Doe" in p_log.stdout # assert "Commit: John Doe" in p_log.stdout - assert (xtl_path / "mook_file.txt").exists() + assert (tmp_path / "mook_file.txt").exists() merge_cmd_2 = [git2cpp_path, "merge", "foregone"] p_merge_2 = subprocess.run( - merge_cmd_2, capture_output=True, cwd=xtl_path, text=True + merge_cmd_2, capture_output=True, cwd=tmp_path, text=True ) assert p_merge_2.returncode == 0 assert p_merge_2.stdout == "Already up-to-date\n" -def test_merge_commit(xtl_clone, commit_env_config, git2cpp_path, tmp_path): - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" +def test_merge_commit(repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path): + assert (tmp_path / "initial.txt").exists() + + default_branch = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + cwd=tmp_path, + text=True, + check=True, + ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented checkout_cmd = [git2cpp_path, "checkout", "-b", "foregone"] p_checkout = subprocess.run( - checkout_cmd, capture_output=True, cwd=xtl_path, text=True + checkout_cmd, capture_output=True, cwd=tmp_path, text=True ) assert p_checkout.returncode == 0 - file_path = xtl_path / "mook_file.txt" + file_path = tmp_path / "mook_file.txt" file_path.write_text("blablabla") add_cmd = [git2cpp_path, "add", "mook_file.txt"] - p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + p_add = subprocess.run(add_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_add.returncode == 0 commit_cmd = [git2cpp_path, "commit", "-m", "test commit foregone"] - p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_commit.returncode == 0 - checkout_cmd_2 = [git2cpp_path, "checkout", "master"] + checkout_cmd_2 = [git2cpp_path, "checkout", default_branch] p_checkout_2 = subprocess.run( - checkout_cmd_2, capture_output=True, cwd=xtl_path, text=True + checkout_cmd_2, capture_output=True, cwd=tmp_path, text=True ) assert p_checkout_2.returncode == 0 - file_path_2 = xtl_path / "mook_file_2.txt" + file_path_2 = tmp_path / "mook_file_2.txt" file_path_2.write_text("BLABLABLA") add_cmd_2 = [git2cpp_path, "add", "mook_file_2.txt"] - p_add_2 = subprocess.run(add_cmd_2, capture_output=True, cwd=xtl_path, text=True) + p_add_2 = subprocess.run(add_cmd_2, capture_output=True, cwd=tmp_path, text=True) assert p_add_2.returncode == 0 commit_cmd_2 = [git2cpp_path, "commit", "-m", "test commit master"] p_commit_2 = subprocess.run( - commit_cmd_2, capture_output=True, cwd=xtl_path, text=True + commit_cmd_2, capture_output=True, cwd=tmp_path, text=True ) assert p_commit_2.returncode == 0 merge_cmd = [git2cpp_path, "merge", "foregone"] - p_merge = subprocess.run(merge_cmd, capture_output=True, cwd=xtl_path, text=True) + p_merge = subprocess.run(merge_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_merge.returncode == 0 log_cmd = [git2cpp_path, "log", "--format=full", "--max-count", "2"] - p_log = subprocess.run(log_cmd, capture_output=True, cwd=xtl_path, text=True) + p_log = subprocess.run(log_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_log.returncode == 0 assert "Author: Jane Doe" in p_log.stdout # assert "Commit: John Doe" in p_log.stdout assert "Johan" not in p_log.stdout - assert (xtl_path / "mook_file.txt").exists() - assert (xtl_path / "mook_file_2.txt").exists() + assert (tmp_path / "mook_file.txt").exists() + assert (tmp_path / "mook_file_2.txt").exists() merge_cmd_2 = [git2cpp_path, "merge", "foregone"] p_merge_2 = subprocess.run( - merge_cmd_2, capture_output=True, cwd=xtl_path, text=True + merge_cmd_2, capture_output=True, cwd=tmp_path, text=True ) assert p_merge_2.returncode == 0 assert p_merge_2.stdout == "Already up-to-date\n" @pytest.mark.parametrize("flag", ["--abort", "--quit", "--continue"]) -def test_merge_conflict(xtl_clone, commit_env_config, git2cpp_path, tmp_path, flag): - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" +def test_merge_conflict( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path, flag +): + assert (tmp_path / "initial.txt").exists() + + default_branch = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + cwd=tmp_path, + text=True, + check=True, + ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented checkout_cmd = [git2cpp_path, "checkout", "-b", "foregone"] p_checkout = subprocess.run( - checkout_cmd, capture_output=True, cwd=xtl_path, text=True + checkout_cmd, capture_output=True, cwd=tmp_path, text=True ) assert p_checkout.returncode == 0 - file_path = xtl_path / "mook_file.txt" + file_path = tmp_path / "mook_file.txt" file_path.write_text("blablabla") - file_path_2 = xtl_path / "mook_file_2.txt" + file_path_2 = tmp_path / "mook_file_2.txt" file_path_2.write_text("Second file") add_cmd = [git2cpp_path, "add", "--all"] - p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + p_add = subprocess.run(add_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_add.returncode == 0 commit_cmd = [git2cpp_path, "commit", "-m", "test commit foregone"] - p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_commit.returncode == 0 - checkout_cmd_2 = [git2cpp_path, "checkout", "master"] + checkout_cmd_2 = [git2cpp_path, "checkout", default_branch] p_checkout_2 = subprocess.run( - checkout_cmd_2, capture_output=True, cwd=xtl_path, text=True + checkout_cmd_2, capture_output=True, cwd=tmp_path, text=True ) assert p_checkout_2.returncode == 0 file_path.write_text("BLABLABLA") add_cmd_2 = [git2cpp_path, "add", "mook_file.txt"] - p_add_2 = subprocess.run(add_cmd_2, capture_output=True, cwd=xtl_path, text=True) + p_add_2 = subprocess.run(add_cmd_2, capture_output=True, cwd=tmp_path, text=True) assert p_add_2.returncode == 0 commit_cmd_2 = [git2cpp_path, "commit", "-m", "test commit master"] p_commit_2 = subprocess.run( - commit_cmd_2, capture_output=True, cwd=xtl_path, text=True + commit_cmd_2, capture_output=True, cwd=tmp_path, text=True ) assert p_commit_2.returncode == 0 merge_cmd = [git2cpp_path, "merge", "foregone"] - p_merge = subprocess.run(merge_cmd, capture_output=True, cwd=xtl_path, text=True) + p_merge = subprocess.run(merge_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_merge.returncode == 0 assert "conflict: " in p_merge.stdout @@ -165,11 +190,11 @@ def test_merge_conflict(xtl_clone, commit_env_config, git2cpp_path, tmp_path, fl if flag == "--abort": for answer in {"y", ""}: p_abort = subprocess.run( - flag_cmd, input=answer, capture_output=True, cwd=xtl_path, text=True + flag_cmd, input=answer, capture_output=True, cwd=tmp_path, text=True ) assert p_abort.returncode == 0 - assert (xtl_path / "mook_file.txt").exists() - text = (xtl_path / "mook_file.txt").read_text() + assert (tmp_path / "mook_file.txt").exists() + text = (tmp_path / "mook_file.txt").read_text() if answer == "y": assert "BLA" in text assert "bla" not in text @@ -198,33 +223,33 @@ def test_merge_conflict(xtl_clone, commit_env_config, git2cpp_path, tmp_path, fl # This checks the merge behaviour when a different branch name points to the same commit. branch_alias_cmd = [git2cpp_path, "branch", "foregone_alias"] p_branch_alias = subprocess.run( - branch_alias_cmd, capture_output=True, cwd=xtl_path, text=True + branch_alias_cmd, capture_output=True, cwd=tmp_path, text=True ) assert p_branch_alias.returncode == 0 file_path.write_text("blablabla") cmd_add = [git2cpp_path, "add", "mook_file.txt"] - p_add = subprocess.run(cmd_add, cwd=xtl_path, text=True) + p_add = subprocess.run(cmd_add, cwd=tmp_path, text=True) assert p_add.returncode == 0 p_continue = subprocess.run( - flag_cmd, capture_output=True, cwd=xtl_path, text=True + flag_cmd, capture_output=True, cwd=tmp_path, text=True ) assert p_continue.returncode == 0 log_cmd = [git2cpp_path, "log", "--format=full", "--max-count", "2"] - p_log = subprocess.run(log_cmd, capture_output=True, cwd=xtl_path, text=True) + p_log = subprocess.run(log_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_log.returncode == 0 assert "Author: Jane Doe" in p_log.stdout # assert "Commit: John Doe" in p_log.stdout assert "Johan" not in p_log.stdout - assert (xtl_path / "mook_file.txt").exists() - assert (xtl_path / "mook_file_2.txt").exists() + assert (tmp_path / "mook_file.txt").exists() + assert (tmp_path / "mook_file_2.txt").exists() merge_cmd_2 = [git2cpp_path, "merge", "foregone"] p_merge_2 = subprocess.run( - merge_cmd_2, capture_output=True, cwd=xtl_path, text=True + merge_cmd_2, capture_output=True, cwd=tmp_path, text=True ) assert p_merge_2.returncode == 0 assert p_merge_2.stdout == "Already up-to-date\n" diff --git a/test/test_mv.py b/test/test_mv.py index 72b71bb..86a0835 100644 --- a/test/test_mv.py +++ b/test/test_mv.py @@ -3,89 +3,94 @@ import pytest -def test_mv_basic(xtl_clone, git2cpp_path, tmp_path): +def test_mv_basic(git2cpp_path, tmp_path): """Test basic mv operation to rename a file""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 # Create a test file - test_file = xtl_path / "test_file.txt" + test_file = tmp_path / "test_file.txt" test_file.write_text("test content") # Add the file to git add_cmd = [git2cpp_path, "add", "test_file.txt"] - p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + p_add = subprocess.run(add_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_add.returncode == 0 # Move/rename the file mv_cmd = [git2cpp_path, "mv", "test_file.txt", "renamed_file.txt"] - p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=xtl_path, text=True) + p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_mv.returncode == 0 # Verify the file was moved assert not test_file.exists() - assert (xtl_path / "renamed_file.txt").exists() + assert (tmp_path / "renamed_file.txt").exists() # Check git status status_cmd = [git2cpp_path, "status", "--long"] - p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + p_status = subprocess.run(status_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_status.returncode == 0 - # TODO: uncomment this when the status command is fixed. - #assert "renamed:" in p_status.stdout and "renamed_file.txt" in p_status.stdout + assert "new file: renamed_file.txt" in p_status.stdout -def test_mv_to_subdirectory(xtl_clone, git2cpp_path, tmp_path): +def test_mv_to_subdirectory(git2cpp_path, tmp_path): """Test moving a file to a subdirectory""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 # Create a test file - test_file = xtl_path / "move_me.txt" + test_file = tmp_path / "move_me.txt" test_file.write_text("content to move") # Add the file to git add_cmd = [git2cpp_path, "add", "move_me.txt"] - p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + p_add = subprocess.run(add_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_add.returncode == 0 + # Create a subdirectory + test_dir = tmp_path / "test" + test_dir.mkdir() + # Move the file to existing subdirectory - mv_cmd = [git2cpp_path, "mv", "move_me.txt", "include/move_me.txt"] - p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=xtl_path, text=True) + mv_cmd = [git2cpp_path, "mv", "move_me.txt", "test/move_me.txt"] + p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_mv.returncode == 0 # Verify the file was moved assert not test_file.exists() - assert (xtl_path / "include" / "move_me.txt").exists() + assert (test_dir / "move_me.txt").exists() # Check git status status_cmd = [git2cpp_path, "status", "--long"] - p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + p_status = subprocess.run(status_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_status.returncode == 0 - # TODO: uncomment this when the status command is fixed. - #assert "renamed:" in p_status.stdout and "move_me.txt" in p_status.stdout + assert "new file: test/move_me.txt" in p_status.stdout -def test_mv_destination_exists_without_force(xtl_clone, git2cpp_path, tmp_path): +def test_mv_destination_exists_without_force(git2cpp_path, tmp_path): """Test that mv fails when destination exists without --force flag""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 # Create source file - source_file = xtl_path / "source.txt" + source_file = tmp_path / "source.txt" source_file.write_text("source content") # Create destination file - dest_file = xtl_path / "destination.txt" + dest_file = tmp_path / "destination.txt" dest_file.write_text("destination content") # Add both files to git add_cmd = [git2cpp_path, "add", "source.txt", "destination.txt"] - p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + p_add = subprocess.run(add_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_add.returncode == 0 # Try to move without force - should fail mv_cmd = [git2cpp_path, "mv", "source.txt", "destination.txt"] - p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=xtl_path, text=True) + p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_mv.returncode != 0 assert "destination already exists" in p_mv.stderr @@ -95,27 +100,28 @@ def test_mv_destination_exists_without_force(xtl_clone, git2cpp_path, tmp_path): @pytest.mark.parametrize("force_flag", ["-f", "--force"]) -def test_mv_destination_exists_with_force(xtl_clone, git2cpp_path, tmp_path, force_flag): +def test_mv_destination_exists_with_force(git2cpp_path, tmp_path, force_flag): """Test that mv succeeds when destination exists with --force flag""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 # Create source file - source_file = xtl_path / "source.txt" + source_file = tmp_path / "source.txt" source_file.write_text("source content") # Create destination file - dest_file = xtl_path / "destination.txt" + dest_file = tmp_path / "destination.txt" dest_file.write_text("destination content") # Add both files to git add_cmd = [git2cpp_path, "add", "source.txt", "destination.txt"] - p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + p_add = subprocess.run(add_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_add.returncode == 0 # Move with force - should succeed mv_cmd = [git2cpp_path, "mv", force_flag, "source.txt", "destination.txt"] - p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=xtl_path, text=True) + p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_mv.returncode == 0 # Verify source file was moved @@ -124,92 +130,97 @@ def test_mv_destination_exists_with_force(xtl_clone, git2cpp_path, tmp_path, for assert dest_file.read_text() == "source content" -def test_mv_nonexistent_source(xtl_clone, git2cpp_path, tmp_path): +def test_mv_nonexistent_source(git2cpp_path, tmp_path): """Test that mv fails when source file doesn't exist""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 # Try to move a file that doesn't exist mv_cmd = [git2cpp_path, "mv", "nonexistent.txt", "destination.txt"] - p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=xtl_path, text=True) + p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_mv.returncode != 0 -def test_mv_multiple_files(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_mv_multiple_files(commit_env_config, git2cpp_path, tmp_path): """Test moving multiple files sequentially""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 # Create test files - file1 = xtl_path / "file1.txt" + file1 = tmp_path / "file1.txt" file1.write_text("content 1") - file2 = xtl_path / "file2.txt" + file2 = tmp_path / "file2.txt" file2.write_text("content 2") # Add files to git add_cmd = [git2cpp_path, "add", "file1.txt", "file2.txt"] - p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + p_add = subprocess.run(add_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_add.returncode == 0 # Commit the files commit_cmd = [git2cpp_path, "commit", "-m", "Add test files"] - p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_commit.returncode == 0 # Move first file mv_cmd1 = [git2cpp_path, "mv", "file1.txt", "renamed1.txt"] - p_mv1 = subprocess.run(mv_cmd1, capture_output=True, cwd=xtl_path, text=True) + p_mv1 = subprocess.run(mv_cmd1, capture_output=True, cwd=tmp_path, text=True) assert p_mv1.returncode == 0 # Move second file mv_cmd2 = [git2cpp_path, "mv", "file2.txt", "renamed2.txt"] - p_mv2 = subprocess.run(mv_cmd2, capture_output=True, cwd=xtl_path, text=True) + p_mv2 = subprocess.run(mv_cmd2, capture_output=True, cwd=tmp_path, text=True) assert p_mv2.returncode == 0 # Verify both files were moved assert not file1.exists() assert not file2.exists() - assert (xtl_path / "renamed1.txt").exists() - assert (xtl_path / "renamed2.txt").exists() + assert (tmp_path / "renamed1.txt").exists() + assert (tmp_path / "renamed2.txt").exists() -def test_mv_and_commit(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_mv_and_commit(commit_env_config, git2cpp_path, tmp_path): """Test moving a file and committing the change""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 # Create a test file - test_file = xtl_path / "original.txt" + test_file = tmp_path / "original.txt" test_file.write_text("original content") # Add and commit the file add_cmd = [git2cpp_path, "add", "original.txt"] - p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + p_add = subprocess.run(add_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_add.returncode == 0 commit_cmd = [git2cpp_path, "commit", "-m", "Add original file"] - p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_commit.returncode == 0 # Move the file mv_cmd = [git2cpp_path, "mv", "original.txt", "moved.txt"] - p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=xtl_path, text=True) + p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_mv.returncode == 0 # Check status before commit status_cmd = [git2cpp_path, "status", "--long"] - p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + p_status = subprocess.run(status_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_status.returncode == 0 assert "Changes to be committed" in p_status.stdout # Commit the move commit_cmd2 = [git2cpp_path, "commit", "-m", "Move file"] - p_commit2 = subprocess.run(commit_cmd2, capture_output=True, cwd=xtl_path, text=True) + p_commit2 = subprocess.run( + commit_cmd2, capture_output=True, cwd=tmp_path, text=True + ) assert p_commit2.returncode == 0 # Verify the file is in the new location - assert not (xtl_path / "original.txt").exists() - assert (xtl_path / "moved.txt").exists() + assert not (tmp_path / "original.txt").exists() + assert (tmp_path / "moved.txt").exists() def test_mv_nogit(git2cpp_path, tmp_path): @@ -224,27 +235,28 @@ def test_mv_nogit(git2cpp_path, tmp_path): assert p_mv.returncode != 0 -def test_mv_preserve_content(xtl_clone, git2cpp_path, tmp_path): +def test_mv_preserve_content(git2cpp_path, tmp_path): """Test that file content is preserved after mv""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 # Create a test file with specific content test_content = "This is important content that should be preserved" - test_file = xtl_path / "important.txt" + test_file = tmp_path / "important.txt" test_file.write_text(test_content) # Add the file to git add_cmd = [git2cpp_path, "add", "important.txt"] - p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + p_add = subprocess.run(add_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_add.returncode == 0 # Move the file mv_cmd = [git2cpp_path, "mv", "important.txt", "preserved.txt"] - p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=xtl_path, text=True) + p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_mv.returncode == 0 # Verify content is preserved - moved_file = xtl_path / "preserved.txt" + moved_file = tmp_path / "preserved.txt" assert moved_file.exists() assert moved_file.read_text() == test_content diff --git a/test/test_rebase.py b/test/test_rebase.py index b6806b9..06d39d9 100644 --- a/test/test_rebase.py +++ b/test/test_rebase.py @@ -3,159 +3,184 @@ import pytest -def test_rebase_basic(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_rebase_basic(repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path): """Test basic rebase operation with fast-forward""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() + + default_branch = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + cwd=tmp_path, + text=True, + check=True, + ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented # Create a feature branch checkout_cmd = [git2cpp_path, "checkout", "-b", "feature"] p_checkout = subprocess.run( - checkout_cmd, capture_output=True, cwd=xtl_path, text=True + checkout_cmd, capture_output=True, cwd=tmp_path, text=True ) assert p_checkout.returncode == 0 # Create a commit on feature branch - file_path = xtl_path / "feature_file.txt" + file_path = tmp_path / "feature_file.txt" file_path.write_text("feature content") add_cmd = [git2cpp_path, "add", "feature_file.txt"] - p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + p_add = subprocess.run(add_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_add.returncode == 0 commit_cmd = [git2cpp_path, "commit", "-m", "feature commit"] - p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_commit.returncode == 0 # Go back to master and create another commit - checkout_master_cmd = [git2cpp_path, "checkout", "master"] + checkout_master_cmd = [git2cpp_path, "checkout", default_branch] p_checkout_master = subprocess.run( - checkout_master_cmd, capture_output=True, cwd=xtl_path, text=True + checkout_master_cmd, capture_output=True, cwd=tmp_path, text=True ) assert p_checkout_master.returncode == 0 - file_path_2 = xtl_path / "master_file.txt" + file_path_2 = tmp_path / "master_file.txt" file_path_2.write_text("master content") add_cmd_2 = [git2cpp_path, "add", "master_file.txt"] - p_add_2 = subprocess.run(add_cmd_2, capture_output=True, cwd=xtl_path, text=True) + p_add_2 = subprocess.run(add_cmd_2, capture_output=True, cwd=tmp_path, text=True) assert p_add_2.returncode == 0 commit_cmd_2 = [git2cpp_path, "commit", "-m", "master commit"] p_commit_2 = subprocess.run( - commit_cmd_2, capture_output=True, cwd=xtl_path, text=True + commit_cmd_2, capture_output=True, cwd=tmp_path, text=True ) assert p_commit_2.returncode == 0 # Switch to feature and rebase onto master checkout_feature_cmd = [git2cpp_path, "checkout", "feature"] p_checkout_feature = subprocess.run( - checkout_feature_cmd, capture_output=True, cwd=xtl_path, text=True + checkout_feature_cmd, capture_output=True, cwd=tmp_path, text=True ) assert p_checkout_feature.returncode == 0 - rebase_cmd = [git2cpp_path, "rebase", "master"] - p_rebase = subprocess.run(rebase_cmd, capture_output=True, cwd=xtl_path, text=True) + rebase_cmd = [git2cpp_path, "rebase", default_branch] + p_rebase = subprocess.run(rebase_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_rebase.returncode == 0 assert "Successfully rebased" in p_rebase.stdout assert "Rebasing 1 commit(s)" in p_rebase.stdout # Verify both files exist - assert (xtl_path / "feature_file.txt").exists() - assert (xtl_path / "master_file.txt").exists() + assert (tmp_path / "feature_file.txt").exists() + assert (tmp_path / "master_file.txt").exists() -def test_rebase_multiple_commits(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_rebase_multiple_commits( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path +): """Test rebase with multiple commits""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() + + default_branch = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + cwd=tmp_path, + text=True, + check=True, + ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented # Create feature branch with multiple commits checkout_cmd = [git2cpp_path, "checkout", "-b", "feature"] p_checkout = subprocess.run( - checkout_cmd, capture_output=True, cwd=xtl_path, text=True + checkout_cmd, capture_output=True, cwd=tmp_path, text=True ) assert p_checkout.returncode == 0 # First commit - file_1 = xtl_path / "file_1.txt" + file_1 = tmp_path / "file_1.txt" file_1.write_text("content 1") add_cmd_1 = [git2cpp_path, "add", "file_1.txt"] - subprocess.run(add_cmd_1, cwd=xtl_path, text=True) + subprocess.run(add_cmd_1, cwd=tmp_path, text=True) commit_cmd_1 = [git2cpp_path, "commit", "-m", "commit 1"] - subprocess.run(commit_cmd_1, cwd=xtl_path, text=True) + subprocess.run(commit_cmd_1, cwd=tmp_path, text=True) # Second commit - file_2 = xtl_path / "file_2.txt" + file_2 = tmp_path / "file_2.txt" file_2.write_text("content 2") add_cmd_2 = [git2cpp_path, "add", "file_2.txt"] - subprocess.run(add_cmd_2, cwd=xtl_path, text=True) + subprocess.run(add_cmd_2, cwd=tmp_path, text=True) commit_cmd_2 = [git2cpp_path, "commit", "-m", "commit 2"] - subprocess.run(commit_cmd_2, cwd=xtl_path, text=True) + subprocess.run(commit_cmd_2, cwd=tmp_path, text=True) # Third commit - file_3 = xtl_path / "file_3.txt" + file_3 = tmp_path / "file_3.txt" file_3.write_text("content 3") add_cmd_3 = [git2cpp_path, "add", "file_3.txt"] - subprocess.run(add_cmd_3, cwd=xtl_path, text=True) + subprocess.run(add_cmd_3, cwd=tmp_path, text=True) commit_cmd_3 = [git2cpp_path, "commit", "-m", "commit 3"] - subprocess.run(commit_cmd_3, cwd=xtl_path, text=True) + subprocess.run(commit_cmd_3, cwd=tmp_path, text=True) # Go to master and add a commit - checkout_master_cmd = [git2cpp_path, "checkout", "master"] - subprocess.run(checkout_master_cmd, cwd=xtl_path) + checkout_master_cmd = [git2cpp_path, "checkout", default_branch] + subprocess.run(checkout_master_cmd, cwd=tmp_path) - master_file = xtl_path / "master_file.txt" + master_file = tmp_path / "master_file.txt" master_file.write_text("master") - subprocess.run([git2cpp_path, "add", "master_file.txt"], cwd=xtl_path) - subprocess.run([git2cpp_path, "commit", "-m", "master commit"], cwd=xtl_path) + subprocess.run([git2cpp_path, "add", "master_file.txt"], cwd=tmp_path) + subprocess.run([git2cpp_path, "commit", "-m", "master commit"], cwd=tmp_path) # Rebase feature onto master checkout_feature_cmd = [git2cpp_path, "checkout", "feature"] - subprocess.run(checkout_feature_cmd, cwd=xtl_path) + subprocess.run(checkout_feature_cmd, cwd=tmp_path) - rebase_cmd = [git2cpp_path, "rebase", "master"] - p_rebase = subprocess.run(rebase_cmd, capture_output=True, cwd=xtl_path, text=True) + rebase_cmd = [git2cpp_path, "rebase", default_branch] + p_rebase = subprocess.run(rebase_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_rebase.returncode == 0 assert "Rebasing 3 commit(s)" in p_rebase.stdout assert "Successfully rebased" in p_rebase.stdout # Verify all files exist - assert (xtl_path / "file_1.txt").exists() - assert (xtl_path / "file_2.txt").exists() - assert (xtl_path / "file_3.txt").exists() - assert (xtl_path / "master_file.txt").exists() + assert (tmp_path / "file_1.txt").exists() + assert (tmp_path / "file_2.txt").exists() + assert (tmp_path / "file_3.txt").exists() + assert (tmp_path / "master_file.txt").exists() -def test_rebase_with_conflicts(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_rebase_with_conflicts( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path +): """Test rebase with conflicts""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() + + default_branch = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + cwd=tmp_path, + text=True, + check=True, + ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented # Create feature branch checkout_cmd = [git2cpp_path, "checkout", "-b", "feature"] - subprocess.run(checkout_cmd, capture_output=True, cwd=xtl_path, text=True) + subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True) # Create conflicting file on feature - conflict_file = xtl_path / "conflict.txt" + conflict_file = tmp_path / "conflict.txt" conflict_file.write_text("feature content") - subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=xtl_path) - subprocess.run([git2cpp_path, "commit", "-m", "feature commit"], cwd=xtl_path) + subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=tmp_path) + subprocess.run([git2cpp_path, "commit", "-m", "feature commit"], cwd=tmp_path) # Go to master and create conflicting commit - checkout_master_cmd = [git2cpp_path, "checkout", "master"] - subprocess.run(checkout_master_cmd, cwd=xtl_path) + checkout_master_cmd = [git2cpp_path, "checkout", default_branch] + subprocess.run(checkout_master_cmd, cwd=tmp_path) conflict_file.write_text("master content") - subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=xtl_path) - subprocess.run([git2cpp_path, "commit", "-m", "master commit"], cwd=xtl_path) + subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=tmp_path) + subprocess.run([git2cpp_path, "commit", "-m", "master commit"], cwd=tmp_path) # Try to rebase feature onto master checkout_feature_cmd = [git2cpp_path, "checkout", "feature"] - subprocess.run(checkout_feature_cmd, cwd=xtl_path) + subprocess.run(checkout_feature_cmd, cwd=tmp_path) - rebase_cmd = [git2cpp_path, "rebase", "master"] - p_rebase = subprocess.run(rebase_cmd, capture_output=True, cwd=xtl_path, text=True) + rebase_cmd = [git2cpp_path, "rebase", default_branch] + p_rebase = subprocess.run(rebase_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_rebase.returncode == 0 assert "Conflicts detected" in p_rebase.stdout assert "rebase --continue" in p_rebase.stdout @@ -163,34 +188,41 @@ def test_rebase_with_conflicts(xtl_clone, commit_env_config, git2cpp_path, tmp_p assert "rebase --abort" in p_rebase.stdout -def test_rebase_abort(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_rebase_abort(repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path): """Test rebase abort after conflicts""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() + + default_branch = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + cwd=tmp_path, + text=True, + check=True, + ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented # Create feature branch checkout_cmd = [git2cpp_path, "checkout", "-b", "feature"] - subprocess.run(checkout_cmd, cwd=xtl_path) + subprocess.run(checkout_cmd, cwd=tmp_path) # Create conflicting file on feature - conflict_file = xtl_path / "conflict.txt" + conflict_file = tmp_path / "conflict.txt" conflict_file.write_text("feature content") - subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=xtl_path) - subprocess.run([git2cpp_path, "commit", "-m", "feature commit"], cwd=xtl_path) + subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=tmp_path) + subprocess.run([git2cpp_path, "commit", "-m", "feature commit"], cwd=tmp_path) # Go to master and create conflicting commit - subprocess.run([git2cpp_path, "checkout", "master"], cwd=xtl_path) + subprocess.run([git2cpp_path, "checkout", default_branch], cwd=tmp_path) conflict_file.write_text("master content") - subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=xtl_path) - subprocess.run([git2cpp_path, "commit", "-m", "master commit"], cwd=xtl_path) + subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=tmp_path) + subprocess.run([git2cpp_path, "commit", "-m", "master commit"], cwd=tmp_path) # Rebase and get conflict - subprocess.run([git2cpp_path, "checkout", "feature"], cwd=xtl_path) - subprocess.run([git2cpp_path, "rebase", "master"], cwd=xtl_path) + subprocess.run([git2cpp_path, "checkout", "feature"], cwd=tmp_path) + subprocess.run([git2cpp_path, "rebase", default_branch], cwd=tmp_path) # Abort the rebase abort_cmd = [git2cpp_path, "rebase", "--abort"] - p_abort = subprocess.run(abort_cmd, capture_output=True, cwd=xtl_path, text=True) + p_abort = subprocess.run(abort_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_abort.returncode == 0 assert "Rebase aborted" in p_abort.stdout @@ -198,38 +230,47 @@ def test_rebase_abort(xtl_clone, commit_env_config, git2cpp_path, tmp_path): assert conflict_file.read_text() == "feature content" -def test_rebase_continue(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_rebase_continue( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path +): """Test rebase continue after resolving conflicts""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() + + default_branch = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + cwd=tmp_path, + text=True, + check=True, + ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented # Create feature branch - subprocess.run([git2cpp_path, "checkout", "-b", "feature"], cwd=xtl_path) + subprocess.run([git2cpp_path, "checkout", "-b", "feature"], cwd=tmp_path) # Create conflicting file on feature - conflict_file = xtl_path / "conflict.txt" + conflict_file = tmp_path / "conflict.txt" conflict_file.write_text("feature content") - subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=xtl_path) - subprocess.run([git2cpp_path, "commit", "-m", "feature commit"], cwd=xtl_path) + subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=tmp_path) + subprocess.run([git2cpp_path, "commit", "-m", "feature commit"], cwd=tmp_path) # Go to master and create conflicting commit - subprocess.run([git2cpp_path, "checkout", "master"], cwd=xtl_path) + subprocess.run([git2cpp_path, "checkout", default_branch], cwd=tmp_path) conflict_file.write_text("master content") - subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=xtl_path) - subprocess.run([git2cpp_path, "commit", "-m", "master commit"], cwd=xtl_path) + subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=tmp_path) + subprocess.run([git2cpp_path, "commit", "-m", "master commit"], cwd=tmp_path) # Rebase and get conflict - subprocess.run([git2cpp_path, "checkout", "feature"], cwd=xtl_path) - subprocess.run([git2cpp_path, "rebase", "master"], cwd=xtl_path) + subprocess.run([git2cpp_path, "checkout", "feature"], cwd=tmp_path) + subprocess.run([git2cpp_path, "rebase", default_branch], cwd=tmp_path) # Resolve conflict conflict_file.write_text("resolved content") - subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=xtl_path) + subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=tmp_path) # Continue rebase continue_cmd = [git2cpp_path, "rebase", "--continue"] p_continue = subprocess.run( - continue_cmd, capture_output=True, cwd=xtl_path, text=True + continue_cmd, capture_output=True, cwd=tmp_path, text=True ) assert p_continue.returncode == 0 assert "Successfully rebased" in p_continue.stdout @@ -238,191 +279,249 @@ def test_rebase_continue(xtl_clone, commit_env_config, git2cpp_path, tmp_path): assert conflict_file.read_text() == "resolved content" -def test_rebase_skip(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_rebase_skip(repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path): """Test rebase skip to skip current commit""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() + + default_branch = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + cwd=tmp_path, + text=True, + check=True, + ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented # Create feature branch - subprocess.run([git2cpp_path, "checkout", "-b", "feature"], cwd=xtl_path) + subprocess.run([git2cpp_path, "checkout", "-b", "feature"], cwd=tmp_path) # Create conflicting file on feature - conflict_file = xtl_path / "conflict.txt" + conflict_file = tmp_path / "conflict.txt" conflict_file.write_text("feature content") - subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=xtl_path) - subprocess.run([git2cpp_path, "commit", "-m", "feature commit"], cwd=xtl_path) + subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=tmp_path) + subprocess.run([git2cpp_path, "commit", "-m", "feature commit"], cwd=tmp_path) # Go to master and create conflicting commit - subprocess.run([git2cpp_path, "checkout", "master"], cwd=xtl_path) + subprocess.run([git2cpp_path, "checkout", default_branch], cwd=tmp_path) conflict_file.write_text("master content") - subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=xtl_path) - subprocess.run([git2cpp_path, "commit", "-m", "master commit"], cwd=xtl_path) + subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=tmp_path) + subprocess.run([git2cpp_path, "commit", "-m", "master commit"], cwd=tmp_path) # Rebase and get conflict - subprocess.run([git2cpp_path, "checkout", "feature"], cwd=xtl_path) - subprocess.run([git2cpp_path, "rebase", "master"], cwd=xtl_path) + subprocess.run([git2cpp_path, "checkout", "feature"], cwd=tmp_path) + subprocess.run([git2cpp_path, "rebase", default_branch], cwd=tmp_path) # Skip the conflicting commit skip_cmd = [git2cpp_path, "rebase", "--skip"] - p_skip = subprocess.run(skip_cmd, capture_output=True, cwd=xtl_path, text=True) + p_skip = subprocess.run(skip_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_skip.returncode == 0 assert "Skipping" in p_skip.stdout -def test_rebase_quit(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_rebase_quit(repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path): """Test rebase quit to cleanup state without resetting HEAD""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() + + default_branch = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + cwd=tmp_path, + text=True, + check=True, + ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented # Create feature branch - subprocess.run([git2cpp_path, "checkout", "-b", "feature"], cwd=xtl_path) + subprocess.run([git2cpp_path, "checkout", "-b", "feature"], cwd=tmp_path) # Create conflicting file - conflict_file = xtl_path / "conflict.txt" + conflict_file = tmp_path / "conflict.txt" conflict_file.write_text("feature content") - subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=xtl_path) - subprocess.run([git2cpp_path, "commit", "-m", "feature commit"], cwd=xtl_path) + subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=tmp_path) + subprocess.run([git2cpp_path, "commit", "-m", "feature commit"], cwd=tmp_path) # Create conflict on master - subprocess.run([git2cpp_path, "checkout", "master"], cwd=xtl_path) + subprocess.run([git2cpp_path, "checkout", default_branch], cwd=tmp_path) conflict_file.write_text("master content") - subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=xtl_path) - subprocess.run([git2cpp_path, "commit", "-m", "master commit"], cwd=xtl_path) + subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=tmp_path) + subprocess.run([git2cpp_path, "commit", "-m", "master commit"], cwd=tmp_path) # Start rebase - subprocess.run([git2cpp_path, "checkout", "feature"], cwd=xtl_path) - subprocess.run([git2cpp_path, "rebase", "master"], cwd=xtl_path) + subprocess.run([git2cpp_path, "checkout", "feature"], cwd=tmp_path) + subprocess.run([git2cpp_path, "rebase", default_branch], cwd=tmp_path) # Quit rebase quit_cmd = [git2cpp_path, "rebase", "--quit"] - p_quit = subprocess.run(quit_cmd, capture_output=True, cwd=xtl_path, text=True) + p_quit = subprocess.run(quit_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_quit.returncode == 0 assert "Rebase state cleaned up" in p_quit.stdout assert "HEAD not reset" in p_quit.stdout -def test_rebase_onto(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_rebase_onto(repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path): """Test rebase with --onto option""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() + + default_branch = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + cwd=tmp_path, + text=True, + check=True, + ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented # Create first branch - subprocess.run([git2cpp_path, "checkout", "-b", "branch1"], cwd=xtl_path) - file1 = xtl_path / "file1.txt" + subprocess.run([git2cpp_path, "checkout", "-b", "branch1"], cwd=tmp_path) + file1 = tmp_path / "file1.txt" file1.write_text("branch1") - subprocess.run([git2cpp_path, "add", "file1.txt"], cwd=xtl_path) - subprocess.run([git2cpp_path, "commit", "-m", "branch1 commit"], cwd=xtl_path) + subprocess.run([git2cpp_path, "add", "file1.txt"], cwd=tmp_path) + subprocess.run([git2cpp_path, "commit", "-m", "branch1 commit"], cwd=tmp_path) # Create second branch from branch1 - subprocess.run([git2cpp_path, "checkout", "-b", "branch2"], cwd=xtl_path) - file2 = xtl_path / "file2.txt" + subprocess.run([git2cpp_path, "checkout", "-b", "branch2"], cwd=tmp_path) + file2 = tmp_path / "file2.txt" file2.write_text("branch2") - subprocess.run([git2cpp_path, "add", "file2.txt"], cwd=xtl_path) - subprocess.run([git2cpp_path, "commit", "-m", "branch2 commit"], cwd=xtl_path) + subprocess.run([git2cpp_path, "add", "file2.txt"], cwd=tmp_path) + subprocess.run([git2cpp_path, "commit", "-m", "branch2 commit"], cwd=tmp_path) # Create target branch from master - subprocess.run([git2cpp_path, "checkout", "master"], cwd=xtl_path) - subprocess.run([git2cpp_path, "checkout", "-b", "target"], cwd=xtl_path) - target_file = xtl_path / "target.txt" + subprocess.run([git2cpp_path, "checkout", default_branch], cwd=tmp_path) + subprocess.run([git2cpp_path, "checkout", "-b", "target"], cwd=tmp_path) + target_file = tmp_path / "target.txt" target_file.write_text("target") - subprocess.run([git2cpp_path, "add", "target.txt"], cwd=xtl_path) - subprocess.run([git2cpp_path, "commit", "-m", "target commit"], cwd=xtl_path) + subprocess.run([git2cpp_path, "add", "target.txt"], cwd=tmp_path) + subprocess.run([git2cpp_path, "commit", "-m", "target commit"], cwd=tmp_path) # Rebase branch2 onto target, upstream is branch1 rebase_cmd = [git2cpp_path, "rebase", "branch1", "branch2", "--onto", "target"] - p_rebase = subprocess.run(rebase_cmd, capture_output=True, cwd=xtl_path, text=True) + p_rebase = subprocess.run(rebase_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_rebase.returncode == 0 # Verify target file exists and branch2 file exists, but not branch1 file - assert not (xtl_path / "target.txt").exists() - assert (xtl_path / "file2.txt").exists() + assert not (tmp_path / "target.txt").exists() + assert (tmp_path / "file2.txt").exists() -def test_rebase_no_upstream_error(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_rebase_no_upstream_error( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path +): """Test that rebase without upstream argument fails""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() rebase_cmd = [git2cpp_path, "rebase"] - p_rebase = subprocess.run(rebase_cmd, capture_output=True, cwd=xtl_path, text=True) + p_rebase = subprocess.run(rebase_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_rebase.returncode != 0 assert "upstream is required for rebase" in p_rebase.stderr -def test_rebase_invalid_upstream_error(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_rebase_invalid_upstream_error( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path +): """Test that rebase with invalid upstream fails""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() rebase_cmd = [git2cpp_path, "rebase", "nonexistent-branch"] - p_rebase = subprocess.run(rebase_cmd, capture_output=True, cwd=xtl_path, text=True) + p_rebase = subprocess.run(rebase_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_rebase.returncode != 0 - assert "could not resolve upstream" in p_rebase.stderr or "could not resolve upstream" in p_rebase.stdout + assert ( + "could not resolve upstream" in p_rebase.stderr + or "could not resolve upstream" in p_rebase.stdout + ) -def test_rebase_already_in_progress_error(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_rebase_already_in_progress_error( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path +): """Test that starting rebase when one is in progress fails""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() + + default_branch = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + cwd=tmp_path, + text=True, + check=True, + ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented # Create feature branch with conflict - subprocess.run([git2cpp_path, "checkout", "-b", "feature"], cwd=xtl_path) - conflict_file = xtl_path / "conflict.txt" + subprocess.run([git2cpp_path, "checkout", "-b", "feature"], cwd=tmp_path) + conflict_file = tmp_path / "conflict.txt" conflict_file.write_text("feature") - subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=xtl_path) - subprocess.run([git2cpp_path, "commit", "-m", "feature"], cwd=xtl_path) + subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=tmp_path) + subprocess.run([git2cpp_path, "commit", "-m", "feature"], cwd=tmp_path) # Create conflict on master - subprocess.run([git2cpp_path, "checkout", "master"], cwd=xtl_path) - conflict_file.write_text("master") - subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=xtl_path) - subprocess.run([git2cpp_path, "commit", "-m", "master"], cwd=xtl_path) + subprocess.run([git2cpp_path, "checkout", default_branch], cwd=tmp_path) + conflict_file.write_text(default_branch) + subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=tmp_path) + subprocess.run([git2cpp_path, "commit", "-m", default_branch], cwd=tmp_path) # Start rebase with conflict - subprocess.run([git2cpp_path, "checkout", "feature"], cwd=xtl_path) - subprocess.run([git2cpp_path, "rebase", "master"], cwd=xtl_path) + subprocess.run([git2cpp_path, "checkout", "feature"], cwd=tmp_path) + subprocess.run([git2cpp_path, "rebase", default_branch], cwd=tmp_path) # Try to start another rebase - rebase_cmd = [git2cpp_path, "rebase", "master"] - p_rebase = subprocess.run(rebase_cmd, capture_output=True, cwd=xtl_path, text=True) + rebase_cmd = [git2cpp_path, "rebase", default_branch] + p_rebase = subprocess.run(rebase_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_rebase.returncode != 0 - assert "rebase is already in progress" in p_rebase.stderr or "rebase is already in progress" in p_rebase.stdout + assert ( + "rebase is already in progress" in p_rebase.stderr + or "rebase is already in progress" in p_rebase.stdout + ) -def test_rebase_continue_without_rebase_error(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_rebase_continue_without_rebase_error( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path +): """Test that --continue without rebase in progress fails""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() continue_cmd = [git2cpp_path, "rebase", "--continue"] - p_continue = subprocess.run(continue_cmd, capture_output=True, cwd=xtl_path, text=True) + p_continue = subprocess.run( + continue_cmd, capture_output=True, cwd=tmp_path, text=True + ) assert p_continue.returncode != 0 - assert "No rebase in progress" in p_continue.stderr or "No rebase in progress" in p_continue.stdout + assert ( + "No rebase in progress" in p_continue.stderr + or "No rebase in progress" in p_continue.stdout + ) -def test_rebase_continue_with_unresolved_conflicts(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_rebase_continue_with_unresolved_conflicts( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path +): """Test that --continue with unresolved conflicts fails""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() + + default_branch = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + cwd=tmp_path, + text=True, + check=True, + ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented # Create conflict scenario - subprocess.run([git2cpp_path, "checkout", "-b", "feature"], cwd=xtl_path) - conflict_file = xtl_path / "conflict.txt" + subprocess.run([git2cpp_path, "checkout", "-b", "feature"], cwd=tmp_path) + conflict_file = tmp_path / "conflict.txt" conflict_file.write_text("feature") - subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=xtl_path) - subprocess.run([git2cpp_path, "commit", "-m", "feature"], cwd=xtl_path) + subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=tmp_path) + subprocess.run([git2cpp_path, "commit", "-m", "feature"], cwd=tmp_path) - subprocess.run([git2cpp_path, "checkout", "master"], cwd=xtl_path) - conflict_file.write_text("master") - subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=xtl_path) - subprocess.run([git2cpp_path, "commit", "-m", "master"], cwd=xtl_path) + subprocess.run([git2cpp_path, "checkout", default_branch], cwd=tmp_path) + conflict_file.write_text(default_branch) + subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=tmp_path) + subprocess.run([git2cpp_path, "commit", "-m", default_branch], cwd=tmp_path) # Start rebase - subprocess.run([git2cpp_path, "checkout", "feature"], cwd=xtl_path) - subprocess.run([git2cpp_path, "rebase", "master"], cwd=xtl_path) + subprocess.run([git2cpp_path, "checkout", "feature"], cwd=tmp_path) + subprocess.run([git2cpp_path, "rebase", default_branch], cwd=tmp_path) # Try to continue without resolving continue_cmd = [git2cpp_path, "rebase", "--continue"] - p_continue = subprocess.run(continue_cmd, capture_output=True, cwd=xtl_path, text=True) + p_continue = subprocess.run( + continue_cmd, capture_output=True, cwd=tmp_path, text=True + ) assert p_continue.returncode != 0 - assert "resolve conflicts" in p_continue.stderr or "resolve conflicts" in p_continue.stdout + assert ( + "resolve conflicts" in p_continue.stderr + or "resolve conflicts" in p_continue.stdout + ) diff --git a/test/test_reset.py b/test/test_reset.py index d816afb..12b52c7 100644 --- a/test/test_reset.py +++ b/test/test_reset.py @@ -3,34 +3,33 @@ import pytest -def test_reset(xtl_clone, commit_env_config, git2cpp_path, tmp_path): - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" +def test_reset(repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path): + assert (tmp_path / "initial.txt").exists() - p = xtl_path / "mook_file.txt" + p = tmp_path / "mook_file.txt" p.write_text("") cmd_add = [git2cpp_path, "add", "mook_file.txt"] - p_add = subprocess.run(cmd_add, cwd=xtl_path, text=True) + p_add = subprocess.run(cmd_add, cwd=tmp_path, text=True) assert p_add.returncode == 0 cmd_commit = [git2cpp_path, "commit", "-m", "test commit"] - p_commit = subprocess.run(cmd_commit, cwd=xtl_path, text=True) + p_commit = subprocess.run(cmd_commit, cwd=tmp_path, text=True) assert p_commit.returncode == 0 cmd_log = [git2cpp_path, "log"] - p_log = subprocess.run(cmd_log, capture_output=True, cwd=xtl_path, text=True) + p_log = subprocess.run(cmd_log, capture_output=True, cwd=tmp_path, text=True) assert p_log.returncode == 0 - assert "Jane Doe" in p_log.stdout + assert "test commit" in p_log.stdout cmd_reset = [git2cpp_path, "reset", "--hard", "HEAD~1"] - p_reset = subprocess.run(cmd_reset, capture_output=True, cwd=xtl_path, text=True) + p_reset = subprocess.run(cmd_reset, capture_output=True, cwd=tmp_path, text=True) assert p_reset.returncode == 0 cmd_log_2 = [git2cpp_path, "log"] - p_log = subprocess.run(cmd_log_2, capture_output=True, cwd=xtl_path, text=True) - assert p_log.returncode == 0 - assert "Jane Doe" not in p_log.stdout + p_log2 = subprocess.run(cmd_log_2, capture_output=True, cwd=tmp_path, text=True) + assert p_log2.returncode == 0 + assert "test commit" not in p_log2.stdout def test_reset_nogit(git2cpp_path, tmp_path): diff --git a/test/test_revlist.py b/test/test_revlist.py index 900fc08..56693a8 100644 --- a/test/test_revlist.py +++ b/test/test_revlist.py @@ -3,18 +3,23 @@ import pytest -def test_revlist(xtl_clone, commit_env_config, git2cpp_path, tmp_path): - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" +def test_revlist(repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path): + assert (tmp_path / "initial.txt").exists() - cmd = [ - git2cpp_path, - "rev-list", - "35955995424eb9699bb604b988b5270253b1fccc", - "--max-count", - "2", - ] - p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + p = tmp_path / "initial.txt" + p.write_text("commit2") + subprocess.run([git2cpp_path, "add", "initial.txt"], cwd=tmp_path, check=True) + subprocess.run([git2cpp_path, "commit", "-m", "commit 2"], cwd=tmp_path, check=True) + + p.write_text("commit3") + subprocess.run([git2cpp_path, "add", "initial.txt"], cwd=tmp_path, check=True) + subprocess.run([git2cpp_path, "commit", "-m", "commit 3"], cwd=tmp_path, check=True) + + cmd = [git2cpp_path, "rev-list", "HEAD", "--max-count", "2"] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 - assert "da1754dd6" in p.stdout - assert "2da8e13ef" not in p.stdout + + lines = [l for l in p.stdout.splitlines() if l.strip()] + assert len(lines) == 2 + assert all(len(oid) == 40 for oid in lines) + assert lines[0] != lines[1] diff --git a/test/test_rm.py b/test/test_rm.py index dcb927c..1fc1d52 100644 --- a/test/test_rm.py +++ b/test/test_rm.py @@ -1,29 +1,31 @@ +import pathlib import subprocess import pytest -def test_rm_basic_file(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_rm_basic_file(commit_env_config, git2cpp_path, tmp_path): """Test basic rm operation to remove a file""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 # Create a test file - test_file = xtl_path / "test_file.txt" + test_file = tmp_path / "test_file.txt" test_file.write_text("test content") # Add and commit the file add_cmd = [git2cpp_path, "add", "test_file.txt"] - p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + p_add = subprocess.run(add_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_add.returncode == 0 commit_cmd = [git2cpp_path, "commit", "-m", "Add test file"] - p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_commit.returncode == 0 # Remove the file rm_cmd = [git2cpp_path, "rm", "test_file.txt"] - p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_rm.returncode == 0 # Verify the file was removed from working tree @@ -31,35 +33,36 @@ def test_rm_basic_file(xtl_clone, commit_env_config, git2cpp_path, tmp_path): # Check git status status_cmd = [git2cpp_path, "status", "--long"] - p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + p_status = subprocess.run(status_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_status.returncode == 0 assert "Changes to be committed" in p_status.stdout assert "deleted" in p_status.stdout -def test_rm_multiple_files(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_rm_multiple_files(commit_env_config, git2cpp_path, tmp_path): """Test removing multiple files at once""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 # Create test files - file1 = xtl_path / "file1.txt" + file1 = tmp_path / "file1.txt" file1.write_text("content 1") - file2 = xtl_path / "file2.txt" + file2 = tmp_path / "file2.txt" file2.write_text("content 2") # Add and commit files add_cmd = [git2cpp_path, "add", "file1.txt", "file2.txt"] - p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + p_add = subprocess.run(add_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_add.returncode == 0 commit_cmd = [git2cpp_path, "commit", "-m", "Add test files"] - p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_commit.returncode == 0 # Remove both files rm_cmd = [git2cpp_path, "rm", "file1.txt", "file2.txt"] - p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_rm.returncode == 0 # Verify both files were removed @@ -68,35 +71,36 @@ def test_rm_multiple_files(xtl_clone, commit_env_config, git2cpp_path, tmp_path) # Check git status status_cmd = [git2cpp_path, "status", "--long"] - p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + p_status = subprocess.run(status_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_status.returncode == 0 assert "Changes to be committed" in p_status.stdout assert "deleted" in p_status.stdout -def test_rm_directory_without_recursive_flag(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_rm_directory_without_recursive_flag(commit_env_config, git2cpp_path, tmp_path): """Test that rm fails when trying to remove a directory without -r flag""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 # Create a directory with a file - test_dir = xtl_path / "test_dir" + test_dir = tmp_path / "test_dir" test_dir.mkdir() test_file = test_dir / "file.txt" test_file.write_text("content") # Add and commit the file add_cmd = [git2cpp_path, "add", "test_dir/file.txt"] - p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + p_add = subprocess.run(add_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_add.returncode == 0 commit_cmd = [git2cpp_path, "commit", "-m", "Add test directory"] - p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_commit.returncode == 0 # Try to remove directory without -r flag - should fail rm_cmd = [git2cpp_path, "rm", "test_dir"] - p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_rm.returncode != 0 assert "not removing" in p_rm.stderr and "recursively without -r" in p_rm.stderr @@ -105,13 +109,14 @@ def test_rm_directory_without_recursive_flag(xtl_clone, commit_env_config, git2c assert test_file.exists() -def test_rm_directory_with_recursive_flag(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_rm_directory_with_recursive_flag(commit_env_config, git2cpp_path, tmp_path): """Test removing a directory with -r flag""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 # Create a directory with files - test_dir = xtl_path / "test_dir" + test_dir = tmp_path / "test_dir" test_dir.mkdir() file1 = test_dir / "file1.txt" file1.write_text("content 1") @@ -120,59 +125,62 @@ def test_rm_directory_with_recursive_flag(xtl_clone, commit_env_config, git2cpp_ # Add and commit the files add_cmd = [git2cpp_path, "add", "test_dir/file1.txt", "test_dir/file2.txt"] - p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + p_add = subprocess.run(add_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_add.returncode == 0 commit_cmd = [git2cpp_path, "commit", "-m", "Add test directory"] - p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_commit.returncode == 0 # Remove directory with -r flag - should succeed rm_cmd = [git2cpp_path, "rm", "-r", "test_dir"] - p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_rm.returncode == 0 # Check git status status_cmd = [git2cpp_path, "status", "--long"] - p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + p_status = subprocess.run(status_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_status.returncode == 0 assert "Changes to be committed" in p_status.stdout assert "deleted" in p_status.stdout -def test_rm_and_commit(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_rm_and_commit(commit_env_config, git2cpp_path, tmp_path): """Test removing a file and committing the change""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 # Create a test file - test_file = xtl_path / "to_remove.txt" + test_file = tmp_path / "to_remove.txt" test_file.write_text("content to remove") # Add and commit the file add_cmd = [git2cpp_path, "add", "to_remove.txt"] - p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + p_add = subprocess.run(add_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_add.returncode == 0 commit_cmd = [git2cpp_path, "commit", "-m", "Add file to remove"] - p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_commit.returncode == 0 # Remove the file rm_cmd = [git2cpp_path, "rm", "to_remove.txt"] - p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_rm.returncode == 0 # Check status before commit status_cmd = [git2cpp_path, "status", "--long"] - p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + p_status = subprocess.run(status_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_status.returncode == 0 assert "Changes to be committed" in p_status.stdout assert "deleted" in p_status.stdout # Commit the removal commit_cmd2 = [git2cpp_path, "commit", "-m", "Remove file"] - p_commit2 = subprocess.run(commit_cmd2, capture_output=True, cwd=xtl_path, text=True) + p_commit2 = subprocess.run( + commit_cmd2, capture_output=True, cwd=tmp_path, text=True + ) assert p_commit2.returncode == 0 # Verify the file is gone @@ -180,54 +188,59 @@ def test_rm_and_commit(xtl_clone, commit_env_config, git2cpp_path, tmp_path): # Check status after commit status_cmd2 = [git2cpp_path, "status", "--long"] - p_status2 = subprocess.run(status_cmd2, capture_output=True, cwd=xtl_path, text=True) + p_status2 = subprocess.run( + status_cmd2, capture_output=True, cwd=tmp_path, text=True + ) assert p_status2.returncode == 0 assert "to_remove.txt" not in p_status2.stdout -def test_rm_nonexistent_file(xtl_clone, git2cpp_path, tmp_path): +def test_rm_nonexistent_file(git2cpp_path, tmp_path): """Test that rm fails when file doesn't exist""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 # Try to remove a file that doesn't exist rm_cmd = [git2cpp_path, "rm", "nonexistent.txt"] - p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_rm.returncode != 0 -def test_rm_untracked_file(xtl_clone, git2cpp_path, tmp_path): +def test_rm_untracked_file(git2cpp_path, tmp_path): """Test that rm fails when trying to remove an untracked file""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 # Create an untracked file - untracked_file = xtl_path / "untracked.txt" + untracked_file = tmp_path / "untracked.txt" untracked_file.write_text("untracked content") # Try to remove untracked file - should fail rm_cmd = [git2cpp_path, "rm", "untracked.txt"] - p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_rm.returncode != 0 -def test_rm_staged_file(xtl_clone, git2cpp_path, tmp_path): +def test_rm_staged_file(git2cpp_path, tmp_path): """Test removing a file that was added but not yet committed""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 # Create a test file - test_file = xtl_path / "staged.txt" + test_file = tmp_path / "staged.txt" test_file.write_text("staged content") # Add the file (but don't commit) add_cmd = [git2cpp_path, "add", "staged.txt"] - p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + p_add = subprocess.run(add_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_add.returncode == 0 # Remove the file rm_cmd = [git2cpp_path, "rm", "staged.txt"] - p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_rm.returncode == 0 # Verify the file was removed @@ -235,33 +248,36 @@ def test_rm_staged_file(xtl_clone, git2cpp_path, tmp_path): # Check git status - should show nothing staged status_cmd = [git2cpp_path, "status", "--long"] - p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + p_status = subprocess.run(status_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_status.returncode == 0 assert "Changes to be committed" not in p_status.stdout assert "staged.txt" not in p_status.stdout -def test_rm_file_in_subdirectory(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_rm_file_in_subdirectory(commit_env_config, git2cpp_path, tmp_path): """Test removing a file in a subdirectory""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 - # Use existing subdirectory - test_file = xtl_path / "include" / "test.txt" + # Create subdirectory + test_dir = tmp_path / "test" + test_dir.mkdir() + test_file = test_dir / "test.txt" test_file.write_text("test content") # Add and commit the file - add_cmd = [git2cpp_path, "add", "include/test.txt"] - p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + add_cmd = [git2cpp_path, "add", "test/test.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_add.returncode == 0 commit_cmd = [git2cpp_path, "commit", "-m", "Add file in subdirectory"] - p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_commit.returncode == 0 # Remove the file - rm_cmd = [git2cpp_path, "rm", "include/test.txt"] - p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + rm_cmd = [git2cpp_path, "rm", "test/test.txt"] + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_rm.returncode == 0 # Verify the file was removed @@ -269,7 +285,7 @@ def test_rm_file_in_subdirectory(xtl_clone, commit_env_config, git2cpp_path, tmp # Check git status status_cmd = [git2cpp_path, "status", "--long"] - p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + p_status = subprocess.run(status_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_status.returncode == 0 assert "Changes to be committed" in p_status.stdout assert "deleted" in p_status.stdout @@ -287,67 +303,69 @@ def test_rm_nogit(git2cpp_path, tmp_path): assert p_rm.returncode != 0 -def test_rm_nested_directory_recursive(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_rm_nested_directory_recursive(commit_env_config, git2cpp_path, tmp_path): """Test removing a nested directory structure with -r flag""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 # Create nested directory structure - nested_dir = xtl_path / "level1" / "level2" + nested_dir = tmp_path / "level1" / "level2" nested_dir.mkdir(parents=True) - file1 = xtl_path / "level1" / "file1.txt" + file1 = tmp_path / "level1" / "file1.txt" file1.write_text("content 1") file2 = nested_dir / "file2.txt" file2.write_text("content 2") # Add and commit the files add_cmd = [git2cpp_path, "add", "level1/file1.txt", "level1/level2/file2.txt"] - p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + p_add = subprocess.run(add_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_add.returncode == 0 commit_cmd = [git2cpp_path, "commit", "-m", "Add nested structure"] - p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_commit.returncode == 0 # Remove the directory tree with -r flag rm_cmd = [git2cpp_path, "rm", "-r", "level1"] - p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_rm.returncode == 0 # Check git status status_cmd = [git2cpp_path, "status", "--long"] - p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + p_status = subprocess.run(status_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_status.returncode == 0 assert "Changes to be committed" in p_status.stdout assert "deleted" in p_status.stdout -def test_rm_mixed_files_and_directory(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_rm_mixed_files_and_directory(commit_env_config, git2cpp_path, tmp_path): """Test removing both individual files and directories in one command""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 # Create a file and a directory with contents - single_file = xtl_path / "single.txt" + single_file = tmp_path / "single.txt" single_file.write_text("single file") - test_dir = xtl_path / "remove_dir" + test_dir = tmp_path / "remove_dir" test_dir.mkdir() dir_file = test_dir / "file.txt" dir_file.write_text("file in dir") # Add and commit everything add_cmd = [git2cpp_path, "add", "single.txt", "remove_dir/file.txt"] - p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + p_add = subprocess.run(add_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_add.returncode == 0 commit_cmd = [git2cpp_path, "commit", "-m", "Add mixed content"] - p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_commit.returncode == 0 # Remove both file and directory rm_cmd = [git2cpp_path, "rm", "-r", "single.txt", "remove_dir"] - p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_rm.returncode == 0 # Verify everything was removed @@ -355,7 +373,7 @@ def test_rm_mixed_files_and_directory(xtl_clone, commit_env_config, git2cpp_path # Check git status status_cmd = [git2cpp_path, "status", "--long"] - p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + p_status = subprocess.run(status_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_status.returncode == 0 assert "Changes to be committed" in p_status.stdout assert "deleted" in p_status.stdout diff --git a/test/test_stash.py b/test/test_stash.py index dadf045..b4b161c 100644 --- a/test/test_stash.py +++ b/test/test_stash.py @@ -3,75 +3,74 @@ import pytest -def test_stash_push(xtl_clone, commit_env_config, git2cpp_path, tmp_path): - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" +def test_stash_push(repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path): + assert (tmp_path / "initial.txt").exists() - p = xtl_path / "mook_file.txt" + p = tmp_path / "mook_file.txt" p.write_text("blabla") cmd_add = [git2cpp_path, "add", "mook_file.txt"] - p_add = subprocess.run(cmd_add, cwd=xtl_path, text=True) + p_add = subprocess.run(cmd_add, cwd=tmp_path, text=True) assert p_add.returncode == 0 - stash_path = xtl_path / ".git/refs/stash" + stash_path = tmp_path / ".git/refs/stash" assert not stash_path.exists() cmd_stash = [git2cpp_path, "stash"] - p_stash = subprocess.run(cmd_stash, capture_output=True, cwd=xtl_path, text=True) + p_stash = subprocess.run(cmd_stash, capture_output=True, cwd=tmp_path, text=True) assert p_stash.returncode == 0 assert stash_path.exists() -def test_stash_list(xtl_clone, commit_env_config, git2cpp_path, tmp_path): - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" +def test_stash_list(repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path): + assert (tmp_path / "initial.txt").exists() - p = xtl_path / "mook_file.txt" + p = tmp_path / "mook_file.txt" p.write_text("blabla") cmd_add = [git2cpp_path, "add", "mook_file.txt"] - p_add = subprocess.run(cmd_add, cwd=xtl_path, text=True) + p_add = subprocess.run(cmd_add, cwd=tmp_path, text=True) assert p_add.returncode == 0 cmd_list = [git2cpp_path, "stash", "list"] - p_list = subprocess.run(cmd_list, capture_output=True, cwd=xtl_path, text=True) + p_list = subprocess.run(cmd_list, capture_output=True, cwd=tmp_path, text=True) assert p_list.returncode == 0 assert "stash@{0}" not in p_list.stdout cmd_stash = [git2cpp_path, "stash"] - p_stash = subprocess.run(cmd_stash, capture_output=True, cwd=xtl_path, text=True) + p_stash = subprocess.run(cmd_stash, capture_output=True, cwd=tmp_path, text=True) assert p_stash.returncode == 0 - p_list_2 = subprocess.run(cmd_list, capture_output=True, cwd=xtl_path, text=True) + p_list_2 = subprocess.run(cmd_list, capture_output=True, cwd=tmp_path, text=True) assert p_list_2.returncode == 0 assert "stash@{0}" in p_list_2.stdout @pytest.mark.parametrize("index_flag", ["", "--index"]) -def test_stash_pop(xtl_clone, commit_env_config, git2cpp_path, tmp_path, index_flag): - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" +def test_stash_pop( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path, index_flag +): + assert (tmp_path / "initial.txt").exists() index = 0 if index_flag == "" else 1 for i in range(index + 1): - p = xtl_path / f"mook_file_{i}.txt" + p = tmp_path / f"mook_file_{i}.txt" p.write_text(f"blabla{i}") cmd_add = [git2cpp_path, "add", f"mook_file_{i}.txt"] - p_add = subprocess.run(cmd_add, cwd=xtl_path, text=True) + p_add = subprocess.run(cmd_add, cwd=tmp_path, text=True) assert p_add.returncode == 0 cmd_stash = [git2cpp_path, "stash"] p_stash = subprocess.run( - cmd_stash, capture_output=True, cwd=xtl_path, text=True + cmd_stash, capture_output=True, cwd=tmp_path, text=True ) assert p_stash.returncode == 0 cmd_status = [git2cpp_path, "status"] p_status = subprocess.run( - cmd_status, capture_output=True, cwd=xtl_path, text=True + cmd_status, capture_output=True, cwd=tmp_path, text=True ) assert p_status.returncode == 0 assert "mook_file" not in p_status.stdout @@ -80,13 +79,13 @@ def test_stash_pop(xtl_clone, commit_env_config, git2cpp_path, tmp_path, index_f if index_flag != "": cmd_pop.append(index_flag) cmd_pop.append("1") - p_pop = subprocess.run(cmd_pop, capture_output=True, cwd=xtl_path, text=True) + p_pop = subprocess.run(cmd_pop, capture_output=True, cwd=tmp_path, text=True) assert p_pop.returncode == 0 assert "mook_file_0" in p_pop.stdout assert "Dropped refs/stash@{" + str(index) + "}" in p_pop.stdout cmd_list = [git2cpp_path, "stash", "list"] - p_list = subprocess.run(cmd_list, capture_output=True, cwd=xtl_path, text=True) + p_list = subprocess.run(cmd_list, capture_output=True, cwd=tmp_path, text=True) assert p_list.returncode == 0 if index_flag == "": assert p_list.stdout == "" @@ -96,29 +95,30 @@ def test_stash_pop(xtl_clone, commit_env_config, git2cpp_path, tmp_path, index_f @pytest.mark.parametrize("index_flag", ["", "--index"]) -def test_stash_apply(xtl_clone, commit_env_config, git2cpp_path, tmp_path, index_flag): - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" +def test_stash_apply( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path, index_flag +): + assert (tmp_path / "initial.txt").exists() index = 0 if index_flag == "" else 1 for i in range(index + 1): - p = xtl_path / f"mook_file_{i}.txt" + p = tmp_path / f"mook_file_{i}.txt" p.write_text(f"blabla{i}") cmd_add = [git2cpp_path, "add", f"mook_file_{i}.txt"] - p_add = subprocess.run(cmd_add, cwd=xtl_path, text=True) + p_add = subprocess.run(cmd_add, cwd=tmp_path, text=True) assert p_add.returncode == 0 cmd_stash = [git2cpp_path, "stash"] p_stash = subprocess.run( - cmd_stash, capture_output=True, cwd=xtl_path, text=True + cmd_stash, capture_output=True, cwd=tmp_path, text=True ) assert p_stash.returncode == 0 cmd_status = [git2cpp_path, "status"] p_status = subprocess.run( - cmd_status, capture_output=True, cwd=xtl_path, text=True + cmd_status, capture_output=True, cwd=tmp_path, text=True ) assert p_status.returncode == 0 assert "mook_file" not in p_status.stdout @@ -127,36 +127,35 @@ def test_stash_apply(xtl_clone, commit_env_config, git2cpp_path, tmp_path, index if index_flag != "": cmd_apply.append(index_flag) cmd_apply.append("1") - p_apply = subprocess.run(cmd_apply, capture_output=True, cwd=xtl_path, text=True) + p_apply = subprocess.run(cmd_apply, capture_output=True, cwd=tmp_path, text=True) assert p_apply.returncode == 0 assert "mook_file_0" in p_apply.stdout cmd_list = [git2cpp_path, "stash", "list"] - p_list = subprocess.run(cmd_list, capture_output=True, cwd=xtl_path, text=True) + p_list = subprocess.run(cmd_list, capture_output=True, cwd=tmp_path, text=True) assert p_list.returncode == 0 assert "stash@{0}" in p_list.stdout if index_flag != "": assert "stash@{1}" in p_list.stdout -def test_stash_show(xtl_clone, commit_env_config, git2cpp_path, tmp_path): - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" +def test_stash_show(repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path): + assert (tmp_path / "initial.txt").exists() filename = "mook_show.txt" - p = xtl_path / filename + p = tmp_path / filename p.write_text("Hello") cmd_add = [git2cpp_path, "add", filename] - p_add = subprocess.run(cmd_add, cwd=xtl_path, text=True) + p_add = subprocess.run(cmd_add, cwd=tmp_path, text=True) assert p_add.returncode == 0 cmd_stash = [git2cpp_path, "stash"] - p_stash = subprocess.run(cmd_stash, capture_output=True, cwd=xtl_path, text=True) + p_stash = subprocess.run(cmd_stash, capture_output=True, cwd=tmp_path, text=True) assert p_stash.returncode == 0 cmd_show = [git2cpp_path, "stash", "show", "--stat"] - p_show = subprocess.run(cmd_show, capture_output=True, cwd=xtl_path, text=True) + p_show = subprocess.run(cmd_show, capture_output=True, cwd=tmp_path, text=True) assert p_show.returncode == 0 # A diffstat should mention the file and summary "file changed" diff --git a/test/test_status.py b/test/test_status.py index b6fb075..9a27441 100644 --- a/test/test_status.py +++ b/test/test_status.py @@ -7,27 +7,42 @@ @pytest.mark.parametrize("short_flag", ["", "-s", "--short"]) @pytest.mark.parametrize("long_flag", ["", "--long"]) -def test_status_new_file(xtl_clone, git2cpp_path, tmp_path, short_flag, long_flag): - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" - - p = xtl_path / "mook_file.txt" # Untracked files +def test_status_new_file( + repo_init_with_commit, git2cpp_path, tmp_path, short_flag, long_flag +): + assert (tmp_path / "initial.txt").exists() + + default_branch = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + cwd=tmp_path, + text=True, + check=True, + ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented + + p = tmp_path / "mook_file.txt" # Untracked files p.write_text("") - pw = xtl_path / "CMakeLists.txt" # Changes not staged for commit / modified + pw = tmp_path / "initial.txt" # Changes not staged for commit / modified pw.write_text("blablabla") - os.remove(xtl_path / "README.md") # Changes not staged for commit / deleted + deletable = tmp_path / "to_delete.txt" + deletable.write_text("delete me") + subprocess.run([git2cpp_path, "add", "to_delete.txt"], cwd=tmp_path, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", "Add deletable"], cwd=tmp_path, check=True + ) + os.remove(deletable) # Changes not staged for commit / deleted cmd = [git2cpp_path, "status"] if short_flag != "": cmd.append(short_flag) if long_flag != "": cmd.append(long_flag) - p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True, check=True) + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True, check=True) if (long_flag == "--long") or ((long_flag == "") & (short_flag == "")): - assert "On branch master" in p.stdout + assert f"On branch {default_branch}" in p.stdout assert "Changes not staged for commit" in p.stdout assert "Untracked files" in p.stdout assert "deleted" in p.stdout @@ -48,17 +63,18 @@ def test_status_nogit(git2cpp_path, tmp_path): @pytest.mark.parametrize("short_flag", ["", "-s", "--short"]) @pytest.mark.parametrize("long_flag", ["", "--long"]) -def test_status_add_file(xtl_clone, git2cpp_path, tmp_path, short_flag, long_flag): - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" +def test_status_add_file( + repo_init_with_commit, git2cpp_path, tmp_path, short_flag, long_flag +): + assert (tmp_path / "initial.txt").exists() - p = xtl_path / "mook_file.txt" # Changes to be committed / new file + p = tmp_path / "mook_file.txt" # Changes to be committed / new file p.write_text("") - os.remove(xtl_path / "README.md") # Changes to be committed / deleted + os.remove(tmp_path / "initial.txt") # Changes to be committed / deleted cmd_add = [git2cpp_path, "add", "--all"] - p_add = subprocess.run(cmd_add, cwd=xtl_path, text=True) + p_add = subprocess.run(cmd_add, cwd=tmp_path, text=True) assert p_add.returncode == 0 cmd_status = [git2cpp_path, "status"] @@ -66,7 +82,7 @@ def test_status_add_file(xtl_clone, git2cpp_path, tmp_path, short_flag, long_fla cmd_status.append(short_flag) if long_flag != "": cmd_status.append(long_flag) - p_status = subprocess.run(cmd_status, capture_output=True, cwd=xtl_path, text=True) + p_status = subprocess.run(cmd_status, capture_output=True, cwd=tmp_path, text=True) assert p_status.returncode == 0 if (long_flag == "--long") or ((long_flag == "") & (short_flag == "")): @@ -89,56 +105,82 @@ def test_status_new_repo(git2cpp_path, tmp_path, run_in_tmp_path): p = subprocess.run(cmd, cwd=tmp_path) assert p.returncode == 0 + default_branch = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + cwd=tmp_path, + text=True, + check=True, + ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented + print(default_branch) + cmd_status = [git2cpp_path, "status"] p_status = subprocess.run(cmd_status, capture_output=True, cwd=tmp_path, text=True) assert p_status.returncode == 0 - assert "On branch ma" in p_status.stdout # "main" locally, but "master" in the CI + assert f"On branch {default_branch}" in p_status.stdout assert "No commit yet" in p_status.stdout assert "Nothing to commit, working tree clean" in p_status.stdout -def test_status_clean_tree(xtl_clone, git2cpp_path, tmp_path): +def test_status_clean_tree(repo_init_with_commit, git2cpp_path, tmp_path): """Test 'Nothing to commit, working tree clean' message""" - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() + + default_branch = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + cwd=tmp_path, + text=True, + check=True, + ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented cmd = [git2cpp_path, "status"] - p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 - assert "On branch master" in p.stdout + assert f"On branch {default_branch}" in p.stdout assert "Nothing to commit, working tree clean" in p.stdout @pytest.mark.parametrize("short_flag", ["", "-s"]) -def test_status_rename_detection(xtl_clone, git2cpp_path, tmp_path, short_flag): +def test_status_rename_detection( + repo_init_with_commit, git2cpp_path, tmp_path, short_flag +): """Test that renamed files are detected correctly""" - xtl_path = tmp_path / "xtl" - - # Rename a file using git mv or by moving and staging - old_readme = xtl_path / "README.md" - new_readme = xtl_path / "README_renamed.md" - - # Move the README file - os.rename(old_readme, new_readme) - - # Move/rename the LICENCE file using mv - cmd_mv = [git2cpp_path, "mv", "LICENSE", "LICENSE_renamed"] - subprocess.run(cmd_mv, capture_output=True, cwd=xtl_path, check=True) + assert (tmp_path / "initial.txt").exists() + + # Create tracked file to rename + new_file = tmp_path / "other_file.txt" + new_file.write_text("Another file") + subprocess.run([git2cpp_path, "add", "other_file.txt"], cwd=tmp_path, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", "Add file to rename"], cwd=tmp_path, check=True + ) + + # Rename a file by moving and staging + # Move the initial file + old_name = tmp_path / "initial.txt" + new_name = tmp_path / "initial_renamed.txt" + os.rename(old_name, new_name) + + # Move/rename the other file using mv + cmd_mv = [git2cpp_path, "mv", "other_file.txt", "other_file_renamed.txt"] + subprocess.run(cmd_mv, capture_output=True, cwd=tmp_path, check=True) # Stage both the deletion and addition cmd_add = [git2cpp_path, "add", "--all"] - subprocess.run(cmd_add, capture_output=True, cwd=xtl_path, check=True) + subprocess.run(cmd_add, capture_output=True, cwd=tmp_path, check=True) # Check status cmd_status = [git2cpp_path, "status"] if short_flag == "-s": cmd_status.append(short_flag) - p = subprocess.run(cmd_status, capture_output=True, cwd=xtl_path, text=True) + p = subprocess.run(cmd_status, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 # Should show as renamed, not as deleted + new file - assert "README.md -> README_renamed.md" in p.stdout - assert "LICENSE -> LICENSE_renamed" in p.stdout + assert "initial.txt -> initial_renamed.txt" in p.stdout + assert "other_file.txt -> other_file_renamed.txt" in p.stdout if short_flag == "-s": assert "R " in p.stdout else: @@ -146,64 +188,78 @@ def test_status_rename_detection(xtl_clone, git2cpp_path, tmp_path, short_flag): @pytest.mark.parametrize("short_flag", ["", "-s"]) -def test_status_mixed_changes(xtl_clone, git2cpp_path, tmp_path, short_flag): +def test_status_mixed_changes( + repo_init_with_commit, git2cpp_path, tmp_path, short_flag +): """Test status with both staged and unstaged changes""" - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() # Create a new file and stage it - staged_file = xtl_path / "staged.txt" + staged_file = tmp_path / "staged.txt" staged_file.write_text("staged content") - # Deleted a file staged - del_file = xtl_path / "README.md" + # Create a tracked file, then delete it (and stage the deletion) + del_file = tmp_path / "to_delete.txt" + del_file.write_text("delete me") + subprocess.run([git2cpp_path, "add", "to_delete.txt"], cwd=tmp_path, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", "Add deletable"], cwd=tmp_path, check=True + ) os.remove(del_file) # Stage the two previous files - subprocess.run([git2cpp_path, "add", "staged.txt", "README.md"], cwd=xtl_path, check=True) + subprocess.run( + [git2cpp_path, "add", "staged.txt", "to_delete.txt"], cwd=tmp_path, check=True + ) # Modify an existing file without staging - unstaged_file = xtl_path / "CMakeLists.txt" + unstaged_file = tmp_path / "initial.txt" unstaged_file.write_text("unstaged changes") # Create an untracked file - untracked_file = xtl_path / "untracked.txt" + untracked_file = tmp_path / "untracked.txt" untracked_file.write_text("untracked") cmd_status = [git2cpp_path, "status"] if short_flag == "-s": cmd_status.append(short_flag) - p = subprocess.run(cmd_status, capture_output=True, cwd=xtl_path, text=True) + p = subprocess.run(cmd_status, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 if short_flag == "-s": assert "A staged.txt" in p.stdout - assert "D README.md" in p.stdout - assert " M CMakeLists.txt" in p.stdout + assert "D to_delete.txt" in p.stdout + assert " M initial.txt" in p.stdout assert "?? untracked.txt" in p.stdout else: assert "Changes to be committed" in p.stdout assert "new file: staged.txt" in p.stdout - assert "deleted: README.md" in p.stdout + assert "deleted: to_delete.txt" in p.stdout assert "Changes not staged for commit" in p.stdout - assert "modified: CMakeLists.txt" in p.stdout + assert "modified: initial.txt" in p.stdout assert "Untracked files" in p.stdout assert "untracked.txt" in p.stdout @pytest.mark.parametrize("short_flag", ["", "-s"]) -def test_status_typechange(xtl_clone, git2cpp_path, tmp_path, short_flag): +def test_status_typechange(repo_init_with_commit, git2cpp_path, tmp_path, short_flag): """Test status shows typechange (file to symlink or vice versa)""" - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() # Remove a file and replace with a symlink - test_file = xtl_path / "README.md" + test_file = tmp_path / "typechange" + test_file.write_text("regular file") + subprocess.run([git2cpp_path, "add", "typechange"], cwd=tmp_path, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", "Add typechange"], cwd=tmp_path, check=True + ) os.remove(test_file) - os.symlink("CMakeLists.txt", test_file) + os.symlink("initial.txt", test_file) cmd_status = [git2cpp_path, "status"] if short_flag == "-s": cmd_status.append(short_flag) - p = subprocess.run(cmd_status, capture_output=True, cwd=xtl_path, text=True) + p = subprocess.run(cmd_status, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 # Should show typechange in unstaged changes @@ -214,12 +270,14 @@ def test_status_typechange(xtl_clone, git2cpp_path, tmp_path, short_flag): @pytest.mark.parametrize("short_flag", ["", "-s"]) -def test_status_untracked_directory(xtl_clone, git2cpp_path, tmp_path, short_flag): +def test_status_untracked_directory( + repo_init_with_commit, git2cpp_path, tmp_path, short_flag +): """Test that untracked directories are shown with trailing slash""" - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() # Create a directory with files - new_dir = xtl_path / "new_directory" + new_dir = tmp_path / "new_directory" new_dir.mkdir() (new_dir / "file1.txt").write_text("content1") (new_dir / "file2.txt").write_text("content2") @@ -227,7 +285,7 @@ def test_status_untracked_directory(xtl_clone, git2cpp_path, tmp_path, short_fla cmd_status = [git2cpp_path, "status"] if short_flag == "-s": cmd_status.append(short_flag) - p = subprocess.run(cmd_status, capture_output=True, cwd=xtl_path, text=True) + p = subprocess.run(cmd_status, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 if short_flag == "-s": @@ -241,7 +299,9 @@ def test_status_untracked_directory(xtl_clone, git2cpp_path, tmp_path, short_fla @pytest.mark.parametrize("short_flag", ["", "-s"]) -def test_status_ahead_of_upstream(commit_env_config, git2cpp_path, tmp_path, short_flag): +def test_status_ahead_of_upstream( + commit_env_config, git2cpp_path, tmp_path, short_flag +): """Test status when local branch is ahead of upstream""" # Create a repository with remote tracking repo_path = tmp_path / "repo" @@ -250,6 +310,14 @@ def test_status_ahead_of_upstream(commit_env_config, git2cpp_path, tmp_path, sho # Initialize repo subprocess.run([git2cpp_path, "init"], cwd=repo_path, check=True) + default_branch = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + cwd=repo_path, + text=True, + check=True, + ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented + # Create initial commit test_file = repo_path / "file.txt" test_file.write_text("initial") @@ -258,39 +326,52 @@ def test_status_ahead_of_upstream(commit_env_config, git2cpp_path, tmp_path, sho # Clone it to create remote tracking clone_path = tmp_path / "clone" - subprocess.run(["git", "clone", str(repo_path), str(clone_path)], check=True) + subprocess.run([git2cpp_path, "clone", str(repo_path), str(clone_path)], check=True) # Make a commit in clone clone_file = clone_path / "file2.txt" clone_file.write_text("new file") subprocess.run([git2cpp_path, "add", "file2.txt"], cwd=clone_path, check=True) - subprocess.run([git2cpp_path, "commit", "-m", "second commit"], cwd=clone_path, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", "second commit"], cwd=clone_path, check=True + ) # Check status cmd_status = [git2cpp_path, "status"] - if (short_flag == "-s"): + if short_flag == "-s": cmd_status.append(short_flag) p = subprocess.run(cmd_status, capture_output=True, cwd=clone_path, text=True) assert p.returncode == 0 if short_flag == "-s": - assert "...origin/ma" in p.stdout # "main" locally, but "master" in the CI + assert f"...origin/{default_branch}" in p.stdout assert "[ahead 1]" in p.stdout else: - assert "Your branch is ahead of" in p.stdout + assert f"Your branch is ahead of 'origin/{default_branch}'" in p.stdout assert "by 1 commit" in p.stdout assert 'use "git push"' in p.stdout @pytest.mark.parametrize("short_flag", ["", "-s"]) @pytest.mark.parametrize("branch_flag", ["-b", "--branch"]) -def test_status_with_branch_and_tracking(commit_env_config, git2cpp_path, tmp_path, short_flag, branch_flag): +def test_status_with_branch_and_tracking( + commit_env_config, git2cpp_path, tmp_path, short_flag, branch_flag +): """Test short format with branch flag shows tracking info""" # Create a repository with remote tracking repo_path = tmp_path / "repo" repo_path.mkdir() subprocess.run([git2cpp_path, "init"], cwd=repo_path) + + default_branch = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + cwd=repo_path, + text=True, + check=True, + ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented + test_file = repo_path / "file.txt" test_file.write_text("initial") subprocess.run([git2cpp_path, "add", "file.txt"], cwd=repo_path, check=True) @@ -298,7 +379,7 @@ def test_status_with_branch_and_tracking(commit_env_config, git2cpp_path, tmp_pa # Clone it clone_path = tmp_path / "clone" - subprocess.run(["git", "clone", str(repo_path), str(clone_path)], check=True) + subprocess.run([git2cpp_path, "clone", str(repo_path), str(clone_path)], check=True) # Make a commit clone_file = clone_path / "file2.txt" @@ -314,36 +395,50 @@ def test_status_with_branch_and_tracking(commit_env_config, git2cpp_path, tmp_pa assert p.returncode == 0 if short_flag == "-s": - assert "## ma" in p.stdout # "main" locally, but "master" in the CI + assert ( + f"## {default_branch}" in p.stdout + ) # "main" locally, but "master" in the CI assert "[ahead 1]" in p.stdout else: - assert "On branch ma" in p.stdout # "main" locally, but "master" in the CI - assert "Your branch is ahead of 'origin/ma" in p.stdout # "main" locally, but "master" in the CI + assert ( + f"On branch {default_branch}" in p.stdout + ) # "main" locally, but "master" in the CI + assert ( + f"Your branch is ahead of 'origin/{default_branch}'" in p.stdout + ) # "main" locally, but "master" in the CI assert "1 commit." in p.stdout -def test_status_all_headers_shown(xtl_clone, git2cpp_path, tmp_path): +def test_status_all_headers_shown(repo_init_with_commit, git2cpp_path, tmp_path): """Test that all status headers can be shown together""" - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() + + default_branch = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + cwd=tmp_path, + text=True, + check=True, + ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented # Changes to be committed - staged = xtl_path / "staged.txt" + staged = tmp_path / "staged.txt" staged.write_text("staged") - subprocess.run([git2cpp_path, "add", "staged.txt"], cwd=xtl_path, check=True) + subprocess.run([git2cpp_path, "add", "staged.txt"], cwd=tmp_path, check=True) # Changes not staged - modified = xtl_path / "CMakeLists.txt" + modified = tmp_path / "initial.txt" modified.write_text("modified") # Untracked - untracked = xtl_path / "untracked.txt" + untracked = tmp_path / "untracked.txt" untracked.write_text("untracked") cmd_status = [git2cpp_path, "status"] - p = subprocess.run(cmd_status, capture_output=True, cwd=xtl_path, text=True) + p = subprocess.run(cmd_status, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 - assert "On branch master" in p.stdout + assert f"On branch {default_branch}" in p.stdout assert "Changes to be committed:" in p.stdout assert 'use "git reset HEAD ..." to unstage' in p.stdout assert "Changes not staged for commit:" in p.stdout diff --git a/test/test_tag.py b/test/test_tag.py index d698463..5575877 100644 --- a/test/test_tag.py +++ b/test/test_tag.py @@ -2,91 +2,100 @@ import pytest -def test_tag_list_empty(xtl_clone, git2cpp_path, tmp_path): + +def test_tag_list_empty(repo_init_with_commit, git2cpp_path, tmp_path): """Test listing tags when there are no tags.""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() - cmd = [git2cpp_path, 'tag'] - p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + cmd = [git2cpp_path, "tag"] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 - assert "0.2.0" in p.stdout + assert p.stdout == "" -def test_tag_create_lightweight(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_tag_create_lightweight( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path +): """Test creating a lightweight tag.""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() # Create a lightweight tag - create_cmd = [git2cpp_path, 'tag', 'v1.0.0'] - subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, text=True, check=True) + create_cmd = [git2cpp_path, "tag", "v1.0.0"] + subprocess.run(create_cmd, capture_output=True, cwd=tmp_path, text=True, check=True) # List tags to verify it was created - list_cmd = [git2cpp_path, 'tag'] - p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + list_cmd = [git2cpp_path, "tag"] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_list.returncode == 0 - assert 'v1.0.0' in p_list.stdout + assert "v1.0.0" in p_list.stdout -def test_tag_create_annotated(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_tag_create_annotated( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path +): """Test creating an annotated tag.""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() # Create an annotated tag - create_cmd = [git2cpp_path, 'tag', '-m', 'Release version 1.0', 'v1.0.0'] - subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, text=True, check=True) + create_cmd = [git2cpp_path, "tag", "-m", "Release version 1.0", "v1.0.0"] + subprocess.run(create_cmd, capture_output=True, cwd=tmp_path, text=True, check=True) # List tags to verify it was created - list_cmd = [git2cpp_path, 'tag', "-n", "1"] - p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + list_cmd = [git2cpp_path, "tag", "-n", "1"] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_list.returncode == 0 - assert 'v1.0.0' in p_list.stdout - assert 'Release version 1.0' in p_list.stdout + assert "v1.0.0" in p_list.stdout + assert "Release version 1.0" in p_list.stdout -def test_tag_create_on_specific_commit(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_tag_create_on_specific_commit( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path +): """Test creating a tag on a specific commit.""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() # Get the commit SHA before creating new commit - old_head_cmd = ['git', 'rev-parse', 'HEAD'] - p_old_head = subprocess.run(old_head_cmd, capture_output=True, cwd=xtl_path, text=True) + old_head_cmd = ["git", "rev-parse", "HEAD"] + p_old_head = subprocess.run( + old_head_cmd, capture_output=True, cwd=tmp_path, text=True + ) old_head_sha = p_old_head.stdout.strip() # Create a commit first - file_path = xtl_path / "test_file.txt" + file_path = tmp_path / "test_file.txt" file_path.write_text("test content") - add_cmd = [git2cpp_path, 'add', 'test_file.txt'] - subprocess.run(add_cmd, cwd=xtl_path, check=True) + add_cmd = [git2cpp_path, "add", "test_file.txt"] + subprocess.run(add_cmd, cwd=tmp_path, check=True) - commit_cmd = [git2cpp_path, 'commit', '-m', 'test commit'] - subprocess.run(commit_cmd, cwd=xtl_path, check=True) + commit_cmd = [git2cpp_path, "commit", "-m", "test commit"] + subprocess.run(commit_cmd, cwd=tmp_path, check=True) # Get new HEAD commit SHA - new_head_cmd = ['git', 'rev-parse', 'HEAD'] - p_new_head = subprocess.run(new_head_cmd, capture_output=True, cwd=xtl_path, text=True) + new_head_cmd = ["git", "rev-parse", "HEAD"] + p_new_head = subprocess.run( + new_head_cmd, capture_output=True, cwd=tmp_path, text=True + ) new_head_sha = p_new_head.stdout.strip() # Verify we actually created a new commit assert old_head_sha != new_head_sha # Create tag on HEAD - tag_cmd = [git2cpp_path, 'tag', 'v1.0.0', 'HEAD'] - subprocess.run(tag_cmd, capture_output=True, cwd=xtl_path, check=True) + tag_cmd = [git2cpp_path, "tag", "v1.0.0", "HEAD"] + subprocess.run(tag_cmd, capture_output=True, cwd=tmp_path, check=True) # Verify tag exists - list_cmd = [git2cpp_path, 'tag'] - p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + list_cmd = [git2cpp_path, "tag"] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_list.returncode == 0 - assert 'v1.0.0' in p_list.stdout + assert "v1.0.0" in p_list.stdout # Get commit SHA that the tag points to - tag_sha_cmd = ['git', 'rev-parse', 'v1.0.0^{commit}'] - p_tag_sha = subprocess.run(tag_sha_cmd, capture_output=True, cwd=xtl_path, text=True) + tag_sha_cmd = ["git", "rev-parse", "v1.0.0^{commit}"] + p_tag_sha = subprocess.run( + tag_sha_cmd, capture_output=True, cwd=tmp_path, text=True + ) tag_sha = p_tag_sha.stdout.strip() # Verify tag points to new HEAD, not old HEAD @@ -94,172 +103,186 @@ def test_tag_create_on_specific_commit(xtl_clone, commit_env_config, git2cpp_pat assert tag_sha != old_head_sha -def test_tag_delete(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_tag_delete(repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path): """Test deleting a tag.""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() # Create a tag - create_cmd = [git2cpp_path, 'tag', 'v1.0.0'] - subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, text=True, check=True) + create_cmd = [git2cpp_path, "tag", "v1.0.0"] + subprocess.run(create_cmd, capture_output=True, cwd=tmp_path, text=True, check=True) # Delete the tag - delete_cmd = [git2cpp_path, 'tag', '-d', 'v1.0.0'] - p_delete = subprocess.run(delete_cmd, capture_output=True, cwd=xtl_path, text=True) + delete_cmd = [git2cpp_path, "tag", "-d", "v1.0.0"] + p_delete = subprocess.run(delete_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_delete.returncode == 0 assert "Deleted tag 'v1.0.0'" in p_delete.stdout # Verify tag is gone - list_cmd = [git2cpp_path, 'tag'] - p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + list_cmd = [git2cpp_path, "tag"] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_list.returncode == 0 - assert 'v1.0.0' not in p_list.stdout + assert "v1.0.0" not in p_list.stdout -def test_tag_delete_nonexistent(xtl_clone, git2cpp_path, tmp_path): +def test_tag_delete_nonexistent(repo_init_with_commit, git2cpp_path, tmp_path): """Test deleting a tag that doesn't exist.""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() # Try to delete non-existent tag - delete_cmd = [git2cpp_path, 'tag', '-d', 'nonexistent'] - p_delete = subprocess.run(delete_cmd, capture_output=True, cwd=xtl_path, text=True) + delete_cmd = [git2cpp_path, "tag", "-d", "nonexistent"] + p_delete = subprocess.run(delete_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_delete.returncode != 0 assert "not found" in p_delete.stderr @pytest.mark.parametrize("list_flag", ["-l", "--list"]) -def test_tag_list_with_flag(xtl_clone, commit_env_config, git2cpp_path, tmp_path, list_flag): +def test_tag_list_with_flag( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path, list_flag +): """Test listing tags with -l or --list flag.""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() # Create a tag - tag_cmd = [git2cpp_path, 'tag', 'v1.0.0'] - subprocess.run(tag_cmd, capture_output=True, cwd=xtl_path, text=True) + tag_cmd = [git2cpp_path, "tag", "v1.0.0"] + subprocess.run(tag_cmd, capture_output=True, cwd=tmp_path, text=True) # List tags - list_cmd = [git2cpp_path, 'tag', list_flag] - p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + list_cmd = [git2cpp_path, "tag", list_flag] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_list.returncode == 0 - assert 'v1.0.0' in p_list.stdout + assert "v1.0.0" in p_list.stdout -def test_tag_list_with_pattern(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_tag_list_with_pattern( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path +): """Test listing tags with a pattern.""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() # Create tags with different prefixes - tag_cmd_1 = [git2cpp_path, 'tag', 'v1.0.0'] - subprocess.run(tag_cmd_1, capture_output=True, cwd=xtl_path, text=True) + tag_cmd_1 = [git2cpp_path, "tag", "v1.0.0"] + subprocess.run(tag_cmd_1, capture_output=True, cwd=tmp_path, text=True) - tag_cmd_2 = [git2cpp_path, 'tag', 'v1.0.1'] - subprocess.run(tag_cmd_2, capture_output=True, cwd=xtl_path, text=True) + tag_cmd_2 = [git2cpp_path, "tag", "v1.0.1"] + subprocess.run(tag_cmd_2, capture_output=True, cwd=tmp_path, text=True) - tag_cmd_3 = [git2cpp_path, 'tag', 'release-1.0'] - subprocess.run(tag_cmd_3, capture_output=True, cwd=xtl_path, text=True) + tag_cmd_3 = [git2cpp_path, "tag", "release-1.0"] + subprocess.run(tag_cmd_3, capture_output=True, cwd=tmp_path, text=True) # List only tags matching pattern - list_cmd = [git2cpp_path, 'tag', '-l', 'v1.0*'] - p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + list_cmd = [git2cpp_path, "tag", "-l", "v1.0*"] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_list.returncode == 0 - assert 'v1.0.0' in p_list.stdout - assert 'v1.0.1' in p_list.stdout - assert 'release-1.0' not in p_list.stdout + assert "v1.0.0" in p_list.stdout + assert "v1.0.1" in p_list.stdout + assert "release-1.0" not in p_list.stdout -def test_tag_list_with_message_lines(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_tag_list_with_message_lines( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path +): """Test listing tags with message lines (-n flag).""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() # Create an annotated tag with a message - create_cmd = [git2cpp_path, 'tag', '-m', 'First line\nSecond line\nThird line\nForth line', 'v1.0.0'] - p_create = subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, text=True) + create_cmd = [ + git2cpp_path, + "tag", + "-m", + "First line\nSecond line\nThird line\nForth line", + "v1.0.0", + ] + p_create = subprocess.run(create_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_create.returncode == 0 # List tags with message lines - list_cmd = [git2cpp_path, 'tag', '-n', '3', '-l'] - p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + list_cmd = [git2cpp_path, "tag", "-n", "3", "-l"] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_list.returncode == 0 - assert 'v1.0.0' in p_list.stdout - assert 'First line' in p_list.stdout - assert 'Second line' in p_list.stdout - assert 'Third line' in p_list.stdout - assert 'Forth line' not in p_list.stdout + assert "v1.0.0" in p_list.stdout + assert "First line" in p_list.stdout + assert "Second line" in p_list.stdout + assert "Third line" in p_list.stdout + assert "Forth line" not in p_list.stdout @pytest.mark.parametrize("force_flag", ["-f", "--force"]) -def test_tag_force_replace(xtl_clone, commit_env_config, git2cpp_path, tmp_path, force_flag): +def test_tag_force_replace( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path, force_flag +): """Test replacing an existing tag with -f or --force flag.""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() # Create initial tag - create_cmd_1 = [git2cpp_path, 'tag', 'v1.0.0'] - subprocess.run(create_cmd_1, capture_output=True, cwd=xtl_path, text=True, check=True) + create_cmd_1 = [git2cpp_path, "tag", "v1.0.0"] + subprocess.run( + create_cmd_1, capture_output=True, cwd=tmp_path, text=True, check=True + ) # Try to create same tag without force (should fail) - create_cmd_2 = [git2cpp_path, 'tag', 'v1.0.0'] - p_create_2 = subprocess.run(create_cmd_2, capture_output=True, cwd=xtl_path) + create_cmd_2 = [git2cpp_path, "tag", "v1.0.0"] + p_create_2 = subprocess.run(create_cmd_2, capture_output=True, cwd=tmp_path) assert p_create_2.returncode != 0 # Create same tag with force (should succeed) - create_cmd_3 = [git2cpp_path, 'tag', force_flag, 'v1.0.0'] - p_create_3 = subprocess.run(create_cmd_3, capture_output=True, cwd=xtl_path, text=True) + create_cmd_3 = [git2cpp_path, "tag", force_flag, "v1.0.0"] + p_create_3 = subprocess.run( + create_cmd_3, capture_output=True, cwd=tmp_path, text=True + ) assert p_create_3.returncode == 0 def test_tag_nogit(git2cpp_path, tmp_path): """Test tag command outside a git repository.""" - cmd = [git2cpp_path, 'tag'] + cmd = [git2cpp_path, "tag"] p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) assert p.returncode != 0 -def test_tag_annotated_no_message(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_tag_annotated_no_message( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path +): """Test creating an annotated tag without a message should fail.""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() # Create a commit with a known message - file_path = xtl_path / "test_file.txt" + file_path = tmp_path / "test_file.txt" file_path.write_text("test content") - add_cmd = [git2cpp_path, 'add', 'test_file.txt'] - subprocess.run(add_cmd, cwd=xtl_path, check=True) + add_cmd = [git2cpp_path, "add", "test_file.txt"] + subprocess.run(add_cmd, cwd=tmp_path, check=True) - commit_cmd = [git2cpp_path, 'commit', '-m', 'my specific commit message'] - subprocess.run(commit_cmd, cwd=xtl_path, check=True) + commit_cmd = [git2cpp_path, "commit", "-m", "my specific commit message"] + subprocess.run(commit_cmd, cwd=tmp_path, check=True) # Create tag with empty message (should create lightweight tag) - create_cmd = [git2cpp_path, 'tag', '-m', '', 'v1.0.0'] - subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, check=True) + create_cmd = [git2cpp_path, "tag", "-m", "", "v1.0.0"] + subprocess.run(create_cmd, capture_output=True, cwd=tmp_path, check=True) - # List tag with messages - lightweight tag shows commit message - list_cmd = [git2cpp_path, 'tag', '-n', '1'] - p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + # List tag with messages - lightweight tag shows commit message + list_cmd = [git2cpp_path, "tag", "-n", "1"] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_list.returncode == 0 - assert 'v1.0.0' in p_list.stdout + assert "v1.0.0" in p_list.stdout # Lightweight tag shows the commit message, not a tag message - assert 'my specific commit message' in p_list.stdout + assert "my specific commit message" in p_list.stdout -def test_tag_multiple_create_and_list(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_tag_multiple_create_and_list( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path +): """Test creating multiple tags and listing them.""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() # Create multiple tags - tags = ['v1.0.0', 'v1.0.1', 'v1.1.0', 'v2.0.0'] + tags = ["v1.0.0", "v1.0.1", "v1.1.0", "v2.0.0"] for tag in tags: - create_cmd = [git2cpp_path, 'tag', tag] - subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, check=True) + create_cmd = [git2cpp_path, "tag", tag] + subprocess.run(create_cmd, capture_output=True, cwd=tmp_path, check=True) # List all tags - list_cmd = [git2cpp_path, 'tag'] - p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + list_cmd = [git2cpp_path, "tag"] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_list.returncode == 0 # Verify all tags are in the list @@ -267,32 +290,33 @@ def test_tag_multiple_create_and_list(xtl_clone, commit_env_config, git2cpp_path assert tag in p_list.stdout -def test_tag_on_new_commit(xtl_clone, commit_env_config, git2cpp_path, tmp_path): +def test_tag_on_new_commit( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path +): """Test creating tags on new commits.""" - assert (tmp_path / "xtl").exists() - xtl_path = tmp_path / "xtl" + assert (tmp_path / "initial.txt").exists() # Tag the current commit - tag_cmd_1 = [git2cpp_path, 'tag', 'before-change'] - subprocess.run(tag_cmd_1, cwd=xtl_path, check=True) + tag_cmd_1 = [git2cpp_path, "tag", "before-change"] + subprocess.run(tag_cmd_1, cwd=tmp_path, check=True) # Make a new commit - file_path = xtl_path / "new_file.txt" + file_path = tmp_path / "new_file.txt" file_path.write_text("new content") - add_cmd = [git2cpp_path, 'add', 'new_file.txt'] - subprocess.run(add_cmd, cwd=xtl_path, check=True) + add_cmd = [git2cpp_path, "add", "new_file.txt"] + subprocess.run(add_cmd, cwd=tmp_path, check=True) - commit_cmd = [git2cpp_path, 'commit', '-m', 'new commit'] - subprocess.run(commit_cmd, cwd=xtl_path, check=True) + commit_cmd = [git2cpp_path, "commit", "-m", "new commit"] + subprocess.run(commit_cmd, cwd=tmp_path, check=True) # Tag the new commit - tag_cmd_2 = [git2cpp_path, 'tag', 'after-change'] - subprocess.run(tag_cmd_2, cwd=xtl_path, check=True) + tag_cmd_2 = [git2cpp_path, "tag", "after-change"] + subprocess.run(tag_cmd_2, cwd=tmp_path, check=True) # List tags - list_cmd = [git2cpp_path, 'tag'] - p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + list_cmd = [git2cpp_path, "tag"] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_list.returncode == 0 - assert 'before-change' in p_list.stdout - assert 'after-change' in p_list.stdout + assert "before-change" in p_list.stdout + assert "after-change" in p_list.stdout From 912588c7f6cbdcc1fdf0ee7ab0c44a7287a61878 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Tue, 10 Mar 2026 19:21:05 +0000 Subject: [PATCH 23/35] Add codecov badge to README (#117) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index f498f03..9eff6b3 100644 --- a/README.md +++ b/README.md @@ -1,6 +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 From bec1e72f7cad4506c7cf3dfbadb2066b88e32f66 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Wed, 11 Mar 2026 09:21:28 +0000 Subject: [PATCH 24/35] Add support for entering user credentials to access remote private repos (#116) * Add support for entering user credentials to access remote private repos * Be more careful with valid but empty strings * Check private token length * Add `commit_env_config` to `test_push_private_repo` --- .github/workflows/test.yml | 4 ++ CMakeLists.txt | 6 +- src/subcommand/clone_subcommand.cpp | 4 +- src/subcommand/commit_subcommand.cpp | 4 +- src/subcommand/fetch_subcommand.cpp | 4 +- src/subcommand/push_subcommand.cpp | 2 + src/utils/credentials.cpp | 42 +++++++++++++ src/utils/credentials.hpp | 13 ++++ src/utils/input_output.cpp | 74 +++++++++++++++++++++++ src/utils/input_output.hpp | 55 +++++++++++++++++ src/utils/output.cpp | 23 ------- src/utils/output.hpp | 37 ------------ src/utils/terminal_pager.cpp | 2 +- test/conftest.py | 18 ++++++ test/test_clone.py | 90 ++++++++++++++++++++++++++-- test/test_fetch.py | 29 +++++++++ test/test_push.py | 62 +++++++++++++++++++ 17 files changed, 398 insertions(+), 71 deletions(-) create mode 100644 src/utils/credentials.cpp create mode 100644 src/utils/credentials.hpp create mode 100644 src/utils/input_output.cpp create mode 100644 src/utils/input_output.hpp delete mode 100644 src/utils/output.cpp delete mode 100644 src/utils/output.hpp create mode 100644 test/test_fetch.py create mode 100644 test/test_push.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3cdc82e..37adfb0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,6 +48,8 @@ jobs: ./git2cpp -v - name: Run tests + env: + GIT2CPP_TEST_PRIVATE_TOKEN: ${{ secrets.GIT2CPP_TEST_PRIVATE_TOKEN }} run: | pytest -v @@ -76,6 +78,8 @@ jobs: run: cmake --build . --parallel 8 - name: Run tests + env: + GIT2CPP_TEST_PRIVATE_TOKEN: ${{ secrets.GIT2CPP_TEST_PRIVATE_TOKEN }} run: | pytest -v diff --git a/CMakeLists.txt b/CMakeLists.txt index 30c36bc..086c956 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -88,10 +88,12 @@ set(GIT2CPP_SRC ${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 diff --git a/src/subcommand/clone_subcommand.cpp b/src/subcommand/clone_subcommand.cpp index acc6f14..926628e 100644 --- a/src/subcommand/clone_subcommand.cpp +++ b/src/subcommand/clone_subcommand.cpp @@ -1,7 +1,8 @@ #include #include "../subcommand/clone_subcommand.hpp" -#include "../utils/output.hpp" +#include "../utils/credentials.hpp" +#include "../utils/input_output.hpp" #include "../utils/progress.hpp" #include "../wrapper/repository_wrapper.hpp" @@ -42,6 +43,7 @@ void clone_subcommand::run() checkout_opts.progress_cb = checkout_progress; checkout_opts.progress_payload = &pd; clone_opts.checkout_opts = checkout_opts; + clone_opts.fetch_opts.callbacks.credentials = user_credentials; clone_opts.fetch_opts.callbacks.sideband_progress = sideband_progress; clone_opts.fetch_opts.callbacks.transfer_progress = fetch_progress; clone_opts.fetch_opts.callbacks.payload = &pd; diff --git a/src/subcommand/commit_subcommand.cpp b/src/subcommand/commit_subcommand.cpp index bca7f39..ceb4038 100644 --- a/src/subcommand/commit_subcommand.cpp +++ b/src/subcommand/commit_subcommand.cpp @@ -2,6 +2,7 @@ #include #include "../subcommand/commit_subcommand.hpp" +#include "../utils/input_output.hpp" #include "../wrapper/index_wrapper.hpp" #include "../wrapper/repository_wrapper.hpp" @@ -24,8 +25,7 @@ void commit_subcommand::run() if (m_commit_message.empty()) { - std::cout << "Please enter a commit message:" << std::endl; - std::getline(std::cin, m_commit_message); + m_commit_message = prompt_input("Please enter a commit message:\n"); if (m_commit_message.empty()) { throw std::runtime_error("Aborting, no commit message specified."); diff --git a/src/subcommand/fetch_subcommand.cpp b/src/subcommand/fetch_subcommand.cpp index 0662970..77d0bc3 100644 --- a/src/subcommand/fetch_subcommand.cpp +++ b/src/subcommand/fetch_subcommand.cpp @@ -3,7 +3,8 @@ #include #include "../subcommand/fetch_subcommand.hpp" -#include "../utils/output.hpp" +#include "../utils/credentials.hpp" +#include "../utils/input_output.hpp" #include "../utils/progress.hpp" #include "../wrapper/repository_wrapper.hpp" @@ -34,6 +35,7 @@ void fetch_subcommand::run() git_indexer_progress pd = {0}; git_fetch_options fetch_opts = GIT_FETCH_OPTIONS_INIT; + fetch_opts.callbacks.credentials = user_credentials; fetch_opts.callbacks.sideband_progress = sideband_progress; fetch_opts.callbacks.transfer_progress = fetch_progress; fetch_opts.callbacks.payload = &pd; diff --git a/src/subcommand/push_subcommand.cpp b/src/subcommand/push_subcommand.cpp index be04267..56b4346 100644 --- a/src/subcommand/push_subcommand.cpp +++ b/src/subcommand/push_subcommand.cpp @@ -3,6 +3,7 @@ #include #include "../subcommand/push_subcommand.hpp" +#include "../utils/credentials.hpp" #include "../utils/progress.hpp" #include "../wrapper/repository_wrapper.hpp" @@ -27,6 +28,7 @@ void push_subcommand::run() auto remote = repo.find_remote(remote_name); git_push_options push_opts = GIT_PUSH_OPTIONS_INIT; + push_opts.callbacks.credentials = user_credentials; push_opts.callbacks.push_transfer_progress = push_transfer_progress; push_opts.callbacks.push_update_reference = push_update_reference; diff --git a/src/utils/credentials.cpp b/src/utils/credentials.cpp new file mode 100644 index 0000000..9506275 --- /dev/null +++ b/src/utils/credentials.cpp @@ -0,0 +1,42 @@ +#include +#include + +#include "credentials.hpp" +#include "input_output.hpp" + +// git_credential_acquire_cb +int user_credentials( + git_credential** out, + const char* url, + const char* username_from_url, + unsigned int allowed_types, + void* payload) +{ + // Check for cached credentials here, if desired. + // It might be necessary to make this function stateful to avoid repeating unnecessary checks. + + *out = nullptr; + + if (allowed_types & GIT_CREDENTIAL_USERPASS_PLAINTEXT) { + std::string username = username_from_url ? username_from_url : ""; + if (username.empty()) { + username = prompt_input("Username: "); + } + if (username.empty()) { + giterr_set_str(GIT_ERROR_HTTP, "No username specified"); + return GIT_EAUTH; + } + + std::string password = prompt_input("Password: ", false); + if (password.empty()) { + giterr_set_str(GIT_ERROR_HTTP, "No password specified"); + return GIT_EAUTH; + } + + // If successful, this will create and return a git_credential* in the out argument. + return git_credential_userpass_plaintext_new(out, username.c_str(), password.c_str()); + } + + giterr_set_str(GIT_ERROR_HTTP, "Unexpected credentials request"); + return GIT_ERROR; +} diff --git a/src/utils/credentials.hpp b/src/utils/credentials.hpp new file mode 100644 index 0000000..452195b --- /dev/null +++ b/src/utils/credentials.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include + +// Libgit2 callback of type git_credential_acquire_cb to obtain user credentials +// (username and password) to authenticate remote https access. +int user_credentials( + git_credential** out, + const char* url, + const char* username_from_url, + unsigned int allowed_types, + void* payload +); diff --git a/src/utils/input_output.cpp b/src/utils/input_output.cpp new file mode 100644 index 0000000..3225f4d --- /dev/null +++ b/src/utils/input_output.cpp @@ -0,0 +1,74 @@ +#include "ansi_code.hpp" +#include "input_output.hpp" + +// OS-specific libraries. +#include + +cursor_hider::cursor_hider(bool hide /* = true */) + : m_hide(hide) +{ + std::cout << (m_hide ? ansi_code::hide_cursor : ansi_code::show_cursor); +} + +cursor_hider::~cursor_hider() +{ + std::cout << (m_hide ? ansi_code::show_cursor : ansi_code::hide_cursor); +} + + +alternative_buffer::alternative_buffer() +{ + tcgetattr(fileno(stdin), &m_previous_termios); + auto new_termios = m_previous_termios; + // Disable canonical mode (buffered I/O) and echo from stdin to stdout. + new_termios.c_lflag &= (~ICANON & ~ECHO); + tcsetattr(fileno(stdin), TCSANOW, &new_termios); + + std::cout << ansi_code::enable_alternative_buffer; +} + +alternative_buffer::~alternative_buffer() +{ + std::cout << ansi_code::disable_alternative_buffer; + + // Restore previous termios settings. + tcsetattr(fileno(stdin), TCSANOW, &m_previous_termios); +} + +echo_control::echo_control(bool echo) + : m_echo(echo) +{ + if (!m_echo) { + tcgetattr(fileno(stdin), &m_previous_termios); + auto new_termios = m_previous_termios; + new_termios.c_lflag &= ~ECHO; + tcsetattr(fileno(stdin), TCSANOW, &new_termios); + } +} + +echo_control::~echo_control() +{ + if (!m_echo) { + // Restore previous termios settings. + tcsetattr(fileno(stdin), TCSANOW, &m_previous_termios); + } +} + + +std::string prompt_input(const std::string_view prompt, bool echo /* = true */) +{ + std::cout << prompt; + + echo_control ec(echo); + std::string input; + + cursor_hider ch(false); // Re-enable cursor if currently hidden. + std::getline(std::cin, input); + + if (!echo) { + std::cout << std::endl; + } + + // Maybe sanitise input, removing escape codes? + return input; +} diff --git a/src/utils/input_output.hpp b/src/utils/input_output.hpp new file mode 100644 index 0000000..fabc27d --- /dev/null +++ b/src/utils/input_output.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include +#include "common.hpp" + +// OS-specific libraries. +#include + +// Scope object to hide the cursor. This avoids +// cursor twinkling when rewritting the same line +// too frequently. +// If you are within a cursor_hider context you can +// reenable the cursor using cursor_hider(false). +class cursor_hider : noncopyable_nonmovable +{ +public: + cursor_hider(bool hide = true); + + ~cursor_hider(); + +private: + bool m_hide; +}; + +// Scope object to use alternative output buffer for +// fullscreen interactive terminal input/output. +class alternative_buffer : noncopyable_nonmovable +{ +public: + alternative_buffer(); + + ~alternative_buffer(); + +private: + struct termios m_previous_termios; +}; + +// Scope object to control echo of stdin to stdout. +// This should be disabled when entering passwords for example. +class echo_control : noncopyable_nonmovable +{ +public: + echo_control(bool echo); + + ~echo_control(); + +private: + bool m_echo; + struct termios m_previous_termios; +}; + +// Display a prompt on stdout and return newline-terminated input received on +// stdin from the user. The `echo` argument controls whether stdin is echoed +// to stdout, use `false` for passwords. +std::string prompt_input(const std::string_view prompt, bool echo = true); diff --git a/src/utils/output.cpp b/src/utils/output.cpp deleted file mode 100644 index 71584b1..0000000 --- a/src/utils/output.cpp +++ /dev/null @@ -1,23 +0,0 @@ -#include "output.hpp" - -// OS-specific libraries. -#include - -alternative_buffer::alternative_buffer() -{ - tcgetattr(fileno(stdin), &m_previous_termios); - auto new_termios = m_previous_termios; - // Disable canonical mode (buffered I/O) and echo from stdin to stdout. - new_termios.c_lflag &= (~ICANON & ~ECHO); - tcsetattr(fileno(stdin), TCSANOW, &new_termios); - - std::cout << ansi_code::enable_alternative_buffer; -} - -alternative_buffer::~alternative_buffer() -{ - std::cout << ansi_code::disable_alternative_buffer; - - // Restore previous termios settings. - tcsetattr(fileno(stdin), TCSANOW, &m_previous_termios); -} diff --git a/src/utils/output.hpp b/src/utils/output.hpp deleted file mode 100644 index 803c20d..0000000 --- a/src/utils/output.hpp +++ /dev/null @@ -1,37 +0,0 @@ -#pragma once - -#include -#include "ansi_code.hpp" -#include "common.hpp" - -// OS-specific libraries. -#include - -// Scope object to hide the cursor. This avoids -// cursor twinkling when rewritting the same line -// too frequently. -struct cursor_hider : noncopyable_nonmovable -{ - cursor_hider() - { - std::cout << ansi_code::hide_cursor; - } - - ~cursor_hider() - { - std::cout << ansi_code::show_cursor; - } -}; - -// Scope object to use alternative output buffer for -// fullscreen interactive terminal input/output. -class alternative_buffer : noncopyable_nonmovable -{ -public: - alternative_buffer(); - - ~alternative_buffer(); - -private: - struct termios m_previous_termios; -}; diff --git a/src/utils/terminal_pager.cpp b/src/utils/terminal_pager.cpp index 3bec415..7b5dcc7 100644 --- a/src/utils/terminal_pager.cpp +++ b/src/utils/terminal_pager.cpp @@ -10,7 +10,7 @@ #include #include "ansi_code.hpp" -#include "output.hpp" +#include "input_output.hpp" #include "terminal_pager.hpp" #include "common.hpp" diff --git a/test/conftest.py b/test/conftest.py index 7ed4a75..e6e6003 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -66,3 +66,21 @@ def repo_init_with_commit(commit_env_config, git2cpp_path, tmp_path, run_in_tmp_ cmd_commit = [git2cpp_path, "commit", "-m", "Initial commit"] p_commit = subprocess.run(cmd_commit, capture_output=True, cwd=tmp_path, text=True) assert p_commit.returncode == 0 + + +@pytest.fixture(scope="session") +def private_test_repo(): + # Fixture containing everything needed to access private github repo. + # GIT2CPP_TEST_PRIVATE_TOKEN is the fine-grained Personal Access Token for private test repo. + # If this is not available as an environment variable, tests that use this fixture are skipped. + token = os.getenv("GIT2CPP_TEST_PRIVATE_TOKEN") + if token is None or len(token) == 0: + pytest.skip("No token for private test repo GIT2CPP_TEST_PRIVATE_TOKEN") + repo_name = "git2cpp-test-private" + org_name = "QuantStack" + return { + "repo_name": repo_name, + "http_url": f"http://github.com/{org_name}/{repo_name}", + "https_url": f"https://github.com/{org_name}/{repo_name}", + "token": token + } diff --git a/test/test_clone.py b/test/test_clone.py index ff9ccec..63cb405 100644 --- a/test/test_clone.py +++ b/test/test_clone.py @@ -1,10 +1,11 @@ +import pytest import subprocess -url = "https://github.com/xtensor-stack/xtl.git" +xtl_url = "https://github.com/xtensor-stack/xtl.git" def test_clone(git2cpp_path, tmp_path, run_in_tmp_path): - clone_cmd = [git2cpp_path, "clone", url] + clone_cmd = [git2cpp_path, "clone", xtl_url] p_clone = subprocess.run(clone_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_clone.returncode == 0 @@ -13,7 +14,7 @@ def test_clone(git2cpp_path, tmp_path, run_in_tmp_path): def test_clone_is_bare(git2cpp_path, tmp_path, run_in_tmp_path): - clone_cmd = [git2cpp_path, "clone", "--bare", url] + clone_cmd = [git2cpp_path, "clone", "--bare", xtl_url] p_clone = subprocess.run(clone_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_clone.returncode == 0 @@ -31,7 +32,7 @@ def test_clone_is_bare(git2cpp_path, tmp_path, run_in_tmp_path): def test_clone_shallow(git2cpp_path, tmp_path, run_in_tmp_path): - clone_cmd = [git2cpp_path, "clone", "--depth", "1", url] + clone_cmd = [git2cpp_path, "clone", "--depth", "1", xtl_url] p_clone = subprocess.run(clone_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_clone.returncode == 0 assert (tmp_path / "xtl").exists() @@ -42,3 +43,84 @@ def test_clone_shallow(git2cpp_path, tmp_path, run_in_tmp_path): p_log = subprocess.run(cmd_log, capture_output=True, cwd=xtl_path, text=True) assert p_log.returncode == 0 assert p_log.stdout.count("Author") == 1 + + +@pytest.mark.parametrize("protocol", ["http", "https"]) +def test_clone_private_repo(git2cpp_path, tmp_path, run_in_tmp_path, private_test_repo, protocol): + # Succeeds with correct credentials. + # Note that http succeeds by redirecting to https. + username = "abc" # Can be any non-empty string. + password = private_test_repo['token'] + input = f"{username}\n{password}" + repo_path = tmp_path / private_test_repo['repo_name'] + url = private_test_repo['https_url' if protocol == 'https' else 'http_url'] + + clone_cmd = [git2cpp_path, "clone", url] + p_clone = subprocess.run(clone_cmd, capture_output=True, text=True, input=input) + assert p_clone.returncode == 0 + assert repo_path.exists() + # Single request for username and password. + assert p_clone.stdout.count("Username:") == 1 + assert p_clone.stdout.count("Password:") == 1 + + status_cmd = [git2cpp_path, "status"] + p_status = subprocess.run(status_cmd, cwd=repo_path, capture_output=True, text=True) + assert p_status.returncode == 0 + assert "On branch main" in p_status.stdout + assert "Your branch is up to date with 'origin/main'" in p_status.stdout + + +def test_clone_private_repo_fails_then_succeeds( + git2cpp_path, tmp_path, run_in_tmp_path, private_test_repo +): + # Fails with wrong credentials, then succeeds with correct ones. + username = "xyz" # Can be any non-empty string. + password = private_test_repo['token'] + input = "\n".join(["wrong1", "wrong2", username, password]) + repo_path = tmp_path / private_test_repo['repo_name'] + + clone_cmd = [git2cpp_path, "clone", private_test_repo['https_url']] + p_clone = subprocess.run(clone_cmd, capture_output=True, text=True, input=input) + assert p_clone.returncode == 0 + assert repo_path.exists() + # Two requests for username and password. + assert p_clone.stdout.count("Username:") == 2 + assert p_clone.stdout.count("Password:") == 2 + + status_cmd = [git2cpp_path, "status"] + p_status = subprocess.run(status_cmd, cwd=repo_path, capture_output=True, text=True) + assert p_status.returncode == 0 + assert "On branch main" in p_status.stdout + assert "Your branch is up to date with 'origin/main'" in p_status.stdout + + +def test_clone_private_repo_fails_on_no_username( + git2cpp_path, tmp_path, run_in_tmp_path, private_test_repo +): + input = "" + repo_path = tmp_path / private_test_repo['repo_name'] + + clone_cmd = [git2cpp_path, "clone", private_test_repo['https_url']] + p_clone = subprocess.run(clone_cmd, capture_output=True, text=True, input=input) + + assert p_clone.returncode != 0 + assert "No username specified" in p_clone.stderr + assert not repo_path.exists() + assert p_clone.stdout.count("Username:") == 1 + assert p_clone.stdout.count("Password:") == 0 + + +def test_clone_private_repo_fails_on_no_password( + git2cpp_path, tmp_path, run_in_tmp_path, private_test_repo +): + input = "username\n" # Note no password after the \n + repo_path = tmp_path / private_test_repo['repo_name'] + + clone_cmd = [git2cpp_path, "clone", private_test_repo['https_url']] + p_clone = subprocess.run(clone_cmd, capture_output=True, text=True, input=input) + + assert p_clone.returncode != 0 + assert "No password specified" in p_clone.stderr + assert not repo_path.exists() + assert p_clone.stdout.count("Username:") == 1 + assert p_clone.stdout.count("Password:") == 1 diff --git a/test/test_fetch.py b/test/test_fetch.py new file mode 100644 index 0000000..6cfb9a5 --- /dev/null +++ b/test/test_fetch.py @@ -0,0 +1,29 @@ +import pytest +import subprocess + + +@pytest.mark.parametrize("protocol", ["http", "https"]) +def test_fetch_private_repo(git2cpp_path, tmp_path, run_in_tmp_path, private_test_repo, protocol): + # Note that http succeeds by redirecting to https. + init_cmd = [git2cpp_path, "init", "."] + p_init = subprocess.run(init_cmd, capture_output=True, text=True) + assert p_init.returncode == 0 + assert (tmp_path / ".git").exists() + + url = private_test_repo['https_url' if protocol == 'https' else 'http_url'] + remote_cmd = [git2cpp_path, "remote", "add", "origin", url] + p_remote = subprocess.run(remote_cmd, capture_output=True, text=True) + assert p_remote.returncode == 0 + + # First fetch with wrong password which fails, then correct password which succeeds. + username = "abc" # Can be any non-empty string. + password = private_test_repo['token'] + input = f"{username}\nwrong_password\n{username}\n{password}" + fetch_cmd = [git2cpp_path, "fetch", "origin"] + p_fetch = subprocess.run(fetch_cmd, capture_output=True, text=True, input=input) + assert p_fetch.returncode == 0 + + branch_cmd = [git2cpp_path, "branch", "--all"] + p_branch = subprocess.run(branch_cmd, capture_output=True, text=True) + assert p_branch.returncode == 0 + assert "origin/main" in p_branch.stdout diff --git a/test/test_push.py b/test/test_push.py new file mode 100644 index 0000000..036ec16 --- /dev/null +++ b/test/test_push.py @@ -0,0 +1,62 @@ +import subprocess +from uuid import uuid4 + + +def test_push_private_repo(git2cpp_path, tmp_path, run_in_tmp_path, private_test_repo, commit_env_config): + # Unique branch name to avoid branch name collisions on remote repo. + branch_name = f"test-{uuid4()}" + + # Start of test follows test_clone_private_repo, then creates a new local branch and pushes + # that to the remote. + username = "abc" # Can be any non-empty string. + password = private_test_repo['token'] + input = f"{username}\n{password}" + repo_path = tmp_path / private_test_repo['repo_name'] + url = private_test_repo['https_url'] + + clone_cmd = [git2cpp_path, "clone", url] + p_clone = subprocess.run(clone_cmd, capture_output=True, text=True, input=input) + assert p_clone.returncode == 0 + assert repo_path.exists() + # Single request for username and password. + assert p_clone.stdout.count("Username:") == 1 + assert p_clone.stdout.count("Password:") == 1 + + status_cmd = [git2cpp_path, "status"] + p_status = subprocess.run(status_cmd, cwd=repo_path, capture_output=True, text=True) + assert p_status.returncode == 0 + assert "On branch main" in p_status.stdout + assert "Your branch is up to date with 'origin/main'" in p_status.stdout + + checkout_cmd = [git2cpp_path, "checkout", "-b", branch_name] + p_checkout = subprocess.run(checkout_cmd, cwd=repo_path, capture_output=True, text=True) + assert p_checkout.returncode == 0 + + p_status = subprocess.run(status_cmd, cwd=repo_path, capture_output=True, text=True) + assert p_status.returncode == 0 + assert f"On branch {branch_name}" in p_status.stdout + + (repo_path / "new_file.txt").write_text("Some text") + add_cmd = [git2cpp_path, "add", "new_file.txt"] + p_add = subprocess.run(add_cmd, cwd=repo_path, capture_output=True, text=True) + assert p_add.returncode == 0 + + commit_cmd = [git2cpp_path, "commit", "-m", "This is my commit message"] + p_commit = subprocess.run(commit_cmd, cwd=repo_path, capture_output=True, text=True) + assert p_commit.returncode == 0 + + log_cmd = [git2cpp_path, "log", "-n1"] + p_log = subprocess.run(log_cmd, cwd=repo_path, capture_output=True, text=True) + assert p_log.returncode == 0 + assert p_log.stdout.count("Author:") == 1 + assert p_log.stdout.count("Date:") == 1 + assert p_log.stdout.count("This is my commit message") == 1 + + # push with incorrect credentials to check it fails, then with correct to check it works. + input = f"${username}\ndef\n{username}\n{password}" + push_cmd = [git2cpp_path, "push", "origin"] + p_push = subprocess.run(push_cmd, cwd=repo_path, capture_output=True, text=True, input=input) + assert p_push.returncode == 0 + assert p_push.stdout.count("Username:") == 2 + assert p_push.stdout.count("Password:") == 2 + assert "Pushed to origin" in p_push.stdout From 1ab489a71ff8a9da690b8193d35e5841cee44eaf Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Wed, 11 Mar 2026 16:48:06 +0100 Subject: [PATCH 25/35] Add ```--abbrev-commit```, ```--format=oneline``` and ```--oneline``` to the log subcommand (#118) * Add --abbrev-commit, --pretty and --oneline to the log subcommand * address review cooment * Address review comment --- src/subcommand/log_subcommand.cpp | 59 ++++++++---- src/subcommand/log_subcommand.hpp | 10 ++- src/subcommand/revparse_subcommand.cpp | 60 ++++++++++--- src/subcommand/revparse_subcommand.hpp | 5 ++ src/subcommand/tag_subcommand.cpp | 13 ++- src/subcommand/tag_subcommand.hpp | 1 + test/test_log.py | 119 ++++++++++++++++++++++++- test/test_revparse.py | 93 +++++++++++++++++++ test/test_tag.py | 55 ++++++++++-- 9 files changed, 376 insertions(+), 39 deletions(-) diff --git a/src/subcommand/log_subcommand.cpp b/src/subcommand/log_subcommand.cpp index 1c7fff0..e0eac66 100644 --- a/src/subcommand/log_subcommand.cpp +++ b/src/subcommand/log_subcommand.cpp @@ -11,16 +11,17 @@ #include "log_subcommand.hpp" #include "../utils/terminal_pager.hpp" -#include "../wrapper/repository_wrapper.hpp" -#include "../wrapper/commit_wrapper.hpp" log_subcommand::log_subcommand(const libgit2_object&, CLI::App& app) { auto *sub = app.add_subcommand("log", "Shows commit logs"); - sub->add_flag("--format", m_format_flag, "Pretty-print the contents of the commit logs in a given format, where can be one of full and fuller"); + sub->add_option("--format", m_format_flag, "Pretty-print the contents of the commit logs in a given format, where can be one of full, fuller or oneline"); sub->add_option("-n,--max-count", m_max_count_flag, "Limit the output to commits."); - // sub->add_flag("--oneline", m_oneline_flag, "This is a shorthand for --pretty=oneline --abbrev-commit used together."); + sub->add_flag("--abbrev-commit", m_abbrev_commit_flag, "Instead of showing the full 40-byte hexadecimal commit object name, show a prefix that names the object uniquely. --abbrev= (which also modifies diff output, if it is displayed) option can be used to specify the minimum length of the prefix."); + sub->add_option("--abbrev", m_abbrev, "Instead of showing the full 40-byte hexadecimal object name in diff-raw format output and diff-tree header lines, show the shortest prefix that is at least hexdigits long that uniquely refers the object."); + sub->add_flag("--no-abbrev-commit", m_no_abbrev_commit_flag, "Show the full 40-byte hexadecimal commit object name. This negates --abbrev-commit, either explicit or implied by other options such as --oneline."); + sub->add_flag("--oneline", m_oneline_flag, "This is a shorthand for --format=oneline --abbrev-commit used together."); sub->callback([this]() { this->run(); }); }; @@ -166,6 +167,7 @@ void print_refs(const commit_refs& refs) return; } + std::cout << termcolor::yellow; std::cout << " ("; bool first = true; @@ -179,7 +181,7 @@ void print_refs(const commit_refs& refs) first = false; } - for (const auto& tag :refs.tags) + for (const auto& tag : refs.tags) { if (!first) { @@ -212,17 +214,49 @@ void print_refs(const commit_refs& refs) std::cout << ")" << termcolor::reset; } -void print_commit(repository_wrapper& repo, const commit_wrapper& commit, std::string m_format_flag) +void log_subcommand::print_commit(repository_wrapper& repo, const commit_wrapper& commit) { - std::string buf = commit.commit_oid_tostr(); + const bool abbrev_commit = (m_abbrev_commit_flag || m_oneline_flag) && !m_no_abbrev_commit_flag; + const bool oneline = (m_format_flag == "oneline") || m_oneline_flag; + + std::string sha = commit.commit_oid_tostr(); + + if (abbrev_commit && m_abbrev <= sha.size()) + { + sha = sha.substr(0, m_abbrev); + } + + commit_refs refs = get_refs_for_commit(repo, commit.oid()); + + std::string message = commit.message(); + while (!message.empty() && message.back() == '\n') + { + message.pop_back(); + } + + if (oneline) + { + std::string subject; + { + std::istringstream s(message); + std::getline(s, subject); + } + + std::cout << termcolor::yellow << sha << termcolor::reset; + print_refs(refs); + if (!subject.empty()) + { + std::cout << " " << subject; + } + return; + } signature_wrapper author = signature_wrapper::get_commit_author(commit); signature_wrapper committer = signature_wrapper::get_commit_committer(commit); stream_colour_fn colour = termcolor::yellow; - std::cout << colour << "commit " << buf; + std::cout << colour << "commit " << sha << termcolor::reset; - commit_refs refs = get_refs_for_commit(repo, commit.oid()); print_refs(refs); std::cout << termcolor::reset << std::endl; @@ -247,11 +281,6 @@ void print_commit(repository_wrapper& repo, const commit_wrapper& commit, std::s } } - std::string message = commit.message(); - while (!message.empty() && message.back() == '\n') - { - message.pop_back(); - } std::istringstream message_stream(message); std::string line; while (std::getline(message_stream, line)) @@ -287,7 +316,7 @@ void log_subcommand::run() std::cout << std::endl; } commit_wrapper commit = repo.find_commit(commit_oid); - print_commit(repo, commit, m_format_flag); + print_commit(repo, commit); ++i; } diff --git a/src/subcommand/log_subcommand.hpp b/src/subcommand/log_subcommand.hpp index a42c052..ec0922d 100644 --- a/src/subcommand/log_subcommand.hpp +++ b/src/subcommand/log_subcommand.hpp @@ -4,6 +4,8 @@ #include #include "../utils/common.hpp" +#include "../wrapper/commit_wrapper.hpp" +#include "../wrapper/repository_wrapper.hpp" class log_subcommand @@ -14,7 +16,13 @@ class log_subcommand void run(); private: + + void print_commit(repository_wrapper& repo, const commit_wrapper& commit); + std::string m_format_flag; int m_max_count_flag=std::numeric_limits::max(); - // bool m_oneline_flag = false; + size_t m_abbrev = 7; + bool m_abbrev_commit_flag = false; + bool m_no_abbrev_commit_flag = false; + bool m_oneline_flag = false; }; diff --git a/src/subcommand/revparse_subcommand.cpp b/src/subcommand/revparse_subcommand.cpp index a521ad6..a866d90 100644 --- a/src/subcommand/revparse_subcommand.cpp +++ b/src/subcommand/revparse_subcommand.cpp @@ -1,14 +1,32 @@ #include "revparse_subcommand.hpp" +#include "../utils/git_exception.hpp" #include "../wrapper/repository_wrapper.hpp" -#include -#include revparse_subcommand::revparse_subcommand(const libgit2_object&, CLI::App& app) { auto* sub = app.add_subcommand("rev-parse", "Pick out and message parameters"); - sub->add_flag("--is-bare-repository", m_is_bare_repository_flag); - sub->add_flag("--is-shallow-repository", m_is_shallow_repository_flag); + auto* bare_opt = sub->add_flag("--is-bare-repository", m_is_bare_repository_flag, "When the repository is bare print \"true\", otherwise \"false\"."); + auto* shallow_opt = sub->add_flag("--is-shallow-repository", m_is_shallow_repository_flag, "When the repository is shallow print \"true\", otherwise \"false\"."); + auto* rev_opt = sub->add_option("", m_revisions, "Revision(s) to parse (e.g. HEAD, main, HEAD~1, dae86e, ...)"); + + sub->parse_complete_callback([this, sub, bare_opt, shallow_opt, rev_opt]() { + for (CLI::Option* opt : sub->parse_order()) + { + if (opt == bare_opt) + { + m_queries_in_order.push_back("is_bare"); + } + else if (opt == shallow_opt) + { + m_queries_in_order.push_back("is_shallow"); + } + else if (opt == rev_opt) + { + m_queries_in_order.push_back("is_rev"); + } + } + }); sub->callback([this]() { this->run(); }); } @@ -18,16 +36,30 @@ void revparse_subcommand::run() auto directory = get_current_git_path(); auto repo = repository_wrapper::open(directory); - if (m_is_bare_repository_flag) - { - std::cout << std::boolalpha << repo.is_bare() << std::endl; - } - else if (m_is_shallow_repository_flag) + size_t i = 0; + for (const auto& q : m_queries_in_order) { - std::cout << std::boolalpha << repo.is_shallow() << std::endl; - } - else - { - std::cout << "revparse only supports --is-bare-repository and --is-shallow-repository for now" << std::endl; + if (q == "is_bare") + { + std::cout << std::boolalpha << repo.is_bare() << std::endl; + } + else if (q == "is_shallow") + { + std::cout << std::boolalpha << repo.is_shallow() << std::endl; + } + else if (q == "is_rev") + { + const auto& rev = m_revisions[i]; + auto obj = repo.revparse_single(rev.c_str()); + + if (!obj.has_value()) + { + throw git_exception("bad revision '" + rev + "'", git2cpp_error_code::BAD_ARGUMENT); + } + + auto oid = obj.value().oid(); + std::cout << git_oid_tostr_s(&oid) << std::endl; + i += 1; + } } } diff --git a/src/subcommand/revparse_subcommand.hpp b/src/subcommand/revparse_subcommand.hpp index dcc363a..a08dcd9 100644 --- a/src/subcommand/revparse_subcommand.hpp +++ b/src/subcommand/revparse_subcommand.hpp @@ -1,5 +1,8 @@ #pragma once +#include +#include + #include #include "../utils/common.hpp" @@ -13,6 +16,8 @@ class revparse_subcommand private: + std::vector m_revisions; + std::vector m_queries_in_order; bool m_is_bare_repository_flag = false; bool m_is_shallow_repository_flag = false; }; diff --git a/src/subcommand/tag_subcommand.cpp b/src/subcommand/tag_subcommand.cpp index fd25be1..a64fdca 100644 --- a/src/subcommand/tag_subcommand.cpp +++ b/src/subcommand/tag_subcommand.cpp @@ -1,8 +1,6 @@ #include #include "../subcommand/tag_subcommand.hpp" -#include "../wrapper/commit_wrapper.hpp" -#include "../wrapper/tag_wrapper.hpp" tag_subcommand::tag_subcommand(const libgit2_object&, CLI::App& app) { @@ -12,6 +10,7 @@ tag_subcommand::tag_subcommand(const libgit2_object&, CLI::App& app) sub->add_flag("-f,--force", m_force_flag, "Replace an existing tag with the given name (instead of failing)"); sub->add_option("-d,--delete", m_delete, "Delete existing tags with the given names."); sub->add_option("-n", m_num_lines, " specifies how many lines from the annotation, if any, are printed when using -l. Implies --list."); + sub->add_flag("-a,--annotate", m_annotate_flag, "Make an annotated tag."); sub->add_option("-m,--message", m_message, "Tag message for annotated tags"); sub->add_option("", m_tag_name, "Tag name"); sub->add_option("", m_target, "Target commit (defaults to HEAD)"); @@ -217,10 +216,18 @@ void tag_subcommand::run() { delete_tag(repo); } - else if (m_list_flag || (m_tag_name.empty() && m_message.empty())) + else if (m_list_flag || (m_tag_name.empty() && m_message.empty() && !m_annotate_flag)) { list_tags(repo); } + else if (m_annotate_flag) + { + if (m_message.empty()) + { + throw git_exception("error: -a/--annotate requires -m/--message", git2cpp_error_code::BAD_ARGUMENT); + } + create_tag(repo); + } else if (!m_message.empty()) { create_tag(repo); diff --git a/src/subcommand/tag_subcommand.hpp b/src/subcommand/tag_subcommand.hpp index 512ea18..4aaba8d 100644 --- a/src/subcommand/tag_subcommand.hpp +++ b/src/subcommand/tag_subcommand.hpp @@ -28,5 +28,6 @@ class tag_subcommand std::string m_target; bool m_list_flag = false; bool m_force_flag = false; + bool m_annotate_flag = false; int m_num_lines = 0; }; diff --git a/test/test_log.py b/test/test_log.py index f4d3e40..5cc3eb1 100644 --- a/test/test_log.py +++ b/test/test_log.py @@ -1,3 +1,4 @@ +import re import subprocess import pytest @@ -193,7 +194,7 @@ def test_log_with_annotated_tag(commit_env_config, git2cpp_path, tmp_path): # Create an annotated tag subprocess.run( - ["git", "tag", "-a", "v2.0.0", "-m", "Version 2.0.0"], + [git2cpp_path, "tag", "-a", "v2.0.0", "-m", "Version 2.0.0"], cwd=tmp_path, check=True, ) @@ -284,3 +285,119 @@ def test_log_commit_without_references(commit_env_config, git2cpp_path, tmp_path if "(" in second_commit_line: # If it has parentheses, they shouldn't be empty assert "()" not in second_commit_line + + +@pytest.mark.parametrize( + "abbrev_commit_flag", ["", "--abbrev-commit", "--no-abbrev-commit"] +) +@pytest.mark.parametrize("abbrev_flag", ["", "--abbrev=10"]) +def test_log_abbrev_commit_flags( + repo_init_with_commit, + commit_env_config, + git2cpp_path, + tmp_path, + abbrev_commit_flag, + abbrev_flag, +): + """Test for --abbrev-commit, --abbrev= (only applies when --abbrev-commit is set) and --no-abbrev-commit""" + assert (tmp_path / "initial.txt").exists() + + cmd = [git2cpp_path, "log", "-n", "1"] + if abbrev_commit_flag != "": + cmd.append(abbrev_commit_flag) + if abbrev_flag != "": + cmd.append(abbrev_flag) + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode == 0 + + m = re.search(r"^commit\s+([0-9a-fA-F]+)", p.stdout, flags=re.MULTILINE) + if abbrev_commit_flag in ["", "--no-abbrev-commit"]: + assert len(m.group(1)) == 40 + else: + if abbrev_flag == "--abbrev=10": + assert len(m.group(1)) == 10 + else: + assert len(m.group(1)) == 7 + + +def test_log_format_oneline(repo_init_with_commit, git2cpp_path, tmp_path): + """Test --format=oneline prints the full sha""" + assert (tmp_path / "initial.txt").exists() + + p = subprocess.run( + [git2cpp_path, "rev-parse", "HEAD"], + cwd=tmp_path, + capture_output=True, + text=True, + ) + assert p.returncode == 0 + full_sha = p.stdout.strip() + + p = subprocess.run( + [git2cpp_path, "log", "--format=oneline", "-n", "1"], + cwd=tmp_path, + capture_output=True, + text=True, + ) + assert p.returncode == 0 + + # assert: should contain full 40-hex sha and subject + assert full_sha in p.stdout + assert "Initial commit" in p.stdout + + +def test_log_oneline(repo_init_with_commit, git2cpp_path, tmp_path): + """Test --oneline prints the default length sha (i.e 7)""" + assert (tmp_path / "initial.txt").exists() + + p = subprocess.run( + [git2cpp_path, "rev-parse", "HEAD"], + cwd=tmp_path, + capture_output=True, + text=True, + ) + assert p.returncode == 0 + full_sha = str(p.stdout.strip()) + abbrev7 = full_sha[:7] + + subprocess.run( + [git2cpp_path, "tag", "v1"], capture_output=True, cwd=tmp_path, check=True + ) + + p = subprocess.run( + [git2cpp_path, "log", "--oneline", "-n", "1"], + cwd=tmp_path, + capture_output=True, + text=True, + ) + assert p.returncode == 0 + + assert abbrev7 in p.stdout + assert full_sha not in p.stdout + assert "tag: v1" in p.stdout + assert "Initial commit" in p.stdout + + +def test_log_oneline_no_abbrev_commit(repo_init_with_commit, git2cpp_path, tmp_path): + """Test --oneline prints prints the full sha when using --no-abbrev-commit""" + assert (tmp_path / "initial.txt").exists() + + p = subprocess.run( + [git2cpp_path, "rev-parse", "HEAD"], + cwd=tmp_path, + capture_output=True, + text=True, + ) + assert p.returncode == 0 + full_sha = str(p.stdout.strip()) + + p = subprocess.run( + [git2cpp_path, "log", "--oneline", "--no-abbrev-commit", "-n", "1"], + cwd=tmp_path, + capture_output=True, + text=True, + ) + assert p.returncode == 0 + + assert full_sha in p.stdout + assert "Initial commit" in p.stdout diff --git a/test/test_revparse.py b/test/test_revparse.py index af99577..5b72ab7 100644 --- a/test/test_revparse.py +++ b/test/test_revparse.py @@ -29,3 +29,96 @@ def test_revparse_shallow(git2cpp_path, tmp_path, run_in_tmp_path): p2 = subprocess.run(cmd2, capture_output=True, text=True, cwd=xtl_path) assert p2.returncode == 0 assert p2.stdout == "true\n" + + cmd3 = [ + git2cpp_path, + "rev-parse", + "--is-shallow-repository", + "--is-bare-repository", + ] + p3 = subprocess.run(cmd3, capture_output=True, text=True, cwd=xtl_path) + assert p3.returncode == 0 + assert p3.stdout == "true\nfalse\n" + + +def test_revparse_multiple_revs(repo_init_with_commit, git2cpp_path, tmp_path): + """Test one sha per line is printed when multiple revisions are provided""" + assert (tmp_path / "initial.txt").exists() + + (tmp_path / "second.txt").write_text("second") + subprocess.run( + [git2cpp_path, "add", "second.txt"], + capture_output=True, + text=True, + cwd=tmp_path, + check=True, + ) + subprocess.run( + [git2cpp_path, "commit", "-m", "Second commit"], + capture_output=True, + text=True, + cwd=tmp_path, + check=True, + ) + + p = subprocess.run( + [git2cpp_path, "rev-parse", "HEAD", "HEAD~1"], + capture_output=True, + text=True, + cwd=tmp_path, + ) + assert p.returncode == 0 + + lines = p.stdout.splitlines() + print() + assert len(lines) == 2 + assert all(len(x) == 40 for x in lines) + assert lines[0] != lines[1] + + +def test_revparse_multiple_opts(git2cpp_path, tmp_path, run_in_tmp_path): + """Test the options are printed in order""" + url = "https://github.com/xtensor-stack/xtl.git" + cmd = [git2cpp_path, "clone", "--depth", "2", url] + p = subprocess.run(cmd, capture_output=True, text=True, cwd=tmp_path) + assert p.returncode == 0 + assert (tmp_path / "xtl").exists() + + xtl_path = tmp_path / "xtl" + + p = subprocess.run( + [ + git2cpp_path, + "rev-parse", + "HEAD", + "--is-shallow-repository", + "--is-bare-repository", + "HEAD~1", + ], + capture_output=True, + text=True, + cwd=xtl_path, + ) + assert p.returncode == 0 + + lines = p.stdout.splitlines() + assert len(lines) == 4 + assert len(lines[0]) == 40 + assert len(lines[3]) == 40 + assert lines[0] != lines[1] + assert "true" in lines[1] + assert "false" in lines[2] + + +def test_revparse_errors(repo_init_with_commit, git2cpp_path, tmp_path): + assert (tmp_path / "initial.txt").exists() + + rev_cmd = [git2cpp_path, "rev-parse", "HEAD~1"] + p_rev = subprocess.run(rev_cmd, capture_output=True, text=True, cwd=tmp_path) + assert p_rev.returncode == 129 + assert "bad revision" in p_rev.stderr + + opt_cmd = [git2cpp_path, "rev-parse", "--parseopt"] + p_opt = subprocess.run(opt_cmd, capture_output=True, text=True, cwd=tmp_path) + assert p_opt.returncode != 0 + assert "The following argument was not expected:" in p_opt.stderr diff --git a/test/test_tag.py b/test/test_tag.py index 5575877..bcb1c30 100644 --- a/test/test_tag.py +++ b/test/test_tag.py @@ -55,7 +55,7 @@ def test_tag_create_on_specific_commit( assert (tmp_path / "initial.txt").exists() # Get the commit SHA before creating new commit - old_head_cmd = ["git", "rev-parse", "HEAD"] + old_head_cmd = [git2cpp_path, "rev-parse", "HEAD"] p_old_head = subprocess.run( old_head_cmd, capture_output=True, cwd=tmp_path, text=True ) @@ -72,7 +72,7 @@ def test_tag_create_on_specific_commit( subprocess.run(commit_cmd, cwd=tmp_path, check=True) # Get new HEAD commit SHA - new_head_cmd = ["git", "rev-parse", "HEAD"] + new_head_cmd = [git2cpp_path, "rev-parse", "HEAD"] p_new_head = subprocess.run( new_head_cmd, capture_output=True, cwd=tmp_path, text=True ) @@ -92,7 +92,7 @@ def test_tag_create_on_specific_commit( assert "v1.0.0" in p_list.stdout # Get commit SHA that the tag points to - tag_sha_cmd = ["git", "rev-parse", "v1.0.0^{commit}"] + tag_sha_cmd = [git2cpp_path, "rev-parse", "v1.0.0^{commit}"] p_tag_sha = subprocess.run( tag_sha_cmd, capture_output=True, cwd=tmp_path, text=True ) @@ -135,8 +135,8 @@ def test_tag_delete_nonexistent(repo_init_with_commit, git2cpp_path, tmp_path): assert "not found" in p_delete.stderr -@pytest.mark.parametrize("list_flag", ["-l", "--list"]) -def test_tag_list_with_flag( +@pytest.mark.parametrize("list_flag", ["", "-l", "--list"]) +def test_tag_list( repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path, list_flag ): """Test listing tags with -l or --list flag.""" @@ -320,3 +320,48 @@ def test_tag_on_new_commit( assert p_list.returncode == 0 assert "before-change" in p_list.stdout assert "after-change" in p_list.stdout + + +def test_tag_create_annotated_with_a_flag( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path +): + """Test creating an annotated tag using -a flag.""" + assert (tmp_path / "initial.txt").exists() + + # Create an annotated tag using -a and -m + create_cmd = [git2cpp_path, "tag", "-a", "-m", "Release version 1.0", "v1.0.0"] + subprocess.run(create_cmd, capture_output=True, cwd=tmp_path, text=True, check=True) + + # List tags with message lines to verify it was created as an annotated tag + list_cmd = [git2cpp_path, "tag", "-n", "1"] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_list.returncode == 0 + assert "v1.0.0" in p_list.stdout + assert "Release version 1.0" in p_list.stdout + + +def test_tag_annotate_flag_requires_message( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path +): + """Test that -a/--annotate without -m fails.""" + assert (tmp_path / "initial.txt").exists() + + create_cmd = [git2cpp_path, "tag", "-a", "v1.0.0"] + p = subprocess.run(create_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode != 0 + assert "requires -m" in p.stderr + + +def test_tag_errors(repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path): + assert (tmp_path / "initial.txt").exists() + + # Test that -a/--annotate without -m fails. + create_cmd = [git2cpp_path, "tag", "-a", "v1.0.0"] + p_create = subprocess.run(create_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_create.returncode != 0 + assert "requires -m" in p_create.stderr + + # Test that command fails when no message + del_cmd = [git2cpp_path, "tag", "-d"] + p_del = subprocess.run(del_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_del.returncode != 0 From ae2bdc057edff2c8e28ad8f4e34849f51ff84c42 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Wed, 11 Mar 2026 17:13:51 +0100 Subject: [PATCH 26/35] Add ```--find-renames``` and ```--find-copies``` to the diff command (#115) * add --find-renames and --find-copies to the diff command * address review comments * address review comments --- src/subcommand/diff_subcommand.cpp | 125 +++++++++------ src/subcommand/diff_subcommand.hpp | 20 +-- test/test_diff.py | 234 +++++++++++++++++++---------- 3 files changed, 244 insertions(+), 135 deletions(-) diff --git a/src/subcommand/diff_subcommand.cpp b/src/subcommand/diff_subcommand.cpp index 1ed8fb1..818825f 100644 --- a/src/subcommand/diff_subcommand.cpp +++ b/src/subcommand/diff_subcommand.cpp @@ -12,7 +12,8 @@ diff_subcommand::diff_subcommand(const libgit2_object&, CLI::App& app) { auto* sub = app.add_subcommand("diff", "Show changes between commits, commit and working tree, etc"); - sub->add_option("", m_files, "tree-ish objects to compare"); + sub->add_option("", m_files, "tree-ish objects to compare") + ->expected(0, 2); sub->add_flag("--stat", m_stat_flag, "Generate a diffstat"); sub->add_flag("--shortstat", m_shortstat_flag, "Output only the last line of --stat"); @@ -33,15 +34,16 @@ diff_subcommand::diff_subcommand(const libgit2_object&, CLI::App& app) sub->add_flag("--patience", m_patience_flag, "Generate diff using patience algorithm"); sub->add_flag("--minimal", m_minimal_flag, "Spend extra time to find smallest diff"); - // TODO: add the following flags after the "move" subcommand has been implemented (needed for the tests) - // sub->add_option("-M,--find-renames", m_rename_threshold, "Detect renames") - // ->expected(0,1) - // ->each([this](const std::string&) { m_find_renames_flag = true; }); - // sub->add_option("-C,--find-copies", m_copy_threshold, "Detect copies") - // ->expected(0,1) - // ->each([this](const std::string&) { m_find_copies_flag = true; }); - // sub->add_flag("--find-copies-harder", m_find_copies_harder_flag, "Detect copies from unmodified files"); - // sub->add_flag("-B,--break-rewrites", m_break_rewrites_flag, "Detect file rewrites"); + sub->add_option("-M,--find-renames", m_rename_threshold, "Detect renames") + ->expected(0,1) + ->default_val(50) + ->each([this](const std::string&) { m_find_renames_flag = true; }); + sub->add_option("-C,--find-copies", m_copy_threshold, "Detect copies") + ->expected(0,1) + ->default_val(50) + ->each([this](const std::string&) { m_find_copies_flag = true; }); + sub->add_flag("--find-copies-harder", m_find_copies_harder_flag, "Detect copies from unmodified files"); + sub->add_flag("-B,--break-rewrites", m_break_rewrites_flag, "Detect file rewrites"); sub->add_option("-U,--unified", m_context_lines, "Lines of context"); sub->add_option("--inter-hunk-context", m_interhunk_lines, "Context between hunks"); @@ -142,7 +144,6 @@ static int colour_printer([[maybe_unused]] const git_diff_delta* delta, [[maybe_ bool use_colour = *reinterpret_cast(payload); // Only print origin for context/addition/deletion lines - // For other line types, content already includes everything bool print_origin = (line->origin == GIT_DIFF_LINE_CONTEXT || line->origin == GIT_DIFF_LINE_ADDITION || line->origin == GIT_DIFF_LINE_DELETION); @@ -172,6 +173,39 @@ static int colour_printer([[maybe_unused]] const git_diff_delta* delta, [[maybe_ std::cout << termcolor::reset; } + // Print copy/rename headers ONLY after the "diff --git" line + if (line->origin == GIT_DIFF_LINE_FILE_HDR) + { + if (delta->status == GIT_DELTA_COPIED) + { + if (use_colour) + { + std::cout << termcolor::bold; + } + std::cout << "similarity index " << delta->similarity << "%\n"; + std::cout << "copy from " << delta->old_file.path << "\n"; + std::cout << "copy to " << delta->new_file.path << "\n"; + if (use_colour) + { + std::cout << termcolor::reset; + } + } + else if (delta->status == GIT_DELTA_RENAMED) + { + if (use_colour) + { + std::cout << termcolor::bold; + } + std::cout << "similarity index " << delta->similarity << "%\n"; + std::cout << "rename from " << delta->old_file.path << "\n"; + std::cout << "rename to " << delta->new_file.path << "\n"; + if (use_colour) + { + std::cout << termcolor::reset; + } + } + } + return 0; } @@ -183,33 +217,30 @@ void diff_subcommand::print_diff(diff_wrapper& diff, bool use_colour) return; } - // TODO: add the following flags after the "move" subcommand has been implemented (needed for the tests) - // if (m_find_renames_flag || m_find_copies_flag || m_find_copies_harder_flag || m_break_rewrites_flag) - // { - // git_diff_find_options find_opts; - // git_diff_find_options_init(&find_opts, GIT_DIFF_FIND_OPTIONS_VERSION); - - // if (m_find_renames_flag) - // { - // find_opts.flags |= GIT_DIFF_FIND_RENAMES; - // find_opts.rename_threshold = m_rename_threshold; - // } - // if (m_find_copies_flag) - // { - // find_opts.flags |= GIT_DIFF_FIND_COPIES; - // find_opts.copy_threshold = m_copy_threshold; - // } - // if (m_find_copies_harder_flag) - // { - // find_opts.flags |= GIT_DIFF_FIND_COPIES_FROM_UNMODIFIED; - // } - // if (m_break_rewrites_flag) - // { - // find_opts.flags |= GIT_DIFF_FIND_REWRITES; - // } - - // diff.find_similar(&find_opts); - // } + if (m_find_renames_flag || m_find_copies_flag || m_find_copies_harder_flag || m_break_rewrites_flag) + { + git_diff_find_options find_opts = GIT_DIFF_FIND_OPTIONS_INIT; + + if (m_find_renames_flag || m_find_copies_flag) + { + find_opts.flags |= GIT_DIFF_FIND_RENAMES; + find_opts.rename_threshold = m_rename_threshold; + } + if (m_find_copies_flag) + { + find_opts.flags |= GIT_DIFF_FIND_COPIES; + find_opts.copy_threshold = m_copy_threshold; + } + if (m_find_copies_harder_flag) + { + find_opts.flags |= GIT_DIFF_FIND_COPIES_FROM_UNMODIFIED; + } + if (m_break_rewrites_flag) + { + find_opts.flags |= GIT_DIFF_FIND_REWRITES; + } + diff.find_similar(&find_opts); + } git_diff_format_t format = GIT_DIFF_FORMAT_PATCH; if (m_name_only_flag) @@ -228,7 +259,7 @@ void diff_subcommand::print_diff(diff_wrapper& diff, bool use_colour) diff.print(format, colour_printer, &use_colour); } -diff_wrapper compute_diff_no_index(std::vector files, git_diff_options& diffopts) //std::pair +diff_wrapper compute_diff_no_index(std::vector files, git_diff_options& diffopts) { if (files.size() != 2) { @@ -242,11 +273,11 @@ diff_wrapper compute_diff_no_index(std::vector files, git_diff_opti if (file1_str.empty()) { - throw git_exception("Cannot read file: " + files[0], git2cpp_error_code::GENERIC_ERROR); //TODO: check error code with git + throw git_exception("Cannot read file: " + files[0], git2cpp_error_code::GENERIC_ERROR); } if (file2_str.empty()) { - throw git_exception("Cannot read file: " + files[1], git2cpp_error_code::GENERIC_ERROR); //TODO: check error code with git + throw git_exception("Cannot read file: " + files[1], git2cpp_error_code::GENERIC_ERROR); } auto patch = patch_wrapper::patch_from_files(files[0], file1_str, files[1], file2_str, &diffopts); @@ -280,6 +311,11 @@ void diff_subcommand::run() use_colour = true; } + if (m_cached_flag && m_no_index_flag) + { + throw git_exception("--cached and --no-index are incompatible", git2cpp_error_code::BAD_ARGUMENT); + } + if (m_no_index_flag) { auto diff = compute_diff_no_index(m_files, diffopts); @@ -302,11 +338,14 @@ void diff_subcommand::run() if (m_untracked_flag) { diffopts.flags |= GIT_DIFF_INCLUDE_UNTRACKED; } if (m_patience_flag) { diffopts.flags |= GIT_DIFF_PATIENCE; } if (m_minimal_flag) { diffopts.flags |= GIT_DIFF_MINIMAL; } + if (m_find_copies_flag || m_find_copies_harder_flag || m_find_renames_flag) + { + diffopts.flags |= GIT_DIFF_INCLUDE_UNMODIFIED; + } std::optional tree1; std::optional tree2; - // TODO: throw error if m_files.size() > 2 if (m_files.size() >= 1) { tree1 = repo.treeish_to_tree(m_files[0]); @@ -324,7 +363,7 @@ void diff_subcommand::run() } else if (m_cached_flag) { - if (m_cached_flag || !tree1) + if (!tree1) { tree1 = repo.treeish_to_tree("HEAD"); } diff --git a/src/subcommand/diff_subcommand.hpp b/src/subcommand/diff_subcommand.hpp index 5c2c23f..62e0947 100644 --- a/src/subcommand/diff_subcommand.hpp +++ b/src/subcommand/diff_subcommand.hpp @@ -38,16 +38,16 @@ class diff_subcommand bool m_patience_flag = false; bool m_minimal_flag = false; - // int m_rename_threshold = 50; - // bool m_find_renames_flag = false; - // int m_copy_threshold = 50; - // bool m_find_copies_flag = false; - // bool m_find_copies_harder_flag = false; - // bool m_break_rewrites_flag = false; - - int m_context_lines = 3; - int m_interhunk_lines = 0; - int m_abbrev = 7; + uint16_t m_rename_threshold = 50; + bool m_find_renames_flag = false; + uint16_t m_copy_threshold = 50; + bool m_find_copies_flag = false; + bool m_find_copies_harder_flag = false; + bool m_break_rewrites_flag = false; + + uint m_context_lines = 3; + uint m_interhunk_lines = 0; + uint m_abbrev = 7; bool m_colour_flag = true; bool m_no_colour_flag = false; diff --git a/test/test_diff.py b/test/test_diff.py index ee1fdb5..c0f907f 100644 --- a/test/test_diff.py +++ b/test/test_diff.py @@ -1,5 +1,6 @@ import re import subprocess +from sys import stderr import pytest @@ -547,120 +548,158 @@ def test_diff_minimal(repo_init_with_commit, git2cpp_path, tmp_path): # assert bool(re.search(ansi_escape, p.stdout)) -# TODO: add the following flags after the "move" subcommand has been implemented (needed for the tests) -# @pytest.mark.parametrize("renames_flag", ["-M", "--find-renames"]) -# def test_diff_find_renames(xtl_clone, git2cpp_path, tmp_path, renames_flag): -# """Test diff with -M/--find-renames""" -# xtl_path = tmp_path / "xtl" +@pytest.mark.parametrize("renames_flag", ["-M", "--find-renames"]) +def test_diff_find_renames(repo_init_with_commit, git2cpp_path, tmp_path, renames_flag): + """Test diff with -M/--find-renames""" + assert (tmp_path / "initial.txt").exists() -# old_file = xtl_path / "old_name.txt" -# old_file.write_text("Hello\n") + old_file = tmp_path / "old.txt" + old_file.write_text("Hello\n") -# cmd_add = [git2cpp_path, "add", "old_name.txt"] -# subprocess.run(cmd_add, cwd=xtl_path, check=True) + cmd_add = [git2cpp_path, "add", "old.txt"] + subprocess.run(cmd_add, cwd=tmp_path, check=True) -# cmd_commit = [git2cpp_path, "commit", "-m", "Add file"] -# subprocess.run(cmd_commit, cwd=xtl_path, check=True) + cmd_commit = [git2cpp_path, "commit", "-m", "Add file"] + subprocess.run(cmd_commit, cwd=tmp_path, check=True) -# new_file = xtl_path / "new_name.txt" -# old_file.rename(new_file) -# old_file.write_text("Goodbye\n") + cmd_mv = [git2cpp_path, "mv", "old.txt", "new.txt"] + subprocess.run(cmd_mv, cwd=tmp_path, check=True) -# cmd_add_all = [git2cpp_path, "add", "-A"] -# subprocess.run(cmd_add_all, cwd=xtl_path, check=True) + cmd = [git2cpp_path, "diff", "--cached", renames_flag] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode == 0 + assert "similarity index" in p.stdout + assert "rename from old.txt" in p.stdout + assert "rename to new.txt" in p.stdout -# cmd = [git2cpp_path, "diff", "--cached", renames_flag] -# p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) -# assert p.returncode == 0 -# # assert "similarity index" in p.stdout -# # assert "rename from" in p.stdout -# assert "+++ b/new_name.txt" in p.stdout -# assert "--- a/old_name.txt" in p.stdout -# print("===\n", p.stdout, "===\n") +def test_diff_find_renames_with_threshold( + repo_init_with_commit, git2cpp_path, tmp_path +): + """Test diff with -M with threshold value""" + assert (tmp_path / "initial.txt").exists() -# def test_diff_find_renames_with_threshold(xtl_clone, git2cpp_path, tmp_path): -# """Test diff with -M with threshold value""" -# xtl_path = tmp_path / "xtl" + old_file = tmp_path / "old.txt" + old_file.write_text("Content\n") -# old_file = xtl_path / "old.txt" -# old_file.write_text("Content\n") + cmd_add = [git2cpp_path, "add", "old.txt"] + subprocess.run(cmd_add, cwd=tmp_path, check=True) -# cmd_add = [git2cpp_path, "add", "old.txt"] -# subprocess.run(cmd_add, cwd=xtl_path, check=True) + cmd_commit = [git2cpp_path, "commit", "-m", "Add file"] + subprocess.run(cmd_commit, cwd=tmp_path, check=True) -# cmd_commit = [git2cpp_path, "commit", "-m", "Add file"] -# subprocess.run(cmd_commit, cwd=xtl_path, check=True) + new_file = tmp_path / "new.txt" + old_file.rename(new_file) -# new_file = xtl_path / "new.txt" -# old_file.rename(new_file) + cmd_add_all = [git2cpp_path, "add", "-A"] + subprocess.run(cmd_add_all, cwd=tmp_path, check=True) -# cmd_add_all = [git2cpp_path, "add", "-A"] -# subprocess.run(cmd_add_all, cwd=xtl_path, check=True) + cmd = [git2cpp_path, "diff", "--cached", "-M60"] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode == 0 + assert "similarity index" in p.stdout + assert "rename from old.txt" in p.stdout + assert "rename to new.txt" in p.stdout -# cmd = [git2cpp_path, "diff", "--cached", "-M50"] -# p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) -# assert p.returncode == 0 -# print(p.stdout) # Doesn't do the same as the previous one. Why ??? +@pytest.mark.parametrize("copies_flag", ["-C", "--find-copies"]) +def test_diff_find_copies_from_modified( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path, copies_flag +): + """Test diff with -C/--find-copies when source file is also modified""" + assert (tmp_path / "initial.txt").exists() -# @pytest.mark.parametrize("copies_flag", ["-C", "--find-copies"]) -# def test_diff_find_copies(xtl_clone, git2cpp_path, tmp_path, copies_flag): -# """Test diff with -C/--find-copies""" -# xtl_path = tmp_path / "xtl" + original_file = tmp_path / "original.txt" + original_file.write_text("Content to be copied\n") -# original_file = xtl_path / "original.txt" -# original_file.write_text("Content to be copied\n") + cmd_add = [git2cpp_path, "add", "original.txt"] + subprocess.run(cmd_add, cwd=tmp_path, check=True) -# cmd_add = [git2cpp_path, "add", "original.txt"] -# subprocess.run(cmd_add, cwd=xtl_path, check=True) + cmd_commit = [git2cpp_path, "commit", "-m", "add original file"] + subprocess.run(cmd_commit, cwd=tmp_path, check=True) -# copied_file = xtl_path / "copied.txt" -# copied_file.write_text("Content to be copied\n") + # Modify original.txt (this makes it a candidate for copy detection) + original_file.write_text("Content to be copied\nExtra line\n") + subprocess.run([git2cpp_path, "add", "original.txt"], cwd=tmp_path, check=True) -# cmd_add_copy = [git2cpp_path, "add", "copied.txt"] -# subprocess.run(cmd_add_copy, cwd=xtl_path, check=True) + # Create copy with original content + copied_file = tmp_path / "copied.txt" + copied_file.write_text("Content to be copied\n") + subprocess.run([git2cpp_path, "add", "copied.txt"], cwd=tmp_path, check=True) -# cmd = [git2cpp_path, "diff", "--cached", copies_flag] -# p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) -# assert p.returncode == 0 -# print(p.stdout) + cmd = [git2cpp_path, "diff", "--cached", copies_flag] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode == 0 + assert "similarity index" in p.stdout + assert "copy from original.txt" in p.stdout + assert "copy to copied.txt" in p.stdout -# def test_diff_find_copies_with_threshold(xtl_clone, git2cpp_path, tmp_path): -# """Test diff with -C with threshold value""" -# xtl_path = tmp_path / "xtl" +@pytest.mark.parametrize("copies_flag", ["-C", "--find-copies"]) +def test_diff_find_copies_harder( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path, copies_flag +): + """Test diff with -C/--find-copies and --find-copies-harder for unmodified sources""" + assert (tmp_path / "initial.txt").exists() -# original_file = xtl_path / "original.txt" -# original_file.write_text("Content\n") + original_file = tmp_path / "original.txt" + original_file.write_text("Content to be copied\n") -# cmd_add = [git2cpp_path, "add", "original.txt"] -# subprocess.run(cmd_add, cwd=xtl_path, check=True) + subprocess.run([git2cpp_path, "add", "original.txt"], cwd=tmp_path, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", "add original file"], + cwd=tmp_path, + check=True, + env=commit_env_config, + ) -# copied_file = xtl_path / "copied.txt" -# copied_file.write_text("Content to be copied\n") + # Create identical copy + copied_file = tmp_path / "copied.txt" + copied_file.write_text("Content to be copied\n") + subprocess.run([git2cpp_path, "add", "copied.txt"], cwd=tmp_path, check=True) -# cmd_add_copy = [git2cpp_path, "add", "copied.txt"] -# subprocess.run(cmd_add_copy, cwd=xtl_path, check=True) + # Use --find-copies-harder to detect copies from unmodified files + cmd = [git2cpp_path, "diff", "--cached", copies_flag, "--find-copies-harder"] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) -# cmd = [git2cpp_path, "diff", "--cached", "-C50"] -# p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) -# assert p.returncode == 0 + assert p.returncode == 0 + assert "similarity index 100%" in p.stdout + assert "copy from original.txt" in p.stdout + assert "copy to copied.txt" in p.stdout -# def test_diff_find_copies_harder(xtl_clone, git2cpp_path, tmp_path): -# """Test diff with --find-copies-harder""" -# xtl_path = tmp_path / "xtl" +@pytest.mark.parametrize("copies_flag", ["-C50", "--find-copies=50"]) +def test_diff_find_copies_with_threshold( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path, copies_flag +): + """Test diff with -C with custom threshold""" + assert (tmp_path / "initial.txt").exists() -# test_file = xtl_path / "test.txt" -# test_file.write_text("Content\n") + original_file = tmp_path / "original.txt" + original_content = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n" + original_file.write_text(original_content) -# cmd_add = [git2cpp_path, "add", "test.txt"] -# subprocess.run(cmd_add, cwd=xtl_path, check=True) + subprocess.run([git2cpp_path, "add", "original.txt"], cwd=tmp_path, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", "add original file"], + cwd=tmp_path, + check=True, + env=commit_env_config, + ) -# cmd = [git2cpp_path, "diff", "--cached", "--find-copies-harder"] -# p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) -# assert p.returncode == 0 + # Create a partial copy (60% similar) + copied_file = tmp_path / "copied.txt" + copied_file.write_text("Line 1\nLine 2\nLine 3\nNew line\nAnother line\n") + subprocess.run([git2cpp_path, "add", "copied.txt"], cwd=tmp_path, check=True) + + # With threshold of 50%, should detect copy + cmd = [git2cpp_path, "diff", "--cached", copies_flag, "--find-copies-harder"] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + + assert p.returncode == 0 + assert "similarity index" in p.stdout + assert "copy from original.txt" in p.stdout + assert "copy to copied.txt" in p.stdout # @pytest.mark.parametrize("break_rewrites_flag", ["-B", "--break-rewrites"]) @@ -683,4 +722,35 @@ def test_diff_minimal(repo_init_with_commit, git2cpp_path, tmp_path): # cmd = [git2cpp_path, "diff", break_rewrites_flag] # p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) # assert p.returncode == 0 -# print(p.stdout) + + +def test_diff_refuses_more_than_two_treeish( + repo_init_with_commit, git2cpp_path, tmp_path +): + # HEAD exists thanks to repo_init_with_commit + p = subprocess.run( + [git2cpp_path, "diff", "HEAD", "HEAD", "HEAD"], + capture_output=True, + text=True, + cwd=tmp_path, + ) + assert p.returncode != 0 + assert "2 required but received 3" in p.stderr + + +def test_diff_cached_and_no_index_are_incompatible(git2cpp_path, tmp_path): + # Create two files to satisfy the --no-index path arguments + a = tmp_path / "a.txt" + b = tmp_path / "b.txt" + a.write_text("hello\n") + b.write_text("world\n") + + p = subprocess.run( + [git2cpp_path, "diff", "--cached", "--no-index", str(a), str(b)], + capture_output=True, + text=True, + cwd=tmp_path, + ) + + assert p.returncode != 0 + assert "--cached and --no-index are incompatible" in (p.stderr + p.stdout) From 372f1ef5416107ce51914c605cc66ca4a0ac7af1 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Wed, 11 Mar 2026 16:19:30 +0000 Subject: [PATCH 27/35] Fix path used for wasm deployment to github pages (#111) * Fix path used for wasm deployment to github pages * Can only deploy to github pages from default branch --- .github/workflows/deploy-wasm.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-wasm.yml b/.github/workflows/deploy-wasm.yml index caff961..94a4de7 100644 --- a/.github/workflows/deploy-wasm.yml +++ b/.github/workflows/deploy-wasm.yml @@ -39,10 +39,11 @@ jobs: - name: Upload artifact uses: actions/upload-pages-artifact@v4 with: - path: ./wasm/serve + path: ./wasm/serve/dist deploy: needs: build + if: github.ref == 'refs/heads/main' permissions: pages: write id-token: write From 1ec65aa8711859466983c4445dce93a2e5889055 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Fri, 13 Mar 2026 14:11:46 +0100 Subject: [PATCH 28/35] Add ```-b``` flag to ```init``` subcommand (#119) * Add -b flag to init subcommand * address review comments * address review comment --- src/subcommand/init_subcommand.cpp | 32 +++++++- src/subcommand/init_subcommand.hpp | 1 + src/wrapper/repository_wrapper.cpp | 13 ++- src/wrapper/repository_wrapper.hpp | 3 +- test/conftest.py | 6 +- test/test_checkout.py | 42 ++-------- test/test_init.py | 82 +++++++++++++++--- test/test_merge.py | 30 +------ test/test_rebase.py | 128 ++++++----------------------- test/test_status.py | 77 +++-------------- 10 files changed, 166 insertions(+), 248 deletions(-) diff --git a/src/subcommand/init_subcommand.cpp b/src/subcommand/init_subcommand.cpp index 2a3a703..bd1c238 100644 --- a/src/subcommand/init_subcommand.cpp +++ b/src/subcommand/init_subcommand.cpp @@ -1,4 +1,4 @@ -// #include +#include #include "init_subcommand.hpp" #include "../wrapper/repository_wrapper.hpp" @@ -12,11 +12,39 @@ init_subcommand::init_subcommand(const libgit2_object&, CLI::App& app) sub->add_option("directory", m_directory, "info about directory arg") ->check(CLI::ExistingDirectory | CLI::NonexistentPath) ->default_val(get_current_git_path()); + sub->add_option("-b,--initial-branch", m_branch, "Use for the initial branch in the newly created repository. If not specified, fall back to the default name."); sub->callback([this]() { this->run(); }); } void init_subcommand::run() { - repository_wrapper::init(m_directory, m_bare); + std::filesystem::path target_dir = m_directory; + bool reinit = std::filesystem::exists(target_dir / ".git" / "HEAD"); + + repository_wrapper repo = [this]() { + if (m_branch.empty()) + { + return repository_wrapper::init(m_directory, m_bare); + } + else + { + git_repository_init_options opts = GIT_REPOSITORY_INIT_OPTIONS_INIT; + if (m_bare) { opts.flags |= GIT_REPOSITORY_INIT_BARE; } + opts.initial_head = m_branch.c_str(); + + return repository_wrapper::init_ext(m_directory, &opts); + } + }(); + + std::string path = repo.path(); + + if (reinit) + { + std::cout << "Reinitialized existing Git repository in " << path <git_path(); - std::string shallow_path = git_path + "shallow"; + std::string path = this->path(); + std::string shallow_path = path + "shallow"; std::vector boundaries_list; std::ifstream f(shallow_path); diff --git a/src/wrapper/repository_wrapper.hpp b/src/wrapper/repository_wrapper.hpp index 18a25e7..a62f8c2 100644 --- a/src/wrapper/repository_wrapper.hpp +++ b/src/wrapper/repository_wrapper.hpp @@ -40,10 +40,11 @@ class repository_wrapper : public wrapper_base repository_wrapper& operator=(repository_wrapper&&) noexcept = default; static repository_wrapper init(std::string_view directory, bool bare); + static repository_wrapper init_ext(std::string_view repo_path, git_repository_init_options* opts); static repository_wrapper open(std::string_view directory); static repository_wrapper clone(std::string_view url, std::string_view path, const git_clone_options& opts); - std::string git_path() const; + std::string path() const; git_repository_state_t state() const; void state_cleanup(); diff --git a/test/conftest.py b/test/conftest.py index e6e6003..138f87d 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -52,7 +52,7 @@ def commit_env_config(monkeypatch): @pytest.fixture def repo_init_with_commit(commit_env_config, git2cpp_path, tmp_path, run_in_tmp_path): - cmd_init = [git2cpp_path, "init", "."] + cmd_init = [git2cpp_path, "init", ".", "-b", "main"] p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path, text=True) assert p_init.returncode == 0 @@ -70,7 +70,7 @@ def repo_init_with_commit(commit_env_config, git2cpp_path, tmp_path, run_in_tmp_ @pytest.fixture(scope="session") def private_test_repo(): - # Fixture containing everything needed to access private github repo. + # Fixture containing everything needed to access private github repo. # GIT2CPP_TEST_PRIVATE_TOKEN is the fine-grained Personal Access Token for private test repo. # If this is not available as an environment variable, tests that use this fixture are skipped. token = os.getenv("GIT2CPP_TEST_PRIVATE_TOKEN") @@ -82,5 +82,5 @@ def private_test_repo(): "repo_name": repo_name, "http_url": f"http://github.com/{org_name}/{repo_name}", "https_url": f"https://github.com/{org_name}/{repo_name}", - "token": token + "token": token, } diff --git a/test/test_checkout.py b/test/test_checkout.py index b9ac96a..3383c9a 100644 --- a/test/test_checkout.py +++ b/test/test_checkout.py @@ -6,14 +6,6 @@ def test_checkout(repo_init_with_commit, git2cpp_path, tmp_path): assert (tmp_path / "initial.txt").exists() - default_branch = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - cwd=tmp_path, - text=True, - check=True, - ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented - create_cmd = [git2cpp_path, "branch", "foregone"] p_create = subprocess.run(create_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_create.returncode == 0 @@ -28,27 +20,19 @@ def test_checkout(repo_init_with_commit, git2cpp_path, tmp_path): branch_cmd = [git2cpp_path, "branch"] p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_branch.returncode == 0 - assert p_branch.stdout == f"* foregone\n {default_branch}\n" + assert p_branch.stdout == "* foregone\n main\n" - checkout_cmd[2] = default_branch + checkout_cmd[2] = "main" p_checkout2 = subprocess.run( checkout_cmd, capture_output=True, cwd=tmp_path, text=True ) assert p_checkout2.returncode == 0 - assert f"Switched to branch '{default_branch}'" in p_checkout2.stdout + assert "Switched to branch 'main'" in p_checkout2.stdout def test_checkout_b(repo_init_with_commit, git2cpp_path, tmp_path): assert (tmp_path / "initial.txt").exists() - default_branch = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - cwd=tmp_path, - text=True, - check=True, - ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented - checkout_cmd = [git2cpp_path, "checkout", "-b", "foregone"] p_checkout = subprocess.run( checkout_cmd, capture_output=True, cwd=tmp_path, text=True @@ -59,16 +43,16 @@ def test_checkout_b(repo_init_with_commit, git2cpp_path, tmp_path): branch_cmd = [git2cpp_path, "branch"] p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_branch.returncode == 0 - assert p_branch.stdout == f"* foregone\n {default_branch}\n" + assert p_branch.stdout == "* foregone\n main\n" checkout_cmd.remove("-b") - checkout_cmd[2] = default_branch + checkout_cmd[2] = "main" p_checkout2 = subprocess.run(checkout_cmd, cwd=tmp_path, text=True) assert p_checkout2.returncode == 0 p_branch2 = subprocess.run(branch_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_branch2.returncode == 0 - assert p_branch2.stdout == f" foregone\n* {default_branch}\n" + assert p_branch2.stdout == " foregone\n* main\n" def test_checkout_B_force_create(repo_init_with_commit, git2cpp_path, tmp_path): @@ -143,14 +127,6 @@ def test_checkout_refuses_overwrite( initial_file = tmp_path / "initial.txt" assert (initial_file).exists() - default_branch = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - cwd=tmp_path, - text=True, - check=True, - ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented - # Create a new branch and switch to it create_cmd = [git2cpp_path, "checkout", "-b", "newbranch"] p_create = subprocess.run(create_cmd, capture_output=True, cwd=tmp_path, text=True) @@ -166,14 +142,14 @@ def test_checkout_refuses_overwrite( subprocess.run(commit_cmd, cwd=tmp_path, text=True) # Switch back to default branch - checkout_default_cmd = [git2cpp_path, "checkout", default_branch] + checkout_default_cmd = [git2cpp_path, "checkout", "main"] p_default = subprocess.run( checkout_default_cmd, capture_output=True, cwd=tmp_path, text=True ) assert p_default.returncode == 0 # Now modify initial.txt locally (unstaged) on default branch - initial_file.write_text(f"Local modification on {default_branch}") + initial_file.write_text("Local modification on main") # Try to checkout newbranch checkout_cmd = [git2cpp_path, "checkout"] @@ -201,7 +177,7 @@ def test_checkout_refuses_overwrite( p_branch = subprocess.run( branch_cmd, capture_output=True, cwd=tmp_path, text=True ) - assert f"* {default_branch}" in p_branch.stdout + assert "* main" in p_branch.stdout else: assert "Switched to branch 'newbranch'" in p_checkout.stdout diff --git a/test/test_init.py b/test/test_init.py index 1ba018e..9a66188 100644 --- a/test/test_init.py +++ b/test/test_init.py @@ -7,10 +7,10 @@ def test_init_in_directory(git2cpp_path, tmp_path): assert list(tmp_path.iterdir()) == [] cmd = [git2cpp_path, "init", "--bare", str(tmp_path)] - p = subprocess.run(cmd, capture_output=True) + p = subprocess.run(cmd, capture_output=True, text=True) assert p.returncode == 0 - assert p.stdout == b"" - assert p.stderr == b"" + assert p.stdout.startswith("Initialized empty Git repository in ") + assert p.stdout.strip().endswith("/") assert sorted(map(lambda path: path.name, tmp_path.iterdir())) == [ "HEAD", @@ -31,10 +31,10 @@ def test_init_in_cwd(git2cpp_path, tmp_path, run_in_tmp_path): assert Path.cwd() == tmp_path cmd = [git2cpp_path, "init", "--bare"] - p = subprocess.run(cmd, capture_output=True) + p = subprocess.run(cmd, capture_output=True, text=True) assert p.returncode == 0 - assert p.stdout == b"" - assert p.stderr == b"" + assert p.stdout.startswith("Initialized empty Git repository in ") + assert p.stdout.strip().endswith("/") assert sorted(map(lambda path: path.name, tmp_path.iterdir())) == [ "HEAD", @@ -52,12 +52,12 @@ def test_init_in_cwd(git2cpp_path, tmp_path, run_in_tmp_path): def test_init_not_bare(git2cpp_path, tmp_path): # tmp_path exists and is empty. assert list(tmp_path.iterdir()) == [] - + assert not (tmp_path / ".git" / "HEAD").exists() cmd = [git2cpp_path, "init", "."] - p = subprocess.run(cmd, capture_output=True, cwd=tmp_path) + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 - assert p.stdout == b"" - assert p.stderr == b"" + assert p.stdout.startswith("Initialized empty Git repository in ") + assert p.stdout.strip().endswith(".git/") # Directory contains just .git directory. assert sorted(map(lambda path: path.name, tmp_path.iterdir())) == [".git"] @@ -79,6 +79,19 @@ def test_init_not_bare(git2cpp_path, tmp_path): assert b"does not have any commits yet" in p.stdout +def test_init_reinitializes_existing_repo_message(git2cpp_path, tmp_path): + cmd = [git2cpp_path, "init", "."] + + p1 = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p1.returncode == 0 + + p2 = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p2.returncode == 0 + assert p2.stderr == "" + assert p2.stdout.startswith("Reinitialized existing Git repository in ") + assert p2.stdout.strip().endswith("/.git/") + + def test_error_on_unknown_option(git2cpp_path): cmd = [git2cpp_path, "init", "--unknown"] p = subprocess.run(cmd, capture_output=True) @@ -93,3 +106,52 @@ def test_error_on_repeated_directory(git2cpp_path): assert p.returncode == 109 assert p.stdout == b"" assert p.stderr.startswith(b"The following argument was not expected: def") + + +def test_init_creates_missing_parent_directories(git2cpp_path, tmp_path): + # Parent "does-not-exist" does not exist yet. + repo_dir = tmp_path / "does-not-exist" / "repo" + assert not repo_dir.parent.exists() + + cmd = [git2cpp_path, "init", "--bare", str(repo_dir)] + p = subprocess.run(cmd, capture_output=True, text=True) + + assert p.returncode == 0 + assert p.stdout.startswith("Initialized empty Git repository in ") + assert p.stdout.strip().endswith("/") + assert ".git" not in p.stdout + + assert repo_dir.exists() + assert sorted(p.name for p in repo_dir.iterdir()) == [ + "HEAD", + "config", + "description", + "hooks", + "info", + "objects", + "refs", + ] + + +def test_init_initial_branch_non_bare(git2cpp_path, tmp_path): + cmd = [git2cpp_path, "init", "-b", "main", "."] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode == 0 + assert p.stderr == "" + assert p.stdout.startswith("Initialized empty Git repository in ") + assert p.stdout.strip().endswith("/.git/") + + head = (tmp_path / ".git" / "HEAD").read_text() + assert "refs/heads/main" in head + + +def test_init_initial_branch_bare(git2cpp_path, tmp_path): + cmd = [git2cpp_path, "init", "--bare", "-b", "main", str(tmp_path)] + p = subprocess.run(cmd, capture_output=True, text=True) + assert p.returncode == 0 + assert p.stderr == "" + assert p.stdout.startswith("Initialized empty Git repository in ") + assert p.stdout.strip().endswith("/") + + head = (tmp_path / "HEAD").read_text() + assert "refs/heads/main" in head diff --git a/test/test_merge.py b/test/test_merge.py index a805ff3..f6d36a3 100644 --- a/test/test_merge.py +++ b/test/test_merge.py @@ -10,14 +10,6 @@ def test_merge_fast_forward( ): assert (tmp_path / "initial.txt").exists() - default_branch = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - cwd=tmp_path, - text=True, - check=True, - ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented - checkout_cmd = [git2cpp_path, "checkout", "-b", "foregone"] p_checkout = subprocess.run( checkout_cmd, capture_output=True, cwd=tmp_path, text=True @@ -35,7 +27,7 @@ def test_merge_fast_forward( p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_commit.returncode == 0 - checkout_cmd_2 = [git2cpp_path, "checkout", default_branch] + checkout_cmd_2 = [git2cpp_path, "checkout", "main"] p_checkout_2 = subprocess.run( checkout_cmd_2, capture_output=True, cwd=tmp_path, text=True ) @@ -64,14 +56,6 @@ def test_merge_fast_forward( def test_merge_commit(repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path): assert (tmp_path / "initial.txt").exists() - default_branch = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - cwd=tmp_path, - text=True, - check=True, - ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented - checkout_cmd = [git2cpp_path, "checkout", "-b", "foregone"] p_checkout = subprocess.run( checkout_cmd, capture_output=True, cwd=tmp_path, text=True @@ -89,7 +73,7 @@ def test_merge_commit(repo_init_with_commit, commit_env_config, git2cpp_path, tm p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_commit.returncode == 0 - checkout_cmd_2 = [git2cpp_path, "checkout", default_branch] + checkout_cmd_2 = [git2cpp_path, "checkout", "main"] p_checkout_2 = subprocess.run( checkout_cmd_2, capture_output=True, cwd=tmp_path, text=True ) @@ -135,14 +119,6 @@ def test_merge_conflict( ): assert (tmp_path / "initial.txt").exists() - default_branch = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - cwd=tmp_path, - text=True, - check=True, - ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented - checkout_cmd = [git2cpp_path, "checkout", "-b", "foregone"] p_checkout = subprocess.run( checkout_cmd, capture_output=True, cwd=tmp_path, text=True @@ -163,7 +139,7 @@ def test_merge_conflict( p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_commit.returncode == 0 - checkout_cmd_2 = [git2cpp_path, "checkout", default_branch] + checkout_cmd_2 = [git2cpp_path, "checkout", "main"] p_checkout_2 = subprocess.run( checkout_cmd_2, capture_output=True, cwd=tmp_path, text=True ) diff --git a/test/test_rebase.py b/test/test_rebase.py index 06d39d9..add183b 100644 --- a/test/test_rebase.py +++ b/test/test_rebase.py @@ -7,14 +7,6 @@ def test_rebase_basic(repo_init_with_commit, commit_env_config, git2cpp_path, tm """Test basic rebase operation with fast-forward""" assert (tmp_path / "initial.txt").exists() - default_branch = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - cwd=tmp_path, - text=True, - check=True, - ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented - # Create a feature branch checkout_cmd = [git2cpp_path, "checkout", "-b", "feature"] p_checkout = subprocess.run( @@ -35,7 +27,7 @@ def test_rebase_basic(repo_init_with_commit, commit_env_config, git2cpp_path, tm assert p_commit.returncode == 0 # Go back to master and create another commit - checkout_master_cmd = [git2cpp_path, "checkout", default_branch] + checkout_master_cmd = [git2cpp_path, "checkout", "main"] p_checkout_master = subprocess.run( checkout_master_cmd, capture_output=True, cwd=tmp_path, text=True ) @@ -61,7 +53,7 @@ def test_rebase_basic(repo_init_with_commit, commit_env_config, git2cpp_path, tm ) assert p_checkout_feature.returncode == 0 - rebase_cmd = [git2cpp_path, "rebase", default_branch] + rebase_cmd = [git2cpp_path, "rebase", "main"] p_rebase = subprocess.run(rebase_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_rebase.returncode == 0 assert "Successfully rebased" in p_rebase.stdout @@ -78,14 +70,6 @@ def test_rebase_multiple_commits( """Test rebase with multiple commits""" assert (tmp_path / "initial.txt").exists() - default_branch = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - cwd=tmp_path, - text=True, - check=True, - ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented - # Create feature branch with multiple commits checkout_cmd = [git2cpp_path, "checkout", "-b", "feature"] p_checkout = subprocess.run( @@ -118,7 +102,7 @@ def test_rebase_multiple_commits( subprocess.run(commit_cmd_3, cwd=tmp_path, text=True) # Go to master and add a commit - checkout_master_cmd = [git2cpp_path, "checkout", default_branch] + checkout_master_cmd = [git2cpp_path, "checkout", "main"] subprocess.run(checkout_master_cmd, cwd=tmp_path) master_file = tmp_path / "master_file.txt" @@ -130,7 +114,7 @@ def test_rebase_multiple_commits( checkout_feature_cmd = [git2cpp_path, "checkout", "feature"] subprocess.run(checkout_feature_cmd, cwd=tmp_path) - rebase_cmd = [git2cpp_path, "rebase", default_branch] + rebase_cmd = [git2cpp_path, "rebase", "main"] p_rebase = subprocess.run(rebase_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_rebase.returncode == 0 assert "Rebasing 3 commit(s)" in p_rebase.stdout @@ -149,14 +133,6 @@ def test_rebase_with_conflicts( """Test rebase with conflicts""" assert (tmp_path / "initial.txt").exists() - default_branch = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - cwd=tmp_path, - text=True, - check=True, - ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented - # Create feature branch checkout_cmd = [git2cpp_path, "checkout", "-b", "feature"] subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True) @@ -168,7 +144,7 @@ def test_rebase_with_conflicts( subprocess.run([git2cpp_path, "commit", "-m", "feature commit"], cwd=tmp_path) # Go to master and create conflicting commit - checkout_master_cmd = [git2cpp_path, "checkout", default_branch] + checkout_master_cmd = [git2cpp_path, "checkout", "main"] subprocess.run(checkout_master_cmd, cwd=tmp_path) conflict_file.write_text("master content") @@ -179,7 +155,7 @@ def test_rebase_with_conflicts( checkout_feature_cmd = [git2cpp_path, "checkout", "feature"] subprocess.run(checkout_feature_cmd, cwd=tmp_path) - rebase_cmd = [git2cpp_path, "rebase", default_branch] + rebase_cmd = [git2cpp_path, "rebase", "main"] p_rebase = subprocess.run(rebase_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_rebase.returncode == 0 assert "Conflicts detected" in p_rebase.stdout @@ -192,14 +168,6 @@ def test_rebase_abort(repo_init_with_commit, commit_env_config, git2cpp_path, tm """Test rebase abort after conflicts""" assert (tmp_path / "initial.txt").exists() - default_branch = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - cwd=tmp_path, - text=True, - check=True, - ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented - # Create feature branch checkout_cmd = [git2cpp_path, "checkout", "-b", "feature"] subprocess.run(checkout_cmd, cwd=tmp_path) @@ -211,14 +179,14 @@ def test_rebase_abort(repo_init_with_commit, commit_env_config, git2cpp_path, tm subprocess.run([git2cpp_path, "commit", "-m", "feature commit"], cwd=tmp_path) # Go to master and create conflicting commit - subprocess.run([git2cpp_path, "checkout", default_branch], cwd=tmp_path) + subprocess.run([git2cpp_path, "checkout", "main"], cwd=tmp_path) conflict_file.write_text("master content") subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=tmp_path) subprocess.run([git2cpp_path, "commit", "-m", "master commit"], cwd=tmp_path) # Rebase and get conflict subprocess.run([git2cpp_path, "checkout", "feature"], cwd=tmp_path) - subprocess.run([git2cpp_path, "rebase", default_branch], cwd=tmp_path) + subprocess.run([git2cpp_path, "rebase", "main"], cwd=tmp_path) # Abort the rebase abort_cmd = [git2cpp_path, "rebase", "--abort"] @@ -236,14 +204,6 @@ def test_rebase_continue( """Test rebase continue after resolving conflicts""" assert (tmp_path / "initial.txt").exists() - default_branch = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - cwd=tmp_path, - text=True, - check=True, - ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented - # Create feature branch subprocess.run([git2cpp_path, "checkout", "-b", "feature"], cwd=tmp_path) @@ -254,14 +214,14 @@ def test_rebase_continue( subprocess.run([git2cpp_path, "commit", "-m", "feature commit"], cwd=tmp_path) # Go to master and create conflicting commit - subprocess.run([git2cpp_path, "checkout", default_branch], cwd=tmp_path) + subprocess.run([git2cpp_path, "checkout", "main"], cwd=tmp_path) conflict_file.write_text("master content") subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=tmp_path) subprocess.run([git2cpp_path, "commit", "-m", "master commit"], cwd=tmp_path) # Rebase and get conflict subprocess.run([git2cpp_path, "checkout", "feature"], cwd=tmp_path) - subprocess.run([git2cpp_path, "rebase", default_branch], cwd=tmp_path) + subprocess.run([git2cpp_path, "rebase", "main"], cwd=tmp_path) # Resolve conflict conflict_file.write_text("resolved content") @@ -283,14 +243,6 @@ def test_rebase_skip(repo_init_with_commit, commit_env_config, git2cpp_path, tmp """Test rebase skip to skip current commit""" assert (tmp_path / "initial.txt").exists() - default_branch = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - cwd=tmp_path, - text=True, - check=True, - ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented - # Create feature branch subprocess.run([git2cpp_path, "checkout", "-b", "feature"], cwd=tmp_path) @@ -301,14 +253,14 @@ def test_rebase_skip(repo_init_with_commit, commit_env_config, git2cpp_path, tmp subprocess.run([git2cpp_path, "commit", "-m", "feature commit"], cwd=tmp_path) # Go to master and create conflicting commit - subprocess.run([git2cpp_path, "checkout", default_branch], cwd=tmp_path) + subprocess.run([git2cpp_path, "checkout", "main"], cwd=tmp_path) conflict_file.write_text("master content") subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=tmp_path) subprocess.run([git2cpp_path, "commit", "-m", "master commit"], cwd=tmp_path) # Rebase and get conflict subprocess.run([git2cpp_path, "checkout", "feature"], cwd=tmp_path) - subprocess.run([git2cpp_path, "rebase", default_branch], cwd=tmp_path) + subprocess.run([git2cpp_path, "rebase", "main"], cwd=tmp_path) # Skip the conflicting commit skip_cmd = [git2cpp_path, "rebase", "--skip"] @@ -321,14 +273,6 @@ def test_rebase_quit(repo_init_with_commit, commit_env_config, git2cpp_path, tmp """Test rebase quit to cleanup state without resetting HEAD""" assert (tmp_path / "initial.txt").exists() - default_branch = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - cwd=tmp_path, - text=True, - check=True, - ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented - # Create feature branch subprocess.run([git2cpp_path, "checkout", "-b", "feature"], cwd=tmp_path) @@ -339,14 +283,14 @@ def test_rebase_quit(repo_init_with_commit, commit_env_config, git2cpp_path, tmp subprocess.run([git2cpp_path, "commit", "-m", "feature commit"], cwd=tmp_path) # Create conflict on master - subprocess.run([git2cpp_path, "checkout", default_branch], cwd=tmp_path) + subprocess.run([git2cpp_path, "checkout", "main"], cwd=tmp_path) conflict_file.write_text("master content") subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=tmp_path) subprocess.run([git2cpp_path, "commit", "-m", "master commit"], cwd=tmp_path) # Start rebase subprocess.run([git2cpp_path, "checkout", "feature"], cwd=tmp_path) - subprocess.run([git2cpp_path, "rebase", default_branch], cwd=tmp_path) + subprocess.run([git2cpp_path, "rebase", "main"], cwd=tmp_path) # Quit rebase quit_cmd = [git2cpp_path, "rebase", "--quit"] @@ -360,14 +304,6 @@ def test_rebase_onto(repo_init_with_commit, commit_env_config, git2cpp_path, tmp """Test rebase with --onto option""" assert (tmp_path / "initial.txt").exists() - default_branch = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - cwd=tmp_path, - text=True, - check=True, - ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented - # Create first branch subprocess.run([git2cpp_path, "checkout", "-b", "branch1"], cwd=tmp_path) file1 = tmp_path / "file1.txt" @@ -383,7 +319,7 @@ def test_rebase_onto(repo_init_with_commit, commit_env_config, git2cpp_path, tmp subprocess.run([git2cpp_path, "commit", "-m", "branch2 commit"], cwd=tmp_path) # Create target branch from master - subprocess.run([git2cpp_path, "checkout", default_branch], cwd=tmp_path) + subprocess.run([git2cpp_path, "checkout", "main"], cwd=tmp_path) subprocess.run([git2cpp_path, "checkout", "-b", "target"], cwd=tmp_path) target_file = tmp_path / "target.txt" target_file.write_text("target") @@ -433,14 +369,6 @@ def test_rebase_already_in_progress_error( """Test that starting rebase when one is in progress fails""" assert (tmp_path / "initial.txt").exists() - default_branch = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - cwd=tmp_path, - text=True, - check=True, - ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented - # Create feature branch with conflict subprocess.run([git2cpp_path, "checkout", "-b", "feature"], cwd=tmp_path) conflict_file = tmp_path / "conflict.txt" @@ -449,17 +377,17 @@ def test_rebase_already_in_progress_error( subprocess.run([git2cpp_path, "commit", "-m", "feature"], cwd=tmp_path) # Create conflict on master - subprocess.run([git2cpp_path, "checkout", default_branch], cwd=tmp_path) - conflict_file.write_text(default_branch) + subprocess.run([git2cpp_path, "checkout", "main"], cwd=tmp_path) + conflict_file.write_text("main") subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=tmp_path) - subprocess.run([git2cpp_path, "commit", "-m", default_branch], cwd=tmp_path) + subprocess.run([git2cpp_path, "commit", "-m", "main"], cwd=tmp_path) # Start rebase with conflict subprocess.run([git2cpp_path, "checkout", "feature"], cwd=tmp_path) - subprocess.run([git2cpp_path, "rebase", default_branch], cwd=tmp_path) + subprocess.run([git2cpp_path, "rebase", "main"], cwd=tmp_path) # Try to start another rebase - rebase_cmd = [git2cpp_path, "rebase", default_branch] + rebase_cmd = [git2cpp_path, "rebase", "main"] p_rebase = subprocess.run(rebase_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_rebase.returncode != 0 assert ( @@ -491,14 +419,6 @@ def test_rebase_continue_with_unresolved_conflicts( """Test that --continue with unresolved conflicts fails""" assert (tmp_path / "initial.txt").exists() - default_branch = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - cwd=tmp_path, - text=True, - check=True, - ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented - # Create conflict scenario subprocess.run([git2cpp_path, "checkout", "-b", "feature"], cwd=tmp_path) conflict_file = tmp_path / "conflict.txt" @@ -506,14 +426,14 @@ def test_rebase_continue_with_unresolved_conflicts( subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=tmp_path) subprocess.run([git2cpp_path, "commit", "-m", "feature"], cwd=tmp_path) - subprocess.run([git2cpp_path, "checkout", default_branch], cwd=tmp_path) - conflict_file.write_text(default_branch) + subprocess.run([git2cpp_path, "checkout", "main"], cwd=tmp_path) + conflict_file.write_text("main") subprocess.run([git2cpp_path, "add", "conflict.txt"], cwd=tmp_path) - subprocess.run([git2cpp_path, "commit", "-m", default_branch], cwd=tmp_path) + subprocess.run([git2cpp_path, "commit", "-m", "main"], cwd=tmp_path) # Start rebase subprocess.run([git2cpp_path, "checkout", "feature"], cwd=tmp_path) - subprocess.run([git2cpp_path, "rebase", default_branch], cwd=tmp_path) + subprocess.run([git2cpp_path, "rebase", "main"], cwd=tmp_path) # Try to continue without resolving continue_cmd = [git2cpp_path, "rebase", "--continue"] diff --git a/test/test_status.py b/test/test_status.py index 9a27441..d7a02a0 100644 --- a/test/test_status.py +++ b/test/test_status.py @@ -12,14 +12,6 @@ def test_status_new_file( ): assert (tmp_path / "initial.txt").exists() - default_branch = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - cwd=tmp_path, - text=True, - check=True, - ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented - p = tmp_path / "mook_file.txt" # Untracked files p.write_text("") @@ -42,7 +34,7 @@ def test_status_new_file( p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True, check=True) if (long_flag == "--long") or ((long_flag == "") & (short_flag == "")): - assert f"On branch {default_branch}" in p.stdout + assert "On branch main" in p.stdout assert "Changes not staged for commit" in p.stdout assert "Untracked files" in p.stdout assert "deleted" in p.stdout @@ -101,23 +93,14 @@ def test_status_new_repo(git2cpp_path, tmp_path, run_in_tmp_path): # tmp_path exists and is empty. assert list(tmp_path.iterdir()) == [] - cmd = [git2cpp_path, "init"] + cmd = [git2cpp_path, "init", "-b", "main"] p = subprocess.run(cmd, cwd=tmp_path) assert p.returncode == 0 - default_branch = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - cwd=tmp_path, - text=True, - check=True, - ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented - print(default_branch) - cmd_status = [git2cpp_path, "status"] p_status = subprocess.run(cmd_status, capture_output=True, cwd=tmp_path, text=True) assert p_status.returncode == 0 - assert f"On branch {default_branch}" in p_status.stdout + assert "On branch main" in p_status.stdout assert "No commit yet" in p_status.stdout assert "Nothing to commit, working tree clean" in p_status.stdout @@ -126,19 +109,11 @@ def test_status_clean_tree(repo_init_with_commit, git2cpp_path, tmp_path): """Test 'Nothing to commit, working tree clean' message""" assert (tmp_path / "initial.txt").exists() - default_branch = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - cwd=tmp_path, - text=True, - check=True, - ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented - cmd = [git2cpp_path, "status"] p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 - assert f"On branch {default_branch}" in p.stdout + assert "On branch main" in p.stdout assert "Nothing to commit, working tree clean" in p.stdout @@ -308,15 +283,7 @@ def test_status_ahead_of_upstream( repo_path.mkdir() # Initialize repo - subprocess.run([git2cpp_path, "init"], cwd=repo_path, check=True) - - default_branch = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - cwd=repo_path, - text=True, - check=True, - ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented + subprocess.run([git2cpp_path, "init", "-b", "main"], cwd=repo_path, check=True) # Create initial commit test_file = repo_path / "file.txt" @@ -344,10 +311,10 @@ def test_status_ahead_of_upstream( assert p.returncode == 0 if short_flag == "-s": - assert f"...origin/{default_branch}" in p.stdout + assert "...origin/main" in p.stdout assert "[ahead 1]" in p.stdout else: - assert f"Your branch is ahead of 'origin/{default_branch}'" in p.stdout + assert "Your branch is ahead of 'origin/main'" in p.stdout assert "by 1 commit" in p.stdout assert 'use "git push"' in p.stdout @@ -362,15 +329,7 @@ def test_status_with_branch_and_tracking( repo_path = tmp_path / "repo" repo_path.mkdir() - subprocess.run([git2cpp_path, "init"], cwd=repo_path) - - default_branch = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - cwd=repo_path, - text=True, - check=True, - ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented + subprocess.run([git2cpp_path, "init", "-b", "main"], cwd=repo_path) test_file = repo_path / "file.txt" test_file.write_text("initial") @@ -395,16 +354,12 @@ def test_status_with_branch_and_tracking( assert p.returncode == 0 if short_flag == "-s": - assert ( - f"## {default_branch}" in p.stdout - ) # "main" locally, but "master" in the CI + assert "## main" in p.stdout # "main" locally, but "master" in the CI assert "[ahead 1]" in p.stdout else: + assert "On branch main" in p.stdout # "main" locally, but "master" in the CI assert ( - f"On branch {default_branch}" in p.stdout - ) # "main" locally, but "master" in the CI - assert ( - f"Your branch is ahead of 'origin/{default_branch}'" in p.stdout + "Your branch is ahead of 'origin/main'" in p.stdout ) # "main" locally, but "master" in the CI assert "1 commit." in p.stdout @@ -413,14 +368,6 @@ def test_status_all_headers_shown(repo_init_with_commit, git2cpp_path, tmp_path) """Test that all status headers can be shown together""" assert (tmp_path / "initial.txt").exists() - default_branch = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - cwd=tmp_path, - text=True, - check=True, - ).stdout.strip() # TODO: use git2cpp when "branch --show-current" is implemented - # Changes to be committed staged = tmp_path / "staged.txt" staged.write_text("staged") @@ -438,7 +385,7 @@ def test_status_all_headers_shown(repo_init_with_commit, git2cpp_path, tmp_path) p = subprocess.run(cmd_status, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 - assert f"On branch {default_branch}" in p.stdout + assert "On branch main" in p.stdout assert "Changes to be committed:" in p.stdout assert 'use "git reset HEAD ..." to unstage' in p.stdout assert "Changes not staged for commit:" in p.stdout From ade2c75354c49ef6f82a0eee05a86874ec2e2b82 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Fri, 13 Mar 2026 16:37:03 +0100 Subject: [PATCH 29/35] Add --show-current flag to the branch subcommand (#120) --- src/subcommand/branch_subcommand.cpp | 21 +++- src/subcommand/branch_subcommand.hpp | 2 + test/test_branch.py | 175 ++++++++++++++++++++++++++- 3 files changed, 193 insertions(+), 5 deletions(-) diff --git a/src/subcommand/branch_subcommand.cpp b/src/subcommand/branch_subcommand.cpp index 82decf1..f0ea9fa 100644 --- a/src/subcommand/branch_subcommand.cpp +++ b/src/subcommand/branch_subcommand.cpp @@ -14,6 +14,7 @@ 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(); }); } @@ -23,7 +24,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); } @@ -64,9 +70,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/test/test_branch.py b/test/test_branch.py index ffaf29b..8c32cb6 100644 --- a/test/test_branch.py +++ b/test/test_branch.py @@ -9,7 +9,7 @@ def test_branch_list(repo_init_with_commit, git2cpp_path, tmp_path): cmd = [git2cpp_path, "branch"] p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 - assert "* ma" in p.stdout + assert "* main" in p.stdout def test_branch_create_delete(repo_init_with_commit, git2cpp_path, tmp_path): @@ -22,7 +22,7 @@ def test_branch_create_delete(repo_init_with_commit, git2cpp_path, tmp_path): list_cmd = [git2cpp_path, "branch"] p_list = subprocess.run(list_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_list.returncode == 0 - assert " foregone\n* ma" in p_list.stdout + assert " foregone\n* main" in p_list.stdout del_cmd = [git2cpp_path, "branch", "-d", "foregone"] p_del = subprocess.run(del_cmd, capture_output=True, cwd=tmp_path, text=True) @@ -30,7 +30,7 @@ def test_branch_create_delete(repo_init_with_commit, git2cpp_path, tmp_path): p_list2 = subprocess.run(list_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_list2.returncode == 0 - assert "* ma" in p_list2.stdout + assert "* main" in p_list2.stdout def test_branch_nogit(git2cpp_path, tmp_path): @@ -51,3 +51,172 @@ def test_branch_new_repo(git2cpp_path, tmp_path, run_in_tmp_path): p_branch = subprocess.run(branch_cmd, cwd=tmp_path) assert p_branch.returncode == 0 + + +def test_branch_list_flag(repo_init_with_commit, git2cpp_path, tmp_path): + """Explicit -l/--list flag behaves the same as bare 'branch'.""" + assert (tmp_path / "initial.txt").exists() + + subprocess.run([git2cpp_path, "branch", "feature-a"], cwd=tmp_path, check=True) + + for flag in ["-l", "--list"]: + cmd = [git2cpp_path, "branch", flag] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode == 0 + assert " feature-a" in p.stdout + assert "* main" in p.stdout + + +def test_branch_list_all(xtl_clone, git2cpp_path, tmp_path): + """The -a/--all flag lists both local and remote-tracking branches.""" + xtl_path = tmp_path / "xtl" + + cmd = [git2cpp_path, "branch", "-a"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "* master" in p.stdout + assert "origin/" in p.stdout + + +def test_branch_list_remotes(xtl_clone, git2cpp_path, tmp_path): + """The -r/--remotes flag lists only remote-tracking branches.""" + xtl_path = tmp_path / "xtl" + + cmd = [git2cpp_path, "branch", "-r"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "origin/" in p.stdout + # Local branch should NOT appear with * prefix + assert "* master" not in p.stdout + + +def test_branch_create_already_exists(repo_init_with_commit, git2cpp_path, tmp_path): + """Creating a branch that already exists should fail without --force.""" + assert (tmp_path / "initial.txt").exists() + + subprocess.run([git2cpp_path, "branch", "duplicate"], cwd=tmp_path, check=True) + + cmd = [git2cpp_path, "branch", "duplicate"] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode != 0 + + +def test_branch_create_force_overwrite( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path +): + """--force allows overwriting an existing branch.""" + assert (tmp_path / "initial.txt").exists() + + subprocess.run([git2cpp_path, "branch", "my-branch"], cwd=tmp_path, check=True) + + # Add a second commit so HEAD moves forward + (tmp_path / "second.txt").write_text("second") + subprocess.run([git2cpp_path, "add", "second.txt"], cwd=tmp_path, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", "Second commit"], cwd=tmp_path, check=True + ) + + # Without --force this would fail; with -f it should reset the branch to current HEAD + cmd = [git2cpp_path, "branch", "-f", "my-branch"] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode == 0 + + +def test_branch_delete_nonexistent(repo_init_with_commit, git2cpp_path, tmp_path): + """Deleting a branch that doesn't exist should fail.""" + assert (tmp_path / "initial.txt").exists() + + cmd = [git2cpp_path, "branch", "-d", "no-such-branch"] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode != 0 + + +def test_branch_create_multiple(repo_init_with_commit, git2cpp_path, tmp_path): + """Creating multiple branches and verifying they all appear in the listing.""" + assert (tmp_path / "initial.txt").exists() + + branches = ["alpha", "beta", "gamma"] + for name in branches: + p = subprocess.run( + [git2cpp_path, "branch", name], capture_output=True, cwd=tmp_path, text=True + ) + assert p.returncode == 0 + + cmd = [git2cpp_path, "branch"] + p_list = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_list.returncode == 0 + for name in branches: + assert f" {name}" in p_list.stdout + # Current branch is still starred + assert "* main" in p_list.stdout + + +def test_branch_show_current(repo_init_with_commit, git2cpp_path, tmp_path): + """--show-current prints the current branch name.""" + assert (tmp_path / "initial.txt").exists() + + cmd = [git2cpp_path, "branch", "--show-current"] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode == 0 + print(p.stdout) + # Default branch after init is "main" or "master" depending on git config + assert p.stdout.strip() == "main" + + +def test_branch_show_current_after_create_and_switch( + repo_init_with_commit, git2cpp_path, tmp_path +): + """--show-current reflects the branch we switched to.""" + assert (tmp_path / "initial.txt").exists() + + subprocess.run( + [git2cpp_path, "checkout", "-b", "new-feature"], cwd=tmp_path, check=True + ) + + cmd = [git2cpp_path, "branch", "--show-current"] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode == 0 + assert p.stdout == "new-feature\n" + + +def test_branch_show_current_detached_head( + repo_init_with_commit, git2cpp_path, tmp_path +): + """--show-current prints nothing when HEAD is detached.""" + assert (tmp_path / "initial.txt").exists() + + result = subprocess.run( + [git2cpp_path, "rev-parse", "HEAD"], + capture_output=True, + cwd=tmp_path, + text=True, + check=True, + ) + head_sha = result.stdout.strip() + subprocess.run([git2cpp_path, "checkout", head_sha], cwd=tmp_path, check=True) + + cmd = [git2cpp_path, "branch", "--show-current"] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode == 0 + assert p.stdout == "" + + +def test_branch_show_current_new_repo(git2cpp_path, tmp_path, run_in_tmp_path): + """--show-current prints the branch name even on a fresh repo with no commits (unborn HEAD).""" + assert list(tmp_path.iterdir()) == [] + + subprocess.run([git2cpp_path, "init", "-b", "main"], cwd=tmp_path, check=True) + + cmd = [git2cpp_path, "branch", "--show-current"] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode == 0 + # Default branch after init is "main" or "master" depending on git config + assert p.stdout.strip() == "main" + + +def test_branch_show_current_nogit(git2cpp_path, tmp_path): + """--show-current fails gracefully outside a git repository.""" + cmd = [git2cpp_path, "branch", "--show-current"] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode != 0 + assert "error: could not find repository at" in p.stderr From d9fe9e42abc9e87395177ea48fbd12e38d5b3b77 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Mon, 16 Mar 2026 17:00:12 +0000 Subject: [PATCH 30/35] Add pre-commit config with linting for C++ and Python (#121) * Add pre-commit config with linting for C++ and python * Some ruff fixes * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .clang-format | 89 ++++++++++ .pre-commit-config.yaml | 31 ++++ README.md | 6 + dev-environment.yml | 1 + docs/create_markdown.py | 14 +- pyproject.toml | 6 + src/main.cpp | 21 ++- src/subcommand/add_subcommand.cpp | 14 +- src/subcommand/add_subcommand.hpp | 1 + src/subcommand/branch_subcommand.cpp | 16 +- src/subcommand/checkout_subcommand.cpp | 36 ++-- src/subcommand/checkout_subcommand.hpp | 14 +- src/subcommand/clone_subcommand.cpp | 15 +- src/subcommand/clone_subcommand.hpp | 3 +- src/subcommand/commit_subcommand.cpp | 14 +- src/subcommand/commit_subcommand.hpp | 1 + src/subcommand/config_subcommand.cpp | 54 ++++-- src/subcommand/diff_subcommand.cpp | 226 +++++++++++++++++-------- src/subcommand/diff_subcommand.hpp | 12 +- src/subcommand/fetch_subcommand.cpp | 28 +-- src/subcommand/init_subcommand.cpp | 31 +++- src/subcommand/init_subcommand.hpp | 1 + src/subcommand/log_subcommand.cpp | 140 +++++++++------ src/subcommand/log_subcommand.hpp | 6 +- src/subcommand/merge_subcommand.cpp | 226 +++++++++++++++---------- src/subcommand/merge_subcommand.hpp | 3 +- src/subcommand/mv_subcommand.cpp | 14 +- src/subcommand/mv_subcommand.hpp | 4 +- src/subcommand/push_subcommand.cpp | 13 +- src/subcommand/rebase_subcommand.cpp | 51 +++--- src/subcommand/rebase_subcommand.hpp | 4 +- src/subcommand/remote_subcommand.cpp | 16 +- src/subcommand/reset_subcommand.cpp | 13 +- src/subcommand/reset_subcommand.hpp | 1 + src/subcommand/revlist_subcommand.cpp | 22 ++- src/subcommand/revlist_subcommand.hpp | 6 +- src/subcommand/revparse_subcommand.cpp | 55 ++++-- src/subcommand/rm_subcommand.cpp | 62 ++++--- src/subcommand/rm_subcommand.hpp | 4 +- src/subcommand/stash_subcommand.cpp | 81 ++++++--- src/subcommand/status_subcommand.cpp | 184 ++++++++++++++------ src/subcommand/status_subcommand.hpp | 1 + src/subcommand/tag_subcommand.cpp | 144 +++++++++------- src/utils/common.cpp | 54 +++--- src/utils/common.hpp | 10 +- src/utils/credentials.cpp | 27 +-- src/utils/credentials.hpp | 8 +- src/utils/git_exception.cpp | 13 +- src/utils/input_output.cpp | 16 +- src/utils/input_output.hpp | 7 + src/utils/progress.cpp | 40 ++--- src/utils/terminal_pager.cpp | 7 +- src/utils/terminal_pager.hpp | 4 +- src/version.hpp | 4 +- src/wrapper/branch_wrapper.cpp | 10 +- src/wrapper/branch_wrapper.hpp | 2 +- src/wrapper/commit_wrapper.cpp | 5 +- src/wrapper/commit_wrapper.hpp | 5 +- src/wrapper/config_wrapper.cpp | 5 +- src/wrapper/diff_wrapper.cpp | 3 +- src/wrapper/diff_wrapper.hpp | 2 +- src/wrapper/diffstats_wrapper.cpp | 3 +- src/wrapper/index_wrapper.cpp | 75 ++++---- src/wrapper/object_wrapper.cpp | 4 +- src/wrapper/patch_wrapper.cpp | 22 ++- src/wrapper/patch_wrapper.hpp | 11 +- src/wrapper/rebase_wrapper.cpp | 12 +- src/wrapper/rebase_wrapper.hpp | 12 +- src/wrapper/refs_wrapper.cpp | 10 +- src/wrapper/refs_wrapper.hpp | 4 +- src/wrapper/remote_wrapper.cpp | 5 +- src/wrapper/remote_wrapper.hpp | 2 +- src/wrapper/repository_wrapper.cpp | 80 ++++++--- src/wrapper/repository_wrapper.hpp | 19 ++- src/wrapper/revwalk_wrapper.cpp | 5 +- src/wrapper/revwalk_wrapper.hpp | 2 +- src/wrapper/signature_wrapper.cpp | 17 +- src/wrapper/signature_wrapper.hpp | 7 +- src/wrapper/status_wrapper.cpp | 25 ++- src/wrapper/status_wrapper.hpp | 1 + src/wrapper/tag_wrapper.cpp | 1 + src/wrapper/tag_wrapper.hpp | 5 +- src/wrapper/tree_wrapper.hpp | 6 +- src/wrapper/wrapper_base.hpp | 5 +- test/conftest.py | 2 +- test/conftest_wasm.py | 66 ++++---- test/test_branch.py | 18 +- test/test_checkout.py | 40 ++--- test/test_clone.py | 24 +-- test/test_commit.py | 8 +- test/test_config.py | 2 - test/test_diff.py | 45 ++--- test/test_fetch.py | 6 +- test/test_fixtures.py | 16 +- test/test_git.py | 12 +- test/test_log.py | 56 ++---- test/test_merge.py | 56 ++---- test/test_mv.py | 4 +- test/test_push.py | 12 +- test/test_rebase.py | 50 ++---- test/test_remote.py | 8 +- test/test_reset.py | 2 - test/test_revlist.py | 4 +- test/test_revparse.py | 2 - test/test_rm.py | 11 +- test/test_stash.py | 24 +-- test/test_status.py | 48 ++---- test/test_tag.py | 44 ++--- wasm/recipe/modify-recipe.py | 38 ++--- wasm/wasm-environment.yml | 1 + 110 files changed, 1673 insertions(+), 1165 deletions(-) create mode 100644 .clang-format create mode 100644 .pre-commit-config.yaml create mode 100644 pyproject.toml 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/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..cc84447 --- /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: v21.1.2 + hooks: + - id: clang-format + args: [--style=file] + exclude_types: [javascript,json] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.0 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format diff --git a/README.md b/README.md index 9eff6b3..4ac0793 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,12 @@ The CLI is tested using `python`. From the top-level directory: pytest -v ``` +`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 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/create_markdown.py b/docs/create_markdown.py index 7cb59fe..0c7bec2 100644 --- a/docs/create_markdown.py +++ b/docs/create_markdown.py @@ -26,7 +26,7 @@ def sanitise_line(line): return line -# Process a single subcommand, adding new subcommands found to to_process. +# Process a single subcommand, adding new subcommands found to to_process. def process(args, to_process): cmd = args + ["--help"] cmd_string = " ".join(cmd) @@ -37,7 +37,7 @@ def process(args, to_process): 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. + # links to the subcommand markdown files. subcommands = [] with open(filename, "w") as f: f.write(f"({filename})=\n") # Target for links. @@ -52,7 +52,9 @@ def process(args, to_process): if match: subcommand = match.group(2) subcommand_filename = get_filename(args + [subcommand]) - line = match.group(1) + f"[{subcommand}]({subcommand_filename})" + match.group(3) + line = ( + match.group(1) + f"[{subcommand}]({subcommand_filename})" + match.group(3) + ) subcommands.append(subcommand) elif line.startswith("SUBCOMMANDS:"): in_subcommand_section = True @@ -74,10 +76,10 @@ def process(args, to_process): if __name__ == "__main__": - # Modify the PATH so that git2cpp is found by name, as using a full path will cause the help + # 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"]}' + 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: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..90d4005 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,6 @@ +# 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" diff --git a/src/main.cpp b/src/main.cpp index 5d0223a..712885e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,10 +1,9 @@ -#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" @@ -21,12 +20,14 @@ #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/stash_subcommand.hpp" #include "subcommand/status_subcommand.hpp" #include "subcommand/tag_subcommand.hpp" -#include "subcommand/revparse_subcommand.hpp" -#include "subcommand/revlist_subcommand.hpp" -#include "subcommand/rm_subcommand.hpp" +#include "utils/git_exception.hpp" +#include "version.hpp" int main(int argc, char** argv) { @@ -69,7 +70,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) { @@ -86,7 +88,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 e1aa4f4..ca52940 100644 --- a/src/subcommand/add_subcommand.cpp +++ b/src/subcommand/add_subcommand.cpp @@ -1,13 +1,13 @@ +#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("", m_add_files, "Files to add"); @@ -16,10 +16,14 @@ add_subcommand::add_subcommand(const libgit2_object&, CLI::App& app) // 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 f0ea9fa..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,9 +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->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() diff --git a/src/subcommand/checkout_subcommand.cpp b/src/subcommand/checkout_subcommand.cpp index d3477a3..1c3dfdb 100644 --- a/src/subcommand/checkout_subcommand.cpp +++ b/src/subcommand/checkout_subcommand.cpp @@ -1,8 +1,9 @@ +#include "../subcommand/checkout_subcommand.hpp" + #include -#include #include +#include -#include "../subcommand/checkout_subcommand.hpp" #include "../subcommand/status_subcommand.hpp" #include "../utils/git_exception.hpp" #include "../wrapper/repository_wrapper.hpp" @@ -15,9 +16,18 @@ checkout_subcommand::checkout_subcommand(const libgit2_object&, CLI::App& app) sub->add_option("", m_branch_name, "Branch to checkout"); 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->callback([this]() { this->run(); }); + 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(); + } + ); } void print_no_switch(status_list_wrapper& sl) @@ -44,7 +54,7 @@ void checkout_subcommand::run() if (repo.state() != GIT_REPOSITORY_STATE_NONE) { - throw std::runtime_error("Cannot checkout, repository is in unexpected state"); + throw std::runtime_error("Cannot checkout, repository is in unexpected state"); } git_checkout_options options; @@ -112,19 +122,14 @@ void checkout_subcommand::run() } } -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, @@ -135,8 +140,7 @@ 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 diff --git a/src/subcommand/checkout_subcommand.hpp b/src/subcommand/checkout_subcommand.hpp index e041174..99661d4 100644 --- a/src/subcommand/checkout_subcommand.hpp +++ b/src/subcommand/checkout_subcommand.hpp @@ -17,23 +17,17 @@ 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 diff --git a/src/subcommand/clone_subcommand.cpp b/src/subcommand/clone_subcommand.cpp index 926628e..4d8ad08 100644 --- a/src/subcommand/clone_subcommand.cpp +++ b/src/subcommand/clone_subcommand.cpp @@ -1,6 +1,7 @@ +#include "../subcommand/clone_subcommand.hpp" + #include -#include "../subcommand/clone_subcommand.hpp" #include "../utils/credentials.hpp" #include "../utils/input_output.hpp" #include "../utils/progress.hpp" @@ -13,11 +14,17 @@ 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, "